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, custom feed rendering, configurable flags, and model providers. Command Code's own built-in features - providers, session titling, the update notice - are built as mods against this very same API. If Command Code can do it, a mod can change it.
A loadable mod IS an AgentMod once loaded; the mod host just builds it from a factory file instead of a code import:
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.
A real one, built by asking
cmd-mod-hi started as exactly that prompt - "roast me when I say hi" - and shipped to npm. Install it and greet your agent:
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:
cmd.hooks({...})is the only place that can change behavior - block a tool (beforeToolCall), rewrite a result (afterToolCall), add to the prompt (appendSystemPrompt), rewrite typed input (transformInput), force a finished run to keep going (onStop), react to session start/end (onSessionStart/onSessionEnd), or run post-turn work (onRunEnd). 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:
Built-in mods always win a name collision against a discovered mod of the same name (the loader shadows the file with a warning), and they are compiled in - never jiti-loaded from a writable path, so they are not supply-chain surface. Structural harness mods (workspace, compaction, checkpoints) are load-bearing for correctness and stay unconditional; only the observer-style built-ins above are disable-able.
Runnable, single-file example mods ship with Command Code inside the bundled mod-builder skill (src/skills/bundled/mod-builder/examples/) and are validated in CI - they load through the real loader on every test run, so they never rot. Ask Command Code to "build a mod" and it reads these.
| 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') |
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). 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 line-based, not component-based.
cmd.addRendererreturns styled text lines the host prints as feed rows; mods do not mount React components into the TUI. That keeps renderers host-agnostic (the same mod renders in any future host) and a crashing renderer degrades to a warning notice, never a broken screen. - Reload is the
/reloadpath. Mods load once per process;/reloadrestarts the process, which re-discovers and re-imports every mod (jiti caches nothing between loads). There is no in-place hot swap. - Session controls stop at the harness surface.
cmd.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})). See packages/harness/src/mod-host/ for the pieces and its __tests__/ for executable examples.
The rest of this page is the complete reference, in order:
- 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.
Fields
| 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 |
cmd.sessions | ModSessionControls | compact / tree / leafId / navigateTree / setLabel |
Registration (factory-time; each returns Disposable)
| 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.on | (event: AgentEventType | 'session_start' | 'session_shutdown', handler) => 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.
Live methods (any time after the harness binds)
| Method | Signature |
|---|---|
cmd.getFlag | (name) => boolean | string | undefined |
cmd.showEntry | (customType, data?) => void |
cmd.queueMessage | ({content, deliverAs?: 'steer' | 'follow-up'}) => void |
cmd.exec | ({command, args?, cwd?, signal?}) => Promise<{stdout, stderr, code}> |
cmd.setSessionName / setModel / setEffort | (value) => void (buffered pre-bind) |
cmd.getAllTools / getActiveTools | () => readonly string[] |
cmd.setActiveTools | (names: readonly string[]) => void |
Details:
cmd.ui-notify(anoticefeed row),confirm/select/input(the Interaction question modal in the TUI; deterministic defaults headless: false/undefined),setStatus,widget,refreshWidgets. The full dialog/status/widget contract is in UI surface.cmd.queueMessage({content, deliverAs})-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).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.getAllTools()/cmd.getActiveTools()/cmd.setActiveTools(names)- a mod-managed tool filter: disabled tools vanish from the model's schemas and refuse execution.cmd.events- a tiny cross-mod pub/sub bus (emit/on).
ModHooks (the mutating lifecycle - all optional, all composable)
transformContext, appendSystemPrompt, beforeToolCall, afterToolCall, onTurnStart, onTurnEnd, shouldStopAfterTurn, prepareNextTurn, onRunEnd, onStop, plus the host-level transformInput, onSessionStart, and onSessionEnd.
beforeToolCallreturns{block?, input?, additionalContext?, terminate?}.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.onSessionStart({source})/onSessionEnd({reason})are the once-per-session SessionStart / SessionEnd hooks - fired when the host binds to / tears down a session (source: 'startup' | 'resume',reason: 'shutdown' | 'replaced'). Act through the mod's 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.
on event types
Any AgentEvent['type'] - including run_start, run_end, turn_start, turn_end, model_request_start, model_request_end, tool_running, tool_completed, tool_errored, subagent_start, subagent_stop, subagent_progress, compaction_start, compaction_done, notice, session_titled, permission_mode_changed, config_setting_changed, mod_error - plus the two host lifecycle events session_start / session_shutdown. (subagent_start/subagent_stop are SubagentStart/Stop; compaction_start/compaction_done are Pre/PostCompact; notice is Notification.) The full payload catalog is in the AgentEvent catalog.
The harness's extension seam. A mod is a plain object of lifecycle hooks that mutates agent state or changes loop decisions. Pure observers (UI, telemetry, loggers) are not mods - they subscribe to the AgentEvent stream. Hooks change behavior; subscribers watch it. In a loadable mod, cmd.hooks({...}) registers the hooks and cmd.on(event, ...) subscribes to events.
A mod may also contribute tools, and every hook receives a ModContext ({emit, signal, cwd, session?}) as an extra last argument, so a mod can raise its own events and persist durable state without extra plumbing.
Lifecycle - where each hook fires
One run() = one user turn → many model turns ("rounds"). The loop below is the agent loop, verbatim in ordering; mod hooks are marked ◆, events →.
Tool-dispatch order
Ordering guarantees mod authors can rely on
- Every hook receives
ModContext({emit, signal, cwd, session?}) as its LAST argument. It's declared optional on every hook signature, so a hook that only declares the params object keeps compiling and running unchanged; the core always supplies a real object at runtime. 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.
The hook contracts
transformContext({messages, state, signal?}, ctx?) → messages
Fires once per round, after appendSystemPrompt, before prepareForSend. Messages are threaded through every mod in order - each receives the previous mod's output. The result is used for this model call only and never written back to state.messages: the durable log is untouched (compaction relies on this - the recorded transcript stays complete). Return the input array unchanged (same reference) to signal "no change".
shouldStopAfterTurn({state, turnNumber}, ctx?) → boolean
Fires after the turn commits. Any mod returning true ends the run with stopReason: 'stop_hook' - first true short-circuits. This is early stop (goal budget spent); it is the opposite of the Stop hook forcing continuation.
prepareNextTurn({state, turnNumber}, ctx?) → {model?, effort?} | undefined
Fires at the bottom of a continuing round. Results are merged across mods - later mods override earlier ones per field. A returned model/effort applies from the next model call onward (config is never mutated).
appendSystemPrompt({state}, ctx?) → string | undefined
Fires once per round, right after the round's base systemPrompt resolves and before transformContext. Every mod's non-empty return is joined with '\n\n', in registration order, and appended AFTER the base prompt. May return a plain string OR a Promise. Must be byte-stable across rounds for the same durable inputs - the provider's prompt-prefix cache keys off the system prompt's bytes, so a value that changes turn-to-turn without a corresponding state/modState change busts the cache every round. Compute once, store in modState, read it back.
beforeToolCall({toolCallId, toolName, input, state}, ctx?) → {block?, additionalContext?, input?, terminate?} | undefined
Fires per tool call, after the permission check passed, before execution.
block: true- the tool does not run. The core 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.
afterToolCall({toolCallId, toolName, input, result, isError, state}, ctx?) → {content?, terminate?, additionalContext?, isError?, modState?} | undefined
Fires per tool call after execution (also after an execution error - result is then the error text content). input is the FINAL input the tool actually ran with (after any beforeToolCall rewrites).
isError(param) - whether the tool's OWN execution failed, read before any hook override and threaded across mods 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.
onTurnStart({state, turnNumber}, ctx?) → AgentState / onTurnEnd({state, turnNumber, hadToolCalls, usage}, ctx?) → AgentState
The only hooks that can persist state changes unconditionally - they return the new AgentState (typically via setModState), which the loop threads onward and commits. onTurnEnd's usage is this turn's token usage. Both fire every round, including the round that stops.
onRunEnd({state, result}, ctx?) → void
Fires once, awaited, after the loop exits and before the run_end event. state is the final state; result carries finalText, stopReason, turnCount, accumulated usage. Cannot alter the result. This is the "must-complete work" hook (learning, flushes).
onStop({state, stopReason, turnNumber, lastAssistantText}, ctx?) → {continue?, reason?} | undefined
The Stop hook - the mods' force-continue channel. Fires when a turn WOULD end the run naturally - the model returned no tool calls and no follow-up provider wants to continue. Returning {continue: true} keeps the run going: reason (or a neutral default) is appended as an automated source: 'stop_hook' user turn, so the model is told why it must keep working.
- ANY mod returning
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.
ModContext.session - the mods' persistence surface
The harness's tree-format session store gives mods a durable, per-entry seam onto the SAME append-only file the transcript lives in. Available in a loadable mod as cmd.session and inside hooks as ctx.session. Two entry kinds:
appendCustomEntry({customType, data?})- 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.
modState conventions
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).
AgentEvent catalog
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 healed tool input pre-execution | rulesFired, hintCount, receivedKeys |
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 |
Worked example - a write-quota mod
Blocks writes outside an allowlist, counts tool activity durably in modState, and reports at run end. Exercises the block channel, the closure-vs-modState split, and onRunEnd.
Wire it as a loadable mod (cmd.hooks({...}) with the same handlers) - or, when embedding the harness, createHarness({..., mods: [createWriteQuotaMod({...})]}).
Everything a mod can put on screen rides cmd.ui, cmd.addRenderer, and cmd.showEntry. All of it is line-based and host-agnostic: the TUI wires real rendering, headless runs degrade to deterministic defaults, and a crashing renderer becomes a warning - never a broken screen.
Notifications and dialogs
cmd.ui.notify(message)- anoticefeed row.cmd.ui.confirm({title, message?})/cmd.ui.select({title, options})/cmd.ui.input({title, placeholder?})- the Interaction question modal in the TUI. Headless, each resolves its deterministic default: confirm →false, select/input →undefined- never auto-approved.
Footer status segments and editor widgets
Not wired into the TUI yet.
cmd.ui.setStatus,cmd.ui.widget, andcmd.ui.refreshWidgetsexist on the surface and are safe to call - they never throw and return the documentedDisposable- but they currently render nowhere (no-op everywhere, interactive and headless alike). The contract below is the intended design; wire-up is pending. Build against it, but don't rely on anything appearing on screen today.
cmd.ui.setStatus(text | null) - a persistent per-mod segment in the TUI footer (under the input panel). One segment per mod: a new call replaces the text, null clears it, the returned Disposable clears it too. Segments from multiple mods concatenate in load order. Headless: renders nowhere (no-op).
cmd.ui.widget({placement: 'above-editor' | 'below-editor', render: () => lines}) - a line-based widget the TUI renders around the input panel. Same verbatim-lines contract as addRenderer (style with ansi); render re-runs on every repaint, and cmd.ui.refreshWidgets() requests a repaint after your data changes. A throwing render is skipped (reported as a mod_error) so siblings keep rendering. Headless: no-op.
Custom feed rendering
cmd.addRenderer(customType, data => lines)- a renderer for a custom entry type; returns the lines to print (style them with ansi escapes - picocolors,@commandcode/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.
Rendering is deliberately line-based, not component-based: mods return styled text lines, never React components. That keeps renderers host-agnostic (the same mod renders in any future host) and a crashing renderer degrades to a warning notice, never a broken screen.
Headless behavior at a glance
| Surface | Interactive TUI | Headless (-p) |
|---|---|---|
notify | notice feed row | printed notice |
confirm | modal | resolves false |
select / input | modal | resolves undefined |
setStatus | no-op (wire-up pending) | no-op |
widget | no-op (wire-up pending) | no-op |
showEntry | rendered feed row | dropped |
A mod starts life as a loose file in ~/.commandcode/mods/. When it should be shared - with a team, or the world - it becomes a package: an npm package, a git repo, or a local directory that cmd mods add installs and Command Code loads on every session.
Install, remove, list, update
Sources persist in the mods.sources settings key (project scope writes .commandcode/settings.json, -g writes ~/.commandcode/settings.json). Identity is version/ref-agnostic - owner/repo@v1 and https://github.com/owner/repo are the same package, and a project entry shadows the same identity at user scope. Installs land in <scope>/.commandcode/mods/.registry/{npm,git}/…; startup never runs npm/git on its own - a configured-but-missing package is a warning pointing at cmd mods update.
What a package ships
A package declares what it ships via package.json - exact paths, directories, or globs (expanded against the package root; entries escaping the root are dropped):
No manifest → the mods/ convention directory → a root index.ts, in that order.
Filtering a source's mods
A mods.sources entry can be the object form to load only part of a package:
Four pattern kinds, applied in precedence order: -path force-exclude (exact, beats everything) → +path force-include (exact, restores what globs dropped) → !glob exclude → plain-glob include (when any includes exist, an entry must match one). Patterns match the entry's package-relative path or its mod name. cmd mods add / remove preserve hand-written object entries - they never flatten your filters.
Disabling without deleting
Works for discovered files, installed packages, and the disable-able built-ins (provider-copilot, update-notice, titling, learning).
Trust and safety
- There is no sandbox - a mod is arbitrary code; install packages you trust.
- Project mods are trust-gated like project skills: they load only after the workspace trust prompt. User-scope and
--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.
1. Load it without installing
--mod is repeatable, loads ahead of installed mods, and wins name collisions - the fastest try-it loop. No build step: jiti compiles the TypeScript at load.
2. Confirm it registered
Your mod must appear, with no load warnings. If it does not appear, the warning printed by cmd mods list says why (import error, no default-export factory, factory threw, duplicate name).
3. Exercise every surface it registers
- Slash command - type
/in the chat input: the command must appear in autocomplete with its description. Run it;{message}renders an info row,{prompt}starts an automated turn. - Tool - ask the model to use it by name ("call count_todos"). The tool call renders in the feed like any built-in.
- Hook - trigger the behavior it guards (for a
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 / widget -
cmd.ui.setStatus/cmd.ui.widget/cmd.ui.refreshWidgetsmust return without throwing and hand back aDisposable. They render nothing today (TUI wire-up pending - see the UI surface note), so don't expect a footer segment or editor widget to appear yet; only verify the calls are safe. - Renderer -
cmd.showEntryrows render styled; an unregistered type pretty-prints as JSON.
Test: block-dangerous-commands guards rm -rf
Expected result:
- ✅ The mod's confirm dialog appears before the shell command runs
- ✅ Declining blocks the tool - the model sees the block reason and adapts
- ❌ No
tool_runningfires for the blocked call
4. Iterate with /reload
Mods load once per process. After editing the file, run /reload - it restarts Command Code, resumes the session, and re-discovers and re-imports every mod (jiti caches nothing between loads).
5. Headless check (CI)
Print mode loads user-scope and --mod mods with the UI bridge degraded to deterministic defaults (confirm → false, select/input → undefined). A mod that must work in CI should behave sensibly under those defaults.
Working from the bundled examples
Command Code ships runnable single-file examples inside the bundled mod-builder skill - each one loads through the real mod loader in CI on every test run, so copying one is copying something proven to load. In the Command Code repo itself, the same validation is a vitest suite (packages/harness/src/mod-host/__tests__/examples.test.ts); if you are contributing an example, that test must stay green.