Streaming
Streaming lets your application show progress while an agent works: text, tool activity, state changes and your own events. It also changes ownership: the process consuming a live stream owns the run.
Complete Installation . Run these blocks in order in IEx.
Stream an answer
This real OpenAI call uses a local policy tool that emits a progress event:
defmodule MyApp.PolicySearch do
use BeamWeaver.Tool
name "policy_search"
description "Read the refund timing policy."
injected :runtime, :tool_runtime
schema do
field :query, :string
end
@impl true
def invoke(_tool, %{runtime: runtime}, _opts) do
BeamWeaver.Core.ToolRuntime.emit_output_delta(runtime, "Reading the refund policy")
{:ok, "Refunds take five business days."}
end
end
defmodule MyApp.StreamingAgent do
use BeamWeaver.Agent
model "openai:gpt-5.4-mini", stream: true, max_tokens: 256, timeout: 60_000
system_prompt "Call policy_search for refund questions, then explain its result."
tools [MyApp.PolicySearch]
end
alias BeamWeaver.Stream.Events
{:ok, stream} = MyApp.StreamingAgent.stream_events(%{messages: ["How long do refunds take?"]}, live: true)
events = Enum.to_list(stream)
false = Enum.any?(events, &match?(%{event: %Events.Error{}}, &1))
%{event: %{tool_call_id: call_id}} =
Enum.find(events, &match?(%{event: %Events.ToolStart{tool_name: "policy_search"}}, &1))
true = Enum.any?(events, &match?(%{event: %Events.ToolFinish{tool_call_id: ^call_id}}, &1))
true = Enum.any?(events, &match?(%{event: %Events.ToolDelta{}}, &1))
text = for %{event: %Events.Token{text: text}} <- events, into: "", do: text
true = byte_size(text) > 0
IO.puts(text)
Use Enum.each/2 and IO.write/1 on token events to display text as it arrives. This example collects once so subsequent inspections do not repeat the run. Token boundaries vary by provider; a token event need not contain a whole word.
Live streams
Two options control different things: stream: true requests incremental model output; live: true returns an enumerable that runs while you consume it. Without model streaming, the agent can still report tools and state changes, but model text arrives as a whole reply.
A live stream is lazy. Each enumeration starts a run. Enumerating it twice can make two sets of API calls and repeat tool side effects. If you need several views, materialize it once or dispatch events from a single consumer. Stopping enumeration early cancels the run; so does the consumer process exiting. Cancellation cannot undo an external side effect already performed.
Without live: true, the call runs to completion and returns a collected list. An interrupted collected run returns {:interrupted, interrupt} with interrupt.events; failure returns {:error, error}.
Events
Each item is a BeamWeaver.Stream.Envelope with an event, run_id, node, namespace and step. Use identifiers to correlate concurrent work.
| Event | Purpose |
|---|---|
Token
|
Incremental model text in text
|
MessageChunk
|
Text, reasoning or partial tool-call data in chunk
|
Message
|
A completed message in message
|
ToolStart, ToolDelta, ToolFinish, ToolError
|
Tool lifecycle and progress, correlated by tool_call_id
|
GraphUpdate, GraphValue
| Per-node updates or state values |
Custom
|
Application-defined payload
|
Task, Checkpoint
| Graph execution and persistence events |
Done
| A streamed model call ended; an agent can make another call |
Debug
| Lifecycle information, model-call failure or an interrupt |
Error
| The entire run failed |
BeamWeaver.Stream.event_mode/1 classifies events. stream_mode: limits emission to channels such as :messages, :tools, :updates, :values, :custom, :tasks, :checkpoints, :lifecycle and :debug. The default :events includes all channels. Terminal failures remain visible when filtering. Restrict channels when you do not need full state snapshots.
Filter the already-collected events from the first example without starting another run:
tool_events = Enum.filter(events, &(BeamWeaver.Stream.event_mode(&1.event) == :tools))
true = Enum.any?(tool_events, &match?(%{event: %Events.ToolStart{}}, &1))
true = Enum.any?(tool_events, &match?(%{event: %Events.ToolFinish{}}, &1))
IO.inspect(Enum.map(tool_events, & &1.event.__struct__), label: "Tool event types")
The final answer
Use tokens for provisional UI text and completed messages as the authoritative copy. Do not append a complete message after already displaying its tokens.
answers =
for %{event: %Events.Message{message: %{role: :assistant} = message}} <- events,
message.tool_calls in [nil, []],
do: BeamWeaver.Core.Message.text(message)
answer = List.last(answers)
true = is_binary(answer) and byte_size(answer) > 0
End of enumeration means the stream ended; inspect terminal errors and interrupts before treating that as successful completion. A Done event belongs to a model call, not necessarily the whole agent run.
Errors
A live run can fail after stream_events/2 returned successfully. Handle Events.Error inside the consumer. Invalid setup can instead return an error before a stream is created.
A failed model attempt emits a debug payload with type: :model_call_failed. Retry or fallback middleware can then start another attempt. Already emitted tokens from the failed attempt are provisional and must not be presented as part of the final answer. Clear or replace that attempt's UI text. A tool failure is different: ToolError normally goes back to the model for recovery.
Built-in Middleware describes retry policies. Testing shows how to reproduce failure paths deterministically.
Tool progress and custom events
The tool above uses BeamWeaver.Core.ToolRuntime.emit_output_delta/2. For arbitrary progress payloads, a graph node's second argument is a runtime whose stream_writer emits custom events:
defmodule MyApp.ProgressGraph do
use BeamWeaver.Graph
graph do
node :classify, &__MODULE__.classify/2
edge start(), :classify
edge :classify, finish()
end
def classify(_state, runtime) do
runtime.stream_writer.(%{stage: "classifying"})
%{category: "billing"}
end
end
{:ok, stream} = MyApp.ProgressGraph.stream_events(%{}, live: true)
progress = for %{event: %Events.Custom{payload: payload}} <- stream, do: payload
[%{stage: "classifying"}] = progress
Tools can inject :runtime for the same writer. Middleware state hooks receive it directly; wrapper hooks read request.runtime.
Tool-call arguments
MessageChunk can contain partial JSON arguments in tool_call_chunks. They are display data until complete; never execute an action from a partial argument string. Agents assemble the call for you. Direct model consumers can use BeamWeaver.Stream.Finalize.finalize/1 to merge chunks into a message. Use call IDs and indices to keep simultaneous calls separate.
Tool arguments can span arbitrary chunk boundaries. Assemble them before parsing/executing:
alias BeamWeaver.Core.Messages
chunks = [
Messages.ai_chunk("",
tool_call_chunks: [Messages.tool_call_chunk(id: "call_1", index: 0, name: "lookup_order", args: ~s({"order_id))]
),
Messages.ai_chunk("", tool_call_chunks: [Messages.tool_call_chunk(id: "call_1", index: 0, args: ~s(": "A-1001"}))])
]
[call] = BeamWeaver.Stream.Finalize.finalize(chunks).tool_calls
{call.name, call.args}
#=> {"lookup_order", %{"order_id" => "A-1001"}}
When the run pauses
A live interrupt arrives as a debug payload with type: :interrupt and an interrupt value, then the stream ends. Display its review request and resume using the original thread/checkpointer plus fresh caller context. When provided, interrupt.response_schema describes the expected form data. See
Human Review
.
Redact a stream
A sensitive value can span tokens. Replacing text inside each token separately can leak it. BeamWeaver.Agent.Middleware.PII.stream_transform/1 buffers text until it can apply the configured policy and also transforms message chunks, whole messages, tool data and state snapshots.
Apply the transform before events leave your server. PII middleware on saved messages alone does not protect already-emitted tokens. Guardrails explains the distinction and redaction strategies.
The transform buffers text across chunk boundaries. Supply a synthetic email in two chunks to see that boundary handled:
alias BeamWeaver.Stream.{Envelope, Events}
{transform, pending} = BeamWeaver.Agent.Middleware.PII.stream_transform(type: :email, strategy: :redact)
source = [
%Envelope{event: %Events.Token{text: "Contact ada@"}},
%Envelope{event: %Events.Token{text: "example.com for help."}},
%Envelope{event: %Events.Done{}}
]
safe =
source
|> Stream.transform(pending, fn envelope, acc ->
{{:ok, emitted}, acc} = transform.(envelope, acc)
{List.wrap(emitted), acc}
end)
|> Enum.to_list()
text = for %{event: %Events.Token{text: part}} <- safe, into: "", do: part
false = text =~ "[email protected]"
true = text =~ "[REDACTED_EMAIL]"
Subgraphs
Subgraph events retain the path of node names in namespace. Parent events use []. A subgraph failure is first a node failure; retry or an error handler may recover it. Only an unrecovered top-level failure ends with Error.
BeamWeaver.Stream.Subgraphs.from_events/2 summarizes paths and statuses. BeamWeaver.Stream.Lifecycle.from_events/2 produces lifecycle events from those summaries. Keep Task events when using either helper.
Push tokens to a LiveView
Consume the stream in a supervised task rather than in the LiveView callback. Send only UI events to the LiveView: provisional text, completed messages, review requests and failures. Correlate messages with a run ID so events from an older request cannot overwrite a newer answer.
Tie task cancellation explicitly to your application's lifecycle. A normal process exit does not automatically terminate every linked process. Cancel work when the view disconnects if it belongs to that view; use a persistent job owner when it must continue. Keep final state in a checkpointer rather than only in a socket assignment.
This complete bridge uses a supervised task and monitors the receiving process. A LiveView can handle the tagged messages in handle_info/2; on disconnect, the monitor stops the worker. Only one process enumerates the stream:
defmodule MyApp.ChatStream do
def start(supervisor, receiver, question) do
owner = make_ref()
{:ok, worker} =
Task.Supervisor.start_child(supervisor, fn ->
receiver_ref = Process.monitor(receiver)
task =
Task.Supervisor.async_nolink(supervisor, fn ->
case MyApp.StreamingAgent.stream_events(%{messages: [question]}, live: true) do
{:ok, stream} ->
Enum.each(stream, &send(receiver, {:agent_event, owner, &1}))
:stream_ended
other ->
other
end
end)
receive do
{ref, result} when ref == task.ref ->
Process.demonitor(ref, [:flush])
Process.demonitor(receiver_ref, [:flush])
send(receiver, {:agent_stream_finished, owner, result})
{:DOWN, ^receiver_ref, :process, ^receiver, _} ->
Task.shutdown(task, :brutal_kill)
{:DOWN, ref, :process, _pid, reason} when ref == task.ref ->
send(receiver, {:agent_stream_finished, owner, {:error, reason}})
end
end)
{:ok, worker, owner}
end
end
{:ok, supervisor} = Task.Supervisor.start_link()
try do
{:ok, _worker, run_ref} = MyApp.ChatStream.start(supervisor, self(), "How long do refunds take?")
receive do
{:agent_stream_finished, ^run_ref, :stream_ended} -> :ok
{:agent_stream_finished, ^run_ref, other} -> raise inspect(other)
after
120_000 -> raise "Stream did not finish"
end
receive do
{:agent_event, ^run_ref, %{event: %BeamWeaver.Stream.Events.ToolFinish{}}} -> :ok
after
0 -> raise "No tool completion was delivered"
end
after
Supervisor.stop(supervisor)
end
Custom producers and backpressure
BeamWeaver.Stream.Mux combines caller-owned producers through a bounded queue. A Stream.Sink lets a producer emit and check cancellation; child sinks attach nested namespaces. Stream.Policy chooses blocking, drop-newest, drop-oldest or error behavior on overflow. Size the buffer and emission timeout for the consumer; dropping events can make an otherwise complete run impossible to reconstruct from its stream.
Heartbeats are opt-in progress signals, not evidence that a model/tool finished. Read-side MessagesTransformer, Transformers and lifecycle/subgraph helpers project collected events without starting work. A synthesized message from partial deltas is not a substitute for a completed provider outcome.
Producer forms select the callback argument:
| Entry | Callback receives |
|---|---|
fn emit -> ... end
| A one-argument emission function |
{name, function}
| An emission function, with a named producer |
{:sink, name, function}
|
A Stream.Sink for child namespaces and cancellation checks
|
max_buffer: defaults to 256 items, emit_timeout: to 5,000 ms, and the whole-stream timeout: to infinity. Choose explicit budgets for application work. Two producers can share a bounded stream while retaining separate namespaces. All events below are consumed once:
alias BeamWeaver.Stream.{Mux, Sink, Events}
producers =
for name <- ["orders", "policy"] do
{:sink, name,
fn sink ->
child = Sink.child(sink, namespace: [name])
:ok = Sink.emit(child, %Events.Custom{payload: %{source: name, status: "ready"}})
end}
end
items = Mux.stream(producers, max_buffer: 4, overflow: :block, timeout: 5_000) |> Enum.to_list()
ready =
for %{event: %Events.Custom{payload: %{source: source}}, namespace: namespace} <- items,
do: {source, namespace}
true = {"orders", ["orders"]} in ready
true = {"policy", ["policy"]} in ready
false = Enum.any?(items, &match?(%{event: %Events.Error{}}, &1))