Tool authoring reference
Reference for the contracts a model-facing tool must satisfy. For an ordered first tool, follow Build a tool. packages/shell/tool-bash is the production-grade three-package example.
The minimal shape
import { readFile } from 'node:fs/promises'
import type { Context } from '@deepseek-ai/cordis'
import { defineTool } from '@deepseek-ai/dsh-tools'
export const name = 'my-tool'
export const inject = ['tools']
export function apply(ctx: Context) {
ctx.tools.register(defineTool({
name: 'read_file',
description: 'Read a file from disk.', // what the model sees
parameters: {
path: { type: 'string', required: true, description: 'Absolute path' },
limit: { type: 'number' }, // optional by default
},
output: {
schema: { type: 'string' },
render: (_args, value) => [{ type: 'text', text: value }],
},
async execute(args, exec) {
// args is TYPED from the schema: { path: string; limit?: number }
// exec carries immutable identity + token; signal is the operational field
return readFile(args.path, { encoding: 'utf8', signal: exec.signal })
},
}))
}Registration is effect-based: disposing the plugin fiber unregisters the tool. Schemas flow into the system-prompt assembly automatically.
Rules of the execute() contract
- Args are validated for you.
defineToolvalidates model-generatedargumentsagainst the unifiedParameterSchemaSpecbeforeexecuteruns (types, required keys, literal constraints, exact-one unions, and nested values — runtime arg validation), so insideexecutethe args matchInferArgs. Explicit object nodes declareadditionalProperties: true | false; the implicit parameter root stays open. You still hand-check constraints the DSL does not express, such as non-empty strings, positive numbers, or cross-field rules. Raw JSON-Schema tools registered directly own their input validation. - Registration borrows your readonly definition. A typed same-process contribution is not a serialization boundary; do not mutate its schema or replace callbacks after registration.
schemas()materializes only the explicit model-facing projection. To hot-swap a tool, dispose its owning effect and register the replacement; mutable state inside the callback's closure remains ordinary plugin state. - Execution identity is protected. The registry materializes
argumentsas detached lossless JSON in one recursive pass, freezes that value before policy starts, and assigns an opaqueexec.token;callId,name,arguments,agent,token, the required caller-ownedsignal, and an optional enclosing-transportparenttoken stay immutable through dispatch.parentis identity-only and exposes no live outer execution. Treatargsas readonly input. Only an around-dispatch wrapper receives a mutable view, and it may replace and restore the requiredexec.signalto impose a deadline but cannot remove it. - Declare and return one canonical JSON value.
output.schemausesValueSchemaSpecand may have an object, array, scalar, or null root.executereturns only the inferred value; the registry snapshots it as lossless JSON, validates it, freezes it, and passes it tooutput.render(args, value). Do not return content blocks from the body or make callers parse prose for ids and fields. - Throwing or returning an invalid value means
isError. The registry catches throws and contains schema, renderer, metadata-projector, and lossless-JSON failures before observers run. Throw for infrastructure failures. Represent a successful domain outcome in the canonical value even when its Native renderer explains a non-ideal state, such as a non-zero process exit. - Honor
exec.signal. Cancel in-flight work when it fires. - Project durable card data with
presentationMeta(optional).output.presentationMeta(args, value)derives replayable JSON from the same canonical value. The core persists it ontool/resultand hands it topresentResult, so a card that needs result-time facts—such aswrite/editapplied hunks—survives replay without persisting the canonical value. The projector is skipped for nested Code dispatches because they have no cards. - Use
exec.agentfor async notifications.agent.inject({ content, source: { kind: 'plugin', plugin: '<name>' } })appends durable context the NEXT model request sees — it is not a wake-up (an idle agent stays idle). Guard against disposed agents (try/catch).
Long-running work
Gate run_in_background with producer config, then register through ctx.jobs.start({ kind, label, owner: exec.agent, run }). The registry rejects a pre-aborted invocation before the producer body; the runtime validates ownership and task-controller availability before run() starts work, then supplies the id, session fence, generic control tools, notices, and owner cleanup. A successful background branch returns a typed canonical handle such as { kind: 'background', jobId }; its Native renderer may keep human prose such as started background job bash-1, but Code Mode must never parse that prose to recover the id.
The producer supplies synchronous cancel, non-rejecting done that settles after resource cleanup, and optional consuming readOutput with bounded-output formatting. A pre-aborted call is a failure because no task exists whose id could satisfy the successful output schema. Once ctx.jobs.start() publishes the id, use a task-owned cancellation signal rather than exec.signal: later outer-call cancellation stops waiting for the call but does not kill published work; job_kill, owner disposal, and service teardown own that lifetime. Foreground work remains coupled to exec.signal. See the background job runtime Agent Note and dsh-tool-bash for a stream producer.
Execution policy and observation
Prefer not to build deployment policy into the tool. Use tools/pre-execute for extensible allow/deny/ask policy (the permission-gate example), ctx.tools.guard() for a final monotonic deny that later listeners cannot undo, tools/execute to wrap dispatch with a deadline, retry, or metrics collection, tools/post-execute to replace presentation content or the returned value, block the result, or attach model-facing context, and tools/result to observe the immutable normalized outcome. A content replacement leaves programmatic access to value intact; confidentiality policy blocks or replaces the value. A sandboxing implementation can also run inside the tool's executor implementation; the dsh-tools README defines each extension point's inputs, order, return values, and failure behavior.
Code Mode reaches your tool for free
In Code Mode, every visible registered tool is available as await tools.<name>(args) without extra integration. The generated ToolArgsMap and ToolOutputMap derive exact argument and canonical-return types from the same schemas, and calls re-enter the normal execution pipeline. A successful call resolves to the final canonical JSON value after policy, not to rendered Native content. A failed call rejects with the real ToolCallError; programs can inspect only its name, toolName, and human-readable message, not internal error codes or a failure union.
Design output.schema as a useful programmatic API: return handles and fields directly, allow scalar/array/null roots when they are the honest value, and keep human explanation in output.render. Intermediate values are execution-local, are not persisted or prompt-truncated, and have no byte cap, so the producer's truthful acquisition bounds and process memory still matter. Only the outer run_code logs/result cross the configurable output cap and model-facing spill pipeline.
How your tool renders in a UI
Your tool's output.render returns model-facing content; its UI card is a separate concern declared through pure presentation projections and optional presentCall / presentResult methods. Design these alongside the canonical value. A tool with no UI presentation falls back to a generic card (title = tool name, raw args as input).
Both methods return a card-tagged render intent — pick the card kind that matches what your tool does:
presentCall(args)→ aToolCallView(the PENDING card):{ card: 'generic', title, kind?, rawInput?, content?, locations? }— the default. Setkindfor an icon (read/search/…); setlocations: [{ path, line? }]for any file your tool touches so a capable editor follows along / jumps to it.{ card: 'terminal', title, description?, cwd? }— your call IS a shell command.titleis the command,descriptionrenders above the terminal card. (tool-bash.){ card: 'diff', title, diffs, locations? }— your call creates or modifies a file.diffs: [{ path, oldText, newText }](oldText: nullfor a new file) renders as an inline diff card. (tool-fswrite/edit.)
presentResult(args, { content, isError, meta? })returns the completed card:genericsupplies an optional title and content.terminalsupplies raw output and optional exit metadata; each UI renders its capable or fallback view.diffsupplies applied hunks, often derived byoutput.presentationMetaand carried in persistedresult.metaso replay reproduces them. Mutation tools keep a diff result because the completed view replaces the pending card.searchsupplies a discovery result reconstructed from persistedresult.meta: grouped-by-file matches (shape: 'matches', grep) or a flat path list (shape: 'paths', glob), plustruncated/totalso a UI never presents a capped result as complete. The view carries no result text (a UI without a search card falls back to the raw result content), and there is nosearchcall view — a discovery call's pending state stays a generic card, since matches exist only afterexecute. (tool-fs-searchgrep/glob.)websupplies a completed web retrieval, discriminated bykind: 'search' | 'fetch'(the structured search sources or the fetch summary), derived fromresult.meta; it carries no body copy, so a UI without thewebcapability falls back to the raw result content. (tool-webweb_search/web_fetch.)
Hard rules (they bite if broken):
- Purity. These run on live streaming AND on session-log REPLAY, so they must be pure functions of
args(+ the result) — NO I/O, NO reading session state, NO clock/random. A diff is derived from the args (writeusesoldText: nullbecause a call-time presenter has no prior file content); the UI adapter, not the tool, supplies session context. If you find yourself wanting the file's old content or the working directory insidepresentCall, stop — that belongs in durable result metadata or the adapter, not the presenter. - UI-only formatting stays out of the model result. A fenced
```consoleblock, a diff, a relativized path—none of these belongs in the canonical value or Native content merely to serve a UI.output.renderowns model-facing prose;presentationMetaplus the card presenters own replayable UI state. Aterminalresult view carries raw output and the adapter adds any fallback framing. defineToolsoft-validates the display path. Malformed or older logged arguments make the wrapper returnundefined(a generic fallback) rather than throw — display must never crash a replay.
The neutral vocabulary lives in dsh-tools; tools never import a UI or transport type. Host/client runtimes map each card into their own view. The design and the why are in the render-intent-union Agent Note; dsh-tool-fs (generic/diff) and dsh-tool-bash (terminal) are the reference implementations.
Verification
Follow the repository testing policy and the owning package's test documentation. A shipped model- or UI-visible change requires the assembled coverage specified there.