Skip to main content

Command Palette

Search for a command to run...

The tool schema is the contract. A loose one tells the model to guess.

When a function-calling tool's input schema has no required list, open additionalProperties, or an untyped field, the model fills the gaps with hallucinated arguments. Here is why loose schemas are a correctness bug, and toolstrict, a static gate that fails the build before they ship.

Updated
8 min readView as Markdown
The tool schema is the contract. A loose one tells the model to guess.
J
Cloud & Platform Engineer focused on building systems that scale and endure. I explore how infrastructure, automation, and engineering practices come together to support modern software teams.

You gave the model a tool. It called it. The arguments were wrong.

Not wrong because the model is bad at its job - wrong because you never told it what "right" was. The tool's input schema said every field was optional, so it left one out. It said nothing about extra keys, so it added one you never defined. It gave a field no type, so the model passed a string where your handler wanted an integer. Every one of those is a bug you can see coming, and every one of them lives in the schema you shipped.

This post is about why the tool schema is the real contract between your code and the model, why a loose contract is an invitation to hallucinate arguments, and a small tool I wrote - toolstrict - that fails your build when a tool's input schema is too loose to trust.

The schema is the contract, and the model reads it literally

When you register a tool for function calling, you hand the model a JSON Schema describing the arguments. That schema is not documentation. It is the specification the model plans against. Whatever the schema permits, the model treats as fair game.

So look at what a typical hand-written schema actually permits:

{
  "type": "object",
  "properties": {
    "destination": { "type": "string" },
    "cabin": { "type": "string" },
    "passengers": { "type": "integer" }
  }
}

That looks fine. It is not. It has no required list, so the model is free to call book_flight with no destination at all - and under load, sometimes it will. It has no additionalProperties: false, so the model may add a seat_preference key you never wrote a handler for. Nothing constrains cabin to real cabin classes, so you get "cabin": "cheapest". The schema said all of that was allowed. The model believed it.

The defining property of a loose schema is that it under-specifies the very thing the model needs to be precise about. Add real volume and the model will eventually exercise every freedom you left open. "It has produced valid arguments every time so far" is not evidence the schema is tight - it is evidence you have been lucky.

Three ways a loose schema hurts you

The failures cluster into three shapes, and they are the reason toolstrict's blocker rule exists:

  • Omitted arguments. With no required list, every field is optional. The model drops the one your handler dereferences, and you get a KeyError in production for an argument the model would have supplied if you had simply marked it required.

  • Invented arguments. With additionalProperties left open (the JSON-Schema default), the model can add keys that were never part of the contract. Now your handler has to defend against arguments you never designed for.

  • Mis-typed arguments. A property with no type places no constraint on the value. The model passes "3" instead of 3, or an object where you wanted a string, and the failure surfaces deep in your code instead of at the boundary.

The remedies are completely standard: a required array, additionalProperties: false, a type on every property (OpenAI's strict mode makes all three mandatory; Anthropic recommends them). The hard part was never how to tighten a schema. It is noticing the one tool definition that is loose, in an agent with thirty tools, before it reaches a user.

Why this needs its own linter (and isn't two tools I already wrote)

I want to be precise about scope, because three small tools of mine sit near each other and it would be easy to assume they overlap. They do not:

  • toolstrict - this one - lints the tool's input schema rigor. Is the schema strict enough that the model cannot emit invalid arguments? That is required, additionalProperties: false, typed properties, enums, bounds, strict: true.

  • toolens lints the tool's description and naming quality - is the wording legible, are two tools overlapping, are there simply too many tools for the model to choose between? It is about whether the model picks the right tool.

  • shapelint works the response side - is the value coming back validated against a schema?

toolstrict never comments on your prose and never looks at responses. It reads the argument schema and asks one question: is this tight enough to trust? A generic JSON-Schema validator can't answer that - it checks whether data matches a schema, not whether the schema itself is strict. And your linters (ruff, ESLint) have no idea a blob of JSON is a tool definition at all.

The two rules

toolstrict has exactly two rules, and they map to "unsafe" versus "could be tighter."

  • TS001 (blocker) - the schema is not safe. Any of: an object with no (or empty) required list, additionalProperties not set to false, or a property with no type. Each is a direct path to omitted, invented, or mis-typed arguments. Exit code 1.

  • TS002 (warning) - the schema works but could be tighter: a string whose description implies a fixed set ("must be one of…") but has no enum, a numeric property with no minimum/maximum, or an OpenAI function without strict: true.

One rule for "this will let the model be wrong," one for "this lets the model be sloppier than it needs to be."

It reads the schemas you already ship

toolstrict reads the three shapes teams actually use, from two kinds of source:

  • OpenAI - tools=[{"type":"function","function":{"parameters":{…}}}] (the strict flag is read from the function).

  • Anthropic - tools=[{"name","input_schema":{…}}].

  • MCP - a tools/list result: {"tools":[{"name","inputSchema":{…}}]}.

Those can live in a .json file, arrive on stdin, or be written as inline dict literals right in your Python source. For Python, toolstrict parses the file with the standard-library ast module and evaluates only literal dicts via ast.literal_eval - it never imports or runs your code, so it is safe on untrusted source in CI. A schema assembled at runtime (values that are variables or function calls) can't be read statically, so it is skipped rather than guessed at. Nested objects and array items get the same checks as the top level, because a strict root wrapped around a loose nested object is still loose.

A real before and after

Here is that flight-booking tool as many people first write it:

{
  "type": "function",
  "function": {
    "name": "book_flight",
    "parameters": {
      "type": "object",
      "properties": {
        "destination": { "type": "string" },
        "cabin": { "type": "string", "description": "one of economy, business, first" },
        "passengers": { "type": "integer" },
        "notes": {}
      }
    }
  }
}

Run toolstrict over it:

$ toolstrict tools.json
BLOCKER TS001 book_flight  (root) has properties but no "required" list.
BLOCKER TS001 book_flight  (root) does not set "additionalProperties": false.
BLOCKER TS001 book_flight  property 'notes' has no "type".
WARN    TS002 book_flight  'cabin' describes a fixed set of values but has no "enum".
WARN    TS002 book_flight  numeric property 'passengers' has no minimum/maximum bound.
WARN    TS002 book_flight  OpenAI function 'book_flight' is not declared strict.
exit 1

And the version that passes - the contract the model can't wander outside of:

{
  "type": "function",
  "function": {
    "name": "book_flight",
    "strict": true,
    "parameters": {
      "type": "object",
      "additionalProperties": false,
      "required": ["destination", "cabin", "passengers"],
      "properties": {
        "destination": { "type": "string", "description": "IATA airport code." },
        "cabin": { "type": "string", "enum": ["economy", "business", "first"] },
        "passengers": { "type": "integer", "minimum": 1, "maximum": 9 }
      }
    }
  }
}
$ toolstrict tools.json
✓ toolstrict: every tool input schema is strict (1 tool, 1 file).
exit 0

Try it

pip install toolstrict

toolstrict agent/tools.json          # lint a JSON file
toolstrict src/                      # recurse .json + .py
cat tools.json | toolstrict -        # or pipe it
toolstrict agent/tools.json --strict # fail on warnings too

Exit codes are CI-native: 0 clean, 1 on a blocker (or any finding under --strict), 2 for a usage error. Drop it into a pre-commit hook or a CI step and a loose tool schema stops being something you find in an incident review.

The project is open source (MIT) at https://github.com/jay-tank/toolstrict. It pairs naturally with toolens (tool description and naming quality) and shapelint (response-side schema validation) - three narrow gates for three different failure modes of the same agent.

A transient bug hides until traffic finds it; a loose schema hides until the model does. toolstrict makes the schema tell the truth about what it allows - before that truth reaches a user.

More from this blog

J

Jay Tank's Engineering Blog

59 posts

Deep dives on running fintech & Web3 infrastructure at scale - AWS, Kubernetes, CI/CD, edge security, observability, and Bitcoin Lightning. Practical architecture breakdowns and open-source DevOps tools from a senior platform engineer.