Reusable, validated tools that an LLM can invoke during a conversation.
Tools give the model access to external capabilities — looking up data, calling APIs, running computations. When the model decides to use a tool, Omni's loop automatically executes it and feeds the result back, continuing until the model produces a final text response.
A tool is a %Tool{} struct with a name, description, optional input schema,
and optional handler function. You can create one directly with Omni.tool/1
for quick, inline use (see the Tools section in Omni), or define a tool
module with use Omni.Tool to bundle the schema, handler, and metadata in a
reusable unit.
Defining a tool module
A tool module implements schema/0 and call/1:
defmodule MyApp.Tools.GetWeather do
use Omni.Tool, name: "get_weather", description: "Gets the weather for a city"
def schema do
import Omni.Schema
object(%{city: string(description: "City name")}, required: [:city])
end
def call(input) do
WeatherAPI.fetch(input.city)
end
endschema/0 returns a plain map following JSON Schema conventions. The
Omni.Schema helpers are a convenient way to build these, but any map with
the right structure works — you can construct schemas by hand or with other
libraries. Import Omni.Schema inside the callback if you use it — it is
not auto-imported by use Omni.Tool.
call/1 receives the validated input with atom keys matching the schema,
regardless of the string keys the LLM sends. Return any term — it will be
serialized and sent back to the model as a tool result.
Stateful tools
When a tool needs runtime state (a database connection, configuration, an
API client), implement init/1 and call/2:
defmodule MyApp.Tools.DbLookup do
use Omni.Tool, name: "db_lookup", description: "Looks up a record by ID"
def schema do
import Omni.Schema
object(%{id: integer()}, required: [:id])
end
def init(repo), do: repo
def call(input, repo) do
repo.get(Record, input.id)
end
endinit/1 receives the argument passed to new/1 and returns the state.
call/2 receives validated input and that state. The state is captured in
a closure at construction time, so each new/1 call can bind different state.
Using tools
Pass tool structs in the context:
weather = MyApp.Tools.GetWeather.new()
db_lookup = MyApp.Tools.DbLookup.new(MyApp.Repo)
context = Omni.context(
messages: [Omni.message("What's the weather in Paris?")],
tools: [weather, db_lookup]
)
{:ok, response} = Omni.generate_text({:anthropic, "claude-sonnet-4-5-20250514"}, context)The loop executes tool uses automatically and continues the conversation
until the model responds with text (or hits :max_steps).
Schema-only tools
A %Tool{} struct with handler: nil defines a tool the model can invoke
but Omni won't auto-execute. The loop breaks and returns the response
containing ToolUse blocks for you to handle manually. This is useful when
tool execution requires human approval or happens outside your application.
schema = Omni.Schema.object(%{query: Omni.Schema.string()}, required: [:query])
tool = Omni.tool(name: "search", description: "Web search", input_schema: schema)Custom schema validators
When the built-in validator isn't enough, return an Omni.Schema.Adapter
tuple from schema/0:
def schema, do: {MyApp.JSVAdapter, @input_root}See Omni.Schema.Adapter for the behaviour and a JSV example. Note that
adapters control their own key conventions for validated input — JSV-style
adapters return string-keyed maps, so handlers must access input
accordingly.
Dynamic descriptions
When a tool's description needs to include runtime context — for example,
environment details or configuration injected at construction time — override
description/1. It receives the state returned by init/1:
defmodule MyApp.Tools.Search do
use Omni.Tool, name: "search", description: "Searches the knowledge base"
def schema do
import Omni.Schema
object(%{query: string()}, required: [:query])
end
@impl Omni.Tool
def description(opts) do
base = description()
case Keyword.get(opts, :extra) do
nil -> base
extra -> base <> "" <> extra
end
end
def init(opts), do: opts
def call(input, _opts) do
KnowledgeBase.search(input.query)
end
end
# Base description
tool = MyApp.Tools.Search.new()
tool.description
#=> "Searches the knowledge base"
# With extra context
tool = MyApp.Tools.Search.new(extra: "Only return results from 2024.")
tool.description
#=> "Searches the knowledge baseOnly return results from 2024."
Implement either description/0 or description/1 (or both). The default
description/1 delegates to description/0, so existing tools that only
implement description/0 are unaffected. If you implement only
description/1, the unused description/0 default raises if invoked.
Dynamic schemas
When a tool's input schema depends on runtime state — for example, the set
of allowed values comes from configuration or a database — override
schema/1. It receives the state returned by init/1:
defmodule MyApp.Tools.SetMode do
use Omni.Tool, name: "set_mode", description: "Switches the assistant's mode"
@impl Omni.Tool
def schema(allowed_modes) do
import Omni.Schema
object(
%{mode: string(enum: allowed_modes)},
required: [:mode]
)
end
def init(modes), do: modes
def call(input, _modes) do
{:ok, input.mode}
end
end
tool = MyApp.Tools.SetMode.new(["focus", "casual", "technical"])Implement either schema/0 or schema/1 (or both). The default schema/1
delegates to schema/0, so existing tools that only implement schema/0
are unaffected.
Callbacks as an alternative to options
The name: and description: options on use Omni.Tool are convenient
shorthand, but both are optional. You can omit them and implement name/0
and description/0 as callbacks directly:
defmodule MyApp.Tools.Ping do
use Omni.Tool
@impl Omni.Tool
def name, do: "ping"
@impl Omni.Tool
def description, do: "Returns pong"
def schema, do: Omni.Schema.object(%{})
def call(_input), do: "pong"
endIf you omit an option without implementing the callback, the compiler will warn about the missing callback.
How execution works
Calling new/0 or new/1 on a tool module:
- Calls
init/1with the given argument (defaults tonil) - Calls
description/1with the init state to resolve the description - Calls
schema/1with the init state to resolve the input schema - Returns a
%Tool{}struct with a handler closure bound to the init state
When the model invokes the tool, execute/2 validates the LLM's
string-keyed input against the schema (via Omni.Schema.validate/2), casts
keys to match the schema's key types, then calls the handler. Direct handler
calls (tool.handler.(input)) bypass this validation.
Summary
Callbacks
Handles a tool invocation (stateless).
Handles a tool invocation with state (stateful).
Returns the tool's base description.
Returns the tool's description, optionally incorporating runtime state.
Initializes state for a stateful tool.
Returns the tool's name.
Returns the tool's input schema.
Returns the tool's input schema, optionally incorporating runtime state.
Functions
Executes a tool's handler with the given input.
Creates a bare tool struct from a keyword list or map.
Types
@type t() :: %Omni.Tool{ description: String.t(), handler: (map() -> term()) | nil, input_schema: Omni.Schema.t() | nil, name: String.t() }
A tool struct.
input_schema is either a JSON Schema map, an Omni.Schema.Adapter
tuple {module, state}, or nil. When handler is nil, the tool is
schema-only — the loop will break and return ToolUse blocks for manual
handling instead of auto-executing.
Callbacks
Handles a tool invocation (stateless).
Receives the validated input map with keys matching the schema. Return any term — it will be serialized and sent back to the model as a tool result.
Implement either call/1 or call/2, not both. The default call/2
delegates to call/1, ignoring state.
Handles a tool invocation with state (stateful).
Same as call/1, but receives the state returned by init/1 as the second
argument. Implement this instead of call/1 when the tool needs runtime
state.
@callback description() :: String.t()
Returns the tool's base description.
Defaults to the description: value passed to use Omni.Tool. Override
this callback to provide the description directly when omitting the option.
Returns the tool's description, optionally incorporating runtime state.
Called by new/1 with the state returned by init/1. The default
implementation delegates to description/0, ignoring state. Override this
when the description needs runtime context — for example, appending
environment-specific prompt fragments.
See the "Dynamic descriptions" section in the module documentation for an example.
Initializes state for a stateful tool.
Called once by new/1 at construction time. The argument is whatever was
passed to new/1 (defaults to nil for new/0). The return value becomes
the second argument to call/2.
The default implementation returns nil.
@callback name() :: String.t()
Returns the tool's name.
Defaults to the name: value passed to use Omni.Tool. Override this
callback to provide the name directly when omitting the option.
@callback schema() :: Omni.Schema.t()
Returns the tool's input schema.
May return either a plain JSON Schema map, or an Omni.Schema.Adapter
tuple {module, state} for adapter-based validation (see
Omni.Schema.Adapter). If using Omni.Schema builders, import them
inside the callback body — they are not auto-imported by use Omni.Tool.
def schema do
import Omni.Schema
object(%{city: string(description: "City name")}, required: [:city])
endImplement schema/1 instead when the schema needs runtime state. See the
"Dynamic schemas" section in the module documentation.
@callback schema(state :: term()) :: Omni.Schema.t()
Returns the tool's input schema, optionally incorporating runtime state.
Called by new/1 with the state returned by init/1. The default
implementation delegates to schema/0, ignoring state. Override this when
the schema needs runtime context — for example, populating an enum from
configuration.
See the "Dynamic schemas" section in the module documentation for an example.
Functions
Executes a tool's handler with the given input.
If the tool has an input_schema, the input is validated and cast before
calling the handler. Peri maps string-keyed LLM input to the key types used
in the schema, so handlers receive atom keys when the schema uses atoms.
When input_schema is nil, input is passed through to the handler as-is.
Returns {:ok, result} on success, {:error, errors} on validation failure,
or {:error, exception} if the handler raises.
Raises FunctionClauseError if the tool has no handler (handler: nil).
@spec new(Enumerable.t()) :: t()
Creates a bare tool struct from a keyword list or map.
This is a low-level constructor — it does not bind a handler closure or
validate fields. Prefer Omni.tool/1 for inline tools or YourModule.new/0
for tool modules.