AI coding

Routing Tool Calls by Version When Two Schemas Run Live

Antonio Evans7 min read

A hardware patch panel with two labeled ports /v1 and /v2 fed by a single cable, a third port capped in red with a short queue of tokens waiting before it, and a routing table overhead showing arrows to handler A.
Key points

Key points

  1. 1Put the schema version in a required place on every request, a header or an envelope field, and reject calls that omit it.
  2. 2Validate the version against a routing table before any handler or tool code runs, so an unknown version never reaches business logic.
  3. 3Map a retired version to the current schema only when the new schema accepts the old payload unchanged; otherwise return a 400 with a stable error code and a migration link.
  4. 4Add a kill switch keyed on the version string that rejects new calls for that version while letting in-flight calls finish.

Where the version belongs on a tool call

Two schemas can run behind one endpoint only if every request says which one it wants. Without that, the server guesses from the payload shape, and guessing is the failure mode you are trying to avoid: a caller sends a field that exists in version 2 but not version 1, the server picks the wrong schema, and validation passes against the wrong contract.

A version identifier belongs in one of two places.

A request header, such as X-Tool-Schema: 2. Headers keep the payload clean, so the JSON body is exactly the tool arguments. The cost is that headers are easy to strip. Proxies, API gateways, and some agent frameworks rebuild requests and drop unknown headers, so the version can vanish silently.

An envelope field, a wrapper object with the version and the arguments:

{
  "schema_version": "2",
  "tool": "create_invoice",
  "arguments": { "customer_id": "cus_8841", "amount_cents": 24900 }
}

The envelope survives every hop because it is part of the body. It also makes the contract self-describing in logs and traces: a stored request tells you which schema it targeted without header capture. The cost is one extra level of nesting in every payload.

The tradeoffs between URI, query parameter, header, and hybrid versioning strategies are covered in Gravitee's overview. For tool calls the envelope wins more often than it does for REST, because tool arguments are usually generated by a language model and the envelope gives you a place for metadata the model does not have to invent.

A practical rule: if your callers are your own agents and you control the client library, use a header. If callers are third parties, or you pass through a gateway you do not fully control, use the envelope from day one. Pick one as canonical and treat the other as an alias you can retire. Two ways to say the same thing is two ways to disagree.

Wherever the version goes, the routing layer reads it first, before deserialization into a typed tool-arguments struct. Deserialize after you know the schema, not before, because a tolerant parser that accepts fields from versions it does not understand is how wrong-schema bugs slip through.

The routing table is the single source of truth

The routing table maps a version string to a handler plus a compatibility mode. It is the only place that decides what a version means.

Version stringStatusHandlerCompatibility modeNotes
1retiredv1 handlermap_to_currentAccepts v1 payloads unchanged if they validate against v2
2activev2 handlernativeDefault for new callers
3shadowv3 handler, results discardednative, log onlyNot advertised; measures caller behavior
empty, 0, 1.5, v2nonenonenoneRejected with unknown_schema_version

Three properties make this table useful.

It is data, not code. A table read from configuration or a feature flag service can change during an incident without a deploy. A routing table baked into a switch statement cannot.

Every version has a status. active is normal service. shadow runs the handler and logs the result without returning it, which lets you measure a new schema against real traffic before advertising it. retired means the version is no longer offered but may still be mapped. killed means reject everything for that version immediately. The status is what the kill switch flips.

The table is checked before the handler runs. This is the load-bearing decision. Version validation happens at the dispatcher boundary, so an unknown version produces a rejection with a stable error code and nothing else executes. No handler is called, no side effect is attempted, no partial write happens. That mirrors the central question in schema evolution work, whether old code can read new data and whether new code can read old data, which a survey of schema evolution tooling frames as the thing compatibility checks exist to answer. At the tool boundary, the dispatcher answers it.

Fail closed: missing and unknown versions

The default for anything the routing table does not recognize is rejection. Not "try the newest schema," not "try each schema until one validates." Those two produce the worst incidents, because they silently reinterpret caller intent.

The dispatcher's decision order:

  1. Extract the version from the envelope field, falling back to the header alias if the envelope is absent.
  2. If no version is present, reject with missing_schema_version and HTTP 400.
  3. Look the version up in the routing table. If absent, reject with unknown_schema_version and 400.
  4. If the status is killed, reject with schema_version_killed and 409 or 410. Use 409 if the version may return, 410 if it will not.
  5. If the status is retired with map_to_current, rewrite the payload into the current shape, then validate against the current schema. If validation fails, reject with schema_migration_failed and 400.
  6. If the status is retired with none, reject with unknown_schema_version and 400.
  7. Otherwise dispatch to the handler named in the row.

The error response should be parseable without reading prose. An agent that gets a rejection needs to know what to do next.

{
  "error": "unknown_schema_version",
  "message": "Schema version '1' is retired and cannot be mapped.",
  "supported_versions": ["2"],
  "migration_url": "https://docs.example.com/tools/invoices#v1-to-v2"
}

That shape follows a principle that makes tools usable by agents: a machine-readable error with an explicit next step beats a stack trace, and status codes should be a documented contract. One write-up on CLI design for agents makes the point that agents branch on exit or status codes, so returning the same code for every failure forces them to parse text and sometimes get it wrong. OpenStatus's account of rebuilding their CLI shows the same pattern applied to errors: tell the caller exactly what to provide.

The status choice carries meaning. 400 says the request itself is wrong and retrying unchanged is pointless, which is the right signal for an unknown version. A 500 tells the caller to retry, which it will, which is how a version mismatch becomes a retry storm.

When to map a retired version and when to refuse

The decision rule is narrow and worth stating precisely.

Map a retired version to the current schema only if the new schema accepts the old payload unchanged. Every field the old callers send still exists with the same name, type, and meaning. If a v1 caller sends {"amount": 249.00} and v2 expects {"amount_cents": 24900}, that is not a mapping candidate, because the old payload does not validate against the new schema. It is a breaking change.

Refuse when the change is breaking. Refuse when a field was removed, renamed, retyped, or given a new meaning. Refuse when an optional field became required. Refuse when validation got stricter, because a payload that passed before now fails and the caller has no way to learn why. These are the rules behind most safe API evolution advice, and Milan Jovanovic's argument against reflexive versioning states them directly: don't take anything away, don't change processing rules, don't make optional things required, and make everything you add optional.

Here is the same rule as a table for a design review.

Change in the new schemaMap retired calls?Response to retired-version caller
New optional field addedYesMap, validate, dispatch
New response field onlyYesMap, validate, dispatch
Field removedNo400, unknown_schema_version, migration link
Field renamedNo400, unknown_schema_version, migration link
Field type changedNo400, unknown_schema_version, migration link
Optional field now requiredNo400, unknown_schema_version, migration link
Stricter validationNo400, unknown_schema_version, migration link

There is a strong argument for avoiding the breaking change entirely. If you can add a new field next to the old one and leave the old field in place, you need no new version, and the routing question disappears for that change. Irina Dominte's deep dive on API versioning frames this as a choice between a new location, a backward-compatible change, and a full break, and notes you need a mix in practice. For tool schemas the additive path is usually cheap, because the model regenerates arguments per call; the expensive part is reprompting callers, not the schema.

Supporting two versions has a real cost. xMatters's versioning guidance recommends enforcing backward compatibility with tests on every change and verifying that endpoints accept the same request schemas and return the same response schemas across versions. That transfers directly: keep a fixture per retired version, run it against the mapping layer in continuous integration, and fail the build when a mapping stops validating.

The kill switch, and why in-flight calls matter

A kill switch is a control keyed on the version string that stops new work for that version without a deploy. It exists for the case where you find a problem in a schema version and need to stop it in seconds.

The semantics need to be explicit, because "reject everything for version 2" is the wrong instruction.

New calls for a killed version are rejected with a stable code and a 409 or 410. They never reach the handler.

In-flight calls finish. A call that passed the routing check before the switch flipped runs to completion and returns its normal result. Killing a version should not abort writes halfway through, because a half-applied side effect is worse than a slow drain.

The switch is checked once, at dispatch time, and the result carried with the request. Do not re-check inside the handler; a handler that re-reads the switch mid-execution produces inconsistent behavior under load.

Two numbers make the timing concrete. Suppose the 99th percentile latency of a tool call is 900 ms. Once you flip the switch, calls already past the check finish within roughly a second, so the trigger for teardown is a couple of seconds. A later one is worth thinking about.

Observe the errors. In the same twenty-four hours, confirm the unknown_schema_version rate for version 1 is zero. If callers still hit the killed version, your error code is not reaching their branching logic, and the fix is a direct message to that caller, not a code change.

Only then remove the mapping code, delete the v1 handler, and delete the v1 fixture from the compatibility test suite in one pull request. Leaving the mapping in place "just in case" is how a v1 never dies. The checkpoints that matter: as-sent v1 traffic near zero before the kill, mapping failure rate at zero throughout, and no killed-version retries after the flip.

FAQ

FAQ

Should the version go in the URL instead of a header or envelope?

For tool calls, no. A path segment like /tools/v2/create_invoice multiplies endpoints and turns the dispatcher into a router over paths rather than a table you can change during an incident. The tradeoff between URI, query, and header versioning is covered in Gravitee's guide, but tool calls add a constraint: the caller is often a model, and the body is where it reliably puts structured data.

What should the server do when a caller sends no version at all?

Reject with missing_schema_version and a 400. Defaulting to the newest schema is tempting and wrong, because it silently reinterprets intent and turns a caller bug into a data bug. The only exception is a short, time-boxed grace period at the launch of versioning, when no caller can yet be sending a version. Log the default even then, so the grace period ends on evidence.

Can I map a retired version if the only change was a rename?

No. A rename is breaking for the caller, because the old payload sends a field name the new schema does not have. You can write a transform that renames the field on the way in, and that works, but it is a transform, not a passthrough. Validate the transformed payload against the new schema and reject with schema_migration_failed if it fails.

How long should a retired version keep being mapped?

Use as-sent traffic as the trigger rather than a fixed date. Retire the mapping when callers on that version fall below a threshold you choose, and pick the threshold by how much support one straggler creates. A mapping serving under one percent of calls for a few weeks is removable, provided the remaining caller has been told and given the migration link.

Does the kill switch need to be separate from the routing table status?

Not as a mechanism, but yes as a control path. The switch should be a field in the table that can be flipped without editing the rest of the row, and it should be changeable without a code review, because the moment you need it is the moment a review will not happen fast enough. Keep the table and switch in the same store, flip the switch, and let new calls reject while in-flight calls drain.

Sources

Sources

  1. API Versioning: Best Practices for Managing Change in APIs
  2. Schema changes without breakage: 15 tools that let you ...
  3. Writing CLI Tools That AI Agents Actually Want to Use
  4. Building a CLI That Works for Humans and Machines
  5. API Versioning Should Be Your Last Resort
  6. Api Versioning - A deep dive
  7. API Versioning: Strategies & Best Practices
About the author

About the author

Antonio Evans
Antonio Evans

Antonio Evans is the founder of Quanta Intelligence and a fractional CTO who builds HIPAA-ready AI systems for health and life-science companies. Before that he was technical co-founder of FriendsEAT for twenty years, CEO of Socialdraft, and a data engineer at IBM. Antonio lives with his dog Santino in Lisbon, Portugal.

All articles