Human-In-The-Loop
Human-in-the-loop (HITL) review lets a graph or agent pause and wait for an external decision before continuing. Use it for approval, review and edit flows, form collection, or any workflow where the next step depends on a human or service outside the graph.
BeamWeaver has two layers:
-
BeamWeaver.Graph.interrupt/1is the low-level graph primitive. Call it from a graph node when you want to pause at an application-defined point. -
BeamWeaver.Agent.Middleware.HumanInTheLoopis the agent tool-review layer. It packages selected tool calls into a standard review payload and applies approve, edit, reject, or respond decisions on resume.
BeamWeaver.Agent.Middleware.HumanInTheLoop checks model-proposed tool calls against a policy. When review is required, the middleware emits a graph interrupt before tool execution. The interrupted state is saved through the configured checkpointer, and the run resumes after your UI, CLI, or service passes back review decisions.
Basic Agent Configuration
For Deep Agents-style tool review, set interrupt_on directly on the agent. BeamWeaver turns this option into BeamWeaver.Agent.Middleware.HumanInTheLoop and pauses after the model proposes a matching tool call, before the tool executes.
alias BeamWeaver.Agent
alias BeamWeaver.Checkpoint.ETS, as: CheckpointETS
alias BeamWeaver.Core.Message
checkpointer = CheckpointETS.new()
config = %{"configurable" => %{"thread_id" => "hitl-review-1"}}
{:ok, agent} =
Agent.build(
model: BeamWeaver.Models.init_chat_model!("openai:gpt-5.4"),
tools: [
MyApp.Tools.RemoveFile,
MyApp.Tools.FetchFile,
MyApp.Tools.NotifyEmail
],
interrupt_on: %{
"remove_file" => true,
"fetch_file" => false,
"notify_email" => %{allowed_decisions: [:approve, :reject]}
},
checkpointer: checkpointer
)
case Agent.invoke(
agent,
%{messages: [Message.user("Delete temp.txt and email the admin.")]},
config: config
) do
{:interrupted, interrupt} ->
IO.inspect(interrupt.value.action_requests, label: "pending tool reviews")
Agent.resume(
agent,
%{decisions: [%{type: :approve}, %{type: :reject}]},
config: config
)
{:ok, state} ->
{:ok, state}
end
Module-defined agents use the interrupt_on DSL:
defmodule MyApp.ReviewedAgent do
use BeamWeaver.Agent
model BeamWeaver.Models.init_chat_model!("anthropic:claude-sonnet-4-6")
tools do
tool MyApp.Tools.RemoveFile
tool MyApp.Tools.FetchFile
tool MyApp.Tools.NotifyEmail
end
interrupt_on %{
"remove_file" => true,
"fetch_file" => false,
"notify_email" => %{allowed_decisions: [:approve, :reject]}
}
end
true enables the default decisions for a tool: :approve, :edit, :reject, and :respond. false disables review for that tool. A map can restrict decisions with :allowed_decisions, gate review with :when or :predicate, and add review metadata such as :description or :args_schema.
Pause Using Interrupt
Call BeamWeaver.Graph.interrupt/1 inside a graph node to pause execution and surface a JSON-safe payload to the caller:
alias BeamWeaver.Graph
approval_node = fn state ->
approved =
Graph.interrupt(%{
question: "Approve this action?",
details: state.action_details
})
%{approved: approved}
end
When the graph reaches the interrupt:
-
Execution pauses inside the node.
-
The checkpointer saves the current thread state.
-
The caller receives
{:interrupted, interrupt}. -
The graph waits until the same thread is resumed.
-
The resume value becomes the return value of
Graph.interrupt/1.
Interrupt payloads should be maps, lists, strings, numbers, booleans, or nil when possible. Persistent checkpointers must be able to serialize both the interrupt payload and the eventual resume value.
Resuming Interrupts
Resume a paused graph with BeamWeaver.Graph.Compiled.resume/3 and the same thread configuration:
alias BeamWeaver.Checkpoint.ETS, as: CheckpointETS
alias BeamWeaver.Graph
alias BeamWeaver.Graph.Compiled
checkpointer = CheckpointETS.new()
config = %{"configurable" => %{"thread_id" => "approval-1"}}
graph =
Graph.new(name: "ApprovalFlow")
|> Graph.add_node(:approval, approval_node)
|> Graph.add_edge(Graph.start(), :approval)
|> Graph.add_edge(:approval, Graph.end_node())
|> Graph.compile!(checkpointer: checkpointer)
{:interrupted, interrupt} =
Compiled.invoke(
graph,
%{action_details: "Transfer $500"},
config: config
)
IO.inspect(interrupt.value, label: "waiting for")
{:ok, state} =
Compiled.resume(graph, true, config: config)
state.approved
#=> true
If the intended resume value is nil, use Graph.null_resume() so BeamWeaver can distinguish "resume with nil" from "no resume value was supplied":
Compiled.resume(graph, Graph.null_resume(), config: config)
BeamWeaver also accepts %BeamWeaver.Graph.Command{resume: value} as graph input for command-driven graph control. For application code, Compiled.resume/3 is usually clearer because it says directly that the run is continuing an interrupted checkpoint.
Handling Multiple Interrupts
Parallel branches can pause at the same super-step. When there is more than one pending interrupt, resume with a map keyed by interrupt ID:
alias BeamWeaver.Graph
alias BeamWeaver.Graph.Compiled
graph =
Graph.new(name: "ParallelQuestions")
|> Graph.add_reducer(:answers, fn existing, update ->
existing ++ List.wrap(update)
end)
|> Graph.add_node(:left, fn _state ->
answer = Graph.interrupt("question_a")
%{answers: ["a:#{answer}"]}
end)
|> Graph.add_node(:right, fn _state ->
answer = Graph.interrupt("question_b")
%{answers: ["b:#{answer}"]}
end)
|> Graph.add_edge(Graph.start(), :left)
|> Graph.add_edge(Graph.start(), :right)
|> Graph.add_edge(:left, Graph.end_node())
|> Graph.add_edge(:right, Graph.end_node())
|> Graph.compile!(checkpointer: checkpointer)
{:interrupted, _interrupt} =
Compiled.invoke(graph, %{answers: []}, config: config)
{:ok, snapshot} =
Compiled.get_state(graph, config)
resume_values =
Map.new(snapshot.interrupts, fn interrupt ->
{interrupt.id, "answer for #{interrupt.value}"}
end)
{:ok, state} =
Compiled.resume(graph, resume_values, config: config)
When only one interrupt is pending, a scalar resume value is accepted. When multiple interrupts are pending, use the map shape so each answer is paired with the intended paused task.
Approval, Review, And Validation Patterns
Use interrupt/1 directly when the graph itself owns the human interaction. For example, approve or cancel a branch:
alias BeamWeaver.Graph.Command
approval_node = fn state ->
decision =
BeamWeaver.Graph.interrupt(%{
question: "Proceed?",
details: state.action_details
})
if decision do
%Command{goto: :proceed}
else
%Command{goto: :cancel}
end
end
Review and edit generated state:
review_node = fn state ->
edited =
BeamWeaver.Graph.interrupt(%{
instruction: "Review and edit this draft",
content: state.generated_text
})
%{generated_text: edited}
end
Validate human input by interrupting again when the resume value is invalid:
age_node = fn _state ->
ask_age = fn ask, prompt ->
answer = BeamWeaver.Graph.interrupt(prompt)
if is_integer(answer) and answer > 0 do
answer
else
ask.(ask, "'#{answer}' is not a valid age. Please enter a positive number.")
end
end
age = ask_age.(ask_age, "What is your age?")
%{age: age}
end
The node restarts from the beginning on each resume, so keep the interrupt sequence deterministic. See Rules Of Interrupts .
Decision Types
BeamWeaver supports the same four review decisions as LangChain:
| Decision | Behavior | Common use |
|---|---|---|
:approve
| Execute the original tool call as-is. | Send an approved email draft. |
:edit
| Execute a modified tool call. | Change the recipient, query, or file path before running. |
:reject
| Skip execution and add rejection feedback as an error tool message. | Tell the agent why a proposed action is not allowed. |
:respond
| Skip execution and use the human's message as the successful tool result. |
Implement an ask_user tool where the human is the tool backend.
|
Allowed decisions are configured per tool. If multiple tool calls are paused in one interrupt, provide one decision for each action in the same order as the interrupt's action_requests.
Advanced Middleware Configuration
Top-level interrupt_on is the usual path. Add BeamWeaver.Agent.Middleware.HumanInTheLoop manually when you need custom middleware ordering, a custom description_prefix, or explicit tool schemas for early validation of edited arguments. interrupt_on maps tool names to review policies:
defmodule MyApp.ReviewedAgent do
use BeamWeaver.Agent
alias BeamWeaver.Agent.Middleware.HumanInTheLoop
model BeamWeaver.Models.init_chat_model!("openai:gpt-5.4")
tools do
tool MyApp.Tools.WriteFile
tool MyApp.Tools.ExecuteSQL
tool MyApp.Tools.ReadData
end
middleware do
use HumanInTheLoop,
interrupt_on: %{
"write_file" => true,
"execute_sql" => %{allowed_decisions: [:approve, :reject]},
"read_data" => false
},
description_prefix: "Tool execution pending approval",
tools: [MyApp.Tools.WriteFile, MyApp.Tools.ExecuteSQL, MyApp.Tools.ReadData]
end
end
true enables all decisions for that tool: :approve, :edit, :reject, and :respond. false means the middleware will not interrupt that tool.
Useful middleware options:
| Option | Meaning |
|---|---|
:interrupt_on
|
Required map of tool names to true, false, or a review config map.
|
:interrupt_mode
|
:all reviews all matching calls in a model response; :first pauses on the first matching call.
|
:description_prefix
|
Prefix used for generated review descriptions. Defaults to "Tool execution requires approval".
|
:tools
| Tool modules or structs used to validate edited tool arguments against tool schemas. |
Review config options:
| Option | Meaning |
|---|---|
:allowed_decisions
|
List of allowed decision atoms or strings: :approve, :edit, :reject, :respond.
|
:description
| Static description string, or a function with arity 2 or 3. |
:args_schema
| Optional argument schema included in the review config for UI validation. |
:when / :predicate
|
Function with arity 1, 2, or 3. Return true to review this call.
|
description functions receive the tool call and state. Arity-3 functions also receive runtime:
middleware do
use BeamWeaver.Agent.Middleware.HumanInTheLoop,
interrupt_on: %{
"execute_sql" => %{
allowed_decisions: [:approve, :reject],
description: fn call, _state, runtime ->
context = runtime.context || %{}
user = Map.get(context, :user_id) || Map.get(context, "user_id", "unknown")
args = Map.get(call, :args, Map.get(call, "args", %{}))
"SQL requested by #{user}: #{inspect(args)}"
end
}
}
end
Predicate functions receive the tool call, optionally the graph state, and optionally runtime. Use interrupt_mode: :first with predicates when one approval should pause a multi-tool response before any later matching calls are reviewed.
Respond To Interrupts
Invoke the agent with a checkpointer and a stable thread ID. A reviewed tool call returns {:interrupted, interrupt} instead of completing the run.
alias BeamWeaver.Agent.HITL
alias BeamWeaver.Checkpoint.ETS, as: CheckpointETS
alias BeamWeaver.Core.Message
checkpointer = CheckpointETS.new()
config = %{"configurable" => %{"thread_id" => "review-thread-1"}}
case MyApp.ReviewedAgent.invoke(
%{messages: [Message.user("Delete old records from the database.")]},
checkpointer: checkpointer,
config: config
) do
{:interrupted, interrupt} ->
{:ok, review} = HITL.from_interrupt(interrupt)
Enum.each(review.action_requests, fn action ->
IO.inspect(action, label: "pending action")
end)
MyApp.ReviewedAgent.resume(
%{decisions: [%{type: :approve}]},
checkpointer: checkpointer,
config: config
)
{:ok, state} ->
{:ok, state}
{:error, error} ->
{:error, error}
end
The interrupt value contains the review payload:
%{
action_requests: [
%{
name: "execute_sql",
args: %{"query" => "DELETE FROM records WHERE created_at < NOW() - INTERVAL '30 days';"},
description: "Tool execution pending approval\n\nTool: execute_sql\nArgs: ..."
}
],
review_configs: [
%{
action_name: "execute_sql",
allowed_decisions: ["approve", "reject"]
}
]
}
BeamWeaver.Agent.HITL.from_interrupt/1 is optional, but it is useful for turning raw interrupt maps into framework-agnostic review structs that Phoenix, LiveView, CLI, or API code can render safely.
Resume Decisions
Approve
Approve the original tool call and continue execution:
MyApp.ReviewedAgent.resume(
%{decisions: [%{type: :approve}]},
checkpointer: checkpointer,
config: config
)
Edit
Edit the tool call before execution:
MyApp.ReviewedAgent.resume(
%{
decisions: [
%{
type: :edit,
edited_action: %{
name: "execute_sql",
args: %{
"query" => "DELETE FROM records WHERE status = 'archived' AND created_at < NOW() - INTERVAL '30 days';"
}
}
}
]
},
checkpointer: checkpointer,
config: config
)
When :tools or :args_schema is provided, BeamWeaver validates edited arguments before allowing the tool call to continue.
Reject
Reject the tool call and send feedback to the model as a tool error:
MyApp.ReviewedAgent.resume(
%{
decisions: [
%{
type: :reject,
message: "Do not delete records. Ask for a date range and dry-run count first."
}
]
},
checkpointer: checkpointer,
config: config
)
Respond
Use :respond when the tool's real backend is the human reply. BeamWeaver skips the tool implementation and returns the human message as a successful tool result:
MyApp.ReviewedAgent.resume_review(
[
HITL.decision(:respond, message: "Blue.")
],
checkpointer: checkpointer,
config: config
)
Multiple Decisions
Provide one decision per pending action, in interrupt order:
%{
decisions: [
%{type: :approve},
%{
type: :edit,
edited_action: %{
name: "send_email",
args: %{"to" => "[email protected]", "subject" => "Review needed"}
}
},
%{type: :reject, message: "This action is not allowed for the current user."}
]
}
Subagent Interrupts
Synchronous BeamWeaver.Agent.Subagent.Spec subagents are normal BeamWeaver agents under the hood. The parent agent's interrupt_on configuration is passed to generated subagents unless the subagent supplies its own review map:
alias BeamWeaver.Agent.Subagent
BeamWeaver.Agent.build(
model: BeamWeaver.Models.init_chat_model!("openai:gpt-5.4"),
tools: [MyApp.Tools.DeleteFile, MyApp.Tools.ReadFile],
interrupt_on: %{
"delete_file" => true,
"read_file" => false
},
subagents: [
Subagent.Spec.new(
name: "file-manager",
description: "Manages file operations.",
system_prompt: "Review filesystem work carefully.",
tools: [MyApp.Tools.DeleteFile, MyApp.Tools.ReadFile],
interrupt_on: %{
"delete_file" => true,
"read_file" => true
}
)
],
checkpointer: checkpointer
)
If that subagent triggers a review, the parent run returns the same {:interrupted, interrupt} shape and resumes with the same thread config.
BeamWeaver.Agent.Subagent.Compiled uses the agent you provide. Configure HITL on that compiled agent directly. Async subagents run behind their own remote client; protect supervisor-side async tools such as start_async_task or cancel_async_task with parent interrupt_on when those operations need approval.
Streaming With HITL
Use BeamWeaver.Agent.stream_events/3 when your UI needs live events while the agent runs. BeamWeaver does not expose Python's stream_mode=["updates", "messages"] chunks; it returns typed stream envelopes or an interrupted result with the events collected up to the pause.
alias BeamWeaver.Stream.Envelope
case MyApp.ReviewedAgent.stream_events(
%{messages: [Message.user("Delete old records from the database.")]},
checkpointer: checkpointer,
config: config
) do
{:interrupted, interrupt} ->
IO.inspect(interrupt.value.action_requests, label: "review required")
for %Envelope{} = envelope <- interrupt.events do
IO.inspect(envelope.event, label: "event before interrupt")
end
{:ok, events} ->
for %Envelope{} = envelope <- events do
IO.inspect(envelope.event, label: "event")
end
end
Execution Lifecycle
The HITL middleware runs in after_model, after the model has produced an AI message and before any tool calls execute:
-
The agent calls the model.
-
The model returns an assistant message, possibly with tool calls.
-
HumanInTheLoopinspects the tool calls againstinterrupt_on. -
Matching calls are packaged as
action_requestsandreview_configs. -
The middleware calls
BeamWeaver.Graph.interrupt/1. -
The checkpointer stores the paused graph state.
-
A resume decision approves, edits, rejects, or responds to each pending action.
-
Approved and edited actions continue to the tool node. Rejected and responded actions become synthesized tool messages. The graph then continues normally.
Custom HITL Logic
Prefer BeamWeaver.Agent.Middleware.HumanInTheLoop for tool review. For specialized graph workflows, you can use the lower-level interrupt primitive inside a graph node or custom middleware:
alias BeamWeaver.Graph
approval_node = fn state ->
decision =
Graph.interrupt(%{
question: "Approve deployment?",
release: state[:release]
})
%{approved_by_human: decision}
end
Low-level interrupts are not automatically rendered as HITL review payloads. Use the middleware when you want the standard action_requests, review_configs, and decision handling.
Interrupts In Tools
The recommended BeamWeaver equivalent of LangGraph's "interrupt inside a tool" pattern is BeamWeaver.Agent.Middleware.HumanInTheLoop. The middleware pauses after the model proposes tool calls and before the tool node executes them, which keeps approval logic outside business tool implementations and gives you consistent action_requests, review_configs, and decision validation.
middleware do
use BeamWeaver.Agent.Middleware.HumanInTheLoop,
interrupt_on: %{
"send_email" => %{
allowed_decisions: [:approve, :edit, :reject],
description: "Approve or edit this email before sending."
}
},
tools: [MyApp.Tools.SendEmail]
end
Calling BeamWeaver.Graph.interrupt/1 from arbitrary tool code is not a public tool API contract. It only works when the tool is executed within a graph task that has the interrupt scratchpad installed, and it will not produce the standard HITL review payload. Put reusable approval policy in the middleware unless you are deliberately writing custom graph runtime code.
Rules Of Interrupts
Interrupts pause by throwing a private graph control signal. The runtime catches that signal, persists the checkpoint, and returns {:interrupted, interrupt}. When the graph resumes, the node starts again from the beginning and replay continues until the interrupted call receives the resume value.
Do Not Catch The Interrupt Signal
Do not wrap Graph.interrupt/1 in broad try/catch code that catches all throws. Catching the control signal prevents the runtime from observing the interrupt.
Good shape:
node = fn state ->
decision = BeamWeaver.Graph.interrupt("Approve?")
case MyApp.External.call(decision) do
{:ok, result} -> %{result: result}
{:error, reason} -> %{error: inspect(reason)}
end
end
Risky shape:
node = fn _state ->
try do
BeamWeaver.Graph.interrupt("Approve?")
catch
_kind, _value ->
%{error: "caught graph control signal"}
end
end
Keep Interrupt Order Stable
Within a single node, resume values are matched to interrupt calls by the order in which that node reaches them. Keep the sequence stable across executions:
node = fn _state ->
name = BeamWeaver.Graph.interrupt("What is your name?")
age = BeamWeaver.Graph.interrupt("What is your age?")
city = BeamWeaver.Graph.interrupt("What is your city?")
%{name: name, age: age, city: city}
end
Avoid conditionally skipping interrupts or looping over data whose length may change between the original run and a resume.
Keep Payloads Serializable
Interrupt values and resume values should be simple data: maps, lists, strings, numbers, booleans, and nil. Do not put functions, PIDs, ports, anonymous references, or application structs that your checkpointer cannot serialize into interrupt payloads.
Make Earlier Side Effects Idempotent
Code before an interrupt runs again on resume. If a node writes to an external database, filesystem, queue, email provider, or payment service before it calls Graph.interrupt/1, make that side effect idempotent or move it after the interrupt.
Safer shape:
node = fn state ->
approved = BeamWeaver.Graph.interrupt("Create audit log?")
if approved do
MyApp.Audit.upsert_event!(state.event_id, state.audit_payload)
end
%{approved: approved}
end
Using With Subgraphs
If a parent node invokes a subgraph and the subgraph interrupts, BeamWeaver stores checkpoint namespace metadata so the parent can resume the child checkpoint. The parent graph is resumed with the same top-level thread config:
{:interrupted, interrupt} =
BeamWeaver.Graph.Compiled.invoke(parent_graph, input, config: config)
{:ok, state} =
BeamWeaver.Graph.Compiled.resume(
parent_graph,
%{interrupt.id => "approved"},
config: config
)
As with ordinary nodes, code before the interrupted point may run again. Keep parent-node setup work and child-node setup work deterministic or idempotent.
Static Breakpoints
Static breakpoints pause before or after named nodes. They are useful for debugging and state inspection, not for product HITL workflows where the pause condition belongs in application logic.
Compile a graph with interrupt_before: or interrupt_after::
graph =
Graph.new(name: "DebuggableFlow")
|> Graph.add_node(:load, load)
|> Graph.add_node(:process, process)
|> Graph.add_edge(:load, :process)
|> Graph.add_edge(Graph.start(), :load)
|> Graph.add_edge(:process, Graph.end_node())
|> Graph.compile!(
checkpointer: checkpointer,
interrupt_before: [:process]
)
{:interrupted, breakpoint} =
Compiled.invoke(graph, %{input: "data"}, config: config)
breakpoint.timing
#=> :before
{:ok, state} =
Compiled.resume(graph, nil, config: config)
The agent DSL exposes the same idea:
defmodule MyApp.DebugAgent do
use BeamWeaver.Agent
interrupt_before [:model]
interrupt_after [:tools]
end
BeamWeaver supports compile-time static breakpoints. It does not expose LangGraph's per-invocation interrupt_before or interrupt_after arguments.