JSON Schema for Tool Calls: What Providers Actually Support
Which JSON Schema features survive the trip to a model, why strict mode changes the rules, and how to write tool parameter schemas that validate and get used correctly.
JSON Schema is a large specification and tool calling supports a subset of it. Which subset depends on the provider, on whether you enabled strict or constrained decoding, and sometimes on the model. A schema that validates perfectly in your test suite can be rejected at the API boundary, or worse, accepted and quietly ignored.
The practical consequence: your tool schema is two things at once. It is a contract the runtime enforces, and it is documentation the model reads. Those two roles pull in different directions, and most tool schema problems come from optimising for one and forgetting the other.
The reliably supported core
Across OpenAI-compatible providers, a narrow set of constructs works everywhere. Objects with named properties, the primitive types string, number, integer, boolean and null, arrays with a single items schema, enum on strings, required, and description on any node.
Build tool parameters out of that vocabulary and you will not spend time debugging schema rejection. Nesting is fine, but keep it shallow — two levels of object nesting is plenty, and deeper structures measurably reduce the rate at which models produce a valid call on the first attempt.
The top level must be an object. A tool whose parameters are a bare array or a string is not expressible, so wrap it: {"paths": ["a", "b"]} rather than a naked list.
Where support gets uneven
Composition keywords are the first casualty. anyOf is widely accepted; oneOf and allOf are not, and not is essentially never honoured. If you need a discriminated union, model it as an object with a required kind enum and optional siblings, and validate the combination in your handler.
String and numeric constraints are the second. minLength, maxLength, pattern, minimum and maximum may be transmitted but not enforced by the decoder. Treat them as hints to the model and as assertions in your own validation layer — never as a guarantee that the arguments you receive respect them.
format is the third and the most misleading. Writing "format": "date-time" does not make the model emit RFC 3339. It sometimes helps, because the string appears in what the model reads, but a description saying "ISO 8601 UTC timestamp, e.g. 2026-08-30T14:00:00Z" helps considerably more. The same reasoning applies to output schemas, as discussed in structured outputs and JSON mode.
Strict mode trades flexibility for a guarantee
When a provider offers strict or constrained decoding, the model is prevented at sampling time from producing tokens that would break the schema. The output is then guaranteed to parse and to match the structure. That is a real guarantee and it is worth having.
The price is a stricter input dialect. In OpenAI strict mode, additionalProperties must be explicitly false on every object, and every property must be listed in required — optional fields are expressed by unioning the type with null instead. Several keywords are disallowed outright rather than ignored, so a schema that worked without strict mode may fail to compile with it.
There is also a first-call latency cost, because the provider builds a decoding constraint from your schema and caches it per schema shape. Generating schemas dynamically per request defeats that cache. Keep the shapes stable.
Strict mode guarantees structure, not sense. A required string field will always be a string; it can still be an empty string, a hallucinated file path, or a plausible-looking identifier that does not exist. Structural validity is not semantic validity, and the tools still need to check.
Descriptions are prompt, not documentation
Every description in the schema is tokens the model reads on every request. That makes them the highest-leverage part of the schema and a real cost line, a tension covered in designing tool schemas the model can use.
Write them for a caller who cannot see your code. Say what the parameter selects, what the units are, what a valid example looks like, and what happens at the boundaries. "The maximum number of results, 1 to 50, default 10" is worth ten times "max results".
The most valuable descriptions are the ones that prevent a wrong call rather than describing a right one. If two tools overlap, say explicitly when to prefer each. If a parameter is only meaningful when another is set, say so — the schema cannot express it and the model will otherwise guess.
Enums beat free strings, almost always
Any parameter with a bounded set of legal values should be an enum. It removes an entire failure class, and under constrained decoding it makes an invalid value impossible rather than merely unlikely.
The limit is cardinality. Enums with a handful of members are excellent; enums with two hundred members bloat the request, hurt caching and start to degrade selection accuracy. Past a few dozen values, switch to a lookup tool that returns the valid options, and have the model call it first.
{
"type": "object",
"properties": {
"status": { "type": "string", "enum": ["open", "closed", "merged"] },
"limit": { "type": "integer", "minimum": 1, "maximum": 50 }
},
"required": ["status"],
"additionalProperties": false
}
Validate anyway, and return the errors
Whatever the provider promises, validate the arguments in your own process before executing anything. Use the same schema, run it through a real validator, and treat a failure as a normal outcome rather than an exception.
Then hand the failure back to the model as a tool result. A short structured message naming the offending field and the expected shape is usually corrected on the next turn. This pattern is shown concretely in the Python and TypeScript walkthroughs — the Python tool loop keeps the schema and the handler in one Pydantic model, and the TypeScript version does the same with Zod.
Keep those error payloads small. A full validator error dump can run to hundreds of tokens, and the model needs the field name and the constraint, not the JSON pointer trail.
Versioning without breaking running agents
Schemas change. The safe direction is additive: new optional parameters with sensible defaults, new enum members, wider numeric bounds. Anything that removes a property or narrows a type will break in-flight conversations whose history contains calls in the old shape.
When a breaking change is unavoidable, register a new tool name rather than mutating the old one, and keep both live until the old one stops being called. This is the same reasoning that applies to pinning model versions — the model is part of the contract, and so is the tool surface it was tested against.
A checklist for a new tool schema
- Top-level object, two levels of nesting at most.
- Stick to the portable core; avoid
oneOf,allOfandnot. - Enum every bounded field; keep enums small.
- Write descriptions with units, ranges and one example.
- If using strict mode, set
additionalProperties: falseand list every property as required. - Validate server-side regardless, and return failures to the model as short structured results.
- Change schemas additively; new name for a breaking change.
Common questions
Does strict mode mean I can skip validation?
No. It guarantees the arguments parse and match the structure, not that the values are meaningful. A valid string can still be a path that does not exist or an id the model invented.
Why is my pattern or maxLength being ignored?
Many providers transmit those keywords to the model as hints but do not enforce them during decoding. Treat them as guidance in the prompt surface and enforce the real constraint in your handler.
How do I express an either-or parameter?
Avoid oneOf. Use a required enum discriminator with optional sibling fields, and check the valid combination in code. It is more portable and models produce correct calls more often.