Skip to content

Toolnexus.Adapters.to_anthropic

Elixir · package toolnexus · SPEC §4 · elixir/lib/toolnexus/adapters.ex

@spec to_anthropic([Toolnexus.Tool.t()]) :: [map()]
def to_anthropic(tools)

Turns a list of tools into the tools array the Anthropic Messages API expects. It is the flattest of the three adapters — no wrapper object, and input_schema keeps its name.

When you are calling POST /v1/messages yourself (Anthropic API, Bedrock, Vertex) and need schema for the request body. Reach for it whenever you own the loop and want toolnexus only for tool aggregation — MCP servers, skills, HTTP endpoints and native functions arriving as one list.

Toolnexus.Toolkit.to_anthropic/1 takes a toolkit and delegates straight here with its tools — use that when you have a toolkit, and this when you have a bare list.

Keys in the emitted maps are strings, not atoms — this is wire data headed for JSON.

alias Toolnexus.{Adapters, Native}
weather =
Native.define_tool(%{
name: "get_weather",
description: "Current weather for a city",
input_schema: %{
"type" => "object",
"properties" => %{"city" => %{"type" => "string"}},
"required" => ["city"]
},
execute: fn args, _ctx -> "sunny in #{args["city"]}" end
})
[entry] = Adapters.to_anthropic([weather])
# Flat — three keys, no "function" wrapper (unlike OpenAI).
true = entry |> Map.keys() |> Enum.sort() == ["description", "input_schema", "name"]
true = entry["name"] == "get_weather"
true = entry["description"] == "Current weather for a city"
true = entry["input_schema"]["required"] == ["city"]
IO.puts("ok: #{entry["name"]}")

input_schema is passed through verbatim — the very map that is on the tool. Anthropic is the one provider whose key name already matches toolnexus’s own.

2. Feeding it straight into a Messages request body

Section titled “2. Feeding it straight into a Messages request body”

The output is plain maps and lists, so Jason.encode!/1 handles it with no custom encoder.

alias Toolnexus.{Adapters, Native}
mk = fn name, desc ->
Native.define_tool(%{
name: name,
description: desc,
input_schema: %{"type" => "object", "properties" => %{}},
execute: fn _args, _ctx -> name end
})
end
tools = [mk.("search", "Search the docs"), mk.("ping", "Health check")]
body = %{
"model" => "claude-sonnet-4-5",
"max_tokens" => 1024,
"messages" => [%{"role" => "user", "content" => "search for adapters"}],
"tools" => Adapters.to_anthropic(tools)
}
# Order is preserved — the adapter is a plain Enum.map/2.
names = Enum.map(body["tools"], & &1["name"])
true = names == ["search", "ping"]
json = Jason.encode!(body)
true = String.contains?(json, ~s("input_schema"))
# The key comes from the environment, never a literal in your source.
{"x-api-key", key} = {"x-api-key", System.get_env("ANTHROPIC_API_KEY") || "YOUR_KEY_HERE"}
true = is_binary(key)
IO.puts("ok: #{Enum.join(names, ", ")}")

3. Round-tripping a tool_use block back to the tool

Section titled “3. Round-tripping a tool_use block back to the tool”

Schema out, tool call in. Unlike OpenAI, Anthropic hands you input as a real map — there is no Jason.decode!/1 step.

alias Toolnexus.{Adapters, Context, Native}
weather =
Native.define_tool(%{
name: "get_weather",
description: "Current weather for a city",
input_schema: %{
"type" => "object",
"properties" => %{"city" => %{"type" => "string"}},
"required" => ["city"]
},
execute: fn args, _ctx ->
if args["city"] == "", do: raise("city is required"), else: "sunny in #{args["city"]}"
end
})
tools = [weather]
1 = length(Adapters.to_anthropic(tools))
# A content block exactly as the model returns it.
block = %{
"type" => "tool_use",
"id" => "toolu_01",
"name" => "get_weather",
"input" => %{"city" => "Chennai"}
}
called = Enum.find(tools, &(&1.name == block["name"]))
true = called != nil
res = called.execute.(block["input"], %Context{})
false = res.is_error
true = res.output == "sunny in Chennai"
# What you append to the conversation for the next turn.
result_block = %{
"type" => "tool_result",
"tool_use_id" => block["id"],
"content" => res.output,
"is_error" => res.is_error
}
true = result_block["tool_use_id"] == "toolu_01"
# Failure is data, not an exception — it maps onto tool_result.is_error.
bad = called.execute.(%{"city" => ""}, %Context{})
true = bad.is_error
true = bad.output == "city is required"
# An empty tool list emits an empty array — no wrapper, unlike Gemini.
[] = Adapters.to_anthropic([])
IO.puts("ok: #{block["name"]} -> #{res.output}")
Path From Notes
[]["name"] Tool.name What the model calls back with, in tool_use.name.
[]["description"] Tool.description What the model reads to decide whether to call it.
[]["input_schema"] Tool.input_schema Passed through unchanged — same key name, no renaming.

There is no "type" key and no nesting: an Anthropic tool entry has exactly these three keys.