# Conflicts: # docs/architecture.md # docs/capability-seams.md # docs/config-catalog.md # docs/cookbook/extension-cookbook.md # docs/cordis-catalog/events.md # docs/cordis-catalog/services.md # docs/event-producer-consumer.md # docs/module-graph.md # docs/rfc/implemented/feature/2026-06-30-interception-seams.md # docs/rfc/proposed/feature/2026-06-14-acp-agent-client-protocol.md # docs/tool-execution-pipeline.md # packages/cordis/tool-cordis/src/api-catalog.ts # packages/core/agent-core/README.md # packages/core/agent-loop/README.md # packages/core/tools/README.md # packages/core/tools/src/index.ts # packages/core/tools/tests/tools.spec.ts # packages/core/tools/tsconfig.json # packages/ui/acp/src/index.ts # scripts/doc-budgets.manifest.json # scripts/gen-cordis-catalog.ts # scripts/gen-doc-graphs.ts
10 KiB
Cookbook: extension plugin shapes
The three plugin shapes you write against the harness extension surface, as illustrative snippets (elided imports and helper stubs — not copy-paste-complete). For the full step-by-step guides see adding a package, adding a tool, and adding an LLM adapter; for the seams these hook into see docs/architecture.md.
A tool plugin
A tool registers on ctx.tools. The annotated defineTool example (typed execute args, result shaping, the run_in_background pattern) lives in adding-a-tool.md — that guide is the source of truth for the tool shape. Raw JSON-Schema ToolDefinitions are also accepted by ctx.tools.register() directly (that is how MCP-sourced tools arrive); defineTool is the typed sugar for first-party tools.
A hook plugin (permission gate)
A hook returns a typed decision from the tools/pre-execute gate to allow or deny a call — the seam where sandbox, permission, and plan-mode plugins live. (A "native hook" is just this: an ordinary cordis plugin on the interception seams, returning typed decisions — no external protocol needed.)
import type { Context } from 'cordis'
import type { PreToolDecision, ToolExecution } from '@deepseek-ai/dsh-tools'
declare function isAllowed(exec: ToolExecution): Promise<boolean>
export const name = 'permission-gate'
export function apply(ctx: Context) {
ctx.on('tools/pre-execute', async (exec, next): Promise<PreToolDecision> => {
if (!(await isAllowed(exec))) {
return { kind: 'deny', reason: 'Denied by policy.' }
}
return next()
})
}
This waterfall is the reorderable policy layer. Use ctx.tools.guard() when an invariant needs a monotonic final denial, tools/execute when a plugin must wrap the actual dispatch lifetime (timeouts/retries/metrics; only exec.signal is replaceable), tools/post-execute for explicit result transformation, and tools/result for contained observation of the immutable final outcome. The adding-a-tool guide gives the selection rule.
A UI plugin
A UI plugin renders from the session/event feed (the assistant token stream as assistant/chunk, plus turn/step boundaries and tool activity), and drives input back in via agent.send() / agent.steer().
import type { Context } from 'cordis'
import { AgentId } from '@deepseek-ai/dsh-agent'
declare function render(text: string): void
declare function onUserInput(handler: (text: string) => void): void
export const name = 'my-ui'
export const inject = ['agents']
export function apply(ctx: Context) {
ctx.on('session/event', (_session, event) => {
if (event.type === 'assistant/chunk' && event.data.chunk.type === 'text-delta') {
render(event.data.chunk.text)
}
})
onUserInput(text => ctx.agents.get(AgentId('main'))?.send([{ type: 'text', text }]))
}
A client-driver plugin (external protocol bridge)
A client driver is a UI plugin whose "user" is another program speaking a wire protocol rather than a human at a terminal. It owns the process's stdio (so it must run with no stdout logger — every non-protocol byte corrupts the stream), creates/resumes agents on demand through the dsh-agent factory seam, translates harness events (session/event, agent/*) into outbound protocol messages, and translates inbound requests back into agent.send() / agent.cancel(). Two harness-specific contracts make it correct: resolve each request exactly once off a settle signal (settle from the durable turn/end session event — the boundary is a session event, not an agent/* mirror — with agent/status as the fallback if a peer listener starved yours), and tear each agent down through its AgentHandle.dispose() (which stops the loop, awaits its exit, and unregisters), not just cancel() — disposal must reach quiescence, not merely request it.
packages/ui/acp is the worked example: it bridges the agent to the Agent Client Protocol (JSON-RPC over stdio) so Zed and other ACP editors can drive it. See its README for the full method surface and the permission-prompt answerer it registers on the approval seam.
import type { Context } from 'cordis'
export const name = 'my-protocol-bridge'
export const inject = ['agents', 'sessions', 'sessionPersistence']
export function apply(ctx: Context) {
// Stream every logged assistant text/reasoning delta out to the client.
ctx.on('session/event', (_session, event) => {
if (event.type === 'assistant/chunk') {
const chunk = event.data.chunk
if (chunk.type === 'text-delta') {
// sendToClient({ kind: 'message_chunk', text: chunk.text })
}
}
})
// Inbound "prompt": create/resume an agent and feed it; settle on turn end.
// Teardown reaches quiescence via AgentHandle.dispose() (stop + await exit).
}
Runnable wirings
Three complete examples load their plugin trees from cordis.yml: examples/echo-agent (mock model + echo tool — the all-mock skeleton check, pnpm run demo:echo), examples/coding-agent (DeepSeek V4 + the bash tool suite behind a terminal REPL UI, pnpm run demo:repl), and examples/acp-agent (an agent exposed as an ACP server over JSON-RPC stdio — the client-driver shape, pnpm run demo:acp). Each leaf is just its swappable backends plus an app-package entry: the stdio demos load @deepseek-ai/dsh-stdio-agent, the ACP demo loads @deepseek-ai/dsh-acp-agent, and both app packages share the spine via the @deepseek-ai/dsh-agent-core bundle.
The feature → mechanism map
Every product feature maps to a listener on a documented extension seam — the microkernel claim made checkable (microkernel RFC). No row modifies the loop.
| Product feature | Plugin mechanism |
|---|---|
| Hook system (user + project level) | listeners on agent/session-start, agent/prompt-submit, agent/request, agent/step-result, tools/pre-execute, tools/post-execute, agent/turn-continuation — each interception waterfall returns a typed Decision; the dsh-hooks-claude / dsh-hooks-codex bridges map hook config files onto these seams |
/goal |
force-continue via agent/turn-continuation + steer() reminders |
/loop |
on the turn/end session event, send() the next iteration; or force-continue |
| Dynamic workflow | ctx.workflows + the worker-thread engine + the workflow tool; structured in-process children enforce output with scoped prompt protection, a monotonic tool guard, final tools/result commit (including enclosing run_code), and terminal agent/turn-stop |
| Queued + steering messages | core Agent.send() / Agent.steer() |
| Context compaction (auto + manual) | the ctx.compact seam + a backend (dsh-compact-basic) on the serial agent/pre-step seam; auto = token-pressure check before each step; a manual trigger invokes the same ctx.compact routine (compaction RFC — the model-facing /compact consumer tool is deferred) |
| System prompt configurability | ctx.systemPrompt.section() with ordering; an owner uses systemPrompt.protect() only when its canonical section/tool presence is a correctness invariant |
| AGENTS.md (root) | a section provider reading the file |
| AGENTS.md (subdir, on-touch) + file-change notices | agent.inject() from a watcher / tool-result listener |
| Built-in tools | ctx.tools.register(); schemas flow into the assembly automatically — the dsh-tool-* families (bash, fs, web, subagent, todo) are the shipped examples |
| ToolSearch / progressive disclosure | filter ordinary capabilities at system-prompt/assemble (the loop logs the result as the request header); owner-protected transport and correctness entries retain their canonical presence or absence |
| Tool deadline / retry / metrics | wrap core dispatch with tools/execute; a wrapper may replace exec.signal, delegate, and inspect the normalized result in one lexical lifetime |
| Final tool-result metrics / audit / capture | observe immutable authoritative outcomes with tools/result; use tools/post-execute instead only when the plugin must transform the result or attach context |
| Monotonic terminal turn policy | return { action: 'stop' } from serial agent/turn-stop, after continuation and steering have already been folded |
| Subprocess sandbox (landlock / sandbox-exec) | use a ctx.sandbox backend through dsh-bash-sandbox; use tools/pre-execute for capability-level denial |
| Permission system / AskUserQuestion | return ask from tools/pre-execute and answer through ctx.approval; register a separate model-facing ask tool for ordinary user questions |
| Plan mode | tools/pre-execute (deny writes) + a mode prompt section via ctx.systemPrompt.section() or agent.inject() (model-visible ⟺ logged: agent/request shapes call config only) |
| Sub-agent delegation | the ctx.subagents provider registry (dsh-subagent-spawn/-fork/-acp) + dsh-tool-subagent exposing one configured provider to the model |
| MCP | one plugin per server: discover tools → ctx.tools.register() |
| Skills | section + tool registration; inject() skill content on invocation |
| Memory | section provider + tool |
| Scheduled tasks (cron) | a plugin registers model-callable scheduling tools; timer fires → send(…, {source: {kind: 'cron', …}}) when idle / inject() notification when busy |
| UI (GUI; CLI emits JSONL) | listen session/event (assistant chunks, boundaries, tool activity); input → send() |
| Telemetry / replayable trace | session/event → JSONL; replay = sessions.create(id, { seed }) |
| Model adapters | LlmAdapter subclass via registerAdapter (dsh-llm-deepseek, dsh-llm-pi-ai) |
| Plugin hot-reload | every registration is a ctx.effect → vendored HMR just works |