Migrating a Function-Calling Agent's Tool Definitions
11 min read · updated August 11, 2026
Converting a tool definition is the easy third of this job. The two harder thirds are the shape of the call the model emits and the shape of the message you send the result back in — and an agent loop that gets those wrong does not fail loudly, it just stops making progress.
Three things move, not one
A function-calling agent has three contact points with the API, and a provider migration touches all three. The definition you send up front; the structure the model returns when it wants to call something; and the message you append carrying what your code did. Each has its own field names and its own place in the request. Convert them together or the loop turns after one iteration and then stalls.
If you have not built one before, the mechanics of the loop itself are in tool calling explained. This page assumes the loop works and you are moving it.
The definition, field by field
OpenAI’s Chat Completions API takes a tools array of objects with a type of function and the details nested under a function key:
{
"type": "function",
"function": {
"name": "get_order_status",
"description": "Look up the current status of a customer order by its id.",
"parameters": {
"type": "object",
"properties": {
"order_id": {"type": "string", "description": "The order id, e.g. A-10422."}
},
"required": ["order_id"],
"additionalProperties": false
},
"strict": true
}
}Anthropic’s Messages API flattens the same information:
{
"name": "get_order_status",
"description": "Look up the current status of a customer order by its id.",
"input_schema": {
"type": "object",
"properties": {
"order_id": {"type": "string", "description": "The order id, e.g. A-10422."}
},
"required": ["order_id"]
}
}So the conversion is: unwrap the function object, drop the type discriminator, and rename parameters to input_schema. The JSON Schema body itself is carried across unchanged, which is the good news — your parameter documentation is the expensive part and it ports.
Two adjacent notes. OpenAI’s newer Responses API flattens the same object one level, putting name, description and parameters alongside type rather than nested — so “the OpenAI format” is already two formats within one vendor, and a converter should target an API rather than a company. And the older functions array with its function_call parameter is deprecated in favour of tools and tool_choice; if your definitions are still in that shape, do that upgrade first, on your current provider, so you are only changing one thing at a time.
The call the model emits
On the OpenAI side, the assistant message carries a tool_calls array. Each entry has an id, a type, and a function object with name and arguments — and arguments is a JSON-encoded string, not an object. The finish reason on that choice is tool_calls.
"tool_calls": [
{
"id": "call_abc123",
"type": "function",
"function": {"name": "get_order_status", "arguments": "{\"order_id\": \"A-10422\"}"}
}
]On the Anthropic side, the assistant message’s content is an array of blocks, and a call is a block with type of tool_use, carrying id, name and an input that is already a parsed object. The stop reason is tool_use.
"content": [
{"type": "text", "text": "Let me check that order."},
{"type": "tool_use", "id": "toolu_abc123", "name": "get_order_status",
"input": {"order_id": "A-10422"}}
]The string-versus-object difference is the one that bites. Code that does json.loads(call.function.arguments) throws on a dict; code that reads call.input["order_id"] gets a character on a string. It also means the JSON-parse failure mode — the model emitting arguments that are not valid JSON — exists on one side and not the other, so an error branch you wrote for it has no trigger after the move and its test becomes vacuous.
The other structural difference: one shape puts text and calls in the same content array, so a model can narrate and call in one message. Flatten that into a text-only field and you lose the narration. Keep both.
Feeding the result back
This is the part most conversion guides omit. OpenAI expects a message with a dedicated tool role, correlated by tool_call_id:
{"role": "tool", "tool_call_id": "call_abc123", "content": "{\"status\": \"shipped\"}"}Anthropic expects a user message whose content array holds a tool_result block correlated by tool_use_id:
{
"role": "user",
"content": [
{"type": "tool_result", "tool_use_id": "toolu_abc123",
"content": "{\"status\": \"shipped\"}", "is_error": false}
]
}Three consequences. The role changes from a dedicated one to user, so a history serialiser with a role allowlist drops the message. The correlation field is renamed, so a mechanical port that keeps tool_call_id produces a request the server rejects or, if it is tolerant, a model that cannot tell which call the result belongs to. And there is an explicit error flag on one side — is_error — where the other convention is to put the error text in the content and let the model infer it. Signalling failure explicitly changes how the model recovers, so this is not a field you can leave unset and forget.
Where several calls come back at once, both sides expect all results before the next assistant turn: one tool message per call, or all the tool_result blocks in a single user message. Returning a subset and letting the next turn catch up is the classic parallel-call bug — see testing parallel tool calls.
Schema dialect and strictness
The JSON Schema you send is not interpreted identically. Providers accept a subset of the specification, and the subsets differ: support for oneOf, anyOf, $ref, format, pattern, numeric bounds and recursive definitions varies, and a keyword that is silently ignored is worse than one that is rejected, because the constraint appears to be in force and is not. Google’s function declarations, for instance, take an OpenAPI-derived subset rather than raw JSON Schema.
Strictness also differs in kind. OpenAI’s strict flag on a function definition asks the provider to constrain generation so arguments conform to the schema, with its own requirements — every property listed in required, and additionalProperties set to false. There is no universal equivalent flag; on a provider without one, schema conformance is a strong tendency rather than a guarantee.
The safe posture, and the one that makes the migration testable: validate arguments yourself, on every call, on both providers. Then a strictness difference shows up as a validation-failure rate you can measure instead of as a corrupted side effect. Keep that validator in your suite — testing tool argument validation is the same test before and after, which is exactly what you want during a swap.
Porting the set
- Extract the definitions into one provider-neutral source. Name, description, and a plain JSON Schema per tool, in your own code, with no vendor wrapper. If they are currently written inline in request bodies, this refactor is the migration.
- Write one emitter per target API — a pure function from the neutral definition to that API’s shape. Two small functions, unit-tested against fixture JSON, no network.
- Write one parser per target API that normalises an assistant turn into your own type: text, plus a list of
(call_id, name, args_object). Do the JSON decode inside the parser so the rest of the loop never sees a string. - Write one result serialiser per target API from
(call_id, payload, is_error). This is the piece to write third and test hardest. - Run the loop against a stub before touching the live model: replay a recorded assistant turn from the new provider through your parser, dispatcher and serialiser, and assert the next request body is well-formed. See testing without the model.
- Then run real multi-step conversations and check that step two happens. A loop that stalls after one tool call is the signature of a broken result message, not a broken definition.