The shape of a stream
Each SSE event's name is the frame kind; its data is an envelope carrying either the frame or a failure. The HTTP
status is 200 as soon as the stream opens, so everything after that point — including failure — is reported in the
stream rather than in the status line.
{
"model": "claude-sonnet-5",
"streaming": true,
"systemPrompt": "Answer in one sentence.",
"messages": [{ "type": "user", "value": "What is the capital of France?" }],
"parameters": { "maxTokens": 1024 }
}Event names and discriminators
The SSE event: name and the type discriminator inside data.value use the same lower-camel spelling.
event: streamStart carries "type":"streamStart". The error event carries no value object.
The envelope
Every data payload is a result envelope — success, code, and either value or message. One parse handles
both outcomes, so a failure never needs a different code path than a success.
{ "success": true, "code": 200, "value": { "type": "content", "value": "Paris." } }
{ "success": false, "code": 502, "message": "The provider closed the connection mid-answer." }event: streamStart
data: {"success":true,"code":200,"value":{"type":"streamStart","model":{"name":"claude-sonnet-5","tier":"high","publisher":"Anthropic","provider":"Anthropic"}}}
event: thinkingStart
data: {"success":true,"code":200,"value":{"type":"thinkingStart"}}
event: thinking
data: {"success":true,"code":200,"value":{"type":"thinking","value":"The user wants a capital city."}}
event: thinkingEnd
data: {"success":true,"code":200,"value":{"type":"thinkingEnd","data":{"anthropic.signature":"…"}}}
event: contentStart
data: {"success":true,"code":200,"value":{"type":"contentStart"}}
event: content
data: {"success":true,"code":200,"value":{"type":"content","value":"Paris."}}
event: contentEnd
data: {"success":true,"code":200,"value":{"type":"contentEnd"}}
event: stop
data: {"success":true,"code":200,"value":{"type":"stop","usage":{"provider":"anthropic","inputTokens":24,"outputTokens":3,"finishReason":"stop"}}}Frame kinds
event |
value.type |
Payload |
|---|---|---|
streamStart |
streamStart |
model — canonical name, tier, publisher, and provider. The only place the resolved model appears. |
thinkingStart |
thinkingStart |
None. Opens a reasoning run. |
thinking |
thinking |
value — a text delta to append to the open reasoning run. |
thinkingEnd |
thinkingEnd |
data — the provider state needed to replay this reasoning natively later. |
contentStart |
contentStart |
None. Opens an answer run. |
content |
content |
value — a text delta to append to the open answer run. |
contentEnd |
contentEnd |
None. Closes an answer run. |
tool |
tool |
value — the whole call at once: id, name, arguments. Never partial. |
stop |
stop |
usage. Always last on a successful stream. |
streamError |
streamError |
model — the provider that broke after producing output. |
error |
— | Terminal. success:false, an HTTP code, a message. No value. |
A run can open and close more than once. A model that thinks, answers, thinks again and then calls a tool emits
thinkingStart … thinkingEnd contentStart … contentEnd thinkingStart … thinkingEnd tool stop. Render each bracketed
run as its own element rather than appending everything to one buffer.
Tool calls arrive whole
There are no argument deltas to reassemble.
event: tool
data: {"success":true,"code":200,"value":{"type":"tool","value":{"id":"toolu_01H","name":"lookupIncident","arguments":"{\"id\":\"INC-4412\"}"}}}One parse, both outcomes
var envelope = JsonSerializer.Deserialize<Result<ModelResponse>>(sse.Data, JsonHelper.SerializerOptions);
if (!envelope.Success)
{
throw new GatewayException(envelope.Code, envelope.Message);
}
switch (envelope.Value)
{
case ModelResponse.StreamStart start: view.OpenSegment(start.Model); break;
case ModelResponse.Thinking thinking: view.Thinking.Append(thinking.Value); break;
case ModelResponse.Content content: view.Answer.Append(content.Value); break;
case ModelResponse.Tool tool: await Dispatch(tool.Value); break;
case ModelResponse.StreamError failed: view.MarkHandover(failed.Model); break;
case ModelResponse.Stop stop: view.Finish(stop.Usage); break;
}Assembling a turn from frames
The frames assemble back into exactly the messages the unary route would have returned: thinking frames between
thinkingStart and thinkingEnd become one thinking message carrying that data, content frames become one
content message, and each tool frame becomes a toolCall. Markers are not messages and are discarded.
If you persist conversations, store the assembled messages rather than the raw frames — that is the shape you send back, and it is what carries the provider state that lets reasoning replay natively.
How a stream ends
Exactly one terminal outcome closes a completed stream. An EOF without either means the stream was cut short and is a failure, not a finished answer.
| Ending | Meaning |
|---|---|
stop |
The model finished. usage is attached. |
error |
The request failed. code and message say how. |
error with 502 |
The provider opened a stream and produced nothing, or ended without a terminal event. |
| EOF with neither | Incomplete. Do not treat the text so far as a finished answer. |
Disconnecting cancels the provider call rather than letting it run on.