Tools
Tools let models and agents take actions: search data, call internal services, run policy-governed commands, update graph state, and return observations to the model.
BeamWeaver tools are normal Elixir values that implement BeamWeaver.Core.Tool. You can create them at runtime with BeamWeaver.Core.Tool.from_function!/1, define stable application tools with use BeamWeaver.Tool, or implement the behaviour directly.
Create Tools
Runtime Tool
Use Tool.from_function!/1 when the tool is built from configuration, tests, or runtime data:
alias BeamWeaver.Core.Tool
search_database =
Tool.from_function!(
name: "search_database",
description: "Search customer records matching a query.",
input_schema: %{
"type" => "object",
"properties" => %{
"query" => %{"type" => "string", "description" => "Search terms"},
"limit" => %{"type" => "integer", "default" => 10}
},
"required" => ["query"]
},
handler: fn input, _opts ->
query = input["query"] || input[:query]
limit = input["limit"] || input[:limit] || 10
"Found #{limit} results for #{inspect(query)}"
end
)
The handler receives a map of validated input and a keyword list of call options. Returning a plain value is accepted; returning {:ok, value} or {:error, %BeamWeaver.Core.Error{}} is preferred when the tool does real work.
Tool names should use provider-safe characters: letters, numbers, underscores, and hyphens. Provider renderers validate this before sending the tool schema to OpenAI or Anthropic.
Runtime tools can also declare execution and parsing options:
tool =
Tool.from_function!(
name: "lookup_order",
description: "Lookup an order by numeric ID.",
input_schema: %{
"type" => "object",
"properties" => %{"order_id" => %{"type" => "integer"}},
"required" => ["order_id"]
},
parse_args: fn
%{"order_id" => id} when is_binary(id) -> {:ok, %{"order_id" => String.to_integer(id)}}
_args -> :ok
end,
concurrent: false,
max_result_chars: 2_000,
handler: fn %{"order_id" => id}, _opts ->
MyApp.Orders.fetch_summary!(id)
end
)
parse_args runs after model tool-call normalization and before schema validation, so it can coerce raw LLM arguments before strict validation. It must return :ok, {:ok, parsed_map}, or {:error, reason}. Parser errors, exceptions, and invalid return shapes become :invalid_input errors and use the same handle_validation_error policy as schema failures.
For callers that validate before creating their own operation record, BeamWeaver.Core.Tool.normalize_input/2 returns a string-keyed input map. It enforces required and closed properties, basic JSON types, enums, string and numeric bounds, array sizes, and nested object/array schemas. Atom and string keys that normalize to the same JSON key are rejected instead of silently overwriting one value.
Module Tool
Use use BeamWeaver.Tool for application tools that should compile to a normal module:
defmodule MyApp.Tools.SearchDatabase do
use BeamWeaver.Tool
name "search_database"
description "Search customer records matching a query."
tags [:customer_data]
metadata %{owner: "support"}
concurrent false
max_result_chars 2_000
schema do
field :query, :string, description: "Search terms"
field :limit, :integer, required: false, default: 10
end
@impl true
def invoke(_tool, input, _opts) do
query = input.query
limit = Map.get(input, :limit, 10)
{:ok, "Found #{limit} results for #{inspect(query)}"}
end
end
Module tools can be passed as modules or structs:
tools = [MyApp.Tools.SearchDatabase, %MyApp.Tools.SearchDatabase{}]
For lists that may include modules, structs, runtime tools, toolkits, or runnable-compatible values, use BeamWeaver.Tool.Converter.to_tools/2.
Module tools can override parse_args/2 directly when they need the same pre-validation coercion:
@impl true
def parse_args(_tool, %{"limit" => limit} = args) when is_binary(limit) do
{:ok, %{args | "limit" => String.to_integer(limit)}}
end
def parse_args(_tool, _args), do: :ok
Direct Behaviour Tool
When you need full control, implement BeamWeaver.Core.Tool directly:
defmodule MyApp.Tools.Uppercase do
@behaviour BeamWeaver.Core.Tool
defstruct []
def name(_tool), do: "uppercase"
def description(_tool), do: "Uppercase a string."
def input_schema(_tool) do
%{
"type" => "object",
"properties" => %{"value" => %{"type" => "string"}},
"required" => ["value"]
}
end
def injected(_tool), do: %{}
def return_direct(_tool), do: false
def response_format(_tool), do: nil
def output_schema(_tool), do: %{"type" => "string"}
def tags(_tool), do: []
def metadata(_tool), do: %{}
def provider_opts(_tool), do: %{}
def invoke(_tool, input, _opts) do
{:ok, String.upcase(input["value"] || input[:value])}
end
end
Schemas
The model sees the tool name, description, and public input schema. BeamWeaver uses JSON Schema-shaped maps at the provider boundary:
weather_schema = %{
"type" => "object",
"properties" => %{
"location" => %{"type" => "string", "description" => "City name or coordinates"},
"units" => %{
"type" => "string",
"enum" => ["celsius", "fahrenheit"],
"default" => "celsius"
},
"include_forecast" => %{"type" => "boolean", "default" => false}
},
"required" => ["location"]
}
The tool DSL builds the same shape from fields:
schema do
field :location, :string, description: "City name or coordinates"
field :units, :string,
required: false,
default: "celsius",
enum: ["celsius", "fahrenheit"]
field :include_forecast, :boolean, required: false, default: false
end
BeamWeaver.Tool.Schema.from/1 can convert explicit field declarations, NimbleOptions-style specs, Ecto-style schema modules, and already-shaped JSON Schema maps:
{:ok, schema} =
BeamWeaver.Tool.Schema.from([
{:query, :string, description: "Search query"},
{:limit, :integer, required: false, default: 5},
{:filters, {:object, [{:section, :string, required: false}]}, required: false}
])
Runtime-Injected Arguments
Some inputs are for the tool implementation, not the model. Declare them on the tool module and mark them as injected:
defmodule MyApp.Tools.SearchPrivate do
use BeamWeaver.Tool
name "search_private"
description "Search private data for the current user."
injected :context, :context, type: :object
injected :tool_call_id, :tool_call_id, type: :string
schema do
field :query, :string, required: true
end
@impl true
def invoke(_tool, input, _opts) do
user_id = input.context[:user_id] || input.context["user_id"]
{:ok, "Searching for #{input.query} as #{user_id}"}
end
end
BeamWeaver.Core.Tool.raw_input_schema(%MyApp.Tools.SearchPrivate{})
BeamWeaver.Core.Tool.input_schema(%MyApp.Tools.SearchPrivate{})
Tool.raw_input_schema/1 includes injected fields. Tool.input_schema/1 removes them before the schema is exposed to a model.
For dynamic runtime tools, pass :injected alongside the generated input schema:
Tool.from_function!(
name: "search_private",
description: "Search private data for the current user.",
input_schema: %{
"type" => "object",
"properties" => %{
"query" => %{"type" => "string"},
"context" => %{"type" => "object"}
},
"required" => ["query", "context"]
},
injected: [context: :context],
handler: fn input, _opts ->
{:ok, "Searching for #{input["query"] || input[:query]}"}
end
)
Injected sources:
| Source | Value |
|---|---|
:state
| full graph or agent state |
{:state, field_or_path}
| one state field or nested path |
:context
| per-run context |
:store
| long-term memory store |
:runtime
| graph runtime struct/map |
:tool_runtime
|
%BeamWeaver.Core.ToolRuntime{}
|
:tool_call_id
| current model tool call ID |
:config
| runtime config |
:checkpointer
| checkpoint adapter |
Access Runtime Data
Runtime injection is available when a tool is executed by an agent or BeamWeaver.Graph.Nodes.ToolNode. Direct Tool.invoke/3 calls are plain function calls; pass any implementation-only values yourself when calling tools outside a graph runtime.
State
State is short-term conversation data. Inject the full state or a specific field:
get_last_user_message =
Tool.from_function!(
name: "get_last_user_message",
description: "Get the most recent user message.",
input_schema: %{
"type" => "object",
"properties" => %{"state" => %{"type" => "object"}},
"required" => ["state"]
},
injected: [state: :state],
handler: fn input, _opts ->
state = input[:state] || input["state"] || %{}
state
|> Map.get(:messages, Map.get(state, "messages", []))
|> Enum.reverse()
|> Enum.find(&match?(%BeamWeaver.Core.Message{role: :user}, &1))
|> case do
nil -> "No user messages found."
message -> BeamWeaver.Core.Message.text(message)
end
end
)
Context
Context is immutable per-run data passed to the agent invocation:
account_tool =
Tool.from_function!(
name: "get_account_info",
description: "Get account information for the current user.",
input_schema: %{
"type" => "object",
"properties" => %{"context" => %{"type" => "object"}},
"required" => ["context"]
},
injected: [context: :context],
handler: fn input, _opts ->
context = input[:context] || input["context"] || %{}
user_id = context[:user_id] || context["user_id"]
"Account for #{user_id}: Premium"
end
)
Invoke the agent with both a stable thread_id for checkpoints and per-run context for tools:
MyApp.Agent.invoke(
%{messages: [BeamWeaver.Core.Message.user("What is my balance?")]},
config: %{"configurable" => %{"thread_id" => "thread-123"}},
context: %{user_id: "user-123"}
)
Store
Stores are long-term memory. Inject the store and use the BeamWeaver.Memory API:
save_user_info =
Tool.from_function!(
name: "save_user_info",
description: "Save user information.",
input_schema: %{
"type" => "object",
"properties" => %{
"user_id" => %{"type" => "string"},
"info" => %{"type" => "object"},
"store" => %{"type" => "object"}
},
"required" => ["user_id", "info", "store"]
},
injected: [store: :store],
handler: fn input, _opts ->
store = input[:store] || input["store"]
user_id = input["user_id"] || input[:user_id]
info = input["info"] || input[:info]
:ok = BeamWeaver.Memory.put(store, ["users"], user_id, info)
"Saved user information."
end
)
Use BeamWeaver.Memory.ETS for local/test storage and BeamWeaver.Memory.Ecto for durable Postgres-backed storage.
Stream Writer
Inject :tool_runtime to emit tool progress events:
streaming_tool =
Tool.from_function!(
name: "stream_tool",
description: "Emit progress while running.",
input_schema: %{
"type" => "object",
"properties" => %{
"text" => %{"type" => "string"},
"tool_runtime" => %{"type" => "object"}
},
"required" => ["text", "tool_runtime"]
},
injected: [tool_runtime: :tool_runtime],
handler: fn input, _opts ->
runtime = input[:tool_runtime] || input["tool_runtime"]
text = input["text"] || input[:text]
BeamWeaver.Core.ToolRuntime.emit_output_delta(runtime, "starting")
BeamWeaver.Core.ToolRuntime.emit_output_delta(runtime, "finished")
text
end
)
Tool progress is visible in agent or graph streams that use typed events.
Execution And Deployment Metadata
%BeamWeaver.Core.ToolRuntime{} includes tool_call_id, tool_call, execution_info, and server_info fields:
handler = fn input, _opts ->
runtime = input[:tool_runtime] || input["tool_runtime"]
%{tool_call_id: runtime.tool_call_id, execution: runtime.execution_info}
end
Tool Execution
Direct Invocation
Call tools directly with Tool.invoke/3:
{:ok, result} = BeamWeaver.Core.Tool.invoke(search_database, %{"query" => "Ada", "limit" => 3})
For a single-input tool, scalar input is accepted:
echo =
Tool.from_function!(
name: "echo",
description: "Echo one value.",
input_schema: %{
"type" => "object",
"properties" => %{"query" => %{"type" => "string"}},
"required" => ["query"]
},
handler: fn input, _opts -> input["query"] || input[:query] end
)
{:ok, "beam"} = Tool.invoke(echo, "beam")
Use Task-backed helpers for explicit async work:
task = BeamWeaver.Core.Tool.async_invoke(echo, "beam")
{:ok, "beam"} = BeamWeaver.Core.Async.await(task)
Model Tool Calls
When the input is a tool-call map, BeamWeaver validates the call, injects the tool call ID, and wraps the result as a tool message:
tool_call = %{
"type" => "tool_call",
"name" => "echo",
"id" => "call_123",
"args" => %{"query" => "beam"}
}
{:ok, %BeamWeaver.Core.Message{role: :tool} = message} =
BeamWeaver.Core.Tool.invoke(echo, tool_call)
Standalone model calls only request tool execution. Agents and ToolNode run the loop automatically.
ToolNode
BeamWeaver.Graph.Nodes.ToolNode executes one or more tool calls, handles parallel calls with supervised tasks, injects runtime data, emits stream events, and returns tool messages or graph commands:
alias BeamWeaver.Graph.Nodes.ToolNode
node = ToolNode.new([search_database], timeout: 5_000)
messages =
ToolNode.invoke(node, [
%{id: "call_search", name: "search_database", args: %{"query" => "Ada"}}
])
In graph state, the node reads assistant tool calls from :messages and returns tool messages that a reducer can append.
ToolNode preserves the model's original tool-call order in its returned messages. Consecutive tools are executed concurrently by default. Set concurrent: false on Tool.from_function!/1 tools, or declare concurrent false in use BeamWeaver.Tool, when a tool must act as an ordering barrier. Consecutive concurrent tools before and after that barrier run in separate supervised groups.
max_result_chars limits only model-visible textual tool message content. It does not change direct handler return values before normalization, graph commands, artifacts, structured metadata, or non-text values.
Return Values
Tools can return several shapes.
String Or Structured Data
Return text when the model should read a simple observation:
handler: fn input, _opts ->
"It is sunny in #{input["city"] || input[:city]}."
end
Return maps or lists when structure helps model reasoning. Tool messages encode non-text values as JSON:
handler: fn input, _opts ->
%{city: input["city"] || input[:city], temperature_c: 22, conditions: "sunny"}
end
ToolResult And Artifacts
Use %BeamWeaver.Core.ToolResult{} or response_format: :content_and_artifact when model-visible content and application-only data should differ:
lookup =
Tool.from_function!(
name: "lookup",
description: "Look up a record.",
input_schema: %{
"type" => "object",
"properties" => %{"id" => %{"type" => "string"}},
"required" => ["id"]
},
handler: fn input, _opts ->
id = input["id"] || input[:id]
BeamWeaver.Core.ToolResult.success("record #{id}",
artifact: %{id: id, raw: %{score: 10}},
metadata: %{source: "records"}
)
end
)
The model sees "record #{id}". The artifact remains available on the tool message metadata or artifacts for application code, tracing, retrieval UIs, and checkpointed state.
Command
Return BeamWeaver.Graph.Command when a tool needs to update state or route the graph. Include a matching tool message in the command update when the model needs an observation:
alias BeamWeaver.Core.Message
alias BeamWeaver.Graph.Command
set_user_name =
Tool.from_function!(
name: "set_user_name",
description: "Set the user's name in state.",
input_schema: %{
"type" => "object",
"properties" => %{
"new_name" => %{"type" => "string"},
"tool_call_id" => %{"type" => "string"}
},
"required" => ["new_name", "tool_call_id"]
},
injected: [tool_call_id: :tool_call_id],
handler: fn input, _opts ->
name = input["new_name"] || input[:new_name]
call_id = input[:tool_call_id] || input["tool_call_id"]
%Command{
update: %{
user_name: name,
messages: [Message.tool("User name set to #{name}.", tool_call_id: call_id)]
}
}
end
)
If multiple tools update the same state field in parallel, define graph reducers for that field.
Return Direct
Use return_direct: true when a successful tool result should stop the agent loop after the tool observation:
Tool.from_function!(
name: "finish",
description: "Return the final answer.",
input_schema: %{
"type" => "object",
"properties" => %{"answer" => %{"type" => "string"}},
"required" => ["answer"]
},
return_direct: true,
handler: fn input, _opts -> input["answer"] || input[:answer] end
)
Error Handling
Tools return tagged errors for recoverable failures:
{:error, %BeamWeaver.Core.Error{type: :tool_exception}}
You can let the error bubble, or format it as model-visible content:
Tool.from_function!(
name: "fragile_lookup",
description: "Lookup that can fail.",
input_schema: %{"type" => "object", "required" => []},
handle_tool_error: fn error -> "Lookup failed: #{error.message}" end,
handle_validation_error: true,
handler: fn _input, _opts -> raise "network unavailable" end
)
When invoked as a tool-call map, handled errors become %BeamWeaver.Core.Message{role: :tool, status: :error} with the original tool_call_id.
At the agent boundary, use middleware:
middleware do
use BeamWeaver.Agent.Middleware.ToolRetry,
max_retries: 2,
on_failure: :continue
end
For custom behavior, implement wrap_tool_call/2 middleware:
defmodule MyApp.ToolErrors do
@behaviour BeamWeaver.Agent.Middleware
alias BeamWeaver.Agent.ToolCallRequest
alias BeamWeaver.Core.Message
def wrap_tool_call(%ToolCallRequest{} = request, handler) do
case handler.(request) do
{:error, error} ->
call_id = Map.get(request.tool_call, :id) || Map.get(request.tool_call, "id")
Message.tool("Tool error: #{error.message}",
tool_call_id: call_id
)
other ->
other
end
end
end
Dynamic Tools
Agents can filter or add tools at the model-call boundary:
middleware do
use BeamWeaver.Agent.Middleware.ToolSelection,
allow: ["public_search"],
deny: ["delete_data"],
tags: [:support],
tools: fn request ->
runtime = request.runtime || %{}
context = Map.get(runtime, :context) || %{}
authenticated? = Map.get(context, :authenticated?) || Map.get(context, "authenticated?")
if authenticated? do
[MyApp.Tools.PrivateSearch]
else
[]
end
end
end
When tools are added dynamically, pair model-time registration with tool-time routing. A wrap_model_call/2 hook exposes the tool to the model, and wrap_tool_call/2 can provide the concrete tool when execution starts. See
Agents
for the full runtime-registration example.
Provider Rendering And Model Binding
Bind tools to a standalone model:
model_with_tools =
BeamWeaver.Models.bind_tools(model, [search_database],
tool_choice: :auto,
parallel_tool_calls: true
)
Render provider schemas directly when building provider-specific requests:
{:ok, openai_tool} = BeamWeaver.Tool.Renderer.openai_tool(search_database, strict: true)
{:ok, anthropic_tool} = BeamWeaver.Tool.Renderer.anthropic_tool(search_database)
Provider renderers strip injected fields from schemas and validate provider-safe names. OpenAI strict rendering closes object schemas and makes every declared property required; Google rendering sanitizes Gemini function declarations by dereferencing local $ref values and removing unsupported JSON Schema keywords.
Prebuilt Tools
BeamWeaver includes a small set of native tools that are useful in agents and graphs.
File Search
Search a retriever or local filesystem roots:
tool =
BeamWeaver.Tools.FileSearch.new(
roots: ["docs"],
include: ["**/*.md"],
max_results: 5,
query_mode: :literal
)
Retrievers can also become tools:
tool =
BeamWeaver.Retriever.as_tool(retriever,
name: "knowledge_search",
response_format: :content_and_artifact
)
Shell
Shell access is policy-governed. The policy allow list is required:
tool =
BeamWeaver.Tools.Shell.new(
policy: [
allow: ["git status", "mix test"],
cwd: File.cwd!(),
timeout: 10_000,
max_output_bytes: 20_000
]
)
Use narrow allow rules and prefer deterministic commands. Session-backed shell tools can use graph state to keep a supervised shell session. String allow rules match a single command prefix, require a command boundary, and reject unquoted shell chaining such as ;, &&, pipes, or command substitution. Use an anchored regex only when a specific compound command is intended.
Todo
BeamWeaver.Tools.Todo updates explicit agent state with graph commands:
tool = BeamWeaver.Tools.Todo.new(state_key: :todos)
It injects state and the tool call ID, updates the TODO list, and returns a tool message in the command update.
Toolkits
Group tools with BeamWeaver.ToolKit:
defmodule MyApp.SupportTools do
@behaviour BeamWeaver.ToolKit
def tools(_opts) do
[MyApp.Tools.SearchDatabase, BeamWeaver.Tools.Todo.new()]
end
end
{:ok, tools} = BeamWeaver.Tool.Converter.to_tools(MyApp.SupportTools)
Several Deep Agents-style capabilities are also exposed as normal toolkits when you want just the tools without the corresponding prompt or state middleware:
BeamWeaver.Tools.Todo.tools(name: "write_todos")
BeamWeaver.Tools.Filesystem.tools(backend: BeamWeaver.Filesystem.State.new())
BeamWeaver.Tools.Subagents.tools(model: model, subagents: subagent_specs)
For the full behavior, prefer the matching middleware: BeamWeaver.Agent.Middleware.TodoList, BeamWeaver.Agent.Middleware.Filesystem, and BeamWeaver.Agent.Middleware.Subagents.
Server-Side Provider Tools
Some model providers execute tools inside the provider request. Those are provider request declarations, not local BeamWeaver.Core.Tool implementations.
OpenAI:
tools = [
BeamWeaver.OpenAI.ToolCalling.web_search(),
BeamWeaver.OpenAI.ToolCalling.code_interpreter(%{"type" => "auto"}),
BeamWeaver.OpenAI.ToolCalling.file_search(["vs_123"]),
BeamWeaver.OpenAI.ToolCalling.apply_patch()
]
BeamWeaver.Core.ChatModel.invoke(model, "Find current release notes.", tools: tools)
Anthropic:
tools = [
BeamWeaver.Anthropic.Tools.web_search(),
BeamWeaver.Anthropic.Tools.code_execution()
]
BeamWeaver.Core.ChatModel.invoke(model, "Search and summarize.", tools: tools)
Google:
tools = [
BeamWeaver.Google.Tools.google_search(),
BeamWeaver.Google.Tools.url_context(),
BeamWeaver.Google.Tools.code_execution()
]
BeamWeaver.Core.ChatModel.invoke(model, "Search and summarize.", tools: tools)
xAI:
tools = [
BeamWeaver.XAI.Tools.web_search(search_depth: :deep),
BeamWeaver.XAI.Tools.x_search(),
BeamWeaver.XAI.Tools.code_execution(),
BeamWeaver.XAI.Tools.file_search()
]
BeamWeaver.Core.ChatModel.invoke(model, "Search and summarize.", tools: tools)
For xAI Chat Completions search tools, use BeamWeaver.XAI.Tools.live_search/1.
Server-side tool calls and results are represented as message content blocks and response metadata. There is no local ToolNode execution step for work the provider already performed.