Cohere’s v1 Chat API does not take tool output as a message. It takes it as a separate tool_results array whose entries pair the call that was made with a list of result objects — and the list is the part that is designed differently from everyone else’s.
The v1 tool_results shape
When Command returns tool calls, they arrive in tool_calls on the response, each with a name and a parameters object. To continue the conversation you send a new request with an empty message and a tool_results array:
curl https://api.cohere.com/v1/chat \
-H "Authorization: Bearer $CO_API_KEY" \
-H "content-type: application/json" \
-d '{
"model": "command-r-plus-08-2024",
"message": "",
"chat_history": [
{"role": "USER", "message": "How many orders shipped late last week?"}
],
"tools": [
{
"name": "query_orders",
"description": "Look up orders in the warehouse database.",
"parameter_definitions": {
"status": {"type": "str", "description": "Order status", "required": true},
"week": {"type": "str", "description": "ISO week, e.g. 2026-W31", "required": true}
}
}
],
"tool_results": [
{
"call": {
"name": "query_orders",
"parameters": {"status": "late", "week": "2026-W31"}
},
"outputs": [
{"order_id": "A-1188", "days_late": 2, "carrier": "DHL"},
{"order_id": "A-1204", "days_late": 5, "carrier": "PostNL"},
{"order_id": "A-1219", "days_late": 1, "carrier": "DHL"}
]
}
]
}'
Note the tool schema too: Cohere’s v1 tools use parameter_definitions, a flat map of names to {type, description, required}, with Python-style type names like str, int, bool and List[str]. It is not JSON Schema. Copying an OpenAI function definition across unmodified is the single most common first error, and it fails at validation rather than producing a bad call.
Why outputs is a list
Every other major API takes tool output as a single string — you serialise whatever you got and hand it over. Cohere takes a list of objects, and that is a deliberate consequence of how the model grounds its answers.
Each object in outputs becomes an individually citable document. When the model then writes “three orders shipped late, the worst by five days”, the citations array can point the span “five days” at the specific result row that carried days_late: 5, rather than at the whole blob of tool output. Structure in, attribution out.
It is worth being concrete about what the model sees. The objects are not handed to it as raw JSON bytes; they are rendered into the prompt as labelled fields, which is why the key names carry meaning and why deeply nested structures read poorly. A flat object of five short scalar fields is read reliably. A three-level nested object with arrays inside it is expensive in tokens and harder for the model to reference precisely, which shows up as vaguer citations rather than as an error.
This changes how you should write the tool wrapper. Returning one object containing an array — [{"results": [...]}] — is legal and works, but it collapses every row into one citable unit and you lose the resolution the format was built to give you. Returning one object per row, with short scalar-valued keys, is what the shape is for. Keys are visible to the model, so days_late is worth more than d.
You echo the call back
The call object is not optional and is not a convenience field. Cohere’s v1 tool protocol has no per-call ID — there is no tool_call_id anywhere in it — so the only way the model can match a result to the request that produced it is by the name and parameters you echo back.
That has a direct consequence when Command issues several calls in one turn: you must send one tool_results entry per call, each with its own call object reproduced exactly as it arrived. Two calls to the same tool with different parameters are distinguished only by those parameters. Mutating them — normalising a date, lowercasing a string, dropping a field the model set to null — is how you end up with results attributed to the wrong call, and the model will happily write a confident answer on top of that mismatch.
The v2 tool message
Cohere’s v2 Chat API drops tool_results entirely and uses a message with the tool role, which will look far more familiar:
{
"model": "command-a-03-2025",
"messages": [
{"role": "user", "content": "How many orders shipped late last week?"},
{
"role": "assistant",
"tool_plan": "I will query the warehouse for late orders in week 31.",
"tool_calls": [
{
"id": "query_orders_0",
"type": "function",
"function": {
"name": "query_orders",
"arguments": "{\"status\": \"late\", \"week\": \"2026-W31\"}"
}
}
]
},
{
"role": "tool",
"tool_call_id": "query_orders_0",
"content": [
{"type": "document", "document": {"id": "0", "data": {"order_id": "A-1188", "days_late": "2"}}},
{"type": "document", "document": {"id": "1", "data": {"order_id": "A-1204", "days_late": "5"}}}
]
}
],
"tools": [{"type": "function", "function": {"name": "query_orders", "description": "...", "parameters": {"type": "object", "properties": {}}}}]
}
Three things changed and all three matter. Tools are now JSON Schema under function.parameters. Calls carry an id, and the tool message references it with tool_call_id, so the echo trick is gone. And the list-of-objects idea survived: v2 contentaccepts an array of document objects, preserving per-row citability, though it also accepts a plain string when you do not need it. The full shape is in Cohere’s tool use documentation.
One v2 field has no equivalent anywhere else: tool_plan, a plain text sentence the model emits before its calls saying what it intends to do. You must send it back on the assistant message. It is also the most useful log line in the whole loop when a run goes wrong, because it tells you what the model thought it was doing before it did it.
Migrating a v1 executor to v2
If you already have a working v1 executor, the port is mechanical but touches every part of it. The correspondence:
v1 v2
tools[].name tools[].function.name
tools[].parameter_definitions tools[].function.parameters (JSON Schema)
{"x": {"type": "str", {"type": "object",
"required": true}} "properties": {"x": {"type": "string"}},
"required": ["x"]}
response.tool_calls[].parameters message.tool_calls[].function.arguments
(an object) (a JSON string — parse it)
tool_results[].call message.tool_calls[].id + tool_call_id
tool_results[].outputs {"role": "tool", "content": [...]}
message: "" on the follow-up no empty message needed
(nothing) message.tool_plan
Two lines in that table are where the bugs come from. The first is parameters versus arguments: v1 hands you a parsed object, v2 hands you a JSON string, so a v1 executor ported by search-and-replace will pass a string where a destructuring assignment expects an object and fail with something unhelpful about undefined properties.
The second is the type vocabulary. str, int, float, bool and List[str] in v1 become string, integer, number, boolean and an array with an items schema in v2 — and required moves from a per-property boolean to a list of names on the parent object. A property that was required: false in v1 is expressed in v2 by simply being absent from the required array, which is easy to get backwards and produces a tool the model believes it must always fill in.
The compensating simplification is that v2 tool schemas are ordinary JSON Schema, so a schema you already maintain for validation — a Zod or Pydantic model exported to JSON Schema, say — can be the tool definition rather than a hand-written parallel copy of it. That removes the class of bug where the schema the model is shown and the schema your code validates against drift apart over a few months of edits.
The four shapes that get rejected
- A JSON string instead of objects.
outputstakes objects, not a serialised blob."outputs": ["{\"order_id\": \"A-1188\"}"]is the wrong type. If you truly have a string, wrap it:[{"text": "..."}]. - An object where a list belongs.
"outputs": {"order_id": "A-1188"}fails validation. Even one result is a list of one. - A missing or edited
call. In v1 there is no other join key. Reproduce it verbatim. - Mixing the versions.
tool_resultssent to/v2/chat, or atool-role message sent to/v1/chat, is an unknown field on an otherwise valid request. The two versions are separate endpoints, not a content negotiation.
If you are routing the same agent loop across Cohere and an OpenAI-shaped provider, this is the seam that costs the most: two tool schema dialects, two ways of returning results, and an ID on one side that does not exist on the other. A gateway that normalises tool definitions and tool results to one shape means the loop is written once — Multigrid does that translation per provider so a model swap does not become a rewrite of the executor.
Top comments (0)