We wrote one agent SDK and ported it, by hand, to seven languages — JS, Python, Go, Java, C#, Elixir, Clojure — until the same conformance suite passes byte-identical output in every one of them.
Point toolnexus at an mcp.json and a skills/ folder and you get a working agent: the
tool-calling loop, skills injection, six unified tool sources, and conversation memory — all
included. Vendor-neutral, byte-identical across JavaScript · Python · Go · Java · C# · Elixir ·
Clojure — the Clojure port is one .cljc tree that runs on both the JVM and cljgo, held
to the same full tier as the other six and published to Clojars like the rest.
Right-sized. Not a framework — no builders, advisors, runnables, or config to wade through. Not a toy that falls over the moment you need streaming or a retry. Exactly what a real agent needs — MCP, skills, native + HTTP + built-in tools, remote A2A agents, in-process sub-agents, the loop, hooks, streaming, retries, memory — and nothing it doesn't.
npm i toolnexus # JS / TypeScript
pip install toolnexus # Python
go get github.com/muthuishere/toolnexus/golang # Go
dotnet add package Toolnexus # C#
{:toolnexus, "~> 0.13"} # Elixir (mix.exs deps)
# Java (Maven): io.github.muthuishere:toolnexus:0.13.0
# Clojure (deps.edn): net.clojars.muthuishere/toolnexus {:mvn/version "0.13.0"} — JVM and cljgo
The insight (borrowed from opencode): MCP server
tools, agent skills, your own functions, remote HTTP endpoints, the built-in shell/file
tools, remote A2A agents, and in-process sub-agents are all the same thing to an LLM — a
named, described, schema'd callable. toolnexus unifies every tool source behind one Tool
interface and drives any model with them.
SOURCES TOOLKIT ANY LLM
┌──────────────────┐
│ MCP servers │──┐
│ (mcp.json) │ │ ┌──────────────────────────┐ ┌──────────────┐
├──────────────────┤ │ │ uniform Tool[] registry │─────▶│ OpenAI-style │
│ Agent skills │ │ │ • tools() / execute() │ ├──────────────┤
│ (SKILL.md) │ ├────▶│ • skillsPrompt() │─────▶│ Anthropic │
├──────────────────┤ │ │ • toOpenAI/Anthropic/ │ ├──────────────┤
│ Native fns │ │ │ Gemini() │─────▶│ Gemini │
│ (defineTool) │ │ └────────────┬─────────────┘ └──────────────┘
├──────────────────┤ │ ▼
│ HTTP / OpenAPI │ │ ┌───────────────────────────────────┐
│ (url+headers) │ ├────▶│ UNIFIED CLIENT (host loop): │
├──────────────────┤ │ │ baseURL + style + model → run() │
│ Built-in tools │──┘ │ inject skills → call → exec → … │
│ (10, on by dflt)│ │ + memory: ask() / ConversationStore│
└──────────────────┘ └───────────────────────────────────┘
+ remote A2A agents (each skill → a tool) · or serve your toolkit as an A2A agent
+ in-process sub-agents (agent() + team → one task tool; SPEC §7D)
No framework, no glue. Two files and one call — and your LLM now has MCP tools, agent skills, and 10 built-in shell/file tools built in, something no other library hands you as a drop-in.
1. Add an MCP config file — mcp.json:
{
"mcpServers": {
"fs": {
"command": ["npx", "-y", "@modelcontextprotocol/server-filesystem", "/data"]
},
"acme": {
"type": "remote",
"url": "https://api.acme.com/mcp",
"headers": { "Authorization": "Bearer ${ACME_TOKEN}" }
}
}
}
2. Add a skills folder — skills/process-refund/SKILL.md:
---
name: process-refund
description: Use when a customer asks for a refund. Walks the policy + steps.
---
# Refund workflow
1. Verify the order …
3. Call any LLM — MCP + skills + built-ins are already in it:
const tk = await createToolkit({ mcpConfig: "./mcp.json", skillsDir: "./skills" })
const agent = createClient({
baseUrl: "https://openrouter.ai/api/v1",
style: "openai",
model: "openai/gpt-4o-mini",
})
const { text } = await agent.run("Refund order 1234 for the customer.", { toolkit: tk })
// The model sees every MCP server tool, a `skill` tool, and the built-in toolset — the skills
// catalog is injected into its system prompt. It loads `process-refund` and calls tools itself.
That's the whole thing. createToolkit() alone (no config) still gives you a working agent —
the 10 built-in tools are on by default. Bring your own loop instead? Use tk.toOpenAI() /
toAnthropic() / toGemini() for the schema and tk.execute(name, args) to run a call.
The same three steps work in Python, Go, Java, C#, Elixir, and Clojure.
The individual pieces — MCP, agent skills (SKILL.md), native tools, HTTP tools — each landed in
the big frameworks during 2026: Spring AI, LangChain (Deep Agents) and Google ADK now
do most of them. What none of them combine is every tool source behind one interface,
byte-identical across seven languages, vendor-neutral, in a small à-la-carte library:
openai/anthropic style; not tied to one provider
(unlike Gemini-centric ADK).Tool registry, for any model.Each language builds on the most popular MCP SDK for that ecosystem — nothing is reimplemented from scratch:
| Lang | Dir | MCP SDK |
|---|---|---|
| JS/TS | js/ | @modelcontextprotocol/sdk (same as opencode) |
| Python | python/ | mcp (modelcontextprotocol/python-sdk) |
| Go | golang/ | github.com/mark3labs/mcp-go |
| Java | java/ | io.modelcontextprotocol.sdk:mcp (official) |
| C# | csharp/ | ModelContextProtocol (official) |
| Elixir | elixir/ | in-house MCP client (OTP-supervised, no SDK) |
| Clojure | clojure/ | in-house MCP client over koine (JVM + cljgo) |
The language-independent behavior is pinned in SPEC.md so all the shipped ports stay
byte-compatible (especially the skill loader output). Clojure (one .cljc source tree on the JVM
and cljgo) is held to the same full tier as the
rest — agent runtime, sub-agents, personas and compaction included — and is on Clojars since
v0.13.0; see clojure/README.md.
Everything below surfaces as the same uniform Tool — one registry, any model.
| # | Source | Declare with | What you get |
|---|---|---|---|
| 1 | MCP servers | mcp.json | Claude-desktop superset (mcpServers/servers/mcp); local stdio + remote streamable-HTTP/SSE; ${ENV} header auth; one bad server is isolated, never fatal. |
| 2 | Agent skills | skills/**/SKILL.md | One skill tool loads each on demand (progressive disclosure) + a system-prompt catalog. Same format as Claude/opencode. |
| 3 | Native functions | defineTool | A plain function → a tool; schema inferred from type hints / struct tags. |
| 4 | HTTP / REST | httpTool | Declare an endpoint; {ph} URL substitution, ${ENV} header expansion; OpenAPI import (best-effort). |
| 5 | Built-in tools | on by default | 10 opencode shell/file tools so an agent can act with zero wiring (see below). |
| 6 | Sub-agents | agent() + team | An in-process agent (prompt × scoped toolkit × loop) delegated to via one task tool — isolated context, tokens rolled up, hierarchical budgets, durable suspension (SPEC §7D, docs). |
(A 7th, remote A2A agents, joins the same registry over HTTP — outbound as tools, or serve your toolkit as an agent; see below.)
Registering your own native + HTTP tools is one call:
const tk = await createToolkit({ mcpConfig: "./mcp.json", skillsDir: "./skills" })
tk.register(
defineTool({
name: "add",
description: "Add two numbers",
inputSchema: {
type: "object",
properties: { a: { type: "number" }, b: { type: "number" } },
required: ["a", "b"],
},
run: ({ a, b }) => `${a + b}`,
}),
httpTool({
name: "get_post",
description: "Fetch a post",
method: "GET",
url: "https://jsonplaceholder.typicode.com/posts/{id}",
inputSchema: {
type: "object",
properties: { id: { type: "number" } },
required: ["id"],
},
}),
)
À la carte. Each source is usable on its own. Want only an MCP host — parse mcp.json,
connect, get the tools, like the MCP-only libraries do? Use just loadMcp / load_mcp /
LoadMcp (no skills, no loop). Everything else is opt-in on top.
toolnexus ships opencode's default toolset — 10 built-in tools (bash, read, write,
edit, grep, glob, webfetch, question, apply_patch, todowrite, with names + input
schemas matching opencode) so an agent can act with zero wiring. They surface in the tool schema
(toOpenAI/toAnthropic/toGemini) like MCP tools — not injected into the system prompt.
The source is on by default with two levels of control:
createToolkit({ builtins: false }) / create_toolkit(builtins=False) /
Options{ Builtins: false } / .builtins(false) turns the whole source off.builtins: { tools: { bash: false } } drops individual tools on the all-on
baseline (other tools stay on, unknown names ignored; a whole-source-off still wins).Because bash/write/edit/apply_patch run commands and mutate the filesystem, these switches
are the off-switch for locked-down hosts.
Beyond the five local sources: agent-to-agent. Point the toolkit at a remote A2A agent and
each of its skills becomes a tool (named <agent>_<skill>, source "a2a") — an agent is just
another tool source. The same toolkit can serve itself as an A2A agent, so other agents
(toolnexus or not) can call it. It's a genuine, minimal subset of real A2A (verified against
a2a-python): JSON-RPC 2.0, the Agent Card at /.well-known/agent-card.json, SendMessage → poll
GetTask. No streaming / push / auth in v1.
// outbound: a remote agent's skills become tools
const tk = await createToolkit({
agents: [agent({ card: "https://peer.example.com/.well-known/agent-card.json" })],
})
await tk.addAgent("https://other.example.com/.well-known/agent-card.json") // or at runtime
// inbound: serve this toolkit as an agent — the card is built from your SKILL.md skills, not raw tools
const llm = createClient({
baseUrl: "https://openrouter.ai/api/v1",
style: "openai",
model: "openai/gpt-4o-mini",
})
const handle = await tk.serve("127.0.0.1:0", {
client: llm,
a2a: { name: "my-agent", store: "memory" },
})
Both directions exist in all six ports (agent(...) / Agent{...}, an agents config block, and
serve / ServeAsync). Served tasks persist through a pluggable TaskStore (in-memory default,
"file:<dir>", or your own). See each port's README for the full option set.
The same axiom, locally: an Agent is a Tool. Define agents declaratively — a routing
description, a scoped toolkit view, an identity file, a team — and one agent delegates to
another through a built-in task tool: the child runs on a fresh transcript with only its own
tools, the parent gets back exactly one result, and the child's tokens roll up into the parent's
usage. Parallel task calls in one turn run concurrently. (SPEC.md §7D.)
import { agents } from "toolnexus" // its own namespace — distinct from the A2A agent()
const explore = agents.agent("explore", {
does: "read-only research",
uses: { tools: [lookup] }, // least privilege: this child sees ONLY these tools
})
const coder = agents.agent("coder", {
does: "implements changes",
soulFile: "./AGENTS.md", // identity → system prompt
team: [explore], // listing agents here IS the wiring; no team ⇒ no task tool
budget: { maxTokens: 10_000 }, // hierarchical, live-enforced across the whole tree
})
const r = await coder.run("fix the failing test", {
llm: { baseUrl: "https://openrouter.ai/api/v1", style: "openai", model: "openai/gpt-4o-mini" },
})
r.status // "done" — a budget stop is a LOUD "incomplete", never silent
coder.asTool() // …or drop any agent into the classic API's extraTools
A suspending child escalates like a suspending tool (the §10 human-in-the-loop layer, verbatim):
no interpreter up the chain ⇒ the run returns status: "pending" with the tree parked at zero
token burn, and runtime.resume(answer) continues from the checkpoint — re-run parents reattach
to their existing children instead of spawning duplicates. Available in all six ports (an
agents namespace per language). See the sub-agents docs.
Sub-agents are workers (spawn, delegate, done). A persona is the other archetype — a
long-lived assistant whose identity lives in files, whose memory it can edit, and which
acts on a heartbeat without being prompted. All three ride the same runtime: fromDir(dir)
composes bootstrap files (SOUL/USER/HEARTBEAT/MEMORY.md) into a frozen soul and wires a
file-backed memory tool; startAgent gives it a clock. (SPEC.md §7E.)
import { agents } from "toolnexus"
const ava = agents.fromDir("./personas/ava") // the directory IS the agent
const r = await ava.run("What's on my plate today?", { llm })
// memory writes land on disk and load at the START of the next session — a frozen snapshot,
// so the prompt cache stays warm (the write does NOT mutate the live prompt).
const started = agents.startAgent(ava, { llm },
{ everyMs: 60_000, onBeat: (text) => notifyMe(text) }) // HEARTBEAT_OK beats stay silent
Runnable JS/Python/Go entrypoints (examples/persona.*) drive a real "Ava" against OpenRouter.
See the persona-agents docs — including
a "when to use which surface" guide (agent() vs fromDir vs the raw verbs) and the
dream/consolidation + channel-assistant recipes.
The other inbound edge: expose your whole toolkit as an MCP server, so any MCP client (Claude
Desktop, an IDE, another agent) can call its tools. Aggregate N MCP servers + skills + your own
functions behind one toolkit, then re-expose the union as one MCP server — a universal MCP
gateway. Unlike A2A (which advertises skills and runs the client loop), the MCP client is the LLM
host, so each tools/call dispatches straight to Tool.execute — no client, no tasks, no store.
// streamable-HTTP — an embeddable MCP server at POST /mcp, beside any A2A routes:
const srv = await tk.serve("127.0.0.1:0", { mcp: { name: "my-gateway" } }) // connect at srv.url + "/mcp"
All six ports ship the streamable-HTTP MCP server (the /mcp endpoint on serve), built on each
port's existing MCP SDK in server mode. tools/list advertises every tool (name verbatim,
inputSchema = the tool's parameters). A stdio transport (for local clients like Claude Desktop) is a
planned follow-up. See SPEC.md §7C.
The host loop remembers a thread for you. ask(prompt, { toolkit, id }) loads that id's transcript
from a ConversationStore, runs the loop with it as history, and saves the updated transcript back
— so the next ask with the same id continues the conversation. No id ⇒ a stateless one-shot
(identical to run).
const agent = createClient({ baseUrl, style: "openai", model }) // in-memory store by default
await agent.ask("Book me a flight to Berlin.", { toolkit: tk, id: "user-42" })
await agent.ask("Actually, make it Munich.", { toolkit: tk, id: "user-42" }) // same thread — remembered
await agent.ask("What is 21 + 21?", { toolkit: tk }) // no id → one-shot
get(id) → messages and save(id, messages). The default is
in-memory (per-client, process lifetime); pass createClient({ ..., store }) with your own
file / db / redis implementation to persist across processes.serve fulfils each SendMessage via
ask(text, { id: contextId }), so a peer's turns are remembered by A2A contextId through the
same store; a message with no contextId is a one-shot.run(prompt, { toolkit, history }) primitive and a stateful
client.conversation({ toolkit }) wrapper are still there when you'd rather own the transcript.id works on the streaming paths. Pass on_text to ask
to stream assistant text deltas while ask still returns the final result, or use
stream(prompt, { toolkit, id }) to iterate events (text / tool_call / tool_result /
usage / done); with an id the thread is loaded before and saved on the done event.Available in all six ports (a ConversationStore interface + in-memory default + ask).
Zero-dependency, two outputs from one internal instrumentation — both opt-in, no cost when unused.
on_metric event feed — createClient({ ..., on_metric }) (idiomatic name per port) receives a
readable semantic record at each significant point: one { event: "llm" } per model call, one
{ event: "tool" } per tool call, one terminal { event: "run" } per run/ask (with tokens,
turns, timings, error). Forward it to statsd, logs, or OpenTelemetry — the library holds no opinion.client.metrics() → Prometheus text — the same events feed a tiny in-memory registry that
renders the Prometheus text exposition format (no third-party dep). Mount it at GET /metrics:
toolnexus_llm_requests_total, toolnexus_llm_tokens_total, toolnexus_tool_calls_total, plus
the toolnexus_llm_request_duration_seconds / toolnexus_tool_duration_seconds histograms. The
rendered text is byte-identical across all six ports; OTLP push is a planned future companion.cd golang && go build -o toolnexus ./cmd/toolnexus
./toolnexus run --config ../examples/mcp.json --skills ../examples/skills \
--base-url https://openrouter.ai/api/v1 --style openai --model openai/gpt-4o-mini
# > you: ... (continuous REPL agent loop)
./toolnexus tools --config ../examples/mcp.json --skills ../examples/skills # list resolved tools
Full docs site (all seven languages): https://muthuishere.github.io/toolnexus/.
Per port: js/ · python/ · golang/ · java/ · csharp/ · elixir/ · clojure/ — quickstarts and API.
Embedding in a Go app? See golang/GUIDE.md.
examples/ holds the shared mcp.json + sample skill used by every
implementation's examples and tests. The cross-language contract lives in SPEC.md.
skill tool)serve your toolkit as an agent); all six ports/mcp on serve); all six portsask + pluggable ConversationStore; A2A serve remembers by contextId)stream/ask take an id; ask gains an on_text delta callback)on_metric event feed + zero-dep client.metrics() Prometheus textheaders for now)fs-based skills, HTTP serve); a from-scratch,
browser-native tool-calling library is a separate project, not a mode of toolnexus.Each port has a hermetic suite (no network, no LLM — local HTTP servers for the HTTP
tool, the shared examples/ fixtures for skills) covering config parsing, ${ENV}
header expansion, the byte-exact skill block, native + HTTP + built-in tools, A2A, the
provider adapters, and toolkit routing.
cd js && npm test # node:test
cd python && uv run pytest -q # pytest
cd golang && go test ./... # go test
cd java && ./gradlew test # JUnit 5
cd csharp && dotnet test # xUnit
cd elixir && mix test # ExUnit
The end-to-end agent loop (MCP + skills + native + HTTP through the host loop) is
additionally verified live against OpenRouter per language via the examples/agent.*
runners (need OPENROUTER_API_KEY).
Questions, ideas, or built something with this? Join AgentNexus — a Discord for people building with AI agents and open tools. This project lives in #toolnexus.
MIT
Can you improve this documentation?Edit on GitHub
cljdoc builds & hosts documentation for Clojure/Script libraries
| Ctrl+k | Jump to recent docs |
| ← | Move to previous article |
| → | Move to next article |
| Ctrl+/ | Jump to the search field |