Skip to content

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:

go
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). ok is 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) gets ok=false and should turn.Reply normally. Respond resets 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/Group may claim the stream directly — concurrent members racing for one client stream would let whichever called Stream() 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 into State, then delivered by the next Respond like any other buffered reply. (Select is 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 close out yourself on every path you can — this is a backstop, not a substitute for closing it.
  • reply.Stream() and reply.ReadAll()/bb.Extract coexist — read the live tokens and still get the whole text (e.g. to save to memory after). You are never forced to choose.
  • Closing out is enough: the framework delivers to the client and records the complete message into State, so Respond/Notify and durability all see the whole reply. Do not also turn.Reply the 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 Respond with nothing new since the previous boundary contributes nothing (not an echo of the client's own message, not an error). A chain with no Respond at all keeps today's convention: the whole chain's last message is the answer.

A tree of flows and agents, disguised as a model.