DEV Community

Matteo Poli
Matteo Poli

Posted on Originally published at reado.watermelon-studio.it

Claude Code can draw now. An experiment in reviewing from inside the agent

Two weeks ago Claude Code shipped something that is easy to miss in a changelog full of fixes. Version 2.1.287 added what the release notes call "Claude Mods: plugins may now modify deeper behavior". That undersells it. A mod is code that runs inside Claude Code, sees every tool call before it happens, and can draw its own interface in the terminal.

I build Reado, an IDE for reading the code your agent writes. When a tool gets a way to change how the agent itself works, the first thing I want to know is what that means for review. So I read the docs, looked at the mods Anthropic shipped, and wrote a small one as an experiment. To be clear up front: it isn't part of Reado, and I'm not shipping it. This post is about what I learned building it, and where I think mods do and don't belong in a review workflow.

What a mod is

The overview puts it plainly: a mod is "made of JavaScript or TypeScript event handlers: Claude Code calls one when an event happens, such as a tool call, a submitted prompt, or a part of the interface being drawn". Each handler can watch the event, change it, or answer it itself so the default behavior never runs.

Claude Code already had hooks, skills and MCP servers, so it's fair to ask what's new. The difference is where the code runs. A settings hook is a shell command Claude Code starts and waits for. An MCP server is a separate process that offers the model tools. A mod is a function in Claude Code's own process, so it can do things the others can't:

  • Draw. A pane beside the transcript, or a band above the prompt, with text, buttons and input fields.
  • Redraw what Claude Code draws. A tool call's row, the spinner, the question dialog. Everything except the permission prompt, which a mod can't restyle.
  • Step into a tool call. Hold it, rewrite it, refuse it, or answer it without running the tool.
  • Add commands that run immediately, without a Claude turn, even while Claude is working.

A mod is a plugin with three files: the manifest, a hooks.json that points at your code, and the code itself. No build step: Claude Code loads .ts and .tsx directly. The handlers have a middleware shape that will be familiar to anyone who has written Express or Koa:

on('tool.call', async ($, e, next) => {
  // e is the call: e.tool, e.file_path, e.old_string...
  const result = await next(e) // let the tool run
  return result
})
Enter fullscreen mode Exit fullscreen mode

$ is the only way out of the handler. Reading a file, running a process, calling a model, drawing: everything goes through it. That's a deliberate design choice, and it matters for security, which I'll get to.

The first mods are about review

What caught my attention is what Anthropic built with them first. There are four mods worth looking at: two ship inside Claude Code, two are samples you install yourself. They do different things, but each one covers a different moment of reviewing what the agent does:

Moment Mod What it does
Before a dangerous command runs blast-radius (sample) Holds a risky shell command (rm -rf, git reset --hard, a force push, a migration), shows what it would change, and waits for Proceed or Cancel.
While the agent works You Should Know (built in, off by default) Runs a second agent alongside the first, in the changelog's words "a side agent [that] watches your back and flags things you or Claude might miss".
After a turn replay-theater (sample) Steps through the file edits Claude made in the last turn, one diff at a time.
Whenever you ask /diff (built in) Claude Code's own diff pane. It's interesting less for what it does than for the fact that it's now written as a mod, with public source.

The samples are in the claude-code-playground repository.

Put them in that order and the pattern is clear. Reviewing an agent's work is moving into the agent: not one pull request at the end, but small checks along the way.

I think that's right, and I also think it has a ceiling. More on that below.

The experiment

Reado's loop works like this: you read the code and leave comments on exact lines, some of them marked as tasks, and the agent resolves the tasks. The comments live in the repository, in .reado/, and the reado command-line tool lists them. The agent finds them through Reado's MCP server or the CLI.

What was missing is the other direction. If Claude is in the middle of something else and edits a line you flagged, nothing tells you. Your comment says "keep cents as integers", Claude rewrites the line for a different reason, and you find out when you next read the file. Or you don't.

So the experiment does one thing: when Claude edits a file where you have an open Reado task, a band appears above the prompt with your comment. If the edit replaced the exact text the comment is anchored to, it says so.

The experimental mod's band above Claude Code's prompt:

That's a real screenshot, from a session running inside Reado's terminal. Here is the core of the mod, everything except the drawing code:

async function tasksFor($: EngineInterface, path: string) {
  const cwd = dirOf(path)
  const top = await $.process.run(['git', 'rev-parse', '--show-toplevel'], { cwd })
  if (top.exitCode !== 0) return null
  const root = top.stdout.trim()
  const listed = await $.process.run(['reado', '--json', 'task', 'list'], { cwd: root })
  if (listed.exitCode !== 0) return null
  const rel = path.startsWith(root + '/') ? path.slice(root.length + 1) : path
  const tasks: Task[] = JSON.parse(listed.stdout)
  return { rel, tasks: tasks.filter(t => t.anchor.file === rel) }
}

export const register: Register = on => {
  // A new prompt starts a new round: the old notices go.
  on('prompt.submit', async ($, e, next) => {
    await update($, hits, () => [])
    return next(e)
  })

  on('tool.call', async ($, e, next) => {
    if (e.tool !== 'Edit' && e.tool !== 'Write') return next(e)
    const found = await tasksFor($, e.file_path)
    const ran = await next(e)
    if (!found || found.tasks.length === 0 || ran.deny !== undefined) return ran

    const replaced = e.tool === 'Edit' ? e.old_string : null
    const fresh = found.tasks.map(t => {
      const snippet = t.context?.snippet?.trim() ?? ''
      return {
        file: found.rel,
        line: t.anchor.startLine,
        body: t.messages[0]?.body ?? '',
        isExact: replaced === null || (snippet !== '' && replaced.includes(snippet)),
      }
    })
    await update($, hits, list => [
      ...list.filter(old => !fresh.some(f => f.file === old.file && f.line === old.line)),
      ...fresh,
    ])
    return ran
  })
}
Enter fullscreen mode Exit fullscreen mode

The band is a ui.render hook on the AbovePrompt site. It returns a small tree of Box, Text and Button elements, which it gets from $.ui.resolve(e) because the elements depend on whether you are in the terminal or the Desktop app. Pressing Got it clears the list.

Three decisions worth explaining

It reads the comments through the CLI, not the files. A mod can read files directly with $.fs.read, and Reado's comments are plain Markdown. But the CLI already knows which comments are open, which are tasks, and what text each one is anchored to. Calling it costs a process per edit. Reimplementing it would cost correctness, and that's the expensive kind.

"Exact" means the replaced text contains the anchored snippet, not that the line numbers match. Line numbers drift as soon as anything above them changes. Reado stores the text a comment is anchored to, and an Edit call carries the exact text it replaces in old_string. If one contains the other, Claude changed the thing you commented on. It's a plain string check, and unlike a line number it survives everything that happens above the line. A Write replaces the whole file, so every task in it counts as touched.

It doesn't block. This was the real decision. A tool.call hook can return { deny: reason }, and refusing any edit to a line with an open task is a one-line change. I didn't make it, because in Reado's loop the edit to a flagged line is very often the fix for that task: you asked for it. A guard there would stop the agent in the middle of doing what you wanted, and push it to explain itself to a hook instead of to you. Blocking would duplicate the job of the task loop. A notice tells you something happened and leaves you to decide whether it matters.

Testing it

Mods come with a test runner, claude plugin test, that runs your *.test.ts files against the real engine with nothing beneath your plugin. That turns out to be convenient: the test answers the calls your mod makes. Mine answers process.run with a fake task list, fires an Edit, then mounts the band on both the terminal and Desktop surfaces and checks what it drew:

on('process.run', async (_$, e) =>
  e.argv[0] === 'git'
    ? { value: { exitCode: 0, stdout: '/repo\n', stderr: '' } }
    : { value: { exitCode: 0, stdout: JSON.stringify([TASK]), stderr: '' } },
)
// ...
const ui = await $.ui.mount({ plugin: 'reado-review', surface, component: 'AbovePrompt', props })
expect(await ui.find({ type: 'Text', text: /changed the line you flagged/ })).toBeDefined()
Enter fullscreen mode Exit fullscreen mode

The test caught a bug before it ever loaded. I'd named the loop variable h, and JSX in a mod compiles to calls to a global h. The render hook crashed with "h is not a function". Shadowing the JSX factory is not a mistake you make twice.

Where it stops

Mods aren't sandboxed. The docs are clear about it: a mod "is code that runs with your permissions". It can read and write your files, see every prompt, rewrite tool calls, and approve a tool call before you're asked. If you have sandboxing turned on, "a process that a mod starts runs outside it". After GitSpawn I'd add that a mod belongs on the same list as .git/config and agent settings: files you'd treat as inert, which are in fact code.

The mitigation is good, and it follows from routing everything through $. Before you install a mod, claude plugin validate reads its source and lists every event it hooks and every call it makes. For mine:

hooks: prompt.submit, tool.call, ui.render{component=AbovePrompt}
calls: $.process.run (via tasksFor), $.state.get, $.state.set, $.ui.resolve
Enter fullscreen mode Exit fullscreen mode

Two lines, and you know this mod runs processes and draws, and doesn't touch the network or the filesystem directly. Read those lines before installing anything.

Drawing is terminal and Desktop only. In the VS Code extension's chat panel, the hooks run but nothing they draw appears, and the same goes for claude -p and the Agent SDK. A mod that draws should check where it is running and fall back to a plain line in the transcript.

It only sees Claude Code's own edits. Edits made through sed in a Bash call don't go through the Edit tool, so my mod doesn't notice them. You could match Bash commands too, but guessing which files a shell command touches is exactly the kind of heuristic I don't want in a review tool.

And the bigger limit: a band above a prompt is a notice, not a review. It tells you that the agent touched the line you cared about. It doesn't show you the line in its file, the function around it, or the three other places that call it. Diff panes, replay theaters and side agents all share that shape: they give you the change, not the code it lands in. That's useful, and it's also why I keep building a separate place to read. A mod like this one can connect the two. It doesn't replace either.

What would make sense in Reado

So, after building it: would I put a mod in Reado? Not this one, and not soon. Here's my reasoning.

Reado works with any agent; a mod works with one. Reado's comments reach the agent through a CLI and an MCP server, which is why the same loop works with Claude Code, Codex, or any agent that can run a command or talk to an MCP server. A mod only runs inside Claude Code. Anything essential to the workflow has to stay in the part every agent can use, or the tool quietly becomes a Claude Code accessory.

This particular notice is nice, not necessary. It helps if you're watching the terminal while the agent works. But in Reado's workflow you read the change afterwards, in the file, and the comment is right there on the line. The band tells you about something you were going to see anyway.

Where a mod could earn its place is the moment only the agent sees. A mod is present while the edit happens. It knows which turn made it, what the user had asked, and what Claude said it was doing. By the time you open the file in an editor, all of that is gone, and the diff is just a diff. Reado already lets an agent narrate its reasoning with reado thought, but only when the agent decides to. A mod could capture the "why" of every edit to a flagged line without asking the agent for anything. That is the one idea from this experiment I'd consider building for real, and only if it holds up in daily use, not because mods are new.

The general lesson, for me: put inside the agent what can only be known inside the agent. Do the reading where you can see the whole file.

Try it

If you use Claude Code 2.1.287 or later, the fastest way to see what mods can do is the sample blast-radius. Clone the playground repository, run claude plugin validate on the folder to see what it does, then load it for one session with claude --plugin-dir. Or turn on the built-in side agent with /plugin enable cc-plugin-you-should-know@builtin.

If you're curious about Reado's side of the loop (comments on exact lines that your agent resolves, with whatever agent you use), it's free and open source. And if you write a review mod of your own, I'd like to see it. Tell me in the comments or on Discord.

Top comments (0)