State and Channels
Every graph has one state map. Nodes return updates, and each key's channel decides how to apply them. Start with ordinary keys; add a reducer when several nodes contribute to one value. A graph needs no model unless its nodes call one.
Run this page’s examples in order in one IEx session; later blocks reuse the modules and values defined earlier.
Declare how state merges
Run this complete graph in IEx:
defmodule MyApp.ReviewState do
use BeamWeaver.Graph
graph do
state do
channel :notes, merge: :list
channel :checks, merge: :map
end
node :order, fn _state -> %{notes: ["Order checked"], checks: %{order: true}} end
node :policy, fn _state -> %{notes: ["Policy checked"], checks: %{policy: true}} end
edge start(), :order
edge start(), :policy
edge :order, finish()
edge :policy, finish()
end
end
{:ok, state} = MyApp.ReviewState.invoke(%{})
["Order checked", "Policy checked"] = Enum.sort(state.notes)
%{order: true, policy: true} = state.checks
Both nodes write in the same step. List and map reducers merge those writes. A plain key uses LastValue and accepts one write per step; multiple writes fail with :invalid_update instead of choosing an accidental winner.
Updates replace values
Return only changed keys. Unwritten values remain. An ordinary key's update replaces its value; nested maps are not recursively merged unless you choose that reducer. Returning full state from every parallel branch creates needless write conflicts and can attempt to update managed values.
Two branches writing one ordinary key produce an explicit conflict:
alias BeamWeaver.Graph
alias BeamWeaver.Graph.Compiled
alias BeamWeaver.Core.Message
alias BeamWeaver.Graph.Channels.LastValue
clash =
Graph.new()
|> Graph.add_node(:fraud_check, fn _state -> %{status: "clear"} end)
|> Graph.add_node(:policy_check, fn _state -> %{status: "auto-approve"} end)
|> Graph.add_edge(Graph.start(), :fraud_check)
|> Graph.add_edge(Graph.start(), :policy_check)
|> Graph.add_edge(:fraud_check, Graph.end_node())
|> Graph.add_edge(:policy_check, Graph.end_node())
|> Graph.compile!()
Compiled.invoke(clash, %{})
#=> {:error, %BeamWeaver.Core.Error{type: :invalid_update, details: %{channel: :status}}}
Reducers
Graph.add_reducer(graph, key, function) installs a two-argument reducer: current value plus one write. List append, Map.merge/2, sum and maximum are common choices. Supply initial state when the reducer requires it; an empty key's first write becomes its value directly.
Prefer associative operations for parallel aggregation. If order matters, include an explicit position in each result and sort after collecting. :list and :map DSL channels provide empty initial collections.
Use separate reducers for the map and the count:
alias BeamWeaver.Graph
alias BeamWeaver.Graph.Compiled
alias BeamWeaver.Core.Message
alias BeamWeaver.Graph.Channels.LastValue
review =
Graph.new()
|> Graph.add_reducer(:checks, &Map.merge/2)
|> Graph.add_reducer(:checks_run, &+/2)
|> Graph.add_node(:fraud_check, fn _state -> %{checks: %{fraud: :clear}, checks_run: 1} end)
|> Graph.add_node(:policy_check, fn _state -> %{checks: %{policy: :ok}, checks_run: 1} end)
|> Graph.add_edge(Graph.start(), :fraud_check)
|> Graph.add_edge(Graph.start(), :policy_check)
|> Graph.add_edge(:fraud_check, Graph.end_node())
|> Graph.add_edge(:policy_check, Graph.end_node())
|> Graph.compile!()
Compiled.invoke(review, %{checks: %{}, checks_run: 0})
#=> {:ok, %{checks: %{fraud: :clear, policy: :ok}, checks_run: 2}}
Messages
Conversation state needs ID-aware merging rather than ordinary list append:
alias BeamWeaver.Core.Message
alias BeamWeaver.Graph
alias BeamWeaver.Graph.Compiled
chat =
Graph.new(state_schema: Graph.Messages.state_schema())
|> Graph.add_node(:reply, fn _state -> %{messages: [Message.assistant("Which order should I check?")]} end)
|> Graph.add_edge(Graph.start(), :reply)
|> Graph.add_edge(:reply, Graph.end_node())
|> Graph.compile!()
{:ok, conversation} = Compiled.invoke(chat, %{messages: ["Hello"]})
[:user, :assistant] = Enum.map(conversation.messages, & &1.role)
true = Enum.all?(conversation.messages, &is_binary(&1.id))
Graph.Messages.state_schema/1 normalizes messages, appends new IDs and replaces existing IDs. remove/1 deletes a known message ID; an unknown ID is an invalid update. remove_all/0 resets history before new messages are added. Agents already use this message-aware channel.
Remove a message by its assigned ID:
alias BeamWeaver.Graph
alias BeamWeaver.Graph.Compiled
alias BeamWeaver.Core.Message
alias BeamWeaver.Graph.Channels.LastValue
forget =
Graph.new(state_schema: BeamWeaver.Graph.Messages.state_schema())
|> Graph.add_node(:forget, fn %{messages: [first | _rest]} ->
%{messages: [BeamWeaver.Graph.Messages.remove(first.id)]}
end)
|> Graph.add_edge(Graph.start(), :forget)
|> Graph.add_edge(:forget, Graph.end_node())
|> Graph.compile!()
{:ok, state} = Compiled.invoke(forget, %{messages: ["My card is 4111 1111.", "Refund order A-1001"]})
Enum.map(state.messages, &Message.text/1)
#=> ["Refund order A-1001"]
Channels
Use Graph.channel/2 in state_schema: or add_channel/4 in the builder for specialized behavior. Function-based channels use {module, function}.
| Channel | Behavior | Typical use |
|---|---|---|
LastValue
| One write per step | Ordinary state |
BinaryOperatorAggregate
| Fold writes with a reducer | Lists, maps, totals |
Topic
|
Collect step writes; accumulate: true retains prior steps
| Event collections |
AnyValue
| Accept multiple writes, retain the last | Values whose writers agree |
EphemeralValue
| Keep a write for the next step | Short-lived signals |
UntrackedValue
| Keep a run-local value without checkpointing | Clients or transient handles |
DeltaChannel
| Reconstruct values from recorded writes | Growing histories |
Non-accumulating topics and ephemeral values clear after the following step unless written again. Untracked data must be supplied again after recovery. Delta writes and their prepare: transformation must be replay-safe: preparing an already-prepared write must not change it. Message delta channels assign stable IDs before persistence.
An accumulating topic records each step while the numeric channel sums amounts:
alias BeamWeaver.Graph
alias BeamWeaver.Graph.Compiled
alias BeamWeaver.Core.Message
alias BeamWeaver.Graph.Channels.LastValue
alias BeamWeaver.Graph.Channels.{BinaryOperatorAggregate, Topic}
audit =
Graph.new(
state_schema: %{
events: Graph.channel(Topic, accumulate: true),
refunded: Graph.channel({BinaryOperatorAggregate, &+/2}, initial: 0)
}
)
|> Graph.add_node(:refund, fn _state -> %{events: "refunded $40", refunded: 40} end)
|> Graph.add_node(:notify, fn _state -> %{events: "emailed Ada"} end)
|> Graph.add_edge(Graph.start(), :refund)
|> Graph.add_edge(:refund, :notify)
|> Graph.add_edge(:notify, Graph.end_node())
|> Graph.compile!()
Compiled.invoke(audit, %{})
#=> {:ok, %{events: ["refunded $40", "emailed Ada"], refunded: 40}}
Overwrite a reducer
Use Graph.Overwrite for an intentional replacement:
reset =
Graph.new()
|> Graph.add_reducer(:notes, &Kernel.++/2)
|> Graph.add_node(:close, fn _ -> %{notes: Graph.Overwrite.new(["Closed"])} end)
|> Graph.add_edge(Graph.start(), :close)
|> Graph.add_edge(:close, Graph.end_node())
|> Graph.compile!()
{:ok, %{notes: ["Closed"]}} = Compiled.invoke(reset, %{notes: ["Opened", "Reviewed"]})
One overwrite is allowed per key per step; it supersedes other writes to that key in the same step. Make that precedence deliberate.
Private keys
Graph.private_channel/2 exposes data to nodes but excludes it from ordinary output and, by default, checkpoints. checkpoint: true keeps private bookkeeping across thread calls while still hiding it from the output.
Private is a visibility convention, not encryption or tenant isolation. Do not put nonserializable handles in checkpointed channels. Agent tools using generic state injection omit private keys; explicit key injection can access them.
Nodes can use the score, but the caller receives only the public decision:
alias BeamWeaver.Graph
alias BeamWeaver.Graph.Compiled
alias BeamWeaver.Core.Message
alias BeamWeaver.Graph.Channels.LastValue
alias BeamWeaver.Graph.Channels.LastValue
scoring =
Graph.new(state_schema: %{risk_score: Graph.private_channel(LastValue)})
|> Graph.add_node(:score, fn _state -> %{risk_score: 0.12} end)
|> Graph.add_node(:decide, fn %{risk_score: score} ->
%{decision: if(score < 0.5, do: :approve, else: :review)}
end)
|> Graph.add_edge(Graph.start(), :score)
|> Graph.add_edge(:score, :decide)
|> Graph.add_edge(:decide, Graph.end_node())
|> Graph.compile!()
Compiled.invoke(scoring, %{})
#=> {:ok, %{decision: :approve}}
Keys that start nodes
A channel's subscribers: schedules named nodes when that key is written. This is data-driven scheduling without an explicit edge. Keep subscriptions visible in the workflow design so a state update cannot unexpectedly trigger an external action. Ordinary retry and idempotency rules still apply.
Writing the alert schedules its subscriber:
alias BeamWeaver.Graph
alias BeamWeaver.Graph.Compiled
alias BeamWeaver.Core.Message
alias BeamWeaver.Graph.Channels.LastValue
alerts =
Graph.new(state_schema: %{alert: Graph.channel(LastValue, subscribers: [:notify_manager])})
|> Graph.add_node(:check_refund, fn %{amount: amount} ->
if amount > 100, do: %{alert: "Large refund: $#{amount}"}, else: %{}
end)
|> Graph.add_node(:notify_manager, fn %{alert: alert} -> %{notified: alert} end)
|> Graph.add_edge(Graph.start(), :check_refund)
|> Graph.compile!()
{:ok, state} = Compiled.invoke(alerts, %{amount: 250})
state.notified
#=> "Large refund: $250"
Managed values
Graph.managed(RemainingSteps) and Graph.managed(IsLastStep) supply runtime budget information to nodes. Managed values are read-only and not saved. Use them to return a controlled partial result before hitting the recursion limit. A write to a managed key fails.
Use the remaining budget to stop a polling loop deliberately:
alias BeamWeaver.Graph
alias BeamWeaver.Graph.Compiled
alias BeamWeaver.Core.Message
alias BeamWeaver.Graph.Channels.LastValue
alias BeamWeaver.Graph.Managed.RemainingSteps
polling =
Graph.new(state_schema: %{remaining_steps: Graph.managed(RemainingSteps)})
|> Graph.add_reducer(:polls, &+/2)
|> Graph.add_node(:poll_carrier, fn state -> %{polls: 1, stop: state.remaining_steps <= 2} end)
|> Graph.add_node(:give_up, fn state -> %{status: "no news after #{state.polls} polls"} end)
|> Graph.add_edge(Graph.start(), :poll_carrier)
|> Graph.add_edge(:poll_carrier, :give_up, when: %{stop: true})
|> Graph.add_edge(:poll_carrier, :poll_carrier, default: true)
|> Graph.add_edge(:give_up, Graph.end_node())
|> Graph.compile!()
{:ok, state} = Compiled.invoke(polling, %{polls: 0}, recursion_limit: 5)
state.status
#=> "no news after 4 polls"
Input and output schemas
Graph input_schema: and output_schema: define the keys crossing the graph boundary. Undeclared keys are filtered; schema descriptions also support Compiled.get_input_json_schema/1 and get_output_json_schema/1. They are not a substitute for validating domain values in your nodes.
Only the declared output crosses the graph boundary:
alias BeamWeaver.Graph
alias BeamWeaver.Graph.Compiled
alias BeamWeaver.Core.Message
alias BeamWeaver.Graph.Channels.LastValue
triage =
Graph.new(input_schema: %{subject: :string}, output_schema: %{reply: :string})
|> Graph.add_node(:classify, fn %{subject: subject} ->
%{category: if(subject =~ ~r/refund/i, do: :billing, else: :shipping)}
end)
|> Graph.add_node(:reply, fn %{category: category} -> %{reply: "Routed to the #{category} queue."} end)
|> Graph.add_edge(Graph.start(), :classify)
|> Graph.add_edge(:classify, :reply)
|> Graph.add_edge(:reply, Graph.end_node())
|> Graph.compile!()
Compiled.invoke(triage, %{subject: "Refund for order A-1001", debug: true})
#=> {:ok, %{reply: "Routed to the billing queue."}}
UI state
BeamWeaver.Graph.UI stores component-name/props messages in an ID-aware channel. New IDs append; existing IDs replace (or merge props when requested); removal markers delete. Runtime push/delete helpers emit UI events and return data for a node update. They do not provide a frontend renderer or persist state merely by emitting an event. Your client owns allowed components and safe rendering of props.
Push a component description and include it in the node update. A later write with the same ID updates its props:
alias BeamWeaver.Graph
alias BeamWeaver.Graph.{Compiled, UI}
flow =
Graph.new(state_schema: UI.state_schema())
|> Graph.add_node(:show, fn _state, runtime ->
{:ok, card} = UI.push_ui_message(runtime, "order-card", %{status: "checking"}, id: "order-A-1001")
%{ui: [card]}
end)
|> Graph.add_node(:update, fn _ ->
%{ui: [UI.message("order-card", %{status: "shipped"}, id: "order-A-1001", merge: true)]}
end)
|> Graph.add_edge(Graph.start(), :show)
|> Graph.add_edge(:show, :update)
|> Graph.add_edge(:update, Graph.end_node())
|> Graph.compile!()
{:ok, result} = Compiled.invoke(flow, %{})
[%{id: "order-A-1001", props: %{status: "shipped"}}] = result.ui
[] = UI.reducer(result.ui, [UI.remove("order-A-1001")])
Which one to use
Choose a plain key for one writer, a reducer for combined results, the message channel for conversations, a private channel for hidden bookkeeping and an untracked channel for data that must never be restored from storage. Subgraphs explains how updates cross nested state boundaries.