DEV Community

Multigrid
Multigrid

Posted on • Originally published at multigrid.ai

Claude's Streaming Event Types, From message_start to message_stop

Set stream: true and the response becomes a sequence of server-sent events with a strict structure. Almost every streaming bug comes from assuming text arrives in one flat channel — it does not, and the block index is the thing that keeps it straight.

The sequence

A stream is one message_start, then a group of events per content block, then message_delta and message_stop. The shape is fixed:

message_start
  content_block_start   (index 0)
  content_block_delta   (index 0)  x many
  content_block_stop    (index 0)
  content_block_start   (index 1)
  content_block_delta   (index 1)  x many
  content_block_stop    (index 1)
message_delta
message_stop
Enter fullscreen mode Exit fullscreen mode

The nesting is the important part. content_block_delta events only ever appear between a content_block_start and the matching content_block_stop, and each one carries the index of the block it belongs to. A response with a text block and a tool call produces two such groups, and concatenating every delta into a single buffer without regard for index silently merges the model’s prose into the tool call’s arguments.

A captured stream, annotated

Here is what actually crosses the wire for a short answer. Each event arrives with an SSE event: line and a data: line carrying JSON whose type repeats the event name:

event: message_start
data: {"type":"message_start","message":{"id":"msg_01…","type":"message",
       "role":"assistant","model":"claude-opus-4-6","content":[],
       "stop_reason":null,"stop_sequence":null,
       "usage":{"input_tokens":27,"output_tokens":1}}}

event: content_block_start
data: {"type":"content_block_start","index":0,
       "content_block":{"type":"text","text":""}}

event: content_block_delta
data: {"type":"content_block_delta","index":0,
       "delta":{"type":"text_delta","text":"Roll"}}

event: content_block_delta
data: {"type":"content_block_delta","index":0,
       "delta":{"type":"text_delta","text":" back with"}}

event: content_block_stop
data: {"type":"content_block_stop","index":0}

event: message_delta
data: {"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},
       "usage":{"output_tokens":19}}

event: message_stop
data: {"type":"message_stop"}
Enter fullscreen mode Exit fullscreen mode

Three things to notice. message_start carries the full message envelope with an empty content array — the id, the model and the input token count are available immediately, before any text. content_block_start carries the block with its content empty, telling you what type is about to stream. And the final answer is the concatenation of the deltas; no event ever repeats the whole text.

The delta types

content_block_delta is a wrapper. The interesting part is delta.type, and it varies with the block:

  • text_delta — carries text. Append it.
  • input_json_delta — carries partial_json, a fragment of the tool call arguments. These fragments are not individually valid JSON. Accumulate the string across all deltas for that index and parse once at content_block_stop.
  • thinking_delta — carries thinking, the reasoning text on a thinking-enabled model.
  • signature_delta — carries a cryptographic signature that closes a thinking block. You do not display it; you preserve it verbatim if you send the thinking block back in a later turn.
  • citations_delta — carries a citation object when the citations feature is enabled on a document. See how citations come back.

The input_json_delta accumulation is where streamed tool calling usually breaks. A partial such as {"deploy_i parses as nothing at all; only the concatenation of every fragment for that index is the complete argument object.

Reassembling the message

Most streaming code eventually needs the finished message as well as the live text — to append to history, to log, to check stop_reason. The accumulator is small, and writing it once makes the shape obvious:

const blocks = [];
let stopReason = null, usage = {};

for await (const ev of stream) {
  switch (ev.type) {
    case "message_start":
      usage = { ...ev.message.usage };
      break;
    case "content_block_start":
      blocks[ev.index] = { ...ev.content_block, _acc: "" };
      break;
    case "content_block_delta": {
      const b = blocks[ev.index];
      if (ev.delta.type === "text_delta")       b._acc += ev.delta.text;
      if (ev.delta.type === "thinking_delta")   b._acc += ev.delta.thinking;
      if (ev.delta.type === "input_json_delta") b._acc += ev.delta.partial_json;
      if (ev.delta.type === "signature_delta")  b.signature = ev.delta.signature;
      break;
    }
    case "content_block_stop": {
      const b = blocks[ev.index];
      if (b.type === "text")     b.text = b._acc;
      if (b.type === "thinking") b.thinking = b._acc;
      if (b.type === "tool_use") b.input = b._acc.length ? JSON.parse(b._acc) : {};
      delete b._acc;
      break;
    }
    case "message_delta":
      stopReason = ev.delta.stop_reason;
      usage.output_tokens = ev.usage.output_tokens;
      break;
  }
}
Enter fullscreen mode Exit fullscreen mode

Three things this makes visible. The accumulator is keyed by index, not by a single buffer, which is the whole reason the index exists. The delta type decides which field the accumulation ends up in, so text, reasoning and tool arguments never mix. And message_stop does not appear in the switch at all — there is nothing to do with it, because everything final arrived on the event before.

The official SDKs ship a helper that does exactly this and hands you the completed message, and using it is usually right. It is worth knowing that the helper is buffering on your behalf rather than doing something you cannot: if you await the finished message and never read the events, you have paid for streaming and thrown away the only thing it buys, which is output before the response is complete.

Where usage and stop_reason live

Neither is where a buffered-response mental model expects. input_tokens arrives at the start, inside message_start, because the prompt has already been read by the time the first token is produced. The final output_tokens and the stop_reason arrive at the end, on message_delta — not on message_stop, which carries nothing but its own type.

if (event.type === "message_start") {
  inputTokens = event.message.usage.input_tokens;
}
if (event.type === "message_delta") {
  stopReason  = event.delta.stop_reason;      // end_turn | tool_use | max_tokens | …
  outputTokens = event.usage.output_tokens;   // final count, not incremental
}
Enter fullscreen mode Exit fullscreen mode

Code that computes cost from a stream and never handles message_delta under-reports every request by its entire output.

The same split applies to the cache fields. On a cached request, cache_read_input_tokens and cache_creation_input_tokens ride along with the input count on message_start, so a cost calculation assembled at message_delta alone will see the output tokens and none of the input. You need both ends of the stream to price a request, which is the single most common reason streamed and buffered traffic disagree in a usage dashboard.

It is also worth being precise about what output_tokens on message_delta means: it is the final total, not an increment to add to a running count. Treating it as a delta and summing it with a per-event tally double-counts the entire response.

ping, error, and the events people miss

  • ping — may be sent at any point to keep the connection alive. It carries no content. A parser with an exhaustive switch that throws on unknown types will fall over on it, which is why the documentation says to ignore unrecognised event types rather than treat them as errors.
  • error — an error can arrive mid-stream, after some text has already been delivered. The HTTP status was 200; the failure is inside the body. Handle it as a distinct terminal state from message_stop.
  • Unknown types are expected. New block types arrive with new features, and a stream carrying one your parser does not know is not a malformed stream. Skip what you do not recognise.

The reference for all of this is Anthropic’s streaming documentation, which lists the current event and delta types.

Streaming shapes are the part of a multi-provider integration that does not port. The event names, the nesting, and where the token counts live all differ between vendors, so a UI written against one provider’s stream needs a second parser for the next. Routing between models means either writing both parsers or putting something in front that normalises the events — which is a large part of what an LLM gateway is doing.

Related

Top comments (0)