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, permission answers, transcript redaction, per-request model routing, panes and widgets with buttons, toasts, sub-agent types, configurable flags, and model providers. Mods hot-reload when you save them. 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 built-ins addCommand ──► /slash dispatch + TUI autocomplete on(event) ──► AgentEvent bus (observe-only) hooks.transformInput ──► typed-prompt interception addProvider ──► ProviderModule addRenderer ──► custom feed entries queueMessage ──► steering drains ui.widget / ui.pane ──► bands, panes, buttons (element tree, any host) addAgent ──► task tool agent types prompt.submit ──► a turn of its own

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.

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"]}}

Turn off every installed mod at once with {"mods": {"disableAll": true}} (any settings file; a disable wins), or for one session with cmd --safe-mode. Both still load the built-in mods, which are Command Code itself; safe mode also skips --mod paths.

  • cmd.hooks({...}) is the only place that can change behavior - block, rewrite, or answer a tool call (beforeToolCall), rewrite a result (afterToolCall), add to or edit the system prompt (appendSystemPrompt, transformSystemPrompt), redact what the transcript keeps (transformMessage), rewrite the answer as it streams (transformTextDelta), send one request to another model (beforeModelRequest), answer a permission prompt (onPermissionRequest), 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"]}}

Some built-ins are opt-in and are disabled by default; cmd mods list --builtin marks them disabled by default. The session todo checklist (todo_write, the TODOS panel, /todos, ctrl+x) is the todos mod: enable it with cmd mods enable todos (project level; -g for the user level), which adds it to the enabled key in the settings file:

{"mods": {"enabled": ["todos"]}}

cmd mods disable <name> disables the mod. A disable in any settings file wins and keeps the mod disabled. The change takes effect on the next session or /reload; see Enable/Disable a mod.

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.

Command Code ships runnable, single-file example mods and reads them itself. You don't need to find them on disk - just ask:

build a mod that blocks dangerous shell commands

Command Code opens the matching example and works from it. Every example loads through the real mod loader in CI on every test run, so what it copies is proven to load.

Ask for any of these by name or by what you want:

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')
pane-and-buttons.tsui.pane + ui.widget + buttons + cmd.state + ui.toast
pane-form.tsa focused form pane: input + select + markdown elements, openPane(id, {focus: true})
spinner-count.tsui.override - a tool-call count beside the spinner, compact shell rows
redact-secrets.tshooks.transformMessage - mask keys before the transcript keeps them
permission-policy.tshooks.onPermissionRequest - answer prompts with a team policy
model-router.tshooks.beforeModelRequest + cmd.model.complete
stream-filter.tshooks.transformTextDelta - redact keys as the answer streams + cmd.process.spawn
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, and cmd.ui.capabilities.status is false). 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 data, not components. Widgets, panes, and feed renderers return strings or a small element tree (text, row, box, button, progress, divider, input, select, markdown) that each host draws; mods never mount React components. That keeps a mod host-agnostic, and a crashing render degrades to a one-line warning, never a broken screen.
  • Mods hot-reload on save. In the interactive TUI every file-backed mod is watched: saving it re-imports the file and swaps the mod in place. The old code gets onSessionEnd with reason: 'reload' and every registration it made is disposed, except tools added with addTool, which keep the old code until the next session. cmd.state is kept, and the new code's onSessionStart fires with source: 'reload'. A save that throws while loading keeps the old code running, and the feed says why. /mods reload [name] does the same on demand; COMMANDCODE_MODS_HOT_RELOAD=0 turns the watcher off. Tools a mod adds take effect on the next session, because the tool set is fixed per session. Built-in mods never reload.
  • Mods restyle rows, never the permission prompt. cmd.ui.override covers the spinner, tool rows, user messages, and command output. What a person is asked to approve is always drawn by Command Code, so the permission prompt (and every other approval dialog) is not on the list and never will be.
  • Permission answers stop at the prompt. onPermissionRequest only sees prompts the engine was about to show you: deny rules, plan mode, and dont-ask always win, and a safety-forced prompt can be denied by a mod but never allowed. In a headless run, where the prompt would otherwise be auto-answered, the mod's answer is used too.
  • 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})), all exported from @commandcode/harness.

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.

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 / pane / openPane / closePane / toast / copy / override / setTitle / capabilities
cmd.sessionsModSessionControlscompact / tree / leafId / navigateTree / setLabel
cmd.stateModStateApiget / set / subscribe - session values that survive a hot reload
cmd.storeModStoreApiget / set / delete - values kept across sessions (~/.commandcode/mods/.store/<mod>.json)
cmd.clockModClockApievery / after - timers cancelled with the mod
cmd.modelModModelApicomplete - one prompt on the session's model client
cmd.promptModPromptApisubmit - start a turn of its own once the session is idle
cmd.processModProcessApispawn - run a process and stream its output as it arrives
cmd.audioModAudioApiplay / bell - sound files shipped with the mod, and the terminal bell

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.addShortcut(keys: string, {description?; handler}) => Disposable
cmd.addSkillsPath(path: string) => Disposable
cmd.on(event: AgentEventType | 'session_start' | 'session_shutdown', handler) => Disposable
cmd.addAgent(definition: {name; description; systemPrompt; tools?; disallowedTools?; model?; maxTurns?}) => Disposable
cmd.expose(name, fn) => 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.
  • cmd.addAgent(definition) - a sub-agent type the model can delegate to through the task tool, next to the bundled and on-disk agents. tools defaults to '*'. An agent name that already exists (bundled, project, user, or another mod's) is skipped with a warning. Read live, so a reload's agents show up on the next turn.
  • cmd.expose(name, fn) / cmd.use(mod, name) - share a function with other mods and call one another mod shared. use answers undefined until that mod has loaded and exposed the name, so call it from a hook or command, not only at factory time.
  • cmd.addShortcut(keys, {description, handler}) - a keyboard shortcut in the interactive TUI. keys is a modifier+key chord that must include Ctrl or Alt (alt+r, ctrl+r); a modifier-less or Shift-only chord (p, enter, shift+p) is rejected with a warning, because it would also type into the prompt, and so is a Ctrl+Shift chord whose key reaches the terminal as a bare control byte — letters, digits, Enter, Tab, Backspace (ctrl+shift+p, ctrl+shift+enter) — where the Shift never arrives (arrow / navigation keys keep their modifiers, so ctrl+shift+up is fine). ctrl+m, ctrl+i, ctrl+h and ctrl+[ are rejected too: terminals send them as Enter, Tab, Backspace and Escape. A chord a built-in already owns (ctrl+c, ctrl+o, an editor key like ctrl+k) is rejected the same way — the host's own bindings run first, so it would never fire — and so are the keys the prompt handles itself (alt+w, ctrl+z, ctrl+g, ctrl+s, ctrl+v, alt+v), which stay taken even when rebound. The handler gets the same {args, ui, cwd, exec} context a command handler does and may return the same {message} / {prompt} result. The host dispatches mod shortcuts AFTER its own bindings, so a mod can add keys but never shadow interrupt, exit, mode cycling, or the transcript toggles; Esc stays host-owned. First registration per chord wins across mods; /hotkeys lists them under Mods. Headless hosts never fire shortcuts.
  • cmd.addSkillsPath(path) - an extra skills location (one skill directory, or a directory of skills) for this session's skill discovery. Relative paths resolve against the mod's own file, so a package can point at the ./skills it ships. Collected at factory time. A package can declare the same thing without code through its manifest - see What a package ships.

MethodSignature
cmd.getFlag(name) => boolean | string | undefined
cmd.showEntry(customType, data?) => void
cmd.queueMessage({content, deliverAs?: 'steer' | 'follow-up'}) => void
cmd.sendUserMessage(content, {deliverAs?}) => void
cmd.exec({command, args?, cwd?, signal?}) => Promise<{stdout, stderr, code}>
cmd.process.spawn({command, args?, cwd?, signal?, onOutput: (chunk, stream) => void}) => Promise<{code}>
cmd.setSessionName / setModel / setEffort(value) => void (buffered pre-bind)
cmd.getSessionName / getModel / getEffort() => value | undefined (undefined pre-bind)
cmd.getContextUsage() => {tokens, contextWindow, percent} | undefined
cmd.getCommands() => readonly {name, description?, mod}[]
cmd.isIdle() => boolean
cmd.shutdown() => void
cmd.getAllTools / getActiveTools() => readonly string[]
cmd.getToolInfo() => readonly {name, description, readOnly, active}[]
cmd.setActiveTools(names: readonly string[]) => void
cmd.use(mod, name) => fn | undefined
cmd.state.get / set / subscribe(key) => T | undefined / (key, value) => void / (key, listener) => Disposable
cmd.store.get / set / delete(key) => T | undefined / (key, value) => Promise<void> / (key) => Promise<void>
cmd.clock.every / after(ms, callback) => Disposable
cmd.model.complete({prompt, system?, model?, maxOutputTokens?, signal?}) => Promise<ModModelCompleteResult>
cmd.prompt.submit(text) => void
cmd.ui.copy(text) => Promise<ModCopyResult>
cmd.audio.play({file, timeoutMs?}) => Promise<ModAudioResult>
cmd.audio.bell() => ModAudioResult

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, pane + openPane(id, {focus?}) / closePane, toast, override, setTitle. The full dialog/status/widget/override 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). Queued messages steer a run that is already going; they never START one.
  • cmd.sendUserMessage(content, {deliverAs}) - a REAL user message: echoed in the feed like typed input and, when the agent is idle, starting a turn right away. While a run is going, steer (default) queues it behind the active turn exactly as typing would, and follow-up holds it until the run would stop. Headless there is no submit path, so it falls back to queueMessage with the same deliverAs.
  • cmd.getSessionName() / getModel() / getEffort() - the live values behind the setters; undefined before a session binds.
  • cmd.getContextUsage() - {tokens, contextWindow, percent} for the bound session: tokens and percent are null until the first model call has measured the window (never a misleading 0%).
  • cmd.getCommands() - every slash command mods registered on this host ({name, description, mod}), so a mod can discover siblings or avoid a taken name. Built-in commands are host-owned and not listed.
  • cmd.isIdle() - false while an agent run is in flight on the bound harness (run_start … run_end), true otherwise and pre-bind.
  • cmd.shutdown() - asks the interactive host to exit once the current turn settles (the same path as /exit). Headless: no-op - the print run ends on its own.
  • cmd.getToolInfo() - the registered tools with description, readOnly, and active (per the mod-managed filter) - the descriptive counterpart of getAllTools()'s bare names.
  • 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.process.spawn({command, args, onOutput}) - the same command line as cmd.exec, but every stdout/stderr chunk reaches onOutput(chunk, stream) as the process produces it (stream is 'stdout' or 'stderr'), so a mod can show progress, tail a log, or react to a line without waiting for the exit. Resolves {code} when the process exits; aborting signal kills the process tree and rejects. A throwing onOutput becomes mod_error {hook: 'on:process'} and the process keeps running. Output is not buffered for you: keep what you need from the chunks.
  • 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).
  • cmd.state - values the host holds for the session, namespaced per mod. They survive a hot reload (module variables start over), and set repaints every widget and pane, so a drawing that reads state is always current. subscribe(key, listener) hears this mod's writes to that key.
  • cmd.store - values kept across sessions, one JSON file per mod. get is synchronous (the file is read before the factory runs); set and delete write through.
  • cmd.clock.every(ms, fn) / after(ms, fn) - timers owned by the mod: cancelled by their Disposable, and all of them when the mod reloads. They never hold the process open, and a throwing callback becomes mod_error while the timer keeps its schedule.
  • cmd.model.complete({prompt}) - one text completion on the session's model client, with no transcript and no tools, billed and attributed like the session. It always resolves: {isAnswered: true, text, model} or {isAnswered: false, reason: 'unavailable' | 'error' | 'aborted'} (unavailable before a session binds).
  • cmd.prompt.submit(text) - submit a prompt as if typed: in the TUI it starts a turn once the session is idle and queues behind a running one. Headless, it rides the follow-up queue.
  • cmd.ui.copy(text) - write text to the system clipboard with the same helper /copy uses. See Clipboard and sound.
  • cmd.audio.play({file}) / cmd.audio.bell() - play a sound file shipped with the mod, or ring the terminal bell. See Clipboard and sound.

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

  • beforeToolCall returns {block?, input?, additionalContext?, terminate?, result?}. result answers the call without running the tool.
  • beforeModelRequest returns {model?} for one request.
  • transformSystemPrompt returns the full system prompt to send.
  • transformMessage returns the message the transcript keeps.
  • transformTextDelta returns the answer chunk to show (synchronously), or '' to drop or hold it back.
  • onPermissionRequest returns 'allow' | 'deny' | undefined.
  • 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.
  • onPermissionRequest({toolName, input, description, isSafetyPrompt}) answers a permission prompt before the person sees it. It fires only where the engine would ask a human, so deny rules, plan mode, and dont-ask have already decided everything they cover. Every hook is asked, and deny wins: any 'deny' answers the prompt, and 'allow' holds only when no hook denies, so a mod that blocks something cannot be overridden by another that allows it. A throwing hook is skipped (mod_error). On a safety-forced prompt (isSafetyPrompt: a sensitive write, a risky command) 'deny' works but 'allow' is ignored and the person is asked.
  • 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' | 'reload', reason: 'shutdown' | 'replaced' | 'reload'). reload is this mod's own hot reload, in the same session: the old code ends with it and the new code starts with it. 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.

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.

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 ◆ beforeModelRequest (each mod; later wins) → the model for THIS request; the deferred-tools prompt and tool list below are built for it 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) ◆ transformSystemPrompt (each mod; the FULL prompt threaded mod→mod) ◆ 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) ◆ transformTextDelta on every streamed text chunk (each mod, threaded; sync), BEFORE its → text_delta; a retry starts the chunk log over → model_request_end {model, usage, stopReason} ◆ transformTextDelta {delta: '', done: true} (flush; output → text_delta) assistant text := the rewritten chunks, joined, per text block → message_end {content} (the rewritten content) ◆ transformMessage on every message before it is committed (user input, assistant reply, tool results, steering, follow-ups) 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}

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 result ⇒ the tool does NOT run; the answer is its (successful) output → 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})

  • 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.

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".

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.

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). effort is tri-state: a ThinkingEffort sets it, null resets it to the incoming model's own default, and omitting the field leaves the current effort unchanged. Send null (not an omitted field) when swapping to a model that should run at its own default - otherwise the run keeps the outgoing model's effort.

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.

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.

  • result - answer the call WITHOUT running the tool: this content is the tool's successful output (unlike block, which is an error). Later mods' beforeToolCall for this call do not run; afterToolCall still does, so a post-hook sees the answer like any real result. block wins when both are set.

Fires once per model request, at the start of the round, before the round's prompt, context, and tools are built. The tool list and the deferred-tools prompt are then built for the model the request goes to, so a model that cannot load deferred tools gets every tool inline, the same as picking it with /model. A returned model sends this request only to that model; the next request goes back to the run's model (use prepareNextTurn to switch for the rest of the run). Later mods win. A swapped request drops the run's explicit BYOK route, which belongs to the run's model. model_request_start / model_request_end report the model the request actually went to.

The other hooks of the round have not run yet, so a value one of them stores is from the previous round. To route on context size, measure state.messages inside beforeModelRequest itself.

Fires once per round, after appendSystemPrompt: receives the round's full system prompt (base + every append) and returns the prompt to send, so a mod can replace, edit, or drop parts of it. Threaded mod to mod. Returning the input unchanged keeps the structured, cache-friendly sections; any change sends one flat string, so keep the result byte-stable across rounds or the prompt-prefix cache misses every turn.

Fires for every message the loop commits: the person's input, each model reply, each tool-result batch, steering and follow-up messages. The returned message is what the transcript stores and what every later request sends, which makes it the place for secret redaction and PII masking. Threaded mod to mod. The role and every tool_use / tool_result id must survive: a rewrite that changes them is rejected with mod_error {hook: 'transformMessage'} and the previous message is kept. Live streaming events (text_delta, message_end) still show the model's raw output; the transcript holds the rewrite. To change what is shown as well, use transformTextDelta.

Fires for every chunk of the model's answer text as it streams, before the text_delta event for it goes out. Return the chunk to show (rewritten or as is), or '' to drop it. Threaded mod to mod; a dropped chunk stops there, so later mods never see it. What the feed shows and what the transcript keeps are the same text: the committed assistant message (and message_end, and the live message_update) carries exactly the rewritten chunks, joined, each in the text block it streamed into. Thinking and tool calls are untouched.

  • Synchronous and fast. It runs inside the model stream. A hook that throws, or returns a promise or a non-string, is skipped for that chunk with mod_error {hook: 'transformTextDelta'}, and the chunk passes on as the previous mod handed it.
  • The final flush. When the response is complete, every mod is called once more with {delta: '', done: true}. Return anything you held back (the start of a key split across two chunks) and it streams as one last text_delta and lands at the end of the last text block.
  • Retries. attempt counts this request's attempts from 0. A mid-stream retry throws the abandoned attempt's chunks away (the feed marks the seam with stream_restart), so a mod that keeps state across chunks should start over when turnNumber or attempt changes.
  • Non-streaming clients. A response that arrived with no text deltas still goes through the hook, one chunk per text block, with no text_delta events invented for it.
  • Runs before transformMessage, which still sees the (rewritten) assistant message on commit.

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.

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.

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).

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.'}; }, };

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.

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).

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 rewrote tool input pre-execution (fires whether or not the call then ran)rulesFired, hintCount, receivedKeys, repaired
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

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 data, never components: a mod returns strings or a small element tree, the TUI and the desktop app draw it, headless runs degrade to deterministic defaults, and a crashing render becomes a one-line warning - never a broken screen.

  • cmd.ui.notify(message, level?) - a notice feed row, styled by level: 'info' (default) is a dim bullet, 'warning' an amber ⚠, 'error' a red ⚠. Rendered during a turn (the per-turn feed projector); a notice raised at factory time flushes once the session binds.
  • cmd.ui.toast(text, {level?, durationMs?}) - a short-lived line above the prompt that clears itself (default 4s). level is info, success, warning, or error. Where nothing draws toasts (headless), it falls back to a notice row so the message is never lost.
  • 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.
  • cmd.ui.setTitle(text | null) - the terminal tab title. The text replaces the session title on the tab (and survives the host's own title re-assertions) until null hands the tab back. Headless: no-op.

  • cmd.ui.copy(text) - write text to the system clipboard through the same helper /copy uses. It never throws: {isCopied: true}, or {isCopied: false, reason} where reason is headless (no interactive host), no-clipboard (the machine's clipboard is unreachable, for example over SSH without X11), or error.
  • cmd.audio.play({file, timeoutMs?}) - play a sound file with the platform player: afplay on macOS, paplay then aplay on Linux, PowerShell Media.SoundPlayer on Windows. A relative file resolves against the directory of the mod's own file, so ship the sound next to it; an absolute path works too. It runs through the runtime shell and is killed after timeoutMs (default 10s). It never throws: {isPlayed: true}, or {isPlayed: false, reason} where reason is headless, not-found, no-player, timeout, or error.
  • cmd.audio.bell() - ring the terminal bell. Returns {isPlayed: true}, or {isPlayed: false, reason: 'headless'}.
  • Where they work today. In the desktop app, copy, bell, and play all answer headless for now. Over SSH without a display, copy answers no-clipboard. On Windows, and on Linux with only ALSA (aplay, no PulseAudio or PipeWire), play handles WAV files only. Ship a .wav if the mod should sound everywhere.
  • Bad arguments never throw either. A file that is not a string answers error. A timeoutMs that is not a positive whole number falls back to the 10s default. A hot reload stops any sound the old code started.

Sound is a side effect, so do not await it in a hook unless the turn should wait for it:

cmd.hooks({ onStop: () => { void cmd.audio.play({file: './done.wav'}); return undefined; }, }); cmd.addCommand({ name: 'copy-branch', handler: async ({exec, ui}) => { const {stdout} = await exec({command: 'git', args: ['branch', '--show-current']}); const copied = await ui.copy(stdout.trim()); return {message: copied.isCopied ? 'Copied.' : `Not copied: ${copied.reason}`}; }, });

cmd.ui.setStatus(text | null) - a persistent per-mod segment on the TUI's status row (under the input). One segment per mod: a new call replaces the text, null (or whitespace-only text) clears it, and the returned Disposable clears it too - but only while it still owns the segment, so a stale handle disposing late can't wipe a newer status. Segments from multiple mods render after the row's built-in items in load order, and a call from async code repaints on its own. Style text with ansi colors (SGR) and OSC 8 links; every other escape - cursor moves, screen clears, OSC titles and clipboard writes - is stripped, and newlines and tabs become spaces. Mod segments share the user's one status line, after the built-ins and before the right-pinned phase model; the line is cut with … at the terminal edge, so trailing segments can be cut off. /statusline off hides the whole row (cmd.ui.capabilities.status stays true). Headless: stored, rendered nowhere. For a complete example built by asking Command Code, see Add your own items with mods.

cmd.hooks({ onSessionStart: () => cmd.ui.setStatus('#channel ✓ connected'), onSessionEnd: () => cmd.ui.setStatus(null), });

cmd.ui.capabilities.status / cmd.ui.capabilities.surface - whether this host renders footer segments, and widgets / panes / toasts (true in the interactive TUI and the desktop app, false headless). Use them to pick a surface instead of shipping a permanent fallback:

if (cmd.ui.capabilities.status) cmd.ui.setStatus(text); else cmd.ui.notify(text);

A widget's or pane's render({width}) returns a list of nodes. A node is a plain string (one line) or one of:

ElementShapeDraws
text{type: 'text', text, color?, bold?, dim?}styled text; color is red green yellow blue magenta cyan white gray
row{type: 'row', children}children on one line, space-separated
box{type: 'box', title?, children}an optional bold title, then each child on its own line
button{type: 'button', label, hotkey?, key?, onPress}[ r: Reset ]; hotkey is one letter or digit, key is its identity (defaults to the label)
progress{type: 'progress', value, width?, label?}label ██████░░░░ 60%, value 0..1
divider{type: 'divider'}a rule across width
input{type: 'input', key, placeholder?, value?, onSubmit(text)}a one-line text field [ value ]; editable only while its pane has focus, Enter submits
select{type: 'select', key, options: [{label, value}], value?, onSelect(value)}○ Small ◉ Medium ○ Large with value marked; while focused, ←/→ move the highlight and Enter picks
markdown{type: 'markdown', text}markdown drawn the way the feed draws replies (headings, lists, code, tables); a host without a markdown renderer falls back to plain lines

width is the columns the surface draws into, so a mod can size its own layout. Mods still own their styling: a plain string is printed as written, ansi colors and styles included. Other escape sequences (cursor moves, screen clears) and control bytes other than newline and tab are dropped from all mod text, so a mod can never take over the terminal.

input and select need a key (their identity for focus and handlers); a button's key defaults to its label. Their handlers run in the mod like a hook: a throw becomes mod_error {hook: 'ui.submit'} or {hook: 'ui.select'}, and the surface repaints after every submit or pick. Keep what they set in cmd.state and read it back as value, so the field shows it after a repaint or a hot reload. Images and raster graphics are not elements: terminal graphics protocols (kitty, iTerm2, sixel) differ per terminal and do not survive the TUI's line-based redraw.

cmd.ui.widget({placement: 'above-editor' | 'below-editor', render}) - lines drawn around the input panel; above-editor is the band right above the prompt, below-editor sits under it with the status row. render re-runs on every repaint; call cmd.ui.refreshWidgets() after the data it reads changed (or keep that data in cmd.state, whose set repaints for you). A throwing render shows a one-line warning and reports a mod_error so siblings keep drawing. The returned Disposable removes the widget. Headless: stored, drawn nowhere.

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

cmd.ui.pane({id, title, render}) - a titled, bordered panel above the prompt that the person opens and closes. Registering does not open it: cmd.ui.openPane(id) does (usually from a command), cmd.ui.closePane(id) closes it, and so do /mods open <pane> / /mods close <pane>. While a pane is open, Alt+<hotkey> presses its buttons (the pane's last line lists them) and Alt+0 closes the newest one; typing still goes to the prompt. Hotkeys the prompt already owns (p, v, w, u, m) are drawn but never pressed by the chord. A pane's id is <mod>/<id>, so two mods can both have a stats pane; whether it is open survives a hot reload.

Docked on wide terminals. From 140 columns up, open panes draw in a column to the right of the prompt (about 40% of the width, 40 to 72 columns) instead of stacking above it, and the prompt narrows to fit beside them. The feed above is scrollback, so panes dock beside the prompt, not beside the feed. Below 140 columns they stack above the prompt.

The prompt keeps the keyboard until a pane takes it, so typing never reaches a pane by accident. Alt+↑ moves focus into the newest open pane (its border lights up and the prompt steps aside, keeping what you typed), and cmd.ui.openPane(id, {focus: true}) opens a pane with focus, like a dialog. While a pane has focus:

KeyDoes
Tab / ↓next button, input, or select (wraps)
Shift+Tab / ↑previous one
Enterpress the button, submit the input, or pick the select's highlighted option
←/→move a select's highlight
typing, Backspaceedit the focused input (it starts from its value)
Escgive the keyboard back to the prompt. The pane stays open, and what you typed in it is still there when it takes focus again.
Alt+0close the newest pane (focus returns to the prompt)

Ctrl+C still reaches the prompt's interrupt and exit keys while a pane has focus. Closing the focused pane, or a dialog taking the screen, hands the keyboard back too.

cmd.ui.pane({ id: 'standup', title: 'Standup', render: () => [ {type: 'markdown', text: '**Today**: one line, then pick how it is going.'}, { type: 'input', key: 'note', placeholder: 'what are you working on?', value: cmd.state.get<string>('note') ?? '', onSubmit: text => cmd.state.set('note', text), }, { type: 'select', key: 'mood', options: [ {label: 'Great', value: 'great'}, {label: 'Blocked', value: 'blocked'}, ], value: cmd.state.get<string>('mood'), onSelect: value => cmd.state.set('mood', value), }, ], }); cmd.addCommand({ name: 'standup', handler: () => cmd.ui.openPane('standup', {focus: true}), });
cmd.ui.pane({ id: 'calls', title: 'Tool calls', render: () => [ {type: 'progress', label: 'budget', value: calls() / 50}, { type: 'button', label: 'Reset', hotkey: 'r', onPress: () => cmd.state.set('calls', 0), }, ], }); cmd.addCommand({name: 'calls', handler: () => cmd.ui.openPane('calls')});

A button's onPress runs in the mod like a hook: a throw becomes mod_error {hook: 'ui.press'}, and the surface repaints after every press.

  • 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.

cmd.ui.override(component, render) - redraw a row Command Code draws itself, with the same elements widgets use. render(props, next) gets the row's data and returns nodes, or undefined to keep the built-in row. The returned Disposable removes the override, and a hot reload drops it with every other registration.

ComponentpropsWhat the nodes do
spinner{status, elapsedMs, tokens}appended after the working status line, on the same line
tool_row{toolName, displayName, input, status, result?}drawn instead of the tool's row; status is pending, success, or error, result is set once it finished
user_message{text}drawn instead of the person's message
command_output{message, details?, isError}drawn instead of a slash command's result row
// "tool calls: 3" beside the spinner. cmd.ui.override('spinner', (_props, next) => [ {type: 'text', text: `tool calls: ${calls()}`, color: 'cyan'}, ...(next() ?? []), ]); // Shell rows as one line; every other tool keeps its built-in row. cmd.ui.override('tool_row', ({toolName, input}) => toolName === 'shell_command' ? [`$ ${input}`] : undefined, );

How overrides compose and fail:

  • Load order, first answer wins. Several mods can override the same component. The first mod to return nodes wins; undefined passes to the next mod, and past the last one to the built-in row.
  • next() is the rest of the chain. It returns what the mods loaded after this one drew, or undefined when the built-in row would draw. Wrap it to add to another mod's drawing instead of replacing it ([...mine, ...(next() ?? [])]). It runs the later mods once, however often you call it.
  • A broken override falls back. An override that throws, returns something other than an array or undefined (a string, or the Promise of an async render), or draws a malformed tree is skipped as if it returned undefined. It reports one mod_error {hook: 'ui.override:<component>'} until the mod registers it again, so a broken spinner override does not report on every frame.
  • Rows redraw with the row. The spinner redraws about 12 times a second; a tool row redraws while it is live and is final once it scrolls into the transcript. An override applies to rows drawn after it registered.
  • Tool and command rows are a trust surface. A tool_row or command_output override changes what the transcript shows for a tool call or a command. Install only mods you trust to show those rows honestly.
  • Never the permission prompt. Only the four components above are overridable; any other name throws at registration.

The desktop app draws the same surface around the chat composer. Mod code still runs only in the app's main process: it renders each widget and pane there at a fixed 80 columns and sends the window plain lines and button ids, never code.

  • Widgets: above-editor sits right above the composer, below-editor right under it, with the status row below that.
  • Panes: an open pane is a card above the composer, titled, with a close button. Buttons are clickable where the mod drew them ([ r: Reset ]); Alt+hotkey chords are a TUI thing.
  • Toasts: the app's own toasts, titled with the mod's name.
  • Styling: ansi bold, dim, and the eight element colors map to the app theme; other escape codes (cursor moves, links, 256-color) are dropped.

SurfaceInteractive TUIDesktop appHeadless (-p)
notifynotice feed rownotice feed rowprinted notice
toastline above the prompt, clears itselfapp toast, clears itselfnotice row
confirmmodalquestion in the composerresolves false
select / inputmodalquestion in the composerresolves undefined
setStatusfooter segment under the inputstatus row under the composerstored, rendered nowhere
widgetband above / below the promptband above / below the composerstored, drawn nowhere
panebordered panel above the prompt (docked right from 140 columns), Alt+key buttons, Alt+↑ focuscard above the composer, clickable buttonsstored, drawn nowhere
overrideredraws the spinner / tool / message / command rowsnot drawnstored, drawn nowhere
setTitleterminal tab titleno-opno-op
showEntryrendered feed rowdroppeddropped
addShortcutdispatched after host bindingsnever firesnever fires
sendUserMessagereal user turn (queued while busy)falls back to queueMessagefalls back to queueMessage
prompt.submitstarts a turn when idle, queues when busyfollow-up queuefollow-up queue
shutdownexits after the turn settlesno-opno-op
ui.copysystem clipboard{isCopied: false, reason: 'headless'}{isCopied: false, reason: 'headless'}
audio.play / audio.bellplatform player / terminal bellno sound, reason: 'headless'no sound, reason: 'headless'

TUI only for now. cmd.ui.override, cmd.ui.setTitle, cmd.ui.copy, cmd.audio, cmd.addShortcut, cmd.shutdown, pane focus, and typing into input or picking in select elements are handled by the interactive TUI only. The desktop app draws an input or select element as plain text it cannot focus, and a markdown element as styled lines.

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.

cmd mods add cmd-mod-hi # bare name = npm package cmd mods add @team/review-mod@1.2.0 # scoped npm name, pinned (npm: prefix optional) 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 # alias: cmd mods ls cmd mods update # reinstall missing, reconcile pinned refs (alias: up) cmd mods remove owner/repo # alias: cmd mods rm cmd mods remove fun-stuff # by the name `mods list` prints cmd mods enable <name> # enable a mod; -g for user level cmd mods disable <name> # disable a mod; -g for user level cmd mods open # open the mods dir (project); --user for ~ cmd mods inspect ./some-mod # what it hooks, registers, and reaches (never runs it)

cmd mods list prints one line per mod - name, scope, origin - project mods first, then user, then package-provided:

Mods (2) guard · project · .commandcode/mods/guard.ts cmd-mod-hi · user · from npm:cmd-mod-hi

The origin is the file for a drop-in mod and from <source> for a package mod (whose file lives under .registry/, which you never edit by hand). Built-in mods are Command Code's own internals and are never listed. Configured package sources stay out of the output entirely unless one needs attention - not installed, or installed but contributing no mods.

remove (rm) takes either a source or the mod NAME cmd mods list prints, and removes whatever backs that name: a drop-in file or directory is deleted from the mods dir, a package-provided mod removes its source (and says which sibling mods went with it), a mods.paths mod drops its settings entry. Scope follows where the mod actually lives, so a user-scope mod does not need -g; a source that is configured in neither scope reports that instead of a silent success.

cmd mods open opens the drop-in mods directory - <project>/.commandcode/mods by default, ~/.commandcode/mods with --user (-g) - creating it if it does not exist yet. --path prints the directory instead of opening it.

A source with no slash is an npm package name, and so is a @scope/name - git shorthand always carries an owner/repo slash, so npm: is optional (an explicit git: prefix always wins). 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.

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", "description": "Blocks risky diffs and adds a /review command.", "keywords": ["cmd-mod", "cmd-mod-tool", "cmd-mod-command"], "repository": "https://github.com/team/review-mod", "commandcode": { "mods": ["./src/review.ts", "src/checks/*.ts"], "skills": ["./skills"] } }

Include the cmd-mod keyword so maintainers can find the package during review. Kind keywords such as cmd-mod-tool and cmd-mod-hook help describe it, but the hub's categories and summary come from its reviewed registry entry. Publishing to npm does not list a package on the mods hub.

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

commandcode.skills lists skill directories the package ships beside its mods (each entry is one skill directory or a directory of skills, relative to the package root; entries escaping the root are dropped). They join skill discovery for every session the package loads in, so one cmd mods add brings the mods and the skills that go with them. A mod can contribute the same thing from code with cmd.addSkillsPath.

After publishing, submit an entry to the public registry for review. Listed npm packages get live monthly downloads, latest version, and publish date from npm. A GitHub repo in the registry entry adds its star count.

The hub lists the reviewed entries in mods/registry.json in the public CommandCodeAI/command-code repo. Open a PR against that file for either an npm or a GitHub mod. Maintainers review npm candidates before adding them to the list. The hub automatically fetches GitHub entries and approved npm entries from this file within about 15 minutes of a merge, without a website deployment.

{ "version": 1, "hidden": [], "mods": [ { "name": "review-guard", "source": "owner/review-guard", "description": "Blocks risky diffs and adds a /review command.", "author": {"name": "Ada", "github": "ada"}, "repo": "https://github.com/owner/review-guard", "kinds": ["tool", "command"], "tags": ["safety", "review"], "official": false } ] }

name, source (what cmd mods add <source> takes), description (≤240 chars), author.name, and at least one kinds value (tool/command/hook/provider/renderer/flag) are required; repo, npm, tags, official, and addedAt are optional. A GitHub repo adds a live star count, npm names the published package for live metadata and download counts, official marks a team-maintained mod, and a hidden name (a mod or an npm package) keeps spam or impersonation off the hub. The public schema is mods/registry.schema.json.

The reviewed list, with live npm metadata and GitHub star counts, is served at commandcode.ai/mods.json, CORS-open, so the CLI or any script can read the same list the hub renders.

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.

cmd mods disable <name> disables a mod without removing it, and cmd mods enable <name> enables it again. Both write the project level (.commandcode/settings.json) by default, or the user level (~/.commandcode/settings.json) with -g flag, and take effect on the next session or /reload:

cmd mods disable review-guard # disable in this project cmd mods enable review-guard -g # enable at the user level

They work for discovered files, installed packages, and the disable-able built-ins (provider-copilot, update-notice, titling, learning etc). The cmd mods disable command adds the name to mods.disabled; cmd mods enable removes it from there and, for an opt-in built-in such as todos, adds it to mods.enabled:

{"mods": {"disabled": ["review-guard"], "enabled": ["todos"]}}

Disabling a mod anywhere will keep a mod disabled. Command Code reads the user, project, and project-local (.commandcode/settings.local.json) settings file, and a mods.disabled entry in any of them keeps the mod off, even when another file enables it. So when another settings file still disables the mod you enable, the command does not report success: it names that file, gives the one fix, and exits 1 so a script's && chain stops:

$ cmd mods enable review-guard ⚠ review-guard is still disabled Your user settings (~/.commandcode/settings.json) disable it. To enable it at the user level, run: cmd mods enable review-guard -g

A disable in settings.local.json has to be removed by hand; no command writes to that file.

A project you have not used yet: Project settings are read only once Command Code has run in that project, the same gate project mods follow. Enabling or disabling there still saves the setting, which applies once you start Command Code in the project; use -g to apply it at the user level right away. cmd mods list --builtin marks a built-in whose state changes then, for example enabled · disabled once you start Command Code here.

  • There is no sandbox - a mod is arbitrary code that runs with your permissions; install packages you trust.
  • Inspect before you trust - cmd mods inspect <path> reads a mod's source (it never imports it) and lists the hooks it registers, what it adds, and what it can reach: processes, the network, the filesystem, the environment, permission answers, transcript rewrites. It is a lexical scan, so code a mod imports or names it builds at runtime can reach further, and the report says so.
$ cmd mods inspect ./permission-policy.ts files: 1 hooks: onPermissionRequest registers: none reaches: answers permission prompts (onPermissionRequest) imports: none
  • Kill switches - cmd --safe-mode loads built-in mods only for one session (no installed mods, no --mod); {"mods": {"disableAll": true}} in any settings file does the same for every session until removed.
  • 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.

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.

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).

  • 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 - cmd.ui.setStatus('text') must show up as a segment on the row under the input, replace itself on the next call, and disappear on setStatus(null).
  • Widget - an above-editor widget draws right above the prompt, a below-editor one under it; cmd.ui.refreshWidgets() (or a cmd.state.set) repaints it.
  • Pane - open it (/mods open <pane>, or the mod's own command): it draws above the prompt with its buttons' Alt+keys on its last line; Alt+<key> presses one, Alt+0 closes it. For inputs and selects, Alt+↑ focuses it: Tab through the fields, type, press Enter, and check the pane redraws with the new value.
  • Toast - cmd.ui.toast('text') shows above the prompt and clears itself.
  • Override - send a prompt that runs a tool: a spinner override shows beside the working line, a tool_row override replaces that tool's row. If a row still looks built-in, the override returned undefined or threw (look for mod_error with ui.override: in its hook).
  • Permission answers - with an onPermissionRequest hook, trigger a call that would prompt: an answered prompt never appears. cmd mods inspect lists the hook so reviewers see it too.
  • Renderer - cmd.showEntry rows render styled; an unregistered type pretty-prints as JSON.

Run rm -rf /tmp/scratch-dir

Expected result:

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

In the interactive TUI, saving a mod's file reloads it in place, with no restart: the feed says [mods] ✓ reloaded <name> (or why it did not). The old code gets onSessionEnd with reason: 'reload', then every registration it made is disposed, except tools added with addTool. Those keep the old code until the next session. cmd.state survives, and onSessionStart fires again with source: 'reload'. A save that throws while loading leaves the old code running. /mods reload [name] reloads on demand. Two things need more than a reload: tools (the tool set is fixed per session, so start a new session or /reload) and new mod files (/reload re-discovers). COMMANDCODE_MODS_HOT_RELOAD=0 turns the watcher off.

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.

Include cmd-mod in your package's keywords so maintainers can discover it. Before publishing, check package.json for the correct mod entrypoints, description, and repository. Publish with pnpm publish, then open a PR adding an entry to the public mods/registry.json. Set source and npm to the full package name for an npm mod. Publishing or adding a keyword alone does not list it on the hub.

Ask Command Code to build the mod you want and it starts from a bundled example rather than a blank file - see Runnable examples for the list. Each one loads through the real mod loader in CI on every test run, so what you start from is proven to load.