← ryanday. ~/ writing / room-to-think ~12 min read

On agents, the shape of an answer, and the plans behind the code

Give the Brain a Room to Think In

Agents answer in text by default. But they can just as easily answer in something you can see, which is what our brains are actually built for.


Friends of mine are grinding through the early years of med school. To memorize drugs they all use SketchyPharm. This platform is a library of illustrated scenes, one picture per drug, where symbols planted inside the scene tell you what the drug does. Every drug that can jam the heart's electrical relay gets the same heart shaped shield tucked into its scene. The shield is how they remember that a drug can cause "atrioventricular block."

Sketchy didn't invent this. It's an ancient trick called the method of loci. You tuck what you want to remember inside a vivid place, then walk back through it to retrieve it. The poet Simonides stepped out of a banquet hall moments before it collapsed, then named every crushed guest by where they'd been sitting (how morbid). That's the first time anyone wrote about the technique. Sherlock fans know it as the "mind palace".

It works because the brain is a spatial, visual organ. It evolved to remember where the lion was, which berry was poison, and how to get home, tuned to notice whatever moves. A bulleted list has none of that: no place, no picture, nothing that moves. Text has been the default my whole software engineering career, syntax highlighted of course. AI output was the same. Then one day I asked for a visual answer instead, and got back a page I could click through instead of read. It was fun to see what visual affordances Claude came up with to explain things, and much easier to understand what it had to say.

the whole problem

When you want to grasp something fast, while minimizing cognitive effort, text is the worst medium we have.

And right now I always want it fast. I've usually got several Claude Code instances and background agents running at once, and everything they produce comes back to me to review. Each one is a withdrawal from my small account of brainpower, and after a couple markdown docs I am usually broke.

After a month or two of experimentation, I read a post by Thariq Shihipar (Anthropic employee), sharing this idea of having Claude Code answer in interactive HTML instead of Markdown to a large audience. By then I had a folder full of these pages, and the ones that worked kept working for the same four reasons. I could take them in at a glance, poke at them, expand detail only when I wanted it, and see how the pieces connect. The rest of this post applies those four to one specific job, a visual artifact for planning code changes that's easy to understand and review.

In practice

Reviewing what the agents will build

Reviewing a plan of code changes before handing it off to a bunch of agents is particularly hard. For big tasks, there's just too much to review in detail. A lot of people say YOLO and let it rip after briefly looking over the plan, and I admit I've done that a decent amount on personal projects. But for software I really care about, the stuff going into production, I still need to know the scope and impact of the changes before they go out. I don't need to read every line, but I do need to understand what the changes affect beyond the task itself. So here's how each of those four properties plays out for a visual plan:

01

At a glance

Markdown gives me one direction, down the page. Heading, heading, paragraph, bullet, bullet, code block, each looking very similar to the last. I can't easily correlate related sections together in markdown, so I am forced to rely on my unreliable working memory to make connections between sections. Here's the same implementation plan, one in rendered markdown, and the other in html. Even though it's the same information, the HTML version is much easier to absorb! It sort of reads as one "thing" rather than a sequence of information.

As Markdown, rendered

Plan
Goal

Rate-limit the API.

Context
  • shared middleware
  • touches auth
Steps
  1. add limiter
  2. wire into middleware
  3. [RISK] loosen session guard
  4. add per-route config
Tests
  • unit: limiter
  • e2e: login still works

As HTML

GOALRate-limit the API
CONTEXTshared middleware · touches auth
1 · add limitersafe
2 · wire into middlewaresafe
3 · loosen session guardRISK
4 · add per-route configsafe
TESTSlimiter · login still works
02

Interaction

I used to have Claude write plans in a markdown template, Goal / Context / Steps / Tests, the same shape every time. On the long ones I'd think I understood the plan, then after it was implemented and tested I'd realize I had missed something big. After some experimentation, I found that making the steps draggable and having the ability to give feedback and ask questions directly inline helps with my understanding. I have the page save my comments in a form Claude can read (Chrome saving to the filesystem, to avoid having to constantly redownload the feedback to my Downloads folder), and then I can easily iterate with Claude instead of having to copy and paste parts of the plans back and forth in the terminal for editing and questions. Occasionally I edit the plan, but more often I just insert questions asking Claude, "What the heck is this part doing?"

TRY IT drag, reorder, or flag a step
  1. 1Add the rate limiter
  2. 2Wire it into the shared middleware
  3. 3Loosen the session guard so the limiter can read the request
  4. 4Add per-route config

Try flagging step 3. The challenge note is editable. The UX can be lazy and still push the thinking onto the model.

03

Detail on demand

Normally in my plans, there is a LOT of detail. Not all of it is important for me to read. HTML lets me fold everything away so I only have to drill into the parts I'm not as familiar with. Here's that same rate-limit plan with every step collapsed to its headline.

TRY IT expand a step to see how it's built
1 · Add the rate limiterNew file, one dependency. 60 req/min, bucketed per user with an IP fallback.

Define one limiter and key each bucket by user, falling back to IP for logged-out traffic. Sixty requests a minute, off the shelf.

// src/rate-limit.ts
import { rateLimiter } from "express-rate-bucket";

export const apiLimiter = rateLimiter({
  window: "1m",
  max: 60,
  keyBy: (req) => req.session?.userId ?? req.ip,
});

The only line worth a second look is keyBy: it reaches into req.session, which is the reach that comes back to bite us two steps down.

2 · Wire it into the shared middlewareOne line in api.ts, registered right after auth so it can see the session.

Drop it into the shared stack so every route inherits it. No per-route wiring, no thinking required.

// src/api.ts
import { apiLimiter } from "./rate-limit";

app.use(requireAuth);
app.use(apiLimiter); // must sit after auth to read req.session

The ordering is the whole trick. The limiter has to run after requireAuth, and that constraint is exactly what forces the next step.

3 · Loosen the session guard so the limiter can read the requestEdits requireAuth in middleware.ts and widens what counts as logged in.

Here's the part that isn't boilerplate. requireAuth rejects any session without a userId before the limiter ever runs, so to let the limiter see a session at all, the plan weakens the check.

// src/middleware.ts
  export function requireAuth(req, next) {
-   if (!req.session?.userId) return redirect("/login")
+   if (!req.session) return redirect("/login")
    return next(req)
  }

One dropped condition, big blast radius. A session with no userId now sails straight through the guard. This is the diff the other three folds exist to make you look at. Hiding the routine steps is what makes sure this one gets my full attention.

4 · Add per-route configOverrides in routes.ts: login capped at 10/min, feed at 120/min.

Per-route overrides so login can be stricter than a read endpoint. Mechanical, easy to review whenever.

// src/routes.ts
router.post("/login", { limit: { max: 10 } },  loginHandler);
router.get( "/feed",  { limit: { max: 120 } }, feedHandler);
04

The map

Knowing what each step does is one thing. Understanding the impact, what else shifts when it lands, is another. That's the half I need a picture for. Mapping files to steps, and steps back to files, is what makes a change legible to me; once I can see which modules move, I get a much better sense of the side effects before I read a single diff. Loosen the guard in middleware.ts and I know auth.ts and login.tsx are downstream.

TRY IT click a file for its diff and the steps it touches
auth.ts session.ts api.ts login.tsx middleware.ts billing.ts
1Add the rate limiter
2Wire it into the shared middleware
3Loosen the session guard
4Add per-route config
05

Putting it all together

I split those into four demos to talk about each point individually. The artifact below puts all four back together, with some extra features. The plan is for a made up photo sharing app. Today, when someone uploads a photo, the server resizes and compresses it while they wait, up to thirty seconds on a big file, then hands back the finished image. The plan moves that processing into a background queue so uploads answer instantly, and the web gallery and mobile app poll until the photo is ready. The mobile app that calls the backend service is represented with a dashed circle since its code is not modified as part of this plan.

Press play, or scrub. Each step lights up the files it touches on the graph and narrates what the system looks like once the step lands. A few minutes of poking around and you know which files are load-bearing and why the mobile app constrains the whole rollout. Geoffrey Litt calls this kind of interface an AI HUD, an instrument that extends your perception instead of riding shotgun and talking at you. The page compiles your questions entered in after pressing the flag buttons into review notes at the bottom, ready to paste back to Claude to make the plan doc better and more clear. Plan review has never been this fun 😏.

The full planall four properties, one artifact
TRY IT press play · click a file · expand a step · flag a question
GOALStop making uploads wait on image processing

Today the server resizes and compresses every image inside the upload request itself. Big files take 8 to 30 seconds, requests time out, and the user watches a spinner. This plan moves the heavy work to a background queue: upload answers instantly with a pending record, a worker processes the image on its own time, and clients poll a status endpoint until it's done.

TODAYupload resize inline, 8-30s respond with the finished image
AFTERupload respond "pending" in ~100ms worker resizes in the background client polls until done
CONTEXTwhat makes this one dangerous
  • The images table has 40 million rows and takes writes constantly. A careless ALTER locks it, and uploads go down with it.
  • Mobile v2.3, about a third of traffic, reads the finished image URL straight off the upload response. It ships on its own release train; this repo can't patch it.
  • The queue retries failed jobs, so everything the worker does has to be safe to run twice.
Rollout · press play to land the plan step by step
mobile app v2.3 gallery.tsx upload.ts images-api.ts queue.ts images table search.ts worker.ts storage.ts
Steps · click to see its files, expand for the diff, flag to question
  1. 1 Stand up the job queueNew file: a Redis-backed job queue with retries. Nothing calls it yet.

    A Redis-backed queue with retries and exponential backoff. New file, and nothing calls it yet.

    // src/queue.ts
    export const queue = new JobQueue(redis, {
      retries: 3,
      backoff: "exponential",
    });

    Because it lands dark it can merge first and soak in prod before anything depends on it. The retries line looks free here; it's what step 4 exists to survive.

  2. 2 Write the processing workerPulls a job, resizes the image, stores the output, marks the row done.

    Consumes process-image jobs: pull the original, resize, write the outputs, mark the row done.

    // src/worker.ts
    queue.consume("process-image", async (job) => {
      const img = await db.images.get(job.imageId);
      const out = await resizeAndCompress(img.original);
      await storage.put(out.key, out.bytes);
      await db.images.markDone(img.id, out.url);
    });

    Four awaits, two of them side effects, and nothing here is safe to run twice yet. That's why step 4 is its own step instead of a code review comment.

  3. 3 Add status columns to the images tableRows need a way to say pending or done. Two nullable columns, but the ALTER wants a lock on a hot table. RISK

    Two nullable columns, no default, so no table rewrite.

    -- migrations/0142_images_status.sql
    ALTER TABLE images
      ADD COLUMN status text,
      ADD COLUMN processed_at timestamptz;

    The risk is the lock. Even a metadata-only ALTER needs an exclusive lock for an instant, and if it queues behind one long-running transaction, every write to a 40 million row hot table queues behind it. This runs with a lock_timeout and a retry loop, and the backfill stays out of it entirely.

  4. 4 Make the worker idempotentRetries mean a job can run twice. Claim the row first; skip if it's already done.

    The queue retries failures, so every side effect in step 2 has to tolerate running twice. Claim the row up front, and make the storage write a no-op when the object already exists.

    // src/worker.ts
    - const img = await db.images.get(job.imageId);
    + const img = await db.images.claimForProcessing(job.imageId);
    + if (!img) return job.ack();  // claimed elsewhere or already done
    
    // src/storage.ts
    - export function put(key, bytes) {
    + export function putIfAbsent(key, bytes) {  // keyed on content hash

    The failure this buys us out of is a worker that dies after uploading but before acking, gets its job redelivered, reprocesses the image, and double-writes the output.

  5. 5 Flip upload to enqueue and return 202Upload stops waiting on the resize and answers pending in milliseconds, behind a flag. RISK

    The cutover. Upload stops blocking on the resize and answers immediately with a pending record, behind a flag so it can roll out by percentage.

    // src/upload.ts
      if (flags.asyncImages(req)) {
    +   await queue.enqueue("process-image", { imageId: row.id });
    +   return res.status(202).json({ id: row.id, status: "pending" });
      }
      const processed = await resizeAndCompress(req.file); // legacy path, deleted in step 8
      return res.json({ url: processed.url });

    This is a breaking change to a public response shape, and the repo compiles green the whole time. The break only shows up on the graph, at the dashed node.

  6. 6 Ship the status endpoint and gallery pollingThe new way clients learn an image is ready. Has to exist before step 5 flips.

    The new read path. One endpoint, and the gallery polls it until the worker reports done.

    // src/images-api.ts
    router.get("/images/:id/status", async (req, res) => {
      const img = await db.images.get(req.params.id);
      res.json({ status: img.status, url: img.url ?? null });
    });
    
    // src/gallery.tsx
    const { status, url } = usePoll(`/images/${id}/status`, { until: "done" });

    Sequencing matters more than the code here: mobile has to be polling this before the step 5 flag goes past zero. That's the whole reason step 5 ships dark behind a flag.

  7. 7 Backfill old rows, then lock the column downGive the 40 million pre-cutover rows a status, in batches, off-peak. Then NOT NULL.

    Everything uploaded before the cutover has a null status. Batched update off-peak, then the NOT NULL constraint once the backfill is clean.

    -- scripts/backfill_status.sql, 10k rows per batch
    UPDATE images SET status = 'done'
      WHERE status IS NULL AND id >= $1 AND id < $2;
    
    ALTER TABLE images ALTER COLUMN status SET NOT NULL;

    Boring by design. The whole reason step 3 added nullable columns was so this part could happen slowly, in batches, without holding a lock.

  8. 8 Delete the synchronous pathWhen v2.3 traffic hits zero, the legacy branch goes; 202 becomes the only shape.

    Once v2.3 traffic reads zero, remove the legacy branch and the flag with it.

    // src/upload.ts
    - const processed = await resizeAndCompress(req.file);
    - return res.json({ url: processed.url });
      // 202 is now the only response shape

    Gated on the mobile adoption number, not on a date.

TESTShow we know it worked
  • unit: deliver the same job twice; exactly one processed image and one stored object come out.
  • contract: with the flag off, the upload response still carries the url field v2.3 expects.
  • migration: run the ALTER against a prod-size snapshot with lock_timeout on, and measure the wait.
  • e2e: upload a file, get "pending" back, watch the worker finish, see the gallery render it.
review notes · what goes back to claude

          
PromptMake this kind of plan yourselfFor the spec driven development uninitiate, here is a prompt to make this kind of plan.
prompt
Write an implementation plan for the change I describe below, as a single self-contained HTML file.

Lay it out spatially: a Goal at the top, then a Context section with everything I need to understand the change (the current behavior, why we're touching it, the key files and how they relate, and any constraints or gotchas), and the tests at the bottom, so the whole thing reads as one object instead of a scroll.

Give me numbered Steps, each with a one-line plain-language summary and a fold I can expand for the diff and the reasoning. Make every step flaggable so I can type a question back to you inline, and mark the risky steps.

Above the steps, draw a small dependency graph of the files the change touches, sized and colored by blast radius, wired so clicking a file lights the steps that touch it and expanding a step lights its files. Include consumers of the change even if they aren't in this repo.

Then make the plan walkable: add a timeline that lands the steps one at a time when I press play or scrub it, lighting up each step's files on the graph and narrating what the system looks like after that step, with a warning note on the risky ones.

Compile my flags into a review-notes block I can copy back to you.

Save it to plan.html so I can open it in the browser and iterate with you.

06

The closest thing to a room

HTML is the closest thing we have to giving the brain a room to think in, something spatial and vivid enough to stick, and now fast enough to build on the fly.

But building the room fast only gets you a room. Deciding which room fits which information is the hard, creative part, and right now we're all bad at it, agents included. The best way to do it is still an open question. But I'm betting it gets good, and goes well past the sack of charts and widgets we generate by default. For inspiration see Bret Victor's "Up and Down the Ladder of Abstraction", a 2011 classic that walks you through an algorithm at several levels of abstraction, letting you grab a number, scrub it, and watch the whole system answer back. Visualizations like these stayed rare because each one was painstaking to build by hand, but that constraint doesn't apply anymore.

So go mess around with it. Next time you'd reach for a plan or an explanation in Markdown, ask your agent for HTML instead and see what it comes up with. Poke at it, break it, throw it out and ask for another. An HTML page costs almost nothing to build, so make a pile of bad ones until you hit a good one. And finally, share it! We all need creative inspiration for this kind of stuff. I'd love to see what you make.

Further reading & sources