Turns tagged functions into tools Claude can call.
Add use Claudex.Tool to a module, then tag a function with @tool
right above its definition. Claudex reads the function's @doc for the
tool's description and its @spec for the argument types, and builds
the JSON schema the Messages API expects:
defmodule MyApp.Tools do
use Claudex.Tool
@doc "Adds two numbers."
@tool true
@spec add(number(), number()) :: number()
def add(a, b), do: a + b
end
Claudex.Tool.list(MyApp.Tools)
#=> [%{name: "add", description: "Adds two numbers.", input_schema: %{...}}]Claudex.Messages.create/2 takes the module directly as tools: and
expands it for you.
Pass a map instead of true for options:
:strict- setsstrict: trueon the tool definition:args_schema- use this JSON schema for the properties instead of inferring one from@spec. Takes full priority: when it's set, the@specis never even inspected, so it's also the way out of a type Claudex can't map (see below), or when you need something a typespec can't express (a per-argumentdescription, anenum)
:args_schema is the properties object, not the whole input schema —
required still comes from which parameters have defaults:
@doc "Fetches the current weather for a city."
@tool %{
args_schema: %{
"city" => %{type: "string", description: "City and country, e.g. Lisbon, PT"},
"unit" => %{type: "string", enum: ["celsius", "fahrenheit"]}
}
}
@spec get_weather(String.t(), String.t()) :: String.t()
def get_weather(city, unit \ "celsius"), do: ...
# input_schema: %{
# type: "object",
# properties: %{"city" => ..., "unit" => ...},
# required: ["city"],
# additionalProperties: false
# }When a tool_use block comes back from Claude, call/3 runs the matching
function:
Claudex.Tool.call(MyApp.Tools, "add", %{"a" => 1, "b" => 2})
#=> {:ok, 3}Claudex.ToolRunner does this natively — it runs the tools Claude asks for
and feeds the results back, so a whole tool conversation is one call.
The @doc is what Claude reads to decide when to call a tool, so a @tool
without one compiles with a warning and a placeholder description.
A function with no @spec at all still registers, with unconstrained
properties. Put the @spec above the function (or anywhere earlier in the
module) and make sure its arity matches, or Claudex won't find it either.
A defaulted argument makes two arities, and Claudex reads the widest one:
@spec add(number()) :: number() alone won't match def add(a, b \ 0),
and both properties come out unconstrained. Writing one @spec per arity
is fine — the matching one is picked whichever order they're in.
A struct type in a @spec — Ticket.t() for a plain defstruct with a
@type t, or for an Ecto schema — expands into a nested object schema
instead of an unconstrained one, recursively (an Ecto embeds_one/
embeds_many field included; belongs_to/has_many/has_one are
skipped, since they're not part of the data itself and expanding them
risks recursing through a relationship graph). This only shapes the
schema Claude sees — the argument your function actually receives is
still the plain decoded JSON map (string keys), never cast into a real
struct.
A @spec type Claudex genuinely can't map — an unsupported typespec
construct, or a Mod.t() that isn't a loaded struct or Ecto schema —
raises Claudex.Tool.SchemaError at compile time. Fix the spec, or pass
:args_schema to skip inference for that tool entirely. See
Claudex.Tool.Schema.StructExpansion for exactly what's supported.
Summary
Functions
Runs one of module's tools by the name Claude used, with the input from its
tool_use block.
Normalizes a tools: value into the plain list of tool maps the
Messages API expects. Accepts a module that uses Claudex.Tool, a
list mixing such modules with already-built tool maps, or nil.
Indexes tool modules by the tool names they implement, so a tool_use block
can be routed to the module that can run it.
Builds a tool_result content block to send back after running a tool.
Functions
@spec call(module(), String.t(), map()) :: {:ok, term()} | {:error, Claudex.Tool.Dispatch.error()}
Runs one of module's tools by the name Claude used, with the input from its
tool_use block.
Claudex.Tool.call(MyApp.Tools, "add", %{"a" => 1, "b" => 2})
#=> {:ok, 3}Arguments are matched to the function's parameters by name, so their order in the map doesn't matter, and a trailing optional parameter can be left out.
A tool that fails returns an error instead of taking the caller down with it. The two tags separate a refusal from a bug:
{:error, {:tool_refused, message}}— the tool raisedClaudex.Tool.Error, which is how a tool declines to do something{:error, {:tool_raised, message}}— anything else went wrong: another exception, athrow, or anexit
For :tool_raised the exception type is part of the message, so a bug
reads differently to Claude than a considered refusal:
{:error, {:tool_refused, "path is outside the workspace"}}
{:error, {:tool_raised, "KeyError: key :missing not found in:
%{}"}}
Normalizes a tools: value into the plain list of tool maps the
Messages API expects. Accepts a module that uses Claudex.Tool, a
list mixing such modules with already-built tool maps, or nil.
Claudex.Messages.create/2 calls this on :tools for you, so you can
just write tools: MyApp.Tools — this is public mainly for building a
tools list ahead of time, or for something other than
Messages.create/2 (Batches, a hand-rolled request).
Indexes tool modules by the tool names they implement, so a tool_use block
can be routed to the module that can run it.
Takes the same shapes as list/1. Plain tool maps have no implementation
behind them, so they don't appear — a tool_use naming one is an unknown
tool as far as dispatch is concerned.
Builds a tool_result content block to send back after running a tool.
content must be a string or a list of content blocks, per the Messages
API — if your tool returns something else, encode it first
(JSON.encode!/1 for structured data).
iex> Claudex.Tool.result("toolu_1", "18")
%{type: "tool_result", tool_use_id: "toolu_1", content: "18", is_error: false}
iex> Claudex.Tool.result("toolu_1", "no such city", is_error: true)
%{type: "tool_result", tool_use_id: "toolu_1", content: "no such city", is_error: true}