Agents
Agents combine chat models with tools to build systems that can reason about a task, choose actions, observe tool results, and continue until a final response or stop condition is reached.
BeamWeaver agents are graph-backed. The public API is Elixir-native: use BeamWeaver.Agent for application modules and BeamWeaver.Agent.build/1 for runtime-configured agents. Both compile to the same BeamWeaver.Graph runtime with a model node, optional tool node, middleware nodes, checkpoints, interrupts, typed streams, and tagged errors.
graph TD
QUERY([input])
LLM{model}
TOOL(tools)
ANSWER([output])
QUERY --> LLM
LLM -- action --> TOOL
TOOL -- observation --> LLM
LLM -- finish --> ANSWER
Semantic Module Shape
Stable application agents should use category blocks. This keeps model-callable tools, lifecycle middleware, subagents, prompts, and structured output visible at a glance:
defmodule MyApp.SupportAgent do
use BeamWeaver.Agent
name "support_agent"
description "Answer support questions from approved sources."
model "openai:gpt-5.4", temperature: 0.2, timeout: 120_000
tools do
tool MyApp.Tools.SearchDocs
end
middleware do
use BeamWeaver.Agent.Middleware.ToolCallNormalization
use BeamWeaver.Agent.Middleware.ModelRetry, max_attempts: 6, retry_on: :transient
end
system_prompt "Answer only from retrieved documentation."
response_schema MyApp.Schemas.SupportAnswer, name: "support_answer"
end
Use BeamWeaver.Agent.build/1 when the same fields need to be assembled at runtime. See
Semantic DSL
for the full module surface.
Core Components
Model
The model is the reasoning engine for the agent. BeamWeaver accepts any value that implements BeamWeaver.Core.ChatModel.
Static Model
Static models are configured once and used for every model call.
Use a provider-prefixed model identifier when you want BeamWeaver to construct the model:
model =
BeamWeaver.Models.init_chat_model!("openai:gpt-5.4",
temperature: 0.1,
max_output_tokens: 1_000,
timeout: 30_000
)
{:ok, agent} =
BeamWeaver.Agent.build(
name: "support_agent",
model: model,
tools: []
)
The model's :timeout is used twice in agent builds:
-
Provider transport timeout: the HTTP receive budget passed to the model adapter.
-
Agent model-node timeout: the graph node budget for the model call.
Agent model nodes are graph nodes, so they still have a hard execution budget. BeamWeaver chooses the generated model node timeout in this order:
-
model_opts[:timeout]fromBeamWeaver.Agent.build/1or themodel/2DSL. -
model.timeoutwhen the configured model struct exposes a timeout field. -
The graph node default of
5_000milliseconds.
For long summarization, extraction, or tool-heavy calls, set the timeout on the model or through model_opts:
{:ok, agent} =
BeamWeaver.Agent.build(
model: "openai:gpt-5.4",
model_opts: [
timeout: 120_000,
max_output_tokens: 2_000
],
tools: []
)
defmodule MyApp.SummaryAgent do
use BeamWeaver.Agent
model "anthropic:claude-sonnet-4-6", timeout: 120_000
end
BeamWeaver.Agent.invoke(..., timeout: value) is not the model-node timeout; it is an invocation/server call option. When invoking through a supervised agent server process, keep that call timeout at least as large as the model-node timeout.
OpenAI identifiers can also be inferred:
model = BeamWeaver.Models.init_chat_model!("gpt-5.4")
Google Gemini identifiers must be explicit:
model = BeamWeaver.Models.init_chat_model!("google:gemini-3.5-flash")
For tests, examples, or local workflows, use the fake provider:
model =
BeamWeaver.Models.init_chat_model!("fake:chat",
response: BeamWeaver.Core.Message.assistant("hello")
)
You can also configure an agent as a normal module:
defmodule MyApp.SupportAgent do
use BeamWeaver.Agent
model BeamWeaver.Models.init_chat_model!("openai:gpt-5.4")
end
Dynamic Model
Dynamic models are selected at the model-call boundary with middleware. Keep a static default model on the agent, then override %BeamWeaver.Agent.ModelRequest{} when runtime state or context calls for a different model:
defmodule MyApp.DynamicModelSelection do
@behaviour BeamWeaver.Agent.Middleware
alias BeamWeaver.Agent.ModelRequest
def wrap_model_call(%ModelRequest{} = request, handler) do
model =
if length(request.messages || []) > 10 do
BeamWeaver.Models.init_chat_model!("openai:gpt-5.4")
else
BeamWeaver.Models.init_chat_model!("openai:gpt-5.4-mini")
end
request
|> ModelRequest.override(model: model)
|> handler.()
end
end
defmodule MyApp.AgentWithDynamicModel do
use BeamWeaver.Agent
model BeamWeaver.Models.init_chat_model!("openai:gpt-5.4-mini")
middleware do
use MyApp.DynamicModelSelection
end
end
When structured output is enabled, prefer unbound model instances. Let the agent attach tools and response-format options at the model boundary.
Tools
Tools let an agent take actions. BeamWeaver agents support multiple tool calls across the loop, parallel tool execution in the tool node, dynamic tool selection, retry/error middleware, runtime state, and checkpoint persistence.
Static Tools
Static tools are declared up front.
alias BeamWeaver.Core.Tool
search =
Tool.from_function!(
name: "search",
description: "Search for information.",
input_schema: %{
"type" => "object",
"required" => ["query"],
"properties" => %{"query" => %{"type" => "string"}}
},
handler: fn %{"query" => query}, _opts ->
"Results for: #{query}"
end
)
weather =
Tool.from_function!(
name: "get_weather",
description: "Get weather information for a location.",
input_schema: %{
"type" => "object",
"required" => ["location"],
"properties" => %{"location" => %{"type" => "string"}}
},
handler: fn %{"location" => location}, _opts ->
"Weather in #{location}: sunny, 72 F"
end
)
{:ok, agent} =
BeamWeaver.Agent.build(
model: BeamWeaver.Models.init_chat_model!("openai:gpt-5.4"),
tools: [search, weather]
)
If the tool list is empty, the compiled agent contains only the model path and middleware path; it will not execute tool calls.
Tool modules are also supported:
defmodule MyApp.Tools.GetWeather do
use BeamWeaver.Tool
name "get_weather"
description "Get weather information for a location."
schema do
field :location, :string, required: true
end
@impl true
def invoke(_tool, %{"location" => location}, _opts) do
{:ok, "Weather in #{location}: sunny, 72 F"}
end
end
Dynamic Tools
Dynamic tools are exposed to the model at runtime. This is useful for permissions, feature flags, conversation phase, or tools discovered from an external registry.
Filtering Pre-Registered Tools
When every possible tool is known at startup, use BeamWeaver.Agent.Middleware.ToolSelection or custom wrap_model_call/2 middleware.
Filter by state:
defmodule MyApp.StateBasedTools do
@behaviour BeamWeaver.Agent.Middleware
alias BeamWeaver.Agent.ModelRequest
alias BeamWeaver.Core.Tool
def wrap_model_call(%ModelRequest{} = request, handler) do
authenticated? = Map.get(request.state || %{}, :authenticated, false)
message_count = request.state |> Map.get(:messages, []) |> length()
tools =
cond do
not authenticated? ->
Enum.filter(request.tools, fn tool ->
Tool.name(tool) |> String.starts_with?("public_")
end)
message_count < 5 ->
Enum.reject(request.tools, &(Tool.name(&1) == "advanced_search"))
true ->
request.tools
end
request
|> ModelRequest.override(tools: tools)
|> handler.()
end
end
Filter by store:
defmodule MyApp.StoreBasedTools do
@behaviour BeamWeaver.Agent.Middleware
alias BeamWeaver.Agent.ModelRequest
alias BeamWeaver.Core.Tool
alias BeamWeaver.Memory
def wrap_model_call(%ModelRequest{} = request, handler) do
user_id = get_in(request.runtime.context || %{}, [:user_id])
store = request.runtime.store
tools =
case Memory.get(store, ["features"], user_id) do
{:ok, %{value: %{"enabled_tools" => enabled}}} ->
Enum.filter(request.tools, &(Tool.name(&1) in enabled))
_other ->
request.tools
end
request
|> ModelRequest.override(tools: tools)
|> handler.()
end
end
defmodule MyApp.StoreFilteredAgent do
use BeamWeaver.Agent
model BeamWeaver.Models.init_chat_model!("openai:gpt-5.4")
tools do
tool MyApp.Tools.Search
tool MyApp.Tools.Analyze
tool MyApp.Tools.Export
end
middleware do
use MyApp.StoreBasedTools
end
context_schema do
field :user_id, :string, required: true
end
store BeamWeaver.Memory.ETS.new()
end
Filter by runtime context:
defmodule MyApp.ContextBasedTools do
@behaviour BeamWeaver.Agent.Middleware
alias BeamWeaver.Agent.ModelRequest
alias BeamWeaver.Core.Tool
def wrap_model_call(%ModelRequest{} = request, handler) do
role = Map.get(request.runtime.context || %{}, :user_role, "viewer")
tools =
case role do
"admin" ->
request.tools
"editor" ->
Enum.reject(request.tools, &(Tool.name(&1) == "delete_data"))
_viewer ->
Enum.filter(request.tools, fn tool ->
Tool.name(tool) |> String.starts_with?("read_")
end)
end
request
|> ModelRequest.override(tools: tools)
|> handler.()
end
end
The built-in ToolSelection middleware covers common allow/deny/tag/metadata filters and can also add tools dynamically:
middleware do
use BeamWeaver.Agent.Middleware.ToolSelection,
allow: ["public_search"],
deny: ["delete_data"],
tools: &__MODULE__.tools_for_request/1
end
Runtime Tool Registration
When a tool is discovered at runtime, add it before the model call and route it before execution. The same middleware can own both hooks.
defmodule MyApp.DynamicTipTool do
@behaviour BeamWeaver.Agent.Middleware
alias BeamWeaver.Agent.ModelRequest
alias BeamWeaver.Agent.ToolCallRequest
alias BeamWeaver.Core.Tool
def wrap_model_call(%ModelRequest{} = request, handler) do
request
|> ModelRequest.override(tools: request.tools ++ [calculate_tip()])
|> handler.()
end
def wrap_tool_call(%ToolCallRequest{tool_call: %{name: "calculate_tip"}} = request, handler) do
request
|> ToolCallRequest.override(tool: calculate_tip())
|> handler.()
end
def wrap_tool_call(%ToolCallRequest{} = request, handler), do: handler.(request)
defp calculate_tip do
Tool.from_function!(
name: "calculate_tip",
description: "Calculate the tip and total for a bill.",
input_schema: %{
"type" => "object",
"required" => ["bill_amount"],
"properties" => %{
"bill_amount" => %{"type" => "number"},
"tip_percentage" => %{"type" => "number", "default" => 20}
}
},
handler: fn args, _opts ->
bill = args["bill_amount"]
percent = Map.get(args, "tip_percentage", 20)
tip = bill * percent / 100
"Tip: #{Float.round(tip, 2)}, total: #{Float.round(bill + tip, 2)}"
end
)
end
end
Registering the tool for the model is not enough. The tool node also needs a tool value when execution starts, so runtime registration should pair wrap_model_call/2 with wrap_tool_call/2.
Tool Error Handling
Use wrap_tool_call/2 to customize tool failures.
defmodule MyApp.ToolErrors do
@behaviour BeamWeaver.Agent.Middleware
alias BeamWeaver.Agent.ToolCallRequest
alias BeamWeaver.Core.Error
alias BeamWeaver.Core.Message
def wrap_tool_call(%ToolCallRequest{} = request, handler) do
case handler.(request) do
{:error, %Error{} = error} ->
Message.tool(
"Tool error: please check your input and try again. (#{error.message})",
tool_call_id: tool_call_id(request)
)
other ->
other
end
rescue
exception ->
Message.tool(
"Tool error: please check your input and try again. (#{Exception.message(exception)})",
tool_call_id: tool_call_id(request)
)
end
defp tool_call_id(%ToolCallRequest{tool_call: call}) do
Map.get(call, :id) || Map.get(call, "id")
end
end
The returned %BeamWeaver.Core.Message{role: :tool} is appended to the agent state and sent back to the model as the observation.
Tool Use In The ReAct Loop
The loop alternates between a model decision and tool observations:
-
The user message is added to agent state.
-
The model node reads
:messagesand returns an assistant message. -
If the assistant message contains tool calls, the tool node executes them.
-
Tool results are appended as tool messages.
-
The model node runs again with the observations.
-
The loop stops when the assistant returns no pending tool calls or the recursion limit is reached.
{:ok, %{messages: messages}} =
MyApp.SupportAgent.invoke(%{
messages: [BeamWeaver.Core.Message.user("Find headphones and check stock")]
})
messages
|> List.last()
|> BeamWeaver.Core.Message.text()
System Prompt
Use system_prompt/1 to shape agent behavior:
defmodule MyApp.ConciseAgent do
use BeamWeaver.Agent
model BeamWeaver.Models.init_chat_model!("openai:gpt-5.4")
system_prompt "You are a helpful assistant. Be concise and accurate."
end
You can also pass a system message:
system_prompt BeamWeaver.Core.Message.system([
%{type: :text, text: "You analyze literary works."},
%{type: :text, text: "<full text of the work>", metadata: %{cache_hint: :ephemeral}}
])
Provider-specific metadata is passed through provider request builders only when the configured provider supports it.
Dynamic System Prompt
Dynamic system prompts belong in middleware. This keeps prompt routing with the stage of execution that owns it and avoids a second function-valued prompt API:
middleware do
use BeamWeaver.Agent.Middleware.DynamicPrompt,
prompt: fn request ->
case Map.get(request.runtime.context || %{}, :user_role, "user") do
"expert" -> "You are helpful. Provide detailed technical responses."
"beginner" -> "You are helpful. Explain concepts simply."
_other -> "You are helpful."
end
end
end
Use DynamicPrompt or custom middleware when prompt text depends on state or runtime context.
Name
Set a name for tracing metadata, graph names, and subgraph identifiers:
defmodule MyApp.ResearchAssistant do
use BeamWeaver.Agent
name "research_assistant"
model BeamWeaver.Models.init_chat_model!("openai:gpt-5.4")
end
Prefer lowercase names with letters, numbers, underscores, or hyphens. Provider tool-call APIs often reject spaces and special characters in agent or tool names.
Invocation
Invoke an agent by passing a state update. Agent state always includes :messages.
alias BeamWeaver.Core.Message
{:ok, result} =
MyApp.SupportAgent.invoke(%{
messages: [Message.user("What is the weather in San Francisco?")]
})
result.messages |> List.last() |> Message.text()
To persist conversation history, provide a checkpointer and reuse the same thread_id:
alias BeamWeaver.Checkpoint.ETS, as: CheckpointETS
alias BeamWeaver.Core.Message
checkpointer = CheckpointETS.new()
thread_id = "thread-" <> Integer.to_string(System.unique_integer([:positive]))
config = %{"configurable" => %{"thread_id" => thread_id}}
{:ok, _first_turn} =
MyApp.SupportAgent.invoke(
%{messages: [Message.user("What is the weather in San Francisco?")]},
checkpointer: checkpointer,
config: config
)
{:ok, follow_up} =
MyApp.SupportAgent.invoke(
%{messages: [Message.user("What about tomorrow?")]},
checkpointer: checkpointer,
config: config
)
thread_id scopes the conversation and checkpoints. context carries per-run data that tools and middleware can read:
defmodule MyApp.UserScopedAgent do
use BeamWeaver.Agent
model BeamWeaver.Models.init_chat_model!("openai:gpt-5.4")
context_schema do
field :user_id, :string, required: true
end
end
{:ok, result} =
MyApp.UserScopedAgent.invoke(
%{messages: [BeamWeaver.Core.Message.user("Show my account summary")]},
checkpointer: checkpointer,
config: config,
context: %{user_id: "user-123"}
)
For durable deployments, use BeamWeaver.Checkpoint.Ecto instead of ETS.
Advanced Concepts
Structured Output
Use response_schema/2 when the final agent state should include a :structured_response. For the complete strategy and error-handling guide, see
Structured Output
.
Tool Strategy
Tool strategy asks the model to call a synthetic schema tool. This works with models that support tool calling.
alias BeamWeaver.Core.Message
defmodule MyApp.ContactSchema do
use BeamWeaver.Schema
title "contact_info"
strict true
field :name, :string, required: true
field :email, :string, required: true
field :phone, :string, required: true
end
defmodule MyApp.ContactAgent do
use BeamWeaver.Agent
model BeamWeaver.Models.init_chat_model!("openai:gpt-5.4-mini")
response_schema MyApp.ContactSchema, name: "contact_info", strategy: :tool
end
{:ok, %{structured_response: contact}} =
MyApp.ContactAgent.invoke(%{
messages: [
Message.user("Extract contact info: John Doe, [email protected], 555-123-4567")
]
})
Provider Strategy
Provider strategy uses provider-native structured output when the model supports it:
response_schema MyApp.ContactSchema, name: "contact_info", strategy: :provider
Auto strategy selects provider-native structured output when supported safely by the model profile and falls back to tool strategy otherwise:
response_schema MyApp.ContactSchema, name: "contact_info", strategy: :auto
Memory
Agents keep conversation history in state and checkpoints. You can extend state with additional short-term fields.
Defining State Via Middleware
Middleware-scoped state is preferred when the state exists for a specific middleware/tool package.
defmodule MyApp.PreferenceMiddleware do
@behaviour BeamWeaver.Agent.Middleware
def state_schema(_middleware) do
%{
user_preferences:
BeamWeaver.Agent.Schema.field(:user_preferences, :map, required: false)
}
end
def before_model(state, _runtime) do
preferences = Map.get(state, :user_preferences, %{})
Map.put(state, :user_preferences, Map.put_new(preferences, :style, "technical"))
end
end
defmodule MyApp.PreferenceAgent do
use BeamWeaver.Agent
model BeamWeaver.Models.init_chat_model!("openai:gpt-5.4")
middleware do
use MyApp.PreferenceMiddleware
end
end
Put state extensions on the middleware that owns the behavior so schema, hooks, and tools stay together.
For long-term memory shared across conversations, configure a store such as BeamWeaver.Memory.ETS or BeamWeaver.Memory.Ecto and access it through runtime.store.
Streaming
Use stream_events/2 to observe intermediate graph and model events.
alias BeamWeaver.Core.Message
alias BeamWeaver.Stream.Envelope
alias BeamWeaver.Stream.Events
{:ok, events} =
MyApp.SupportAgent.stream_events(
%{messages: [Message.user("Search for AI news and summarize it")]}
)
for %Envelope{event: event} <- events do
case event do
%Events.Message{message: message} ->
IO.puts("Agent: " <> Message.text(message))
%Events.ToolStart{tool_name: name} ->
IO.puts("Calling tool: " <> name)
%Events.ToolFinish{tool_name: name} ->
IO.puts("Finished tool: " <> name)
_other ->
:ok
end
end
If you need state-shaped updates instead of raw typed envelopes, project the event list:
alias BeamWeaver.Stream.Transformers
{:ok, events} = MyApp.SupportAgent.stream_events(%{messages: [Message.user("hello")]})
values = Transformers.stream(events, :values)
Middleware
Middleware is ordinary Elixir data. A middleware entry can be a module, a struct, or {module, opts}. Middleware can:
-
process state before the agent or model runs
-
modify the model request
-
validate or rewrite model responses
-
add or filter tools
-
handle tool execution failures
-
implement retry, fallback, call limits, summarization, PII handling, human-in-the-loop, and context editing
-
return
BeamWeaver.Graph.Commandvalues for explicit jumps or state updates
The middleware behaviour supports these callbacks:
@callback before_agent(map(), BeamWeaver.Graph.Runtime.t()) :: term()
@callback before_model(map(), BeamWeaver.Graph.Runtime.t()) :: term()
@callback wrap_model_call(BeamWeaver.Agent.ModelRequest.t(), function()) :: term()
@callback after_model(map(), BeamWeaver.Graph.Runtime.t()) :: term()
@callback wrap_tool_call(BeamWeaver.Agent.ToolCallRequest.t(), function()) :: term()
@callback after_agent(map(), BeamWeaver.Graph.Runtime.t()) :: term()
Common built-in middleware modules include:
-
BeamWeaver.Agent.Middleware.DynamicPrompt -
BeamWeaver.Agent.Middleware.ToolSelection -
BeamWeaver.Agent.Middleware.ToolRetry -
BeamWeaver.Agent.Middleware.ModelRetry -
BeamWeaver.Agent.Middleware.ModelFallback -
BeamWeaver.Agent.Middleware.ToolCallLimit -
BeamWeaver.Agent.Middleware.ModelCallLimit -
BeamWeaver.Agent.Middleware.Summarization -
BeamWeaver.Agent.Middleware.StructuredOutputRetry -
BeamWeaver.Agent.Middleware.HumanInTheLoop -
BeamWeaver.Agent.Middleware.ContextEditing -
BeamWeaver.Agent.Middleware.PII -
BeamWeaver.Agent.Middleware.TodoList -
BeamWeaver.Agent.Middleware.ShellTool -
BeamWeaver.Agent.Middleware.ToolEmulator
See Custom Middleware for implementing your own hooks. See Prebuilt Middleware for configuration examples and the places where BeamWeaver intentionally uses tools or graph composition instead of Python middleware classes. See Guardrails for agent safety checks built from the same middleware lifecycle.
Runtime-Built Agents
Prefer module-defined agents for stable application code. Use BeamWeaver.Agent.build/1 for config-driven or user-generated workflows.
{:ok, agent} =
BeamWeaver.Agent.build(
name: "support_agent",
model: BeamWeaver.Models.init_chat_model!("openai:gpt-5.4"),
tools: [],
system_prompt: "You are helpful."
)
{:ok, state} =
BeamWeaver.Agent.invoke(agent, %{
messages: [BeamWeaver.Core.Message.user("hi")]
})
Runtime-built agents support the same model/tools loop, schemas, middleware, checkpointer, store, cache, interrupts, recursion limits, and event-streaming options represented by BeamWeaver.Agent.Spec.