The streaming verdict frame
Streamed responses end with one extra SSE comment carrying the turn's verdict โ its exact shape, what to do with it, and why it cannot break a client that ignores it.
A streamed Messages, Chat Completions, or Responses call is inspected the same way a non-streamed one is, but it has nowhere to put a response header: headers go out before the first frame, and the verdict does not exist until the last one. So the verdict travels at the end of the stream instead, on a single extra line.
That line is an SSE comment. It is spec-legal, every compliant parser
throws it away, and a client that does nothing about it keeps working
exactly as it did before. Reading it is opt-in. From 0.8, all three
streamed APIs omit X-Sentisec-Verdict, including Chat requests routed to
another provider. Native Bedrock ConverseStream carries its verdict on
messageStop.additionalModelResponseFields.sentisec instead of an SSE
comment; it also omits the header.
The frame
One line, then the blank line that terminates it:
: sentisec-verdict {"verdict":"HIJACK"}Three parts, and each of them is the contract:
- The leading
:is what makes it a comment. The SSE specification defines a line beginning with a colon as a comment and requires parsers to ignore it. This frame is never adata:frame and never a namedevent:โ see why below. sentisec-verdictidentifies it. Match on this prefix; any other comment on the stream is not ours.- A compact JSON object carrying
verdict. Read theverdictkey and ignore any key you do not recognise, so a future addition to the payload cannot break your parser.
A stream on which a tool call is held for approval carries one more
sentisec-verdict comment, with an approval object and no verdict
key. Skip it when you read the verdict. What it holds is in
calls waiting for approval.
Where it sits
On a normally completed SSE response, one verdict comment follows the
provider's terminal event. An interrupted or failed stream may have no
comment. For Chat providers that send data: [DONE], it follows that
sentinel:
data: {"id":"chatcmpl-โฆ","choices":[{"index":0,"delta":{"content":"โฆ"}}]}
data: [DONE]
: sentisec-verdict {"verdict":"NOMINAL"}From 0.8, a blocked Chat stream emits exactly one data: [DONE] before
the verdict comment. An allowed Bedrock mantle Chat stream retains
its declared finish and ends without [DONE], as mantle sends it.
Messages uses message_stop; Responses uses its own completion,
incomplete, or failure events. Do not require a Chat sentinel on those
APIs or append one yourself.
A client that stops reading at its provider's terminal sentinel will never see the frame. That is expected and supported โ it is why the verdict is not the mechanism by which anything important is communicated. Read to the end of the stream only if you want it.
What the value means
The verdict is one of the same four literals used everywhere else in Sentisec โ the response header on non-streamed calls, the session record, the dashboard, and your SIEM export:
NOMINAL ยท ELEVATED ยท SUSPICIOUS ยท HIJACK
Each is defined on Verdicts and interventions.
One turn can contain several actions, and the frame reports the worst
verdict of the turn โ the highest-severity result any inspected action
produced, not the last one and not an average. Reporting NOMINAL
because two of three actions were clean would hide the third. A turn with
nothing to inspect reports NOMINAL.
Reading it
Read the raw stream lines rather than letting your provider SDK decode them, since the SDK will have discarded the comment by the time you see anything:
# pseudocode โ inspect raw SSE lines before the provider parser
if line starts with ": sentisec-verdict ": read its JSON verdict
otherwise: pass the line to normal provider stream handlingUseful things to do with the value once you have it: show it in your own
UI while the run is live, log it alongside your
session id
so a support conversation can start from the right session, or raise a
banner when a turn comes back SUSPICIOUS or worse.
Anthropic thinking and tool continuation
Keep the completed assistant message's original content blocks when
continuing an Anthropic conversation. Do not rebuild that message from
visible text alone: a thinking block can have an empty thinking
string and a populated signature. Dropping that block or its signature
changes the turn and can make the next request invalid.
Preserve the block types, order, thinking data, signatures, and
tool_use ids exactly as returned. Collect streamed deltas into completed
blocks before storing the assistant turn, or use the provider SDK's
completed-message result. Return tool outputs in a following user message
as tool_result blocks with their matching tool_use_id; batch results
for parallel calls together. Display text separately from this replay
record. Do not fabricate a completed block after an interrupted stream.
The trailing Sentisec verdict comment is metadata outside those content blocks. It belongs in your telemetry, not in the assistant message you send back. Keep the same run id throughout the continuation.
What not to do with it
- Do not parse it as a
data:event. It is not a completion chunk and will not validate as one. If your parser is strict aboutdata:frames, that strictness is the reason this is a comment. - Do not require a named event type. There is none, deliberately.
- Do not use it to detect a block. A stopped call is visible in the response body itself, in your provider's own format โ the shapes are on Chat endpoints. The frame tells you how the turn was judged, not what was done about it.
- Do not treat it as the record. It is an inline hint. The authoritative verdict is the session (Sessions) or the dashboard, both of which carry the evidence behind it.
Why a comment and not an event
Strict clients โ opencode and the Vercel AI SDK among them โ validate
every data: chunk on an OpenAI-shaped stream against the completion
schema and reject anything that is not a valid chunk. A trailing
data: frame carrying a verdict would fail that validation and surface
to your users as a type error at the end of an otherwise successful run.
A named event: fares no better with clients that switch exhaustively on
event type.
A comment has neither problem. It is the one line in the SSE grammar that
every conforming parser is required to discard, which makes it the only
place a verdict can ride without asking every client in the world to
change first. That is a fixed property of this contract, not an
implementation detail: the frame will not become a data: frame or a
named event later.
Related
Headers and streaming frames
The headers Sentisec reads and adds, how requests are stitched into sessions, and the trailing verdict frame on streamed responses.
Errors and limits
Every Sentisec-specific error you can get back, what caused it, and the exact fix โ plus how request-rate limits are surfaced.