M3.2
Designing schemas models use correctly
1,987 words · about 12 min
This is the highest-leverage craft knowledge in this course. A schema is not documentation; it is the only instruction the model reliably receives about a tool, it is re-read on every single request, and it competes for attention with everything else in the prompt. Most tool failures that look like model errors are schema defects.
Concept
Treat a schema as a user interface for a reader who cannot ask questions. The model sees the name, the description, and the parameter shapes; it never sees your implementation, your tests, or your intent. Everything it needs to call correctly on the first attempt must be present in those three surfaces, and everything present in those surfaces costs tokens on every request forever. Schema design is therefore a compression problem with a correctness constraint: say the minimum that prevents the maximum number of wrong calls.
There are five levers.
Mechanism — lever 1: naming
MechanismNames are the strongest signal because they are what the model pattern-matches
against its pretraining. Use the boring, conventional, verb-first name. read,
write, edit, grep, glob, bash — these are what dsh ships, and their
virtue is that a model has seen a million examples of what those words mean.
Bad:
fsAccessV2 — versioned, abbreviated, camelCase, and vague about direction
handleFileRequest — "handle" says nothing; is this read or write?
doSearch — "do" is noise
utility — no.Good:
read — one verb, unambiguous direction
write
grep — borrows an established tool's exact semantics
read_image — the modifier narrows a known verbConsistency within a family matters as much as any individual name. dsh uses
snake_case throughout (read_image, run_in_background, job_kill,
web_fetch). Mixing snake_case and camelCase across your tool surface makes
the model guess, and it will guess wrong at a measurable rate.
Note one deliberate inconsistency in the shipped bash schema: the parameters are
command, description, workdir, run_in_background, and timeoutMs. That
camelCase outlier is real and worth flagging honestly — it is the kind of drift
that accumulates when a parameter name is inherited from an internal config field
rather than designed at the model boundary.
Mechanism — lever 2: description economy
MechanismThe description has one job: eliminate the wrong calls the model would otherwise make. Not to explain the implementation, not to be complete. Every sentence should be traceable to a specific failure it prevents.
Compare. A naive description:
"Runs a bash command."Now the real shipped bash description, with each clause annotated by the failure
it prevents:
Execute a bash command (
bash -c) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — passworkdirinstead of usingcd. Non-zero exits are reported as[exit code: N]. Current harness environment facts are exposed through managed$DSH_*variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as[sandbox: file access denied under <mode> mode]— a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Setrun_in_background: truefor long-running commands: the call returns a job id immediately; read its output withjob_outputand stop it withjob_kill.
| Clause | Failure it prevents |
|---|---|
| "fresh shell: no state persists" | Model runs cd /src then ls in a second call and is confused by the result |
"pass workdir instead of using cd" | Gives the correct alternative, not just a prohibition |
"Non-zero exits are reported as [exit code: N]" | Model misreads the exit marker as command output |
| "a policy denial, not a bug in the command; do not retry another way" | Model burns five turns trying sudo, then python, then node to defeat a sandbox |
| "Long output is truncated to its tail" | Model concludes a build succeeded because it can't see the earlier error |
"run_in_background … read with job_output" | Model foreground-runs a dev server and hangs the turn |
Every clause is load-bearing. That is the standard. The glob description meets
the same bar with a subtle disambiguation most authors would never think to write:
A pattern with no "/" matches the basename at any depth, so "*" and "*.ts" both search the whole tree; include a separator to anchor the depth.
That single sentence exists because models otherwise write *.ts expecting
current-directory-only results and misinterpret a whole-tree match as a bug.
The cost side. These descriptions are long, and they are paid on every request for the entire session. The discipline is that length must buy prevented failures. A 200-token description that prevents one wasted 3,000-token turn per session pays for itself immediately; a 200-token description that restates the parameter names is pure loss.
Mechanism — lever 3: parameter shape
MechanismPrefer flat scalar parameters. Deep nesting, polymorphic unions, and "do-everything" argument objects degrade emission accuracy sharply.
Bad — a mode-switched god tool:
{
"type": "object",
"properties": {
"operation": { "type": "string" },
"payload": { "type": "object" },
"options": {
"type": "object",
"properties": {
"flags": { "type": "array", "items": { "type": "object" } }
}
}
},
"required": ["operation", "payload"]
}Three defects. operation is an unconstrained string, so the model invents
"readFile", "read_file", and "READ" across three calls. payload is a typed
hole — its valid shape depends on operation, and the schema cannot say so, so
the model has no way to know what goes in it. The nested options.flags array of
objects is where emission accuracy goes to die.
Good — split into separate tools with flat scalars:
{
"type": "object",
"properties": {
"file_path": { "type": "string", "description": "Path to read, resolved by the filesystem backend." },
"offset": { "type": "number", "description": "1-based first line to return. Defaults to 1." },
"limit": { "type": "number", "description": "Maximum number of lines to return. Defaults to 2000." }
},
"required": ["file_path"]
}This is the real read schema (packages/fs/tool-fs/src/index.ts). Three flat
scalars, one required, each with a description that states its default. The rule:
if a parameter's valid values depend on another parameter's value, you have two
tools, not one.
The counter-pressure is real. Every tool you split costs another full schema in
the prefix. dsh splits read/write/edit but keeps grep's include as a
single glob string rather than a list — and says so explicitly in the description
("Not a list; negation is not supported"), which is the honest way to ship a
narrowed parameter.
Mechanism — lever 4: enum over freeform
MechanismAny parameter with a closed value set must be an enum. A freeform string where an enum belongs is an invitation for the model to invent a plausible value that your code doesn't handle. The enum moves the failure from runtime to emission, where the provider's constrained decoding can often prevent it entirely.
Bad: { "format": { "type": "string", "description": "Output format: json, text, or csv" } }
The model will eventually emit "JSON", "application/json", or "plain".
Good: { "format": { "type": "string", "enum": ["json", "text", "csv"] } }
dsh's schema DSL supports this directly — ValueSchemaSpec allows scalar enum
and const values, and requires that they match their node type
(packages/core/tools/src/schema.ts). The enforced raw JSON Schema subset
(packages/core/tools/src/json-schema.ts) accepts enum as
JsonSchemaScalar[] where a scalar is string | number | boolean | null.
The judgment call is when the value set is nearly closed. bash's command is
correctly freeform. A severity parameter is correctly an enum. A language
parameter is genuinely contested — enumerate and you reject valid inputs as your
support grows; leave it open and you get "Typescript", "ts", and "TS". The
honest answer is that the field has no settled convention here; enumerate when the
set changes slower than your release cycle.
Mechanism — lever 5: required vs optional
MechanismMark required only what the tool genuinely cannot proceed without. Every required parameter is another thing the model can omit and fail on. Every optional parameter with a documented default is a thing the model can safely ignore.
Look again at read: only file_path is required. offset and limit are
optional and their descriptions state their defaults ("Defaults to 1", "Defaults
to 2000"). That combination is what makes the schema safe to call minimally. A
model that emits {"file_path": "/a.ts"} gets a correct, predictable result.
Contrast bash, which requires both command and description — a parameter
that is purely for the human-facing UI, not for execution. This is a deliberate,
debatable choice: it costs the model a mandatory extra generation on every shell
call, and it buys a legible activity log. The schema teaches the model what "good"
looks like by example rather than by instruction ("ls" → "List files in current
directory"), which is measurably more effective than an abstract rule.
dsh expresses requiredness per-property rather than as a separate array. In its
authoring DSL a ParameterPropertySpec is a ValueSchemaSpec & { required?: true },
and parameterSchemaSpecToJsonSchema() compiles that into the standard wire-level
required: [...] array. Authors annotate the property; the wire gets conventional
JSON Schema.
Worked example — rewriting a bad schema
Worked exampleBefore:
{
"name": "fileOp",
"description": "Performs file operations.",
"parameters": {
"type": "object",
"properties": {
"mode": { "type": "string" },
"target": { "type": "string" },
"data": { "type": "string" },
"opts": { "type": "object" }
},
"required": ["mode", "target", "data", "opts"]
}
}Defects: vague name; description prevents no failures; mode should be an enum or
a tool split; data is required even for reads, forcing the model to invent a
value; opts is an untyped hole that is also required; nothing states any default
or bound.
After — split into three tools; showing one:
{
"name": "write",
"description": "Create or fully replace a UTF-8 text file.",
"parameters": {
"type": "object",
"properties": {
"file_path": { "type": "string", "description": "Path to write, resolved by the filesystem backend." },
"content": { "type": "string", "description": "Full UTF-8 text content to write." }
},
"required": ["file_path", "content"]
}
}Note "fully replace" — three words that prevent the single most expensive
filesystem failure mode, a model using write for a small change and silently
destroying the rest of the file.
Tradeoffs
Rich schemas cost prefix tokens and cache stability. Narrow tools multiply schema count. Enums reduce error rates but create version-skew when the value set grows. There is no configuration that optimizes all three; the defensible default is few tools, flat parameters, long descriptions, because model attention is scarcer than context window.
How `dsh` does it
How dsh does itdsh gives authors one typed vocabulary — defineTool with a ParameterSchemaSpec
— that simultaneously produces the wire JSON Schema, the runtime validator, and
the TypeScript argument type. Because all three derive from one declaration, a
schema and its implementation cannot drift. The shipped schemas are harvested
mechanically from schemas() into docs/tool-catalog.md, so the catalog is
generated from the running registry rather than hand-maintained — the documentation
literally cannot describe a tool that doesn't exist.
How others differ
Claude Code splits discovery into Glob (by name) and Grep (by content) as
separate tools from Read, on the theory that a combined search-and-read tool
makes the model over-fetch. Aider takes the most radical position: it treats the
edit format itself as the primary design axis and ships several
(whole, diff, diff-fenced, udiff), selecting per model — udiff was
introduced specifically because GPT-4 Turbo emitted placeholder comments under
other formats. That is schema design as empirical model-fitting, and it is a fair
critique of the assumption that one schema suits all models. Cline recently
renamed its surface to bash, editor, read_files, apply_patch, search,
fetch_web, ask_question — converging on the same short, conventional verbs.
Check yourself
4 questions on M3.2