Streaming
The client can see tokens as the model types them — but only at each stage's terminal boundary: the flow whose reply is that stage's answer (the one before a bb.Respond, or the last in the chain if there's none). Everywhere upstream, flows hand each other complete messages, because that is what durable checkpointing needs. So State always carries whole messages; streaming is a parallel live tee that exists only at each stage's end.
Why not stream everywhere
A live stream cannot cross a flow boundary and still leave a consistent save point — durability checkpoints complete messages between flows. So there are two output paths: the durable one (always whole messages) and an ephemeral live tee to the client that exists only at each stage's terminus. Genuine keep-working-after-the-connection-closes is future engine work, not a streaming limitation.
Default agents stream for free
A default agent (no OnMessage) streams automatically when it is terminal and the client asked for it — nothing to write. To stream from a handler, tee the model's live output into the outgoing channel turn.Stream() hands you:
OnMessage(func(ctx context.Context, turn bb.Turn, chat bb.ModelChat) error {
chat.Add(turn.Last())
reply, err := chat.Ask()
if err != nil {
return err
}
if out, ok := turn.Stream(); ok { // ok only when terminal + client wants SSE
for tok := range reply.Stream() { // live model tokens
out <- tok // forward (or transform/inject)
}
close(out) // done; the full text is captured into State for you
return reply.Err() // a mid-stream model error surfaces here
}
turn.Reply(reply.ReadAll()) // buffered fallback (non-terminal, or non-streaming)
return nil
})Key facts
turn.Stream()returns(chan<- string, ok).okis claim-once per stage: the first agent to call it in a stage's terminal flow wins; everyone else (a sibling in a concurrent group, a non-terminal agent, a non-streaming request) getsok=falseand shouldturn.Replynormally.Respondresets the claim once it flushes its stage, so the next stage's terminal flow gets its own fresh shot at it.- No member of
Select/One/All/Groupmay claim the stream directly — concurrent members racing for one client stream would let whichever calledStream()first win, regardless of which member's content actually ends up in the answer (One's winner, say). Their replies still reach the client: buffered intoState, then delivered by the nextRespondlike any other buffered reply. (Selectis the exception in name only — it routes to exactly one member synchronously, so there's no race to guard against; that member's own terminal flow can stream normally.) The same rule applies to multiple agents inside one flow (WithAgent(a, b)): they run concurrently too, so none of them may claim the stream either, even when that flow is the terminal one —Respond's claim-once flush only accounts for a single streamed contribution per stage, so letting two agents race for it would silently drop whichever one lost. - If a handler claims
turn.Stream()and then returns an error without closing the channel, the framework still recovers: the flow cancels the turn's context on error, and the stream's tee goroutine watches that cancellation as well as the channel, so the request can't hang forever on an abandoned stream. Still closeoutyourself on every path you can — this is a backstop, not a substitute for closing it. reply.Stream()andreply.ReadAll()/bb.Extractcoexist — read the live tokens and still get the whole text (e.g. to save to memory after). You are never forced to choose.- Closing
outis enough: the framework delivers to the client and records the complete message intoState, soRespond/Notifyand durability all see the whole reply. Do not alsoturn.Replythe same text. reply.Err()is where a mid-stream model error lands (once tokens are flowing there is no HTTP status left to fail with; the server emits an SSE error frame).- A schema agent never streams live (structured output is validated whole); its
reply.Stream()yields the finished JSON once. - A non-streaming client gets every stage's text, joined with a blank line between stages — a
Respondwith nothing new since the previous boundary contributes nothing (not an echo of the client's own message, not an error). A chain with noRespondat all keeps today's convention: the whole chain's last message is the answer.