Skip to content

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

  1. 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.
  2. Key text by content index. The index on content-start and content-delta exists because more than one block can be open. Concatenating every delta into one buffer works until the first response that does not.
  3. Accumulate tool arguments as a string, and parse once. tool-call-delta carries fragments of a JSON string. Parsing on each fragment throws on every one but the last. Parse at tool-call-end.
  4. Read finish_reason at message-end and act on it. MAX_TOKENS means the answer you just rendered is incomplete. TOOL_CALL means the turn is not over and the loop continues.
  5. 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.