WeaveScope

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:

  1. model_opts[:timeout] from BeamWeaver.Agent.build/1 or the model/2 DSL.

  2. model.timeout when the configured model struct exposes a timeout field.

  3. The graph node default of 5_000 milliseconds.

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:

  1. The user message is added to agent state.

  2. The model node reads :messages and returns an assistant message.

  3. If the assistant message contains tool calls, the tool node executes them.

  4. Tool results are appended as tool messages.

  5. The model node runs again with the observations.

  6. 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.Command values 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.