Streaming
The chunk sequence of a streamed completion, the plancover object, pings and [DONE].
With "stream": true, POST /v1/chat/completions answers 200 with
text/event-stream and sends the turn as chat.completion.chunk objects, the
way OpenAI does. Any OpenAI-style SDK reads it as is. This page is for reading
the stream yourself, and for the PlanCover data that rides inside it.
Every refusal (a bad key, a bad request, a limit) is an HTTP status
before the stream opens; see
Errors and rate limits. Once the 200 is
sent, the stream ends in either [DONE] or one
error frame.
Native SSE API
PlanCover's own frontends use the richer Native SSE API
(/chat/stream), with eleven named events. This page covers only the
OpenAI-compatible stream.
The wire
Every frame is one data: line with a JSON chunk, followed by a blank line.
There is no event: line and no id: line. A whole turn looks like this:
data: {"id": "chatcmpl-6f1d…", "object": "chat.completion.chunk", "created": 1793260800, "model": "plancover-assistant", "choices": [{"index": 0, "delta": {"role": "assistant", "content": ""}, "finish_reason": null}], "usage": null}
data: {"id": "chatcmpl-6f1d…", "object": "chat.completion.chunk", "created": 1793260800, "model": "plancover-assistant", "choices": [{"index": 0, "delta": {"reasoning_content": "Searching policy documents…"}, "finish_reason": null}], "usage": null}
: ping
data: {"id": "chatcmpl-6f1d…", "object": "chat.completion.chunk", "created": 1793260800, "model": "plancover-assistant", "choices": [{"index": 0, "delta": {"content": "Maternity has a nine month "}, "finish_reason": null}], "usage": null}
data: {"id": "chatcmpl-6f1d…", "object": "chat.completion.chunk", "created": 1793260800, "model": "plancover-assistant", "choices": [{"index": 0, "delta": {"content": "waiting period [1]."}, "finish_reason": null}], "usage": null}
data: {"id": "chatcmpl-6f1d…", "object": "chat.completion.chunk", "created": 1793260800, "model": "plancover-assistant", "choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}], "usage": null, "plancover": {"conversation_id": "support-4821", "message_id": "6f1d…", "finish_reason": "stop", "citations": [{"index": 1, "id": "chunk-gmc-14", "title": "Group Medical Cover — Maternity — p.12", "document_id": "gmc-2026", "url": "https://docs.plancover.example/kb/gmc-2026", "snippet": "Maternity benefits are payable after a waiting period of nine months…"}], "documents": []}}
data: {"id": "chatcmpl-6f1d…", "object": "chat.completion.chunk", "created": 1793260800, "model": "plancover-assistant", "choices": [], "usage": {"prompt_tokens": 1841, "completion_tokens": 212, "total_tokens": 2053}}
data: [DONE]Every chunk of a turn has the same id (chatcmpl- and the turn's
message_id), the same created, and the request's model.
The chunks, in order
| Chunk | choices[0].delta | Notes |
|---|---|---|
| First | {"role": "assistant", "content": ""} | Sent as soon as the turn starts |
| Tool status | {"reasoning_content": "<status line>"} | One each time the assistant starts a tool. See Status lines |
| Text | {"content": "<text>"} | The answer, in order. Join every content to get it |
| Tool call | {"tool_calls": [ … ]} | Only when the turn pauses on a tool you declared. One complete call. See Confirmations and sign-in |
| Final | {} | The finish_reason and the top-level plancover object |
| Usage | none: choices is [] | Only with stream_options.include_usage. See The usage chunk |
[DONE] | The literal data: [DONE], not JSON |
finish_reason is null on every chunk but the final one. There it is
stop, or tool_calls after a tool call chunk. The real reason is
plancover.finish_reason. A stream
that ends normally always has a final chunk with a finish_reason.
Status lines
While a tool runs, the stream would otherwise be quiet, so each tool start
sends a short, fixed line in reasoning_content. It never contains the tool's
arguments, such as a claim number. Show it as a transient "working" hint, not as
part of the answer.
| Tool | Status line |
|---|---|
search_policy_knowledge | Searching policy documents… |
list_claims | Checking your claims… |
get_claim_detail | Looking up that claim… |
get_claim_status | Checking the claim status… |
list_my_policies | Checking your policies… |
get_policy_details | Looking up your policy… |
get_policy_validity | Checking your policy dates… |
get_plan_details | Looking up your plan… |
get_covered_benefits | Checking what is covered… |
get_insured_members | Checking who is covered… |
get_company_details | Looking up your company… |
get_ecard | Fetching your e-card… |
get_policy_documents | Fetching your policy documents… |
find_network_hospitals | Finding network hospitals… |
request_callback | Arranging a callback… |
| any other | Working on it… |
reasoning_content is not in OpenAI's chunk schema. @ai-sdk/openai-compatible
surfaces it as reasoning; the OpenAI Python SDK puts it on
delta.model_extra; @ai-sdk/openai ignores it.
The plancover object
The final chunk carries a top-level plancover object with everything about
the turn that has no place in OpenAI's schema. A
non-streaming completion carries the
same object.
| Field | Always | Meaning |
|---|---|---|
conversation_id | Yes | The conversation id in use: yours, or the one the server minted |
message_id | Yes | The turn's id, 32 hexadecimal characters. Also the tail of the chunk id |
finish_reason | Yes | How the turn really ended. See finish_reason |
citations | Yes | The sources the answer rests on, [] if none. See Citations |
documents | Yes | Member documents ready to download, [] if none. See Document handles |
auth_required | No | Set when the turn needs the member to sign in |
confirmation | No | Set when the turn paused on an action that needs the member's approval |
auth_required
{ "reason": "sign_in_required", "resumable": true, "resume_id": "78d8933d80fd576aaa549d0de2bee768" }The answer text already tells the member to sign in. resumable: true means the
turn is paused (finish_reason: "awaiting_sign_in"). Sign the member in with
in-chat OTP on the same
session. Then answer the sign_in tool call
if you declared it, and the paused question is answered; otherwise send the
question again on the same conversation. A non-resumable card has
resumable: false and resume_id: null.
An anonymous call can get this card too, but cannot act on it: it has no session to sign in on, and its next call is a new identity. Mint a session first for members who may ask about their own policies.
confirmation
{
"confirmation_id": "3f9a…",
"name": "request_callback",
"summary": "Ask a PlanCover advisor to call you back about: my claim is stuck",
"arguments_preview": { "reason": "my claim is stuck" },
"risk": "send",
"expires_at": "2026-09-12T11:24:07+00:00"
}Nothing has happened upstream yet. Show the summary, and treat the action as
not done until the member approves it. Declare the confirm_action tool to
get the confirmation as a tool call you can answer; see
Confirmations and sign-in. A new user
message on the conversation replaces the pending confirmation.
The usage chunk
With "stream_options": {"include_usage": true}, one more chunk follows the
final one, with empty choices and the turn's usage. Every earlier chunk
then has "usage": null. Without include_usage, no chunk has a usage key
and every chunk has a choice.
What the figures count is described under Usage.
[DONE]
The stream ends with the literal line data: [DONE], never JSON-encoded. Stop
reading there. A stream that ends with an error frame instead has no [DONE].
Pings
While the assistant searches or calls tools, nothing else may be sent for a
while. To keep proxies and load balancers from closing the connection, the
server sends an SSE comment every 15 seconds of silence
(SSE_HEARTBEAT_SECONDS):
: pingSSE parsers skip comments, and every OpenAI-style SDK does too. If you parse the
stream by hand, ignore any line that starts with :.
Errors mid-stream
A failure after the stream opened sends one data: frame with an error in the
OpenAI envelope, then
closes the stream. No [DONE] follows.
data: {"error": {"message": "That took too long. Nothing was changed.", "type": "server_error", "param": null, "code": "upstream_timeout"}}The OpenAI SDKs and the AI SDK raise this as an error. Text that streamed before it stays valid: show it, marked incomplete. The codes are listed under After the turn started.
Disconnects
If the client hangs up mid-turn, the server stops the turn at its next safe point, so the conversation's history stays consistent. Nothing is sent back. The next message on the same conversation carries on from there.