Events and streaming
Events are flat tagged-union objects. The type field selects the remaining
shape; persisted events receive an id and processed_at.
Send events
POST /v1/sessions/{id}/events
{
"events": [{
"type": "user.message",
"content": [{"type": "text", "text": "Inspect the failure"}]
}]
}
The list must be non-empty. Clients cannot provide id or processed_at, and
server-emitted event types are rejected.
The PostgreSQL/Temporal control plane currently accepts:
| Event | Current behavior |
|---|---|
user.message | Starts a model turn |
user.interrupt | Cancels active work, or is acknowledged as an idle no-op; omit session_thread_id to interrupt every non-archived Thread, or provide it to target one Thread |
user.custom_tool_result | Supplies a result for a pending custom tool call |
user.tool_result | Supplies a client-executed built-in result for a self_hosted environment |
user.tool_confirmation | Allows or denies a pending always_ask built-in |
user.define_outcome | Starts outcome work and independent evaluation/revision cycles |
system.message | Text-only companion context; must be the final event immediately after a message or tool result |
user.tool_result is rejected unless it resolves a pending self-hosted
agent.tool_use. A tool confirmation or result may include
session_thread_id when answering a child action cross-posted onto the primary
stream. The event reference is authoritative for routing; the hint is optional
and a conflicting value is rejected. The persisted response and any companion
system.message belong to the child Thread.
An interrupt without session_thread_id is admitted to the primary and every
active, non-archived child Thread. Supplying session_thread_id admits it only
to that Thread and wakes only its Workflow. An unknown or cross-Session Thread
ID is rejected, as is a direct target that can no longer execute. The send
response still contains exactly one event for each caller-submitted input;
server-created fan-out receipts remain internal to their owning Thread ledgers.
Content blocks are validated as closed tagged unions. Images accept base64
and url sources. Documents accept base64, text, url, and file
sources, with text sources requiring media_type: text/plain. A file
source is supported only on user.message documents and must reference a
ready, top-level File in the same Workspace whose declared media type and bytes
are eligible for UTF-8 text projection. Mango checks the stored size and
SHA-256, rejects empty, NUL-containing, non-UTF-8, scoped, missing, corrupt,
and non-text Files, and limits resolved File content to 262,144 characters per
admission. The private text snapshot is committed with the event and projected
to the model as an ordinary text block; the public event continues to expose
only the documented file_id. File-sourced images and tool-result documents
return 422 unsupported_error.
Tool-result search blocks require source, title, citations.enabled, and
an array of text blocks. Unknown fields are rejected at every nested level.
Outcome rubrics accept inline {type: "text", content: "..."} or reusable
{type: "file", file_id: "file_..."} inputs. Both are limited to 262,144
characters. A File rubric must be a ready, top-level File in the same Workspace;
Mango validates and snapshots its UTF-8 text before admitting the event. The
public event keeps the File reference and never exposes the private snapshot.
An interrupt is first committed to PostgreSQL and then delivered to each
affected Workflow as a metadata-only wakeup. An interrupt that commits before
turn completion wins that ordering point: the owning execution ends with
exactly one idle boundary whose stop reason is end_turn. If completion
commits first, a later interrupt is an idle control event. A batch may place a
new user.message after user.interrupt to redirect the primary Session into
another turn.
The response echoes only the submitted events:
{"data": []}
Status and agent output are asynchronous and appear in list/stream results.
List events
GET /v1/sessions/{id}/events
Supported query parameters:
| Parameter | Meaning |
|---|---|
limit | Page size, 1–1000; default 100. Values above 1000 return a validation error. |
order | asc or desc; default asc |
page | Opaque forward cursor |
types[] | Repeatable event type filter |
created_at[gt|gte|lt|lte] | RFC 3339 bounds applied to processed_at |
{
"data": [{
"id": "sevt_...",
"type": "agent.message",
"content": [{"type": "text", "text": "Done"}],
"processed_at": "2026-07-27T00:00:01Z"
}],
"next_page": null
}
Ordering, timestamp bounds, and cursors use processed_at, matching the public
contract despite the compatibility query name created_at. Ascending order
places processed events first and unprocessed (null) events last; descending
order reverses that placement. The internal receipt sequence is used only as a
stable tie-breaker for equal or null timestamps and is never exposed.
Stream events
GET /v1/sessions/{id}/events/stream
The endpoint returns text/event-stream. Persisted frames use their event type
as the SSE discriminator:
event: agent.message
data: {"id":"sevt_...","type":"agent.message","content":[...],"processed_at":"..."}
The stream starts after the latest committed event at subscription time. It does
not replay earlier history and does not implement Last-Event-ID.
For reconnect without gaps:
- open a new stream;
- list persisted history while the stream is open;
- merge both sources and deduplicate by event
id.
An active stream receives session.deleted and then EOF when its session is
deleted.
Live event previews
Opt in to ephemeral assistant text and privacy-safe thinking starts:
GET /v1/sessions/{id}/events/stream?event_deltas[]=agent.message
Repeat event_deltas[] with agent.thinking to receive thinking starts too.
The stream may first emit:
event: event_start
data: {"type":"event_start","event":{"type":"agent.message","id":"sevt_..."}}
event: event_delta
data: {"type":"event_delta","event_id":"sevt_...","delta":{"type":"content_delta","index":0,"content":{"type":"text","text":"partial"}}}
The preview and eventual persisted agent.message share the same event ID.
An agent.thinking preview emits only event_start; it never emits reasoning
content or an event_delta. Its ID is reused by the privacy-preserving
persisted agent.thinking event.
Preview frames:
- are delivered only to opted-in subscribers;
- are never written to the event log;
- never appear in list results;
- may end without an authoritative event if generation or the process fails.
If generation is interrupted after preview delivery, the terminal
span.model_request_end closes the preview even when no buffered
agent.message is produced.
Model request span IDs are allocated before provider execution. The durable
span.model_request_start is appended before its provider call can publish a
preview; the authoritative message and correlated span.model_request_end
follow when that model round completes. If a preview arrives before its
persisted-event wakeup, the stream reconciles its PostgreSQL cursor before
forwarding the first preview frame.
An outcome evaluation durably publishes span.outcome_evaluation_start before
the grader runs and emits periodic span.outcome_evaluation_ongoing events
while it remains active. Its terminal span.outcome_evaluation_end references
the start event. This correlation is preserved when an active grader is
interrupted. If interruption happens before any evaluation start can be
published, the documented outcome_evaluation_start_id is the empty string.
Completed needs_revision evaluation pairs remain in history and the
interrupt end uses the next zero-based iteration.
Backpressure
NATS Core carries best-effort wakeups and previews across API/worker processes; PostgreSQL remains authoritative. Each subscriber periodically reconciles its durable PostgreSQL cursor, so a lost wakeup delays a persisted event but does not lose it. The output buffer is bounded: a slow subscriber is disconnected and should reconnect using the open-stream-then-list procedure above. Preview frames are ephemeral and can be lost. A replacement API process opens a new subscription after the latest committed event; listing history after that stream is open fills the process-restart gap without replaying old events on the stream itself.