WeaveScope

Graph DSL

Use a graph when your application decides the workflow: fixed steps, conditional branches, parallel checks or a review loop. Use an agent when the model should choose its own actions. A graph can contain model calls, agents and other graphs.

This first example is ordinary Elixir and needs no provider credentials. Run the blocks in order in IEx after installing BeamWeaver.

Build and run a graph

defmodule MyApp.TicketRouter do
  use BeamWeaver.Graph

  graph do
    node :classify, &__MODULE__.classify/1
    node :billing, &__MODULE__.billing/1
    node :shipping, &__MODULE__.shipping/1

    edge start(), :classify
    edge :classify, :billing, when: %{category: :billing}
    edge :classify, :shipping, default: true
    edge :billing, finish()
    edge :shipping, finish()
  end

  def classify(%{subject: subject}),
    do: %{category: if(subject =~ ~r/refund/i, do: :billing, else: :shipping)}

  def billing(_state), do: %{queue: "billing"}
  def shipping(_state), do: %{queue: "shipping"}
end

{:ok, state} = MyApp.TicketRouter.invoke(%{subject: "Refund for order A-1001"})
"billing" = state.queue
"Refund for order A-1001" = state.subject

graph do describes the topology; node functions do the work. start() and finish() are boundary markers, not application nodes. The module supplies invoke/2, stream_events/2, resume/2, get_state/1 and compiled_graph/1. Definitions are built/compiled with each call's options. Storage must be owned outside the invocation, just as for agents .

Nodes

A node receives state and returns only changed keys. Unchanged keys remain. A two-argument node also receives BeamWeaver.Graph.Runtime with context, configuration, execution metadata and a custom-event writer.

Return Effect
A map Merge changed keys through their channels
nil Keep state unchanged
Graph.Command Update state and choose a destination
A list of Graph.Send values Dispatch dynamic work
{:error, reason} Fail the node

A Core.Error propagates as an error; other reasons normalize to :node_error. Raised exceptions become :node_exception. Return an error rather than inventing success when an external dependency fails.

Node options

Option Purpose
timeout: Milliseconds for one attempt; unlimited by default
retry: Retry policy for node failure
error_handler: Recovery after retry exhaustion
cache: Cache results through configured cache storage
input:, output: Map the node's state boundary
deps: Wait for named prerequisite nodes
destinations: Declare dynamic routing targets for validation/drawing

Unknown options fail compilation. Durable Execution explains retries and timeouts; Caching covers cache keys.

A node can combine input state with trusted runtime context:

alias BeamWeaver.Graph
alias BeamWeaver.Graph.Compiled

greet = fn state, runtime ->
  %{greeting: "Hi #{runtime.context.customer}, about order #{state.order_id}:"}
end

greeter =
  Graph.new()
  |> Graph.add_node(:greet, greet)
  |> Graph.add_edge(Graph.start(), :greet)
  |> Graph.add_edge(:greet, Graph.end_node())
  |> Graph.compile!()

{:ok, state} = Compiled.invoke(greeter, %{order_id: "A-1001"}, context: %{customer: "Ada"})
state.greeting
#=> "Hi Ada, about order A-1001:"

Return a tagged error when the action fails:

alias BeamWeaver.Graph
alias BeamWeaver.Graph.Compiled

lookup = fn _state -> {:error, "order service unavailable"} end

failing =
  Graph.new()
  |> Graph.add_node(:lookup, lookup)
  |> Graph.add_edge(Graph.start(), :lookup)
  |> Graph.add_edge(:lookup, Graph.end_node())
  |> Graph.compile!()

Compiled.invoke(failing, %{})
#=> {:error, %BeamWeaver.Core.Error{type: :node_error, message: "order service unavailable"}}

Edges and routing

An edge runs a destination after its source. when: normally examines the source update, not the entire accumulated state. Use a two-argument predicate to inspect both update and state. Map guards support literal values, allowed-value lists, ranges and predicates.

All matching guarded edges run; this can create parallel branches. A default edge runs only if none match. Without a match or default, that path ends. An exception in a predicate becomes :edge_exception.

Loops

Back-edges create loops. max_runs: bounds traversal of a guarded edge and then lets a default route handle the exhausted case. Counts belong to one run and survive continuation; a new run resets them. Always distinguish "accepted" from "revision budget exhausted" in final state.

This workflow routes a large amount to review:

alias BeamWeaver.Graph
alias BeamWeaver.Graph.Compiled

refunds =
  Graph.new()
  |> Graph.add_node(:check_refund, fn %{amount: amount} -> %{large: amount > 100} end)
  |> Graph.add_node(:auto_approve, fn _state -> %{decision: :approved} end)
  |> Graph.add_node(:manager_review, fn _state -> %{decision: :needs_manager} end)
  |> Graph.add_edge(Graph.start(), :check_refund)
  |> Graph.add_edge(:check_refund, :manager_review, when: %{large: true})
  |> Graph.add_edge(:check_refund, :auto_approve, default: true)
  |> Graph.add_edge(:auto_approve, Graph.end_node())
  |> Graph.add_edge(:manager_review, Graph.end_node())
  |> Graph.compile!()

{:ok, state} = Compiled.invoke(refunds, %{amount: 250})
state.decision
#=> :needs_manager

This local reviewer always requests revision, so the bounded loop reaches escalation:

alias BeamWeaver.Graph
alias BeamWeaver.Graph.Compiled

drafting =
  Graph.new()
  |> Graph.add_reducer(:drafts, &+/2)
  |> Graph.add_node(:draft, fn _state -> %{drafts: 1} end)
  |> Graph.add_node(:review, fn _state -> %{verdict: :revise} end)
  |> Graph.add_node(:escalate, fn _state -> %{escalated: true} end)
  |> Graph.add_edge(Graph.start(), :draft)
  |> Graph.add_edge(:draft, :review)
  |> Graph.add_edge(:review, :draft, when: %{verdict: :revise}, max_runs: 2)
  |> Graph.add_edge(:review, :escalate, default: true)
  |> Graph.add_edge(:escalate, Graph.end_node())

Compiled.invoke(Graph.compile!(drafting), %{drafts: 0})
#=> {:ok, %{drafts: 3, verdict: :revise, escalated: true}}

The same saved thread retains accumulated state; the next invocation gets a new traversal budget:

alias BeamWeaver.Graph
alias BeamWeaver.Graph.Compiled

drafting = Graph.compile!(drafting, checkpointer: BeamWeaver.Checkpoint.ETS.new())
thread = [config: %{"configurable" => %{"thread_id" => "ticket-42"}}]

{:ok, %{drafts: 3}} = Compiled.invoke(drafting, %{drafts: 0}, thread)
Compiled.invoke(drafting, %{drafts: 0}, thread)
#=> {:ok, %{drafts: 6, verdict: :revise, escalated: true}}

Parallel branches and joins

Branches execute in the same superstep and merge their updates after finishing. Use reducers when several branches write the same key. A join waits for all listed branches, even when they take different numbers of steps:

defmodule MyApp.OrderChecks do
  use BeamWeaver.Graph

  graph do
    state do
      channel :checks, merge: :map
    end

    node :order, fn _state -> %{checks: %{order_exists: true}} end
    node :policy, fn %{amount: amount} -> %{checks: %{within_limit: amount <= 100}} end
    node :decide, fn %{checks: checks} -> %{eligible: checks.order_exists and checks.within_limit} end

    edge start(), :order
    edge start(), :policy
    join [:order, :policy], :decide
    edge :decide, finish()
  end
end

{:ok, checked} = MyApp.OrderChecks.invoke(%{amount: 40})
%{order_exists: true, within_limit: true} = checked.checks
true = checked.eligible

These are local example checks, not a payment policy. deps: on a node is an alternative way to declare its join. Join arrivals are checkpointed so completed branches still count after another branch pauses or fails.

Update and route with Command

%Graph.Command{update: changes, goto: destination} combines data and control flow. goto can be a node, a list, sends, or the end marker. It replaces the returning node's ordinary outgoing edges. Declare possible targets in destinations: for static validation. Use a command when destination selection belongs inside the node; use guarded edges when it belongs to the topology.

This node returns both the category and the next destination:

alias BeamWeaver.Graph
alias BeamWeaver.Graph.Compiled

alias BeamWeaver.Graph.Command

route = fn %{subject: subject} ->
  category = if subject =~ ~r/refund/i, do: :billing, else: :shipping
  %Command{update: %{category: category}, goto: category}
end

router =
  Graph.new()
  |> Graph.add_node(:classify, route, destinations: [:billing, :shipping])
  |> Graph.add_node(:billing, fn _ -> %{reply: "Our billing team is checking your refund."} end)
  |> Graph.add_node(:shipping, fn _ -> %{reply: "Our shipping team is checking your order."} end)
  |> Graph.add_edge(Graph.start(), :classify)
  |> Graph.add_edge(:billing, Graph.end_node())
  |> Graph.add_edge(:shipping, Graph.end_node())
  |> Graph.compile!()

{:ok, state} = Compiled.invoke(router, %{subject: "Where is order A-1001?"})
state.reply
#=> "Our shipping team is checking your order."

Map-reduce with Send

Use Send when the number of tasks is known only at runtime:

alias BeamWeaver.Graph
alias BeamWeaver.Graph.{Compiled, Send}

batch =
  Graph.new()
  |> Graph.add_reducer(:results, &Kernel.++/2)
  |> Graph.add_node(
    :dispatch,
    fn %{values: values} ->
      Enum.map(values, &%Send{node: :double, update: %{value: &1}})
    end,
    destinations: [:double]
  )
  |> Graph.add_node(:double, fn %{value: value} -> %{results: [value * 2]} end)
  |> Graph.add_node(:total, fn %{results: results} -> %{total: Enum.sum(results)} end)
  |> Graph.add_edge(Graph.start(), :dispatch)
  |> Graph.add_edge(:double, :total)
  |> Graph.add_edge(:total, Graph.end_node())
  |> Graph.compile!()

{:ok, reduced} = Compiled.invoke(batch, %{values: [2, 3, 5], results: []})
[4, 6, 10] = Enum.sort(reduced.results)
20 = reduced.total

Each send runs the worker with state plus its update. Worker results merge through reducers; the following node runs after that batch. Do not depend on completion order when order is not part of the task contract.

Static graphs as modules

Inside graph do, use node, edge, join, reducer and state do. A state channel can use merge: :last, :list, :map or a reducer function, plus visibility options. Graph State explains channels.

Use the builder (Graph.new, add_node, add_edge, add_join) for topology computed from data. A graph module can return that builder from graph/0 instead of declaring nodes. Choose one style per module. A compiled value runs through BeamWeaver.Graph.Compiled; a module runs through its generated functions.

Call a model or an agent from a node

A node can call ChatModel.invoke/3 and return the reply as an update. Model initialization creates a value; network work happens at invocation. Set a node timeout that accommodates the provider timeout and retries.

Adding an agent module as the node makes it a subgraph with integrated checkpoints, interrupts and events. Calling Agent.invoke inside a plain function creates a separate run. See Subgraphs and the real-model Workflow Patterns .

Batch independent runs

Compiled.batch/3 returns sequential outcomes in input order. batch_as_completed/3 returns a lazy concurrent stream with input indices; set max concurrency and task timeouts. Keep independent thread IDs for concurrent runs. bulk_update_state/4 is a different operation: it applies checkpointed supersteps of state edits without executing business nodes.

For independent inputs, consume the indexed completion stream and correlate results by index:

{:ok, router} = MyApp.TicketRouter.compiled_graph([])
inputs = [%{subject: "Refund A-1001"}, %{subject: "Delivery A-1002"}]
[{:ok, %{queue: "billing"}}, {:ok, %{queue: "shipping"}}] = BeamWeaver.Graph.Compiled.batch(router, inputs)
{:ok, completions} = BeamWeaver.Graph.Compiled.batch_as_completed(router, inputs, max_concurrency: 2)
completed = Enum.to_list(completions)
true = length(completed) == 2
IO.inspect(completed, label: "Results with input indices")

Stream a run

stream_events/2 reports typed events; live: true makes it lazy. Consume once, handle failures and correlate subgraph namespaces. Nodes emit custom progress through runtime.stream_writer. Streaming covers lifecycle and cancellation.

Interrupts

Call Graph.interrupt/2 inside a node or configure static before/after breakpoints. Both need a checkpointer for continuation. Dynamic interrupt nodes rerun from the beginning on resume, so earlier side effects can repeat. See Human Review .

Checkpoints

Graph modules accept storage and thread configuration per invocation. Compiled graphs take the saver at compilation and thread configuration per call. A checkpoint preserves values and pending execution, not arbitrary process state. Checkpoints explains inspection and replay.

Recursion limit

A superstep runs all currently ready nodes. The default graph budget is 25 steps; reaching it returns :recursion_limit. Set a per-call or declared budget. RemainingSteps and IsLastStep managed values let nodes choose a controlled exit before exhaustion.

An unbounded polling edge is stopped by the run budget:

alias BeamWeaver.Graph
alias BeamWeaver.Graph.Compiled

poller =
  Graph.new()
  |> Graph.add_node(:poll, fn %{polls: polls} -> %{polls: polls + 1} end)
  |> Graph.add_edge(Graph.start(), :poll)
  |> Graph.add_edge(:poll, :poll)
  |> Graph.compile!()

Compiled.invoke(poller, %{polls: 0}, recursion_limit: 5)
#=> {:error, %BeamWeaver.Core.Error{type: :recursion_limit, details: %{limit: 5}}}

Validate a graph

Graph.compile/2 returns a result tuple; compile!/2 raises. validation_report/2 reports all detected problems. With validate_static: true, validation also checks reachability and paths to an end. Dynamic routing is visible only when destinations are declared.

Compiled.get_graph/2 returns the topology as data: nodes, edges, branches, and the sorted entry_points and finish_points, the nodes that edges from the start lead to and the nodes with an edge to the end. draw_mermaid/2 and draw_ascii/2 render it.

%{diagnostics: []} = Graph.validation_report(MyApp.TicketRouter.graph(), validate_static: true)
{:ok, compiled} = MyApp.TicketRouter.compiled_graph([])
topology = Compiled.get_graph(compiled)
["classify"] = topology.entry_points
["billing", "shipping"] = topology.finish_points
diagram = Compiled.draw_mermaid(compiled)
true = is_binary(diagram) and byte_size(diagram) > 0

Static validity does not prove external services or node business rules work. Exercise representative inputs and failure paths in your application tests.