Mods

Experimental. The ModApi is new and still taking shape. Surfaces and signatures can change as we learn from how mods get built - so pin what you ship, and tell us what's missing. Your feedback is what stabilizes it.

Mods are TypeScript packages that let you modify almost any behavior of Command Code. They're far more powerful than the plugin or extension APIs you'll find in other agents - though you can absolutely use one to build a plugin or an extension. Each mod is written against the ModApi: a plain TypeScript file that Command Code discovers on disk, loads at startup, and compiles onto its agent loop. One file can add tools the model calls, slash commands, mutating lifecycle hooks, event observers, typed-input interception, custom feed rendering, configurable flags, and model providers. Command Code's own built-in features - providers, session titling, the update notice - are built as mods against this very same API. If Command Code can do it, a mod can change it.

A loadable mod IS an AgentMod once loaded; the mod host just builds it from a factory file instead of a code import:

~/.commandcode/mods/review-guard.ts one file = one mod ▼ jiti (TypeScript, no build step) default-export factory(cmd: ModApi) ▼ createModHost().register(...) hooks + addTool ──► ONE AgentMod (id `mod:<name>`), appended after the built-ins addCommand ──► /slash dispatch + autocomplete in the TUI on(event) ──► AgentEvent bus subscription (observe-only) hooks.transformInput ─► typed-prompt interception (transform / consume) addProvider ──► ProviderModule appended to the host's provider set addRenderer ──► custom feed entries (showEntry → styled lines in the TUI) queueMessage ──► the loop's steering / follow-up drains

The factory receives the API bound as cmd. Registration verbs are all add* and each returns a Disposable - call .dispose() to undo exactly that one registration.

This page is the whole mods surface end to end: the quick start and loading rules first, then the full ModApi reference, the hooks and events contract, the UI surface, packaging and install, and how to verify a mod. Jump to any section from the sidebar.

You rarely need to hand-write a mod. Command Code already knows this entire API - describe the behavior you want and it writes the mod, loads it with --mod, and iterates until it works. The fastest way in is to ask what's possible, then ask for the one you want:

What kinds of mods can I build for Command Code? Show me a few examples.
Build me a mod that roasts me every time I greet you with "hi".
Add a /standup slash command that summarizes what changed in git today.
Block any shell command containing "rm -rf" unless I confirm it first.

Under the hood it reads the bundled examples and writes against the same ModApi this page documents. When it's done, cmd --mod ./your-mod.ts tries it instantly - no build step.

A real one, built by asking

cmd-mod-hi started as exactly that prompt - "roast me when I say hi" - and shipped to npm. Install it and greet your agent:

cmd mods add npm:cmd-mod-hi -g

Then type hi, yo, howdy, or hello and watch it judge you; /hi-stats tallies the damage. The whole mod is one transformInput hook plus a renderer:

import type {ModApi} from '@commandcode/harness'; const roasts = [ '"hi" — bold. groundbreaking. truly pushing the boundaries of human communication.', 'you typed "hi" and pressed enter. your ancestors are weeping.', // …a dozen more ]; let greetingCount = 0; export default function (cmd: ModApi): void { cmd.addRenderer('hi-roast', (data: {text: string}) => ['[cmd-mod-hi]: ' + data.text]); cmd.addCommand({ name: 'hi-stats', description: 'See how many times you\'ve been roasted for saying "hi"', handler: () => ({message: `[cmd-mod-hi]: You've been roasted ${greetingCount} times.`}), }); cmd.hooks({ transformInput({text}) { if (/^(hi|hello|hey|yo|sup|howdy)[.!?\s]*$/.test(text.trim().toLowerCase())) { greetingCount += 1; cmd.showEntry('hi-roast', {text: roasts[greetingCount % roasts.length]}); } return undefined; // let the greeting through - the roast is a side show }, }); }

Want to understand what it's doing, or write one by hand? The rest of this page is the full reference.

Create ~/.commandcode/mods/review-guard.ts:

import type {ModApi} from '@commandcode/harness'; export default function (cmd: ModApi) { // Block dangerous writes (a mutating hook - see the hooks catalog). cmd.hooks({ beforeToolCall: async ({toolName, input}) => { if (toolName !== 'shell_command') return undefined; const command = typeof input.command === 'string' ? input.command : ''; if (!command.includes('rm -rf')) return undefined; const allow = await cmd.ui.confirm({title: 'Allow rm -rf?'}); return allow ? undefined : {block: true, additionalContext: 'Blocked by review-guard.'}; }, }); // A tool the model can call. cmd.addTool({ schema: { name: 'count_todos', description: 'Count TODO markers in the repo', input_schema: {type: 'object', properties: {}, required: []}, }, run: async () => { const result = await cmd.exec({command: 'grep', args: ['-rc', 'TODO', '.']}); return {ok: true, content: [{type: 'text', text: result.stdout}]}; }, }); // A host slash command: /todos in the TUI. cmd.addCommand({ name: 'todos', description: 'Summarize open TODOs', handler: () => ({prompt: 'List every TODO comment in this repo and rank by urgency.'}), }); // Observe the event stream (never mutates - mutation is what hooks are for). cmd.on('turn_end', () => cmd.ui.notify('turn finished')); }

It loads on the next session (or cmd --mod ./review-guard.ts to try it without installing). The factory may be async; jiti compiles the TypeScript at load time, so there is no build step.

LocationScopeNotes
built-inshippedcompiled-in first-party mods (providers, titling, …); registered first
~/.commandcode/mods/*.tsuserloose files, one mod each
~/.commandcode/mods/<dir>/userpackage.json manifest → mods/ dir → index.ts
<project>/.commandcode/mods/…projectsame layout; loads only once the workspace is trusted
settings mods.pathsuser/projectexplicit files/dirs, relative to the settings scope
settings mods.sourcesuser/projectinstalled packages (see Packaging and install)
--mod <path>sessionrepeatable; loads ahead of installed, wins name collisions

Dot-entries and node_modules are never scanned (the package registry lives under mods/.registry/ precisely so discovery skips it). Duplicate mod names keep the first and warn. Disable without deleting via settings:

{"mods": {"disabled": ["review-guard"]}}

  • cmd.hooks({...}) is the only place that can change behavior - block a tool (beforeToolCall), rewrite a result (afterToolCall), add to the prompt (appendSystemPrompt), rewrite typed input (transformInput), force a finished run to keep going (onStop), react to session start/end (onSessionStart/onSessionEnd), or run post-turn work (onRunEnd). Multiple hooks() calls compose in registration order.
  • cmd.on(event, ...) only observes - it cannot block or rewrite. Handlers are isolated (a throw becomes a mod_error event, never a crash).

If you find yourself wanting an on handler to stop a tool, you want a hook instead. The full mutating surface is in Hooks and events; the full registration/live surface is the ModApi reference.

Command Code's own features ride this exact API. First-party providers (provider-anthropic / provider-copilot / provider-openai), the update notice, and the harness-side titling and taste-learning triggers are built-in mods: compiled-in factories registered on the same host before any discovered mod. They are not special - they use cmd.addProvider, cmd.on, and cmd.hooks like any mod, appear in cmd mods list with source builtin, and honor the same disable key:

{"mods": {"disabled": ["provider-copilot", "update-notice", "titling", "learning"]}}

Built-in mods always win a name collision against a discovered mod of the same name (the loader shadows the file with a warning), and they are compiled in - never jiti-loaded from a writable path, so they are not supply-chain surface. Structural harness mods (workspace, compaction, checkpoints) are load-bearing for correctness and stay unconditional; only the observer-style built-ins above are disable-able.

Runnable, single-file example mods ship with Command Code inside the bundled mod-builder skill (src/skills/bundled/mod-builder/examples/) and are validated in CI - they load through the real loader on every test run, so they never rot. Ask Command Code to "build a mod" and it reads these.

FileShows
slash-command.tsaddCommand - a /command returning {prompt} or {message}
custom-tool.tsaddTool - a model-callable tool with run + exec
block-dangerous-commands.tshooks.beforeToolCall - block/allow with a confirm
input-shortcuts.tshooks.transformInput - rewrite / handle typed input
observe-events.tson(event) + the cross-mod events bus
custom-entry-renderer.tsaddRenderer + showEntry - styled feed rows
flags-and-options.tsaddFlag / getFlag + --mod-option
lifecycle-hooks.tshooks.onStop (Stop) + onSessionStart/onSessionEnd + afterToolCall isError + on('subagent_start'/'subagent_stop')
kitchen-sink.tsevery capability in one annotated file

  • Hooks mutate, on observes. Event handlers cannot block tools or rewrite context; that is what cmd.hooks is for.
  • Project mods are trust-gated like project skills: they load only after the workspace trust prompt, because a mod is arbitrary code. User-scope and --mod mods always load. There is no sandbox - install packages you trust. Package installs run npm with --ignore-scripts (mods are jiti-loaded TypeScript; they need no build step, so lifecycle scripts are pure attack surface).
  • Print mode loads user-scope and --mod mods only, with the ui bridge degraded to headless defaults (confirm → false, select/input → undefined - never auto-approved; setStatus/widget render nowhere). Project mods stay out of headless runs because print never shows a trust prompt; pass --dangerously-skip-permissions to opt a repo's own mods into a headless run (CI).
  • Mod-queued messages don't echo in the feed the way typed input does - they land in the transcript and steer the model, but the visible record is the model's response.
  • Rendering is line-based, not component-based. cmd.addRenderer returns styled text lines the host prints as feed rows; mods do not mount React components into the TUI. That keeps renderers host-agnostic (the same mod renders in any future host) and a crashing renderer degrades to a warning notice, never a broken screen.
  • Reload is the /reload path. Mods load once per process; /reload restarts the process, which re-discovers and re-imports every mod (jiti caches nothing between loads). There is no in-place hot swap.
  • Session controls stop at the harness surface. cmd.sessions covers what the live harness owns (compact, tree, navigate, labels); creating/switching/forking sessions is host lifecycle, not harness state, and stays with the host UI.

Embedders wire the same machinery directly: createModHost + loadMods + createHarness({modHost}) (or createNodeHarnessSession({modHost})). See packages/harness/src/mod-host/ for the pieces and its __tests__/ for executable examples.

The rest of this page is the complete reference, in order:

export default function (cmd: ModApi) - the factory. ModFactory = (cmd: ModApi) => void | Promise<void>. Every add* / on / hooks call returns Disposable { dispose(): void } - .dispose() undoes exactly that one registration (idempotent; a second call is a no-op). Disposables make a registration retractable and are the seam future hot-reload will recycle.

Fields

MemberTypeNotes
cmd.namestringthe mod's name (from filename / manifest)
cmd.cwdstringworkspace root
cmd.sessionModSessionApi | undefinedpersistence seam; undefined until bound
cmd.eventsModEventBusemit(channel, data?) / on(channel, handler) cross-mod bus
cmd.uiModUinotify / confirm / select / input / setStatus / widget / refreshWidgets
cmd.sessionsModSessionControlscompact / tree / leafId / navigateTree / setLabel

Registration (factory-time; each returns Disposable)

MethodSignature
cmd.hooks(hooks: ModHooks) => Disposable
cmd.addTool(tool: ToolModule) => Disposable
cmd.addCommand(command: {name; description?; argumentHint?; handler}) => Disposable
cmd.addFlag(name, {type: 'boolean'|'string'; default?; description?}) => Disposable
cmd.addProvider(module: ProviderModule) => Disposable
cmd.addRenderer(customType: string, (data) => readonly string[]) => Disposable
cmd.on(event: AgentEventType | 'session_start' | 'session_shutdown', handler) => Disposable

What each does:

  • cmd.hooks(hooks) - the mutating lifecycle hooks: the AgentMod surface (transformContext, beforeToolCall, afterToolCall, onTurnStart/End, appendSystemPrompt, shouldStopAfterTurn, prepareNextTurn, onRunEnd, onStop) plus transformInput, onSessionStart, and onSessionEnd. Multiple calls compose in order with mod-runner semantics (threading, chaining, any-true, later-wins). See the hook contracts for the full contract of each hook.
  • cmd.addTool(toolModule) - a ToolModule the model can call; same collision policy as AgentMod.tools (existing names win, collision emits mod_error). The tool's run receives {input, runtime, signal} and returns {ok: true, content: [{type: 'text', text}]} or {ok: false, error}. Mark readOnly: true when the tool never mutates (stays available in plan mode).
  • cmd.addCommand({name, description, handler}) - a /name slash command. The handler returns data, not callbacks: {prompt} submits an automated turn, {message} renders an info row, nothing = pure side effect. It gets {args, ui, cwd, exec}. First registration of a name wins across mods.
  • cmd.addFlag(name, {type, default}) / cmd.getFlag(name) - named options; values come from repeatable --mod-option name=value CLI flags (defaults apply otherwise).
  • cmd.on(event, handler) - observe any AgentEvent plus the host lifecycle events session_start / session_shutdown (fired when a harness binds the host and when a session switch or dispose replaces it). Handlers are isolated: a throw becomes a mod_error {hook: 'on:<event>'} event, never a crash.
  • cmd.addProvider(providerModule) - register a model provider through the same ProviderModule seam the built-ins use (id, transport, auth hooks, model list). The host appends mod providers to its set - mods extend the provider set, never replace it.
  • cmd.addRenderer(customType, data => lines) - a renderer for a custom entry type; returns the lines to print (style them with ansi escapes - picocolors, @commandcode/tui helpers, or raw codes). First registration per type wins across mods.

Live methods (any time after the harness binds)

MethodSignature
cmd.getFlag(name) => boolean | string | undefined
cmd.showEntry(customType, data?) => void
cmd.queueMessage({content, deliverAs?: 'steer' | 'follow-up'}) => void
cmd.exec({command, args?, cwd?, signal?}) => Promise<{stdout, stderr, code}>
cmd.setSessionName / setModel / setEffort(value) => void (buffered pre-bind)
cmd.getAllTools / getActiveTools() => readonly string[]
cmd.setActiveTools(names: readonly string[]) => void

Details:

  • cmd.ui - notify (a notice feed row), confirm / select / input (the Interaction question modal in the TUI; deterministic defaults headless: false/undefined), setStatus, widget, refreshWidgets. The full dialog/status/widget contract is in UI surface.
  • cmd.queueMessage({content, deliverAs}) - steer lands after the current tool batch (behind any queued user messages in the same poll), follow-up only when the run would stop (ahead of continuation nudges, behind queued user input).
  • cmd.session - the ModSessionApi persistence seam (appendCustomEntry / appendCustomMessageEntry / getCustomEntries), undefined until bound. See ModContext.session - the mods' persistence surface.
  • cmd.showEntry(customType, data) - render a custom entry into the live feed through the renderer registered for that type (unrendered types pretty-print as JSON). The TUI wires the sink; headless runs drop entries. Pair with cmd.session.appendCustomEntry when the data should also persist.
  • cmd.sessions - live session controls: compact(), tree() (the session branch tree as stable {id, label, children} nodes), leafId(), navigateTree({targetId, summarize?, customInstructions?}), and setLabel({targetId, label}). These read live harness state, so unlike the buffered setters they throw with a clear message when no session is bound yet.
  • cmd.exec({command, args}) - run a process through the harness Runtime (args are shell-quoted).
  • cmd.getAllTools() / cmd.getActiveTools() / cmd.setActiveTools(names) - a mod-managed tool filter: disabled tools vanish from the model's schemas and refuse execution.
  • cmd.events - a tiny cross-mod pub/sub bus (emit / on).

ModHooks (the mutating lifecycle - all optional, all composable)

transformContext, appendSystemPrompt, beforeToolCall, afterToolCall, onTurnStart, onTurnEnd, shouldStopAfterTurn, prepareNextTurn, onRunEnd, onStop, plus the host-level transformInput, onSessionStart, and onSessionEnd.

  • beforeToolCall returns {block?, input?, additionalContext?, terminate?}.
  • afterToolCall receives an isError param (PostToolUse vs PostToolUseFailure) and returns {content?, isError?, additionalContext?, terminate?, modState?}.
  • onStop receives {state, stopReason, turnNumber, lastAssistantText} and returns {continue?, reason?}.
  • transformInput returns {action:'continue'} \| {action:'transform', text} \| {action:'handled', message?}.
  • onSessionStart/onSessionEnd receive {source} / {reason}.

The per-hook contracts, ordering guarantees, and error policy are in the hook contracts.

  • transformInput({text}) intercepts typed user prompts before they reach the model (the mods' UserPromptSubmit hook): return {action: 'transform', text} to rewrite (handlers chain - the next sees the rewrite), {action: 'handled', message?} to consume the prompt entirely (an optional info row renders in its place), or undefined/{action: 'continue'} to pass through. Only real typed input is intercepted - automated turns, meta messages, image-carrying prompts, and slash commands never route through it; a throwing handler is skipped (mod_error {hook: 'transformInput'}), never a swallowed prompt. It lives in hooks because it MUTATES what the agent sees.
  • onStop({state, stopReason, turnNumber, lastAssistantText}) is the Stop hook - return {continue: true, reason?} to force a run that would otherwise finish to keep going (the reason rides an automated turn). Fires only on natural completion, any-mod wins, and the loop caps consecutive continuations.
  • onSessionStart({source}) / onSessionEnd({reason}) are the once-per-session SessionStart / SessionEnd hooks - fired when the host binds to / tears down a session (source: 'startup' | 'resume', reason: 'shutdown' | 'replaced'). Act through the mod's captured cmd; they run fire-and-forget (a throw becomes mod_error), so a session hook never blocks bind/dispose. Pure observation is also available via cmd.on('session_start' | 'session_shutdown') - these named hooks add the typed metadata.

on event types

Any AgentEvent['type'] - including run_start, run_end, turn_start, turn_end, model_request_start, model_request_end, tool_running, tool_completed, tool_errored, subagent_start, subagent_stop, subagent_progress, compaction_start, compaction_done, notice, session_titled, permission_mode_changed, config_setting_changed, mod_error - plus the two host lifecycle events session_start / session_shutdown. (subagent_start/subagent_stop are SubagentStart/Stop; compaction_start/compaction_done are Pre/PostCompact; notice is Notification.) The full payload catalog is in the AgentEvent catalog.

The harness's extension seam. A mod is a plain object of lifecycle hooks that mutates agent state or changes loop decisions. Pure observers (UI, telemetry, loggers) are not mods - they subscribe to the AgentEvent stream. Hooks change behavior; subscribers watch it. In a loadable mod, cmd.hooks({...}) registers the hooks and cmd.on(event, ...) subscribes to events.

A mod may also contribute tools, and every hook receives a ModContext ({emit, signal, cwd, session?}) as an extra last argument, so a mod can raise its own events and persist durable state without extra plumbing.

Lifecycle - where each hook fires

One run() = one user turn → many model turns ("rounds"). The loop below is the agent loop, verbatim in ordering; mod hooks are marked , events .

run({state, userInput?, config}) fork AbortController; working := state (+ user message when userInput given) ctx := {emit, signal, cwd, session} - built ONCE per run, passed as the LAST argument to every ◆ hook below → run_start {sessionId} poll getSteeringMessages once (catch pre-run queued input) loop: abort check ──────────────────────────────────► stop: interrupted (→ interrupted) ◆ onTurnStart (each mod, in order; returns new state) → turn_start {turnNumber} read live permission mode ONCE - pinned for this round resolve systemPrompt (string | builder({sessionId, state, permissionMode})) ◆ appendSystemPrompt (each mod; non-empty returns joined with '\n\n', in registration order, and appended AFTER the base prompt) ◆ transformContext (each mod; messages threaded mod→mod; result is EPHEMERAL - used for this call only, never written back to state.messages) prepareForSend (core wire-validity pass: orphan heal, merge, strip meta) → message_start → model_request_start {model} modelClient.complete(...) (streams → text_delta / thinking_* / message_update / continuation_recovery / tool_input_coerced; retry → api_retry) → model_request_end {model, usage, stopReason} → message_end {content} working += assistant message (provider-executed blocks filtered out) if client tool calls: runTools(...) (see tool-dispatch order below) working += tool-results user message working := setModState(...) for every afterToolCall `modState` contribution across the batch (BEFORE onTurnEnd runs) any deny ────────────────────────► pendingStop: permission_denied any terminate ───────────────────► pendingStop: terminate else poll getSteeringMessages → append (source 'steering') ◆ onTurnEnd (each mod; receives THIS turn's usage; returns new state) → turn_end {turnNumber, hadToolCalls, usage} onCommit(working) ← the durability boundary pendingStop? ──────────────────────► stop ◆ shouldStopAfterTurn (any true) ──► stop: stop_hook turnNumber ≥ maxTurns ─────────────► stop: max_turns if no tool calls: no follow-up wanted ─────────────► ◆ onStop (any continue → keep going) else stop: end_turn ◆ prepareNextTurn (merged; later mods win) → may swap model / effort ◆ onRunEnd (each mod, awaited) → run_end {result}

Tool-dispatch order

for each tool_use: → tool_queued {toolCallId, toolName, input} ← ALWAYS the original input phase 1 - permissions (always sequential): permissions.generateDescription (5s timeout → null) permissions.check({toolName, input, description, permissionMode}) throw ⇒ deny (fail closed) deny → tool_denied; batch aborted: every call gets a tool_result, run stops with permission_denied AFTER the turn commits phase 2 - execution (sequential, or parallel when config.toolExecution='parallel'): ◆ beforeToolCall (each mod, in order; `input` rewrites CHAIN mod→mod; execution uses the LAST mod's input; a mod may also set `terminate`) block ⇒ → tool_hook_blocked {hookOutput}; the block reason becomes the tool_result text; NO tool_running / tool_completed / tool_errored fires → tool_running {toolCallId, toolName, description} toolRunner.execute({toolName, input: <possibly rewritten>, signal, onUpdate}) ◆ afterToolCall (each mod, in order; sees current content, may replace it, append `additionalContext`, flip `isError`, set `terminate`, or persist `modState`) beforeToolCall additionalContext strings appended to the final content → tool_completed {result} (or → tool_errored {error})

Ordering guarantees mod authors can rely on

  • Every hook receives ModContext ({emit, signal, cwd, session?}) as its LAST argument. It's declared optional on every hook signature, so a hook that only declares the params object keeps compiling and running unchanged; the core always supplies a real object at runtime.
  • beforeToolCall completes (for every mod) before tool_running is emitted. Its input rewrites chain across mods, but the tool_queued event fired with the ORIGINAL input before any hook ran and is never re-emitted - display always shows what the model asked for, only execution sees the rewrite.
  • afterToolCall completes before tool_completed / tool_errored is emitted - the terminal event carries the post-hook content, and its isError (when set) decides which of the two fires, independent of whether execution itself threw.
  • afterToolCall's modState is committed right after the batch's tool-result message is appended - BEFORE onTurnEnd runs, so onTurnEnd sees it.
  • onTurnStart precedes turn_start; onTurnEnd precedes turn_end and onCommit.
  • onCommit fires once per completed turn with the full serializable state.
  • shouldStopAfterTurn is evaluated after the turn commits, so a stop_hook never loses the turn that triggered it.
  • prepareNextTurn runs only when the loop actually continues (never on a stopping turn).
  • onRunEnd is awaited before the run_end event - it is the place for must-complete work (flush, trigger learning); event subscribers must never be.
  • Mods run in registration order in every phase; the built-ins come first and caller mods last (they see post-compaction context and post-hook tool results).
  • Hooks never throw upward: the runner catches per-mod, per-hook. A failing transformContext leaves messages unchanged; a failing onTurnStart/onTurnEnd keeps the prior state; a failing shouldStopAfterTurn means "don't stop"; failing tool hooks are skipped. Every catch site ALSO emits a mod_error {modId, hook, error} event before falling back to its no-crash default - the decision the loop makes is unchanged, but the degradation is never silent.

The hook contracts

transformContext({messages, state, signal?}, ctx?) → messages

Fires once per round, after appendSystemPrompt, before prepareForSend. Messages are threaded through every mod in order - each receives the previous mod's output. The result is used for this model call only and never written back to state.messages: the durable log is untouched (compaction relies on this - the recorded transcript stays complete). Return the input array unchanged (same reference) to signal "no change".

shouldStopAfterTurn({state, turnNumber}, ctx?) → boolean

Fires after the turn commits. Any mod returning true ends the run with stopReason: 'stop_hook' - first true short-circuits. This is early stop (goal budget spent); it is the opposite of the Stop hook forcing continuation.

prepareNextTurn({state, turnNumber}, ctx?) → {model?, effort?} | undefined

Fires at the bottom of a continuing round. Results are merged across mods - later mods override earlier ones per field. A returned model/effort applies from the next model call onward (config is never mutated).

appendSystemPrompt({state}, ctx?) → string | undefined

Fires once per round, right after the round's base systemPrompt resolves and before transformContext. Every mod's non-empty return is joined with '\n\n', in registration order, and appended AFTER the base prompt. May return a plain string OR a Promise. Must be byte-stable across rounds for the same durable inputs - the provider's prompt-prefix cache keys off the system prompt's bytes, so a value that changes turn-to-turn without a corresponding state/modState change busts the cache every round. Compute once, store in modState, read it back.

beforeToolCall({toolCallId, toolName, input, state}, ctx?) → {block?, additionalContext?, input?, terminate?} | undefined

Fires per tool call, after the permission check passed, before execution.

  • block: true - the tool does not run. The core emits tool_hook_blocked with hookOutput = your additionalContext (or 'Blocked by a pre-tool hook.'), and that same text becomes the tool_result the model sees. Later mods' beforeToolCall for this call do not run. No tool_running/tool_completed/tool_errored is emitted for a blocked call.
  • additionalContext (without block) - collected across mods and appended as extra text blocks to the tool_result after execution and after afterToolCall overrides.
  • input - rewrites the input the tool actually executes with. Chained across mods: the next mod's input param is YOUR rewrite, not the original.
  • terminate: true - stops the run after this tool batch finishes. ANY-semantics (one hook, on one call, in one batch, is enough). Combines with block on the same return value.
  • undefined - no opinion.

afterToolCall({toolCallId, toolName, input, result, isError, state}, ctx?) → {content?, terminate?, additionalContext?, isError?, modState?} | undefined

Fires per tool call after execution (also after an execution error - result is then the error text content). input is the FINAL input the tool actually ran with (after any beforeToolCall rewrites).

  • isError (param) - whether the tool's OWN execution failed, read before any hook override and threaded across mods like result. This is the PostToolUse vs PostToolUseFailure distinction: a hook can branch on isError to react only to failures. It is the INPUT signal; the returned isError field below is the OUTPUT override that selects the terminal event.
  • content replaces the tool result the model will see (threaded mod→mod).
  • terminate: true ends the run with stopReason: 'terminate' after the whole batch finishes.
  • additionalContext - appended as a SEPARATE text block after content (and after any beforeToolCall additionalContext strings).
  • isError - overrides whether the terminal event is tool_completed or tool_errored, independent of whether execution itself threw.
  • modState - a Record<string, unknown> merged into state.modState[mod.id] right after the tool-result message commits - a full replace of that mod's slot, exactly like setModState. The durable-state channel for tool hooks: neither tool hook can return a whole AgentState the way onTurnStart/onTurnEnd can.

onTurnStart({state, turnNumber}, ctx?) → AgentState / onTurnEnd({state, turnNumber, hadToolCalls, usage}, ctx?) → AgentState

The only hooks that can persist state changes unconditionally - they return the new AgentState (typically via setModState), which the loop threads onward and commits. onTurnEnd's usage is this turn's token usage. Both fire every round, including the round that stops.

onRunEnd({state, result}, ctx?) → void

Fires once, awaited, after the loop exits and before the run_end event. state is the final state; result carries finalText, stopReason, turnCount, accumulated usage. Cannot alter the result. This is the "must-complete work" hook (learning, flushes).

onStop({state, stopReason, turnNumber, lastAssistantText}, ctx?) → {continue?, reason?} | undefined

The Stop hook - the mods' force-continue channel. Fires when a turn WOULD end the run naturally - the model returned no tool calls and no follow-up provider wants to continue. Returning {continue: true} keeps the run going: reason (or a neutral default) is appended as an automated source: 'stop_hook' user turn, so the model is told why it must keep working.

  • ANY mod returning continue wins (first wins, short-circuits).
  • Does NOT fire for hard stops (max_turns, terminate, permission_denied, interrupted).
  • A follow-up provider (e.g. the continuation nudger) gets first say; onStop is consulted only when the provider declines.
  • The loop caps consecutive stop-hook continuations at 8 - a hook that always says continue can't loop forever.
  • Distinct from shouldStopAfterTurn (force EARLY stop) and onRunEnd (observe the stop): only onStop can push a finished run onward.
const persistUntilTestsPass = { id: 'until-green', onStop: async ({lastAssistantText}) => { if (/all tests pass/i.test(lastAssistantText)) return {continue: false}; return {continue: true, reason: 'Tests are not green yet - keep going.'}; }, };

ModContext.session - the mods' persistence surface

The harness's tree-format session store gives mods a durable, per-entry seam onto the SAME append-only file the transcript lives in. Available in a loadable mod as cmd.session and inside hooks as ctx.session. Two entry kinds:

  • appendCustomEntry({customType, data?}) - a custom tree entry. Mod-private data; never sent to the LLM, never rendered. Use it for durable bookkeeping a mod wants to survive resume (counters, cursors, cached decisions).
  • appendCustomMessageEntry({customType, content, display, details?}) - a custom_message tree entry. Content the model SHOULD see: it is projected as an ordinary user message on the NEXT turn (display: true also renders it in the TUI with distinct styling; display: false is context-only). The call returns {entryId, message} - a mod MUST fold message onto the AgentState it hands back from its hook for the model to see it that turn.
  • getCustomEntries({customType}) - reads back every custom entry this mod itself wrote (filtered by customType), in file order, over the ACTIVE branch's full entry list. The standard reload pattern: a mod with in-memory state seeds it from getCustomEntries at first onTurnStart.
const MOD_ID = 'turn-counter'; const CUSTOM_TYPE = 'turn-counter/count'; const mod = { id: MOD_ID, onTurnStart: async ({state}, ctx) => { if (!ctx?.session) return state; // a bare unit-test config without a store const priorCount = ctx.session.getCustomEntries({ customType: CUSTOM_TYPE, }).length; ctx.session.appendCustomEntry({customType: CUSTOM_TYPE, data: {count: priorCount + 1}}); return state; }, };

session is absent (not merely empty) only for a bare unit-test config built without a durable store - every hook must treat ctx.session as possibly undefined and no-op gracefully. A --no-session run DOES populate session - entries still append and are readable for the lifetime of the process, they simply never touch disk.

modState conventions

AgentState.modState is a Readonly<Record<string, unknown>> - one slot per mod, keyed by the mod's id:

const value = getModState<MyShape>({state, modId: 'my-mod'}); // undefined when unset const next = setModState({state, modId: 'my-mod', value: {...}}); // fresh AgentState
  • Namespacing is by convention: write only your own id's slot.
  • Values must be JSON-serializable - modState is persisted with the session and survives resume. Run-scoped/volatile data (budgets, locks, in-flight promises) belongs in the mod factory's closure instead.
  • onTurnStart/onTurnEnd return the whole AgentState (typically via setModState). afterToolCall can also persist state via its modState return field. beforeToolCall has no state channel; a before-hook that needs to accumulate data stores it in the closure and flushes it via afterToolCall's modState or onTurnEnd.
  • Custom data never goes into state.messages: the message union is closed wire types. When a mod must put something in front of the model, it emits real messages via transformContext (or appendCustomMessageEntry).

AgentEvent catalog

One sync sink, fan out with createEventBus. Payloads are snapshots - never live references.

EventFiresPayload highlights
run_start / run_endrun boundariessessionId / result
turn_start / turn_endround boundaries (after onTurnStart / onTurnEnd)turnNumber; end adds hadToolCalls, this turn's usage
message_startbefore each model call-
text_delta, thinking_start/delta/endstreamingdeltas; thinking_end carries full text
message_updatestreaming (ModelClient-accumulated)whole partial assistant message incl. partial tool JSON
message_endresponse completefull assistant content
model_request_start/endbracket the inference callmodel; end adds usage, raw-preferred stopReason
tool_queuedper call, before permission checksinput - the ORIGINAL input, never the rewritten one
tool_deniedpermission denied- (batch then aborts)
tool_hook_blockeda mod's beforeToolCall blockedhookOutput - terminal for that call: no tool_running/completed/errored follows
tool_runningexecution begins (after beforeToolCall)description from permissions.generateDescription
tool_updatestreaming tool progresspartial content
tool_completed / tool_erroredafter afterToolCallpost-hook result / error text; afterToolCall's isError can select which one fires
tool_hooksemitted by the user-hooks mod, before the phase's terminal tool eventphase: 'pre'|'post', lines, outcome
subagent_start / subagent_stopthe agent tool brackets a nested sub-agent runtoolCallId, subagentType; stop adds tokensUsed
subagent_progressper child tool call inside a running sub-agenttoolCallId, subagentType, toolName, toolInput, tokensUsed
api_retryretry loop, after 3 silent attemptsattempt, error, delayMs
compaction_start / compaction_donecompaction modtokensSaved (done, only when > 0)
noticeuser-facing info/warninglevel, message
skill_loadeda skill was activated by an explicit user /name invocationname
session_titledthe auto-generated session title was persistedtitle
permission_mode_changedthe effective permission mode changed (shift+tab, /plan, enter/exit plan tools)mode
config_setting_changeda setting changed through the config toolsetting, value, previousValue?
continuation_recoverya turn was auto-continued (pause/empty/length/intent)kind, attempt, maxAttempts
tool_input_coercedModelClient rescued malformed (array/null) tool inputrawType, recovered
tool_input_repairedrepair layer healed tool input pre-executionrulesFired, hintCount, receivedKeys
mod_errora mod hook threw (any phase), or a mod tool collided with an existing toolmodId, hook, error
interruptedabort observed-
run_errornon-retryable failurethe raw Error

Worked example - a write-quota mod

Blocks writes outside an allowlist, counts tool activity durably in modState, and reports at run end. Exercises the block channel, the closure-vs-modState split, and onRunEnd.

import type {AgentMod, AgentState} from '@commandcode/harness'; import {getModState, setModState} from '@commandcode/harness'; const MOD_ID = 'write-quota'; interface WriteQuotaState { readonly writesThisSession: number; // durable - survives resume } export function createWriteQuotaMod(options: { readonly allowedRoot: string; readonly maxWrites: number; readonly report: (summary: string) => void; }): AgentMod { // Run-scoped tally lives in the closure; flushed into modState at turn end // (beforeToolCall cannot return state - see modState conventions). let writesThisTurn = 0; return { id: MOD_ID, beforeToolCall: async ({toolName, input, state}) => { if (toolName !== 'write_file' && toolName !== 'edit_file') return undefined; const path = typeof input.file_path === 'string' ? input.file_path : ''; if (!path.startsWith(options.allowedRoot)) { // Block: this text becomes the tool_result the model sees, and the // core emits tool_hook_blocked. The run continues - the model adapts. return { block: true, additionalContext: `Writes outside ${options.allowedRoot} are not allowed.`, }; } const durable = getModState<WriteQuotaState>({state, modId: MOD_ID}); if ((durable?.writesThisSession ?? 0) + writesThisTurn >= options.maxWrites) { return {block: true, additionalContext: 'Write quota exhausted for this session.'}; } writesThisTurn += 1; return undefined; }, onTurnEnd: async ({state}) => { if (writesThisTurn === 0) return state; const durable = getModState<WriteQuotaState>({state, modId: MOD_ID}); const next: AgentState = setModState({ state, modId: MOD_ID, value: {writesThisSession: (durable?.writesThisSession ?? 0) + writesThisTurn}, }); writesThisTurn = 0; return next; // committed via onCommit at the turn boundary }, onRunEnd: async ({state, result}) => { const durable = getModState<WriteQuotaState>({state, modId: MOD_ID}); options.report( `run stopped (${result.stopReason}) after ${result.turnCount} turns; ` + `${durable?.writesThisSession ?? 0} writes used`, ); }, }; }

Wire it as a loadable mod (cmd.hooks({...}) with the same handlers) - or, when embedding the harness, createHarness({..., mods: [createWriteQuotaMod({...})]}).

Everything a mod can put on screen rides cmd.ui, cmd.addRenderer, and cmd.showEntry. All of it is line-based and host-agnostic: the TUI wires real rendering, headless runs degrade to deterministic defaults, and a crashing renderer becomes a warning - never a broken screen.

Notifications and dialogs

  • cmd.ui.notify(message) - a notice feed row.
  • cmd.ui.confirm({title, message?}) / cmd.ui.select({title, options}) / cmd.ui.input({title, placeholder?}) - the Interaction question modal in the TUI. Headless, each resolves its deterministic default: confirm → false, select/input → undefined - never auto-approved.

Not wired into the TUI yet. cmd.ui.setStatus, cmd.ui.widget, and cmd.ui.refreshWidgets exist on the surface and are safe to call - they never throw and return the documented Disposable - but they currently render nowhere (no-op everywhere, interactive and headless alike). The contract below is the intended design; wire-up is pending. Build against it, but don't rely on anything appearing on screen today.

cmd.ui.setStatus(text | null) - a persistent per-mod segment in the TUI footer (under the input panel). One segment per mod: a new call replaces the text, null clears it, the returned Disposable clears it too. Segments from multiple mods concatenate in load order. Headless: renders nowhere (no-op).

cmd.ui.widget({placement: 'above-editor' | 'below-editor', render: () => lines}) - a line-based widget the TUI renders around the input panel. Same verbatim-lines contract as addRenderer (style with ansi); render re-runs on every repaint, and cmd.ui.refreshWidgets() requests a repaint after your data changes. A throwing render is skipped (reported as a mod_error) so siblings keep rendering. Headless: no-op.

let failing = 0; cmd.ui.widget({ placement: 'above-editor', render: () => [failing > 0 ? `${failing} tests failing` : '✓ tests green'], }); cmd.on('tool_completed', () => { failing = readFailingCount(); cmd.ui.refreshWidgets(); });

Custom feed rendering

  • cmd.addRenderer(customType, data => lines) - a renderer for a custom entry type; returns the lines to print (style them with ansi escapes - picocolors, @commandcode/tui helpers, or raw codes). First registration per type wins across mods.
  • cmd.showEntry(customType, data) - render a custom entry into the live feed through the renderer registered for that type (unrendered types pretty-print as JSON). The TUI wires the sink; headless runs drop entries. Pair with cmd.session.appendCustomEntry when the data should also persist.

Rendering is deliberately line-based, not component-based: mods return styled text lines, never React components. That keeps renderers host-agnostic (the same mod renders in any future host) and a crashing renderer degrades to a warning notice, never a broken screen.

Headless behavior at a glance

SurfaceInteractive TUIHeadless (-p)
notifynotice feed rowprinted notice
confirmmodalresolves false
select / inputmodalresolves undefined
setStatusno-op (wire-up pending)no-op
widgetno-op (wire-up pending)no-op
showEntryrendered feed rowdropped

A mod starts life as a loose file in ~/.commandcode/mods/. When it should be shared - with a team, or the world - it becomes a package: an npm package, a git repo, or a local directory that cmd mods add installs and Command Code loads on every session.

Install, remove, list, update

cmd mods add npm:@team/review-mod@1.2.0 # npm registry cmd mods add owner/repo@v1 # GitHub shorthand (any git host via git:<host>/…) cmd mods add ./tools/local-mod # local path, referenced in place cmd mods add -g owner/repo # user scope instead of project cmd mods list cmd mods update # reinstall missing, reconcile pinned refs cmd mods remove owner/repo

Sources persist in the mods.sources settings key (project scope writes .commandcode/settings.json, -g writes ~/.commandcode/settings.json). Identity is version/ref-agnostic - owner/repo@v1 and https://github.com/owner/repo are the same package, and a project entry shadows the same identity at user scope. Installs land in <scope>/.commandcode/mods/.registry/{npm,git}/…; startup never runs npm/git on its own - a configured-but-missing package is a warning pointing at cmd mods update.

What a package ships

A package declares what it ships via package.json - exact paths, directories, or globs (expanded against the package root; entries escaping the root are dropped):

{ "name": "@team/review-mod", "commandcode": {"mods": ["./src/review.ts", "src/checks/*.ts"]} }

No manifest → the mods/ convention directory → a root index.ts, in that order.

Filtering a source's mods

A mods.sources entry can be the object form to load only part of a package:

{ "mods": { "sources": [ {"source": "owner/review-pack", "mods": ["review/*.ts", "!review/slow.ts"]} ] } }

Four pattern kinds, applied in precedence order: -path force-exclude (exact, beats everything) → +path force-include (exact, restores what globs dropped) → !glob exclude → plain-glob include (when any includes exist, an entry must match one). Patterns match the entry's package-relative path or its mod name. cmd mods add / remove preserve hand-written object entries - they never flatten your filters.

Disabling without deleting

{"mods": {"disabled": ["review-guard"]}}

Works for discovered files, installed packages, and the disable-able built-ins (provider-copilot, update-notice, titling, learning).

Trust and safety

  • There is no sandbox - a mod is arbitrary code; install packages you trust.
  • Project mods are trust-gated like project skills: they load only after the workspace trust prompt. User-scope and --mod mods always load.
  • Package installs run npm with --ignore-scripts - mods are jiti-loaded TypeScript; they need no build step, so lifecycle scripts are pure attack surface.
  • Print mode loads user-scope and --mod mods only. Project mods stay out of headless runs because print never shows a trust prompt; pass --dangerously-skip-permissions to opt a repo's own mods into a headless run (CI).

A mod that fails to import, exports no factory, or throws in its factory becomes a warning - never a crashed session. That safety also means a broken mod can fail silently if you never check. This section is the verification loop: load it, list it, exercise it, reload it.

1. Load it without installing

cmd --mod ./your-mod.ts

--mod is repeatable, loads ahead of installed mods, and wins name collisions - the fastest try-it loop. No build step: jiti compiles the TypeScript at load.

2. Confirm it registered

cmd mods list

Your mod must appear, with no load warnings. If it does not appear, the warning printed by cmd mods list says why (import error, no default-export factory, factory threw, duplicate name).

3. Exercise every surface it registers

  • Slash command - type / in the chat input: the command must appear in autocomplete with its description. Run it; {message} renders an info row, {prompt} starts an automated turn.
  • Tool - ask the model to use it by name ("call count_todos"). The tool call renders in the feed like any built-in.
  • Hook - trigger the behavior it guards (for a beforeToolCall blocker, ask for the blocked action and watch the block reason land as the tool result).
  • Input interception - type the pattern transformInput matches and confirm the rewrite/consume happened.
  • Status / widget - cmd.ui.setStatus / cmd.ui.widget / cmd.ui.refreshWidgets must return without throwing and hand back a Disposable. They render nothing today (TUI wire-up pending - see the UI surface note), so don't expect a footer segment or editor widget to appear yet; only verify the calls are safe.
  • Renderer - cmd.showEntry rows render styled; an unregistered type pretty-prints as JSON.

Test: block-dangerous-commands guards rm -rf

Run rm -rf /tmp/scratch-dir

Expected result:

  • ✅ The mod's confirm dialog appears before the shell command runs
  • ✅ Declining blocks the tool - the model sees the block reason and adapts
  • ❌ No tool_running fires for the blocked call

4. Iterate with /reload

Mods load once per process. After editing the file, run /reload - it restarts Command Code, resumes the session, and re-discovers and re-imports every mod (jiti caches nothing between loads).

5. Headless check (CI)

cmd -p "exercise the mod" --mod ./your-mod.ts

Print mode loads user-scope and --mod mods with the UI bridge degraded to deterministic defaults (confirm → false, select/input → undefined). A mod that must work in CI should behave sensibly under those defaults.

Working from the bundled examples

Command Code ships runnable single-file examples inside the bundled mod-builder skill - each one loads through the real mod loader in CI on every test run, so copying one is copying something proven to load. In the Command Code repo itself, the same validation is a vitest suite (packages/harness/src/mod-host/__tests__/examples.test.ts); if you are contributing an example, that test must stay green.