Toolnexus.Tool
Elixir · package toolnexus · SPEC §1 · elixir/lib/toolnexus/types.ex
defmodule Toolnexus.Tool do @enforce_keys [:name, :description, :input_schema, :source, :execute] defstruct [:name, :description, :input_schema, :source, :execute]
# execute is (args :: map, ctx :: Toolnexus.Context.t()) -> Toolnexus.ToolResult.t()endThe one struct. An MCP server tool, an agent skill, a built-in shell tool, a remote A2A agent, an
HTTP endpoint and a plain function of your own are all the same thing to an LLM — a named,
described, schema’d callable. Toolnexus.Tool is that thing, and every source produces it.
When to use it
Section titled “When to use it”You mostly receive tools rather than construct them: Toolnexus.Toolkit.tools/1 hands you a
list, and that is what you Enum.map and pass to an adapter.
Construct one directly when you are writing a new tool source — something producing tools from
a shape toolnexus doesn’t already cover. For a single ordinary function, use
Toolnexus.Native.define_tool/1 instead.
Why this and not the alternative
Section titled “Why this and not the alternative”execute is an arity-2 function, (args, ctx). Unlike JavaScript and Go, ctx is not
optional — the loop always passes a %Toolnexus.Context{}, so you can pattern-match on it
directly without nil-guards.
Examples
Section titled “Examples”1. The smallest possible tool
Section titled “1. The smallest possible tool”alias Toolnexus.{Tool, ToolResult, Context}
echo = %Tool{ name: "echo", description: "Return whatever it is given", input_schema: %{ "type" => "object", "properties" => %{"text" => %{"type" => "string"}}, "required" => ["text"] }, source: "custom", execute: fn args, _ctx -> %ToolResult{output: to_string(args["text"]), is_error: false} end}
res = echo.execute.(%{"text" => "hello"}, %Context{})
^res = %ToolResult{output: "hello", is_error: false, metadata: nil}true = res.output == "hello"false = res.is_error
IO.puts("ok: #{res.output}")source is not free-form — it is one of "mcp", "skill", "native", "http", "builtin",
"a2a", "custom". Use "custom" for tools you construct yourself.
2. Reporting failure, and carrying metadata
Section titled “2. Reporting failure, and carrying metadata”A tool that fails does not raise — it returns is_error: true. The loop feeds that text back to
the model, so it can react. ToolResult.ok/1 and ToolResult.error/1 are the shorthand.
alias Toolnexus.{Tool, ToolResult, Context}
divide = %Tool{ name: "divide", description: "Divide two numbers", input_schema: %{ "type" => "object", "properties" => %{"a" => %{"type" => "number"}, "b" => %{"type" => "number"}}, "required" => ["a", "b"] }, source: "custom", execute: fn args, _ctx -> a = args["a"] / 1 b = args["b"] / 1
if b == 0 do # The model sees this text and can correct itself on the next turn. ToolResult.error("Cannot divide by zero") else %ToolResult{ output: to_string(a / b), is_error: false, metadata: %{title: "divide", operands: [a, b]} } end end}
ok = divide.execute.(%{"a" => 10, "b" => 4}, %Context{})true = ok.output == "2.5"true = ok.metadata.operands == [10.0, 4.0]
bad = divide.execute.(%{"a" => 1, "b" => 0}, %Context{})true = bad.is_error
IO.puts("ok: #{ok.output} | error path: #{bad.output}")3. A generated tool source — the real reason this struct is public
Section titled “3. A generated tool source — the real reason this struct is public”Producing many tools from data is where you build the struct directly. Tool.sanitize/1 makes each
name schema-safe.
alias Toolnexus.{Tool, ToolResult, Context}
endpoints = [ %{key: "get user", path: "/users/:id"}, %{key: "list orders", path: "/orders"}]
tools = Enum.map(endpoints, fn e -> %Tool{ # Names must match [a-zA-Z0-9_-]; sanitize/1 does exactly that. name: Tool.sanitize(e.key), description: "Call #{e.path}", input_schema: %{"type" => "object", "properties" => %{"id" => %{"type" => "string"}}}, source: "custom", execute: fn args, _ctx -> ToolResult.ok("#{e.path} <- #{args["id"]}") end } end)
true = Enum.map(tools, & &1.name) == ["get_user", "list_orders"]
res = hd(tools).execute.(%{"id" => "42"}, %Context{})true = res.output == "/users/:id <- 42"
IO.puts("ok: #{Enum.map_join(tools, ", ", & &1.name)}")Fields
Section titled “Fields”| Field | Type | What it is |
|---|---|---|
name |
String.t() |
The name the model calls. Must match [a-zA-Z0-9_-] — run it through Tool.sanitize/1. |
description |
String.t() |
What the model reads to decide whether to call it. |
input_schema |
map() |
A JSON-Schema object, string-keyed. |
source |
String.t() |
One of mcp, skill, native, http, builtin, a2a, custom. |
execute |
(map(), Context.t() -> ToolResult.t()) |
Arity-2 function. ctx is always passed. |
Functions
Section titled “Functions”| Function | What it does |
|---|---|
Toolnexus.Tool.sanitize/1 |
Replaces every character outside [a-zA-Z0-9_-] with _ (SPEC §0.2). |
See also
Section titled “See also”Toolnexus.ToolResult— whatexecutereturnsToolnexus.Context— whatexecutereceivesToolnexus.Native.define_tool— build a tool from a functionToolnexus.create_toolkit— aggregate tools from every source