toolnexus (Elixir)
Your LLM, with MCP tools and agent skills built in — in 3 lines, on the BEAM.
toolnexus unifies every tool source an agent needs — MCP servers (stdio + streamable-HTTP),
agent skills (SKILL.md folders), your own functions, HTTP endpoints, ten built-in shell/file
tools, and remote A2A agents — behind one uniform Tool, emits the schema in OpenAI /
Anthropic / Gemini formats, and ships a unified client with a built-in tool-calling loop.
The Elixir port of toolnexus — the same library, byte-identical, also in JavaScript, Python, Go, Java, C#, Clojure and Rust. The MCP client is implemented in-house on OTP (supervised connections, no third-party MCP SDK), which is also why this port ships the full elicitation bridge (form and URL mode).
Install
def deps do
[{:toolnexus, "~> 0.26"}]
end
Zero to agent
{:ok, toolkit} = Toolnexus.create_toolkit(mcp_config: "mcp.json", skills_dir: ["skills"])
client = Toolnexus.Client.create(base_url: System.get_env("OPENAI_BASE_URL"),
style: "openai", model: "gpt-4.1",
api_key: System.get_env("OPENAI_API_KEY"))
result = Toolnexus.Client.run(client, "What tools do you have? Use one.", toolkit)
IO.puts(result.text)
mcp.jsonis the standard Claude-desktop-style config (mcpServers/servers/mcpkeys all accepted).skills/is a folder of**/SKILL.mdfiles, loaded on demand through oneskilltool.- Remote MCP
headersvalues expand${ENV_VAR}at call time and are never logged.
API key by host (BREAKING in 0.22.0)
With no api_key, the client reads only api_key_env (the name of an env var) or the env
var that belongs to the base_url host, over https: api.openai.com → OPENAI_API_KEY,
api.anthropic.com → ANTHROPIC_API_KEY, openrouter.ai → OPENROUTER_API_KEY. There is no
cross-provider fallback any more, so a local or custom base_url must set api_key or
api_key_env, or the run raises before any request:
Toolnexus.Client.create(base_url: "http://localhost:11434/v1", model: "llama3", api_key: "unused")
Toolnexus.Client.create(base_url: "https://gateway.example/v1", model: "m", api_key_env: "GATEWAY_KEY")
Prompt cache and tool tiers
client =
Toolnexus.Client.create(
base_url: "https://api.anthropic.com", style: "anthropic", model: "claude-sonnet-4-5",
prompt_cache: %{long_ttl: false}, # cache breakpoints (anthropic) / prompt_cache_key (openai)
tool_tiers: %{core: ["read", "grep"], deferred: []} # other tools load via tool_search / call_tool
)
result = Toolnexus.Client.run(client, "fix the failing test", toolkit)
result.usage[:cache_read_tokens] # present when the provider reports cache hits
Toolnexus.Client.tool_tier_plan(client, toolkit) # %{"read" => :core, "bash" => :listed, ...}
Toolnexus.Client.reset_tool_tiers(client, toolkit) # rebuild the frozen plan at a session start
The tools array never changes mid-session, so the cached prefix holds even when tools are added
later. Runnable: mix run examples/prompt_cache_tiers.exs (hermetic, no key needed).
Host additions (0.22.0)
Toolnexus.Mcp.load(cfg, on_server: fn %{name, status, tools, error} -> ... end)— use each MCP server as soon as it is ready;Toolnexus.Mcp.resource_tools/1lists and reads MCP resources.wait_for_ctx: fn ctx, request -> answer end—ctx.signalis the turn's pid, so a pending question ends with the turn.- Tool
hints(%Toolnexus.ToolHints{read_only, destructive, open_world}) on builtins, MCP annotations and everybefore_toolevent — a permission gate is abefore_toolhook. - Opt-in builtins:
background: Toolnexus.Builtin.BackgroundShells.new()(bash_output,kill_shell),web_search: fn query, limit -> ... end,notebook_edit: true;grepcontext/before/after; truncation inread/grepmetadata. - Compactor
on_compact(counts, summary,foldedmessages);Toolnexus.Agents.as_tool_with/2and runtime:wait_for;ctx.call_idon every tool call;Toolnexus.Client.list_models/1.
Simple judgments
Toolnexus.Judge is a thin layer over any Toolnexus.Classifier (SPEC §8B); the wire is unchanged.
import Toolnexus.Judge
st = state("You are Donkey Kong, you want to win.", %{message_received: "jump off the stage now"})
{:ok, answers} =
ask(classifier, st, [
noul(:is_appropriate, "Does message_received contain harmful language or topics?"),
noul(:does_this_help, "Does message_received help Donkey Kong win?")
])
answers["is_appropriate"].band #=> :yes | :no | :uncertain (cut-points 0.30 / 0.70, exclusive)
Toolnexus.Judge.Answer.value(answers["does_this_help"])
{:ok, outcome} =
gate(classifier, st, questions, [%{question: "fixable", below: 0.3, action: "fail"}])
# outcome.escalated => a §10 `input` Request instead of a guess
The role lives in the state, and each question names the state field it judges. Also: Policy
(default, skip_uncertain), Tape (record / replay by call name), Judge.static/4 (one-line
static classifier), and Classifier.evaluate_batch/4 (many states, state order, fail-closed, 16 in flight).
Batteries (SPEC §8B): Toolnexus.Judge.ToolGuard, ToolRelevance, SkillRelevance,
ToolResultFilter, IsComplete, AgentRouter, ContentGuard, ModelRouter — each a typed
verdict from check/select/filter/pick, and as_hook(battery, next) where a hook seam
exists. A before_llm hook may also return %{model: "id"} to override the model for that turn.
Backends, images, cache and helpers
Proposed defaults, owner to confirm (openspec/changes/add-classifier-backends, SPEC §8B).
alias Toolnexus.Classifier
alias Toolnexus.Classifier.{MemoryCache, Thresholds}
import Toolnexus.Judge
# Backends are data: Classifier.backends() — typesafe (default) | openrouter
# (typesafe/jev-1.14) | cloudflare | vercel | custom. ${NAME} in base_url expands at
# call time; errors name the endpoint unexpanded. custom needs base_url + supports.
{:ok, c} = Classifier.create(backend: "openrouter", cache: MemoryCache.new(3_600_000, 1000))
# A ContentPart image in the state is lifted into `assets` (PNG/JPEG, 280..1568 px
# a side, 16 MiB each, 32 MiB a request; never resized). A backend whose row says no
# images fails BEFORE the network with %Classifier.Unsupported{}; `supports:` overrides.
photo = Toolnexus.ContentPart.image!("listing.png")
{:ok, d} = c |> Classifier.state(%{"photo" => photo}) |> Classifier.evaluate([noul(:red, "Is the photo's object red?")])
d.cached # true when served from the cache, no request sent
# Batch: per-item %BatchResult{decision | error}, state order, 16 in flight.
{:ok, results} = c |> Classifier.states(["a", "b"]) |> Classifier.evaluate([noul(:q, "…")])
state(v) |> evaluate(qs)validates the reply fail-closed (validate_decision/2); plainevaluate/3keeps its old behaviour and does not.- The cache is opt-in (
%{get:, set:}orMemoryCache), per request, keyed on the SERVED model, and stores only valid replies. Key bytes match every port (model_cache_key/2,decision_cache_key/5). - Helpers:
validate_decision/2,Thresholds.band/2(inclusive 0.8 / 0.2 / 0.6;ask/gatekeep their bands),chunk_questions/2,Judge.raw/4(an unmodelled question type; unknown answer types decode to%RawAnswer{}). classifier.evaluatemetric events carryquestions,questions_hashandcached.style: "llm"is deprecated: it still works unchanged for at least one minor release, with no runtime warning.
MCP 2026-07-28 + OAuth
Every MCP server is probed with server/discover first (SPEC §2.2). A server on
2026-07-28 is used directly — no initialize, no session, stdio and HTTP, MRTR input
requests through wait_for — and anything older drops to the existing (frozen) client.
Nothing in mcp.json has to change.
tk =
Toolnexus.Toolkit.build(
mcp_config: %{
"mcpServers" => %{
"docs" => %{
"url" => "https://mcp.example.com/mcp",
"auth" => %{"clientId" => "my-registered-app"}, # or clientMetadataUrl (CIMD); never DCR
"toolSearch" => true, # expose docs_search_tools + docs_call_tool
"description" => "the product docs"
}
}
},
wait_for: &MyHost.wait_for/1, # MRTR input + auth "pause" answered here
token_store: MyHost.TokenStore.new(), # optional; default in-memory per load
open_url: &MyHost.open/1 # optional; "auto" mode browser opener
)
auth.modeis"auto"(browser + loopback callback) or"pause"(a §10kind:"authorization"request; answer it withdata: %{"callbackUrl" => url}). Withoutwait_for, a pause during load marks the serverfailed(authorization pending); during a tool call it returns a §10 pending result, resumed by the next execution carryingctx.answer.- Remote skills (the MCP skills extension) join the same
skilltool as local skills. - Tokens are never logged;
inspect/1on aToolnexus.Mcp.Tokenis redacted. - Gap: the frozen fallback gets the bearer token as a static
Authorizationheader when it starts; a 401 later on the fallback path does not re-run the OAuth flow.
Why the BEAM port
Long-running agents want supervision. Every MCP connection is a supervised process; a crashed
stdio server is isolated (status "failed") without taking your toolkit down; parallel tool calls
ride Task.async_stream. Same contract as the other six ports, native OTP underneath.
Media generation
Generate images, video and speech (plus transcription, voice cloning and talking-head video)
from a JSON style instead of provider code. Async jobs are started and polled for you. The same
verbs become agent tools (generate_image, generate_video, generate_speech, …), and whatever
they produce lands in RunResult.files. examples/media-styles/a2e.json ships first and reads
A2E_API_KEY at call time. Its result URLs expire in 3–30 days, so save what you keep. SPEC §7G;
guide: https://muthuishere.github.io/toolnexus/media/.
alias Toolnexus.Media
{:ok, style} = Media.load_style("examples/media-styles/a2e.json")
media = Media.new(style, on_progress: &IO.inspect/1)
{:ok, img} = Media.image(media, %{prompt: "a red fox in fresh snow", width: 1024, height: 768})
{:ok, vid} = Media.video(media, %{prompt: "the fox blinks", image: hd(img.files).url, seconds: 5})
{:ok, tk} = Toolnexus.create_toolkit(media: media) # generate_image, …
res = Toolnexus.Client.run(client, "Draw a fox, then animate it.", tk)
for f <- res.files, do: IO.puts("#{f.from} #{f.mime_type} #{f[:url]}")
Embeddable MCP handler
Toolnexus.mcp_handler(toolkit, cfg) is a Plug you mount on your listener (Bandit, Phoenix),
behind your own TLS, CORS and OAuth. Every policy is a hook: authenticate (401 +
www_authenticate), list_tools per request (a name outside it is never executed), before_call
(:allow / {:deny, msg} / {:confirm, msg} — confirm elicits on the same POST's SSE stream when
the client can), on_call, server_info, resources/prompts, pluggable session_ids, stateless.
Toolnexus.McpHandler.notify_tools_changed/1 pushes tools/list_changed to open GET streams.
SPEC §7C.1; runnable: mix run examples/mcp_handler.exs.
handler =
Toolnexus.mcp_handler(toolkit,
authenticate: fn conn -> MyAuth.verify(conn) end, # {:ok, identity} | {:error, _}
list_tools: fn ctx -> MyTools.for(Toolnexus.McpHandler.identity(ctx)) end,
before_call: fn _ctx, tool, _args ->
if tool.annotations && tool.annotations.destructive_hint, do: {:confirm, "Sure?"}, else: :allow
end
)
Bandit.start_link(plug: {Toolnexus.McpHandler, handler}, port: 4000)
Tools may also return %ToolResult{structured: map, meta: map} (→ structuredContent / _meta)
and declare output_schema / annotations; absent, the wire is unchanged.
MCP 2026-07-28 (opt-in)
List "2026-07-28" in protocol_versions (alone, or next to "2025-06-18" for both wires on one
mount) and the handler also speaks the stateless 2026 wire: server/discover, header validation,
resultType + _meta serverInfo, and MRTR — a {:confirm, msg} or a tool's §10 suspension
answers input_required, and the retry's answer reaches the tool as ctx.answer. initialize
never negotiates 2026-07-28; without it in the list the handler is unchanged. list_skills
publishes skills (skills/list, skills/get, skill:// reads); Toolnexus.load_mcp_skills/1
builds them from a directory. Toolnexus.McpServerAuth makes the handler an OAuth protected
resource: bearer JWT (RS256, JWKS discovered from the issuer) or your own verify, per-tool
scopes, exact 401/403 challenges, and an RFC 9728 metadata plug. SPEC §7C.2.
auth = Toolnexus.McpServerAuth.new(issuer: "https://login.example", scopes: %{"*" => ["mcp"], "wipe" => ["mcp:admin"]})
handler =
Toolnexus.mcp_handler(toolkit,
protocol_versions: ["2026-07-28", "2025-06-18"],
authenticate: Toolnexus.McpServerAuth.authenticator(auth), # identity = %Toolnexus.McpPrincipal{}
list_skills: fn _ctx -> Toolnexus.load_mcp_skills("skills") end
)
# mount {Toolnexus.McpHandler, handler} at /mcp and
# Toolnexus.McpServerAuth.metadata(auth) at /.well-known/oauth-protected-resource[/mcp]
Bring your own authorization server: Toolnexus.McpServerAuth verifies its tokens. The auto-approving dev
authorization server the suite uses is test tooling (test/support/mcp_dev_auth_server.ex), not part of the library.
Documentation
Everything else — the full surface, with runnable examples — lives on the docs site:
| Start here | Quickstart · Concepts · Install |
| Tool sources | MCP · Skills · Native · HTTP · Built-ins · A2A |
| The loop | Streaming · Memory · Suspension · Resilience · Observability |
| Agents | Sub-agents & teams · Personas · Typed decisions |
| API reference | Elixir |
| Cookbook | Zero to agent · MCP servers · Agent skills · Judge |
Contract across all eight ports: SPEC.md.
Agents via ACP (SPEC §8C)
toolnexus ships no per-agent client. Three vendor-neutral pieces compose instead:
Toolnexus.Toolkit.as_mcp_server/2 serves the toolkit to a local agent
(%{url, token, close}, loopback + bearer), Toolnexus.Acp.connect/2 drives any
ACP agent (:mcp_servers sent verbatim, :session_meta, :client_caps_meta,
:model), and Toolnexus.Acp.generate_stream/1 plugs into
Toolnexus.Client.create_in_process(generate_stream: …) for incremental streaming.
srv = Toolnexus.Toolkit.as_mcp_server(tk)
entry = %{"type" => "http", "name" => "toolnexus", "url" => srv.url,
"headers" => [%{"name" => "Authorization", "value" => "Bearer " <> srv.token}]}
{:ok, acp} = Toolnexus.Acp.connect(["my-agent", "acp"], mcp_servers: [entry])
client = Toolnexus.Client.create_in_process(model: "acp", generate_stream: Toolnexus.Acp.generate_stream(acp))