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.24"}]
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)

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)

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.

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]}")

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.