Mods
Experimental. The
ModApiis 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:
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:
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:
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:
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:
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.
| Location | Scope | Notes |
|---|---|---|
| built-in | shipped | compiled-in first-party mods (providers, titling, …); registered first |
~/.commandcode/mods/*.ts | user | loose files, one mod each |
~/.commandcode/mods/<dir>/ | user | package.json manifest → mods/ dir → index.ts |
<project>/.commandcode/mods/… | project | same layout; loads only once the workspace is trusted |
settings mods.paths | user/project | explicit files/dirs, relative to the settings scope |
settings mods.sources | user/project | installed packages (see Packaging and install) |
--mod <path> | session | repeatable; 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:
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). Multiplehooks()calls compose in registration order.cmd.on(event, ...)only observes - it cannot block or rewrite. Handlers are isolated (a throw becomes amod_errorevent, 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:
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:
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:
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:
| File | Shows |
|---|---|
slash-command.ts | addCommand - a /command returning {prompt} or {message} |
custom-tool.ts | addTool - a model-callable tool with run + exec |
block-dangerous-commands.ts | hooks.beforeToolCall - block/allow with a confirm |
input-shortcuts.ts | hooks.transformInput - rewrite / handle typed input |
observe-events.ts | on(event) + the cross-mod events bus |
custom-entry-renderer.ts | addRenderer + showEntry - styled feed rows |
flags-and-options.ts | addFlag / getFlag + --mod-option |
lifecycle-hooks.ts | hooks.onStop (Stop) + onSessionStart/onSessionEnd + afterToolCall isError + on('subagent_start'/'subagent_stop') |
pane-and-buttons.ts | ui.pane + ui.widget + buttons + cmd.state + ui.toast |
pane-form.ts | a focused form pane: input + select + markdown elements, openPane(id, {focus: true}) |
spinner-count.ts | ui.override - a tool-call count beside the spinner, compact shell rows |
redact-secrets.ts | hooks.transformMessage - mask keys before the transcript keeps them |
permission-policy.ts | hooks.onPermissionRequest - answer prompts with a team policy |
model-router.ts | hooks.beforeModelRequest + cmd.model.complete |
stream-filter.ts | hooks.transformTextDelta - redact keys as the answer streams + cmd.process.spawn |
kitchen-sink.ts | every capability in one annotated file |
- Hooks mutate,
onobserves. Event handlers cannot block tools or rewrite context; that is whatcmd.hooksis 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
--modmods 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
--modmods only, with the ui bridge degraded to headless defaults (confirm → false, select/input → undefined - never auto-approved;setStatus/widgetrender nowhere, andcmd.ui.capabilities.statusis false). Project mods stay out of headless runs because print never shows a trust prompt; pass--dangerously-skip-permissionsto 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
onSessionEndwithreason: 'reload'and every registration it made is disposed, except tools added withaddTool, which keep the old code until the next session.cmd.stateis kept, and the new code'sonSessionStartfires withsource: '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=0turns 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.overridecovers 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.
onPermissionRequestonly 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.sessionscovers 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:
- ModApi reference: every field, registration verb, and live method.
- Hooks and events: the mutating lifecycle, the
AgentEventcatalog. - UI surface: dialogs, footer status, editor widgets, custom feed rendering.
- Packaging and install:
cmd mods add, manifests, filtering, scopes. - Verify a mod: load it, list it, test it, ship it.
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.
| Member | Type | Notes |
|---|---|---|
cmd.name | string | the mod's name (from filename / manifest) |
cmd.cwd | string | workspace root |
cmd.session | ModSessionApi | undefined | persistence seam; undefined until bound |
cmd.events | ModEventBus | emit(channel, data?) / on(channel, handler) cross-mod bus |
cmd.ui | ModUi | notify / confirm / select / input / setStatus / widget / refreshWidgets / pane / openPane / closePane / toast / copy / override / setTitle / capabilities |
cmd.sessions | ModSessionControls | compact / tree / leafId / navigateTree / setLabel |
cmd.state | ModStateApi | get / set / subscribe - session values that survive a hot reload |
cmd.store | ModStoreApi | get / set / delete - values kept across sessions (~/.commandcode/mods/.store/<mod>.json) |
cmd.clock | ModClockApi | every / after - timers cancelled with the mod |
cmd.model | ModModelApi | complete - one prompt on the session's model client |
cmd.prompt | ModPromptApi | submit - start a turn of its own once the session is idle |
cmd.process | ModProcessApi | spawn - run a process and stream its output as it arrives |
cmd.audio | ModAudioApi | play / bell - sound files shipped with the mod, and the terminal bell |
| Method | Signature |
|---|---|
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: theAgentModsurface (transformContext, beforeToolCall, afterToolCall, onTurnStart/End, appendSystemPrompt, shouldStopAfterTurn, prepareNextTurn, onRunEnd, onStop) plustransformInput,onSessionStart, andonSessionEnd. 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)- aToolModulethe model can call; same collision policy asAgentMod.tools(existing names win, collision emitsmod_error). The tool'srunreceives{input, runtime, signal}and returns{ok: true, content: [{type: 'text', text}]}or{ok: false, error}. MarkreadOnly: truewhen the tool never mutates (stays available in plan mode).cmd.addCommand({name, description, handler})- a/nameslash 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=valueCLI flags (defaults apply otherwise).cmd.on(event, handler)- observe anyAgentEventplus the host lifecycle eventssession_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 amod_error {hook: 'on:<event>'}event, never a crash.cmd.addProvider(providerModule)- register a model provider through the sameProviderModuleseam 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/tuihelpers, or raw codes). First registration per type wins across mods.cmd.addAgent(definition)- a sub-agent type the model can delegate to through thetasktool, next to the bundled and on-disk agents.toolsdefaults 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.useanswersundefineduntil 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.keysis amodifier+keychord 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 aCtrl+Shiftchord 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, soctrl+shift+upis fine).ctrl+m,ctrl+i,ctrl+handctrl+[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 likectrl+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;Escstays host-owned. First registration per chord wins across mods;/hotkeyslists 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./skillsit ships. Collected at factory time. A package can declare the same thing without code through its manifest - see What a package ships.
| Method | Signature |
|---|---|
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(anoticefeed 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})-steerlands after the current tool batch (behind any queued user messages in the same poll),follow-uponly 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, andfollow-upholds it until the run would stop. Headless there is no submit path, so it falls back toqueueMessagewith the samedeliverAs.cmd.getSessionName()/getModel()/getEffort()- the live values behind the setters;undefinedbefore a session binds.cmd.getContextUsage()-{tokens, contextWindow, percent}for the bound session:tokensandpercentarenulluntil 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()-falsewhile an agent run is in flight on the bound harness (run_start…run_end),trueotherwise 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 withdescription,readOnly, andactive(per the mod-managed filter) - the descriptive counterpart ofgetAllTools()'s bare names.cmd.session- theModSessionApipersistence 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 withcmd.session.appendCustomEntrywhen 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?}), andsetLabel({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 ascmd.exec, but every stdout/stderr chunk reachesonOutput(chunk, stream)as the process produces it (streamis'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; abortingsignalkills the process tree and rejects. A throwingonOutputbecomesmod_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), andsetrepaints 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.getis synchronous (the file is read before the factory runs);setanddeletewrite through.cmd.clock.every(ms, fn)/after(ms, fn)- timers owned by the mod: cancelled by theirDisposable, and all of them when the mod reloads. They never hold the process open, and a throwing callback becomesmod_errorwhile 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/copyuses. 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.
beforeToolCallreturns{block?, input?, additionalContext?, terminate?, result?}.resultanswers the call without running the tool.beforeModelRequestreturns{model?}for one request.transformSystemPromptreturns the full system prompt to send.transformMessagereturns the message the transcript keeps.transformTextDeltareturns the answer chunk to show (synchronously), or''to drop or hold it back.onPermissionRequestreturns'allow' | 'deny' | undefined.afterToolCallreceives anisErrorparam (PostToolUse vs PostToolUseFailure) and returns{content?, isError?, additionalContext?, terminate?, modState?}.onStopreceives{state, stopReason, turnNumber, lastAssistantText}and returns{continue?, reason?}.transformInputreturns{action:'continue'} \| {action:'transform', text} \| {action:'handled', message?}.onSessionStart/onSessionEndreceive{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), orundefined/{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 inhooksbecause 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').reloadis 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 capturedcmd; they run fire-and-forget (a throw becomesmod_error), so a session hook never blocks bind/dispose. Pure observation is also available viacmd.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 →.
- 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. beforeToolCallcompletes (for every mod) beforetool_runningis emitted. Itsinputrewrites chain across mods, but thetool_queuedevent 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.afterToolCallcompletes beforetool_completed/tool_erroredis emitted - the terminal event carries the post-hook content, and itsisError(when set) decides which of the two fires, independent of whether execution itself threw.afterToolCall'smodStateis committed right after the batch's tool-result message is appended - BEFOREonTurnEndruns, soonTurnEndsees it.onTurnStartprecedesturn_start;onTurnEndprecedesturn_endandonCommit.onCommitfires once per completed turn with the full serializable state.shouldStopAfterTurnis evaluated after the turn commits, so a stop_hook never loses the turn that triggered it.prepareNextTurnruns only when the loop actually continues (never on a stopping turn).onRunEndis awaited before therun_endevent - 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
transformContextleaves messages unchanged; a failingonTurnStart/onTurnEndkeeps the prior state; a failingshouldStopAfterTurnmeans "don't stop"; failing tool hooks are skipped. Every catch site ALSO emits amod_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 emitstool_hook_blockedwithhookOutput= youradditionalContext(or'Blocked by a pre-tool hook.'), and that same text becomes the tool_result the model sees. Later mods'beforeToolCallfor this call do not run. Notool_running/tool_completed/tool_erroredis emitted for a blocked call. -
additionalContext(withoutblock) - collected across mods and appended as extra text blocks to the tool_result after execution and afterafterToolCalloverrides. -
input- rewrites the input the tool actually executes with. Chained across mods: the next mod'sinputparam 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 withblockon the same return value. -
undefined- no opinion. -
result- answer the call WITHOUT running the tool: this content is the tool's successful output (unlikeblock, which is an error). Later mods'beforeToolCallfor this call do not run;afterToolCallstill does, so a post-hook sees the answer like any real result.blockwins 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 lasttext_deltaand lands at the end of the last text block. - Retries.
attemptcounts this request's attempts from 0. A mid-stream retry throws the abandoned attempt's chunks away (the feed marks the seam withstream_restart), so a mod that keeps state across chunks should start over whenturnNumberorattemptchanges. - Non-streaming clients. A response that arrived with no text deltas still goes through the hook, one chunk per text block, with no
text_deltaevents 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 likeresult. This is the PostToolUse vs PostToolUseFailure distinction: a hook can branch onisErrorto react only to failures. It is the INPUT signal; the returnedisErrorfield below is the OUTPUT override that selects the terminal event.contentreplaces the tool result the model will see (threaded mod→mod).terminate: trueends the run withstopReason: 'terminate'after the whole batch finishes.additionalContext- appended as a SEPARATE text block aftercontent(and after anybeforeToolCalladditionalContext strings).isError- overrides whether the terminal event istool_completedortool_errored, independent of whether execution itself threw.modState- aRecord<string, unknown>merged intostate.modState[mod.id]right after the tool-result message commits - a full replace of that mod's slot, exactly likesetModState. The durable-state channel for tool hooks: neither tool hook can return a wholeAgentStatethe wayonTurnStart/onTurnEndcan.
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
continuewins (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;
onStopis consulted only when the provider declines. - The loop caps consecutive stop-hook continuations at 8 - a hook that always says
continuecan't loop forever. - Distinct from
shouldStopAfterTurn(force EARLY stop) andonRunEnd(observe the stop): onlyonStopcan push a finished run onward.
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?})- acustomtree 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?})- acustom_messagetree entry. Content the model SHOULD see: it is projected as an ordinaryusermessage on the NEXT turn (display: truealso renders it in the TUI with distinct styling;display: falseis context-only). The call returns{entryId, message}- a mod MUST foldmessageonto theAgentStateit hands back from its hook for the model to see it that turn.getCustomEntries({customType})- reads back everycustomentry this mod itself wrote (filtered bycustomType), in file order, over the ACTIVE branch's full entry list. The standard reload pattern: a mod with in-memory state seeds it fromgetCustomEntriesat firstonTurnStart.
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:
- Namespacing is by convention: write only your own
id's slot. - Values must be JSON-serializable -
modStateis 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/onTurnEndreturn the wholeAgentState(typically viasetModState).afterToolCallcan also persist state via itsmodStatereturn field.beforeToolCallhas no state channel; a before-hook that needs to accumulate data stores it in the closure and flushes it viaafterToolCall'smodStateoronTurnEnd.- 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 viatransformContext(orappendCustomMessageEntry).
One sync sink, fan out with createEventBus. Payloads are snapshots - never live references.
| Event | Fires | Payload highlights |
|---|---|---|
run_start / run_end | run boundaries | sessionId / result |
turn_start / turn_end | round boundaries (after onTurnStart / onTurnEnd) | turnNumber; end adds hadToolCalls, this turn's usage |
message_start | before each model call | - |
text_delta, thinking_start/delta/end | streaming | deltas; thinking_end carries full text |
message_update | streaming (ModelClient-accumulated) | whole partial assistant message incl. partial tool JSON |
message_end | response complete | full assistant content |
model_request_start/end | bracket the inference call | model; end adds usage, raw-preferred stopReason |
tool_queued | per call, before permission checks | input - the ORIGINAL input, never the rewritten one |
tool_denied | permission denied | - (batch then aborts) |
tool_hook_blocked | a mod's beforeToolCall blocked | hookOutput - terminal for that call: no tool_running/completed/errored follows |
tool_running | execution begins (after beforeToolCall) | description from permissions.generateDescription |
tool_update | streaming tool progress | partial content |
tool_completed / tool_errored | after afterToolCall | post-hook result / error text; afterToolCall's isError can select which one fires |
tool_hooks | emitted by the user-hooks mod, before the phase's terminal tool event | phase: 'pre'|'post', lines, outcome |
subagent_start / subagent_stop | the agent tool brackets a nested sub-agent run | toolCallId, subagentType; stop adds tokensUsed |
subagent_progress | per child tool call inside a running sub-agent | toolCallId, subagentType, toolName, toolInput, tokensUsed |
api_retry | retry loop, after 3 silent attempts | attempt, error, delayMs |
compaction_start / compaction_done | compaction mod | tokensSaved (done, only when > 0) |
notice | user-facing info/warning | level, message |
skill_loaded | a skill was activated by an explicit user /name invocation | name |
session_titled | the auto-generated session title was persisted | title |
permission_mode_changed | the effective permission mode changed (shift+tab, /plan, enter/exit plan tools) | mode |
config_setting_changed | a setting changed through the config tool | setting, value, previousValue? |
continuation_recovery | a turn was auto-continued (pause/empty/length/intent) | kind, attempt, maxAttempts |
tool_input_coerced | ModelClient rescued malformed (array/null) tool input | rawType, recovered |
tool_input_repaired | repair layer rewrote tool input pre-execution (fires whether or not the call then ran) | rulesFired, hintCount, receivedKeys, repaired |
mod_error | a mod hook threw (any phase), or a mod tool collided with an existing tool | modId, hook, error |
interrupted | abort observed | - |
run_error | non-retryable failure | the 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.
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?)- anoticefeed row, styled bylevel:'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).levelisinfo,success,warning, orerror. 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) untilnullhands the tab back. Headless: no-op.
cmd.ui.copy(text)- writetextto the system clipboard through the same helper/copyuses. It never throws:{isCopied: true}, or{isCopied: false, reason}wherereasonisheadless(no interactive host),no-clipboard(the machine's clipboard is unreachable, for example over SSH without X11), orerror.cmd.audio.play({file, timeoutMs?})- play a sound file with the platform player:afplayon macOS,paplaythenaplayon Linux, PowerShellMedia.SoundPlayeron Windows. A relativefileresolves 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 aftertimeoutMs(default 10s). It never throws:{isPlayed: true}, or{isPlayed: false, reason}wherereasonisheadless,not-found,no-player,timeout, orerror.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
headlessfor now. Over SSH without a display, copy answersno-clipboard. On Windows, and on Linux with only ALSA (aplay, no PulseAudio or PipeWire), play handles WAV files only. Ship a.wavif the mod should sound everywhere. - Bad arguments never throw either. A
filethat is not a string answerserror. AtimeoutMsthat 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.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.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:
A widget's or pane's render({width}) returns a list of nodes. A node is a plain string (one line) or one of:
| Element | Shape | Draws |
|---|---|---|
| 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.
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:
| Key | Does |
|---|---|
| Tab / ↓ | next button, input, or select (wraps) |
| Shift+Tab / ↑ | previous one |
| Enter | press the button, submit the input, or pick the select's highlighted option |
| ←/→ | move a select's highlight |
| typing, Backspace | edit the focused input (it starts from its value) |
| Esc | give 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+0 | close 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.
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/tuihelpers, 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 withcmd.session.appendCustomEntrywhen 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.
| Component | props | What 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 |
How overrides compose and fail:
- Load order, first answer wins. Several mods can override the same component. The first mod to return nodes wins;
undefinedpasses 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, orundefinedwhen 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 anasyncrender), or draws a malformed tree is skipped as if it returnedundefined. It reports onemod_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_roworcommand_outputoverride 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-editorsits right above the composer,below-editorright 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.
| Surface | Interactive TUI | Desktop app | Headless (-p) |
|---|---|---|---|
notify | notice feed row | notice feed row | printed notice |
toast | line above the prompt, clears itself | app toast, clears itself | notice row |
confirm | modal | question in the composer | resolves false |
select / input | modal | question in the composer | resolves undefined |
setStatus | footer segment under the input | status row under the composer | stored, rendered nowhere |
widget | band above / below the prompt | band above / below the composer | stored, drawn nowhere |
pane | bordered panel above the prompt (docked right from 140 columns), Alt+key buttons, Alt+↑ focus | card above the composer, clickable buttons | stored, drawn nowhere |
override | redraws the spinner / tool / message / command rows | not drawn | stored, drawn nowhere |
setTitle | terminal tab title | no-op | no-op |
showEntry | rendered feed row | dropped | dropped |
addShortcut | dispatched after host bindings | never fires | never fires |
sendUserMessage | real user turn (queued while busy) | falls back to queueMessage | falls back to queueMessage |
prompt.submit | starts a turn when idle, queues when busy | follow-up queue | follow-up queue |
shutdown | exits after the turn settles | no-op | no-op |
ui.copy | system clipboard | {isCopied: false, reason: 'headless'} | {isCopied: false, reason: 'headless'} |
audio.play / audio.bell | platform player / terminal bell | no 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 list prints one line per mod - name, scope, origin - project mods first, then user, then package-provided:
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):
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.
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:
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:
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:
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:
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.
- Kill switches -
cmd --safe-modeloads 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
--modmods 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
--modmods only. Project mods stay out of headless runs because print never shows a trust prompt; pass--dangerously-skip-permissionsto 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.
--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.
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
beforeToolCallblocker, ask for the blocked action and watch the block reason land as the tool result). - Input interception - type the pattern
transformInputmatches 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 onsetStatus(null). - Widget - an
above-editorwidget draws right above the prompt, abelow-editorone under it;cmd.ui.refreshWidgets()(or acmd.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+0closes it. For inputs and selects,Alt+↑focuses it: Tab through the fields, type, press Enter, and check the pane redraws with the newvalue. - Toast -
cmd.ui.toast('text')shows above the prompt and clears itself. - Override - send a prompt that runs a tool: a
spinneroverride shows beside the working line, atool_rowoverride replaces that tool's row. If a row still looks built-in, the override returnedundefinedor threw (look formod_errorwithui.override:in its hook). - Permission answers - with an
onPermissionRequesthook, trigger a call that would prompt: an answered prompt never appears.cmd mods inspectlists the hook so reviewers see it too. - Renderer -
cmd.showEntryrows render styled; an unregistered type pretty-prints as JSON.
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_runningfires 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.
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.