Certified Agent Harness Engineer
Course sections

M7.6

Slash commands and user-facing extension points

795 words · about 5 min

Concept

Extension points so far serve the model. But some capabilities belong to the human: switching a mode, showing status, clearing state. Routing those through the model is wasteful and unreliable — you pay a request and its latency, and the model may decline, mangle, or narrate the action.

docs/capability-seams.md states the purpose of ctx.commands in one line: "Plugins register direct human commands without sending invocations to the model."

Mechanism

From packages/interaction/commands/README.md, a command is registered with ctx.commands.register(definition):

interface CommandDefinition {
  /** Lowercase command name without the leading slash. */
  readonly name: string
  /** Human-readable summary used in discovery UI. */
  readonly description: string
  /** Optional free-form input hint advertised to capable clients. */
  readonly input?: CommandInputDescriptor
  /**
   * Whether `command/run` records `rawInput`. Defaults to true. A command
   * whose domain event owns the payload sets this false to avoid duplicating
   * that payload in the session log.
   */
  readonly recordInput?: boolean
  /** Execute against the receiving agent without sending the command to the model. */
  readonly handler: (invocation: CommandInvocation) => CommandResult | Promise<CommandResult>
}

The handler receives the exact target agent, the rawInput after the name (retaining separator whitespace — "consumers own their command-specific grammar"), durably admitted image attachments, a commandId already written to the log, and an AbortSignal owned by the dispatching UI. It returns:

type CommandResult =
  | { readonly kind: 'success'; readonly text?: string; readonly sourceEventSeq?: number }
  | { readonly kind: 'error'; readonly text: string }

Discovery hands adapters handler-free immutable CommandDescriptors. That is deliberate: a UI that could reach a handler could invoke it outside the lifecycle that logs and bounds it.

Scoping mirrors skills and tools: "A plain-context registration is global. A command-producing plugin mounted beneath agent.ctx declares its own commands injection and creates an exact agent-scoped definition; it shadows a global definition with the same name." The README names the payoff: "This child-injection shape preserves the agent scope without making the core agent loop depend on a UI service." The loop knows nothing about commands, and commands still resolve per agent.

Three design decisions with transferable reasoning:

Duplicate names fail within one layer. Silently shadowing would make a plugin's command vanish for reasons no log records.

Results never enter model history. "Results are rendered directly by the adapter and never enter model history … The registry never submits rawInput to the agent implicitly." Zero token cost, zero KV-cache effect. A command producer that wants model-visible work must schedule it explicitly through the Agent — dsh-plan-mode submits the optional message in /plan [message] after selecting the mode — and then owns that message contract.

The lifecycle is logged even though the result is not. command/run before the handler and command/done at settlement, "log-only" and standalone: "no turn wraps them." A thrown or aborted handler settles as kind: 'error'. This is the Module 4 invariant read carefully — the rule is model-visible ⟺ logged, so something non-model-visible need not be logged for correctness, but is logged anyway for auditability. Knowing a human typed /plan off at a certain point is essential to reconstructing why a session behaved as it did.

recordInput: false handles the duplication case: when an authoritative domain event already owns the payload, command/run omits args rather than storing it twice.

The second user-facing extension point

Skills carry userInvocable in SkillInvocationPolicy, so a skill can be human-runnable, model-runnable, both, or (uselessly) neither. Together the surfaces cover the matrix: commands are always human-initiated and never model-visible; tools are always model-initiated; skills choose per skill.

Tradeoffs

Commands are text-only: "Only unstructured text input — forms, completion schemas, and typed arguments remain command-owned parsing concerns." Every producer writes its own parser, and they will be inconsistent. A typed-argument schema would fix that at the cost of a much larger registry contract, and dsh has deferred it.

Cancellation is cooperative: "dispatch stops awaiting on abort; handlers must honor the signal to stop work that has already escaped into external systems." A hung command's UI unblocks while its side effects continue.

And discovery does not scale by itself. list(agent) returns everything registered; with fifty plugins the palette is unusable without grouping the registry does not provide.

How others differ

Claude Code supports Markdown-file slash commands in .claude/commands/ whose content is submitted to the model as a prompt — the opposite choice. That is far more accessible (any user can author one with no code) and strictly more expensive (every invocation is a model turn) and less reliable (the model may not comply). dsh's commands are code-registered and never reach the model: precise, auditable, free — and requiring a plugin to author. Neither is wrong; they serve different authors. A mature harness arguably wants both, and dsh currently has only the code-registered kind.


Check yourself

1 question on M7.6

Start drill