The deployment passed, but an old run resumed with a v1 tool payload. The new handler expects subject and priority; the persisted payload still contains title and details. The failure appears only when someone resumes the run, after the change is already live.

To avoid this break, treat the contract version as part of the run. Record which tool contract was selected, validate the input at the boundary, adapt old versions in one place, and stop before the side effect when the version is unknown. Do not make every handler guess whether an old payload is still safe.

This is different from validating one call. Validating AI agent tool calls in TypeScript helps reject invalid arguments. This article covers contract changes over time while queues, checkpoints, and old context are still alive.

Diagram shows an old run passing through a contract adapter while an unknown version is rejected.

Short answer

  • Record input and output contract versions with the run.
  • Make every new run choose an explicit version.
  • Keep an adapter for old versions while work is still in flight.
  • Validate and adapt before calling the handler that creates external effects.
  • Reject unknown versions and remove the old adapter only after those runs are drained.

What breaks when a tool changes?

A tool exposed to a model has more parts than its function. Its name, description, input schema, result shape, and effect rules influence what the agent can request and how the runtime interprets the response. The MCP Tools specification defines a tool with name, description, inputSchema, and an optional outputSchema. In OpenAI function calling, the schema is also part of the definition sent to the model.

A change can be structural or semantic:

Change Risk to an old run
Add an optional field Usually low if the old handler ignores it and the new path accepts its absence.
Rename title to subject High. The old payload does not contain the field the new handler reads.
Change status from a string to an object High. The JSON can be valid while the meaning has changed.
Remove an enum option High for persisted tasks or queued calls.
Change what send_email does without changing its schema Very high. The shape looks compatible, but the approved action may not cover the new behavior.

A provider schema can prevent some malformed arguments. It cannot decide by itself whether yesterday's run can use today's policy. That is an application-runtime decision.

The contract must travel with the run

Keep at least three identifiers separate:

  1. agentRelease: the executable composition, including instructions, model, tools, and policies.
  2. toolContractVersion: the contract version the run used to choose and build the call.
  3. runId: the identity of the run that may be resumed, retried, or investigated.

Do not use one field named version for all three. A new tool description may not require state migration. A new policy may require a new approval even when the JSON is unchanged. A deployment can change code without changing the tool contract.

A minimal record can look like this:

{
  "runId": "run_7f2",
  "agentRelease": "support-agent-2026-10-29.1",
  "toolContractVersion": 1,
  "status": "waiting_for_tool",
  "nextTool": "create_ticket"
}

runId is not a compatibility control. It lets you locate the work. The contract version tells the runtime which interpretation to apply before execution.

Where should the compatibility adapter live?

Put the adapter between the received message and the tool handler. The boundary should receive a versioned envelope, validate the shape for that version, and produce one internal format for the current handler.

The example below is illustrative and does not replace schema validation. It shows the important separation: the business handler does not need to understand every historical format.

type ToolCall = {
  name: "create_ticket";
  contractVersion: 1 | 2;
  arguments: unknown;
};

type CreateTicketV1 = {
  title: string;
  details: string;
};

type CreateTicketV2 = {
  subject: string;
  body: string;
  priority: "low" | "normal" | "high";
};

function adaptCreateTicket(call: ToolCall): CreateTicketV2 {
  if (call.contractVersion === 1) {
    const oldArgs = call.arguments as CreateTicketV1;

    return {
      subject: oldArgs.title,
      body: oldArgs.details,
      priority: "normal",
    };
  }

  if (call.contractVersion === 2) {
    return call.arguments as CreateTicketV2;
  }

  throw new Error("Unsupported tool contract version");
}

async function execute(call: ToolCall) {
  const args = adaptCreateTicket(call);
  // Validate `args` here in a real implementation.
  return createTicket(args);
}

In production code, validate arguments against the schema for that version before the cast, limit which versions each release can serve, and record the resolved version in the trace. OpenAI recommends strict: true so function calls reliably follow the supplied schema. That reduces malformed calls, but v1-to-v2 compatibility still has to exist in your system.

The adapter is also where you decide whether an old response can go back to the agent. If v2 returns { ticketId, url } and a v1 run expects { id }, adapt the output, keep the old worker, or stop explicitly. Do not send a new object to an old consumer just because both are valid JSON.

Four decisions for every live version

Once you identify an old call, choose a policy explicitly:

Situation Policy Action
The old contract is still safe Pin Run it with the old worker and schema until the run finishes.
The structure changed but the meaning is the same Adapt Validate the old version, convert at the boundary, and call the current handler.
The state can be converted without losing a decision or effect Migrate Apply a recorded transformation, validate the new state, and mark its resulting version.
The version is unknown or the semantics changed Reject Stop before the effect, record the reason, and request intervention or replanning.

The dangerous mistake is confusing adapt with migrate. Adapting converts a call at the input or output boundary. Migrating changes the persisted state of the run. If a human approved the old arguments, converting the JSON does not prove that the approval still covers the new arguments.

Workflow systems make a similar distinction. Temporal’s workflow versioning documentation separates workers tied to code revisions from compatible changes made with patching. Its worker versioning guidance describes pinning executions to a deployment version so a code change does not break work already in progress. The platform does not define your tool’s business semantics, but the principle transfers: a long-running execution needs a known route between the version that created it and the version that resumes it.

How to roll out the change without guessing

A safe rollout does not start by replacing the schema everywhere. Change it in stages:

  1. Inventory live work. Count runs by contract version, step, age, pending approval, and confirmed external effect. An empty queue does not prove that no sleeping checkpoint remains in the database.
  2. Define compatibility. List added and removed fields, renames, enum changes, and effect changes. Mark each one pin, adapt, migrate, or reject.
  3. Deploy the adapter before the v2 producer. The new version must read a v1 call before any run starts producing v2.
  4. Pin the choice at run start. A resume should not recalculate the tool set only because the current deployment is newer.
  5. Send new runs to v2. The producer, runtime, and tool should agree on the version. If selection happens in the prompt, record the decision in the runtime envelope too.
  6. Observe the transition. Record counts by version, rejections, adaptations, validation failures, duplicate effects, and runs that stopped.
  7. Drain and retire. Remove the adapter only when no live run needs it, or when a verified migration replaced all of them.

If the system uses MCP, do not treat a changed tool list as a contract migration. The protocol defines tool discovery and calls, and servers can notify clients that the list changed. The application still owns the relationship between a run, a schema, and a policy.

How should you test a schema change?

Build a small matrix before deployment. Each row needs an input and an expected boundary result:

Case Expected result
Valid v1 call Adapted to the current internal format.
v1 call missing a required field Rejected with no external effect.
Valid v2 call Executed by the current handler.
Unknown version Rejected before the handler.
v2 response for a v1 consumer Adapted, kept on the old worker, or stopped explicitly.
Retry after a timeout Checks operation state before creating another effect.

The contract test should verify more than an HTTP status. Check the received version, validated schema, normalized arguments, called handler, and absence of an effect on the rejected path. The article on contract tests for a TypeScript MCP server shows how to make a tool interface a testable boundary.

For persisted runs, keep fixtures for every version that can still appear. Combine that with regression testing for AI agents without exact text matching. The question is not whether the model repeated the same sentence. It is whether the runtime still chose an allowed tool, preserved the recorded intent, and avoided an effect with ambiguous arguments.

Failures that look like compatibility but are not

Accepting any JSON object. Valid syntax is not a valid contract. Check required fields, types, enums, and business invariants before the handler.

Keeping v2 in every tool name forever. create_ticket_v1 and create_ticket_v2 can help during a transition window. If they become the permanent catalog, the model sees duplicate choices and the team gets more surfaces to maintain.

Migrating state without reviewing approvals. An approval is tied to what a person saw, not only to what the database can convert. A material parameter change should reopen the decision.

Removing the adapter after the first green deployment. A deployment proves that the new code started. It does not prove that old queues, retries, sessions, or checkpoints are gone.

Hiding the version in the prompt. A prompt can help the model choose a tool, but the runtime still has to validate the received version. The safety boundary cannot depend only on the instruction the model read.

Checklist before publishing v2

  • Does each run record agentRelease and toolContractVersion separately?
  • Does the adapter validate the old payload before transforming it?
  • Does the current handler receive one validated internal format?
  • Does an unknown version fail before any external effect?
  • Is there a compatibility policy for output too?
  • Are there fixtures for every version still alive?
  • Are old approvals re-evaluated when the action’s meaning changes?
  • Can the trace find the run, version, decision, and result?
  • Does the adapter have a verifiable retirement condition?

Conclusion

Versioning a tool means controlling a contract change, not just renaming a function. Pin the version when a run starts, validate and adapt at the boundary, keep new runs separate from old ones, and reject what you cannot interpret safely. This reduces the chance that a deployment breaks work already accepted and makes it clear when compatibility is finished.

If the change also involves checkpoints, queues, or resumes, connect it to durable execution for AI agents with checkpoints, queues, and workflows. A tool should not be the only versioned part of a long-running execution.

How this article was produced

Samuel Fajreldines wrote this article from the repository’s post and link inventory, current result inspection, and public documentation from MCP, OpenAI, and Temporal. The pin, adapt, migrate, and reject matrix is original synthesis for explaining the compatibility boundary. The code is illustrative: it was not run against a model provider, MCP server, or production queue.

I used RemoteCode as the author’s tool to organize research and review the text for consistency. It did not run a production agent and does not replace validation in the reader’s environment.

Sources consulted