Workflow Patterns
Choose the smallest workflow that makes your application's decisions explicit. A pipeline, router or bounded review loop often needs less autonomy than a general agent. Graph nodes can combine real model calls with deterministic validation and data access.
Complete Installation . The live examples below use OpenAI and facts supplied in the input; they do not invent a customer's order status.
Run this page’s examples in order in one IEx session; later blocks reuse the modules and values defined earlier.
Prompt chaining
Separate extraction from writing so application code can validate the intermediate result. This module performs two real calls:
defmodule MyApp.TicketWorkflow do
use BeamWeaver.Graph
alias BeamWeaver.Core.{ChatModel, Message}
graph do
node :summarize, &__MODULE__.summarize/1, timeout: 70_000
node :draft, &__MODULE__.draft/1, timeout: 70_000
edge start(), :summarize
edge :summarize, :draft
edge :draft, finish()
end
def summarize(%{ticket: ticket}) do
with {:ok, reply} <-
ChatModel.invoke(model(), "Summarize this request in one sentence, preserving the order ID: #{ticket}") do
%{summary: Message.text(reply)}
end
end
def draft(%{summary: summary, facts: facts}) do
with {:ok, reply} <-
ChatModel.invoke(model(), [
Message.system(
"Write a brief customer reply. Use only supplied facts. Include the order ID and delivery day."
),
Message.user("Request: #{summary}\nVerified facts: #{facts}")
]) do
%{reply: Message.text(reply)}
end
end
defp model, do: BeamWeaver.Models.init_chat_model!("openai:gpt-5.4-mini", max_tokens: 256, timeout: 60_000)
end
{:ok, result} =
MyApp.TicketWorkflow.invoke(%{
ticket: "Where is my package A-1001?",
facts: "Order A-1001 arrives Thursday."
})
true = result.summary =~ "A-1001"
true = result.reply =~ "A-1001"
true = String.downcase(result.reply) =~ "thursday"
A node propagates provider errors rather than converting them into successful text. Add a validation node or guarded edge between stages when missing facts should stop the pipeline. For machine routing, prefer structured output to parsing arbitrary prose.
Routing
A classifier writes a constrained label; guarded edges select the handler. Choose an explicit fallback such as manual review for an invalid/unknown label. Do not silently route every unexpected model answer to a business category. Validate confidence and required facts where your use case needs them.
The Graph DSL demonstrates routing without a model. Substitute a structured classifier only when the input requires one. Decision models are another option; see Models .
Use a schema for the classifier and give unexpected/uncertain input an explicit manual-review label:
defmodule MyApp.TicketTeam do
use BeamWeaver.Schema
field :team, :string, enum: ["billing", "shipping", "manual"]
end
defmodule MyApp.TeamClassifier do
use BeamWeaver.Agent
model "openai:gpt-5.4-mini", max_tokens: 256, timeout: 60_000
system_prompt "Classify the request. Payment/refund questions are billing, delivery questions are shipping. Use manual when neither fits."
response_schema MyApp.TicketTeam
end
alias BeamWeaver.Graph
alias BeamWeaver.Graph.Compiled
routing =
Graph.new()
|> Graph.add_node(:classify, fn %{ticket: ticket} ->
with {:ok, answer} <- MyApp.TeamClassifier.invoke(%{messages: [ticket]}),
do: %{team: answer.structured_response["team"]}
end)
|> Graph.add_node(:billing, fn _ -> %{queue: "billing"} end)
|> Graph.add_node(:shipping, fn _ -> %{queue: "shipping"} end)
|> Graph.add_node(:manual, fn _ -> %{queue: "manual"} end)
|> Graph.add_edge(Graph.start(), :classify)
|> Graph.add_edge(:classify, :billing, when: %{team: "billing"})
|> Graph.add_edge(:classify, :shipping, when: %{team: "shipping"})
|> Graph.add_edge(:classify, :manual, default: true)
|> Graph.add_edge(:billing, Graph.end_node())
|> Graph.add_edge(:shipping, Graph.end_node())
|> Graph.add_edge(:manual, Graph.end_node())
|> Graph.compile!()
{:ok, %{team: "billing", queue: "billing"}} =
Compiled.invoke(routing, %{ticket: "I was charged twice. Please review the duplicate payment."})
Parallelization
Run independent checks concurrently and join before deciding. Each branch should write a different key or contribute through a reducer. The join must wait for every required check; a successful tone check cannot stand in for a failed policy check.
Use a map keyed by check name to preserve provenance. Do not assume several model opinions constitute independent factual evidence. Supply source facts to each reviewer and define how disagreement affects the workflow. Graph State shows a complete parallel merge.
These independent checks are ordinary application rules. A reducer collects their named results and a join waits for both:
defmodule MyApp.ParallelReview do
use BeamWeaver.Graph
graph do
state do
channel :checks, merge: :map
end
node :order_id, fn %{draft: text} -> %{checks: %{order_id: text =~ "A-1001"}} end
node :delivery, fn %{draft: text} -> %{checks: %{delivery: text =~ "Thursday"}} end
node :decide, fn %{checks: checks} -> %{approved: Enum.all?(checks, fn {_, ok} -> ok end)} end
edge start(), :order_id
edge start(), :delivery
join [:order_id, :delivery], :decide
edge :decide, finish()
end
end
{:ok, %{approved: false, checks: %{order_id: true, delivery: false}}} =
MyApp.ParallelReview.invoke(%{draft: "A-1001 has shipped."})
{:ok, %{approved: true}} = MyApp.ParallelReview.invoke(%{draft: "A-1001 arrives Thursday."})
Orchestrator-workers
When task count is dynamic, dispatch with Send. This real-model example summarizes two supplied records and retains the source ID with each answer:
alias BeamWeaver.Graph
alias BeamWeaver.Graph.{Compiled, Send}
alias BeamWeaver.Core.{ChatModel, Message}
model = BeamWeaver.Models.init_chat_model!("openai:gpt-5.4-mini", max_tokens: 128, timeout: 60_000)
workers =
Graph.new()
|> Graph.add_reducer(:summaries, &Kernel.++/2)
|> Graph.add_node(
:dispatch,
fn %{records: records} ->
Enum.map(records, &%Send{node: :summarize, update: %{record: &1}})
end,
destinations: [:summarize]
)
|> Graph.add_node(
:summarize,
fn %{record: record} ->
with {:ok, reply} <- ChatModel.invoke(model, "Summarize only this fact briefly: #{record.text}") do
%{summaries: [%{id: record.id, text: Message.text(reply)}]}
end
end,
timeout: 70_000
)
|> Graph.add_edge(Graph.start(), :dispatch)
|> Graph.add_edge(:summarize, Graph.end_node())
|> Graph.compile!()
{:ok, batch} =
Compiled.invoke(workers, %{
records: [%{id: "A-1001", text: "Delivery is Thursday."}, %{id: "A-1002", text: "Delivery is Friday."}],
summaries: []
})
["A-1001", "A-1002"] = batch.summaries |> Enum.map(& &1.id) |> Enum.sort()
true = Enum.all?(batch.summaries, &(byte_size(&1.text) > 0))
A worker can instead be an agent with tools. Bound task count and concurrency at the application boundary so a model-generated plan cannot create unbounded provider work.
Subagents
offers model-selected delegation; Send expresses application-selected work.
Evaluator-optimizer
A writer produces a candidate; an evaluator returns an explicit accepted or revision-required result. A guarded back-edge carries feedback to the writer. Bound revisions with max_runs: and the whole run with a timeout.
Require an explicit accepted verdict. Malformed output or an exhausted budget must not be treated as acceptance. Preserve the final verdict alongside the candidate so downstream code knows whether it met the criteria. Structured schemas help represent verdict, feedback and evidence consistently.
Begin with an incomplete draft, validate required facts in application code, and ask a real model to revise it. Acceptance remains an explicit result:
alias BeamWeaver.Graph
alias BeamWeaver.Graph.Compiled
alias BeamWeaver.Core.{ChatModel, Message}
model = BeamWeaver.Models.init_chat_model!("openai:gpt-5.4-mini", max_tokens: 256, timeout: 60_000)
improve =
Graph.new()
|> Graph.add_node(:evaluate, fn %{draft: draft} ->
accepted = draft =~ "A-1001" and String.downcase(draft) =~ "thursday"
%{verdict: if(accepted, do: :accept, else: :revise), feedback: "Include order A-1001 and delivery Thursday."}
end)
|> Graph.add_node(
:write,
fn state ->
with {:ok, reply} <-
ChatModel.invoke(
model,
"Revise this customer reply using only these facts: A-1001 arrives Thursday. #{state.feedback} Draft: #{state.draft}"
),
do: %{draft: Message.text(reply)}
end,
timeout: 70_000
)
|> Graph.add_edge(Graph.start(), :evaluate)
|> Graph.add_edge(:evaluate, :write, when: %{verdict: :revise}, max_runs: 3)
|> Graph.add_edge(:evaluate, Graph.end_node(), default: true)
|> Graph.add_edge(:write, :evaluate)
|> Graph.compile!()
{:ok, result} = Compiled.invoke(improve, %{draft: "Your order has shipped."}, run_timeout: 240_000)
:accept = result.verdict
true = result.draft =~ "A-1001"
true = String.downcase(result.draft) =~ "thursday"
An agent is a graph
An agent coordinates model turns and tool execution with additional hooks, state management, structured output and review. Use use BeamWeaver.Agent for that standard loop. Building it by hand means owning tool correlation, error handling, persistence and termination yourself.
An agent module can be one node in a workflow. Subgraphs shows how to map the parent input and extract the agent's answer while keeping nested lifecycle behavior.
For advanced orchestration, the same model/tool cycle can be built explicitly. This example uses a real provider and a local lookup:
alias BeamWeaver.Agent.ToolNode
alias BeamWeaver.{Models, Graph}
alias BeamWeaver.Graph.Compiled
alias BeamWeaver.Core.{ChatModel, Message}
defmodule MyApp.Tools.LookupOrder do
use BeamWeaver.Tool
name "lookup_order"
description "Look up the shipping status of an order by its id."
schema do
field :order_id, :string, description: "The order id"
end
@impl true
def invoke(_tool, %{"order_id" => order_id}, _opts), do: {:ok, "Order #{order_id} shipped on Monday."}
end
model = Models.init_chat_model!("openai:gpt-5.4-mini", max_tokens: 256, timeout: 60_000)
call_model = fn %{messages: messages} ->
{:ok, reply} = ChatModel.invoke(model, messages, tools: [MyApp.Tools.LookupOrder])
%{messages: [reply]}
end
agent_loop =
Graph.new(state_schema: BeamWeaver.Graph.Messages.state_schema())
|> Graph.add_node(:model, call_model)
|> Graph.add_node(:tools, ToolNode.new([MyApp.Tools.LookupOrder]))
|> Graph.add_edge(Graph.start(), :model)
|> Graph.add_edge(:model, :tools, when: fn %{messages: [reply]} -> reply.tool_calls != [] end)
|> Graph.add_edge(:model, Graph.end_node(), default: true)
|> Graph.add_edge(:tools, :model)
|> Graph.compile!()
{:ok, state} =
Compiled.invoke(agent_loop, %{messages: ["Use lookup_order to check A-1001, then report its shipping status."]})
true = Enum.any?(state.messages, &(&1.role == :tool and &1.name == "lookup_order"))
true = Message.text(List.last(state.messages)) =~ "Monday"
Choosing a pattern
| Pattern | Choose it for |
|---|---|
| Chaining | Fixed stages with intermediate checks |
| Routing | Distinct handlers selected by validated input |
| Parallel checks | Independent work with a clear aggregation rule |
| Orchestrator-workers | A dynamic but bounded set of subtasks |
| Evaluator-optimizer | Measurable acceptance criteria and bounded revisions |
| Agent | Model-selected action sequences |