Concepts · agent
Streaming
Per-run event streams, event lifecycle, and event types
- Consuming agent events (streaming or blocking)
- Understanding the event lifecycle and types
This page gives you the per-run stream model, consumption patterns, event order, and error semantics.
Agent.Run returns a Stream, which is the event stream of that single run. There is no shared broker and no subscription management. One run has one stream and one consumer. The generic pkg/stream package provides the stream. Its concrete type is stream.Stream[agent.Event, []ai.Message]. It connects the run’s producer goroutine to the caller.
Dual consumption
- Streaming — range over
Stream.Events(), aniter.Seq2[Event, error], to show events as they arrive. - Blocking — call
Stream.Wait()to discard events and block until the run completes. It returns the new messages that the run produced.agent.Prompt(ctx, a, input)wraps Run + Wait for the send-text-get-answer case.
Both patterns mirror ai.EventStream (Events() / Result()). The agent layer streams the same way as the provider layer.
Error semantics: one channel
The iterator’s error value is the only error channel. A successful run ends with agent_end and nil errors throughout. A run with an error yields the events produced so far. Then it yields a final iteration with a zero Event and the run’s error. There is no agent_end on error and no Err field on events. Wait() returns the same terminal error.
Canceling the context passed to Run aborts the run. The stream ends with the context’s error. For subprocess agents, cancellation interrupts or kills the child according to that backend’s semantics. The Claude agent sends a stream-json interrupt and keeps the subprocess alive. Codex and Cursor kill the per-turn child.
Event lifecycle
Each run’s stream carries one bracket pair. Session lifecycle events do not exist at this layer because backend and session lifetimes belong to the caller. Durable agents expose those notifications through a separate publisher. See Durable Events. For CLI agents, agent_start carries the backend’s SessionID once known.
The stream does not echo caller-supplied input messages as message_start / message_end events. The caller already has those messages. The agent appends them to history before the run begins. The stream emits only messages produced inside the loop: assistant outputs, tool results, and everything a hook appends.
Every message the loop adds to history crosses the stream exactly once. That is the invariant a consumer can rely on. A consumer that persists from the event stream sees each of those messages once, as a message_start / message_end pair. The two exclusions are deliberate: caller input, which the caller already has, and a partial assistant message from a run that failed mid-turn, which reaches the stream but never reaches history.
A consumer tells the kinds apart by reading the message, not the event. ai.Message.Injected marks a message that an injector added, and Origin names the injector. There used to be an Event.Input flag for this, which said only “a BeforeStop hook produced this” and said nothing about a stored transcript. The field on the message replaces it, and it answers the same question live and later.
A successful run emits events in this order:
agent_start ← first event; carries SessionID if available turn_start message_start (assistant) message_update ← repeated as tokens stream (provider-dependent) message_end ┌── for each tool call (interleaved per-call) ──┐ tool_execution_start tool_execution_update ← optional streaming progress tool_execution_end message_start (tool result) message_end └────────────────────────────────────────────────┘ turn_end turn_start ← next turn if tools were called ... turn_end message_start (append) ← if an AfterTurn hook appends messages message_end message_start (follow-up) ← if a BeforeStop hook continues the loop message_end turn_start ← loop continues ... turn_endagent_end ← carries new Messages and accumulated Usagemessage_update events are provider-dependent. The Default agent emits one per provider delta as text, thinking, and tool blocks accumulate. Transports that deliver complete messages per line skip directly from message_start to message_end. The Claude CLI agent’s NDJSON assistant lines are one example.
When tool calls run in parallel, per-call event groups remain self-consistent. This applies when all calls in the batch are parallel-safe. Each goroutine pushes its own tool_execution_start → tool_execution_end → message_start (tool result) → message_end as a contiguous sub-sequence. Groups for different calls can interleave with each other.
If the provider stream errors mid-message, the agent still emits message_end. That event carries the partial accumulated message. Consumers that track message scope never see a dangling message_start. The matching turn_end follows. Then the stream ends with the error.
Incremental message accumulation
During streaming, the agent maintains a partial ai.Message that grows as provider deltas arrive. Every message_update carries two views:
AssistantEvent— the raw provider delta withContentIndexandDeltafor append-style showing.Message— an independent snapshot of the accumulated message at that point.
message_start fires on the first non-done provider event, before any content arrives. message_end carries the provider’s final authoritative message.
Design: providers emit bare deltas. Delta events carry no Message. Only EventDone carries the final message. The agent’s streamTurn bridges this gap by accumulating content blocks incrementally.
Abandoning a stream
Breaking out of Events() early does not stop the run. The producer keeps running with subsequent events removed, so history stays consistent. To abort it, cancel the run’s context. To block until it finishes, call Wait().
Event design: flat struct, not union types
Go has no discriminated unions. Events use a single Event struct with a Type discriminator. Each event type populates only its relevant fields. Unused fields are zero-valued. Custom MarshalJSON includes only relevant fields per event type for a clean wire format. agent_start and agent_end stay explicit events because serialized event logs need in-band run brackets.
Related
- Agent — construction, options, entry points
- Agent State — runtime state observability
- Durable Events — persistence receipts and session lifecycle notifications