Claudex.Tool (Claudex v0.6.1)

Copy Markdown View Source

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 - sets strict: true on 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 @spec is 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-argument description, an enum)

: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 @specTicket.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

call(module, name, input)

@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 raised Claudex.Tool.Error, which is how a tool declines to do something
  • {:error, {:tool_raised, message}} — anything else went wrong: another exception, a throw, or an exit

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:

%{}

"}}

list(module)

@spec list(nil | module() | [module() | map()]) :: [map()]

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).

registry(module)

@spec registry(module() | [module() | map()] | nil) :: %{
  required(String.t()) => module()
}

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.

result(tool_use_id, content, opts \\ [])

@spec result(String.t(), String.t() | [map()], keyword()) :: map()

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}