ormusClaude Code mods
Claude Code 2.1.289 · Ghostty 1.3.1

Claude Code Mods Field Guide

A mod is a small plugin of function hooks that changes how Claude Code behaves and what it draws. Every screenshot here comes from one real session in Ghostty, running a demo mod named mods-tour that uses each capability once.

Source: code.claude.com/docs/en/plugins/mods/referenceRecorded 4 Oct 2026
A Ghostty window running Claude Code with the mods-tour mod: a toast in the top right of the transcript, a tool-call pane on the right, a blocked .env edit, a band above the prompt and a status line at the bottom. 1 2 3 4 5
One turn, asked to add a docstring to app.py and then change API_KEY in .env. Everything marked is drawn or decided by the mod.
  1. 1Toast when the turn ends
  2. 2Pane listing each tool call
  3. 3Tool call blocked by a hook
  4. 4Band above the prompt
  5. 5Status line entry
Anatomy

Three files and one function

A mod is a plugin folder. Its hooks module exports register(on, options), and every on(event, matcher?, hook) call adds a hook. The module runs in its own environment with no DOM and no Node. It reaches the outside world only through $.

  • .claude-plugin/plugin.jsonName, version, description, and the path to its type contract
  • hooks/hooks.json{ "modules": ["./register.tsx"] }
  • hooks/register.tsxThe hooks module: TypeScript, with JSX for drawing
  • types/index.d.tsNeeded only when the mod keeps values in $.state
on('tool.call', { tool: 'Edit' }, ($, e, next) =>
  PROTECTED.test(e.file_path)
    ? { deny: `${$.plugin.name}: ${e.file_path} is protected here.` }
    : next(e),
)

Every hook gets ($, e, next). $ is the engine, e is the event's frozen input, and next(e) hands the event on. Return without calling next to answer for yourself. Call next({ ...e, x }) to rewrite what the rest of the chain sees.

your hook→ next(e) →other plugins→engine behaviour
Capabilities, as recorded

What the demo mod does on screen

Guard a tool call

A tool.call hook sees each call before it runs. Returning { deny } stops it, and Claude reads the reason. In the recording, Claude edited app.py, was refused on .env, and quoted the mod's message back.

on('tool.call', { tool }) · return { deny } · next({ ...e })
Update(.env) shows Error editing file, and Claude explains that a hook blocked the edit with the message mods-tour: .env is protected here.

Open a pane

$.ui.open({ id, title }) asks for a side pane, and a ui.render hook on { component: 'Pane', requestId } draws it. Here the pane lists each tool call and ticks it off when it finishes. A pane opened by something you did seats at any width. One opened unasked, like this one at session start, waits until the terminal is at least 144 columns wide.

$.ui.open · on('ui.render', { component: 'Pane' })
Side pane listing three finished tool calls: Read, Read, Edit.
return (
  <Box flexDirection="column">
    {list.slice(-room).map(call => (
      <Text dimColor={call.isDone}>
        {call.isDone ? '✓' : '…'} {call.tool}
      </Text>
    ))}
  </Box>
)

Draw a band above the prompt

A ui.render hook on AbovePrompt returns a small tree, or calls next(e) when there is nothing to show. The band reads from an atom that the turn.complete hook writes, so it redraws on its own after every turn.

on('ui.render', { component: 'AbovePrompt' }) · atom · read · update
Band above the prompt reading: mods-tour last turn 8s, 3 tool calls.

Post a status line entry

$.ui.status(text) works from any hook. The demo sets it to the running tool while a call is in flight, then to the turn's count. Passing undefined clears it.

$.ui.status(text | undefined)
Status line reading mods-tour: 3 tool calls this turn, above the auto mode indicator.

Show a toast

$.ui.toast(text) also works from any hook. The demo raises one from turn.complete with the turn's duration, which it times from prompt.submit using $.clock.now().

$.ui.toast · on('turn.complete') · $.clock.now
Toast titled mods-tour reading Turn done: 8s, 3 tool calls.

Add a slash command

$.command.register({ name, description }) in session.start adds the command, and a command.run hook answers it with { text }. The demo's /tour reopens the pane after you close it.

$.command.register · on('command.run', { command })
Not in the recording. The demo session ran from a prompt passed on the command line, with nobody typing at the keyboard.
Found while recording

A guard on one tool doesn't cover the others

The first version of the demo blocked only Edit on .env. In the first recorded run, Claude never called Edit. It changed both files with a Python script through Bash, and the guard never fired. The fixed mod also hooks Write and denies any Bash command that names .env. A tests file covers all three cases, and claude plugin test passes them. When a hook protects a resource, cover every tool that can reach that resource.

Reference map

Events you can hook, and what $ can reach

Tools

Block, rewrite or react to calls. Register tools the model can call.

tool.calltool.checktool.describetool.listtool.register

Prompt and system prompt

Rewrite a submitted prompt, or add, replace or drop system prompt sections.

prompt.submitprompt.composeprompt.contextprompt.suggestprompt.attachment

Turns

turn.step streams one model request at a time. Hook it with an async generator.

turn.startturn.stepturn.completeturn.abort

Session

Start and end (a /clear fires session.end with reason: 'clear'), plus the rows the conversation keeps.

session.startsession.endsession.appendsession.compactsession.receive

Interface

Draw into named render sites on the terminal, desktop, VS Code and mobile surfaces.

ui.render$.ui.open$.ui.status$.ui.toast$.ui.resolve

Commands, agents, skills

Slash commands, subagent types and skill prompts.

command.runcommand.registeragent.spawnagent.registerskill.prompt

Processes and telemetry

process.spawn streams a child's output. Telemetry hooks pick a stream with to.

process.spawnprocess.runtelemetry.logtelemetry.markattribution.text

Settings hooks

Every classic settings hook event can also be hooked as a function, with the same stdin fields as e.

classic.Stopclassic.SessionEndclassic.PreToolUse

Engine nouns on $

Spelled noun then method. The full list, with doc comments, is in the generated claude-code.d.ts.

$.clock$.model$.fs$.process$.agent$.audio$.state$.store
Development loop

How this demo was built and checked

  1. Write the files directly

    No scaffolding command. The engine writes this build's types next to the mod as it loads, so tsc -p <mod folder> type-checks with no setup.

  2. Validate

    claude plugin validate <folder> reports every hook, every $ call and every state key the module uses, and anything the engine would refuse.

  3. Test against the real engine

    claude plugin test <folder> runs *.test.ts files from claude-code/testing. Here, $.tool.call({ tool: 'Edit', file_path: 'app/.env', … }) resolves with the mod's deny.

  4. Load it

    With hot reloading turned on, a mod reloads after each edit when the turn ends. In any other terminal, start claude --plugin-dir <folder>, which is how the recorded Ghostty session ran.