Usage
Copy MarkdownEnd-to-end: define a tool, emit the schema your provider wants, dispatch the
call back to the function. The library does not speak HTTP and does not encode
JSON — Elixir ≥ 1.18 ships JSON, so a zero-dependency setup works.
1. Define a tool module
defmodule MyApp.Tools.Weather do
use ToolCall
@doc """
Get the forecast for a city.
## Parameters
* `city` - city name, e.g. "Oslo"
* `days` - how many days ahead (1-7)
"""
@spec get_weather(city :: String.t(), days :: pos_integer() | nil) ::
{:ok, map()} | {:error, String.t()}
def get_weather(city, days \\ 3) do
{:ok, %{city: city, days: days, temp: 21.5}}
end
endEverything else is derived. What the library reads:
| You write | It reads |
|---|---|
@spec f(city :: String.t()) | parameter name city and its type. Without city :: the name is taken by position from a @doc bullet when available, then falls back to the head variable (or argN) with a warning; a bare type variable in a when constraint counts as unnamed |
def f(city, days \\ 3) | days is optional, 3 is emitted as default; a module attribute value is expanded when it is a JSON literal |
first @doc paragraph | tool description; a leading # Title is stripped and a leading level-2+ section heading is omitted in favor of its body |
* name - text (also + and -; optional (Type.t())) under ## Parameters (a trailing : and up to three spaces of heading indentation are accepted) | per-parameter description (indented continuation lines are joined; nested bullets, their continuations, and other shapes are ignored) |
@doc false | the function is not a tool |
no @spec | the function is skipped with a warning |
@type in the same module | inlined; a remote type without a provider stays {} |
@typedoc on a local @type or @opaque | fallback parameter description when no @doc bullet or @tool_param description exists; local alias chains are followed to the nearest documented type; @typep is unsupported |
use ToolCall, types: [MyProvider] | resolves custom remote types through ToolCall.TypeProvider |
a duplicate name, e.g. f(city :: String.t(), city :: integer()) | the second becomes city_2, with a warning |
_ :: String.t() | the head variable name, or argN if that is _ too |
Boolean and nil atom() defaults are omitted, including nullable atom() | nil.
Custom types
Built-in remote types are resolved by a built-in provider. A user-defined type
gets a ToolCall.TypeProvider registered per tool module:
defmodule MyApp.MoneyType do
@behaviour ToolCall.TypeProvider
@impl true
def resolve(Money, :t, []) do
{:ok, %ToolCall.IR.Type{kind: :string, pattern: "^-?\\d+\\.\\d{2}$"}}
end
def resolve(_module, _function, _args), do: :error
@impl true
def coerce(value), do: {:ok, Money.new(value)}
end
defmodule MyApp.Tools do
use ToolCall, types: [MyApp.MoneyType]
@spec price(amount :: Money.t()) :: Money.t()
def price(amount), do: amount
endresolve/3 receives the remote module, the type name and the already-resolved
argument types. Return {:ok, %ToolCall.IR.Type{}}, or :error to fall back to
{}. coerce/1 is optional; it runs after the wire value has been coerced.
Providers are tried after the built-ins (String, Keyword, Path, calendar),
so a provider cannot replace them. types: is per module. The provider module
must be compiled before the tool module; the generated require adds that
compile dependency.
Decimal.t() is not built in; implement a provider for it, or use String.t()
and convert in the function.
Compile-time conventions
Annotate arguments to name them.
@spec f(city :: String.t()), not@spec f(String.t()). Unnamed parameters can be named by position from@docparameter bullets; otherwise they fall back to the head variable (orargNwhen the head is_) with a warning. A bare type variable in awhenspec counts as unnamed too: write@spec id(value :: x) :: x when x: integer().A default makes a parameter optional.
def f(a, b \\ 1)putsainrequiredand leavesbout. Defaults that are not literals (such asDateTime.utc_now()) are not sent to the model; the parameter is still optional. A module attribute default (def f(x \\ @default)) is expanded when its value is a JSON literal; struct values are still dropped. Defaults JSON cannot carry (tuples, non-string map keys) are dropped too, and a literal default that does not belong to the parameter's value domain is dropped with a compile-time warning.~s,~cand~wsigil defaults are kept (a charlist default is sent as a string); sigils that interpolate or build non-JSON values (dates, regexes) are dropped. A non-booleanatom()default is sent as its string form;true,falseandnildefaults are omitted. The atom wire domain rejects booleans, and nil defaults are kept out even for nullable atom types. Defaults are parsed against the declared type at any depth: nestedkeyword(t), object/list fields and union branches keep their values, and nestedchar()/charlist()values become strings in the schema.Names must be unique and provider-safe. A duplicate argument name is exposed as
name_2with a warning; an unnamed_argument falls back to the head variable orargN. Provider tool names must match^[a-zA-Z0-9_-]{1,64}$: a function namedfind?warns, and the export task refuses to write it. Two arities of the same function share one tool name, so only the first is exposed.@docfirst paragraph is the description. A leading# Titleis stripped; a leading level-2+ section heading is omitted and its body is used. Bullets inside a## Parametersheading become per-parameter descriptions:* city - city name,+ city - city nameor- city - city name(backticks around the name are optional; an optional type annotation such as(String.t())is ignored). Indented continuation lines are appended; a blank line ends the current bullet's continuation, and nested bullets (and their continuations) are not parameters.@doc falseopts a function out of the tool list.@typedeclarations in the same module are resolved, including@typepand@opaque, with parameters (@type pair(a) :: {a, a}). Recursive definitions stop at the first re-entry and that reference becomes{}.@typedocis a fallback parameter description. A parameter declared as a local@typeor@opaque, directly or wrapped as<type>() | nil, uses the type's typedoc when the parameter has no description. Parameterized local types work too:pair(integer())uses the typedoc ofpair/1; a local alias chain such as@type location :: address()follows toaddress()whenlocationhas no typedoc, including current-module__MODULE__or alias references in the alias body. Precedence is@tool_paramdescription, then a@doc## Parametersbullet, then@typedoc, then no description; an empty-string@tool_paramsuppresses the fallback. Nested and wrapped uses such aslist(address())or%{address: address()}do not inherit typedoc.@typepis unsupported for descriptions because Elixir discards its typedoc at definition time; the private type is still resolved for the schema.@typedoc falseis hidden too.Large integers travel as strings. Opt in per argument:
@tool_param %{fun: :transfer, name: "user_id", wire: :string_integer, description: "Snowflake id"}The
:funkey scopes the override to one function; without it the override is ignored with a compile-time warning. A misspelled function or argument name warns too. Unknown keys and a non-string:descriptionare ignored with a warning; duplicate entries for the same argument warn because only one is used. A:descriptionofnilleaves the doc description in place; an empty string clears it.
2. Emit a schema
ToolCall.emit(MyApp.Tools.Weather, :openai)
ToolCall.emit(MyApp.Tools.Weather, :openai, strict: true)
ToolCall.emit(MyApp.Tools.Weather, :anthropic)
# several modules: concatenate the lists
tools = ToolCall.emit(A, :openai, strict: true) ++ ToolCall.emit(B, :openai, strict: true)strict: true adds "strict" => true, makes every property required (nested
objects included), widens optional values to ["T", "null"] and closes every
object. For an optional argument or field whose type does not accept nil, an
explicit null is treated as absent; if the type accepts nil, the value is
passed through and can override a default.
When it happens
Everything is compile time. Inside @before_compile the library derives the IR
and renders the three supported variants — {:openai, false},
{:openai, true} and {:anthropic, false} — into literals stored in
__schemas__/0. A second compile phase (@after_compile) reads the
just-compiled Docs chunk for @typedoc fallback descriptions; if any apply,
the enriched literals live in a generated companion module and __schemas__/0
delegates to it. ToolCall.emit/3 on a module still returns a precompiled
literal, so strict may be a runtime boolean and no schema is rendered per
call.
The accessors are direct when the module's effective typedoc key map is empty:
no parameter that still needs a description has a module-local declared type
(for example, every local-type parameter already has a @doc bullet or
@tool_param description), or the compile has docs: false. __schemas__/0
and __tools__/0 then return the phase-one literals with no helper call. A
parameter gains a typedoc key only when its declared type is a module-local
type, directly or wrapped as <type>() | nil; list(address()) and
%{address: address()} stay on the direct path. When at least one such
undocumented parameter exists and docs are enabled, a @typedoc companion may
exist, so the accessors call Code.ensure_loaded?/1 and
function_exported?/3 first and fall back to the phase-one literals when the
companion is stale or missing.
Measured locally on a 20-tool module (Elixir 1.20.5, OTP 29): a module with an
empty typedoc key map takes about 0.07 µs for emit/3, 13 ns for
__schemas__/0 and about 0.15 µs for run/3 on the generated scalar fast path.
A local-type parameter that is already documented by @doc or @tool_param
takes that same direct path, because Typedoc.apply/3 would not touch it. With
a typedoc companion present, emit/3 takes about 0.14 µs; run/3 uses the
generated per-tool IR without the companion guard. A complex nested tool falls
back to the generic coercion path at roughly 0.5 µs. A module with an
undocumented local-type parameter whose type has no visible @typedoc still
pays the guard on the first __tools__/__schemas__ accessor call — roughly
16–26 µs because the failed Code.ensure_loaded?/1 searches the code path,
which depends on the code path length. The absence is cached per companion,
function and generation, so later calls use the phase-one literal directly. A
fresh render of a runtime ToolCall.IR.Tool costs about 0.5 µs. The embedded
schemas add roughly 35 KB to the beam and 9 ms to compilation.
That is also why releases work: mix release strips the Docs and Dbgi
chunks, so Code.Typespec.fetch_specs/1 and Code.fetch_docs/1 fail on the
compiled beam, and this library never calls them. See the release notes in
design.md.
A ToolCall.IR.Tool — a tool built at runtime — is still rendered on demand.
That is the only path that pays for rendering.
3. Call the provider
# OpenAI
%{"model" => "gpt-4.1", "messages" => messages,
"tools" => MyApp.Tools.openai_tools()}
# Anthropic
%{"model" => "claude-sonnet-4-5", "max_tokens" => 1024, "messages" => messages,
"tools" => ToolCall.emit(MyApp.Tools.Weather, :anthropic)}The two protocols differ in exactly one place, and it matters for dispatch:
OpenAI returns arguments as a JSON string, Anthropic returns input
already decoded.
4. Dispatch a call
defmodule MyApp.ToolRunner do
@modules [MyApp.Tools.Weather, MyApp.Tools.Math]
# OpenAI: arguments is a JSON string
def openai_tool_result(%{"id" => id, "function" => %{"name" => name, "arguments" => json}}) do
content =
with {:ok, args} <- JSON.decode(json),
{:ok, value} <- run(name, args) do
JSON.encode!(%{ok: true, result: value})
else
{:error, reason} -> JSON.encode!(%{ok: false, error: render(reason)})
end
%{"role" => "tool", "tool_call_id" => id, "content" => content}
end
# Anthropic: input is already a map
def anthropic_tool_result(%{"type" => "tool_use", "id" => id, "name" => name, "input" => input}) do
content =
case run(name, input) do
{:ok, value} -> JSON.encode!(value)
{:error, reason} -> render(reason)
end
%{"type" => "tool_result", "tool_use_id" => id, "content" => content}
end
defp run(name, args) do
module = Enum.find(@modules, &(name in ToolCall.tool_names(&1)))
if module,
do: ToolCall.run(module, name, args),
else: {:error, {:unknown_tool, name, []}}
end
defp render({:invalid_argument, {name, reason}}), do: "#{name}: #{inspect(reason)}"
defp render(other), do: inspect(other)
endMeasured output for a good call and two bad ones:
%{"role" => "tool", "tool_call_id" => "call_1",
"content" => "{\"ok\":true,\"result\":{\"temp\":21.5,\"city\":\"Oslo\",\"days\":5}}"}
# the model sent a string where the schema says integer
"content" => "{\"error\":\"days: {:expected_integer, \\\"5\\\"}\",\"ok\":false}"
# the model invented an argument
"content" => "{\"error\":\"{:unknown_arguments, [\\\"c\\\"]}\",\"ok\":false}"5. Export the schemas
mix tool_call.export # OpenAI function objects
mix tool_call.export --format anthropic # Anthropic tool definitions
mix tool_call.export --format mcp --out priv/tools.json
mix tool_call.export --strict
mix tool_call.clean # remove orphaned typedoc companions
--format is openai (default), anthropic or mcp; --strict renders the
strict subset for OpenAI and MCP; --app discovers another application. Without
module arguments every module in the app that exports __tools__/0 is included.
The output indents two spaces, sorts object keys and modules, and keeps
declaration order inside a module, so a committed file stays stable across runs.
Formatting uses json:format/1, which is why the project floor is Erlang/OTP
27.1+. The formatter is OTP's: the same OTP version reproduces the file, while an
upgrade can reflow it without changing the JSON.
Tool names outside ^[a-zA-Z0-9_-]{1,64}$ are refused.
The task currently formats with OTP's json:format/1, not Elixir's JSON; the
library itself still has no runtime JSON dependency. When the request is built
somewhere else, ToolCall.describe/2 returns
%{module, name, description, parameters} for every tool — the same data
without a provider envelope.
6. The loop
def ask(messages, rounds \\ 0)
def ask(_messages, rounds) when rounds > 8, do: {:error, :too_many_rounds}
def ask(messages, rounds) do
%{"choices" => [%{"message" => message}]} = post_chat(messages)
case message["tool_calls"] do
[] ->
{:ok, message["content"]}
calls ->
results = Enum.map(calls, &MyApp.ToolRunner.openai_tool_result/1)
ask(messages ++ [message | results], rounds + 1)
end
end7. Runtime behaviour
| Input | Result |
|---|---|
%{"city" => "Oslo"} | {:ok, %{city: "Oslo", days: 3, temp: 21.5}} — trailing defaults fill in |
%{} | {:error, {:missing_argument, "city"}} |
%{"days" => 5} | {:error, {:missing_argument, "city"}} |
%{"b" => 2} on def f(a \\ 1, b \\ 2) | {:error, {:argument_gap, "a", ["b"]}} — a reduced-arity call would bind 2 to a; use %{"a" => 2, "b" => ...} |
%{"b" => 2} on def f(a \\ 1, b) | {:ok, {1, 2}} — Elixir can omit a while keeping required b |
%{"city" => "Oslo", "days" => 1.5} | {:error, {:invalid_argument, {"days", {:expected_integer, 1.5}}}} |
| an invalid UTF-8 binary in any string argument | {:error, {:invalid_argument, {name, {:invalid_utf8, value}}}} |
%{"city" => "Oslo", "days" => nil} | {:ok, %{... days: nil}} — days is nullable, so explicit null is preserved |
[1, 2] instead of a map | {:error, {:invalid_arguments, [1, 2]}} |
| a raising function | {:error, {:raised, "..."}} |
Error terms
ToolCall.run/3 never raises for bad input; it returns one of:
| Error | Meaning |
|---|---|
{:unknown_tool, name, known} | no tool with that name |
{:unknown_arguments, names} | keys the schema does not declare |
{:non_string_arguments, keys} | args used atom or other non-string keys; JSON objects decode to strings |
{:invalid_arguments, args} | args was not a map (a provider may hand back a list or a scalar) |
{:missing_argument, name} | a required argument is absent |
{:argument_gap, missing, dropped} | a later optional argument was supplied after an omitted optional one, so Elixir would shift it into the wrong slot; a required argument after an omitted optional one is fine |
{:invalid_argument, {name, reason}} | coercion failed, e.g. {:expected_integer, "1.5"} |
{:raised, message} | the function raised |
{:thrown, value} / {:exited, reason} | the function threw or exited |
Top-level argument maps use string keys, as JSON decodes them, and nested object
values should too; keyword(t) values also accept atom keys because a keyword
list is their value domain.
A raised exception is also logged with its stacktrace at :error; the model only
sees {:raised, message}. A tool that raises is a bug, not a model error — return
{:error, reason} for input the model can fix.
Send the error back to the model as the tool result — the reason is plain data on purpose:
case ToolCall.run(MyApp.Tools.Weather, call_name, call_args) do
{:ok, value} -> inspect(value)
{:error, reason} -> inspect(reason)
end8. Rules and caveats
- A raised exception is logged with its stacktrace at
:error; the model only sees{:raised, message}. Raising is for bugs — return{:error, reason}for anything the model can fix. - Tool results must be JSON-encodable.
{1, :ok}and[a: 1]raiseProtocol.UndefinedErrorinJSON.encode!/1; return maps, lists and scalars, or sendinspect/1as the content. Date.t()/Time.t()/DateTime.t()/NaiveDateTime.t()are parsed from ISO 8601. The schema carriesformat: date/time/date-time, but a model may still send another format; the dispatcher answers{:invalid_argument, {name, {:invalid_date, value}}}and the model can retry. DeclareString.t()if you want the raw string.- String arguments must be valid UTF-8. JSON strings are Unicode; invalid
bytes from a provider or direct caller become
{:invalid_argument, {name, {:invalid_utf8, value}}}, not an exception. This applies toString.t(),binary(),bitstring(), patterns,charlist(),char(), atoms, enums and keyword keys. - Wire shapes are restored before dispatch.
%{a: integer()}arrives with atom keys at every declared level,keyword(t)as a keyword list,charlist()as a charlist,char()as a codepoint, tuples as Erlang tuples, and1.0as1. Open maps and%{k() => v}keep the string keys JSON gives them.keyword/1keys must already exist as atoms in the VM, likeatom(): an unknown key is{:unknown_atom, key}. struct()is not reconstructed. It is emitted as an open object and the function receives a plain map. Use a concrete struct type with a type provider when the function needs the struct itself.- Do not use a bare
atom(). The schema allows any string while the dispatcher only accepts atoms that already exist in the VM. Enumerate::red | :green. - Open objects cannot be expressed with
strict: true.map(),keyword(t)and%{k() => v}are all objects with free keys; strict mode closes every object withadditionalProperties: false, so the model can only send{}. The build warns for those parameters (and forterm(), which has no type at all). Tuples lose their element types in strict mode (prefixItemsis rejected) and keep onlyminItems/maxItems; the build warns too. Defaults are dropped in strict mode, becausedefaultis rejected. For a non-nullable optional value,nullmeans "use the default"; for a nullable value it is passed through asnil. - Tool names are function names; there is no namespace. When you concatenate
tools from several modules, keep the names unique and route back to the right
module. Both providers require
^[a-zA-Z0-9_-]{1,64}$, so a function namedfind?warns. Two arities of one function share a name; only the first is exposed, with a warning. use ToolCallonly accepts:types.use ToolCall, emit: [...]raises; the three provider variants are always rendered.@tool_paramneedsfun::@tool_param %{fun: :transfer, name: "user_id", wire: :string_integer}. Large integers travel as decimal strings; seenumber-handling.md.- Deeply nested
@typehierarchies are inlined up to a budget (100 inlinings). Past that the rest of the type is treated as{}and the build warns; flatten the hierarchy or enumerate the fields. - The return type is not checked. Tool results are returned as plain terms and must be JSON-encodable by the caller.
run/3has no timeout. A tool that can hang should be wrapped inTask.async/1+Task.yield/2by the caller.