Streaming Event Types in the Cohere Chat Endpoint
9 min read · updated August 11, 2026
Cohere streams typed events rather than a single delta shape, and the two API versions use different vocabularies. A handler written against v1 will silently ignore every v2 event, because none of the names match.
The v2 event vocabulary
Setting "stream": true on /v2/chat returns server-sent events whose type field is one of the following, per Cohere’s streaming documentation:
type carries you need it for message-start id, role request id for logs content-start content index, type opening a text block content-delta delta.message.content.text the visible tokens content-end content index closing a text block tool-plan-delta delta.message.tool_plan the model's stated plan tool-call-start tool call id, function name allocating a call tool-call-delta delta of function.arguments accumulating JSON args tool-call-end - args are now complete citation-start citation object with start/end grounded span opened citation-end - grounded span closed message-end finish_reason, usage billing, truncation
Two of those eleven carry no payload worth reading and are still worth handling. content-start tells you a block has opened and what type it is, which is where a renderer should create its target element rather than inferring one from the first delta; tool-call-end is the signal that an arguments string is complete and can be parsed. Ignoring them works for the simple case and produces a handler that cannot express anything else.
The naming is regular: every block-shaped thing has a -start, a -delta and an -end, and the deltas nest under delta.message. That regularity is the point — a switch on type with a default branch that ignores unknown values will keep working when Cohere adds a block type, which is not true of a handler that assumes every event contains text.
The order they arrive in
A single-turn text answer produces this sequence, one event per line:
event: message-start {"id": "c1a...", "delta": {"message": {"role": "assistant"}}}
event: content-start {"index": 0, "delta": {"message": {"content": {"type": "text"}}}}
event: content-delta {"index": 0, "delta": {"message": {"content": {"text": "The Utrecht"}}}}
event: content-delta {"index": 0, "delta": {"message": {"content": {"text": " store expects"}}}}
...
event: content-end {"index": 0}
event: message-end {"delta": {"finish_reason": "COMPLETE",
"usage": {"tokens": {"input_tokens": 812, "output_tokens": 34}}}}A turn that calls a tool replaces the middle: tool-plan-delta events stream the plan sentence first, then a tool-call-start / tool-call-delta / tool-call-end group per call, and message-end arrives with finish_reason of TOOL_CALL rather than COMPLETE. A grounded turn interleaves citation-start and citation-end around the content deltas they cover.
Two consequences follow. Usage is only known at message-end, so any per-request cost accounting has to survive to the end of the stream — a client that disconnects early never learns what it spent. And finish_reason also only arrives at the end, which means a stream truncated at MAX_TOKENS looks exactly like a completed one until the final event.
The v1 event vocabulary
/v1/chat uses an older and completely disjoint set of names in an event_type field:
stream-start generation_id search-queries-generation queries the model decided to run search-results documents retrieved by connectors text-generation the visible tokens, in .text citation-generation a citations array for the span just emitted tool-calls-generation complete tool calls tool-calls-chunk partial tool call, streamed stream-end finish_reason, and the full response object
Note the practical differences beyond naming. Text arrives on text-generation in a flat text field, not nested under a delta. Citations arrive as whole arrays on citation-generation rather than as start/end markers. And stream-end carries the entire assembled response, which makes a lazy v1 client legitimate: you can ignore every intermediate event and read the final one, which is impossible in v2.
The search-queries-generation and search-results events exist only in v1, because they belong to the connector mechanism that v2 did not carry forward.
What a correct handler does
- Switch on the type, with a default that ignores. Never assume an event carries text. New event types are added; unknown ones must be survivable rather than fatal.
- Key text by content index. The
indexoncontent-startandcontent-deltaexists because more than one block can be open. Concatenating every delta into one buffer works until the first response that does not. - Accumulate tool arguments as a string, and parse once.
tool-call-deltacarries fragments of a JSON string. Parsing on each fragment throws on every one but the last. Parse attool-call-end. - Read finish_reason at message-end and act on it.
MAX_TOKENSmeans the answer you just rendered is incomplete.TOOL_CALLmeans the turn is not over and the loop continues. - Treat a stream that ends without message-end as a failure. A dropped connection produces a plausible partial answer and no error. Only the terminal event proves the turn finished.
Assembling the final message
In v1 you can cheat: stream-end carries the whole response, so a client that needs the assembled message can wait for it. v2 has no such event — message-end carries only finish_reason and usage — so if you stream a turn that calls tools, you must reassemble the assistant message yourself before you can send it back in the next request. That is a real requirement rather than an optimisation: the loop cannot continue without it.
const state = { text: "", toolPlan: "", calls: new Map(), finishReason: null };
for await (const ev of stream) {
switch (ev.type) {
case "content-delta":
state.text += ev.delta.message.content.text;
break;
case "tool-plan-delta":
state.toolPlan += ev.delta.message.tool_plan;
break;
case "tool-call-start": {
const tc = ev.delta.message.tool_calls;
state.calls.set(ev.index, { id: tc.id, name: tc.function.name, args: "" });
break;
}
case "tool-call-delta":
state.calls.get(ev.index).args += ev.delta.message.tool_calls.function.arguments;
break;
case "message-end":
state.finishReason = ev.delta.finish_reason;
break;
// content-start, content-end, tool-call-end, citation-*: nothing to do here
}
}
if (state.finishReason === null) throw new Error("stream ended without message-end");The index keying on the tool call map is the part that is easy to skip and expensive to skip. When the model issues three calls in one turn, their deltas interleave, and a single args string accumulator produces one unparseable concatenation of three JSON documents. The symptom is a parse error that appears only under parallel tool use, which means it survives every test written against a single-tool example.
The other detail worth copying is the final line. A stream can end because the turn finished or because the connection dropped, and only the presence of message-end distinguishes them. Without that check a network failure at 90% of a response is indistinguishable from a short answer, and it will be stored, cached and rendered as if it were complete.
Errors arrive mid-stream
Once the response has begun, the HTTP status is already 200. An error after that point cannot change it, so Cohere sends the failure as an event in the stream with a message body describing it. A client that only checks the status code will report success for a request that failed after forty tokens.
This is the same structural problem every streaming API has, and it is worth handling once rather than per integration: the outcome of a streamed request is decided by its last event, not its first line.
It also changes what a retry can safely do. A request that fails before the first event can be retried transparently — nothing was shown to anyone. A request that fails after two hundred tokens have been rendered cannot: retrying produces a second answer that will not match the first, and a client that appends it produces visible duplication. The usual resolutions are to buffer until the stream completes and only then render, which sacrifices the entire benefit of streaming, or to make the interface able to replace an in-progress answer rather than only append to it. That is a product decision, and it is better made deliberately than discovered during an incident.
A final operational note: streaming interacts with timeouts differently from a normal request. A long grounded generation can hold a connection open for tens of seconds while emitting events the whole time, so any proxy, load balancer or serverless platform between you and the API needs an idle timeout that understands a stream is alive. A gateway that closes the connection at thirty seconds truncates long answers in a way that looks exactly like a model problem and is not one.