Tool Output Contract: Value Separated from Display
Same result — what the model sees and what people see can differ. Core source:packages/core/tools/src/index.ts and presentation.ts。
Waiting for execute()…
schema check
packages/core/tools/src/index.ts lines 211–219 and the card union in presentation.ts. Compare the two panes: same value, two totally different presentations.First, the title question: is a tool result a string or a structured value? In DSH it’s both — with different status. execute returns only one canonical JSON value, which must pass the tool’s own output.schema. Strings come later: the registry takes the validated value, calls render(args, value), and projects the content blocks the model sees.
So the chain is: execute produces the value, schema gates it, render projects model content, optional presentationMeta projects replayable UI data, and presentResult turns that into a card. Both render and presentResult are pure — no I/O — because they run on live streaming and session-log replay, and must produce the same output.
What the UI gets is render intent: a card-tagged union whose domain is six cards — generic, terminal, diff, read, search, web. The client only switches on card; it never needs tool names. Swap the search backend provider and the whole tool implementation — as long as it still emits a search card, the UI doesn’t change a line. That’s UI contract decoupled from tool implementation.
value lives only at execution timePersisted tool/result events store only content, error, and meta — the canonical value never hits disk. Replay can recreate every card and every model text, but not the intermediate value (docs/subsystems/tools.zh.md, “结果仅承载产出” section).
A broken projection ≠ a crashSchema failure, render throwing, or presentationMeta emitting non-JSON — all become a JSON-safe isError result. The model sees an error string; the pipeline still finishes. Source: createSuccessResult from line 1793 in index.ts.
Truncation must show its handSearch cards must carry truncated and total, so the UI never paints a cut result as complete (presentation.ts lines 223–231). Read cards likewise carry offset and totalLines, so they can show “lines N, of M total”.
The whole output contract is nine lines. schema is required, render is required, presentationMeta is optional. Both projector comments stress Pure — that’s the foundation of replay determinism.
/** Tool-owned canonical output contract used after the body returns a JSON value. */
export interface ToolOutputDefinition {
/** Raw supported JSON Schema enforced against every successful canonical value. */
readonly schema: JsonSchemaNode
/** Pure projection from validated arguments and value to Native/model content. */
render(args: unknown, value: JsonValue): ContentBlock[]
/** Pure replayable presentation projection, computed only for top-level calls. */
presentationMeta?(args: unknown, value: JsonValue): JsonValue
}
packages/core/tools/src/index.ts, verified on 2026-08-13. Code blocks keep the original source text.Value/display separation also explains a quirky post-execute plugin rule: on accept, you may replace content or value — never both. It’s not a doc convention; it’s baked into the types. PostToolDecision accept has two branches: one allows content and types value as never; the other allows value and types content as never. In TypeScript, never has no legal inhabitants — stuff both fields into one decision and the compiler errors. The third branch is block, turning corrective feedback into an error result.
Source:packages/core/tools/src/index.ts PostToolDecision type definition, lines 593–600, verified on 2026-08-13。
Why not both at once? Different semantics. Replacing content is a display-layer move: keep the value, change only model-facing text. Replacing value is a data-layer move: the registry re-runs schema on the new value, then recomputes content and meta so all three projections share one source. Allow both and you can get text saying A while the value is B. Docs add a key warning: content swap is a presentation tactic — plugins that must hide a value from programs must swap the value or block; rewriting text alone won’t fool Code Mode code that reads the value (docs/subsystems/tools.zh.md, “后置策略” section).
Two fallback questions are answered too. If render throws, the registry turns it into a JSON-safe isError and the model sees error text. If a third-party tool skips presentCall / presentResult, the client falls back to a generic card: title = tool name, raw args as input (index.ts lines 79–83 comments spell out this fallback). Nothing crashes; everything has a landing place.
Claude Code keeps rendering on the tool interface itself. The Tool interface has renderToolResultMessage() for UI and mapToolResultToToolResultBlockParam() for format conversion (study/chapters/02-tool-system.md lines 96–98 interface map). Tool files are .tsx; rendering is the tool’s own React component. Upside: authors control every pixel. Cost: swap clients (terminal → editor plugin) and you rewrite the render layer; replay must re-run render code. DSH turns that layer into data: tools only declare render intent; six card vocabularies are a neutral host↔client protocol — anyone can render.
Oversized-result handling lines up too: CC uses maxResultSizeChars — spill to disk, leave the model a preview plus path (study/chapters/02-tool-system.md lines 463–496). DSH’s counterpart is the spill policy, covered in Compaction Dual Paths. Both sides nailed the same point: someone must police tool-result bulk so it can’t blow up context.
Grok Build types tool output with Rust enums: e.g. search_replace yields SearchReplaceOutput, with InvalidInput, NoMatchesFound and other failure shapes fixed at compile time (crates/codegen/xai-grok-tools/src/implementations/grok_build/search_replace/mod.rs). Inputs are equally careful — model-facing canonical input is a stable projection; see Canonical input is a stable projection. Whether output UI and model text share a unified card vocabulary like DSH — no equivalent showed up in reviewed Grok materials; that conclusion stays provisional on public evidence.
Design an output contract for a SQL query tool
You’re integrating a third-party sql_query tool: the query returns 1200 rows but you keep only the first 50. Write: roughly what the value schema looks like (hint: rows, total, truncated are non-negotiable); whether render’s model text should include all 50 rows; which of the six cards presentResult should pick, and where truncation info goes. Final question: a security plugin wants to hide a phone-number column from the model — in post-execute, swap content or value? Think about what Code Mode programs actually receive.