This is the full developer documentation for toolnexus
# toolnexus
> Building any agentic framework? Build it on toolnexus. Fast tool calling, any number of MCP servers, any agent skill and media generation, for any LLM, in eight languages.
[](/toolnexus/toolnexus-explainer.mp4)
## Point it at your tools. Get a working agent.
[Section titled “Point it at your tools. Get a working agent.”](#point-it-at-your-tools-get-a-working-agent)
Point toolnexus at an `mcp.json` and a `skills/` folder and you get the tool-calling loop, skills injection, six unified tool sources, and conversation memory — all included. The same three lines, in every language:
* JavaScript
```ts
import { createToolkit, createClient } from "toolnexus"
const tk = await createToolkit({
mcpConfig: "./mcp.json", // every MCP server's tools
skillsDir: "./skills", // every SKILL.md, loaded on demand
})
const agent = createClient({
baseUrl: "https://openrouter.ai/api/v1", // any OpenAI- or Anthropic-style endpoint
style: "openai",
model: "openai/gpt-4o-mini",
})
const { text } = await agent.run("Use my tools to answer this.", { toolkit: tk })
console.log(text)
```
* Python
```python
import asyncio
from toolnexus import create_toolkit, create_client
async def main():
tk = await create_toolkit(
mcp_config="./mcp.json", # every MCP server's tools
skills_dir="./skills", # every SKILL.md, loaded on demand
)
agent = create_client(
base_url="https://openrouter.ai/api/v1", style="openai",
model="deepseek/deepseek-chat",
)
res = await agent.run("Use my tools to answer this.", tk)
print(res.text)
await tk.close()
asyncio.run(main())
```
* Go
```go
package main
import (
"context"
"fmt"
"github.com/muthuishere/toolnexus/golang"
)
func main() {
ctx := context.Background()
tk, _ := toolnexus.CreateToolkit(ctx, toolnexus.Options{
MCPConfig: "./mcp.json", // every MCP server's tools
SkillsDir: "./skills", // every SKILL.md, loaded on demand
})
defer tk.Close()
agent := toolnexus.CreateClient(toolnexus.ClientOptions{
BaseURL: "https://openrouter.ai/api/v1",
Style: "openai",
Model: "openai/gpt-4o-mini",
})
res, _ := agent.Run(ctx, "Use my tools to answer this.", tk)
fmt.Println(res.Text)
}
```
* Java
```java
Toolkit tk = Toolkit.create(new Toolkit.Options()
.mcpConfig("./mcp.json") // every MCP server's tools
.skillsDir("./skills")); // every SKILL.md, loaded on demand
LlmClient agent = LlmClient.create(new LlmClient.Options()
.baseUrl("https://openrouter.ai/api/v1")
.style("openai")
.model("openai/gpt-4o-mini"));
System.out.println(agent.run("Use my tools to answer this.", tk).text);
```
* C#
```csharp
using Toolnexus;
await using var tk = await Toolkit.CreateAsync(new Toolkit.Options
{
McpConfig = "./mcp.json", // every MCP server's tools
SkillsDir = "./skills", // every SKILL.md, loaded on demand
});
var agent = LlmClient.Create(new LlmClient.Options
{
BaseUrl = "https://openrouter.ai/api/v1",
Style = "openai",
Model = "anthropic/claude-3.5-sonnet",
ApiKey = Environment.GetEnvironmentVariable("OPENROUTER_API_KEY"),
});
var res = await agent.RunAsync("Use my tools to answer this.", tk);
Console.WriteLine(res.Text);
```
* Elixir
```elixir
{:ok, tk} =
Toolnexus.create_toolkit(
mcp_config: "./mcp.json", # every MCP server's tools
skills_dir: "./skills" # every SKILL.md, loaded on demand
)
agent =
Toolnexus.Client.create(
base_url: "https://openrouter.ai/api/v1", # any OpenAI- or Anthropic-style endpoint
style: "openai",
model: "openai/gpt-4o-mini"
)
res = Toolnexus.Client.run(agent, "Use my tools to answer this.", tk)
IO.puts(res.text)
```
* Clojure
```clojure
(require '[toolnexus.core :as tn] '[toolnexus.client :as client])
(def tk (tn/build {:mcp "./mcp.json" ; every MCP server's tools
:skills "./skills"})) ; every SKILL.md, loaded on demand
(def agent
(client/create-client
{:base-url "https://openrouter.ai/api/v1" ; any OpenAI- or Anthropic-style endpoint
:style "openai"
:model "openai/gpt-4o-mini"}))
(println (:text (client/run agent "Use my tools to answer this." {:toolkit tk})))
```
* Rust
```rust
use toolnexus::client::{ClientOptions, Style, create_client};
use toolnexus::{McpConfigInput, ToolkitOptions, create_toolkit};
#[tokio::main]
async fn main() -> Result<(), Box> {
let tk = create_toolkit(ToolkitOptions {
mcp_config: Some(McpConfigInput::Path("./mcp.json".into())), // every MCP server's tools
skills_dir: vec!["./skills".into()], // every SKILL.md, loaded on demand
..Default::default()
})
.await?;
let agent = create_client(ClientOptions {
base_url: "https://openrouter.ai/api/v1".into(),
style: Style::OpenAI,
model: "openai/gpt-4o-mini".into(),
..Default::default()
});
let res = agent.run("Use my tools to answer this.", &tk).await?;
println!("{}", res.text);
tk.close().await;
Ok(())
}
```
## One interface for every tool
[Section titled “One interface for every tool”](#one-interface-for-every-tool)
MCP servers
Read an `mcp.json`, connect to every server (local stdio + remote streamable-HTTP), expose each server tool as a uniform `Tool`.
Agent skills
Glob a `skills/` folder of `SKILL.md` files; one `skill` tool loads instructions and resources on demand — progressive disclosure.
Your own functions
Register a plain function as a tool. Native, HTTP endpoints, and the built-in shell/file tools all share the same shape.
Remote A2A agents
Another agent, called like a function. `toolkit.serve(...)` also exposes *your* toolkit to the world as an A2A agent.
The loop, included
System prompt, skills injection, parallel + chained tool calls, hooks, streaming, retries, conversation memory, observability metrics.
Human in the loop
The suspension layer: an agent can pause, ask *you* a question, and resume — plus an MCP elicitation bridge. Nobody else ships this across eight ports.
Typed decisions
`createClassifier` and the judge adapters: a yes/no, a pick-one or a score with confidence, from any System One backend. [Judge & classifier →](/toolnexus/judge/)
Media generation
Image, video, speech, transcription, voice cloning and talking-head video, as typed verbs and agent tools, from a JSON provider style. [Media →](/toolnexus/media/)
## Built on toolnexus
[Section titled “Built on toolnexus”](#built-on-toolnexus)
wfnexus
Typed pipelines for coding agents: each step is a whole agent with scoped tools, a budget, guardrails and a JSON-schema output. [muthuishere.github.io/wfnexus](https://muthuishere.github.io/wfnexus/)
Built something on toolnexus? [Open a pull request](https://github.com/muthuishere/toolnexus/edit/main/README.md) adding it to the **Built on toolnexus** list in `README.md` and here.
## Byte-identical across eight languages
[Section titled “Byte-identical across eight languages”](#byte-identical-across-eight-languages)
The whole point is **parity**: the same `examples/` fixtures produce the same behavior in every language — all seven published to their registries. [Clojure](/toolnexus/api/clojure/) is one `.cljc` tree that runs on the JVM and on cljgo, wired into the same conformance checker at the same **full** tier, agent layer included. Install for yours on the [install page](/toolnexus/install/), or start with the [quickstart](/toolnexus/quickstart/).
# A2A — call remote agents, or be one
> Point the toolkit at a remote agent-to-agent (A2A) agent and each of its skills becomes a tool; the same toolkit can serve itself as an A2A agent others call. A minimal, verified subset of A2A — outbound and inbound — in all eight languages.
An agent is just another tool source. Point toolnexus at a remote **A2A agent** and each of its advertised skills becomes a tool (named `_`, `source: "a2a"`). The *same* toolkit can turn around and **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`, and `SendMessage` → poll `GetTask`. No streaming / push / auth in v1.
## Outbound — a remote agent’s skills become tools
[Section titled “Outbound — a remote agent’s skills become tools”](#outbound--a-remote-agents-skills-become-tools)
* JavaScript
```ts
import { createToolkit, agent } from "toolnexus"
const tk = await createToolkit({
agents: [agent({ card: "https://peer.example.com/.well-known/agent-card.json" })],
})
// each advertised skill is now a tool named _, source "a2a"
await tk.addAgent("https://other.example.com/.well-known/agent-card.json") // or add one at runtime
```
* Python
```python
from toolnexus import create_toolkit, agent
tk = await create_toolkit(
agents=[agent("https://peer.example.com/.well-known/agent-card.json")],
)
await tk.add_agent("https://other.example.com/.well-known/agent-card.json") # or at runtime
```
* Go
```go
tk, _ := toolnexus.CreateToolkit(ctx, toolnexus.Options{
Agents: []toolnexus.Agent{{Card: "https://peer.example.com/.well-known/agent-card.json"}},
})
tk.AddAgent(ctx, "https://other.example.com/.well-known/agent-card.json", nil) // or at runtime
```
* Java
```java
Toolkit tk = Toolkit.create(new Toolkit.Options()
.agents(List.of(A2A.agent("https://peer.example.com/.well-known/agent-card.json", null, null, null))));
tk.addAgent(A2A.agent("https://other.example.com/.well-known/agent-card.json", null, null, null)); // runtime
```
* C#
```csharp
await using var tk = await Toolkit.CreateAsync(new Toolkit.Options
{
Agents = new List { new() { Card = "https://peer.example.com/.well-known/agent-card.json" } },
});
await tk.AddAgentAsync("https://other.example.com/.well-known/agent-card.json"); // or at runtime
```
* Elixir
```elixir
{:ok, tk} = Toolnexus.create_toolkit(
agents: [Toolnexus.A2A.agent(card: "https://peer.example.com/.well-known/agent-card.json")])
# each advertised skill is now a tool named _, source "a2a"
Toolnexus.Toolkit.add_agent(tk, "https://other.example.com/.well-known/agent-card.json") # runtime
```
* Clojure
```clojure
(require '[toolnexus.a2a :as a2a] '[toolnexus.core :as tn] '[toolnexus.tool :as tool])
(def tk (tn/build {:agents [{:card "https://peer.example.com/.well-known/agent-card.json"}]}))
;; each advertised skill is now a tool named _, source "a2a"
;; a toolkit is a plain value here, so "add one at runtime" is adding its tools to it
(def tk2
(tool/add-tools tk (a2a/agent-tools
{:card "https://other.example.com/.well-known/agent-card.json"})))
```
* Rust
```rust
use toolnexus::a2a::Agent;
let tk = create_toolkit(ToolkitOptions {
agents: vec![Agent::new("https://peer.example.com/.well-known/agent-card.json")],
..Default::default()
})
.await?;
tk.add_agent(Agent::new("https://other.example.com/.well-known/agent-card.json")).await?; // or at runtime
```
The `agents` block mirrors `mcpServers` — a config list you can also load from a top-level `agents` key. Each descriptor takes optional `headers` (`${ENV}` expanded at call time, never logged), a poll `timeout`, and `pollEvery` interval.
### Timeouts and cancellation
[Section titled “Timeouts and cancellation”](#timeouts-and-cancellation)
A remote agent is a network call inside a poll loop, so both ways it can end early are part of the contract (`SPEC.md` §7A) and both are reported as a **tool result**, not as an exception — the model reads the outcome and can decide what to do next.
| what happened | `output` | `metadata.state` |
| ------------------------------------------ | ------------------------------------------------------ | ---------------- |
| the task finished in a non-completed state | `A2A task [: ]` | that state |
| the poll `timeout` elapsed | `A2A task timed out after ms (state=)` | last known |
| the `ctx` signal was aborted | `A2A task canceled` | `canceled` |
**A cancellation counts wherever it is observed** — between polls, or while a `SendMessage`/`GetTask` request is still in flight. Every error and exit path re-checks the abort signal *before* reporting a transport error, so an abort never reaches you wearing the transport’s clothes.
This was wrong until 0.17.0
Before 0.17.0 the guarantee only held *between* polls. An abort that landed mid-request surfaced whatever the transport happened to raise: Java returned the literal string `"java.lang.InterruptedException"`, Go returned `context canceled` with the state still `submitted`, and Elixir had no abort semantics at all — `Context.signal` existed in the struct and nothing in the port read it. If you wrote code that pattern-matched on those strings, match on `metadata.state == "canceled"` instead.
## Inbound — serve your toolkit as an agent
[Section titled “Inbound — serve your toolkit as an agent”](#inbound--serve-your-toolkit-as-an-agent)
The other direction: expose *your* toolkit as an A2A agent. The Agent Card is built from your `SKILL.md` skills (never raw tools), and each inbound `SendMessage` is fulfilled by a client running the tool-calling loop.
* JavaScript
```ts
const llm = createClient({ baseUrl, style: "openai", model })
const handle = await tk.serve("127.0.0.1:0", {
client: llm,
a2a: { name: "my-agent", store: "memory" }, // store: "memory" | "file:" | your own TaskStore
})
// handle.url now speaks A2A; stop with handle.stop()
```
* Python
```python
from toolnexus import A2AConfig
llm = create_client(base_url=base, style="openai", model=model)
handle = await tk.serve(
"127.0.0.1:0",
client=llm,
a2a=A2AConfig(name="my-agent", store="memory"),
)
```
* Go
```go
llm := toolnexus.CreateClient(toolnexus.ClientOptions{
BaseURL: baseURL,
Style: toolnexus.StyleOpenAI,
Model: model,
})
handle, _ := tk.Serve("127.0.0.1:0", toolnexus.ServeOptions{
Client: llm,
A2A: &toolnexus.A2AConfig{Name: "my-agent", Store: "memory"},
})
```
* Java
```java
LlmClient llm = LlmClient.create(new LlmClient.Options()
.baseUrl(base)
.style("openai")
.model(model));
A2AServer.ServeHandle handle = tk.serve("127.0.0.1:0",
new Toolkit.ServeOptions()
.client(llm)
.a2a(new A2AServer.A2AConfig().name("my-agent")));
```
* C#
```csharp
var handle = await tk.ServeAsync("127.0.0.1:0", new Toolkit.ServeOptions
{
Client = ClientFor(llm),
A2A = new A2AConfig { Name = "my-agent" },
});
```
* Elixir
```elixir
llm = Toolnexus.Client.create(base_url: base, style: "openai", model: model)
handle = Toolnexus.Toolkit.serve(tk, "127.0.0.1:0",
client: llm,
a2a: %{name: "my-agent", store: "memory"})
# handle.url now speaks A2A; Toolnexus.Serve.stop(handle)
```
* Clojure
```clojure
(require '[toolnexus.client :as client] '[toolnexus.serve :as serve])
(def llm (client/create-client {:base-url base-url :style "openai" :model model}))
(def handle
(serve/serve tk {:port 0 ; 0 ⇒ an OS-assigned port
:host "127.0.0.1"
:client llm
:a2a {:name "my-agent"}
:skills (:skills tk) ; what the Agent Card advertises
:store "memory"})) ; "memory" | "file:" | your own TaskStore
;; (:url handle) now speaks A2A; stop with (serve/stop! handle)
```
* Rust
```rust
use toolnexus::serve::{A2aConfig, ServeOptions, TaskStoreSpec};
let llm = create_client(ClientOptions {
base_url: base_url.clone(),
style: Style::OpenAI,
model: model.clone(),
..Default::default()
});
let handle = tk
.serve("127.0.0.1:0", ServeOptions {
client: Some(llm),
a2a: Some(A2aConfig {
name: "my-agent".into(),
store: TaskStoreSpec::Selector("memory".into()),
..Default::default()
}),
..Default::default()
})
.await?;
```
## Tasks persist through a pluggable store
[Section titled “Tasks persist through a pluggable store”](#tasks-persist-through-a-pluggable-store)
A served agent fulfils each request as a **Task**. The `store` selects where tasks live: `"memory"` (default), `"file:"`, or your own **TaskStore** (`get(id)` / `save(task)`) — so a served agent can survive restarts and be scaled across processes.
Suspension crosses A2A
If a served run [suspends for a human](/toolnexus/suspension/) with no `waitFor`, the A2A layer maps it to the A2A `input-required` state (the question in the task’s status message) — never a `completed` task. So a served toolnexus agent can ask *its caller* a question, and the caller resumes it by answering. The human-in-the-loop primitive reaches across the agent boundary.
Both directions exist in all eight ports. The same `serve(...)` can co-mount an [MCP server](/toolnexus/mcp/) endpoint, so what you assemble is consumable as an agent *and* as an MCP server at once.
[See an A2A agent in the five-source demo](/toolnexus/quickstart/)
# API Reference
> The public surface of every port — toolkit options, client options, skill loading and inventory, MCP load, and adapters — with the exact symbol in all eight languages side by side.
The same API, eight languages. Every entry below shows its **exact per-language equivalent** in synced tabs — pick your language once and it sticks across the page. Each port’s full reference has its own dedicated nav; open one in a new tab: [JavaScript](/toolnexus/api/javascript/) · [Python](/toolnexus/api/python/) · [Go](/toolnexus/api/go/) · [Java](/toolnexus/api/java/) · [C#](/toolnexus/api/csharp/) · [Elixir](/toolnexus/api/elixir/) · [Clojure](/toolnexus/api/clojure/) · [Rust](/toolnexus/api/rust/).
Tip
These are the public entry points. Behavior is identical across ports by contract — see [SPEC.md](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md).
## Build a toolkit
[Section titled “Build a toolkit”](#build-a-toolkit)
The one aggregator: MCP tools + skills + built-ins + A2A agents + your own tools behind one `Tool` interface.
* JavaScript
```ts
import { createToolkit } from "toolnexus"
const tk = await createToolkit({
mcpConfig: "./mcp.json", // path | object
skillsDir: "./skills", // string | string[]
skills: [/* SkillDef[] */], // data skills
skillProvider: async () => [/* SkillDef[] */], // lazy, resolved once
skillsFilter: { deploy: true }, // per-agent allowlist
skillSampleLimit: 0, // 0=10, n=cap, -1=off
builtins: true,
agents: [/* Agent[] */],
extraTools: [/* Tool[] */],
})
```
* Python
```python
from toolnexus import create_toolkit
tk = await create_toolkit(
mcp_config="./mcp.json",
skills_dir="./skills",
skills=[...], # SkillDef list
skill_provider=lambda: [...], # sync or async
skills_filter={"deploy": True},
skill_sample_limit=0,
builtins=True,
agents=[...],
extra_tools=[...],
)
```
* Go
```go
tk, _ := toolnexus.CreateToolkit(ctx, toolnexus.Options{
McpConfig: "./mcp.json",
SkillsDir: []string{"./skills"},
Skills: []toolnexus.SkillDef{ /* … */ },
SkillProvider: func(ctx context.Context) ([]toolnexus.SkillDef, error) {
return nil, nil
},
SkillsFilter: map[string]bool{"deploy": true},
SkillSampleLimit: 0,
Builtins: true,
})
```
* Java
```java
Toolkit tk = Toolkit.create(new Toolkit.Options()
.mcpConfig("./mcp.json")
.skillsDir("./skills")
.skills(List.of(/* SkillDef */))
.skillProvider(() -> List.of(/* SkillDef */))
.skillsFilter(Map.of("deploy", true))
.skillSampleLimit(0)
.builtins(true));
```
* C#
```csharp
await using var tk = await Toolkit.CreateAsync(new Toolkit.Options
{
McpConfig = "./mcp.json",
SkillsDir = new List { "./skills" },
Skills = new List { /* … */ },
SkillProvider = () => new List(),
SkillsFilter = new Dictionary { ["deploy"] = true },
SkillSampleLimit = 0,
Builtins = true,
});
```
* Elixir
```elixir
{:ok, tk} =
Toolnexus.create_toolkit(
mcp_config: "./mcp.json",
skills_dir: ["./skills"],
skills: [%{name: "…", content: "…"}], # data skills
skill_provider: fn -> [] end, # lazy, resolved once
skills_filter: %{"deploy" => true}, # per-agent allowlist
skill_sample_limit: 0, # 0=10, n=cap, -1=off
builtins: true,
agents: [],
extra_tools: []
)
```
* Clojure
```clojure
(require '[toolnexus.core :as tn])
(def tk
(tn/build {:mcp "./mcp.json" ; path | map
:skills ["./skills"] ; a root, or a seq of roots
:builtins true ; §0.11 toggle
:tools []})) ; extra Tools of your own
;; => {:tools .. :skills .. :statuses .. :errors .. :connections ..}
;; Not in this port: :skill-provider, :skills-filter, :agents (parity debt).
```
* Rust
```rust
use toolnexus::skills::SkillDef;
let tk = create_toolkit(ToolkitOptions {
mcp_config: Some(McpConfigInput::Path("./mcp.json".into())),
skills_dir: vec!["./skills".into()],
skills: vec![SkillDef { /* … */ ..Default::default() }],
skill_provider: Some(Arc::new(|| Box::pin(async { Ok(Vec::::new()) }))),
skills_filter: [("deploy".to_string(), true)].into(),
skill_sample_limit: 0,
builtins: None, // None ⇒ on; Some(BuiltinsConfig::off()) ⇒ off
..Default::default()
})
.await?;
```
Two more options prune the assembled toolkit after the sources above have run: `disableTools` drops tools by their final exposed name, `disableSkills` drops skills by name from the `skill` catalog — both cheaper than filtering at the source. See [`toolkit/create`](/api/javascript/toolkit/create/) for the full field list per language.
## Skills — load, supply as data, filter, inventory
[Section titled “Skills — load, supply as data, filter, inventory”](#skills--load-supply-as-data-filter-inventory)
Directories, in-memory `SkillDef`s, and a lazy provider all compose (first-wins dedupe). See the [Agent skills](/skills/) guide for the concepts; this is the exact surface.
### `SkillDef` — a skill as data
[Section titled “SkillDef — a skill as data”](#skilldef--a-skill-as-data)
* JavaScript
```ts
interface SkillDef {
name: string
description?: string
content: string
resources?: string[] // logical names; nil ⇒ instruction-only
base?: string // logical base; default "skill:///"
}
```
* Python
```python
@dataclass
class SkillDef:
name: str
content: str
description: str | None = None
resources: list[str] | None = None
base: str | None = None
```
* Go
```go
type SkillDef struct {
Name string
Description string
Content string
Resources []string
Base string
}
```
* Java
```java
new SkillSource.SkillDef(name, description, content); // instruction-only
new SkillSource.SkillDef(name, description, content, resources, base);
```
* C#
```csharp
new SkillSource.SkillDef(Name, Description, Content,
Resources: null, Base: null); // record
```
* Elixir
```elixir
# a data skill is a plain map; keys mirror the struct
%{
name: "…",
description: "…",
content: "…",
resources: nil, # logical names; nil ⇒ instruction-only
base: nil # logical base; default "skill:///"
}
```
* Clojure
```clojure
;; a data skill is a plain map; keys mirror the other ports
{:name "…"
:description "…"
:body "…" ; the markdown body (post-frontmatter)
:dir "…" ; base directory for the skill's files
:files []} ; sibling files sampled for the skill
```
* Rust
```rust
pub struct SkillDef {
pub name: String,
pub description: String,
pub content: String,
pub resources: Vec,
pub base: String,
}
```
### List & validate — `ListSkills`
[Section titled “List & validate — ListSkills”](#list--validate--listskills)
Discover + validate without building a toolkit. Returns parsed skills plus **typed skip reasons**: `missing-name` · `malformed-frontmatter` · `duplicate-name` · `unreadable`.
* JavaScript
```ts
import { listSkills } from "toolnexus"
const inv = listSkills({ dirs: "./skills" })
// inv.skills: SkillInfo[] inv.skipped: { location, reason }[]
```
* Python
```python
from toolnexus import list_skills
inv = list_skills("./skills")
# inv.skills: list[SkillInfo] inv.skipped: list[SkillSkip]
```
* Go
```go
inv := toolnexus.ListSkills(toolnexus.LoadSkillsOptions{Dirs: []string{"./skills"}})
// inv.Skills []SkillInfo inv.Skipped []SkillSkip
```
* Java
```java
var inv = SkillSource.listSkills(new SkillSource.LoadOptions().dirs("./skills"));
// inv.skills inv.skipped (SkillSkip.reason)
```
* C#
```csharp
var inv = SkillSource.ListSkills(new SkillSource.LoadOptions { Dirs = new[] { "./skills" } });
// inv.Skills inv.Skipped (SkillSkip.Reason)
```
* Elixir
```elixir
inv = Toolnexus.Skill.list("./skills")
# inv.skills inv.skipped (%{location, reason})
```
* Clojure
```clojure
(require '[toolnexus.skill :as skill])
(def inv (skill/list-skills "./skills"))
;; {:skills [..] :skipped [..]} — skip reasons: missing-name ·
;; malformed-frontmatter · duplicate-name · unreadable
```
* Rust
```rust
use toolnexus::skills::{LoadSkillsOptions, list_skills};
let inv = list_skills(&LoadSkillsOptions { dirs: vec!["./skills".into()], ..Default::default() });
// inv.skills: Vec inv.skipped: Vec
```
## Load MCP servers
[Section titled “Load MCP servers”](#load-mcp-servers)
Read an `mcp.json`, connect every server (stdio + streamable-HTTP), expose each tool as a `Tool`.
* JavaScript
```ts
import { loadMcp } from "toolnexus"
const mcp = await loadMcp("./mcp.json") // mcp.tools: Tool[]
```
* Python
```python
from toolnexus import load_mcp
mcp = await load_mcp("./mcp.json")
```
* Go
```go
mcp, _ := toolnexus.LoadMcp("./mcp.json")
```
* Java
```java
McpSource mcp = McpSource.load("./mcp.json");
```
* C#
```csharp
var mcp = await McpSource.LoadAsync("./mcp.json");
```
* Elixir
```elixir
{:ok, mcp} = Toolnexus.Mcp.load("./mcp.json") # mcp.tools
```
* Clojure
```clojure
(require '[toolnexus.mcp :as mcp])
(def m (mcp/from-config "./mcp.json")) ; {:tools .. :statuses .. :connections ..}
(mcp/tools m)
```
* Rust
```rust
use toolnexus::mcp::{load_mcp, parse_mcp_config_file};
let mcp = load_mcp(&parse_mcp_config_file("./mcp.json")?).await;
```
## Adapters — emit provider schema
[Section titled “Adapters — emit provider schema”](#adapters--emit-provider-schema)
Turn a toolkit into the tool-schema shape each provider expects.
* JavaScript
```ts
import { toOpenAI, toAnthropic, toGemini } from "toolnexus"
const tools = toOpenAI(tk) // toAnthropic(tk) · toGemini(tk)
```
* Python
```python
from toolnexus import to_openai, to_anthropic, to_gemini
tools = to_openai(tk)
```
* Go
```go
tools := tk.ToOpenAI() // tk.ToAnthropic() · tk.ToGemini()
```
* Java
```java
var tools = Adapters.toOpenAI(tk); // toAnthropic · toGemini
```
* C#
```csharp
var tools = Adapters.ToOpenAI(tk); // ToAnthropic · ToGemini
```
* Elixir
```elixir
tools = Toolnexus.Adapters.to_openai(tk.tools) # to_anthropic · to_gemini
```
* Clojure
```clojure
(tn/to-openai tk) ; tn/to-anthropic · tn/to-gemini
```
* Rust
```rust
let tools = tk.to_openai(); // tk.to_anthropic() · tk.to_gemini()
```
## Build a client (the tool-calling loop)
[Section titled “Build a client (the tool-calling loop)”](#build-a-client-the-tool-calling-loop)
* JavaScript
```ts
import { createClient } from "toolnexus"
const client = createClient({ style: "openai", model: "gpt-5", baseUrl: "…", apiKey: "…" })
const res = await client.ask("…", { toolkit: tk })
```
* Python
```python
from toolnexus import create_client
client = create_client(style="openai", model="gpt-5", base_url="…", api_key="…")
res = await client.ask("…", tk)
```
* Go
```go
client := toolnexus.CreateClient(toolnexus.ClientOptions{
Style: "openai",
Model: "gpt-5",
BaseURL: "…",
APIKey: "…",
})
res, _ := client.Ask(ctx, "…", tk, "")
```
* Java
```java
LlmClient client = LlmClient.create(new LlmClient.Options()
.style("openai")
.model("gpt-5")
.baseUrl("…")
.apiKey("…"));
var res = client.ask("…", tk);
```
* C#
```csharp
var client = LlmClient.Create(new LlmClient.Options {
Style = "openai",
Model = "gpt-5",
BaseUrl = "…",
ApiKey = "…",
});
var res = await client.AskAsync("…", tk);
```
* Elixir
```elixir
client = Toolnexus.Client.create(style: "openai", model: "gpt-5",
base_url: "…", api_key: "…")
res = Toolnexus.Client.run(client, "…", tk)
```
* Clojure
```clojure
(require '[toolnexus.client :as client])
(def c (client/create-client {:style "openai" :model "gpt-5"
:base-url "…" :api-key "…"}))
(def res (client/run c "…" {:toolkit tk}))
;; {:text :messages :tool-calls :turns :usage :status}
```
* Rust
```rust
let client = create_client(ClientOptions {
style: Style::OpenAI,
model: "gpt-5".into(),
base_url: "…".into(),
api_key: "…".into(),
..Default::default()
});
let res = client.ask("…", &tk, "").await?;
```
`ClientOptions` carries more than `style`/`model`/`baseUrl`/`apiKey` — the rest of the surface, by area:
* **Resilience** — `retries`, `retryBaseMs`, `retryableStatuses`, `onError`, `timeoutMs`: the retry/backoff/classification layer around transient LLM errors. See [`client/resilience`](/api/javascript/client/resilience/).
* **Conversation memory** — a `ConversationStore` (in-memory by default) keyed by `ask`’s `id`, so a second `ask` with the same id continues the transcript. See [`client/store`](/api/javascript/client/store/) and [`client/conversation`](/api/javascript/client/conversation/).
* **Observability** — `onMetric` reports per-call token/cost/latency metrics as the loop runs. See [`client/metrics`](/api/javascript/client/metrics/).
* **Streaming** — `stream()` yields `StreamEvent`s instead of a single `RunResult`. See [`client/stream`](/api/javascript/client/stream/).
* **Hooks** — `beforeLLM`/`beforeTool` run ahead of each request/tool call and can veto or rewrite it. See [`client/hooks`](/api/javascript/client/hooks/).
* **Request shaping** — `requestParams`, `bodyTransform`, and a custom `fetch` reshape or intercept the outbound HTTP call itself (SPEC §8). See [`client/create`](/api/javascript/client/create/).
* **Multimodal content** — `ContentPart` and `attach()` let a prompt carry images/files alongside text. See [`types/content-part`](/api/javascript/types/content-part/) and [`types/attach`](/api/javascript/types/attach/).
* **Typed provider errors** — failed LLM calls raise a typed error with the redacted response body attached, not a bare exception. See [`client/provider-error`](/api/javascript/client/provider-error/).
* **Classifier (§8B)** — a lighter-weight `Classifier` for yes/no and multiple-choice decisions that don’t need the full tool-calling loop. See [`judge/classifier`](/api/javascript/judge/classifier/).
## Your own functions — native tools
[Section titled “Your own functions — native tools”](#your-own-functions--native-tools)
Wrap a function as a `Tool` (`source: "native"`). Pass them via `extraTools` or hand them to an agent’s `uses.tools`. See the [Native tools](/native/) guide.
* JavaScript
```ts
import { defineTool } from "toolnexus"
const add = defineTool({
name: "add", description: "Add two numbers",
run: (a) => String(Number(a.x) + Number(a.y)),
})
// decorator sugar: @tool(description, { name?, inputSchema? }) + collectTools(obj)
```
* Python
```python
from toolnexus import tool # or define_tool(...)
@tool # schema inferred from type hints + docstring
def add(a: int, b: int) -> str:
"""Add two numbers."""
return str(a + b)
```
* Go
```go
add := toolnexus.NativeTool("add", "Add two numbers", nil,
func(ctx context.Context, a map[string]any) (string, error) {
return fmt.Sprint(a["x"], a["y"]), nil
})
// or NativeToolReflect[T] — schema derived from a struct's json tags
```
* Java
```java
Tool add = NativeTool.of("add", "Add two numbers", schema,
args -> String.valueOf((int) args.get("a") + (int) args.get("b")));
// overload with (args, ToolContext) for suspension-aware tools
```
* C#
```csharp
var add = NativeTool.Of("add", "Add two numbers", inputSchema: null,
args => (object?)((int)args["a"]! + (int)args["b"]!));
// also NativeTool.OfAsync(...) and an (args, ToolContext) overload
```
* Elixir
```elixir
add = Toolnexus.Native.define_tool(
name: "add", description: "Add two numbers",
execute: fn args -> to_string(args["a"] + args["b"]) end)
# execute may be fn args -> end or fn args, ctx -> end
```
* Clojure
```clojure
(require '[toolnexus.native :as native])
(def add
(native/native-tool {:name "add" :description "Add two numbers"
:run (fn [args] (str (+ (:a args) (:b args))))}))
;; a ctx-aware tool: {:ctx? true :run (fn [args ctx] …)}
```
* Rust
```rust
let add = define_tool("add", "Add two numbers", None, |a: Value, _ctx| async move {
Ok::<_, String>(format!("{}", a["x"].as_f64().unwrap_or(0.0) + a["y"].as_f64().unwrap_or(0.0)))
});
// or define_typed_tool:: — the args deserialize into your serde struct
```
## HTTP / REST tools
[Section titled “HTTP / REST tools”](#http--rest-tools)
Turn any endpoint into a `Tool` (`source: "http"`). `{placeholder}` path substitution, `${ENV_VAR}` header expansion (never logged), `resultMode` `text` (default) · `json` · `status+text`. See the [HTTP tools](/http/) guide.
* JavaScript
```ts
import { httpTool } from "toolnexus"
const weather = httpTool({
name: "weather", description: "Current weather",
method: "GET", url: "https://api.example.com/weather/{city}",
}) // timeout ms (default 30000)
```
* Python
```python
from toolnexus import http_tool
weather = http_tool(name="weather", description="Current weather",
method="GET", url="https://api.example.com/weather/{city}")
# timeout in SECONDS here
```
* Go
```go
weather := toolnexus.HTTPTool(toolnexus.HTTPToolOptions{
Name: "weather", Description: "Current weather",
Method: "GET", URL: "https://api.example.com/weather/{city}",
}) // Timeout ms (0 ⇒ 30000)
```
* Java
```java
var o = new HttpTool.Options();
o.name = "weather"; o.description = "Current weather";
o.method = "GET"; o.url = "https://api.example.com/weather/{city}";
Tool weather = HttpTool.of(o); // timeout Long ms
```
* C#
```csharp
var weather = HttpTool.Of(new HttpTool.Options {
Name = "weather", Description = "Current weather",
Method = "GET", Url = "https://api.example.com/weather/{city}",
}); // Timeout long? ms
```
* Elixir
```elixir
weather = Toolnexus.Http.tool(
name: "weather", description: "Current weather",
method: "GET", url: "https://api.example.com/weather/{city}")
# note: Toolnexus.Http.tool/1 (not http_tool); :timeout in ms
```
* Clojure
```clojure
(require '[toolnexus.http :as http])
(def weather
(http/http-tool {:name "weather" :description "Current weather"
:method :get :url "https://api.example.com/weather/{city}"}))
;; :timeout-ms (default 30000)
```
* Rust
```rust
use toolnexus::http::{HttpToolOptions, http_tool};
let weather = http_tool(HttpToolOptions {
name: "weather".into(),
description: "Current weather".into(),
method: "GET".into(),
url: "https://api.example.com/weather/{city}".into(),
..Default::default()
}); // timeout_ms (0 ⇒ 30000)
```
## Built-in tools
[Section titled “Built-in tools”](#built-in-tools)
`builtins` (toolkit option, default **on**) ships the same ten tools in every port, `source: "builtin"`, identical names:
```plaintext
bash · read · write · edit · grep · glob · webfetch · question · apply_patch · todowrite
```
`question` suspends through the human loop (a `kind: "question"` Request). The toggle accepts `true | false | { enabled?, disabled?, tools?: { : false } }` — `disabled: true` wins, and a `tools` entry set to `false` drops that one built-in. See the [Built-in tools](/builtins/) guide.
* JavaScript
```ts
const tk = await createToolkit({
builtins: { tools: { bash: false } }, // all built-ins except bash
})
```
* Python
```python
tk = await create_toolkit(builtins={"tools": {"bash": False}})
```
* Go
```go
tk, _ := toolnexus.CreateToolkit(ctx, toolnexus.Options{
Builtins: map[string]any{"tools": map[string]any{"bash": false}},
})
```
* Java
```java
Toolkit tk = Toolkit.create(new Toolkit.Options()
.builtins(Map.of("tools", Map.of("bash", false))));
```
* C#
```csharp
await using var tk = await Toolkit.CreateAsync(new Toolkit.Options {
Builtins = new Dictionary {
["tools"] = new Dictionary { ["bash"] = false } },
});
```
* Elixir
```elixir
{:ok, tk} = Toolnexus.create_toolkit(builtins: %{"tools" => %{"bash" => false}})
```
* Clojure
```clojure
(tn/build {:builtins {:tools {"bash" false}}}) ; all built-ins except bash
```
* Rust
```rust
let tk = create_toolkit(ToolkitOptions {
builtins: Some(BuiltinsConfig {
tools: [("bash".to_string(), false)].into(),
..Default::default()
}),
..Default::default()
})
.await?;
```
## Sub-agents & teams
[Section titled “Sub-agents & teams”](#sub-agents--teams)
An **Agent is a Tool**: a soul (system prompt) × a scoped toolkit × the client loop. `team` is the only set of targets the built-in `task` tool can delegate to — no team ⇒ no `task` tool. `asTool` turns an agent into a `Tool` for `extraTools`; `composeSoul` builds a soul from a directory (`AGENTS.md` + sections). Result `status` ∈ `done · pending · incomplete · interrupted · closed · timeout · error`. An agent (or the runtime) also accepts `hooks` and `onMetric` — the same two lifecycle seams a hand-built client takes, forwarded verbatim; an agent’s value replaces the runtime-wide one, which is how a `compactor` gives each agent its own context budget. See the [Sub-agents & teams](/subagents/) guide.
* JavaScript
```ts
import { agents } from "toolnexus"
const explore = agents.agent("explore", { does: "read-only research", uses: { tools: [lookup] } })
const coder = agents.agent("coder", {
does: "implements changes", soulFile: "./AGENTS.md",
team: [explore], budget: { maxTokens: 10_000 },
})
const r = await coder.run("fix the failing test", {
llm: { baseUrl: "…", style: "openai", model: "gpt-4o-mini" },
})
// r.status · r.text · r.totalTokens ; delegate as a tool: explore.asTool({ llm })
```
* Python
```python
from toolnexus.agents import agent, Budget
explore = agent("explore", does="read-only research", uses={"tools": [lookup]})
coder = agent("coder", does="implements changes", soul_file="./AGENTS.md",
team=[explore], budget=Budget(max_tokens=10_000))
r = await coder.run("fix the failing test",
llm={"base_url": "…", "style": "openai", "model": "gpt-4o-mini"})
# r.status · r.text · r.total_tokens ; explore.as_tool(llm=llm)
```
* Go
```go
import "github.com/muthuishere/toolnexus/golang/agents"
explore := agents.New("explore", agents.Spec{Does: "read-only research", Tools: []toolnexus.Tool{lookup}})
coder := agents.New("coder", agents.Spec{
Does: "implements changes", SoulFile: "./AGENTS.md",
Team: []*agents.Agent{explore}, Budget: &agents.Budget{MaxTokens: 10_000}})
r, _ := coder.Run(agents.Options{LLM: &agents.LLMOptions{
BaseURL: "…", Style: toolnexus.StyleOpenAI, Model: "gpt-4o-mini"}}, "fix the failing test")
// r.Status · r.Text · r.TotalTokens ; explore.AsTool(agents.Options{LLM: llm})
```
* Java
```java
import static io.github.muthuishere.toolnexus.agents.Agents.agent;
var explore = agent("explore", new Agents.AgentSpec().does("read-only research").tools(lookup));
var coder = agent("coder", new Agents.AgentSpec()
.does("implements changes").soulFile(Path.of("AGENTS.md"))
.team(explore).budget(new Budget().maxTokens(10_000)));
TaskResult r = coder.run(new RuntimeOptions()
.baseUrl("…").style("openai").defaultModel("gpt-4o-mini"), "fix the failing test");
// r.status() · r.text() · r.totalTokens() ; explore.asTool(rtOpts)
```
* C#
```csharp
using Toolnexus.Agents;
var explore = new Agent("explore", new AgentSpec { Does = "read-only research", Uses = new List { lookup } });
var coder = new Agent("coder", new AgentSpec {
Does = "implements changes", SoulFile = "./AGENTS.md",
Team = new List { explore }, Budget = new Budget { MaxTokens = 10_000 } });
var r = await coder.RunAsync(new RuntimeOptions {
BaseUrl = "…", Style = "openai", Model = "gpt-4o-mini" }, "fix the failing test");
// r.Status · r.Text · r.TotalTokens ; explore.AsTool(rtOpts)
```
* Elixir
```elixir
alias Toolnexus.Agents
explore = Agents.agent("explore", does: "read-only research", uses: %{tools: [lookup]})
coder = Agents.agent("coder",
does: "implements changes", soul_file: "AGENTS.md",
team: [explore], budget: %{max_tokens: 10_000})
r = Agents.run(coder,
[llm: %{base_url: "…", style: "openai", model: "gpt-4o-mini"}], "fix the failing test")
# r.status · r.text · r.total_tokens ; Agents.as_tool(explore, rt_opts)
```
Clojure
Local sub-agents (`agents` / `team` / `Budget`) are **not** in the Clojure port — they are recorded as parity debt (see the [Clojure API page](/api/clojure/)). The cross-process form of delegation it does ship is **A2A remote agents** (below): expose a toolkit with `toolnexus.serve/serve` and consume a peer with `toolnexus.a2a/remote-agent`.
Underneath `agents.agent`/`team` sits the actual run loop: a gated `Loop` that only calls the model again when a `Guardrail` says the turn isn’t a real `Completion` yet (e.g. all todos aren’t `done`). `loopUnsupported` names the ports/configurations where a given loop shape isn’t wired up, instead of silently no-op’ing. See the [harness](/harness/) guide for the concept and [`runtime/loop`](/api/javascript/runtime/loop/) for the per-language surface.
## A2A — serve inbound, consume remote agents
[Section titled “A2A — serve inbound, consume remote agents”](#a2a--serve-inbound-consume-remote-agents)
Two directions of the same protocol (JSON-RPC 2.0; card at `GET /.well-known/agent-card.json`). **Serve** exposes a toolkit as a remote A2A agent; **remote agents** pull a peer’s card and expose its skills as `_` tools (`source: "a2a"`). See the [A2A](/a2a/) guide.
### Serve — expose a toolkit
[Section titled “Serve — expose a toolkit”](#serve--expose-a-toolkit)
* JavaScript
```ts
const handle = await tk.serve("127.0.0.1:0", {
client: llm,
a2a: { name: "my-agent", store: "memory" }, // store: "memory" | "file:" | TaskStore
})
// handle.url speaks A2A ; await handle.stop()
```
* Python
```python
from toolnexus import A2AConfig
handle = await tk.serve("127.0.0.1:0", client=llm,
a2a=A2AConfig(name="my-agent", store="memory"))
# handle.url ; await handle.stop()
```
* Go
```go
handle, _ := tk.Serve("127.0.0.1:0", toolnexus.ServeOptions{
Client: llm,
A2A: &toolnexus.A2AConfig{Name: "my-agent", Store: "memory"},
})
// handle.URL ; handle.Stop()
```
* Java
```java
A2AServer.ServeHandle handle = tk.serve("127.0.0.1:0",
new Toolkit.ServeOptions()
.client(llm)
.a2a(new A2AServer.A2AConfig().name("my-agent").store("memory")));
```
* C#
```csharp
var handle = await tk.ServeAsync("127.0.0.1:0", new Toolkit.ServeOptions {
Client = llm,
A2A = new A2AConfig { Name = "my-agent", Store = "memory" },
});
```
* Elixir
```elixir
handle = Toolnexus.Toolkit.serve(tk, "127.0.0.1:0",
client: llm, a2a: %{name: "my-agent", store: "memory"})
# handle.url ; Toolnexus.Serve.stop(handle)
```
* Clojure
```clojure
(require '[toolnexus.serve :as serve])
(def handle
(serve/serve tk {:port 0 ; 0 ⇒ OS-assigned
:client llm
:a2a {:name "my-agent" :store "memory"}}))
;; (:url handle) speaks A2A ; ((:stop! handle))
```
* Rust
```rust
use toolnexus::serve::{A2aConfig, ServeOptions, TaskStoreSpec};
let handle = tk
.serve("127.0.0.1:0", ServeOptions {
client: Some(client),
a2a: Some(A2aConfig {
name: "my-agent".into(),
store: TaskStoreSpec::Selector("memory".into()),
..Default::default()
}),
..Default::default()
})
.await?;
// handle.url ; handle.stop()
```
### Remote agents — consume a peer
[Section titled “Remote agents — consume a peer”](#remote-agents--consume-a-peer)
* JavaScript
```ts
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")
```
* Python
```python
from toolnexus import create_toolkit, agent
tk = await create_toolkit(
agents=[agent("https://peer.example.com/.well-known/agent-card.json")])
await tk.add_agent("https://other.example.com/.well-known/agent-card.json")
```
* Go
```go
tk, _ := toolnexus.CreateToolkit(ctx, toolnexus.Options{
Agents: []toolnexus.Agent{{Card: "https://peer.example.com/.well-known/agent-card.json"}},
})
tk.AddAgent(ctx, "https://other.example.com/.well-known/agent-card.json", nil)
```
* Java
```java
Toolkit tk = Toolkit.create(new Toolkit.Options()
.agents(List.of(A2A.agent("https://peer.example.com/.well-known/agent-card.json"))));
tk.addAgent("https://other.example.com/.well-known/agent-card.json");
```
* C#
```csharp
await using var tk = await Toolkit.CreateAsync(new Toolkit.Options {
Agents = new List {
new() { Card = "https://peer.example.com/.well-known/agent-card.json" } },
});
await tk.AddAgentAsync("https://other.example.com/.well-known/agent-card.json");
```
* Elixir
```elixir
{:ok, tk} = Toolnexus.create_toolkit(
agents: [Toolnexus.A2A.agent(card: "https://peer.example.com/.well-known/agent-card.json")])
Toolnexus.Toolkit.add_agent(tk, "https://other.example.com/.well-known/agent-card.json")
```
* Clojure
```clojure
(require '[toolnexus.a2a :as a2a])
(def peer (a2a/remote-agent {:card "https://peer.example.com/.well-known/agent-card.json"}))
;; each of the peer's skills is a Tool in (:tools peer) — hand them to build:
(tn/build {:tools (:tools peer)})
```
* Rust
```rust
use toolnexus::a2a::Agent;
let tk = create_toolkit(ToolkitOptions {
agents: vec![Agent::new("https://peer.example.com/.well-known/agent-card.json")],
..Default::default()
})
.await?;
tk.add_agent(Agent::new("https://other.example.com/.well-known/agent-card.json")).await?;
```
## Suspension & the human loop
[Section titled “Suspension & the human loop”](#suspension--the-human-loop)
`waitFor` is the host resolver `(Request) → Answer`. A tool suspends by returning a **pending** result (`isError: true`, `metadata.pending = Request`); the loop calls `waitFor`, then re-runs the tool with `Answer` on the tool context. `Request { id, kind, prompt, url?, data?, expiresAt? }` · `Answer { id, ok, data?, reason? }`. This is also the bridge for MCP elicitation and the built-in `question` tool. See the [Suspension](/suspension/) guide.
* JavaScript
```ts
import { pending, authRequired, pendingOf } from "toolnexus"
const tk = await createToolkit({
waitFor: async (req) => ({ id: req.id, ok: true, data: { answer: "…" } }),
})
// inside a tool: return pending({ kind: "input", prompt: "…" }) // or authRequired(url)
```
* Python
```python
from toolnexus import pending, auth_required, Answer
tk = await create_toolkit(
wait_for=lambda req: Answer(id=req.id, ok=True, data={"answer": "…"}))
# inside a tool: return pending(kind="input", prompt="…")
```
* Go
```go
tk, _ := toolnexus.CreateToolkit(ctx, toolnexus.Options{
WaitFor: func(req toolnexus.Request) (toolnexus.Answer, error) {
return toolnexus.Answer{ID: req.ID, Ok: true, Data: map[string]any{"answer": "…"}}, nil
},
})
// inside a tool: return toolnexus.Pending(toolnexus.Request{Kind: "input", Prompt: "…"})
```
* Java
```java
Toolkit tk = Toolkit.create(new Toolkit.Options()
.waitFor(req -> new Answer(req.id(), true, Map.of("answer", "…"), null)));
// inside a tool: return ToolResult.pending(new Request(id, "input", "…"));
```
* C#
```csharp
await using var tk = await Toolkit.CreateAsync(new Toolkit.Options()
.WithWaitFor(req => Task.FromResult(
new Answer { Id = req.Id, Ok = true, Data = new() { ["answer"] = "…" } })));
// inside a tool: return ToolResult.Pending(new Request { Kind = "input", Prompt = "…" });
```
* Elixir
```elixir
{:ok, tk} = Toolnexus.create_toolkit(
wait_for: fn req -> %Toolnexus.Answer{id: req.id, ok: true, data: %{"answer" => "…"}} end)
# a tool suspends by returning %Toolnexus.ToolResult{is_error: true,
# metadata: %{pending: %Toolnexus.Request{kind: "input", prompt: "…"}}}
```
* Clojure
```clojure
(def c (client/create-client
{:base-url "…" :model "…"
:wait-for (fn [req] (client/make-answer (:id req) true {:answer "…"}))}))
;; inside a tool: (client/suspend (client/make-request "input" "…"))
;; or (client/auth-required url) ; (client/pending-of result) -> Request|nil
```
* Rust
```rust
let tk = create_toolkit(ToolkitOptions {
wait_for: Some(Arc::new(|req: Request| {
Box::pin(async move {
let mut data = Map::new();
data.insert("answer".into(), json!("…"));
Ok(Answer { id: req.id, ok: true, data: Some(data), ..Default::default() })
})
})),
..Default::default()
})
.await?;
// inside a tool: return pending(Request { kind: "input".into(), prompt: "…".into(), ..Default::default() })
```
# API Reference — Clojure
> The toolnexus Clojure port — toolkit, client, skills, MCP, adapters, native/HTTP/built-in tools, A2A and suspension — one .cljc source tree that runs on the JVM and on cljgo, with each symbol's equivalents in the other ports.
One `.cljc` source tree that runs unchanged on **Clojure (JVM)** and on [**cljgo**](https://github.com/muthuishere/cljgo) (Clojure hosted on Go). Every host difference lives behind [koine](https://github.com/muthuishere/koine), the only third-party dependency. See the [combined API Reference](/api/) for the shared surface in the other ports’ tabs.
Note
This port is wired into the shared conformance checker at tier **full**, the same tier as the other six (`conformance/options_manifest.json`) — including the agent layer: the §7D runtime (`toolnexus.agents.runtime` — sub-agents, the `task` tool, hierarchical budgets, `:on-budget`), §7E personas (`toolnexus.agents.home` — `from-dir`, the `memory` tool, the heartbeat) and §7F compaction (`toolnexus.agents.compaction`). What it does **not** ship: real **SSE streaming** — the loop buffers and emits no text deltas, and synthesising deltas from a buffered body would be a lie — and **durable cross-process resume**. Symbols below are the real public surface of `clojure/src/toolnexus/`; the source is the authority.
Namespaces live under **`toolnexus.*`** (`clojure/src/toolnexus/`). On Clojars since v0.13.0: `net.clojars.muthuishere/toolnexus {:mvn/version "0.27.0"}` — koine comes with it transitively.
Every entry point below also has its own page, generated from the same manifest as the other six ports — open the **Clojure** topic in the API-reference picker, or start at [`toolnexus.core/build`](/api/clojure/toolkit/create/).
## Toolkit — `toolnexus.core/build`
[Section titled “Toolkit — toolnexus.core/build”](#toolkit--toolnexuscorebuild)
* `(build {:keys [mcp skills skill-defs skill-provider skills-filter skill-sample-limit disable-skills builtins agents tools disable-tools wait-for]}) -> toolkit`
* `:mcp` — an `mcp.json` string or map (§2/§0.3)
* `:skills` — a skills root, a seq of roots, or a seq of skill defs (§3); `:skill-defs` and `:skill-provider` supply them as data, `:skills-filter` / `:disable-skills` / `:skill-sample-limit` narrow the catalog
* `:builtins` — the §0.11 toggle: `false | {:disabled ..} | {:enabled ..} | {:tools {..}}`
* `:agents` — remote A2A peers; each advertised skill becomes a tool (§7A)
* `:tools` — extra Tools of your own (native, http, a2a — anything); `:disable-tools` drops names across EVERY source
* `:wait-for` — the ONE §10 host resolver, handed to the MCP source so a server may elicit input mid-`tools/call`; absent ⇒ the `elicitation` capability is not advertised at all
* Returns `{:tools .. :sources .. :skills .. :statuses .. :errors .. :connections .. :wait-for ..}`. Every source is isolated: a dead MCP server contributes an error + status, never an exception.
* `(shutdown! tk)` — disconnect every MCP connection the toolkit opened (idempotent).
* `(tool-names tk)` — sorted tool names. `(execute tk name args)` / `(execute tk name args ctx)`.
* Equivalents: `createToolkit` (js) · `create_toolkit` (py) · `CreateToolkit` (go) · `Toolkit.create` (java) · `Toolkit.CreateAsync` (c#) · `Toolnexus.create_toolkit` (elixir).
## Skills — `toolnexus.skill`
[Section titled “Skills — toolnexus.skill”](#skills--toolnexusskill)
* `(load-skills input) -> {:skills .. :by-name .. :skipped .. :filter-unmatched .. :sample-limit ..}` — `input` is a root string, a seq of roots, one skill def, a seq of defs, or the options map `{:dirs .. :skills .. :filter .. :sample-limit ..}`. Roots are visited in the order given, and an earlier one wins a name collision.
* `(list-skills input) -> {:skills .. :skipped ..}` — discover + validate without building a toolkit; typed skip reasons `missing-name` · `malformed-frontmatter` · `duplicate-name` · `unreadable`.
* A **data skill** is a plain map: `{:name .. :description .. :content .. :resources [..] :base ..}`. With no `:base` it gets the logical base `skill:///`, so a source that never touched a disk cannot leak a host path.
* `(skill-tool loaded)` — the single `skill` tool over a loaded source (progressive disclosure); `(skills-prompt loaded)` — the §3/§0.6 system-prompt catalog (re-exported as `toolnexus.core/skills-prompt`).
* Equivalents: `loadSkills` / `listSkills` (js) · `load_skills` / `list_skills` (py) · `LoadSkillsWith` / `ListSkills` (go) · `SkillSource.loadWith` / `listSkills` (java) · `SkillSource.LoadWith` / `ListSkills` (c#) · `Toolnexus.Skill.load` / `Toolnexus.Skill.list` (elixir).
## MCP · Adapters · Client
[Section titled “MCP · Adapters · Client”](#mcp--adapters--client)
* `toolnexus.mcp` — `(from-config config)` connects every server (stdio + streamable-HTTP), returns `{:tools .. :statuses .. :connections .. :errors ..}`; `(parse-config config)` validates without connecting; `(tools conn)`, `(disconnect conn)`, `(disconnect-all conns)`. `(from-config config {:wait-for f})` also bridges a server’s **elicitation** onto §10 — `(elicitation->request params)` / `(answer->elicit-result answer)` — and with no `:wait-for` the capability is not advertised at all.
* Adapters (`toolnexus.adapter`, re-exported on `core`): `(to-openai tk)` · `(to-anthropic tk)` · `(to-gemini tk)`.
* Client (`toolnexus.client`): `(create-client opts)` → `(run client prompt {:toolkit tk :history [..] :on-event f :conversation-id id})`.
## Tools you build — native, HTTP, built-ins
[Section titled “Tools you build — native, HTTP, built-ins”](#tools-you-build--native-http-built-ins)
* `toolnexus.native/native-tool` — map form `(native-tool {:name .. :description .. :input-schema {..} :run (fn [args] ..)})`; a tool that needs the §1 Context declares `:ctx? true` and its `:run` takes `(fn [args ctx] ..)`. (The flag exists because Clojure/cljgo have no portable fn-arity introspection.)
* `toolnexus.http/http-tool` — `(http-tool {:name .. :description .. :method :get :url ".../{id}" :headers {"authorization" "Bearer ${API_TOKEN}"} :query [:verbose] :body "json" :timeout-ms 30000 :result-mode "text"})`. `:timeout-ms` in **milliseconds** (default 30000); `:body` is `"json" | "form" | "raw"`; `:result-mode` is `"text" | "json" | "status+text"`.
* `toolnexus.builtin` — `builtin-names` / `builtin-tools`: the same ten built-ins, `source "builtin"`, identical names: `bash · read · write · edit · grep · glob · webfetch · question · apply_patch · todowrite`. The toolkit `:builtins` toggle selects them.
* `toolnexus.tool` — the primitives every source flows through: `(tool {..})`, `(success output)` / `(failure output)` (deliberately **not** `ok`/`err` — those shadow a `clojure.core` name on cljgo), `(toolkit tools)`, `(add-tools tk tools)`, `(tool-names tk)`, `(execute tk name args)`.
* Equivalents: `defineTool` / `httpTool` (js) · `define_tool` / `http_tool` (py) · `NativeTool` / `HTTPTool` (go) · `NativeTool.of` / `HttpTool.of` (java) · `NativeTool.Of` / `HttpTool.Of` (c#) · `Toolnexus.Native.define_tool` / `Toolnexus.Http.tool` (elixir).
## A2A — serve & remote agents
[Section titled “A2A — serve & remote agents”](#a2a--serve--remote-agents)
* Serve (`toolnexus.serve`): `(serve tk {:port 0 :a2a {..} :mcp {..} :skills .. :client llm :on-task f :on-call f})` → `{:url :port :store :stop! :handle}`; `(stop! handle)`. `:a2a`/`:mcp` absent ⇒ those routes off. `(agent-card a2a base skills)`.
* Remote agents (`toolnexus.a2a`): `(remote-agent {:card url :headers {..} :timeout ms :poll-every ms})` → `{:card .. :endpoint .. :tools [..]}`; a failing agent is isolated to `{:tools [] :error ".."}`. `(agent-tools spec)` returns just the tools; `(parse-agents-config config)` declares peers in config the way MCP servers are declared. Pass the specs via `build`’s `:agents`, or the tools via `:tools`.
* Equivalents: `tk.serve` / `agent` + `tk.addAgent` (js) · `serve` / `add_agent` (py) · `Serve` / `AddAgent` (go) · `serve` / `addAgent` (java) · `ServeAsync` / `AddAgentAsync` (c#) · `Toolkit.serve` / `Toolkit.add_agent` (elixir).
Caution
**Not in this port**: local sub-agents / `team` / `Budget` (§7D), the `memory` built-in and the `fromDir`/heartbeat persona layer (§7E), the compactor (§7F), and SSE text-delta streaming — `run`’s `:on-event` sink emits `tool_call`, `tool_result`, `pending`, `usage` and `done`, but no text deltas, because the loop buffers the whole response. A2A remote agents (above) are the cross-process form of delegation this port does ship.
## Translation — `toolnexus.translate`
[Section titled “Translation — toolnexus.translate”](#translation--toolnexustranslate)
* `(translate client {:messages [..] :tools [..] :toolkit tk :toolChoice .. :system .. :maxTokens ..})` — SPEC §11: exactly **one** provider call, returned in OpenAI shape `{:text :toolCalls [{:id :name :arguments}] :finishReason :usage :model :raw}`. Drives no loop, executes no tool, touches no conversation store. `:arguments` is a JSON **string** — the OpenAI wire form.
* `(openai-messages-to-anthropic messages)` · `(openai-tools-to-anthropic tools)` · `(openai-tool-choice-to-anthropic choice)` — the inbound half, including the tool-result merging a flattening translator gets wrong.
## Suspension & the human loop — `toolnexus.client`
[Section titled “Suspension & the human loop — toolnexus.client”](#suspension--the-human-loop--toolnexusclient)
* `:wait-for` — the host resolver on `create-client`: `(fn [request] answer)`, the one host slot of §10.
* `(make-request kind prompt opts)` · `(make-answer id ok data reason)`.
* `(suspend request)` / `(suspend request output)` — a `ToolResult` whose `metadata.pending` is a `Request` **is** a suspension (§0.12); `(pending-of result)` returns that `Request` or nil.
* `(auth-required url)` / `(auth-required url prompt)` — a `kind:"authorization"` suspension at `url`.
* Equivalents: `waitFor` / `pending` / `authRequired` / `pendingOf` (js) · `wait_for` / `pending` (py) · `WaitFor` / `Pending` / `AuthRequired` / `PendingOf` (go) · `waitFor` / `ToolResult.pending` (java) · `WithWaitFor` / `ToolResult.Pending` (c#) · `wait_for` / `ToolResult.pending?` (elixir).
## Media generation — `toolnexus.media`
[Section titled “Media generation — toolnexus.media”](#media-generation--toolnexusmedia)
Guide: [Media generation](/toolnexus/media/) · recipe: [Generate images, video and speech](/toolnexus/cookbook/media/) · SPEC §7G.
* `(load-style path)` — one style JSON file, string-keyed.
* `(create-media styles)` / `(create-media styles opts)` — one style or a seq (string- or keyword-keyed); a verb resolves to the first style that declares it. opts: `:on-progress` (`(fn [{:verb :jobId :status}])`, after every poll) · `:http-client` (`(fn [{:method :url :headers :body}]) -> {:status :body}`; default `koine.http/request` — unlike the LLM client’s seam it carries a method, because polls are GETs) · `:env` (`(fn [name])` for `${VAR}` header expansion).
* Verbs, each `(verb m args)` / `(verb m args opts)`: `image` (`:prompt :width :height :aspectRatio :seed`) · `video` (`:prompt :image :seconds :aspectRatio`) · `speech` (`:text :voice`) · `transcribe` (`:audio`) · `clone-voice` (`:name :samples`) · `talking` (`:image :audio`).
* `(generate m verb args)` / `(generate m verb args {:provider-options .. :cancel ..})` — the raw call; `verb` is the canonical name (`"cloneVoice"`), `:cancel` an atom or 0-arg fn (truthy ⇒ cancelled).
* Returns MediaResult `{:files [{:type :mimeType :url}] :text? :jobId? :status}`.
* `(tools m)` — source `"media"` tools; or `(toolnexus.core/build {:media m ..})`. `(media? x)`.
* `:files` on the run result — `{:type :mimeType :url :from :callId}`, in call order; the same parts are on each `:tool-calls` entry’s `:parts`.
* Errors: the function face **throws** `ex-info` whose message is the exact §7G string; a media tool never throws (`:isError true`).
* Equivalents: `createMedia` (js) · `create_media` (py) · `CreateMedia` (go) · `Media.create` (java) · `Media.Create` (c#) · `Toolnexus.Media.new` (elixir) · `create_media` (rust).
# toolnexus.adapter/to-anthropic
> Render the toolkit as Anthropic tool-use schema. — Clojure.
**Clojure (JVM) + cljgo** · package `net.clojars.muthuishere/toolnexus` · [SPEC §4](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [clojure/src/toolnexus/adapter.cljc](https://github.com/muthuishere/toolnexus/blob/main/clojure/src/toolnexus/adapter.cljc)
```clojure
(to-anthropic tools-or-toolkit)
;; => [{:name "echo"
;; :description "Echo the input back."
;; :input_schema {:type "object" :properties {…} :required […]}}
;; …]
```
`to-anthropic` renders each tool as a **flat** map — no wrapper object, no nested `:function` key. That is the first thing that differs from [`to-openai`](/api/clojure/adapters/openai/), and the second is the schema’s key: Anthropic calls it **`:input_schema`**, in snake\_case.
The underscore is deliberate and must survive. It is a wire name, not a Clojure convention, so it is never kebab-ised to `:input-schema` on the way out — the tool’s internal key is `:input-schema` and the emitted key is `:input_schema`, and the two are not the same keyword. Any JSON encoder you pass the result to will write it as `input_schema` verbatim.
Everything else matches the other adapters. Schema only, no network. A toolkit is sorted by tool name because map order is not a contract on either host; an explicit seq of tools keeps the order you gave it. With no tools you get `[]` — Gemini is the one adapter that still emits a wrapper.
## When to use it
[Section titled “When to use it”](#when-to-use-it)
* **Calling the Anthropic Messages API** — this is the `tools` array it expects.
* **Rendering a subset per request** — pass a vector instead of the toolkit.
* **Checking the wire keys** — this is the adapter where a silent key rename would break a live call, so it is worth asserting on in a test.
* **Building a payload without an SDK** — the output is data, so a plain HTTP POST works.
## Why this and not the alternative
[Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative)
One shape per endpoint — they are not interchangeable
[`to-openai`](/api/clojure/adapters/openai/) wraps each tool in `{:type "function" :function {…}}` and names the schema `:parameters`; [`to-gemini`](/api/clojure/adapters/gemini/) puts everything inside one `:functionDeclarations` wrapper. Anthropic accepts neither. Choose by the endpoint you are posting to.
`toolnexus.core/to-anthropic` re-exports this function, so `toolnexus.core` alone is enough for a caller who does not want a second require.
## Examples
[Section titled “Examples”](#examples)
### Render a toolkit, and confirm the wire key
[Section titled “Render a toolkit, and confirm the wire key”](#render-a-toolkit-and-confirm-the-wire-key)
```clojure
(require '[toolnexus.core :as toolnexus]
'[toolnexus.adapter :as adapter])
(def tk (toolnexus/build {:skills "examples/skills"}))
(first (toolnexus/to-anthropic tk))
;; => {:name "apply_patch"
;; :description "Apply an add/update/delete patch."
;; :input_schema {:type "object"
;; :properties {:patchText {:type "string" …}}
;; :required ["patchText"]}}
;; snake_case out, kebab-case in — these are two different keywords.
(contains? (first (adapter/to-anthropic tk)) :input_schema) ;=> true
(contains? (first (adapter/to-anthropic tk)) :input-schema) ;=> false
(map :name (toolnexus/to-anthropic tk))
;; => ("apply_patch" "bash" "edit" "glob" "grep" "question" "read"
;; "skill" "todowrite" "webfetch" "write")
```
### A hand-ordered subset for one request
[Section titled “A hand-ordered subset for one request”](#a-hand-ordered-subset-for-one-request)
```clojure
(require '[toolnexus.adapter :as adapter]
'[toolnexus.http :as http]
'[toolnexus.native :as native])
(def lookup
(http/http-tool {:name "lookup_order" :description "Fetch one order by id"
:input-schema {:type "object"
:properties {:id {:type "string"}}
:required ["id"]}
:url "https://api.example.com/orders/{id}"
:headers {"authorization" "Bearer ${API_TOKEN}"}}))
(def refund
(native/native-tool {:name "refund" :description "Refund an order"
:run (fn [args] (str "refunded " (:id args)))}))
;; A vector preserves this order; the toolkit form would sort it.
(adapter/to-anthropic [lookup refund])
;; => [{:name "lookup_order" :description "Fetch one order by id"
;; :input_schema {:type "object" :properties {:id {:type "string"}} :required ["id"]}}
;; {:name "refund" :description "Refund an order" :input_schema {:type "object"}}]
(adapter/to-anthropic []) ;=> []
```
The tool source makes no difference to the payload. An HTTP tool, a native tool, an MCP tool and the `skill` tool all render identically — that uniformity is the point of the `Tool` abstraction.
### Closing the loop: `tool_use` block back to `execute`
[Section titled “Closing the loop: tool\_use block back to execute”](#closing-the-loop-tool_use-block-back-to-execute)
```clojure
(require '[toolnexus.core :as toolnexus])
(def tk (toolnexus/build {:skills "examples/skills"}))
(def request {:model "claude-sonnet-4-5" :messages []
:tools (toolnexus/to-anthropic tk)})
;; Anthropic hands back a tool_use content block: id, name, and args already
;; parsed as an object — no JSON string to decode, unlike OpenAI.
(defn tool-result-block [tk block]
(let [res (toolnexus/execute tk (get block "name") (get block "input"))]
{:type "tool_result"
:tool_use_id (get block "id")
:content (:output res)
;; A failed tool is reported, not thrown — the model sees it and retries.
:is_error (:isError res)}))
(tool-result-block tk {"id" "toolu_1" "name" "skill" "input" {"name" "hello-world"}})
;; => {:type "tool_result" :tool_use_id "toolu_1"
;; :content "…" :is_error false}
```
| Emitted key | Source |
| --------------- | -------------------------------------------------------------------------------------------- |
| `:name` | The tool’s `:name`. |
| `:description` | The tool’s `:description`, `""` when unset — never `nil`. |
| `:input_schema` | The tool’s `:input-schema` verbatim, `{:type "object"}` when unset. Snake\_case on the wire. |
| Input | Order |
| ----------------------- | -------------------- |
| a toolkit | Sorted by tool name. |
| a seq of tools | Exactly as given. |
| an empty toolkit or seq | `[]`. |
## See also
[Section titled “See also”](#see-also)
* [`toolnexus.adapter/to-openai`](/api/clojure/adapters/openai/) — Render the toolkit as OpenAI function-calling schema.
* [`toolnexus.adapter/to-gemini`](/api/clojure/adapters/gemini/) — Render the toolkit as Gemini function-declaration schema.
* [`toolnexus.builtin/builtin-tools`](/api/clojure/builtins/create/) — the other half of the loop: executing what the model picked, identically for every provider.
# toolnexus.adapter/to-gemini
> Render the toolkit as Gemini function-declaration schema. — Clojure.
**Clojure (JVM) + cljgo** · package `net.clojars.muthuishere/toolnexus` · [SPEC §4](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [clojure/src/toolnexus/adapter.cljc](https://github.com/muthuishere/toolnexus/blob/main/clojure/src/toolnexus/adapter.cljc)
```clojure
(to-gemini tools-or-toolkit)
;; ALWAYS a one-element vector, whatever the tool count:
;; => [{:functionDeclarations
;; [{:name "echo"
;; :description "Echo the input back."
;; :parameters {:type "object" :properties {…} :required […]}}
;; …]}]
```
`to-gemini` is the outlier of the three. Where OpenAI and Anthropic return one entry per tool, this returns **exactly one element** — a wrapper map whose `:functionDeclarations` vector holds every tool. Two levels of nesting, not one, and the tool entries themselves are the OpenAI-style trio of `:name`, `:description` and `:parameters` rather than Anthropic’s `:input_schema`.
It stays a one-element vector even with no tools: `[{:functionDeclarations []}]`, not `[]`. That is not an accident of the implementation — every shipped port emits the wrapper with an empty declarations array, and matching them is the point of the parity contract. `to-openai` and `to-anthropic` both return a bare `[]` in the same situation.
Everything else is shared with the other two adapters. Schema only, no network, no key. A toolkit is sorted by tool name because map order is not a contract on either host; an explicit seq keeps the order you gave it. `:description` defaults to `""` and `:parameters` to `{:type "object"}`, so neither can be `nil` in the payload.
## When to use it
[Section titled “When to use it”](#when-to-use-it)
* **Calling the Gemini `generateContent` API** — this is the `tools` value it expects.
* **Rendering a subset per request** — pass a vector of tools rather than the whole toolkit.
* **Writing the payload by hand** — the extra nesting is the thing people get wrong; render it rather than hand-building it.
* **Parity checks** — the empty case is the one that catches an adapter reimplemented from memory.
## Why this and not the alternative
[Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative)
Do not unwrap it to look like the others
It is tempting to `first` the result so it matches [`to-openai`](/api/clojure/adapters/openai/) and [`to-anthropic`](/api/clojure/adapters/anthropic/), but the wrapper *is* the payload — Gemini accepts a list of tool groups, and toolnexus emits exactly one group. Send the vector as-is.
`toolnexus.core/to-gemini` re-exports this function, so `toolnexus.core` alone is enough for a caller who does not want a second require.
## Examples
[Section titled “Examples”](#examples)
### Render a toolkit, and note the two levels
[Section titled “Render a toolkit, and note the two levels”](#render-a-toolkit-and-note-the-two-levels)
```clojure
(require '[toolnexus.core :as toolnexus])
(def tk (toolnexus/build {:skills "examples/skills"}))
(count (toolnexus/to-gemini tk)) ;=> 1 ; always
(count (get-in (toolnexus/to-gemini tk) [0 :functionDeclarations])) ;=> 11
(get-in (toolnexus/to-gemini tk) [0 :functionDeclarations 0])
;; => {:name "apply_patch"
;; :description "Apply an add/update/delete patch."
;; :parameters {:type "object"
;; :properties {:patchText {:type "string" …}}
;; :required ["patchText"]}}
(map :name (get-in (toolnexus/to-gemini tk) [0 :functionDeclarations]))
;; => ("apply_patch" "bash" "edit" "glob" "grep" "question" "read"
;; "skill" "todowrite" "webfetch" "write")
```
### The empty case, which is where ports drift
[Section titled “The empty case, which is where ports drift”](#the-empty-case-which-is-where-ports-drift)
```clojure
(require '[toolnexus.adapter :as adapter]
'[toolnexus.tool :as tool])
(adapter/to-gemini (tool/toolkit [])) ;=> [{:functionDeclarations []}]
;; The other two collapse to nothing at all.
(adapter/to-openai (tool/toolkit [])) ;=> []
(adapter/to-anthropic (tool/toolkit [])) ;=> []
```
An agent configured with `:builtins false` and no other source hits this on every request, so it is worth having a test for rather than discovering against a live endpoint.
### Closing the loop: `functionCall` back to `execute`
[Section titled “Closing the loop: functionCall back to execute”](#closing-the-loop-functioncall-back-to-execute)
```clojure
(require '[toolnexus.core :as toolnexus])
(def tk (toolnexus/build {:skills "examples/skills"}))
(def request {:contents [] :tools (toolnexus/to-gemini tk)})
;; Gemini returns a functionCall part: a name and already-parsed args.
;; The reply goes back as a functionResponse part, not a plain string.
(defn function-response [tk call]
(let [res (toolnexus/execute tk (get call "name") (get call "args"))]
{:functionResponse
{:name (get call "name")
:response {:output (:output res)
;; A failed tool is a value the model reads, not an exception.
:isError (:isError res)}}}))
(function-response tk {"name" "skill" "args" {"name" "hello-world"}})
;; => {:functionResponse
;; {:name "skill"
;; :response {:output "…" :isError false}}}
```
| Emitted key | Source |
| --------------------------- | ------------------------------------------------------------------- |
| `[0] :functionDeclarations` | The whole tool list. Present even when empty. |
| `:name` | The tool’s `:name`. |
| `:description` | The tool’s `:description`, `""` when unset. |
| `:parameters` | The tool’s `:input-schema` verbatim, `{:type "object"}` when unset. |
| Input | Result |
| ----------------------- | --------------------------------------------------- |
| a toolkit | One wrapper, declarations sorted by tool name. |
| a seq of tools | One wrapper, declarations in the order given. |
| an empty toolkit or seq | `[{:functionDeclarations []}]` — still one wrapper. |
## See also
[Section titled “See also”](#see-also)
* [`toolnexus.adapter/to-openai`](/api/clojure/adapters/openai/) — Render the toolkit as OpenAI function-calling schema.
* [`toolnexus.adapter/to-anthropic`](/api/clojure/adapters/anthropic/) — Render the toolkit as Anthropic tool-use schema.
* [`toolnexus.builtin/enabled-builtins`](/api/clojure/builtins/select/) — what decides how many declarations end up in the wrapper.
# toolnexus.adapter/to-openai
> Render the toolkit as OpenAI function-calling schema. — Clojure.
**Clojure (JVM) + cljgo** · package `net.clojars.muthuishere/toolnexus` · [SPEC §4](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [clojure/src/toolnexus/adapter.cljc](https://github.com/muthuishere/toolnexus/blob/main/clojure/src/toolnexus/adapter.cljc)
```clojure
(to-openai tools-or-toolkit)
;; => [{:type "function"
;; :function {:name "echo"
;; :description "Echo the input back."
;; :parameters {:type "object" :properties {…} :required […]}}}
;; …]
```
`to-openai` renders every tool in the toolkit as one entry in OpenAI’s function-calling array. The shape is a wrapper — `:type "function"` plus a `:function` map — and the tool’s `:input-schema` is emitted verbatim under the key **`:parameters`**. That nesting is what distinguishes this adapter from the other two: Anthropic’s entries are flat, and Gemini’s live inside a single declarations wrapper.
It is schema only. Nothing here touches a network, a key or a host — the return value is data you hand to whatever OpenAI client you already use. Execution is identical for all three providers: read the tool name and args the model returned, call `toolnexus.tool/execute`, feed the output back as a tool message.
Order is deterministic by construction. A toolkit goes through `toolnexus.tool/all-tools`, which sorts by name, because map order is not a contract on either host. An explicit seq of tools is left in the order you gave it — that is the only way to pin a non-alphabetical order.
With no tools, `to-openai` returns `[]`. Same for Anthropic; Gemini is the one that does not.
## When to use it
[Section titled “When to use it”](#when-to-use-it)
* **Calling the OpenAI Chat Completions API** — or anything that speaks its function-calling shape, which is most of the OpenAI-compatible gateways.
* **Rendering a subset** — pass a vector of tools rather than the toolkit when this request should only see some of them.
* **Pinning a tool order** — models are mildly sensitive to it, and an explicit seq is how you control it.
* **Asserting on a payload in a test** — the output is plain data with no hidden state, so comparing it against an expected vector is a normal `=`.
## Why this and not the alternative
[Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative)
Match the adapter to the API you are actually calling
The three adapters are not interchangeable renderings of one payload — they emit different key names and different nesting. Sending [`to-anthropic`](/api/clojure/adapters/anthropic/) output to OpenAI fails on the missing `:function` wrapper, and [`to-gemini`](/api/clojure/adapters/gemini/) output is wrapped one level deeper again. Pick by endpoint, not by preference.
`toolnexus.core/to-openai` re-exports this function, so a caller that already requires `toolnexus.core` does not need a second require. It is the same function with the same result.
## Examples
[Section titled “Examples”](#examples)
### Render a whole toolkit
[Section titled “Render a whole toolkit”](#render-a-whole-toolkit)
```clojure
(require '[toolnexus.core :as toolnexus])
(def tk (toolnexus/build {:skills "examples/skills"}))
(toolnexus/to-openai tk)
;; => [{:type "function"
;; :function {:name "apply_patch"
;; :description "Apply an add/update/delete patch."
;; :parameters {:type "object"
;; :properties {:patchText {:type "string" …}}
;; :required ["patchText"]}}}
;; {:type "function" :function {:name "bash" …}}
;; …]
(map #(get-in % [:function :name]) (toolnexus/to-openai tk))
;; => ("apply_patch" "bash" "edit" "glob" "grep" "question" "read"
;; "skill" "todowrite" "webfetch" "write")
```
### One request, a hand-picked subset in a hand-picked order
[Section titled “One request, a hand-picked subset in a hand-picked order”](#one-request-a-hand-picked-subset-in-a-hand-picked-order)
```clojure
(require '[toolnexus.adapter :as adapter]
'[toolnexus.native :as native])
(def search
(native/native-tool {:name "search" :description "Search the catalog"
:input-schema {:type "object"
:properties {:q {:type "string"}}
:required ["q"]}
:run (fn [args] (str "results for " (:q args)))}))
(def refund
(native/native-tool {:name "refund" :description "Refund an order"
:run (fn [args] (str "refunded " (:order args)))}))
;; A vector keeps YOUR order — the toolkit form would sort these alphabetically.
(adapter/to-openai [search refund])
;; => [{:type "function" :function {:name "search"
;; :description "Search the catalog"
;; :parameters {:type "object"
;; :properties {:q {:type "string"}}
;; :required ["q"]}}}
;; {:type "function" :function {:name "refund"
;; :description "Refund an order"
;; :parameters {:type "object"}}}]
```
A tool declared without a description renders `""`, never `nil`, and one declared without a schema renders `{:type "object"}` — `toolnexus.tool/tool` fills both defaults in, so no adapter can emit a null into a provider payload.
### Closing the loop: model reply back to `execute`
[Section titled “Closing the loop: model reply back to execute”](#closing-the-loop-model-reply-back-to-execute)
```clojure
(require '[koine.json :as json]
'[toolnexus.core :as toolnexus])
(def tk (toolnexus/build {:skills "examples/skills"}))
;; What you send.
(def request {:model "gpt-4o" :messages [] :tools (toolnexus/to-openai tk)})
;; What comes back, per tool call: a name and a JSON string of arguments.
(defn run-tool-call [tk call]
(let [nm (get-in call ["function" "name"])
args (json/read-str (get-in call ["function" "arguments"]) {:key-fn keyword})
res (toolnexus/execute tk nm args)]
;; A failed tool is a message, not an exception — the model reads it and retries.
{:role "tool" :tool_call_id (get call "id") :content (:output res)}))
(run-tool-call tk {"id" "call_1"
"function" {"name" "skill" "arguments" "{\"name\":\"hello-world\"}"}})
;; => {:role "tool" :tool_call_id "call_1" :content "…"}
```
| Emitted key | Source |
| -------------------------- | ------------------------------------------------------------------- |
| `:type` | Always the literal `"function"`. |
| `:function` `:name` | The tool’s `:name`. |
| `:function` `:description` | The tool’s `:description`, `""` when unset. |
| `:function` `:parameters` | The tool’s `:input-schema` verbatim, `{:type "object"}` when unset. |
| Input | Order |
| ----------------------- | -------------------- |
| a toolkit | Sorted by tool name. |
| a seq of tools | Exactly as given. |
| an empty toolkit or seq | `[]`. |
## See also
[Section titled “See also”](#see-also)
* [`toolnexus.adapter/to-anthropic`](/api/clojure/adapters/anthropic/) — Render the toolkit as Anthropic tool-use schema.
* [`toolnexus.adapter/to-gemini`](/api/clojure/adapters/gemini/) — Render the toolkit as Gemini function-declaration schema.
* [`toolnexus.native/native-tool`](/api/clojure/native/define-tool/) — where the `:input-schema` that lands in `:parameters` comes from.
# toolnexus.a2a/remote-agent
> Point at a remote agent's card and use it exactly like a local tool. — Clojure.
**Clojure (JVM) + cljgo** · package `net.clojars.muthuishere/toolnexus` · [SPEC §7A](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [clojure/src/toolnexus/a2a.cljc](https://github.com/muthuishere/toolnexus/blob/main/clojure/src/toolnexus/a2a.cljc)
```clojure
(toolnexus.a2a/remote-agent
{:card "https://peer.example/.well-known/agent-card.json" ; required
:headers {"authorization" "Bearer ${PEER_TOKEN}"} ; ${ENV} expanded, never logged
:timeout 300000 ; ms, default 300000
:poll-every 1000}) ; ms, default 1000
;; card fetched
;; => {:card {:name "tn-agent" :url "https://peer.example/" :skills [...]}
;; :endpoint "https://peer.example/"
;; :tools [Tool ...]}
;; card could not be fetched — isolated, never thrown
;; => {:card nil :tools [] :error "HTTP 404: Not Found"}
```
`remote-agent` does one GET of an A2A Agent Card and turns every skill the card advertises into an ordinary `Tool`. The tool name is `sanitize(card name)` + `_` + `sanitize(skill id, else skill name)`, and each one takes the same one-field schema — an object with a required string `task`. To the toolkit and to the model, a remote agent is indistinguishable from a native tool.
Calling one of those tools sends a single JSON-RPC `SendMessage` to the card’s `url` (falling back to the card URL’s own `scheme://host:port` when the card omits it), then polls `GetTask` every `:poll-every` ms until the task reaches `completed`, `failed` or `canceled`, the `:timeout` budget runs out, or the context’s abort predicate fires. A completed task’s output is every `kind: "text"` part across `artifacts[].parts[]` joined with newlines; with no artifacts it falls back to the last `role: "agent"` history message.
The failure posture is the load-bearing part: this function **never throws**. An unreachable peer, a non-2xx card response, or a card that is not JSON all come back as a map carrying `:error` with an empty `:tools`, so a dead peer costs you its tools and nothing else. The same rule holds during execution — a transport failure mid-poll becomes an error `ToolResult` built with `toolnexus.tool/failure`, not an exception thrown into your loop.
Every result, success or failure, carries metadata: the agent name, task id, last state, poll count and elapsed ms. `:polls` counts successful `GetTask` responses only — the opening `SendMessage` is not a poll — and `:ms` is elapsed wall time, while the timeout *message* quotes the configured budget.
## When to use it
[Section titled “When to use it”](#when-to-use-it)
* **Delegating to another process or another language.** A peer running the Python or Go port — or any A2A-conforming agent — becomes callable without sharing a runtime.
* **You need the card, not just the tools.** Registry views, health checks and “what can this peer actually do” pages want `:card` and `:endpoint` alongside the tools.
* **You want a dead peer to be visible.** `:error` is the only place a fetch failure is reported; the tools list simply goes empty.
## Why this and not the alternative
[Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative)
Use agent-tools when you only want the tools
[`toolnexus.a2a/agent-tools`](/api/clojure/agents/agent-tools/) is `(:tools (remote-agent opts))` and nothing more. Reach for it when you are splicing peers straight into `:tools` on [`toolnexus.core/build`](/api/clojure/toolkit/create/) and have no use for the card or the error string. Use `remote-agent` when a failed peer has to be *noticed* rather than silently absent.
The name is `remote-agent`, not `agent`, deliberately: `clojure.core/agent` exists on both hosts and this port never shadows a core name — on cljgo a core-shaped symbol can make the static interop scan reject the whole namespace, not just the one function.
## Examples
[Section titled “Examples”](#examples)
### Attach a peer’s skills to your own toolkit
[Section titled “Attach a peer’s skills to your own toolkit”](#attach-a-peers-skills-to-your-own-toolkit)
```clojure
(require '[toolnexus.a2a :as a2a]
'[toolnexus.core :as core])
(let [peer (a2a/remote-agent {:card "http://127.0.0.1:8080/.well-known/agent-card.json"})]
(when (:error peer)
(println "peer unavailable:" (:error peer)))
(core/build {:skills "examples/skills"
:tools (:tools peer)}))
```
The toolkit is built either way. With the peer down you get your local tools plus a printed reason; with it up you additionally get one tool per advertised skill.
If you do not need the reason, `:agents` on [`toolnexus.core/build`](/api/clojure/toolkit/create/) resolves descriptors for you — `(core/build {:agents [{:card "..."}]})` — and it also reads a top-level `agents` block off a parsed `:mcp` config map.
### Call a peer tool and read the task metadata
[Section titled “Call a peer tool and read the task metadata”](#call-a-peer-tool-and-read-the-task-metadata)
```clojure
(require '[toolnexus.a2a :as a2a]
'[toolnexus.tool :as tool])
(let [peer (a2a/remote-agent {:card "http://127.0.0.1:8080/.well-known/agent-card.json"
:timeout 30000
:poll-every 200})
tk (tool/toolkit (:tools peer))
nm (first (tool/tool-names tk))
r (tool/execute tk nm {:task "summarise the release notes"})]
(println (:output r))
(println (:isError r))
;; {:agent "tn-agent" :taskId "..." :state "completed" :polls 3 :ms 640}
(println (:metadata r)))
```
A `failed` or `canceled` task comes back as a `toolnexus.tool/failure` whose output reads `A2A task failed: `; a timeout reads `A2A task timed out after 30000ms (state=working)`.
### Authenticated peer, cancelled from the outside
[Section titled “Authenticated peer, cancelled from the outside”](#authenticated-peer-cancelled-from-the-outside)
```clojure
(require '[toolnexus.a2a :as a2a]
'[toolnexus.tool :as tool])
;; PEER_TOKEN is read from the environment at call time and never logged.
(def peer
(a2a/remote-agent {:card "https://peer.example/.well-known/agent-card.json"
:headers {"authorization" "Bearer ${PEER_TOKEN}"}
:timeout 120000}))
(def cancelled? (atom false))
(tool/execute (tool/toolkit (:tools peer))
(:name (first (:tools peer)))
{:task "long running job"}
{:aborted? (fn [] @cancelled?)})
;; setting cancelled? to true resolves to "A2A task canceled",
;; with metadata :state "canceled" — the local verdict, not the peer's.
```
## Options
[Section titled “Options”](#options)
| Key | Default | Meaning |
| ------------- | -------- | -------------------------------------------------------------------------------------------------------------- |
| `:card` | — | Required. URL of the peer’s Agent Card. |
| `:headers` | none | Sent on the card GET and on every JSON-RPC call. Values expand `${ENV_VAR}`; never logged. |
| `:timeout` | `300000` | Per-request budget in ms, and the overall task budget. |
| `:poll-every` | `1000` | Delay between `GetTask` polls, in ms. `:pollEvery` is accepted as an alias, matching the config-file spelling. |
### What you get back
[Section titled “What you get back”](#what-you-get-back)
| Key | Type | What it is |
| ----------- | ----------- | --------------------------------------------------------------------------------------------------- |
| `:card` | map / `nil` | The decoded Agent Card, `nil` when the fetch failed. |
| `:endpoint` | string | The JSON-RPC endpoint: the card’s `url`, else the card URL’s origin. |
| `:tools` | vector | One `Tool` per advertised skill, `:source "a2a"`. Empty on failure. |
| `:error` | string | Present **only** on failure — `HTTP : `, `HTTP transport `, or `bad card: ...`. |
### Result metadata (on every call)
[Section titled “Result metadata (on every call)”](#result-metadata-on-every-call)
| Key | What it is |
| --------- | ------------------------------------------------------------- |
| `:agent` | The card’s `name`. |
| `:taskId` | The remote task id; `""` when `SendMessage` itself failed. |
| `:state` | The task’s last known state; `"canceled"` on a local abort. |
| `:polls` | Successful `GetTask` responses. `SendMessage` does not count. |
| `:ms` | Elapsed wall time. |
## See also
[Section titled “See also”](#see-also)
* [`toolnexus.a2a/agent-tools`](/api/clojure/agents/agent-tools/) — the tools-only shorthand
* [`toolnexus.a2a/parse-agents-config`](/api/clojure/agents/parse-config/) — declare peers in config, the way `mcpServers` are declared
* [`toolnexus.serve/serve`](/api/clojure/serve/a2a/) — the other direction: be the peer
# toolnexus.a2a/agent-tools
> Expand a remote agent card into one tool per advertised skill. — Clojure.
**Clojure (JVM) + cljgo** · package `net.clojars.muthuishere/toolnexus` · [SPEC §7A](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [clojure/src/toolnexus/a2a.cljc](https://github.com/muthuishere/toolnexus/blob/main/clojure/src/toolnexus/a2a.cljc)
```clojure
(toolnexus.a2a/agent-tools
{:card "https://peer.example/.well-known/agent-card.json" ; required
:headers {"authorization" "Bearer ${PEER_TOKEN}"}
:timeout 300000
:poll-every 1000})
;; => [Tool ...] one per advertised skill, :source "a2a"
;; => [] the card could not be fetched — the reason is discarded
```
`agent-tools` is exactly `(:tools (remote-agent opts))`. It takes the same options, fetches the same card, and produces the same tools — it just throws the card and the error string away and hands you the vector, which is the shape `:tools` on [`toolnexus.core/build`](/api/clojure/toolkit/create/) already wants.
Each returned tool is named `sanitize(card name)_sanitize(skill id)` and takes an object with a required string `task`. Executing one runs the full §7A cycle: one `SendMessage`, then `GetTask` polling until a terminal state, a timeout or an abort. Everything documented on [`remote-agent`](/api/clojure/agents/agent/) about output strings, metadata and error isolation applies unchanged, because it is the same code path.
The isolation guarantee is what makes the one-liner safe. A peer that is down, slow to hand back its card, or serving malformed JSON contributes `[]` — never a thrown exception, never a half-built toolkit. `into` a vector of peers and the toolkit is whatever peers were actually reachable at startup.
## When to use it
[Section titled “When to use it”](#when-to-use-it)
* **Composing a toolkit in one expression** — peers spliced into `:tools` next to your native and HTTP tools, with no intermediate bindings.
* **Fan-out over several peers** — `(into [] (mapcat agent-tools) peers)` collects every reachable peer’s skills and quietly drops the rest.
* **You have already checked liveness elsewhere** — a health endpoint or a startup probe is reporting peer status, so the per-call `:error` is redundant.
## Why this and not the alternative
[Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative)
Use remote-agent when silence is the wrong answer
`agent-tools` cannot tell you *why* a peer produced nothing — an empty vector means “no skills advertised” and “the host is unreachable” identically. If a missing peer should be logged, alerted on, or surfaced in a status page, call [`toolnexus.a2a/remote-agent`](/api/clojure/agents/agent/) and read `:error`. Use `agent-tools` only where a quiet degrade is the behaviour you actually want.
Both functions fetch the card at call time, so neither is free — call them once at startup and reuse the tools, rather than per request.
## Examples
[Section titled “Examples”](#examples)
### One peer, spliced into a toolkit
[Section titled “One peer, spliced into a toolkit”](#one-peer-spliced-into-a-toolkit)
```clojure
(require '[toolnexus.a2a :as a2a]
'[toolnexus.core :as core])
(def tk
(core/build {:mcp "examples/mcp.json"
:skills "examples/skills"
:tools (a2a/agent-tools
{:card "http://127.0.0.1:8080/.well-known/agent-card.json"})}))
(core/tool-names tk)
```
### Several peers, reachable ones win
[Section titled “Several peers, reachable ones win”](#several-peers-reachable-ones-win)
```clojure
(require '[toolnexus.a2a :as a2a]
'[toolnexus.core :as core])
(def peers
[{:card "http://127.0.0.1:8080/.well-known/agent-card.json"}
{:card "http://127.0.0.1:8081/.well-known/agent-card.json" :timeout 5000}
;; deliberately dead — contributes [] and nothing else
{:card "http://127.0.0.1:9/.well-known/agent-card.json" :timeout 250}])
(def tk (core/build {:tools (into [] (mapcat a2a/agent-tools) peers)}))
```
Two peers up and one down yields the union of the two live catalogs. Note that §7A tool names collide the moment two cards share a `name`, and a later tool wins the collision — give your peers distinct card names.
### Peers from a config block
[Section titled “Peers from a config block”](#peers-from-a-config-block)
```clojure
(require '[toolnexus.a2a :as a2a]
'[toolnexus.core :as core]
'[koine.fs :as fs]
'[koine.json :as json])
(def cfg (json/read-str (fs/read-file "config.json")))
(def tk
(core/build {:tools (into [] (mapcat a2a/agent-tools)
(a2a/parse-agents-config (:agents cfg)))}))
```
[`parse-agents-config`](/api/clojure/agents/parse-config/) normalises the block into exactly the descriptors `agent-tools` accepts, drops disabled entries, and orders peers by name so two runtimes cannot disagree about who registered first.
Most of the time you do not need either call: [`toolnexus.core/build`](/api/clojure/toolkit/create/) takes descriptors on `:agents` and resolves them itself — and reads a top-level `agents` block straight off a parsed `:mcp` config map.
```clojure
(core/build {:agents [{:card "http://127.0.0.1:8080/.well-known/agent-card.json"}]})
(core/build {:mcp cfg}) ;; picks up cfg's "mcpServers" AND its "agents" block
```
## Options
[Section titled “Options”](#options)
Identical to [`remote-agent`](/api/clojure/agents/agent/):
| Key | Default | Meaning |
| ------------- | -------- | ---------------------------------------------------------------------- |
| `:card` | — | Required. URL of the peer’s Agent Card. |
| `:headers` | none | Card GET and every JSON-RPC call. `${ENV_VAR}` expanded, never logged. |
| `:timeout` | `300000` | Per-request and overall task budget, in ms. |
| `:poll-every` | `1000` | `GetTask` interval in ms. `:pollEvery` also accepted. |
### What you get back
[Section titled “What you get back”](#what-you-get-back)
| Shape | When |
| ------------ | ------------------------------------------------------------------------------------ |
| `[Tool ...]` | The card was fetched. One tool per entry in the card’s `skills`, in card order. |
| `[]` | The card had no skills, **or** the fetch failed. The two are indistinguishable here. |
## See also
[Section titled “See also”](#see-also)
* [`toolnexus.a2a/remote-agent`](/api/clojure/agents/agent/) — the same call, plus `:card`, `:endpoint` and `:error`
* [`toolnexus.a2a/parse-agents-config`](/api/clojure/agents/parse-config/) — turn a config block into descriptors
* [`toolnexus.core/build`](/api/clojure/toolkit/create/) — where the tools go
# toolnexus.a2a/parse-agents-config
> Declare remote peers in config the way MCP servers are declared, with precedence rules. — Clojure.
**Clojure (JVM) + cljgo** · package `net.clojars.muthuishere/toolnexus` · [SPEC §7A](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [clojure/src/toolnexus/a2a.cljc](https://github.com/muthuishere/toolnexus/blob/main/clojure/src/toolnexus/a2a.cljc)
```clojure
(toolnexus.a2a/parse-agents-config
{:planner {:card "http://127.0.0.1:8080/.well-known/agent-card.json"
:headers {"authorization" "Bearer ${PEER_TOKEN}"}
:timeout 30000
:pollEvery 250}
:retired {:card "http://127.0.0.1:8082/.well-known/agent-card.json"
:disabled true}})
;; => [{:card "http://127.0.0.1:8080/.well-known/agent-card.json"
;; :headers {"authorization" "Bearer ${PEER_TOKEN}"}
;; :timeout 30000
;; :poll-every 250}]
```
`parse-agents-config` reads the top-level `agents` block of a parsed config object and returns a vector of descriptors — exactly the maps [`remote-agent`](/api/clojure/agents/agent/) and [`agent-tools`](/api/clojure/agents/agent-tools/) accept. It is pure: no network, no card fetched, nothing connected. Feed the result to either of those to actually reach the peers.
The block is a **name → descriptor** map, mirroring `mcpServers` deliberately, and the same two disable spellings apply: `:disabled true` and `:enabled false` both drop an entry. So does an entry whose `:card` is missing or is not a string — that is not an agent, and §0.3’s isolation rule reaches config parsing too, so it is skipped rather than raised.
The output is **ordered by name**, not by insertion. Two runtimes reading the same config must not disagree about which peer registered first, because §7A tool names are derived from the card’s own `name` and the later registration wins a collision. Sorting by key makes that deterministic across Clojure and cljgo.
Config files spell the poll interval `pollEvery`; Clojure code spells it `:poll-every`. Both are read on the way in, and the descriptor always comes out kebab-case.
## When to use it
[Section titled “When to use it”](#when-to-use-it)
* **Peers live in a config file** — the same document that carries `mcpServers` can carry an `agents` block, parsed here.
* **Feature-flagging a peer off** — `:disabled true` stops an entry producing tools without deleting it from the config.
* **Validating before connecting** — the parse is offline, so a startup check can assert the shape of the block without a single network call.
## Why this and not the alternative
[Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative)
Build reads the block for you
[`toolnexus.core/build`](/api/clojure/toolkit/create/) already calls this on the `agents` key of a parsed `:mcp` config map, and its `:agents` option takes descriptors directly — so `(core/build {:mcp cfg})` picks up an `agents` block in the same document with no extra call. Reach for `parse-agents-config` when you want the descriptors as *data*: to inspect them, to log the peer list, to attach headers programmatically, or to resolve peers through [`remote-agent`](/api/clojure/agents/agent/) so a failure is visible.
It never fetches, so a descriptor coming out of here says nothing about whether the peer is alive. Liveness is `remote-agent`’s `:error`.
## Examples
[Section titled “Examples”](#examples)
### One config document, MCP servers and peers together
[Section titled “One config document, MCP servers and peers together”](#one-config-document-mcp-servers-and-peers-together)
```clojure
(require '[toolnexus.core :as core]
'[koine.json :as json]
'[koine.fs :as fs])
(def cfg (json/read-str (fs/read-file "config.json")))
;; {"mcpServers": {...}, "agents": {"planner": {"card": "..."}}}
;; build reads BOTH blocks off the same parsed map
(def tk (core/build {:mcp cfg}))
```
To take the peers but not the MCP servers, parse the block yourself and pass descriptors:
```clojure
(require '[toolnexus.a2a :as a2a]
'[toolnexus.core :as core])
(def tk (core/build {:agents (a2a/parse-agents-config (:agents cfg))}))
```
### Everything unusable is dropped, never raised
[Section titled “Everything unusable is dropped, never raised”](#everything-unusable-is-dropped-never-raised)
```clojure
(require '[toolnexus.a2a :as a2a])
(a2a/parse-agents-config nil) ;; => []
(a2a/parse-agents-config {}) ;; => []
(a2a/parse-agents-config {:nocard {:timeout 1}}) ;; => []
(a2a/parse-agents-config {:off {:card "http://a" :disabled true}}) ;; => []
(a2a/parse-agents-config {:off {:card "http://a" :enabled false}}) ;; => []
```
A malformed `agents` block degrades to “no peers”, which is still a working toolkit. Log the count yourself if a silently empty peer list would be a surprise.
### Deterministic ordering, then resolve
[Section titled “Deterministic ordering, then resolve”](#deterministic-ordering-then-resolve)
```clojure
(require '[toolnexus.a2a :as a2a])
(mapv :card (a2a/parse-agents-config {:zeta {:card "z-card"}
:mid {:card "m-card"}
:alfa {:card "c-card"}}))
;; => ["c-card" "m-card" "z-card"] sorted by the entry NAME: alfa, mid, zeta
;; resolve them with the failure reason visible, rather than silently empty
(doseq [d (a2a/parse-agents-config (:agents cfg))]
(let [peer (a2a/remote-agent d)]
(println (:card d)
(if (:error peer)
(str "DOWN: " (:error peer))
(str "ok, " (count (:tools peer)) " tools")))))
```
## Config keys
[Section titled “Config keys”](#config-keys)
| Key | Effect |
| ---------------------------- | ------------------------------------------------------------------------ |
| `:card` | Required, must be a string. Absent or non-string ⇒ the entry is skipped. |
| `:headers` | Copied through verbatim; `${ENV_VAR}` is expanded later, at call time. |
| `:timeout` | Copied through as `:timeout`. |
| `:pollEvery` / `:poll-every` | Either spelling is read; the descriptor always carries `:poll-every`. |
| `:disabled` | `true` skips the entry. |
| `:enabled` | `false` skips the entry. |
Keys other than these are not carried onto the descriptor.
### What you get back
[Section titled “What you get back”](#what-you-get-back)
| Shape | When |
| ---------------------------------------------------------------- | ------------------------------------------------------------------------------ |
| `[{:card ... :headers? ... :timeout? ... :poll-every? ...} ...]` | Sorted by entry name. Optional keys appear only when the config supplied them. |
| `[]` | The argument was not a map, was empty, or every entry was skipped. |
## See also
[Section titled “See also”](#see-also)
* [`toolnexus.a2a/agent-tools`](/api/clojure/agents/agent-tools/) — turn each descriptor into tools
* [`toolnexus.a2a/remote-agent`](/api/clojure/agents/agent/) — same, plus the card and the failure reason
* [`toolnexus.core/build`](/api/clojure/toolkit/create/) — the `:agents` option, and the automatic read off `:mcp`
* [`toolnexus.mcp/parse-config`](/api/clojure/mcp/parse-config/) — the block this one mirrors
# toolnexus.builtin/background-shells
> Opt-in background shells: bash gains run_in_background, bash_output reads new output, kill_shell stops the process group. The host owns the registry and closes it. — Clojure.
**Clojure (JVM) + cljgo** · package `net.clojars.muthuishere/toolnexus` · [SPEC §4A](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [clojure/src/toolnexus/builtin.cljc](https://github.com/muthuishere/toolnexus/blob/main/clojure/src/toolnexus/builtin.cljc)
```clojure
(def bg (builtin/background-shells)) ; an empty registry; the HOST owns it
(builtin/builtin-tools-for {:background bg}) ; bash gains run_in_background; adds bash_output, kill_shell
(builtin/bg-list bg) ; => [{:id :command :started :running :exit-code :killed} …]
(builtin/bg-kill! bg "bg1")
(builtin/bg-close-all! bg) ; on shutdown — background jobs are not tied to a turn
```
With `:background`, `bash {:command … :run_in_background true}` starts the command and returns at once with a background id (in metadata `:backgroundId`). `bash_output {:id}` returns the output produced since the last read plus `[status: running|killed|exited with code N]`. `kill_shell {:id}` stops the whole process group: SIGTERM, `:kill-grace-ms` (default 2000), SIGKILL. A shell killed by a signal it did not get from `kill_shell` reports `exited with code -1` (and, as the runtime cannot tell the difference, so does a command that itself exits 129..159). POSIX only.
## When to use it
[Section titled “When to use it”](#when-to-use-it)
* **Dev servers and watchers** — start, read output later, stop.
* **Long builds** — the model keeps working while the job runs.
## Why this and not the alternative
[Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative)
Plain bash when the command finishes
Foreground `bash` waits, bounds the run with a timeout, and is killed with the turn. Use background shells only for commands that must outlive the call, and remember the host must `bg-close-all!`.
## Examples
[Section titled “Examples”](#examples)
### 1. Start, read, finish
[Section titled “1. Start, read, finish”](#1-start-read-finish)
```clojure
(require '[toolnexus.builtin :as builtin]
'[toolnexus.tool :as tool]
'[koine.time :as ktime])
(println)
(def bg (builtin/background-shells))
(def tk (tool/toolkit (builtin/builtin-tools-for {:background bg})))
(def started (tool/execute tk "bash" {:command "echo hello-bg" :run_in_background true}))
(def id (get-in started [:metadata :backgroundId]))
(assert (string? id))
(def out (loop [n 0]
(let [o (:output (tool/execute tk "bash_output" {:id id}))]
(if (or (re-find #"exited" o) (> n 200)) o (do (ktime/sleep! 20) (recur (inc n)))))))
(println out)
(assert (re-find #"exited with code 0" out))
(builtin/bg-close-all! bg)
(println "OK")
```
### 2. Stop a long job
[Section titled “2. Stop a long job”](#2-stop-a-long-job)
```clojure
(def s (tool/execute tk "bash" {:command "sleep 600" :run_in_background true}))
(tool/execute tk "kill_shell" {:id (get-in s [:metadata :backgroundId])})
```
### 3. Owned by the host, closed on shutdown
[Section titled “3. Owned by the host, closed on shutdown”](#3-owned-by-the-host-closed-on-shutdown)
```clojure
(def bg (builtin/background-shells))
(def llm-toolkit (tool/toolkit (builtin/builtin-tools-for {:background bg :kill-grace-ms 500})))
;; … run turns …
(builtin/bg-close-all! bg)
```
## See also
[Section titled “See also”](#see-also)
* [`builtins`](/api/clojure/builtins/create/) — the other opt-in builtins (`:web-search`, `:notebook-edit`).
# toolnexus.builtin/builtin-tools
> Construct every built-in tool directly, outside a toolkit. — Clojure.
**Clojure (JVM) + cljgo** · package `net.clojars.muthuishere/toolnexus` · [SPEC §4A](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [clojure/src/toolnexus/builtin.cljc](https://github.com/muthuishere/toolnexus/blob/main/clojure/src/toolnexus/builtin.cljc)
```clojure
builtin-tools ; a VECTOR of ten Tools, in SPEC §4A table order — not a constructor
builtin-names ; ["bash" "read" "write" "edit" "grep" "glob"
; "webfetch" "question" "apply_patch" "todowrite"]
;; each element is an ordinary Tool map
{:name "read" :description "Read a file. A known media file (png/jpg/…) comes back as a content part; …"
:input-schema {:type "object" :properties {…} :required ["path"]}
:source "builtin" :execute (fn ([args]) ([args ctx]))}
;; the schemas are public values too
bash-schema read-schema write-schema edit-schema grep-schema
glob-schema webfetch-schema question-schema apply-patch-schema todowrite-schema
```
`builtin-tools` is a `def`, not a function. There is nothing to construct: a Tool here is the same plain map every other source produces, so the ten built-ins are simply a vector you can filter, inspect, or hand to `toolnexus.tool/toolkit` yourself.
That makes the built-in set inspectable in ways a constructor would not be. The `:input-schema` of each tool is the §4A contract — the thing the model actually sees — and you can read it, diff it against another port, or assert on it in a test without running anything.
Executing one directly is `((:execute t) args)` or `((:execute t) args ctx)`. Both arities exist on every builtin. Going through `toolnexus.tool/execute` instead adds the §0.8 boundary rule, which converts a throw into an error ToolResult rather than letting it escape into your loop — prefer it unless you are deliberately testing the raw closure.
## When to use it
[Section titled “When to use it”](#when-to-use-it)
* **Assembling a toolkit by hand** — a subset of builtins plus your own tools, without going through `toolnexus.core/build`.
* **Reading the schemas** — checking what §4A actually pins for `edit` or `webfetch`, or wiring the same schema into something else.
* **Calling one tool without an LLM** — a script that wants the `glob` or `apply_patch` implementation and nothing else.
* **Conformance work** — comparing the ten names, descriptions and schemas against another port.
## Why this and not the alternative
[Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative)
Use enabled-builtins when a config decides the set
[`toolnexus.builtin/enabled-builtins`](/api/clojure/builtins/select/) applies the SPEC §0.11 toggle — the whole-source gate and the per-tool drop-list — and returns the surviving subset. `builtin-tools` is the unfiltered baseline it filters. If a config value is choosing for you, use `enabled-builtins`; if you are choosing in code, filter this vector.
Nothing here is lazy or stateful. The vector is built once at namespace load; the tools close over no session, hold no handle, and need no shutdown.
## Examples
[Section titled “Examples”](#examples)
### Look at the set, then execute one directly
[Section titled “Look at the set, then execute one directly”](#look-at-the-set-then-execute-one-directly)
```clojure
(require '[toolnexus.builtin :as builtin]
'[toolnexus.tool :as tool])
builtin/builtin-names
;; => ["bash" "read" "write" "edit" "grep" "glob" "webfetch" "question" "apply_patch" "todowrite"]
(count builtin/builtin-tools) ;=> 10
(:source (first builtin/builtin-tools)) ;=> "builtin"
(def read-tool (first (filter #(= "read" (:name %)) builtin/builtin-tools)))
(:input-schema read-tool)
;; => {:type "object"
;; :properties {:path {:type "string" :description "Path to the file to read"}
;; :offset {:type "number" :description "1-based line to start from"}
;; :limit {:type "number" :description "Number of lines to read"}}
;; :required ["path"]}
((:execute read-tool) {:path "README.md" :offset 1 :limit 3})
;; => {:output "# toolnexus — Clojure\n\nOne `.cljc` source tree, two runtimes…" :isError false}
;; A missing file is a value the model can read, not an exception.
((:execute read-tool) {:path "nope.md"})
;; => {:output "read: file not found: nope.md" :isError true}
```
### A toolkit of exactly the tools you want
[Section titled “A toolkit of exactly the tools you want”](#a-toolkit-of-exactly-the-tools-you-want)
```clojure
(require '[toolnexus.builtin :as builtin]
'[toolnexus.native :as native]
'[toolnexus.tool :as tool])
(def safe-names #{"read" "grep" "glob"})
(def whoami
(native/native-tool {:name "whoami" :description "Who this agent runs as"
:run (fn [_] "reporting-bot")}))
;; Builtins first, yours after — later tools win a name collision, which is
;; how §0.11's precedence rule is expressed.
(def tk (tool/toolkit (conj (vec (filter #(contains? safe-names (:name %))
builtin/builtin-tools))
whoami)))
(tool/tool-names tk) ;=> ["glob" "grep" "read" "whoami"]
(:output (tool/execute tk "glob" {:pattern "**/*.md" :path "docs"}))
```
### The two builtins that do not just return a string
[Section titled “The two builtins that do not just return a string”](#the-two-builtins-that-do-not-just-return-a-string)
```clojure
(require '[koine.json :as json]
'[toolnexus.builtin :as builtin]
'[toolnexus.tool :as tool])
(def tk (tool/toolkit builtin/builtin-tools))
;; `question` suspends: metadata.pending carries a §10 Request, and the client
;; loop resumes the call with ctx.answer once a human has replied.
(def asked (tool/execute tk "question" {:questions [{:question "Ship it?"
:options ["yes" "no"]}]}))
(:isError asked) ;=> true
(get-in asked [:metadata :pending :kind]) ;=> "question"
(get-in asked [:metadata :pending :prompt]) ;=> "Ship it? (options: yes, no)"
;; Re-executed with an answer in the Context, it resolves to that answer.
(:output (tool/execute tk "question"
{:questions [{:question "Ship it?"}]}
{:answer {:ok true :data {:choice "yes"}}}))
;; => "{\"choice\":\"yes\"}"
;; `apply_patch` plans every file change from reads before writing anything,
;; so a hunk that does not match leaves the tree untouched.
(:output (tool/execute tk "apply_patch"
{:patchText "*** Begin Patch\n*** Add File: notes.md\n+hello\n*** End Patch\n"}))
;; => "Applied 1 file change(s): add notes.md"
```
| Tool | What it does | Notes |
| ------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
| `bash` | Runs a shell command, combined stdout+stderr. | `timeout` in **milliseconds**, default 60000. A timeout and a non-zero exit are both `isError`. |
| `read` | Reads a file. A known media file (`png jpg jpeg gif webp pdf mp3 wav`) comes back as a [content part](/api/clojure/types/content-part/) under `:parts`; anything else is read as UTF-8 text. | Optional 1-based `offset` and `limit` on the text path. Bytes that are not decodable UTF-8 give an `isError` result rather than mojibake. |
| `write` | Writes a file, creating parent directories. | Returns the UTF-8 byte count written. |
| `edit` | Exact-string replace. | Refuses a non-unique `oldString` unless `replaceAll` is true. |
| `grep` | Regex search over file contents. | Dialect differs per host — `java.util.regex` versus Go RE2 — so only the common subset is portable. |
| `glob` | Lists files matching a glob. | `**` crosses separators, `*` and `?` do not. |
| `webfetch` | HTTP GET, returned as `markdown`, `text` or `html`. | `timeout` in **seconds**, default 30. |
| `question` | Asks the human. | Suspends via `metadata.pending`; see above. |
| `apply_patch` | Add / update / delete files from a patch. | Atomic: all changes are planned from reads first. |
| `todowrite` | Replaces the session todo list. | Stateless in this version — it echoes the list back. |
Two rendering details are this port's choice, not the contract
§4A pins the tool names and input schemas, not every byte of output. `webfetch`’s HTML-to-markdown conversion and `todowrite`’s `- [x]` rendering are unpinned, so they are this port’s decision and are not guaranteed to match the other six. Both are reported upstream. Everything in the schemas is contract.
## New in 0.22.0
[Section titled “New in 0.22.0”](#new-in-0220)
* **grep context.** `grep` takes `context`, `before` and `after` line counts: matches print as `path:N:text`, context lines as `path-N-text`, and `--` separates groups that do not touch. Absent, the output is unchanged.
* **Truncation metadata.** A `read` window that stops before the end sets `:truncated`, `:totalLines` and `:nextOffset` in the result metadata; a `grep` cut by `limit` sets `:truncated` and `:total`. The text itself is the same bytes as before.
* **Hints.** Every builtin carries `:hints` (`read`, `grep`, `glob`, `question`, `todowrite` read-only; `webfetch` read-only and open-world; `write`, `edit`, `apply_patch` destructive; `bash` destructive and open-world) for a [permission gate](/api/clojure/client/hooks/).
* **Opt-in builtins**, absent unless configured: `:background (builtin/background-shells)` adds `run_in_background`, `bash_output`, `kill_shell` ([page](/api/clojure/builtins/background-shells/)); `:web-search (fn [query limit] [{:title :url :snippet}])` adds `websearch` over a provider you supply (the library ships none); `:notebook-edit true` adds `notebook_edit` for `.ipynb` cells (replace / insert / delete by id or index).
```clojure
(builtin/builtin-tools-for
{:web-search (fn [query limit] (my-search query limit))
:notebook-edit true})
```
## See also
[Section titled “See also”](#see-also)
* [`toolnexus.builtin/enabled-builtins`](/api/clojure/builtins/select/) — Pick which built-in file/shell/web tools are in play, by name or by source toggle.
* [`toolnexus.native/native-tool`](/api/clojure/native/define-tool/) — the same Tool shape, for code of your own.
# toolnexus.builtin/enabled-builtins
> Pick which built-in file/shell/web tools are in play, by name or by source toggle. — Clojure.
**Clojure (JVM) + cljgo** · package `net.clojars.muthuishere/toolnexus` · [SPEC §4A](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [clojure/src/toolnexus/builtin.cljc](https://github.com/muthuishere/toolnexus/blob/main/clojure/src/toolnexus/builtin.cljc)
```clojure
(enabled-builtins opt) ; => [Tool …] the builtins this config exposes
(enabled-builtin-names opt) ; => ["bash" "read" …]
(source-on? opt) ; => true | false the whole-source gate
(builtin-toolkit opt) ; => {:tools {…} :sources {"builtin" "connected" | "disabled"}}
;; opt — the SPEC §0.11 toggle, and the same value :builtins takes on
;; toolnexus.core/build
;; nil | true ; absent => on
;; false ; whole source off
;; {:disabled true} ; whole source off
;; {:enabled false} ; whole source off
;; {:tools {:bash false}} ; per-tool drop-list over the all-on baseline
```
`enabled-builtins` answers one question: given this configuration, which of the ten built-in tools exist? It is a pure function over a toggle value, returning the tools themselves in SPEC table order, so you can inspect the decision without building a toolkit around it.
The whole-source gate **short-circuits**. If `opt` turns the source off, the `:tools` map is never consulted at all — `{:disabled true :tools {:bash true}}` yields no tools, not one. `:disabled true` wins over `:enabled`, and an absent toggle means on.
`builtin-toolkit` is the same decision wrapped as a toolkit, with a `"builtin"` source status of `"connected"` or `"disabled"`. `toolnexus.core/build` calls it first, before skills, your own tools and MCP, because registration order is what implements §0.11’s rule that an MCP tool wins a name collision against a builtin of the same name.
## When to use it
[Section titled “When to use it”](#when-to-use-it)
* **Inspecting a policy before you apply it** — print `enabled-builtin-names` for a config and see exactly what the agent will be able to do.
* **Removing the dangerous ones** — drop `bash`, `write`, `edit` and `apply_patch` for a read-only agent while keeping `read`, `grep` and `glob`.
* **Turning the source off entirely** — an agent whose only tools should be your own or an MCP server’s.
* **Building the toolkit yourself** — `builtin-toolkit` when you are assembling sources by hand rather than through `toolnexus.core/build`.
## Why this and not the alternative
[Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative)
Pass :builtins to build unless you need the decision as a value
`(toolnexus.core/build {:builtins {:tools {:bash false}}})` applies the same toggle, in the right registration order, with the source status filled in. Call `enabled-builtins` directly when you want the tools as data — to assert on them in a test, to log them, or to hand a subset to a toolkit you are constructing yourself. See also [`toolnexus.builtin/builtin-tools`](/api/clojure/builtins/create/), the unfiltered vector.
## Examples
[Section titled “Examples”](#examples)
### The default, and turning the source off
[Section titled “The default, and turning the source off”](#the-default-and-turning-the-source-off)
```clojure
(require '[toolnexus.builtin :as builtin])
(builtin/enabled-builtin-names nil)
;; => ["bash" "read" "write" "edit" "grep" "glob" "webfetch" "question" "apply_patch" "todowrite"]
;; Three ways to say off, one result.
(builtin/enabled-builtins false) ;=> []
(builtin/enabled-builtins {:disabled true}) ;=> []
(builtin/enabled-builtins {:enabled false}) ;=> []
(builtin/source-on? {:disabled true}) ;=> false
;; The status is reported, not silent — a disabled source still shows up.
(:sources (builtin/builtin-toolkit false)) ;=> {"builtin" "disabled"}
```
Note the order: `enabled-builtins` returns tools in the SPEC §4A table order, while a toolkit’s `tool-names` are always sorted. Two different orders, both deliberate.
### A read-only agent
[Section titled “A read-only agent”](#a-read-only-agent)
```clojure
(require '[toolnexus.builtin :as builtin]
'[toolnexus.core :as toolnexus])
(def read-only
{:tools {:bash false :write false :edit false :apply_patch false}})
(builtin/enabled-builtin-names read-only)
;; => ["read" "grep" "glob" "webfetch" "question" "todowrite"]
;; The same value goes straight to build.
(def tk (toolnexus/build {:builtins read-only :skills "examples/skills"}))
(toolnexus/tool-names tk)
;; => ["glob" "grep" "question" "read" "skill" "todowrite" "webfetch"]
```
Keys in `:tools` are keywords matching the tool name. `apply_patch` and `todowrite` keep their underscore-free spellings exactly as §4A names them — `:apply_patch`, not `:apply-patch`.
### The one case that does not mean what it looks like
[Section titled “The one case that does not mean what it looks like”](#the-one-case-that-does-not-mean-what-it-looks-like)
```clojure
(require '[toolnexus.builtin :as builtin])
;; A `true` value does NOT make this an allowlist.
(builtin/enabled-builtin-names {:tools {:bash true}})
;; => ["bash" "read" "write" "edit" "grep" "glob" "webfetch" "question" "apply_patch" "todowrite"]
;; To get bash alone, drop the other nine.
(builtin/enabled-builtin-names
{:tools (zipmap (map keyword (remove #{"bash"} builtin/builtin-names)) (repeat false))})
;; => ["bash"]
```
| Toggle value | Result |
| ------------------------ | --------------------------------------------------------- |
| absent / `nil` / `true` | All ten tools. |
| `false` | No tools. `:tools` is not consulted. |
| `{:disabled true}` | No tools, and this wins over any `:enabled`. |
| `{:enabled false}` | No tools. |
| `{:tools {:name false}}` | Every tool except the false-mapped ones. |
| `{:tools {:name true}}` | Every tool. A `true` is a no-op over the all-on baseline. |
\`true\` in \`:tools\` is a drop-list no-op, and SPEC contradicts itself here
§4A and §0.11 define `:tools` as a name-to-boolean map applied on the all-on baseline: `false` drops, `true` or absent stays on. §3’s skills filter and §2’s MCP per-server filter describe themselves as identical to §4A but specify an allowlist instead — at least one `true` means only the true-mapped names survive. This port implements the §4A wording, because §4A owns builtins and §0.11 restates it. The contradiction is reported upstream; until it is resolved, write drop-lists here and do not assume the skills or MCP filters behave the same way.
## See also
[Section titled “See also”](#see-also)
* [`toolnexus.builtin/builtin-tools`](/api/clojure/builtins/create/) — Construct every built-in tool directly, outside a toolkit.
* [`toolnexus.native/native-tool`](/api/clojure/native/define-tool/) — adding tools of your own next to the builtins.
# ACP host hooks — permission, updates, cancel, stream
> Opt-in hooks on an ACP model source: choose the permission option, watch every session/update, cancel a turn, and stream through an in-process client. — Clojure.
**Clojure** · package `net.clojars.muthuishere/toolnexus` · [SPEC §8](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md)
Not available in Clojure
Go-only today (golang/acp.go). The other six ports ship an ACP client without these hooks; tracked in openspec/changes/add-go-host-asks task 2.6d.
Opt-in hooks on an ACP model source: choose the permission option, watch every session/update, cancel a turn, and stream through an in-process client.
## What to use instead
[Section titled “What to use instead”](#what-to-use-instead)
The Clojure ACP client (`toolnexus.acp`, see `clojure/examples/src/examples/acp_demo.cljc`) drives a coding agent over stdio through the in-process client seam, as in 0.21: permission requests are answered with the first allow option, only the agent’s message text forms the reply, and the turn cannot be streamed or cancelled mid-turn through a hook.
To gate what such an agent may do today, run it in a sandboxed working directory and configure the agent’s own permission settings; to keep a UI informed, show the final reply. Go has `ACPOptions.OnPermission`, `OnUpdate`, `ACPClient.GenerateCtx` and `GenerateStream` — see the [Go page](/api/go/client/acp-hooks/).
## See also
[Section titled “See also”](#see-also)
* [`toolnexus.client/create-client`](/api/clojure/client/create/) — The unified client: system prompt, skills injection, parallel and chained tool calls, retries, memory.
* [`toolnexus.client/run`](/api/clojure/client/run/) — Send a prompt, let the loop call tools until the model stops, get a RunResult.
* [`toolnexus.client/create-client`](/api/clojure/client/hooks/) — Intercept before/after model calls and tool calls: audit, redact, veto, or rewrite.
* [`toolnexus.client/run`](/api/clojure/client/conversation/) — Keep a transcript across turns so the model remembers what it already did.
# api-key-env
> Which key the client sends where: apiKey, else apiKeyEnv, else only the env var of the baseUrl host (https). A local or custom host needs one of the two. — Clojure.
**Clojure (JVM) + cljgo** · package `net.clojars.muthuishere/toolnexus` · [SPEC §8](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [clojure/src/toolnexus/client.cljc](https://github.com/muthuishere/toolnexus/blob/main/clojure/src/toolnexus/client.cljc)
```clojure
;; how create-client / list-models pick the key, in order:
;; 1. :api-key "…" used as-is
;; 2. :api-key-env "MY_LLM_KEY" the NAME of an env var, read at call time
;; 3. the env var of the :base-url host, https only:
;; https://api.openai.com -> OPENAI_API_KEY
;; https://api.anthropic.com -> ANTHROPIC_API_KEY
;; https://openrouter.ai (+ subdomains) -> OPENROUTER_API_KEY
;; anything else (a local server, a proxy, plain http) -> throws ex-info
;; {:toolnexus/error :config} at the first request
```
The key is never sent to a host it does not belong to. Before 0.22.0 a client with no `:api-key` took the first of `OPENAI_API_KEY` / `ANTHROPIC_API_KEY` / `OPENROUTER_API_KEY` that was set and sent it to whatever `:base-url` was configured, so an OpenRouter key could reach api.openai.com, or any key a local or third-party server. Now the environment is consulted only for the env var that matches the host.
BREAKING in 0.22.0
A client on a local or custom `:base-url` that relied on the old fallback now throws `no API key: set :api-key or :api-key-env …`. Set one of the two.
The value is resolved at call time, only ever reaches a request header, and never appears in a log, a result or an error message.
## When to use it
[Section titled “When to use it”](#when-to-use-it)
* **A local model server or a company proxy** — any host that is not OpenAI, Anthropic or OpenRouter needs `:api-key` or `:api-key-env`.
* **Several keys in one environment** — `:api-key-env` names exactly the one this client may use.
* **Never for the three known hosts** — leave both unset and the host’s own env var is read.
## Why this and not the alternative
[Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative)
Prefer :api-key-env over :api-key
`:api-key` takes the **value**, which then lives in your config map. `:api-key-env` takes only the **name** of the variable, so the secret stays in the process environment. Use `:api-key` when the key comes from a vault at runtime, or for a placeholder against a local server that ignores it.
## Examples
[Section titled “Examples”](#examples)
### 1. A custom host without a key fails before anything is sent
[Section titled “1. A custom host without a key fails before anything is sent”](#1-a-custom-host-without-a-key-fails-before-anything-is-sent)
```clojure
(require '[toolnexus.client :as client]
'[toolnexus.tool :as tool])
(println)
(def sent (atom 0))
(def llm (client/create-client
{:base-url "http://localhost:11434/v1" :model "llama3"
:http-client (fn [_ _ _] (swap! sent inc) {:status 500 :headers {} :body ""})}))
(def err (try (client/run llm "hi" {:toolkit (tool/toolkit [])}) nil
(catch Exception e e)))
(assert (some? err))
(assert (= :config (:toolnexus/error (ex-data err))))
(assert (re-find #":api-key-env" (ex-message err)))
(assert (zero? @sent) "nothing reached the server")
(println "OK")
```
### 2. `:api-key-env` names the variable — and an unset one says which
[Section titled “2. :api-key-env names the variable — and an unset one says which”](#2-api-key-env-names-the-variable--and-an-unset-one-says-which)
```clojure
(require '[toolnexus.client :as client]
'[toolnexus.tool :as tool])
(println)
(def llm (client/create-client
{:base-url "https://gateway.internal/v1" :model "gpt-4.1"
:api-key-env "TN_DOCS_SURELY_UNSET_KEY"
:http-client (fn [_ _ _] {:status 500 :headers {} :body ""})}))
(def err (try (client/run llm "hi" {:toolkit (tool/toolkit [])}) nil
(catch Exception e e)))
(assert (re-find #"TN_DOCS_SURELY_UNSET_KEY" (ex-message err)) "the error names the variable, never a value")
(println "OK")
```
### 3. An explicit key reaches the header, and only the header
[Section titled “3. An explicit key reaches the header, and only the header”](#3-an-explicit-key-reaches-the-header-and-only-the-header)
```clojure
(require '[toolnexus.client :as client]
'[toolnexus.tool :as tool]
'[koine.json :as json])
(println)
(def auth (atom nil))
(def llm (client/create-client
{:base-url "http://localhost:8080/v1" :model "local" :api-key "YOUR_KEY_HERE"
:http-client (fn [_url headers _body]
(reset! auth (get headers "authorization"))
{:status 200 :headers {}
:body (json/write-str {:choices [{:message {:role "assistant" :content "hi"}}]})})}))
(def r (client/run llm "hello" {:toolkit (tool/toolkit [])}))
(assert (= "hi" (:text r)))
(assert (= "Bearer YOUR_KEY_HERE" @auth))
(println "OK")
```
## See also
[Section titled “See also”](#see-also)
* [`toolnexus.client/create-client`](/api/clojure/client/create/) — every client option.
* [`toolnexus.client/list-models`](/api/clojure/client/list-models/) — follows the same key rules.
# toolnexus.client/run
> Keep a transcript across turns so the model remembers what it already did. — Clojure.
**Clojure (JVM) + cljgo** · package `net.clojars.muthuishere/toolnexus` · [SPEC §8](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [clojure/src/toolnexus/client.cljc](https://github.com/muthuishere/toolnexus/blob/main/clojure/src/toolnexus/client.cljc)
```clojure
(run client prompt {:toolkit tk
:conversation-id "user-42" ; memory is keyed by this
:history [{:role "user" :content "…"}]}) ; optional override
;; the seed the loop starts from, in precedence order:
;; 1. :history — explicit, always wins
;; 2. the client's :store — consulted only when :conversation-id is given
;; 3. nothing — a one-shot run
```
There is no `Conversation` type in this port and nothing to construct. Memory is a **key**: pass `:conversation-id` to `run` and the client’s [store](/api/clojure/client/store/) loads that id’s transcript before the run and saves the finished `:messages` after it. Reuse the id on the next call and the model sees everything that happened before.
The precedence rule is deliberate. An explicit `:history` always wins, because a caller that hands over a transcript has already decided what the model should see. The store is consulted **only** when an id is supplied — without one, a run is one-shot and must not inherit a previous run’s transcript, even from the same client.
The save happens on every exit path, including the `:max-turns` one. Wiring it into only the success path is the classic half-fix: a turn-limit exit would silently forget the conversation and the next turn would look like a fresh start with no error anywhere.
The default store is per-client and in-memory, so two clients never share a transcript by accident, and nothing survives a process restart until you supply your own.
## When to use it
[Section titled “When to use it”](#when-to-use-it)
* **A chat interface** — one id per user or per thread; the loop rebuilds context for you.
* **A multi-step operator session** — the model calls tools in turn 1 and must remember what it found in turn 3.
* **Not for a one-shot** — omit `:conversation-id` entirely for classification, summarisation or any request that should not see history.
## Why this and not the alternative
[Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative)
Pass :history when you own the transcript
If your application already persists messages — a database table, an event log, a resumed job — skip `:conversation-id` and pass `:history` directly. It wins over the store, so there is no ambiguity about which transcript the model saw, and you avoid keeping the same conversation in two places that can drift apart.
Use `:conversation-id` when you want the client to be the one that remembers, and `:history` when you want to be. Mixing them is legal but pointless: `:history` simply overrides.
## Examples
[Section titled “Examples”](#examples)
### Two turns that remember each other
[Section titled “Two turns that remember each other”](#two-turns-that-remember-each-other)
```clojure
(require '[toolnexus.core :as tn]
'[toolnexus.client :as client])
(def toolkit (tn/build {:skills "./skills"}))
(def llm (client/create-client {:base-url "https://api.openai.com/v1"
:model "gpt-4.1"}))
(client/run llm "My deploy target is staging-eu."
{:toolkit toolkit :conversation-id "ops-session-1"})
;; turn 2 carries turn 1's messages — that is what memory IS
(let [r (client/run llm "Deploy the current branch there."
{:toolkit toolkit :conversation-id "ops-session-1"})]
(println (:text r)))
```
Without the id, the second call would start from an empty transcript and the model would have no idea what “there” means.
### One conversation per user, in a request handler
[Section titled “One conversation per user, in a request handler”](#one-conversation-per-user-in-a-request-handler)
```clojure
(defn handle-message
"One HTTP request, one turn of that user's conversation."
[user-id text]
(let [r (client/run llm text {:toolkit toolkit
:conversation-id (str "user:" user-id)})]
{:reply (:text r)
:tools (mapv :name (:tool-calls r))
:tokens (get-in r [:usage :total-tokens])}))
```
The id is an opaque string — namespace it however your application already namespaces things. With the default in-memory store this holds every live conversation in the process heap, which is fine for a CLI or a short-lived worker and is exactly the point at which you swap in [your own store](/api/clojure/client/store/).
### Owning the transcript yourself
[Section titled “Owning the transcript yourself”](#owning-the-transcript-yourself)
```clojure
;; Resume a job whose messages you persisted elsewhere.
(def saved (read-transcript-from-db "job-77"))
(def r (client/run llm "Continue where you stopped."
{:toolkit toolkit
:history saved}))
;; :messages is the whole updated transcript — persist it and repeat.
(write-transcript-to-db! "job-77" (:messages r))
```
`:messages` comes back in the provider’s own message shape, which is the same shape `:history` takes, so a round trip through your own storage is a straight write and read.
### The three inputs
[Section titled “The three inputs”](#the-three-inputs)
| Key | Where it goes | Notes |
| ------------------------ | ------------------------------------------- | ------------------------------------------------------------------------ |
| `:conversation-id` | Store `get` before the run, `save` after it | Absent ⇒ nothing is loaded and nothing is saved. |
| `:history` | Seeds the transcript directly | Wins over the store. Provider-shaped messages. |
| `:store` (on the client) | The two-operation provider | Defaults to [`in-memory-store`](/api/clojure/client/store/), per client. |
## See also
[Section titled “See also”](#see-also)
* [`toolnexus.client/create-client`](/api/clojure/client/create/) — The unified client: system prompt, skills injection, parallel and chained tool calls, retries, memory.
* [`toolnexus.client/run`](/api/clojure/client/run/) — Send a prompt, let the loop call tools until the model stops, get a RunResult.
* [`toolnexus.client/in-memory-store`](/api/clojure/client/store/) — Swap in-memory history for your own store so a conversation survives a process restart.
* [`toolnexus.client/create-client`](/api/clojure/client/metrics/) — Emit one metric event per model call and per tool call: tokens, latency, outcome.
# toolnexus.client/create-client
> The unified client: system prompt, skills injection, parallel and chained tool calls, retries, memory. — Clojure.
**Clojure (JVM) + cljgo** · package `net.clojars.muthuishere/toolnexus` · [SPEC §8](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [clojure/src/toolnexus/client.cljc](https://github.com/muthuishere/toolnexus/blob/main/clojure/src/toolnexus/client.cljc)
```clojure
(create-client opts) ; => client
;; opts — idiomatic kebab-case; none of these keys ever reach the wire
;; :base-url required "https://api.anthropic.com"
;; :model required "claude-sonnet-4-5"
;; :style "openai" (default) | "anthropic"
;; :api-key optional; else :api-key-env, else ONLY the env var of the :base-url host
;; :api-key-env the NAME of the env var holding the key (never the value)
;; :headers extra request headers
;; :system-prompt prepended to the toolkit's skills prompt
;; :max-turns default 10
;; :request-params shallow-merged into the request body, wins on collision
;; :body-transform (fn [body] body) — runs last, its return value is sent
;; :http-client (fn [url headers body] response) — supply the transport
;; :prompt-cache {:key :long-ttl} — mark the stable prefix for the provider cache
;; :tool-tiers {:core :deferred :budget-tokens …} — tool_search / call_tool
;; … plus the :hooks / :retries / :on-metric / :store / :wait-for / :wait-for-ctx slices, each on its own page
;; the returned client is a plain map with the defaults filled in:
;; {:base-url "…" :model "…" :style "openai" :max-turns 10
;; :store {:get (fn [id]) :save (fn [id msgs])} …}
```
`create-client` builds nothing and connects to nothing. It validates that `:base-url` and `:model` are present, normalizes `:style` to exactly `"openai"` or `"anthropic"`, fills in `:max-turns` 10, and attaches a default [in-memory store](/api/clojure/client/store/) so two clients never share a transcript by accident. The result is an ordinary map — you can `assoc` into it, print it, or hold several of them side by side.
The style decides two things: the endpoint and the tool-result wire shape. `"openai"` posts to `{base}/chat/completions`, carries the system prompt as message 0, and feeds results back as `role: "tool"` messages. `"anthropic"` posts to `{base}/v1/messages` (a `/v1` suffix already on your base URL is not doubled), carries `system` in the body alongside a default `max_tokens` of 4096, and feeds results back as **one** `user` message of `tool_result` blocks. Both are implemented and both are exercised by the suite against a recording fake server, so “it composes” is a measurement rather than a claim.
The API key is resolved at call time and only ever reaches a request header — never a log line, never a return value. With `:api-key` unset the key comes from the env var `:api-key-env` names, else **only** from 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`. Any other host, or plain http, throws without `:api-key` / `:api-key-env` (**breaking in 0.22.0** — there is no cross-provider fallback any more). See [API key by host](/api/clojure/client/api-key/).
## When to use it
[Section titled “When to use it”](#when-to-use-it)
* **Once per model configuration**, at startup — a client is cheap, immutable data, and safe to hold for the life of the process.
* **When you want the loop**, not one round trip: system prompt plus skills prompt, tool schema emission, parallel tool execution, results fed back in the provider’s own shape, bounded by `:max-turns`.
* **When you need to reach a gateway** — OpenRouter, a company proxy, a local model server. Set `:base-url` and the style its wire format matches, plus `:api-key` or `:api-key-env` for any host that is not OpenAI, Anthropic or OpenRouter.
## Why this and not the alternative
[Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative)
Skip the client entirely if you only want a schema
If your application already has an LLM SDK it likes, you do not need this client at all. Build a toolkit with [`toolnexus.core/build`](/api/clojure/toolkit/create/), emit the schema with `to-openai` / `to-anthropic` / `to-gemini`, and call `toolnexus.core/execute` when the model asks for a tool. The client is the batteries-included path, not the only path — and every tool source in this port works identically without it.
`toolnexus.client/call-provider` sits one level below `run`: it sends a body map **you** built through exactly the same endpoint, headers, retry policy and `llm` metric as the loop, without any loop at all. That is the seam for single-turn work where a full agent loop would be overhead.
## Examples
[Section titled “Examples”](#examples)
### A client and one run
[Section titled “A client and one run”](#a-client-and-one-run)
```clojure
(require '[toolnexus.core :as tn]
'[toolnexus.client :as client])
(def toolkit (tn/build {:skills "./skills"}))
;; The key is read from the environment by the client; never hard-code it.
(def llm
(client/create-client
{:base-url "https://api.anthropic.com"
:style "anthropic"
:model "claude-sonnet-4-5"
:system-prompt "You are a release engineer. Be terse."}))
(def result (client/run llm "What skills do you have?" {:toolkit toolkit}))
(println (:text result)) ; the final assistant text
(println (:turns result)) ; LLM round trips actually made
(println (:tool-call-count result))
```
### Pointing at a gateway, and bounding the loop
[Section titled “Pointing at a gateway, and bounding the loop”](#pointing-at-a-gateway-and-bounding-the-loop)
Anything OpenAI-shaped works with `:style "openai"` — only the base URL changes.
```clojure
(def llm
(client/create-client
{:base-url "https://openrouter.ai/api/v1"
:model "anthropic/claude-sonnet-4.5"
:max-turns 4 ; default is 10
:headers {"http-referer" "https://example.com"
"x-title" "toolnexus demo"}}))
(let [r (client/run llm "Summarise the repo" {:toolkit toolkit})]
(when (= "incomplete" (:status r))
;; the loop hit :max-turns before the model produced text
(println "gave up after" (:turns r) "turns, limit" (:limit r))))
```
`:max-turns` is a hard stop, not a hint. When it fires the result comes back with `:status "incomplete"` and `:limit "maxTurns"` instead of silently truncated text.
### Shaping the request body, and owning the transport
[Section titled “Shaping the request body, and owning the transport”](#shaping-the-request-body-and-owning-the-transport)
`:request-params` is merged over the body the client assembled and **wins** on collision, which is how you override the anthropic style’s built-in `max_tokens`. `:body-transform` runs last and its return value is what is marshalled, so it can drop keys the merge added. `:messages`, `:tools` and `:stream` are refused from `:request-params` with a warning on stderr — the loop owns them, and rewriting messages is deliberately `:body-transform`’s job.
```clojure
(require '[koine.http :as http])
(def trace-header "corp-gateway-7f2a")
(def llm
(client/create-client
{:base-url "https://api.anthropic.com"
:style "anthropic"
:model "claude-sonnet-4-5"
:request-params {:max_tokens 1024 :temperature 0.2}
:body-transform (fn [body] (dissoc body :temperature)) ; last word wins
;; same (url headers body) shape as koine.http/post-json, so wrapping is a one-liner
:http-client (fn [url headers body]
(http/post-json url (assoc headers "x-trace" trace-header) body))}))
```
`:http-client` exists for the credentials this library must never see — a corporate proxy, mTLS, a signing gateway. Its scope is the LLM path only; MCP transports are a separate seam and are not routed through it.
## Options
[Section titled “Options”](#options)
| Option | Default | What it does |
| ----------------- | ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
| `:base-url` | — | **Required.** Trailing slashes are trimmed. |
| `:model` | — | **Required.** Sent as `model` and echoed on the RunResult. |
| `:style` | `"openai"` | `"openai"` or `"anthropic"`. Anything else normalizes to `"openai"`. |
| `:api-key` | env | Else `:api-key-env`, else only the host’s own env var (https `api.openai.com` / `api.anthropic.com` / `openrouter.ai`). Any other host throws. |
| `:api-key-env` | `nil` | The **name** of the env var to read the key from. See [API key by host](/api/clojure/client/api-key/). |
| `:prompt-cache` | `nil` | `{:key :long-ttl}` — see [prompt cache](/api/clojure/client/prompt-cache/). Absent ⇒ body byte-identical. |
| `:tool-tiers` | `nil` | `{:core :deferred …}` — see [tool tiers](/api/clojure/client/tool-tiers/). Absent ⇒ every tool is sent. |
| `:wait-for-ctx` | `nil` | `(fn [ctx request] answer)` — `:wait-for` with the turn’s context; wins when both are set. |
| `:headers` | `nil` | Merged over the auth headers the style built. |
| `:system-prompt` | `nil` | Joined to the toolkit’s skills prompt with a blank line. Empty parts are dropped. |
| `:max-turns` | `10` | Hard cap on LLM round trips. |
| `:request-params` | `nil` | Shallow-merged into the body, wins on collision. `:messages` / `:tools` / `:stream` are ignored. |
| `:body-transform` | `nil` | `(fn [body] body)` — runs after the merge; its return value is sent. |
| `:http-client` | koine | `(fn [url headers body] response)` for the LLM call only. |
| `:hooks` | `nil` | `{:before-llm :after-llm :before-tool :after-tool}` — §8 lifecycle middleware. Absent changes nothing. |
With neither `:request-params` nor `:body-transform` set the body is byte-identical to the default, and explicitly-nil options are indistinguishable from absent ones.
## See also
[Section titled “See also”](#see-also)
* [`toolnexus.client/run`](/api/clojure/client/run/) — Send a prompt, let the loop call tools until the model stops, get a RunResult.
* [`toolnexus.client/run`](/api/clojure/client/conversation/) — Keep a transcript across turns so the model remembers what it already did.
* [`toolnexus.client/in-memory-store`](/api/clojure/client/store/) — Swap in-memory history for your own store so a conversation survives a process restart.
* [`toolnexus.client/create-client`](/api/clojure/client/metrics/) — Emit one metric event per model call and per tool call: tokens, latency, outcome.
* [`toolnexus.client/create-client`](/api/clojure/client/resilience/) — `:retries`, `:retry-base-ms`, `:timeout-ms` and the `:on-error` classifier.
* [`toolnexus.client/create-client`](/api/clojure/suspension/wait-for/) — `:wait-for`, the one host slot of SPEC §10.
* [`toolnexus.client/create-client`](/api/clojure/client/hooks/) — `:hooks`, the four lifecycle interception points.
# toolnexus.client/create-client
> Intercept before/after model calls and tool calls: audit, redact, veto, or rewrite. — Clojure.
**Clojure (JVM) + cljgo** · package `net.clojars.muthuishere/toolnexus` · [SPEC §8](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [clojure/src/toolnexus/client.cljc](https://github.com/muthuishere/toolnexus/blob/main/clojure/src/toolnexus/client.cljc)
```clojure
(toolnexus.client/create-client
{:base-url "https://api.anthropic.com"
:style "anthropic"
:model "claude-sonnet-4-6"
:hooks {:before-llm (fn [{:keys [messages tools model turn]}] nil) ; -> {:messages …} / {:tools …}
:after-llm (fn [{:keys [response model turn]}] nil) ; observe only
:before-tool (fn [{:keys [name args id turn]}] nil) ; -> {:result …} or {:args …}
:after-tool (fn [{:keys [name args result id turn]}] nil)}}) ; -> {:result …}
```
There is no `Hooks` type — hooks are a plain map under `:hooks`, and every key is optional. Each hook takes one map and returns one map, or `nil`; **`nil` changes nothing**, which is the common case because most hooks only observe. Every application inside the loop is an `or` against the current value rather than a blind overwrite, so an observer cannot accidentally blank a field.
The event maps are kebab-case, because a hook never crosses the wire. The one exception is the `ToolResult` a tool hook carries, which keeps its pinned `:isError` — that one does cross the wire.
## When to use it
[Section titled “When to use it”](#when-to-use-it)
* **Audit and cost tracing** — `:after-llm` receives the provider’s decoded response, so `usage` is right there. Use it for logging and tracing; use `:on-metric` when you want the semantic event rather than the raw payload.
* **A policy gate on tools** — `:before-tool` returning `{:result …}` **short-circuits**: the real tool never runs. That one shape covers deny, dry-run and cache-hit.
* **Rewriting a call** — `:before-tool` returning `{:args …}` replaces the arguments. Those args are what runs, what the transcript records, and what a §10 retry re-executes with.
* **Redacting output** — `:after-tool` returning `{:result …}` replaces the result the model sees.
## Why this and not the alternative
[Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative)
Use :body-transform when you care about the wire, not the loop
`:before-llm` operates on the loop’s own values — `messages` and `tools`, before the body is assembled. [`:body-transform`](/api/clojure/client/create/) sits at the other end of the pipeline and sees the fully marshalled body. The order is the spec: base body → `:before-llm` → `:request-params` merge → `:body-transform` → marshal → wire. Reach for a hook to change what the loop believes; reach for `:body-transform` to change what the HTTP call sends.
Two behaviours are worth knowing before you rely on them:
* **A suspension skips `:after-tool`.** A pending `Request` is not a real result, so the hook does not fire for it. The resolved result — once `:wait-for` has answered — does flow through `:after-tool`, so a redaction hook still sees it exactly once.
* **`before-llm!` and `after-llm!` are public.** They are public because §11 [`toolnexus.translate/translate`](/api/clojure/translate/translate/) must fire `:before-llm` exactly once for its single call, and a second copy of that rule in another namespace is precisely the drift the ports exist to prevent.
## Examples
[Section titled “Examples”](#examples)
### 1. Observe — log every model call and every tool call
[Section titled “1. Observe — log every model call and every tool call”](#1-observe--log-every-model-call-and-every-tool-call)
```clojure
(require '[toolnexus.client :as client]
'[toolnexus.core :as core])
(def tk (core/build {:skills "examples/skills"}))
(def c
(client/create-client
{:base-url "https://api.anthropic.com"
:style "anthropic"
:model "claude-sonnet-4-6"
:hooks {:after-llm (fn [{:keys [response turn]}]
(println "turn" turn "usage" (:usage response))
nil)
:after-tool (fn [{:keys [name args]}]
(println "called" name "with" args)
nil)}})) ; nil => nothing is rewritten
(client/run c "list the skills you have" {:toolkit tk})
```
### 2. Gate — deny a tool without the tool ever running
[Section titled “2. Gate — deny a tool without the tool ever running”](#2-gate--deny-a-tool-without-the-tool-ever-running)
```clojure
(def read-only
{:before-tool (fn [{:keys [name]}]
(when (#{"bash" "write" "edit" "apply_patch"} name)
;; a short-circuit result: the model sees a refusal, the tool never runs
{:result {:output (str "denied: " name " is disabled in this environment")
:isError true}}))})
(def c (client/create-client {:base-url "https://api.anthropic.com"
:style "anthropic"
:model "claude-sonnet-4-6"
:hooks read-only}))
```
Returning `nil` for every other tool name is what lets the rest through — the `when` yields `nil` on a miss, and `nil` means “no opinion”.
### 3. Rewrite both directions — pin an argument, redact an output
[Section titled “3. Rewrite both directions — pin an argument, redact an output”](#3-rewrite-both-directions--pin-an-argument-redact-an-output)
```clojure
(require '[clojure.string :as str])
(def tenant-scoped
{;; force every search into this tenant, whatever the model asked for
:before-tool (fn [{:keys [name args]}]
(when (= name "search")
{:args (assoc args :tenant "acme")}))
;; never let a token reach the transcript
:after-tool (fn [{:keys [result]}]
(let [redacted (str/replace (:output result) #"sk-[A-Za-z0-9]+" "sk-***")]
(when (not= redacted (:output result))
{:result (assoc result :output redacted)})))
;; trim the tool list the model is offered on later turns
:before-llm (fn [{:keys [tools turn]}]
(when (> turn 3)
{:tools (vec (take 5 tools))}))})
```
The rewritten `:args` are authoritative — they are what executes, what the transcript records, and what a §10 resume re-executes with — so a hook that scopes a tenant cannot be undone by a retry.
## The four hooks
[Section titled “The four hooks”](#the-four-hooks)
| Hook | Receives | What returning a map changes |
| -------------- | --------------------------------- | ------------------------------------------------------------------------- |
| `:before-llm` | `{:messages :tools :model :turn}` | `{:messages …}` and/or `{:tools …}`, applied before the body is assembled |
| `:after-llm` | `{:response :model :turn}` | Nothing. Observe only — a return value would be a silent contract |
| `:before-tool` | `{:name :args :id :turn}` | `{:result …}` short-circuits the call · `{:args …}` rewrites it |
| `:after-tool` | `{:name :args :result :id :turn}` | `{:result …}` replaces what the model sees. Skipped for a suspension |
## Tool hints and permission gates (0.22.0)
[Section titled “Tool hints and permission gates (0.22.0)”](#tool-hints-and-permission-gates-0220)
Every builtin carries `:hints {:read-only :destructive :open-world}`, MCP tools map their `readOnlyHint` / `destructiveHint` / `openWorldHint` annotations onto it, and a `:before-tool` event carries the called tool’s `:hints`. A permission gate is just `:before-tool`: return nothing to allow, `{:result (tool/failure "denied")}` to deny (the model sees it, the run continues), or a `pending` result to ask the user (resolved by `:wait-for` / `:wait-for-ctx`). Hints are advisory and never sent to the model; a tool with no `:hints` is unknown, so treat it as mutating.
```clojure
{:hooks {:before-tool (fn [{:keys [name hints]}]
(when-not (:read-only hints)
{:result (tool/failure (str name ": needs approval"))}))}}
```
Under [tool tiers](/api/clojure/client/tool-tiers/) a `call_tool` call is unwrapped **before** the hook, so the gate sees the real tool name and its hints; `tool_search` is engine-internal and never reaches the hook.
## See also
[Section titled “See also”](#see-also)
* [`toolnexus.client/create-client`](/api/clojure/client/create/) — the rest of the option map, including `:body-transform` and `:request-params`
* [`toolnexus.client/run`](/api/clojure/client/run/) — the loop the hooks fire inside, and its `:on-event` sink
* [metrics — `:on-metric`](/api/clojure/client/metrics/) — the semantic event instead of the raw payload
* [`toolnexus.translate/translate`](/api/clojure/translate/translate/) — the one-call path that fires `:before-llm` exactly once
# toolnexus.client/list-models
> List a provider's models (id, display name, context length when reported), sorted by id, under the same key rules as the client. — Clojure.
**Clojure (JVM) + cljgo** · package `net.clojars.muthuishere/toolnexus` · [SPEC §8](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [clojure/src/toolnexus/client.cljc](https://github.com/muthuishere/toolnexus/blob/main/clojure/src/toolnexus/client.cljc)
```clojure
(list-models opts) ; opts: the same map create-client takes (:base-url :style :api-key :api-key-env :headers)
;; => [{:id "anthropic/claude-sonnet-4.5" :name "Claude Sonnet 4.5" :context-length 200000} …]
;; sorted by :id; :name and :context-length only when the provider reports them
```
One GET, no retries. openai style: `GET {base}/models` (OpenRouter’s `context_length` is read when present). anthropic style: `GET {base}/v1/models` (or `{base}/models` when the base already ends in `/v1`), following `has_more` / `last_id` across pages. The key follows the client’s [rules](/api/clojure/client/api-key/) exactly. A non-2xx throws `ex-info` with `{:toolnexus/error :provider :status n}`.
`list-models` uses koine’s HTTP client directly, not `:http-client` (which only posts).
## When to use it
[Section titled “When to use it”](#when-to-use-it)
* **A model picker** — list what the configured provider serves and let the user switch.
* **Sizing a context budget** — `:context-length` feeds `:tool-tiers` `:context-window` or a compactor.
## Why this and not the alternative
[Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative)
Hard-code the model when it never changes
A service pinned to one model gains nothing from a network call at startup. Use `list-models` when a human or a router chooses the model at runtime.
## Examples
[Section titled “Examples”](#examples)
### 1. A local server without a key is refused before any request
[Section titled “1. A local server without a key is refused before any request”](#1-a-local-server-without-a-key-is-refused-before-any-request)
```clojure
(require '[toolnexus.client :as client])
(println)
(def err (try (client/list-models {:base-url "http://localhost:11434/v1"}) nil
(catch Exception e e)))
(assert (= :config (:toolnexus/error (ex-data err))))
(println "OK")
```
### 2. OpenRouter, with context lengths
[Section titled “2. OpenRouter, with context lengths”](#2-openrouter-with-context-lengths)
```clojure
(require '[toolnexus.client :as client])
;; key from OPENROUTER_API_KEY — the host's own env var
(doseq [m (client/list-models {:base-url "https://openrouter.ai/api/v1"})]
(println (:id m) (or (:context-length m) "?")))
```
### 3. Anthropic, paginated, with an explicit key variable
[Section titled “3. Anthropic, paginated, with an explicit key variable”](#3-anthropic-paginated-with-an-explicit-key-variable)
```clojure
(def models (client/list-models {:base-url "https://api.anthropic.com" :style "anthropic"
:api-key-env "TEAM_ANTHROPIC_KEY"}))
(def llm (client/create-client {:base-url "https://api.anthropic.com" :style "anthropic"
:api-key-env "TEAM_ANTHROPIC_KEY"
:model (:id (first models))}))
```
## See also
[Section titled “See also”](#see-also)
* [`api-key`](/api/clojure/client/api-key/) — the key rules `list-models` shares with the client.
# toolnexus.client/create-client
> Emit one metric event per model call and per tool call: tokens, latency, outcome. — Clojure.
**Clojure (JVM) + cljgo** · package `net.clojars.muthuishere/toolnexus` · [SPEC §8](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [clojure/src/toolnexus/client.cljc](https://github.com/muthuishere/toolnexus/blob/main/clojure/src/toolnexus/client.cljc)
```clojure
(create-client {:base-url "…"
:model "…"
:on-metric (fn [event] nil)}) ; the whole observability surface
;; three event shapes, distinguished by :event — plain maps, no type
;; {:event "llm" :model :status :ms :prompt_tokens :completion_tokens}
;; {:event "tool" :tool :source :is_error :ms}
;; {:event "run" :model :turns :tool_calls :total_tokens :ms :error}
```
`:on-metric` is one function, called synchronously with one map per measurable thing the loop does: an `llm` event per successful provider round trip, a `tool` event per tool call, and exactly one `run` event per `run`. There is no `MetricEvent` type to import and no counter, gauge or histogram API — the events are semantic, and translating them into whatever your metrics backend calls a counter is your side of the seam, where the vocabulary belongs.
With `:on-metric` unset the event map is **never built**. The emission point is guarded, so the cost of not observing is one `nil` check per event site rather than an allocated map thrown away.
The timings are real: a `tool` event is emitted after its result lands, so `:ms` and `:is_error` describe what happened rather than what was attempted, and an `llm` event is emitted only on a successful response — a retried 429 does not produce one, though the retry that finally succeeded does.
## When to use it
[Section titled “When to use it”](#when-to-use-it)
* **Cost tracking** — `:prompt_tokens` and `:completion_tokens` per call, `:total_tokens` per run, attributed to `:model`.
* **Finding the slow tool** — the `tool` event carries `:ms` and `:source`, so “MCP server X is the reason every run takes nine seconds” is one group-by away.
* **A run-level SLO** — one `run` event per run with `:turns`, `:tool_calls`, `:ms` and `:error`.
## Why this and not the alternative
[Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative)
Use :on-event when you need the run to be visible, not measured
[`:on-event`](/api/clojure/client/run/) on `run` reports the loop as it moves — `tool_call`, `tool_result`, `pending`, `usage`, `done` — with the arguments and outputs attached. That is the sink for a progress UI. `:on-metric` is for aggregate telemetry: it carries durations and token counts, never payloads, and is configured once on the client rather than per run.
If all you want is the token cost of one call, the RunResult’s `:usage` already has it and needs no callback at all.
## Examples
[Section titled “Examples”](#examples)
### Counting tokens and spotting failures
[Section titled “Counting tokens and spotting failures”](#counting-tokens-and-spotting-failures)
```clojure
(require '[toolnexus.client :as client])
(def totals (atom {:tokens 0 :tool-errors 0 :runs 0}))
(def llm
(client/create-client
{:base-url "https://api.openai.com/v1"
:model "gpt-4.1"
:on-metric (fn [m]
(case (:event m)
"llm" (swap! totals update :tokens
+ (or (:prompt_tokens m) 0) (or (:completion_tokens m) 0))
"tool" (when (:is_error m) (swap! totals update :tool-errors inc))
"run" (swap! totals update :runs inc)
nil))}))
(client/run llm "Audit the changelog" {:toolkit toolkit})
@totals ;; => {:tokens 4210 :tool-errors 1 :runs 1}
```
The `case` over `:event` is the whole dispatch. Keep the handler cheap — it runs on the loop’s thread, and a slow one is a slow run.
### Feeding a metrics backend
[Section titled “Feeding a metrics backend”](#feeding-a-metrics-backend)
Your backend’s vocabulary lives in your adapter, not in the library.
```clojure
(defn statsd-sink [m]
(case (:event m)
"llm" (do (timing! "toolnexus.llm.ms" (:ms m) {:model (:model m)
:status (str (:status m))})
(count! "toolnexus.llm.tokens" (+ (or (:prompt_tokens m) 0)
(or (:completion_tokens m) 0))
{:model (:model m)}))
"tool" (timing! "toolnexus.tool.ms" (:ms m) {:tool (:tool m)
:source (:source m)
:error (str (:is_error m))})
"run" (timing! "toolnexus.run.ms" (:ms m) {:model (:model m)
:turns (str (:turns m))})
nil))
(def llm (client/create-client {:base-url "https://api.anthropic.com"
:style "anthropic"
:model "claude-sonnet-4-5"
:on-metric statsd-sink}))
```
`:source` on the `tool` event is the tool’s origin — `"mcp"`, `"builtin"`, `"native"`, `"http"`, `"a2a"` or `"custom"` — which is what makes “which subsystem is slow” answerable rather than “which tool name”.
### Reading the whole surface once
[Section titled “Reading the whole surface once”](#reading-the-whole-surface-once)
The cheapest way to learn the shapes is to collect them.
```clojure
(def seen (atom []))
(client/run (client/create-client {:base-url base-url :model "gpt-4.1" :api-key-env "LLM_API_KEY"
:on-metric #(swap! seen conj %)})
"Say hello and read README.md"
{:toolkit toolkit})
(group-by :event @seen)
;; {"llm" [{:event "llm" :model "gpt-4.1" :status 200 :ms 812
;; :prompt_tokens 1204 :completion_tokens 63} …]
;; "tool" [{:event "tool" :tool "read" :source "builtin" :is_error false :ms 4}]
;; "run" [{:event "run" :model "gpt-4.1" :turns 2 :tool_calls 1
;; :total_tokens 2140 :ms 1633 :error nil}]}
```
Note the key casing: metric events use snake\_case keys (`:prompt_tokens`, `:is_error`, `:tool_calls`) because they mirror the provider’s own field names and the other ports’ event payloads. The RunResult, which never crosses a wire, stays kebab-case.
### The events
[Section titled “The events”](#the-events)
| `:event` | Keys | Emitted |
| -------- | -------------------------------------------------------------- | -------------------------------------------- |
| `"llm"` | `:model` `:status` `:ms` `:prompt_tokens` `:completion_tokens` | Once per **successful** provider round trip. |
| `"tool"` | `:tool` `:source` `:is_error` `:ms` | Once per tool call, after the result lands. |
| `"run"` | `:model` `:turns` `:tool_calls` `:total_tokens` `:ms` `:error` | Exactly once per `run`, on every exit path. |
Token fields come straight from the provider payload and may be `nil` if it sent none.
## Cache tokens in usage (0.22.0)
[Section titled “Cache tokens in usage (0.22.0)”](#cache-tokens-in-usage-0220)
Usage gains `:cache-read-tokens` and `:cache-write-tokens` (anthropic `cache_read_input_tokens` / `cache_creation_input_tokens`; openai `prompt_tokens_details.cached_tokens`), present only when the provider reports them. Under anthropic the real context size is `:prompt-tokens + :cache-read-tokens + :cache-write-tokens`; under openai `:prompt-tokens` already includes the cached part. See [prompt cache](/api/clojure/client/prompt-cache/).
## See also
[Section titled “See also”](#see-also)
* [`toolnexus.client/create-client`](/api/clojure/client/create/) — The unified client: system prompt, skills injection, parallel and chained tool calls, retries, memory.
* [`toolnexus.client/run`](/api/clojure/client/run/) — Send a prompt, let the loop call tools until the model stops, get a RunResult.
* [`toolnexus.client/run`](/api/clojure/client/conversation/) — Keep a transcript across turns so the model remembers what it already did.
* [`toolnexus.client/in-memory-store`](/api/clojure/client/store/) — Swap in-memory history for your own store so a conversation survives a process restart.
* [`toolnexus.client/create-client`](/api/clojure/client/resilience/) — `:retries`, `:timeout-ms` and the `:on-error` classifier, whose retries are what `llm` events do and do not count.
# prompt-cache
> Mark the stable prompt prefix for the provider (Anthropic cache breakpoints, OpenAI prompt_cache_key) and read cacheRead/cacheWrite tokens from usage. — Clojure.
**Clojure (JVM) + cljgo** · package `net.clojars.muthuishere/toolnexus` · [SPEC §8](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [clojure/src/toolnexus/client.cljc](https://github.com/muthuishere/toolnexus/blob/main/clojure/src/toolnexus/client.cljc)
```clojure
(create-client {… :prompt-cache {:key "session-1" ; openai: sent as prompt_cache_key
:long-ttl true}}) ; anthropic: ttl "1h" on each breakpoint
;; usage on every RunResult then carries, when the provider reports them:
;; :cache-read-tokens :cache-write-tokens
```
`:prompt-cache` marks the stable prefix of every request for the provider’s cache. Under the anthropic style the client puts `cache_control {:type "ephemeral"}` (plus `:ttl "1h"` with `:long-ttl`) on the last tool, on the system prompt (a string becomes one text block) and on the last content block of the latest message. Under the openai style it sends `prompt_cache_key` = `:key` (OpenAI caches the prefix by itself). Gemini gets nothing yet. The marks go on before `:request-params` and `:body-transform`, so both can still override them, and the transcript itself is never changed. Absent, the body is byte-identical to 0.21.
Usage gains `:cache-read-tokens` and `:cache-write-tokens`. Under anthropic the real context size is `:prompt-tokens + :cache-read-tokens + :cache-write-tokens` (Anthropic’s input count excludes cached tokens); under openai `:prompt-tokens` already includes the cached part.
## When to use it
[Section titled “When to use it”](#when-to-use-it)
* **Long system prompts or big skills catalogs** — the stable prefix is paid for once, then read.
* **Many tools** — the tools array is part of the prefix; combine with [tool tiers](/api/clojure/client/tool-tiers/) so registering a tool mid-session does not break it.
* **To watch cache hits** — a drop in `:cache-read-tokens` shows a prefix that changed.
## Why this and not the alternative
[Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative)
Hand-placing cache\_control in :body-transform
You can add `cache_control` yourself in `:body-transform`, but you then own the breakpoint placement on every request. `:prompt-cache` places the three breakpoints the same way in every port. Use `:body-transform` only for a placement it does not cover.
## Examples
[Section titled “Examples”](#examples)
### 1. Anthropic: breakpoints on tools, system and the latest message
[Section titled “1. Anthropic: breakpoints on tools, system and the latest message”](#1-anthropic-breakpoints-on-tools-system-and-the-latest-message)
```clojure
(require '[toolnexus.client :as client]
'[toolnexus.core :as core]
'[toolnexus.native :as native]
'[koine.json :as json])
(println)
(defn body-of [b] (if (string? b) (json/read-str b) b))
(defn mock-llm
"An :http-client that records every request body and answers from `replies` in order."
[seen replies]
(let [q (atom replies)]
(fn [_url _headers body]
(swap! seen conj (body-of body))
(let [r (first @q)]
(swap! q #(if (next %) (vec (rest %)) %))
{:status 200 :headers {} :body (json/write-str r)}))))
(def seen (atom []))
(def llm (client/create-client
{:base-url "https://api.anthropic.com" :style "anthropic" :model "claude-sonnet-4-5"
:api-key "YOUR_KEY_HERE"
:system-prompt "You are a release engineer."
:prompt-cache {:long-ttl true}
:http-client (mock-llm seen [{:content [{:type "text" :text "ok"}] :stop_reason "end_turn"
:usage {:input_tokens 10 :output_tokens 2
:cache_read_input_tokens 900 :cache_creation_input_tokens 0}}])}))
(def tk (core/build {:tools [(native/native-tool {:name "notes" :description "Read notes." :run (fn [_] "n")})]}))
(def r (client/run llm "ship it" {:toolkit tk}))
(def body (first @seen))
(assert (= {:type "ephemeral" :ttl "1h"} (:cache_control (last (:tools body)))))
(assert (= {:type "ephemeral" :ttl "1h"} (:cache_control (first (:system body)))))
(assert (= {:type "ephemeral" :ttl "1h"} (:cache_control (last (:content (last (:messages body)))))))
(assert (= 900 (get-in r [:usage :cache-read-tokens])))
(println "OK")
```
### 2. OpenAI: a cache key, and the cached count read back
[Section titled “2. OpenAI: a cache key, and the cached count read back”](#2-openai-a-cache-key-and-the-cached-count-read-back)
```clojure
(require '[toolnexus.client :as client]
'[toolnexus.tool :as tool]
'[koine.json :as json])
(println)
(defn body-of [b] (if (string? b) (json/read-str b) b))
(defn mock-llm
"An :http-client that records every request body and answers from `replies` in order."
[seen replies]
(let [q (atom replies)]
(fn [_url _headers body]
(swap! seen conj (body-of body))
(let [r (first @q)]
(swap! q #(if (next %) (vec (rest %)) %))
{:status 200 :headers {} :body (json/write-str r)}))))
(def seen (atom []))
(def llm (client/create-client
{:base-url "https://api.openai.com/v1" :model "gpt-4.1" :api-key "YOUR_KEY_HERE"
:prompt-cache {:key "session-42"}
:http-client (mock-llm seen [{:choices [{:message {:role "assistant" :content "hi"}}]
:usage {:prompt_tokens 1200 :completion_tokens 3 :total_tokens 1203
:prompt_tokens_details {:cached_tokens 1024}}}])}))
(def r (client/run llm "hello" {:toolkit (tool/toolkit [])}))
(assert (= "session-42" (:prompt_cache_key (first @seen))))
(assert (= 1024 (get-in r [:usage :cache-read-tokens])))
(assert (= 1200 (get-in r [:usage :prompt-tokens])) "openai prompt tokens already include the cached part")
(println "OK")
```
### 3. Off is byte-identical
[Section titled “3. Off is byte-identical”](#3-off-is-byte-identical)
```clojure
(require '[toolnexus.client :as client]
'[toolnexus.tool :as tool]
'[koine.json :as json])
(println)
(defn body-of [b] (if (string? b) (json/read-str b) b))
(defn mock-llm
"An :http-client that records every request body and answers from `replies` in order."
[seen replies]
(let [q (atom replies)]
(fn [_url _headers body]
(swap! seen conj (body-of body))
(let [r (first @q)]
(swap! q #(if (next %) (vec (rest %)) %))
{:status 200 :headers {} :body (json/write-str r)}))))
(def seen (atom []))
(def reply [{:content [{:type "text" :text "ok"}] :stop_reason "end_turn" :usage {:input_tokens 1 :output_tokens 1}}])
(def llm (client/create-client {:base-url "https://api.anthropic.com" :style "anthropic" :model "m"
:api-key "YOUR_KEY_HERE" :system-prompt "s"
:http-client (mock-llm seen reply)}))
(def r (client/run llm "hi" {:toolkit (tool/toolkit [])}))
(assert (string? (:system (first @seen))) "no :prompt-cache ⇒ system stays a string")
(assert (not (re-find #"cache_control" (json/write-str (first @seen)))))
(assert (nil? (get-in r [:usage :cache-read-tokens])) "uncached usage has no cache keys")
(println "OK")
```
## See also
[Section titled “See also”](#see-also)
* [`tool-tiers`](/api/clojure/client/tool-tiers/) — keep the tools array, and so the cached prefix, stable.
* [`metrics`](/api/clojure/client/metrics/) — usage on every model call.
# toolnexus.client/provider-error
> A non-2xx response from the model endpoint raises a typed provider error carrying the status code, a redacted+capped body, and the raw `Retry-After` header — never a bare unstructured exception. — Clojure.
**Clojure (JVM) + cljgo** · package `net.clojars.muthuishere/toolnexus` · [SPEC §8](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [clojure/src/toolnexus/client.cljc](https://github.com/muthuishere/toolnexus/blob/main/clojure/src/toolnexus/client.cljc)
```clojure
(provider-error status body retry-after)
;; => an ex-info whose DATA is
;; {:toolnexus/error :provider
;; :status 402 ; the HTTP status, verbatim
;; :body "…" ; redacted, UNCAPPED — the cap is message-only
;; :retry-after "3"} ; the RAW Retry-After header, verbatim; omitted when absent
;; and whose MESSAGE is "LLM 402: "
;; catch it with ex-data, the idiomatic Clojure way — there is no exception CLASS to catch
(try
(client/run llm prompt {:toolkit tk})
(catch Throwable e
(when (= :provider (:toolnexus/error (ex-data e)))
(:status (ex-data e)))))
```
A non-2xx response from the model endpoint raises a typed provider error carrying the status code, a redacted+capped body, and the raw `Retry-After` header — never a bare unstructured exception. `provider-error` is the constructor `run` calls internally the moment an HTTP attempt exhausts its retry budget on a terminal status; you do not normally call it yourself, but its shape is exactly what your `catch` sees.
There is no `ProviderError` record or exception class in this port — unlike js/python/golang/ java/csharp, which each define a named type (`LlmHttpError`, `ProviderError`, `ProviderException`). Clojure represents the same failure as a plain `ex-info` (`clojure.lang.ExceptionInfo`) tagged with a namespaced key, `{:toolnexus/error :provider ...}`, in its `ex-data`. That is an idiomatic choice, not a lesser one: `ex-info` + `ex-data` *is* how this language spells “a typed, structured failure” — a host branches with `(= :provider (:toolnexus/error (ex-data e)))` instead of an `instanceof`/`isinstance` check, and the same tagging convention already carries `:timeout` (see [`timeout-error`](/api/clojure/client/create/)) and every other client-thrown failure in this namespace.
A real, minor drift: no named cap constant
Five of the eight ports (js, python, golang, java, csharp) give the 200-character message cap a named constant — `ERROR_BODY_CAP`, `MAX_ERROR_BODY`, `providerBodyCap`, `ErrorBodyCap`. Clojure (like elixir) inlines the literal `200` directly in `cap-message` (`clojure/src/toolnexus/client.cljc:591`) rather than naming it. The *value* is identical everywhere — this is a naming/maintainability gap, not a behavioral one, and it is called out here rather than hidden.
## When to use it
[Section titled “When to use it”](#when-to-use-it)
* **Catching a failed LLM call** without regexing a raw exception message. Before this shape existed, a host’s only signal was the message string itself, account identifiers and all (issues #91/#92, ADR 0027) — now `ex-data` gives you `:status`, `:body` and `:retry-after` as data.
* **Deciding whether to surface a status to a user** (e.g. show “rate limited, retrying” on 429) without parsing prose.
* **Building your own retry/alerting layer above `:retries`** — read `:retry-after` off a caught error the same way `:on-error`’s `info` map exposes it per attempt, if you need the value after the budget is already exhausted and the call has thrown.
## Why this and not the alternative
[Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative)
\`:retry-after\` is the RAW header — not a parsed number
`:retry-after` is the `Retry-After` header value **exactly as the provider sent it**, and is absent from the data map when the response sent no such header. It is deliberately not pre-parsed: `Retry-After` may legitimately be an HTTP-date, or fractional, signed, out-of-range or unparseable, and a numeric field would have to drop every one of those — destroying information the response actually supplied and which a host may well want to log or handle itself. The field is for the HOST.
The library’s own **waiting** rule is separate and unchanged: internally the client still honours only the delay-seconds form (RFC 9110 §10.2.3 — a run of ASCII digits, `0`…`2147483647`, waited as whole seconds, `0` meaning “retry now”) and falls back to exponential backoff for anything else. So `Retry-After: Wed, 21 Oct 2026 07:28:00 GMT` still produces a backoff wait *and* still arrives verbatim on `:retry-after`. This port previously carried milliseconds here — see the changelog.
Use :on-error when you want to react DURING the retry budget
`provider-error` (and the exception it builds) is what you get **after** the retry budget in [`create-client`](/api/clojure/client/resilience/)’s `:retries` is exhausted — it is the terminal failure, not a per-attempt hook. If you want to inspect or override the retry decision on *every* failed attempt — including ones that end up retried successfully — use `:on-error`, which receives the same `:status`/`:retryable?` shape per attempt and can return `:retry` or `:fail`. Reach for `ex-data` on a caught throw when the run is already over; reach for `:on-error` when it is still in flight.
Keeping this as an `ex-info` rather than inventing a `defrecord` and a matching `deftype` predicate also keeps the port to one `.cljc` file compiled for two hosts (JVM + cljgo): `ex-info`/`ex-data` are core Clojure on both, whereas a custom exception class is exactly the kind of host-specific surface this port avoids.
## Examples
[Section titled “Examples”](#examples)
### 1. The smallest useful call — catch and branch on status
[Section titled “1. The smallest useful call — catch and branch on status”](#1-the-smallest-useful-call--catch-and-branch-on-status)
```clojure
(require '[toolnexus.client :as client]
'[toolnexus.tool :as tool])
(println)
(def llm
(client/create-client
{:base-url "http://mock.local" :api-key "test-key" :style "openai" :model "gpt-4o-mini"
:http-client (fn [_url _headers _body]
{:status 402 :headers {} :body "{\"error\":\"insufficient_quota\"}"})}))
(def toolkit (tool/toolkit []))
(def caught
(try
(client/run llm "Summarise the incident" {:toolkit toolkit})
(catch Throwable e
(let [d (ex-data e)]
(if (= :provider (:toolnexus/error d))
(do (println "provider failed with status" (:status d)) (:status d))
(throw e))))))
(assert (= 402 caught))
(println "OK")
```
### 2. The realistic case — surface a rate-limit to the caller with the retry hint
[Section titled “2. The realistic case — surface a rate-limit to the caller with the retry hint”](#2-the-realistic-case--surface-a-rate-limit-to-the-caller-with-the-retry-hint)
```clojure
(require '[toolnexus.client :as client]
'[toolnexus.tool :as tool])
(println)
(defn run-and-report [llm prompt toolkit]
(try
{:status "done" :result (client/run llm prompt {:toolkit toolkit})}
(catch Throwable e
(let [d (ex-data e)]
(if (= :provider (:toolnexus/error d))
{:status "provider-error"
:http-status (:status d)
:retry-after (:retry-after d) ; the raw header, or nil if none was sent
:detail (:body d)} ; already redacted, safe to log or show
(throw e))))))
(def llm
(client/create-client
{:base-url "http://mock.local" :api-key "test-key" :style "openai" :model "gpt-4o-mini" :retries 0
:http-client (fn [_url _headers _body]
{:status 429 :headers {"retry-after" "2"}
:body "{\"error\":\"rate limited\"}"})}))
(def toolkit (tool/toolkit []))
(def report (run-and-report llm "hi" toolkit))
(assert (= "provider-error" (:status report)))
(assert (= 429 (:http-status report)))
(assert (= "2" (:retry-after report)))
(println "OK")
```
`:body` here is safe to log or return to a caller as-is — account identifiers (`user_id`, `account_id`, `org_id`, `organization`) are already replaced with `«redacted»`, and a 401/403 body is blanked to `""` entirely rather than redacted, per the credentials guarantee (ADR 0027, D5).
### 3. The full surface — every field, and the message-vs-data split
[Section titled “3. The full surface — every field, and the message-vs-data split”](#3-the-full-surface--every-field-and-the-message-vs-data-split)
```clojure
(require '[toolnexus.client :as client]
'[toolnexus.tool :as tool]
'[clojure.string :as str])
(println)
(def llm
(client/create-client
{:base-url "http://mock.local" :api-key "test-key" :style "openai" :model "gpt-4o-mini" :retries 0
:http-client (fn [_url _headers _body]
{:status 402
:headers {"retry-after" "3"}
:body "{\"account_id\":\"acct_9\",\"user_id\":\"u-42\",\"message\":\"over quota\"}"})}))
(def toolkit (tool/toolkit []))
(def surface
(try
(client/run llm "hi" {:toolkit toolkit})
(catch Throwable e
(let [d (ex-data e)]
(when (= :provider (:toolnexus/error d))
{:status (:status d) ; e.g. 402 — verbatim HTTP status
:body (:body d) ; redacted, UNCAPPED — the full body, minus identifiers
:retry-after (:retry-after d) ; the raw header verbatim, present only if one was sent
:message (ex-message e) ; "LLM 402: ", CAPPED at 200 chars + "…"
:message-len (count (ex-message e))})))))
(assert (= 402 (:status surface)))
(assert (= "3" (:retry-after surface)))
(assert (str/includes? (:body surface) "«redacted»"))
(assert (not (str/includes? (:body surface) "acct_9")))
(assert (str/starts-with? (:message surface) "LLM 402:"))
(println "OK")
```
The cap applies to the exception **message only** — `(:body (ex-data e))` is the whole redacted body, however long, because a host that opted into a typed error asked for the full diagnostic, not a truncated one (addendum A5). Only the human-readable message string is bounded.
## See also
[Section titled “See also”](#see-also)
* [`toolnexus.client/create-client`](/api/clojure/client/resilience/) — Classify an LLM error into retry / fail, bound a run with a deadline, and cancel cleanly — the resilience policy that decides whether a failing call ever reaches `provider-error` at all.
* [`toolnexus.client/run`](/api/clojure/client/run/) — Send a prompt, let the loop call tools until the model stops, get a RunResult.
* [`toolnexus.client/create-client`](/api/clojure/client/hooks/) — Intercept before/after model calls and tool calls: audit, redact, veto, or rewrite.
* [`toolnexus.client/run`](/api/clojure/client/conversation/) — Keep a transcript across turns so the model remembers what it already did.
# toolnexus.client/create-client
> Classify an LLM error into retry / fail, bound a run with a deadline, and cancel cleanly. — Clojure.
**Clojure (JVM) + cljgo** · package `net.clojars.muthuishere/toolnexus` · [SPEC §8](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [clojure/src/toolnexus/client.cljc](https://github.com/muthuishere/toolnexus/blob/main/clojure/src/toolnexus/client.cljc)
```clojure
(create-client {:base-url "…"
:model "…"
:retries 3 ; extra attempts after the first (default 0)
:retry-base-ms 500 ; backoff base; delay = base * 2^attempt + jitter
:timeout-ms 30000 ; per HTTP call, passed to the transport
:retryable-statuses [520 521 522] ; ADDED to the default set, never replacing it
:on-error (fn [info] :retry)}) ; or :fail
;; :on-error receives, per failed attempt:
;; {:error "the response body, or a koine error keyword"
;; :status 429 ; nil on a transport failure
;; :attempt 0 ; zero-based
;; :retryable? true} ; the default verdict, for you to accept or override
```
The default policy retries 429, 500, 502, 503, 504 and 529 plus any transport failure, and treats everything else as terminal. That is the whole list, not “429 plus any 5xx” — a 501, a 507 and the Cloudflare 520–527 family are terminal until you say otherwise. Backoff is exponential from `:retry-base-ms` — 500ms, 1s, 2s, each plus 0–99ms of jitter — and a numeric `Retry-After` header, in seconds, overrides it. The HTTP-date form of `Retry-After` is deliberately not parsed: date parsing is not portable across these two hosts without reaching past koine, and a server that sends it gets our backoff instead of a wrong answer.
`:retryable-statuses` says otherwise. It is a collection of statuses **added** to the default set and never replacing it, so it can only make more statuses retryable, never fewer — a host cannot accidentally drop 429 and lose `Retry-After` handling with it. The case it exists for is a Cloudflare-fronted origin:
```clojure
(create-client {:base-url base-url :api-key-env "LLM_API_KEY"
:model model
:retries 3
:retryable-statuses [520 521 522 523 524 525 526 527]})
```
It sets the **default verdict** only. `:on-error` still runs on every failed attempt and has the last word, so an `:on-error` returning `:fail` overrides a status the host itself listed. `Retry-After` handling is unchanged either way.
`:retries` counts **extra** attempts, so the default of 0 means one try. A budget of 3 means up to four calls.
`:on-error` overrides the default verdict in either direction, but `:retries` still bounds it. A classifier that could loop unbounded would be a denial-of-service on your own bill, so a forced `:retry` on a terminal status still stops when the budget runs out.
The verdicts are `:retry` and `:fail`, and nothing else. An LLM failure never becomes a §10 suspension: suspension is a user-action pause, and a 500 from the provider is not one. When the budget is exhausted the call throws an `ex-info` carrying `:status` or `:error` — a provider failure is not something the model can be shown and asked to retry, unlike a tool error.
:timeout-ms depends on the host
The client passes `:timeout-ms` to `koine.http/request`, which classifies a timeout as **data** — `{:status nil :error :timeout}` — so the retry loop treats it as one more retryable failure rather than a host-specific exception. Measured 2026-08-01 against koine 0.9.0: the JVM bounds the call; cljgo accepts the budget and ignores it. The port’s own suite reports that gap rather than asserting past it. Passing the budget through is all this port can do until koine enforces it on both hosts.
## When to use it
[Section titled “When to use it”](#when-to-use-it)
* **Any production run** — 429 and 503 are normal traffic from every provider, and a bare client makes one attempt and gives up.
* **When your gateway has its own error vocabulary** — an `:on-error` classifier is how a 402 top-up-required or a proxy’s 418 gets the treatment it deserves.
* **When a run must not hang** — `:timeout-ms` bounds each HTTP call, and `:max-turns` bounds how many there can be.
## Why this and not the alternative
[Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative)
Wrap the transport instead when the policy is not about the LLM
`:http-client` on [`create-client`](/api/clojure/client/create/) hands you the whole `(url headers body)` call. If you already have a resilience library, a circuit breaker or a proxy with its own retry rules, put them there and leave `:retries` at 0 — two retry layers stacked on each other multiply attempts rather than adding them. Use `:on-error` when the decision genuinely depends on the provider’s status code.
Note the scope: this policy covers the **LLM path only**. MCP transports are their own seam, and a tool that fails returns an error ToolResult the model can react to — retrying it is the model’s job, not the client’s.
## Examples
[Section titled “Examples”](#examples)
### Surviving rate limits
[Section titled “Surviving rate limits”](#surviving-rate-limits)
```clojure
(require '[toolnexus.client :as client])
(def llm
(client/create-client
{:base-url "https://api.anthropic.com"
:style "anthropic"
:model "claude-sonnet-4-5"
:retries 3 ; up to four attempts per LLM call
:retry-base-ms 500 ; 500ms, 1s, 2s (+jitter) — unless Retry-After says otherwise
:timeout-ms 60000}))
(client/run llm "Summarise the incident" {:toolkit toolkit})
```
Two 429s followed by a 200 costs three calls and returns normally. A 400 costs one call and throws immediately — no budget is spent on an error that will not change.
### A classifier for your gateway
[Section titled “A classifier for your gateway”](#a-classifier-for-your-gateway)
```clojure
(def llm
(client/create-client
{:base-url "https://gateway.internal/v1"
:api-key-env "GATEWAY_API_KEY" ; a custom host needs :api-key or :api-key-env
:model "gpt-4.1"
:retries 4
:on-error (fn [{:keys [status attempt error retryable?]}]
(println "llm attempt" attempt "failed:" status)
(cond
;; the gateway rate-limits with 409, which is not in the default set
(= 409 status) :retry
;; do not burn the budget on a bad key
(= 401 status) :fail
;; anything else: keep the default verdict
retryable? :retry
:else :fail))}))
```
Returning anything other than `:retry` means `:fail`, so a classifier that falls through a `cond` fails safe rather than looping.
### Bounding the whole run, not just one call
[Section titled “Bounding the whole run, not just one call”](#bounding-the-whole-run-not-just-one-call)
Resilience options bound a single HTTP call; `:max-turns` bounds how many the loop may make. Both matter — a model that keeps calling tools will otherwise spend a long time succeeding.
```clojure
(def llm
(client/create-client
{:base-url "https://api.openai.com/v1"
:model "gpt-4.1"
:timeout-ms 20000 ; per call
:max-turns 5 ; per run
:retries 2
:on-metric (fn [m] (when (= "run" (:event m))
(println "run took" (:ms m) "ms over" (:turns m) "turns")))}))
(let [r (try (client/run llm "Reindex everything" {:toolkit toolkit})
(catch Throwable e
;; an LLM failure that outlived the retry budget arrives here
{:status "failed" :text (ex-message e) :ex-data (ex-data e)}))]
(case (:status r)
"done" (println (:text r))
"incomplete" (println "turn limit:" (:limit r))
"failed" (println "provider error:" (:text r))
(println (:status r))))
```
Only the LLM call throws. A tool that throws is converted to an error ToolResult and the run continues, which is why the `try` here is narrow rather than a blanket around everything.
## Options
[Section titled “Options”](#options)
| Option | Default | What it does |
| --------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------- |
| `:retries` | `0` | Extra attempts after the first. Bounds `:on-error` too. |
| `:retry-base-ms` | `500` | Backoff base; the delay for attempt *n* is `base * 2^n` plus 0–99ms of jitter. |
| `:timeout-ms` | none | Per HTTP call, passed to `koine.http/request`. See the host caveat above. |
| `:retryable-statuses` | none | Extra statuses added to the default retryable set. Widens it only — never shrinks it — and `:on-error` still has the last word. |
| `:on-error` | none | `(fn [info] :retry-or-:fail)`. Overrides the default verdict in either direction. |
### The default verdict
[Section titled “The default verdict”](#the-default-verdict)
| Condition | Default |
| --------------------------------- | -------------------------------------------------- |
| Transport failure (no status) | `:retry` — `:retryable?` is true, `:status` is nil |
| 429, 500, 502, 503, 504, 529 | `:retry` |
| Anything in `:retryable-statuses` | `:retry` |
| Any other non-2xx | `:fail` |
### The classifier has the same backoff base
[Section titled “The classifier has the same backoff base”](#the-classifier-has-the-same-backoff-base)
`Classifier` carries the same knob under the same name, the same default of `500`, and the same `base * 2^attempt` shape — minus the jitter, which the classifier path does not add. See [`create-classifier`](/api/clojure/judge/classifier/).
## See also
[Section titled “See also”](#see-also)
* [`toolnexus.client/create-client`](/api/clojure/client/create/) — The unified client: system prompt, skills injection, parallel and chained tool calls, retries, memory.
* [`toolnexus.client/run`](/api/clojure/client/run/) — Send a prompt, let the loop call tools until the model stops, get a RunResult.
* [`toolnexus.client/create-client`](/api/clojure/client/metrics/) — Emit one metric event per model call and per tool call: tokens, latency, outcome.
* [`toolnexus.client/in-memory-store`](/api/clojure/client/store/) — Swap in-memory history for your own store so a conversation survives a process restart.
# toolnexus.client/run
> Send a prompt, let the loop call tools until the model stops, get a RunResult. — Clojure.
**Clojure (JVM) + cljgo** · package `net.clojars.muthuishere/toolnexus` · [SPEC §8](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [clojure/src/toolnexus/client.cljc](https://github.com/muthuishere/toolnexus/blob/main/clojure/src/toolnexus/client.cljc)
```clojure
(run client prompt {:keys [toolkit history on-event conversation-id]})
;; => RunResult
;; {:text "the final assistant text"
;; :messages [{:role "user" :content "…"} …] ; the whole transcript
;; :tool-calls [{:name :args :output :isError :metadata} …]
;; :tool-call-count 3
;; :turns 4 ; LLM round trips made
;; :usage {:prompt-tokens 0 :completion-tokens 0 :total-tokens 0}
;; :model "claude-sonnet-4-5"
;; :status "done" | "pending" | "incomplete"
;; :limit "maxTurns" ; only when :status is "incomplete"
;; :pending Request} ; only when :status is "pending"
```
`run` is the whole agent loop in one blocking call. It assembles the system message (`:system-prompt` + a blank line + the toolkit’s skills prompt, empty parts dropped), emits the toolkit’s schema in the client’s style, posts, executes whatever tools the model asked for, feeds the results back in the provider’s own shape, and repeats until the model answers with text or `:max-turns` is reached.
Tool calls within one turn run **in parallel** and their results are ordered by **call order**, not completion order. That is not cosmetic: a scrambled transcript pairs the wrong output with the wrong `tool_call_id`, and the model never sees a crash, only a lie. Execution uses `koine.process/run-async!` rather than `future`, because Clojure’s future-pool threads are non-daemon with a 60-second keep-alive and would hold a consumer’s process open long after its program finished.
A tool that throws does not take the run down. `toolnexus.tool/execute` converts the throw into an error ToolResult, the model sees it, and the loop continues — SPEC §0.8. An **LLM** failure is the opposite: it throws an `ex-info`, because a 500 from the provider is not something the model can be shown and asked to retry.
The three terminal statuses are the whole contract. `"done"` means the model produced text. `"incomplete"` means `:max-turns` fired first, and `:limit` is `"maxTurns"`. `"pending"` means a tool suspended and no `:wait-for` was configured, and `:pending` carries the [`Request`](/api/clojure/suspension/pending/) that parked it.
## When to use it
[Section titled “When to use it”](#when-to-use-it)
* **Every time you want the model to actually do something** — this is the entry point the whole library exists to feed.
* **When tools are chained** — the model calls `grep`, reads the output, then calls `read`; that is several turns, and `run` handles all of them under one `:max-turns` budget.
* **When you want the transcript back** — `:messages` is the full conversation, ready to hand to the next `run` as `:history`.
## Why this and not the alternative
[Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative)
Use call-provider when there is nothing to loop over
`toolnexus.client/call-provider` sends a body map you built through the same endpoint, headers, retry policy and `llm` metric — with no loop, no toolkit and no tool execution. Prefer it for single-turn work (classification, a translation, a one-shot completion) where a full agent loop is pure overhead. `run` is for the case where the model may need several steps and several tools to answer.
For a conversation that spans several user messages, do not stitch transcripts by hand: pass `:conversation-id` and let the client’s [store](/api/clojure/client/store/) keep the history — see [conversations](/api/clojure/client/conversation/).
## Examples
[Section titled “Examples”](#examples)
### One prompt, one answer
[Section titled “One prompt, one answer”](#one-prompt-one-answer)
```clojure
(require '[toolnexus.core :as tn]
'[toolnexus.client :as client])
(def toolkit (tn/build {:mcp "./examples/mcp.json" :skills "./examples/skills"}))
;; The API key is read from the environment by the client; never hard-code it.
(def llm (client/create-client {:base-url "https://api.openai.com/v1"
:model "gpt-4.1"}))
(let [r (client/run llm "List the files in this directory" {:toolkit toolkit})]
(println (:status r) "in" (:turns r) "turns")
(println (:text r))
(doseq [c (:tool-calls r)]
(println " " (:name c) "->" (if (:isError c) "ERROR" "ok"))))
```
`:tool-calls` is a flat record of every call across every turn, in the order they were made, each with the `:args` the model supplied and the `:output` your tool produced.
### Watching the run as it happens
[Section titled “Watching the run as it happens”](#watching-the-run-as-it-happens)
`:on-event` is a synchronous sink for the event vocabulary this non-streaming loop can honestly produce. There are no text deltas — see [streaming](/api/clojure/client/stream/) for why.
```clojure
(def events (atom []))
(client/run llm "Check the build and fix it"
{:toolkit toolkit
:on-event (fn [ev]
(swap! events conj ev)
(case (:type ev)
"tool_call" (println "->" (:name ev) (:args ev))
"tool_result" (println "<-" (:name ev)
(if (:isError ev) "ERROR" "ok"))
"pending" (println "!! waiting on"
(get-in ev [:request :prompt]))
"usage" nil
"done" (println "==" (:status (:result ev)))
nil))})
```
| Event `:type` | Payload | When |
| ------------- | ---------------------------------- | --------------------------------------------------------------------------------- |
| `tool_call` | `:id` `:name` `:args` | Once per call, before execution starts. |
| `tool_result` | `:id` `:name` `:output` `:isError` | Once per call, after the result lands and after any suspension was resolved. |
| `pending` | `:request` | **Before** `:wait-for` runs, so a channel handler can push the link in real time. |
| `usage` | `:usage` | After each LLM round trip, carrying the running total. |
| `done` | `:result` | Once, with the same RunResult `run` returns. |
### The full call shape — history, a turn limit, and a pending outcome
[Section titled “The full call shape — history, a turn limit, and a pending outcome”](#the-full-call-shape--history-a-turn-limit-and-a-pending-outcome)
```clojure
(def llm (client/create-client {:base-url "https://api.anthropic.com"
:style "anthropic"
:model "claude-sonnet-4-5"
:max-turns 6}))
(def r
(client/run llm "Deploy to staging"
{:toolkit toolkit
;; an explicit :history always wins over the store
:history [{:role "user" :content "The branch is release/1.4"}
{:role "assistant" :content "Noted."}]}))
(case (:status r)
"done" (println (:text r))
"incomplete" (println "hit the turn limit:" (:limit r))
"pending" (println "parked on:"
(get-in r [:pending :kind])
(get-in r [:pending :prompt])
(get-in r [:pending :url])))
;; Usage is summed across every turn of the run.
(:usage r) ;; => {:prompt-tokens 3120 :completion-tokens 412 :total-tokens 3532}
```
A `"pending"` result is not an error — the run stopped cleanly, and the transcript in `:messages` holds every call up to and including the one that parked. Configure [`:wait-for`](/api/clojure/suspension/wait-for/) if you want the run to resolve suspensions in place instead.
### What you get back
[Section titled “What you get back”](#what-you-get-back)
| Key | Type | What it is |
| ------------------ | ------ | ------------------------------------------------------------------------------------------------------------------- |
| `:text` | string | Final assistant text. The pending prompt when `:status` is `"pending"`; `""` on an anthropic-style turn-limit exit. |
| `:messages` | vector | The full transcript, in the provider’s own message shape. |
| `:tool-calls` | vector | Every call: `:name` `:args` `:output` `:isError` `:metadata`. |
| `:tool-call-count` | int | The count of the above. |
| `:turns` | int | LLM round trips actually made. |
| `:usage` | map | `:prompt-tokens` / `:completion-tokens` / `:total-tokens`, summed across turns. |
| `:model` | string | Echoed from the client. |
| `:status` | string | `"done"` · `"incomplete"` · `"pending"`. |
| `:limit` | string | `"maxTurns"`, present only when `"incomplete"`. |
| `:pending` | map | The §10 `Request`, present only when `"pending"`. |
## See also
[Section titled “See also”](#see-also)
* [`toolnexus.client/create-client`](/api/clojure/client/create/) — The unified client: system prompt, skills injection, parallel and chained tool calls, retries, memory.
* [`toolnexus.client/run`](/api/clojure/client/conversation/) — Keep a transcript across turns so the model remembers what it already did.
* [`toolnexus.client/in-memory-store`](/api/clojure/client/store/) — Swap in-memory history for your own store so a conversation survives a process restart.
* [`toolnexus.client/create-client`](/api/clojure/client/metrics/) — Emit one metric event per model call and per tool call: tokens, latency, outcome.
* [streaming](/api/clojure/client/stream/) — why there are no text deltas, and what the event sink gives you instead.
# toolnexus.client/in-memory-store
> Swap in-memory history for your own store so a conversation survives a process restart. — Clojure.
**Clojure (JVM) + cljgo** · package `net.clojars.muthuishere/toolnexus` · [SPEC §8](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [clojure/src/toolnexus/client.cljc](https://github.com/muthuishere/toolnexus/blob/main/clojure/src/toolnexus/client.cljc)
```clojure
(in-memory-store) ; => the shipped default, per-client, process lifetime
;; A store is exactly two operations. Any map with these keys is a store:
;; {:get (fn [id] messages-or-nil)
;; :save (fn [id msgs] nil)}
(create-client {:base-url "…" :model "…" :store my-store})
```
A store is two closures in a map, and that is the entire interface. `:get` is handed a conversation id and returns the messages it holds, or `nil` if it holds none. `:save` is handed an id and the finished transcript and returns nothing. There is no protocol, no record and no `deftype` — a plain map plus closures ports to any Clojure host without behaving differently on either, which is the same reason a `Tool` is a map in this port.
`in-memory-store` is the shipped default. `create-client` attaches a **fresh** one per client, so two clients built in the same process never share a transcript by accident. It is an atom of `id -> messages` with process lifetime: perfect for a CLI or a test, gone on restart.
Because the surface is this small, a file store, a Postgres store or a Redis store is a few lines and `toolnexus.client` learns nothing about any of them. Only `run` calls the store, and only when [`:conversation-id`](/api/clojure/client/conversation/) is supplied.
## When to use it
[Section titled “When to use it”](#when-to-use-it)
* **Call `in-memory-store` explicitly** when you want two clients to *share* one transcript store, or when you want to reach into it yourself between runs.
* **Supply your own** the moment conversations must outlive the process, be shared across instances, or be subject to a retention policy.
* **Neither** for one-shot runs — with no `:conversation-id`, the store is never touched.
## Why this and not the alternative
[Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative)
Pass :history instead when your app already owns the messages
If the transcript already lives in your database and your code already loads it, a store is an extra indirection buying nothing. Pass `:history` to `run` and persist the returned `:messages` yourself — that path wins over the store and keeps one copy of the truth. Use a store when you want the client to do the remembering, not when you already do.
The two-operation shape is also what keeps this honest across hosts: anything richer (streaming appends, transactions, TTLs) would need host-specific machinery, and this port has none.
## Examples
[Section titled “Examples”](#examples)
### The default, made explicit and shared
[Section titled “The default, made explicit and shared”](#the-default-made-explicit-and-shared)
```clojure
(require '[toolnexus.client :as client])
(def shared (client/in-memory-store))
(def fast (client/create-client {:base-url "https://api.openai.com/v1"
:model "gpt-4.1-mini" :store shared}))
(def smart (client/create-client {:base-url "https://api.openai.com/v1"
:model "gpt-4.1" :store shared}))
;; the cheap model takes the first turn …
(client/run fast "Triage this stack trace" {:toolkit toolkit :conversation-id "t-9"})
;; … and the strong one continues the SAME conversation
(client/run smart "Now write the fix" {:toolkit toolkit :conversation-id "t-9"})
```
Without the shared store each client would have kept its own, and the second run would have started from nothing.
### A store on disk
[Section titled “A store on disk”](#a-store-on-disk)
```clojure
(require '[koine.fs :as fs]
'[koine.json :as json]
'[toolnexus.tool :as tool])
(defn file-store
"One JSON file per conversation, under `dir`. Survives a restart."
[dir]
(fs/mkdirs! dir)
;; toolnexus.tool/sanitize is exactly the [^a-zA-Z0-9_-] -> _ rule (SPEC §0.2)
(let [path (fn [id] (str dir "/" (tool/sanitize id) ".json"))]
{:get (fn [id]
(let [p (path id)]
(when (fs/exists? p) (json/read-str (fs/read-file p)))))
:save (fn [id msgs]
(fs/write-file (path id) (json/write-str msgs))
nil)}))
(def llm (client/create-client {:base-url "https://api.anthropic.com"
:style "anthropic"
:model "claude-sonnet-4-5"
:store (file-store "./conversations")}))
```
Sanitising the id into the filename matters: a conversation id is caller-supplied data, and it should never be able to choose a path.
### A store with a cap, and reading it back
[Section titled “A store with a cap, and reading it back”](#a-store-with-a-cap-and-reading-it-back)
Nothing stops a store from editing what it saves. Trimming there rather than in the loop keeps the policy in one place, and `:get` returns whatever `:save` wrote.
```clojure
(defn capped-store
"Keep only the most recent `n` messages of each conversation."
[n]
(let [a (atom {})]
{:get (fn [id] (get @a id))
:save (fn [id msgs]
(swap! a assoc id (vec (take-last n msgs)))
nil)
;; extra keys are ignored by the client — this one is for your own use
:dump (fn [] @a)}))
(def store (capped-store 40))
(def llm (client/create-client {:base-url "https://api.openai.com/v1"
:model "gpt-4.1" :store store}))
(client/run llm "Start the audit" {:toolkit toolkit :conversation-id "audit-1"})
;; inspect what was kept
(count ((:get store) "audit-1"))
```
Trimming a transcript is a real decision, not a detail: cutting mid-tool-call leaves a `tool_call_id` with no matching result, and some providers reject that. Trim at message boundaries you understand, or keep whole turns.
### The contract
[Section titled “The contract”](#the-contract)
| Key | Signature | Called when |
| ------- | --------------------------- | ---------------------------------------------------------- |
| `:get` | `(fn [id] messages-or-nil)` | Before a run, only if `:conversation-id` was supplied. |
| `:save` | `(fn [id msgs] nil)` | After **every** run exit — including the `:max-turns` one. |
`msgs` is the RunResult’s `:messages`, in the provider’s own message shape.
## See also
[Section titled “See also”](#see-also)
* [`toolnexus.client/create-client`](/api/clojure/client/create/) — The unified client: system prompt, skills injection, parallel and chained tool calls, retries, memory.
* [`toolnexus.client/run`](/api/clojure/client/run/) — Send a prompt, let the loop call tools until the model stops, get a RunResult.
* [`toolnexus.client/run`](/api/clojure/client/conversation/) — Keep a transcript across turns so the model remembers what it already did.
* [`toolnexus.client/create-client`](/api/clojure/client/metrics/) — Emit one metric event per model call and per tool call: tokens, latency, outcome.
# stream — token and event streaming
> The streaming loop: text deltas, tool-call events, and suspension events as they happen. — Clojure.
**Clojure (JVM) + cljgo** · package `net.clojars.muthuishere/toolnexus` · [SPEC §8](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [clojure/src/toolnexus/client.cljc](https://github.com/muthuishere/toolnexus/blob/main/clojure/src/toolnexus/client.cljc)
Not available in Clojure
There is no streaming loop in this port. `toolnexus.client/run` takes an `:on-event` sink that emits `tool_call`, `tool_result`, `pending`, `usage` and `done` — but **no text deltas**, because the loop buffers the whole response and synthesising deltas out of a buffered body would be a lie. Real SSE is a separate seam (`koine.stream/sse-post`, proven on both hosts) and is not wired in.
The streaming loop: text deltas, tool-call events, and suspension events as they happen.
## What to use instead
[Section titled “What to use instead”](#what-to-use-instead)
Everything in the §8 event vocabulary except text deltas is available today, through `:on-event` on [`toolnexus.client/run`](/api/clojure/client/run/). The sink is synchronous and called on the loop’s own thread, so a handler that blocks slows the run down — push onto an atom, a queue or a socket and return.
For the progress-reporting case this covers what a delta stream would: the user sees which tool is running, whether it succeeded, and what the run cost, in real time.
```clojure
(require '[toolnexus.client :as client])
(client/run llm "Audit the repo and fix the lint errors"
{:toolkit toolkit
:on-event (fn [ev]
(case (:type ev)
"tool_call" (println "running" (:name ev))
"tool_result" (println " " (:name ev)
(if (:isError ev) "failed" "ok"))
"pending" (notify-user! (get-in ev [:request :prompt]))
"usage" (println " tokens so far"
(get-in ev [:usage :total-tokens]))
"done" (println "finished:" (:status (:result ev)))
nil))})
```
The `pending` event is the one worth wiring first: it fires **before** `:wait-for` runs, so a chat or web front end can push an approval prompt or a login link to the user at the moment the tool parks rather than after the answer comes back.
What you cannot get is the assistant’s prose arriving word by word. `run` returns the finished `:text` on the RunResult, and the whole response lands at once. If character-level output is a hard requirement for your interface, call the provider’s streaming endpoint yourself with `koine.stream/sse-post` and use the toolkit only for the schema ([`to-openai`](/api/clojure/adapters/openai/) / [`to-anthropic`](/api/clojure/adapters/anthropic/)) and for `toolnexus.core/execute` — the tool sources in this port work identically without the client loop.
## Tool events under tool tiers (0.22.0)
[Section titled “Tool events under tool tiers (0.22.0)”](#tool-events-under-tool-tiers-0220)
With [`:tool-tiers`](/api/clojure/client/tool-tiers/) set, the `:on-event` `tool_call` / `tool_result` events of a `call_tool` call name the **real** tool and its inner args, and carry `:via "call_tool"`.
## See also
[Section titled “See also”](#see-also)
* [`toolnexus.client/create-client`](/api/clojure/client/create/) — The unified client: system prompt, skills injection, parallel and chained tool calls, retries, memory.
* [`toolnexus.client/run`](/api/clojure/client/run/) — Send a prompt, let the loop call tools until the model stops, get a RunResult.
* [`toolnexus.client/run`](/api/clojure/client/conversation/) — Keep a transcript across turns so the model remembers what it already did.
* [`toolnexus.client/in-memory-store`](/api/clojure/client/store/) — Swap in-memory history for your own store so a conversation survives a process restart.
# tool-tiers
> Send only core tool schemas; the model loads the rest with tool_search and calls them by name or through call_tool. The plan is frozen until resetToolTiers. — Clojure.
**Clojure (JVM) + cljgo** · package `net.clojars.muthuishere/toolnexus` · [SPEC §8](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [clojure/src/toolnexus/client.cljc](https://github.com/muthuishere/toolnexus/blob/main/clojure/src/toolnexus/client.cljc)
```clojure
(create-client {… :tool-tiers {:core ["read" "grep"] ; schemas always sent
:deferred ["deploy"] ; name only in tool_search's description
:budget-tokens 2000 ; optional; else 5% of :context-window
:context-window 200000
:usage-count (fn [name] n) ; optional, for the budget
:count-tokens (fn [v] n)}}) ; optional
(client/tool-tier-plan client toolkit) ; => {"read" "core" "lint" "listed" "deploy" "deferred"}
(client/reset-tool-tiers! client toolkit) ; rebuild at the next request (call at session start)
(client/reset-tool-tiers! client) ; forget every plan
```
With `:tool-tiers` the tools array of every request is the core tools sorted by name, then two meta tools: `tool_search` and `call_tool`. Every other tool is *listed* (`- name — first line` in `tool_search`’s description) or *deferred* (name only). The model loads schemas with `tool_search {:query "select:a,b"}` (exact names) or a keyword query (name match 3, description match 1, default 5 results); the definitions come back as a **tool result**, so the tools array — and the cached prefix — never change mid-session. A loaded tool is called by name, or as `call_tool {:name :arguments}`, which the loop unwraps **before** `:before-tool`, so permission gates and hints see the real tool. `:on-event` `tool_call` / `tool_result` events name the real tool and carry `:via "call_tool"`.
The plan is built on a toolkit’s first request and then frozen per toolkit identity (which `tool/add-tools` keeps). A tool added later is deferred, absent from the description, and still found by search. Over the budget, the least-used core tool moves to listed (ties: larger schema, then name), then the least-used listed tool to deferred. `tool_search` is engine-internal and read-only; it never goes through `:before-tool`.
## When to use it
[Section titled “When to use it”](#when-to-use-it)
* **Dozens of tools** — MCP servers plus builtins quickly cost thousands of tokens per request.
* **With [`:prompt-cache`](/api/clojure/client/prompt-cache/)** — a tools array that never changes is a prefix that stays cached.
* **When tools arrive mid-session** — a new MCP server’s tools are found by search instead of changing the array.
## Why this and not the alternative
[Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative)
A smaller toolkit is simpler when you know the tools up front
If a session only ever needs five tools, build a toolkit of those five — no meta tools, no extra round trip to load a schema. Tool tiers pay off when the full set is large or unknown.
## Examples
[Section titled “Examples”](#examples)
### 1. Only core schemas are sent
[Section titled “1. Only core schemas are sent”](#1-only-core-schemas-are-sent)
```clojure
(require '[toolnexus.client :as client]
'[toolnexus.tool :as tool]
'[toolnexus.native :as native])
(println)
(defn t [n d] (native/native-tool {:name n :description d :run (fn [_] (str n " ran"))}))
(def tk (tool/toolkit [(t "read_notes" "Read the notes.") (t "lint" "Lint the code.")
(t "deploy" "Deploy the service.")]))
(def llm (client/create-client {:base-url "https://api.openai.com/v1" :model "gpt-4.1" :api-key "YOUR_KEY_HERE"
:tool-tiers {:core ["read_notes"] :deferred ["deploy"]}}))
(assert (= {"read_notes" "core" "lint" "listed" "deploy" "deferred"} (client/tool-tier-plan llm tk)))
(println "OK")
```
### 2. The model searches, then calls through `call_tool`
[Section titled “2. The model searches, then calls through call\_tool”](#2-the-model-searches-then-calls-through-call_tool)
```clojure
(require '[toolnexus.client :as client]
'[toolnexus.tool :as tool]
'[toolnexus.native :as native]
'[koine.json :as json])
(println)
(defn body-of [b] (if (string? b) (json/read-str b) b))
(defn mock-llm
"An :http-client that records every request body and answers from `replies` in order."
[seen replies]
(let [q (atom replies)]
(fn [_url _headers body]
(swap! seen conj (body-of body))
(let [r (first @q)]
(swap! q #(if (next %) (vec (rest %)) %))
{:status 200 :headers {} :body (json/write-str r)}))))
(defn call [id nm args] {:choices [{:message {:role "assistant" :content nil
:tool_calls [{:id id :type "function"
:function {:name nm :arguments (json/write-str args)}}]}}]})
(def seen (atom []))
(def gated (atom []))
(def tk (tool/toolkit [(native/native-tool {:name "read_notes" :description "Read." :run (fn [_] "n")})
(native/native-tool {:name "deploy" :description "Deploy." :run (fn [a] (str "deployed " (:env a)))})]))
(def llm (client/create-client
{:base-url "https://api.openai.com/v1" :model "gpt-4.1" :api-key "YOUR_KEY_HERE"
:tool-tiers {:core ["read_notes"]}
:hooks {:before-tool (fn [ev] (swap! gated conj (:name ev)) nil)}
:http-client (mock-llm seen [(call "s1" "tool_search" {:query "select:deploy"})
(call "c1" "call_tool" {:name "deploy" :arguments {:env "prod"}})
{:choices [{:message {:role "assistant" :content "done"}}]}])}))
(def r (client/run llm "deploy to prod" {:toolkit tk}))
(assert (= "done" (:text r)))
(assert (= ["read_notes" "tool_search" "call_tool"]
(mapv #(get-in % [:function :name]) (:tools (first @seen)))))
(assert (apply = (map #(json/write-str (:tools %)) @seen)) "the tools array never changed")
(assert (= ["deploy"] @gated) "the gate saw the real tool, never tool_search")
(assert (some #(= "deployed prod" (:output %)) (:tool-calls r)))
(println "OK")
```
### 3. A late tool is found by search; reset rebuilds the plan
[Section titled “3. A late tool is found by search; reset rebuilds the plan”](#3-a-late-tool-is-found-by-search-reset-rebuilds-the-plan)
```clojure
(require '[toolnexus.client :as client]
'[toolnexus.tool :as tool]
'[toolnexus.native :as native])
(println)
(defn t [n] (native/native-tool {:name n :description (str n " things") :run (fn [_] n)}))
(def tk (tool/toolkit [(t "read_notes") (t "lint")]))
(def llm (client/create-client {:base-url "https://api.openai.com/v1" :model "gpt-4.1" :api-key "YOUR_KEY_HERE"
:tool-tiers {:core ["read_notes"]}}))
(assert (= {"read_notes" "core" "lint" "listed"} (client/tool-tier-plan llm tk)))
(def tk2 (tool/add-tools tk [(t "late")]))
(assert (= {"read_notes" "core" "lint" "listed"} (client/tool-tier-plan llm tk2)) "the plan is frozen")
(client/reset-tool-tiers! llm tk2)
(assert (= "listed" (get (client/tool-tier-plan llm tk2) "late")) "rebuilt at the next session start")
(println "OK")
```
## See also
[Section titled “See also”](#see-also)
* [`prompt-cache`](/api/clojure/client/prompt-cache/) — why a stable tools array matters.
* [`hooks`](/api/clojure/client/hooks/) — `:before-tool` sees the unwrapped call.
# toolnexus.agents.compaction/compactor
> Summarise older turns when the transcript grows, so a long-lived agent stays inside its context window. — Clojure.
**Clojure (JVM) + cljgo** · package `net.clojars.muthuishere/toolnexus` · [SPEC §7F](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [clojure/src/toolnexus/agents/compaction.cljc](https://github.com/muthuishere/toolnexus/blob/main/clojure/src/toolnexus/agents/compaction.cljc)
Supported on Clojure (JVM) and on cljgo
One `.cljc` source tree, zero reader conditionals, verified in all five execution modes — `jvm-main`, `jvm-repl`, `cljgo-aot`, `cljgo-run`, `cljgo-repl`. Every example below is extracted and executed against `deps.edn` on the JVM **and** through `build.cljgo` on cljgo, including both REPLs.
```clojure
(compactor {:max-tokens 120000 ; REQUIRED — compact only when the estimate exceeds this
:summarize (fn [older] "…") ; REQUIRED — MAY call an LLM; your choice
:keep-tail 60000 ; default: half of :max-tokens
:count-tokens estimate-tokens ; default: ceil(chars/4) summed over messages
:flush-to-memory false}) ; default: off
;; => (fn [event] …) returning {:messages [...]} when it compacts, nil when it does not
```
`compactor` builds a `:before-llm` hook (§8) — the same seam `toolnexus.client/create-client` takes under `:hooks`. Below `:max-tokens` it returns `nil` and the transcript is untouched, byte-identical to a run with no compactor. Above it, the transcript becomes `[leading system message (verbatim), summary system message, (flush reminder?), …tail]`, where the tail is the largest **user-boundary** slice that fits `:keep-tail` — so a `tool` message is never orphaned from the `assistant` turn carrying its `tool_call_id`.
## When to use it
[Section titled “When to use it”](#when-to-use-it)
* **A long-lived agent’s transcript keeps growing** and it must stay inside the model’s context window without losing the thread.
* **You want summarisation on your own terms** — `:summarize` is a plain function. Call an LLM from it, or don’t; `compactor` never makes a model call on your behalf.
* **You want it inside the existing loop**, not a second code path — it rides `:before-llm`, the same hook whose rewritten transcript flows into the run result and the conversation store.
## Why this and not the alternative
[Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative)
It's a hook, not a different client
There is no “compacting client”. `compactor` returns an ordinary function; hand it to `(create-client {:hooks {:before-llm …}})` and the loop behaves exactly as before, except the transcript it sends may now be a summary plus a tail. Leave it out and nothing changes shape.
## Examples
[Section titled “Examples”](#examples)
### 1. Under budget is a no-op
[Section titled “1. Under budget is a no-op”](#1-under-budget-is-a-no-op)
The hook returns `nil`, which the loop reads as “change nothing” — which is what makes a compactor safe to wire in before you actually need it.
```clojure
(require '[toolnexus.agents.compaction :as compaction])
(def msgs [{:role "system" :content "you are terse"}
{:role "user" :content "hello"}
{:role "assistant" :content "hi"}])
(def hook (compaction/compactor {:max-tokens 100000
:summarize (fn [_] "never called")}))
(println "under budget =>" (pr-str (hook {:messages msgs})))
(assert (nil? (hook {:messages msgs})))
(println "OK")
```
### 2. Over budget — system prompt kept, older turns summarised
[Section titled “2. Over budget — system prompt kept, older turns summarised”](#2-over-budget--system-prompt-kept-older-turns-summarised)
`:count-tokens` is injected so the example is deterministic: one “token” per message makes `:max-tokens 4` mean “more than four messages”.
```clojure
(require '[toolnexus.agents.compaction :as compaction]
'[clojure.string :as str])
(def transcript
(into [{:role "system" :content "you are terse"}]
(mapcat (fn [i] [{:role "user" :content (str "q" i)}
{:role "assistant" :content (str "a" i)}])
(range 5))))
(def hook
(compaction/compactor
{:max-tokens 4
:keep-tail 3
:count-tokens count ; one "token" per message
:summarize (fn [older] (str "covered " (count older) " earlier messages"))}))
(def out (:messages (hook {:messages transcript})))
(println "before:" (count transcript) "after:" (count out))
(println "summary:" (pr-str (:content (second out))))
;; The leading system prompt survives verbatim — identity is never summarised.
(assert (= (first transcript) (first out)))
;; The summary carries the prefix every port emits.
(assert (str/starts-with? (:content (second out)) "[Summary of earlier conversation]\n"))
;; The retained tail begins at a user turn, so no tool message is orphaned.
(assert (= "user" (:role (nth out 2))))
(assert (< (count out) (count transcript)))
(println "OK")
```
### 3. Wired into a client
[Section titled “3. Wired into a client”](#3-wired-into-a-client)
`:hooks` is the seam; nothing else about the client changes.
```clojure
(require '[toolnexus.agents.compaction :as compaction]
'[toolnexus.client :as client])
;; A real summariser would call an LLM here. Keeping it pure makes this example
;; hermetic — and the library never summarises on your behalf by default.
(defn elide [older] (str "…" (count older) " earlier messages elided…"))
(def c (client/create-client
{:base-url "http://127.0.0.1:1" ; never called in this example
:style "openai"
:model "gpt-4o-mini"
:api-key "not-used"
:hooks {:before-llm (compaction/compactor
{:max-tokens 120000 :summarize elide})}}))
(assert (fn? (get-in c [:hooks :before-llm])))
(println "client carries the compactor on :before-llm")
(println "OK")
```
## `estimate-tokens`
[Section titled “estimate-tokens”](#estimate-tokens)
The default estimator: `ceil(chars/4)` over each message’s JSON serialisation, summed. An **estimator, not a tokenizer** — exactness is your call, which is why `:count-tokens` exists.
```clojure
(require '[toolnexus.agents.compaction :as compaction])
(println "estimate =>" (compaction/estimate-tokens [{:role "user" :content "hello there"}]))
(assert (pos? (compaction/estimate-tokens [{:role "user" :content "hello there"}])))
;; Additive: two messages estimate as the sum of their individual estimates.
(assert (= (+ (compaction/estimate-tokens [{:role "user" :content "a"}])
(compaction/estimate-tokens [{:role "user" :content "bb"}]))
(compaction/estimate-tokens [{:role "user" :content "a"}
{:role "user" :content "bb"}])))
(println "OK")
```
Counts are not comparable across ports
Each port serialises JSON with its own writer, so the *number* `estimate-tokens` returns may differ between Clojure and JavaScript for the same transcript. SPEC §7F pins the formula, not the byte count. If you need an exact budget, pass `:count-tokens`.
## `:on-compact` — hear about each compaction (0.22.0)
[Section titled “:on-compact — hear about each compaction (0.22.0)”](#on-compact--hear-about-each-compaction-0220)
`:on-compact` is called after each compaction, never on a no-op, with `{:tokens-before :tokens-after :messages-before :messages-after :summarized :summary :folded}`. `:folded` is the summarized messages verbatim — the loop saves only the compacted transcript, so this is the host’s one chance to persist them.
```clojure
(compaction/compactor {:max-tokens 8000
:summarize summarize-fn
:on-compact (fn [{:keys [tokens-before tokens-after]}]
(println "context compacted" tokens-before "->" tokens-after))})
```
Also fixed in 0.22.0: the sole user prompt of a turn followed only by tool traffic is kept (the result is `[system?, prompt, summary]`), and a compaction that would not make the transcript smaller is skipped.
## See also
[Section titled “See also”](#see-also)
* [`toolnexus.client/create-client`](/toolnexus/api/clojure/client/create/) — the `:hooks` seam
* [`toolnexus.agents.home/memory-tool`](/toolnexus/api/clojure/persona/memory-tool/) — what `:flush-to-memory` tells the model to write to
# toolnexus.http/http-tool
> Turn an HTTP endpoint into a tool: URL, method, headers with ${ENV} expansion, and a schema. — Clojure.
**Clojure (JVM) + cljgo** · package `net.clojars.muthuishere/toolnexus` · [SPEC §7](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [clojure/src/toolnexus/http.cljc](https://github.com/muthuishere/toolnexus/blob/main/clojure/src/toolnexus/http.cljc)
```clojure
(http-tool {:name "get_user"
:description "Fetch a user by id"
:input-schema {:type "object" :properties {:id {:type "string"}} :required ["id"]}
:method :get ; default :get
:url "https://api.example.com/users/{id}"
:headers {"authorization" "Bearer ${API_TOKEN}"}
:query [:verbose]
:body "json" ; "json" (default) | "form" | "raw"
:timeout-ms 30000 ; MILLISECONDS, default 30000
:result-mode "text"}) ; "text" (default) | "json" | "status+text"
;; => {:name "get_user" :description "…" :input-schema {…}
;; :source "http" :execute (fn ([args]) ([args ctx]))}
```
`http-tool` declares a remote endpoint as a Tool. The model supplies args; the tool decides where each one goes. Consumption order is fixed and worth memorising: URL `{placeholder}` names first, then the names listed in `:query`, and whatever is left becomes the request body on any non-GET method. Nothing is sent twice, and an arg with nowhere to go is dropped rather than appended.
Status mapping follows SPEC §0.9. A 2xx returns `toolnexus.tool/success` with the body as output and `{:status n}` as metadata. A non-2xx returns `toolnexus.tool/failure` with `"HTTP : "` — a value the model reads and can act on, not an exception. A transport failure, where no status was ever produced, returns `"HTTP transport failure: "` with `{:error :timeout | :dns | :connect-failed | :transport}`.
That last case is why this namespace never catches an exception class. `koine.http/request` returns the failure as data, because the host exception types cannot be named portably — the JVM has `java.net.*` classes and cljgo has Go errors, and a `catch` naming either would be a reader conditional in disguise. The branch is on `koine.http/failed?` instead, so one source file behaves identically on both hosts.
Header values expand `${ENV_VAR}` from the environment at call time, not at declaration time. Nothing in the namespace logs, prints, or copies a header value into a ToolResult.
## When to use it
[Section titled “When to use it”](#when-to-use-it)
* **An internal service the model should be able to query** — a search endpoint, a status API, a lookup. Declare it and it is a tool.
* **A vendor REST API you have a token for** — put the token in the environment and reference it as `${VENDOR_TOKEN}` in a header, so the value never lands in the config, the repo, or a log line.
* **Anything a `native-tool` would only wrap** — if the function body would be “build a URL, set headers, return the body”, this already is that.
* **Endpoints that must not hang the loop** — `:timeout-ms` is enforced, and a timeout arrives as an error ToolResult rather than a stuck call.
## Why this and not the alternative
[Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative)
Use native-tool when there is logic, not just a call
[`toolnexus.native/native-tool`](/api/clojure/native/define-tool/) is the right choice as soon as you need to pick between two endpoints, retry on a specific body, reshape the response, or read the Context. `http-tool` has no hooks and no response transform beyond `:result-mode` — that is deliberate, and the moment you want one, you want a native tool that calls your own HTTP client.
An MCP server is the other alternative. If the endpoint already speaks MCP, connect it as a server instead and get its whole tool list, descriptions included, rather than declaring each route by hand.
## Examples
[Section titled “Examples”](#examples)
### A GET with a path placeholder and a secret header
[Section titled “A GET with a path placeholder and a secret header”](#a-get-with-a-path-placeholder-and-a-secret-header)
```clojure
(require '[toolnexus.http :as http]
'[toolnexus.tool :as tool])
(def get-user
(http/http-tool
{:name "get_user"
:description "Fetch a user by id"
:input-schema {:type "object"
:properties {:id {:type "string" :description "The user id"}}
:required ["id"]}
:method :get
:url "https://api.example.com/users/{id}"
;; Expanded from the environment at call time. Never logged, never returned.
:headers {"authorization" "Bearer ${API_TOKEN}"}}))
(:source get-user) ;=> "http"
(def tk (tool/toolkit [get-user]))
(tool/execute tk "get_user" {:id "u-42"})
;; 200 => {:output "{\"id\":\"u-42\",…}" :isError false :metadata {:status 200}}
;; 404 => {:output "HTTP 404: not found" :isError true :metadata {:status 404}}
```
Placeholder values are percent-encoded, so an id containing a space produces a URL the host will accept rather than a transport failure. A placeholder with no matching arg is left verbatim: a half-built URL that 404s is debuggable, a silently blanked one is not.
### A POST whose leftover args become the body
[Section titled “A POST whose leftover args become the body”](#a-post-whose-leftover-args-become-the-body)
```clojure
(require '[toolnexus.http :as http])
(def create-ticket
(http/http-tool
{:name "create_ticket"
:description "Open a support ticket for a project"
:input-schema {:type "object"
:properties {:project {:type "string"}
:notify {:type "boolean"}
:title {:type "string"}
:body {:type "string"}}
:required ["project" "title"]}
:method :post
:url "https://api.example.com/projects/{project}/tickets"
:query [:notify]
:body "json"
:headers {"authorization" "Bearer ${API_TOKEN}"}
:timeout-ms 10000
:result-mode "json"}))
((:execute create-ticket) {:project "acme" :notify true
:title "Login fails" :body "Since 09:00."})
;; POST https://api.example.com/projects/acme/tickets?notify=true
;; content-type: application/json
;; {"body":"Since 09:00.","title":"Login fails"}
```
`:project` was consumed by the placeholder and `:notify` by `:query`, so only `:title` and `:body` reach the request body. The `content-type` header is set from `:body` and does not need declaring. A GET never sends a body, whatever is left over.
### Timeouts, transport failures, and the three result modes
[Section titled “Timeouts, transport failures, and the three result modes”](#timeouts-transport-failures-and-the-three-result-modes)
```clojure
(require '[toolnexus.http :as http])
(def slow
(http/http-tool {:name "slow_probe"
:description "Probe an endpoint that may not answer"
:url "https://api.example.com/health"
:timeout-ms 1500 ; milliseconds, not seconds
:result-mode "status+text"}))
((:execute slow) {})
;; answered => {:output "HTTP 200\nok" :isError false :metadata {:status 200}}
;; never answered =>
;; {:output "HTTP transport failure: timeout" :isError true :metadata {:error :timeout}}
```
| Option | Default | What it does |
| --------------- | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `:name` | required | The tool name the model calls. |
| `:description` | `""` | What the model reads to decide whether to call it. |
| `:input-schema` | `{:type "object"}` | JSON Schema for the args, emitted verbatim by the adapters. |
| `:method` | `:get` | Any verb; a keyword or a string, lower-cased internally. |
| `:url` | required | May contain `{placeholder}` names filled from args and percent-encoded. |
| `:headers` | `{}` | Values expand `${ENV_VAR}` at call time. Never logged or returned. |
| `:query` | `[]` | Arg names to send as the query string. Nil-valued args are omitted. |
| `:body` | `"json"` | `"json"` sends the leftover args as a JSON object; `"form"` sends them URL-encoded, keys sorted; `"raw"` sends the first key’s value as `text/plain`. |
| `:timeout-ms` | `30000` | **Milliseconds.** Exceeding it is a transport failure, not a hang. |
| `:result-mode` | `"text"` | `"text"` returns the body as-is; `"json"` re-serialises it with sorted keys, falling back to the raw body when it does not parse; `"status+text"` returns `"HTTP \n"`. |
| Outcome | ToolResult |
| ---------------- | ------------------------------------------------------------------------------ |
| 2xx | `success`, output per `:result-mode`, metadata `{:status n}` |
| non-2xx | `failure`, output `"HTTP : "`, metadata `{:status n}` |
| no status at all | `failure`, output `"HTTP transport failure: "`, metadata `{:error kw}` |
\`:timeout-ms\` is milliseconds, and the builtin webfetch is not
`http-tool` takes `:timeout-ms` in milliseconds with a 30000 default. The built-in `webfetch` tool takes its `timeout` in **seconds** with a default of 30, because §4A pins that schema. Same duration, two units, two different tools — do not copy a number from one to the other.
## See also
[Section titled “See also”](#see-also)
* [`toolnexus.native/native-tool`](/api/clojure/native/define-tool/) — when the call needs logic around it, or the Context.
* [`toolnexus.builtin/builtin-tools`](/api/clojure/builtins/create/) — includes `webfetch`, the general-purpose fetch the model can point anywhere.
* [`toolnexus.adapter/to-openai`](/api/clojure/adapters/openai/) — how the declared schema reaches the model.
# toolnexus.classifier/create-classifier
> A sibling of the client: pre-declared typed questions in, calibrated answers out — no messages, no tool calling, no loop. — Clojure.
**Clojure (JVM) + cljgo** · package `net.clojars.muthuishere/toolnexus` · [SPEC §8B](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [clojure/src/toolnexus/classifier.cljc](https://github.com/muthuishere/toolnexus/blob/main/clojure/src/toolnexus/classifier.cljc)
```clojure
(create-classifier opts) ; => classifier (a plain map, defaults filled in)
(evaluate classifier state questions) ; => decision (a plain map)
;; opts — idiomatic kebab-case; none of these keys ever reach the wire
;; :style "systemone" (default) | "llm" | "custom" | "static"
;; :base-url "https://api.typesafe.ai/v1"
;; :model "jev-latest"
;; :api-key-env "TYPESAFE_API_KEY" — the NAME of an env var, never the value
;; :headers extra request headers; values expand ${ENV_VAR} at call time
;; :timeout-ms 10000 — bounds ONE request
;; :http-client (fn [url headers body] response)
;; :retries 2
;; :retryable-statuses extra statuses, ADDED to the default set
;; :on-error (fn [info] :retry | :fail) — the §8 classifier, reused
;; :request-params shallow-merged into the body, wins on collision
;; :body-transform (fn [body] body) — runs last, its return value is sent
;; :on-metric (fn [event] …) — the SAME §8 sink
;; :client :style "llm" only
;; :evaluate :style "custom" only — (fn [state questions] decision)
;; :decisions :style "static" only — [{:state … :questions … :response …}]
```
A sibling of the client: pre-declared typed questions in, calibrated answers out — no messages, no tool calling, no loop. `Tool` is the contract for an **action**; `Classifier` is the contract for a **judgment**. It has no messages, no streaming and no tool calling, so it never enters the §8 client loop and is never selected as a model for `run`/`ask`.
Everything here is **plain data**. A classifier is a map, a question is a map, a decision is a map — no record, no protocol, no `deftype`, because this file must load identically on the JVM and on cljgo. You can `assoc` into a classifier, print it, or hold several side by side. `evaluate` is a plain blocking call, as in the Go and Java ports; the Python port’s is a coroutine.
`create-classifier` builds nothing and connects to nothing. It fills in the §8B defaults, rejects an unknown `:style`, and rejects a style whose required option is missing — `"llm"` without `:client`, `"custom"` without `:evaluate` — **at construction**, not at the first call. Failures in this port are thrown `ex-info`s, unlike Go’s `(T, error)` and Elixir’s tuples; the `ex-data` carries the structured detail (`:question`, `:limit`, `:count`, `:status`, `:url`).
A classifier interprets; it never authorises
Its output is a reading of intent inside a boundary, never the boundary. Schema validity is not correctness: a decision can be confidently wrong, and “cannot hallucinate” means only that the returned value is in the declared schema. Numeric limits, permission checks and allowlists stay in code. Nothing here is a security control.
## When to use it
[Section titled “When to use it”](#when-to-use-it)
* You need a **judgment, not an action** — is this ticket asking for a refund, which desk owns it, how urgent is it — and you want a typed number back rather than prose you then parse.
* You want **many questions in one round trip** over one state ingest. Questions are independent: one answer is never context for another.
* You want a **threshold you can tune**: a calibrated probability is a number you can compare against a cutoff, which free text is not.
* You want the judgment **out of the loop** — a classifier makes no tool call, so it cannot act on what it concluded.
## Why this and not the alternative
[Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative)
Use the §8 client when you want an action or prose
[`toolnexus.client/create-client`](/api/clojure/client/create/) is for anything that talks, calls tools, streams or remembers. A classifier does none of those. If the answer has to be a sentence, or the model has to *do* something with its conclusion, that is a client run. If you only need a number out of a fixed set of questions, routing it through a chat completion costs a loop, a parse and a prompt-injection surface you do not need — and measured on one routing job the `llm` backend was 3.3–4.4× slower and 2.5–3.4× costlier than the System One wire.
A host that constructs no classifier observes byte-identical behaviour to a build without §8B, and constructing one alters no request the client loop makes. That is proven by a test, not asserted.
## Options
[Section titled “Options”](#options)
`opts` mirrors §8 `create-client` field-for-field wherever a field makes sense, so a host that has configured one has configured the other.
| Option | Default | What it does |
| --------------------- | ------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `:style` | `"systemone"` | `"systemone"`, `"llm"`, `"custom"` or `"static"`. Same slot as the client’s `:style`; the values differ because the wires differ. See [Backends](/judge/backends/). |
| `:base-url` | `"https://api.typesafe.ai/v1"` | The System One endpoint base; trailing slashes are trimmed. OpenRouter (`https://openrouter.ai/api/v1`) serves the same wire, and so do self-hosted and open-weights implementations. |
| `:model` | `"jev-latest"` | A floating alias. **Pin it** (e.g. `jev-1.13.0`) once thresholds are tuned — the decision’s `:model` echoes what actually answered, which may be more specific. |
| `:api-key-env` | `"TYPESAFE_API_KEY"` | The **name** of an environment variable, not the value. Read at call time, never logged. §8’s `:api-key` takes a value; this option deliberately does not. |
| `:headers` | — | Extra request headers. Keys are lower-cased; values expand `${ENV_VAR}` from the environment at call time and are never logged, identically to remote-MCP headers (§2). |
| `:timeout-ms` | `10000` | Bounds **one request**, not a run — a classifier has no loop to bound. Spelled in milliseconds here; §8B names the slot `timeout`. |
| `:http-client` | koine | `(fn [url headers body] response)` — the §8 Gap 2 injectable transport, scoped to the classifier path only. `body` is the marshalled canonical string; the response is a map with `:status` and `:body`. |
| `:retries` | `2` | Attempts after the first, on a retryable status or a network fault. Backoff is exponential from `:retry-base-ms` (`base * 2^attempt`), with no jitter; a `Retry-After` header still wins. |
| `:retry-base-ms` | `500` | Base of that backoff, in ms. The classifier’s half of the §8 client option it mirrors, same default — set it to `1` to take a retry test off the clock. The classifier’s backoff adds no jitter; the client’s does. |
| `:retryable-statuses` | — | Extra HTTP statuses to treat as retryable, **added** to the default set. It can only widen, never remove. |
| `:on-error` | retry when retryable | The §8 `ErrorInfo -> :retry \| :fail` classifier, **reused verbatim** from `toolnexus.client`. There is no second retry policy in this library, and no `:suspend` tier here. |
| `:request-params` | — | Shallow-merged over the base body; a param **wins** on collision. `:questions` is refused — it is what this seam *is*, and rewriting it is `:body-transform`’s job. |
| `:body-transform` | — | `(fn [body] body)` — runs last, and its return value is what is marshalled. Order: base body → `:request-params` → `:body-transform` → marshal. |
| `:on-metric` | — | Receives `"classifier.evaluate"` events (latency, tokens, model, status) in the **same** §8 sink, plus `"classifier.warning"` for degenerate criteria — whose text is in `:warning`, never `:error`. |
| `:client` | — | `:style "llm"` only: the §8 client to emulate over. Missing ⇒ throws at construction. |
| `:evaluate` | — | `:style "custom"` only: `(fn [state questions] decision)`. Every wire option is ignored. Missing ⇒ throws at construction. |
| `:decisions` | — | `:style "static"` only: a vector of `{:state … :questions … :response }`, keyed by the canonical request **and** the state. Named in §8B and gated at **core** tier by the options manifest — it is the backend CI runs on, so a port without it cannot run the shared fixtures. |
### Retries and `Retry-After`
[Section titled “Retries and Retry-After”](#retries-and-retry-after)
The default retryable set is `408`, `429`, `500`, `502`, `503`, `504`, `529`, plus every network fault. **`408` is the classifier’s addition** over the §8 client set — a request timeout on a single short POST is worth one more attempt. `:retryable-statuses` **adds** to that set and can never remove from it, so a host cannot drop `429` and lose `Retry-After` handling with it; `:on-error` still runs per attempt and has the final say, so returning `:fail` overrides a status listed there. A `Retry-After` header is honoured by the §8 delay-seconds rule and takes precedence over the exponential backoff.
### Secrets
[Section titled “Secrets”](#secrets)
The credential is read from the **named** environment variable at call time, and `${ENV_VAR}` header references expand at call time. No credential value and no expanded header value appears in any log, metric, error message or returned value. A `401`/`403` body is never echoed back, because a gateway happily reflects a bad `Authorization` header into its own error text — an authentication failure names the status and the endpoint and nothing else. Every other status carries its reported cause intact (truncated at 200 characters), so you can tell a limit error from a transport fault.
## Examples
[Section titled “Examples”](#examples)
### 1. The smallest useful call — one question, recorded, offline
[Section titled “1. The smallest useful call — one question, recorded, offline”](#1-the-smallest-useful-call--one-question-recorded-offline)
The `static` backend replays a recorded response keyed by the canonical request plus the state. It is what CI runs: no network, no credential. It is not a convenience — the live backend is non-deterministic, so it is the only backend a test may assert a *number* against.
```clojure
(require '[toolnexus.classifier :as jev])
(def ticket "My card was charged twice and the second charge has not been refunded.")
(def questions
{"wants_money_back" (jev/noul-question "Is the customer asking for money to be returned?")})
(def recorded
{:state ticket
:questions questions
;; :response is a PARSED body — string keys, exactly the wire
:response {"model" "typesafe/jev-1.13-20260917"
"answers" {"wants_money_back" {"type" "noul" "noul" 0.99}}
"usage" {"input_tokens" 118 "output_tokens" 6}}})
(def judge (jev/create-classifier {:style "static" :model "typesafe/jev-1.13"
:decisions [recorded]}))
(def d (jev/evaluate judge ticket questions))
;; a noul carries NO confidence — the number IS the answer
(assert (= 0.99 (:noul (jev/noul d "wants_money_back"))))
(assert (true? (:calibrated d)))
;; the decision echoes what actually answered, which is more specific than what we asked for
(assert (= "typesafe/jev-1.13-20260917" (:model d)))
(assert (= 118 (get-in d [:usage :input-tokens])))
;; cost is a gateway field; absent is NOT zero
(assert (false? (contains? (:usage d) :cost)))
;; an unrecorded state is an ERROR, never a neighbouring band
(assert (= :threw (try (jev/evaluate judge "a different ticket" questions)
:no-throw
(catch Throwable _ :threw))))
(println "wants_money_back:" (:noul (jev/noul d "wants_money_back")) "| model:" (:model d))
(println "OK")
```
### 2. The realistic case — `custom`, so a rules engine or a cache is the same seam
[Section titled “2. The realistic case — custom, so a rules engine or a cache is the same seam”](#2-the-realistic-case--custom-so-a-rules-engine-or-a-cache-is-the-same-seam)
`:style "custom"` hands `:evaluate` the state and the questions and takes your decision back. Every wire option is ignored. A cache in front of the live backend, a fine-tuned local encoder and a hand-written rules engine are all this shape — and all of them report `:calibrated false`, because nothing in them derived a probability from token probabilities.
```clojure
(def judge
(jev/create-classifier
{:style "custom"
:evaluate (fn [state _questions]
(let [money? (or (str/includes? state "charged")
(str/includes? state "refund"))]
{:model "rules-engine-v1"
:calibrated false ; say so — thresholds do NOT carry over
:usage {}
:answers {"wants_money_back"
{:type "noul" :noul (if money? 1.0 0.0)}}}))}))
```
### 3. The full surface — the real wire, with the transport injected
[Section titled “3. The full surface — the real wire, with the transport injected”](#3-the-full-surface--the-real-wire-with-the-transport-injected)
`:http-client` is the §8 Gap 2 seam. Injecting it exercises the actual `systemone` path — the canonical body, `:request-params`, `:body-transform`, the retry budget, the metric sink — with no socket and no credential. Note what the transport receives: the canonical bytes, keys sorted recursively in ASCII order, arrays never reordered, and `state` **verbatim**.
```clojure
(require '[toolnexus.classifier :as jev]
'[koine.json :as json])
(def seen (atom nil))
(def events (atom []))
(def attempts (atom 0))
(def payload
(json/write-str
{"model" "jev-1.13.0"
"answers" {"resolved" {"type" "noul" "noul" 0.12}
"urgency" {"type" "score"
"score" 1.4
"legend" {"0" "can wait" "1" "chase today" "2" "blocked now"}
"probabilities" {"0" 0.1 "1" 0.5 "2" 0.4}
"confidence" 0.61}}
"usage" {"input_tokens" 210 "output_tokens" 30 "cost" 0.0000118}}))
;; a 503 first, then the answer — the retry policy is the §8 one, reused
(def http
(fn [url headers body]
(reset! seen {:url url :headers headers :body (json/read-str body {:key-fn str})})
(if (< (swap! attempts inc) 2)
{:status 503 :body "upstream warming up"}
{:status 200 :body payload})))
(def questions
{"urgency" (jev/score-question "How fast does this ticket need a human?"
;; ORDER IS THE NUMBERING — nothing sorts it
["can wait" "chase today" "blocked now"])
"resolved" (jev/noul-question "Has the customer's problem already been solved?")})
(def judge
(jev/create-classifier
{:base-url "https://api.typesafe.ai/v1"
:model "jev-1.13.0" ; pinned, not the floating "jev-latest"
:api-key-env "TYPESAFE_API_KEY" ; the NAME of an env var, never the value
:timeout-ms 5000
:retries 3
:retryable-statuses [520 521] ; ADDS to {408 429 500 502 503 504 529}
:request-params {:trace "docs"} ; merged in; a collision here WINS
:body-transform (fn [b] (assoc b :tier "batch")) ; last before marshalling
:on-metric (fn [ev] (swap! events conj ev))
:http-client http}))
(def d (jev/evaluate judge {:ticket 4021 :text "still waiting"} questions))
(assert (= "https://api.typesafe.ai/v1/systemone" (:url @seen)))
(assert (= 2 @attempts)) ; the 503 was retried
(assert (= ["can wait" "chase today" "blocked now"]
(get-in @seen [:body "questions" "urgency" "criteria"])))
(assert (= {"ticket" 4021 "text" "still waiting"} (get-in @seen [:body "state"]))) ; verbatim
(assert (= "docs" (get-in @seen [:body "trace"])))
(assert (= "batch" (get-in @seen [:body "tier"])))
(assert (= 1.4 (:score (jev/score d "urgency")))) ; a score MAY fall between levels
(assert (= 0.0000118 (get-in d [:usage :cost])))
(assert (= "classifier.evaluate" (:event (last @events))))
(assert (= "ok" (:status (last @events))))
(println "urgency:" (:score (jev/score d "urgency")) "| attempts:" @attempts)
(println "OK")
```
## Backends in one line each
[Section titled “Backends in one line each”](#backends-in-one-line-each)
| `:style` | What it is |
| ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `"systemone"` | One `POST {base-url}/systemone` with the canonical body. Chunking under the token budget and the 255-option cap is the backend’s business and invisible to you. Reports `:calibrated true`. |
| `"llm"` | The three question types rendered as **one** structured-output call on any §8 client. This is what makes the seam vendor-neutral — no System One credential needed. Reports `:calibrated false`. |
| `"custom"` | Your own `:evaluate`: a fine-tuned encoder, a rules engine, or a cache in front of either. |
| `"static"` | Recorded decisions keyed by the canonical request **and** the state. **This is what CI runs.** A miss throws rather than guessing. |
The full comparison, with the measured latency and cost figures, is on [Backends](/judge/backends/).
## See also
[Section titled “See also”](#see-also)
* [`toolnexus.classifier/noul-question`](/api/clojure/judge/questions/) — The three question types, the criteria each one needs, and the limits enforced client-side before the request.
* [`toolnexus.classifier/parse-decision`](/api/clojure/judge/decision/) — One answer per question under the caller’s own keys, read through typed accessors that fail loudly rather than hand back a zero.
* [Typed decisions](/judge/) — why a judgment is a different contract from an action.
* [The encoding rules](/judge/encoding/) — the measured reason option descriptions decide whether a `choice` works at all.
* [Backends & configuration](/judge/backends/) — `systemone`, `llm`, `custom`, `static`.
* [Cookbook: a classifier in the loop](/cookbook/judge/) — the end-to-end recipe.
# toolnexus.classifier/parse-decision
> One answer per question under the caller's own keys, read through typed accessors that fail loudly rather than hand back a zero. — Clojure.
**Clojure (JVM) + cljgo** · package `net.clojars.muthuishere/toolnexus` · [SPEC §8B](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [clojure/src/toolnexus/classifier.cljc](https://github.com/muthuishere/toolnexus/blob/main/clojure/src/toolnexus/classifier.cljc)
```clojure
(parse-decision body) ; a parsed response body (STRING keys) => a decision map
(noul decision key) ; => the noul answer at key, or throws
(choice decision key) ; => the choice answer at key, or throws
(score decision key) ; => the score answer at key, or throws
(levels answer) ; => a score answer's legend in LEVEL order
(near-uniform? probabilities) ; => true when max|p - 1/n| <= 0.05
;; the decision
{:model "typesafe/jev-1.13-20260917" ; what ACTUALLY answered
:calibrated true
:usage {:input-tokens 516 :output-tokens 72 :cost 0.000021672} ; :cost only if reported
:answers {"wants_money_back" {:type "noul" :noul 0.99}
"department" {:type "choice" :choice "billing"
:probabilities {"billing" 1 "shipping" 0 "technical" 0}
:confidence 1
:near-uniform false} ; DERIVED, never read from the wire
"urgency" {:type "score" :score 0.49
:legend {"0" "…" "1" "…" "2" "…"}
:probabilities {"0" 0.52 "1" 0.48 "2" 0}
:confidence 0.27}}}
```
A decision carries **one answer per question, keyed by your own keys** — the same keys you handed [`evaluate`](/api/clojure/judge/classifier/), which were never transmitted to the model. It is a plain map, so `get-in` works, `pr-str` works, and it round-trips through EDN or JSON without a codec.
`parse-decision` is the decoder the `systemone`, `llm` and `static` backends all go through, and it is public because a `custom` backend that already holds a wire-shaped body should use it rather than hand-building the map. It takes a body with **string** keys — exactly the wire — and the answer set is **closed**: an unknown answer type throws rather than guessing. An unparseable answer is no answer; nothing here is repaired.
A classifier interprets; it never authorises
A decision is a reading of intent inside a boundary, never the boundary. Schema validity is not correctness: a decision can be confidently wrong, and “cannot hallucinate” means only that the returned value is in the declared schema. Numeric limits, permission checks and allowlists stay in code. Nothing on this page is a security control.
## The three answer shapes
[Section titled “The three answer shapes”](#the-three-answer-shapes)
| key | `noul` | `choice` | `score` |
| ------------- | ------------------------------ | ------------------------------------------- | ------------------------------------------------------------------ |
| `:type` | `"noul"` | `"choice"` | `"score"` |
| the answer | `:noul` — one number in `0..1` | `:choice` — one of **your** offered ids | `:score` — a number within the rubric’s bounds, fractional allowed |
| distribution | — | `:probabilities` — one entry per offered id | `:probabilities` — keyed `"0"`, `"1"`, … |
| `:confidence` | **absent by design** | present | present |
| extra | — | `:near-uniform` (derived) | `:legend` — the rubric echoed back |
* **`noul` reports no confidence, on purpose.** The number *is* the confidence, and there is no second number to reconcile it with.
* **A `choice` is always one of the options you offered**, and the probability map names exactly those options.
* **A `score` may land between levels.** `0.49` is a real answer and usually the interesting one: the model is genuinely split between level 0 and level 1. Use `levels` to get the legend back in level order — a map loses it, and `"2"` sorts before `"10"` as a level but after it as a string, which is exactly the bug `levels` exists to not have.
### Typed accessors fail loudly
[Section titled “Typed accessors fail loudly”](#typed-accessors-fail-loudly)
`noul`, `choice` and `score` each assert the answer’s type. A missing key, or a key whose answer is a different type, **throws an `ex-info`** whose `ex-data` carries `{:question … :want … :got …}` — it never hands back a zero, a `nil` or a default you would then threshold. That is the whole point of the accessors over a bare `get-in`: a renamed question key fails at the read, not three branches later when a `0.0` quietly routes everything to the wrong desk.
Failures in this port are thrown `ex-info`s — the same shape as the JS, Python, Java and C# ports’ exceptions, unlike Go’s `(T, error)` and Elixir’s tuples.
### `:usage`, and why absent is not zero
[Section titled “:usage, and why absent is not zero”](#usage-and-why-absent-is-not-zero)
`:input-tokens` and `:output-tokens` are always present (possibly `nil`). **`:cost` is present only when the backend reported one** — OpenRouter does, TypeSafe’s own API does not. Check with `contains?` and say “not reported” rather than printing a `$0.00` that would read as a free call.
## When to use it
[Section titled “When to use it”](#when-to-use-it)
* Read every answer through the accessors, at the point you use it.
* Reach for `parse-decision` directly when you are writing a `custom` backend over a body that is already wire-shaped — a cache, a replay, a gateway that hands you JSON.
* Reach for `near-uniform?` directly when you want the check on a distribution you did not get from this library.
## Why this and not the alternative
[Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative)
Destructure the map only after the accessor has checked it
Nothing stops you writing `(get-in d [:answers "department" :choice])`, and on the happy path it is identical. It is the unhappy path that differs: the accessor tells you *which* question was absent or mistyped and what you asked for, while `get-in` returns `nil` and lets a threshold comparison downstream turn it into a confident wrong answer. Use `get-in` to reach **inside** an answer you already got from an accessor.
## `:calibrated` and `:near-uniform` — the two health flags
[Section titled “:calibrated and :near-uniform — the two health flags”](#calibrated-and-near-uniform--the-two-health-flags)
Both are advisory. Both are worth reading on every decision. **Neither is a correctness signal.**
**`:calibrated`** travels with the decision and says whether the probabilities came from a calibrated backend. The `systemone` style reports `true`; the `llm` style reports `false` unless it derived its numbers from provider token probabilities, which is the common case, so its numbers are the model’s self-report. Absent on the wire means `true` — the System One wire reports calibration by being itself, and a backend that is *not* calibrated says so explicitly. **A threshold tuned against one backend does not transfer to another.**
**`:near-uniform`** is on every `choice` answer, **derived on decode and never read from the wire** — no wire change, no request change, no fixture change. The rule:
```plaintext
near-uniform ⇔ max over i of |p_i − 1/n| ≤ 0.05
```
* `n` is the **number of entries in the probability map**, and the values are taken **as returned** — never renormalised, never sorted, never rounded. An offered option absent from the map counts as `0` by not being an entry.
* The tolerance is **0.05 absolute** and the comparison is **inclusive** (`≤`, so a maximum deviation of exactly 0.05 *is* near-uniform). Absolute rather than relative because at 255 options `1/n` is 0.0039 and any relative band is finer than the two-decimal rounding the wire already applies. The comparison is made in double precision, and the shared fixtures never place a deviation within `1e-9` of the tolerance — so the rule is decidable without a port-specific epsilon, and an “exactly 0.05” case is a property of your literals rather than of the rule.
* **`n = 1` is true** (a single option is trivially uniform). **An empty map is false** — it has no distribution at all.
The 0.05 is measured, not felt: wire rounding alone is ±0.005, backend non-determinism is σ ≈ 0.015 (so 0.05 is roughly 3σ), and on a four-option choice the undescribed-options encoding returned a median top probability of 0.29 (deviation 0.04, inside) against the described one’s 0.80 (deviation 0.55, far outside). The full derivation is on [The encoding rules](/judge/encoding/).
What neither flag detects
`:near-uniform` detects an encoding that gave the model **nothing to rank on** — the one encoding health check available with no ground truth, cheap enough to run on live traffic. It **cannot tell a good encoding from a subtly wrong one**: a wrong-but-answerable question still reads as answerable, and the worst working encoding measured carried the **highest** median confidence (0.82). `:calibrated` carries the same caveat — it says where the numbers came from, not whether they are right. A threshold tuned on one backend does not transfer to another. Neither flag is a correctness signal, and neither is a security control.
## Examples
[Section titled “Examples”](#examples)
### 1. The smallest useful call — reading all three answer types
[Section titled “1. The smallest useful call — reading all three answer types”](#1-the-smallest-useful-call--reading-all-three-answer-types)
```clojure
(require '[toolnexus.classifier :as jev])
(def d
(jev/parse-decision
{"model" "typesafe/jev-1.13-20260917"
"answers"
{"wants_money_back" {"type" "noul" "noul" 0.99}
"department" {"type" "choice" "choice" "billing"
"probabilities" {"technical" 0 "shipping" 0 "billing" 1}
"confidence" 1}
"urgency" {"type" "score" "score" 0.49
"legend" {"0" "waiting on an answer"
"1" "will chase if nobody replies today"
"2" "blocked right now"}
"probabilities" {"0" 0.52 "1" 0.48 "2" 0}
"confidence" 0.27}}
"usage" {"input_tokens" 516 "output_tokens" 72}}))
;; a noul carries NO confidence — the number IS the answer
(def want (jev/noul d "wants_money_back"))
(assert (= 0.99 (:noul want)))
(assert (false? (contains? want :confidence)))
;; a choice is always one of the options offered, with a probability for every one
(def dept (jev/choice d "department"))
(assert (= "billing" (:choice dept)))
(assert (= 3 (count (:probabilities dept))))
(assert (false? (:near-uniform dept))) ; DERIVED here, never sent on the wire
;; a score MAY fall between levels — 0.49 is a real answer, not a rounding artefact
(def urg (jev/score d "urgency"))
(assert (= 0.49 (:score urg)))
(assert (= ["waiting on an answer"
"will chase if nobody replies today"
"blocked right now"]
(jev/levels urg))) ; the legend in LEVEL order, which the map loses
;; absent `calibrated` means TRUE: the systemone wire reports calibration by being itself
(assert (true? (:calibrated d)))
;; :cost is absent, and absent is NOT zero
(assert (false? (contains? (:usage d) :cost)))
(println "department:" (:choice dept) "| urgency:" (:score urg))
(println "OK")
```
### 2. The realistic case — the accessors fail loudly, which is the point
[Section titled “2. The realistic case — the accessors fail loudly, which is the point”](#2-the-realistic-case--the-accessors-fail-loudly-which-is-the-point)
```clojure
;; a renamed question key fails HERE, not three branches later
(try (jev/noul d "wants_refund") ; typo for "wants_money_back"
(catch Throwable t
(ex-data t))) ; => {:question "wants_refund" :want "noul"}
;; and so does asking for the wrong type
(try (jev/noul d "department")
(catch Throwable t
(ex-data t))) ; => {:question "department" :want "noul" :got "choice"}
```
### 3. The full surface — the near-uniform boundary, exactly as pinned
[Section titled “3. The full surface — the near-uniform boundary, exactly as pinned”](#3-the-full-surface--the-near-uniform-boundary-exactly-as-pinned)
```clojure
(require '[toolnexus.classifier :as jev])
;; the tolerance is 0.05 ABSOLUTE and the comparison is INCLUSIVE
(assert (true? (jev/near-uniform? {"a" 0.2999 "b" 0.2 "c" 0.2501 "d" 0.25}))) ; max dev 0.0499
(assert (false? (jev/near-uniform? {"a" 0.3001 "b" 0.2 "c" 0.2499 "d" 0.25}))) ; max dev 0.0501
;; The fixtures never place a deviation within 1e-9 of the tolerance, and neither should
;; you: `0.55` is 0.05000000000000004 away from `1/2` in double precision, so an
;; "exactly 0.05" case is a property of your literals, not of the rule.
;; n is the number of ENTRIES IN THE MAP — nothing is renormalised, sorted or rounded
(assert (true? (jev/near-uniform? {"only" 1.0}))) ; n = 1 is trivially uniform
(assert (false? (jev/near-uniform? {}))) ; an empty map has NO distribution
(assert (= jev/near-uniform-tolerance 0.05))
;; a flat distribution is what an undescribed roster returns: schema-valid, HTTP 200, chance
(def flat
(jev/parse-decision
{"model" "m"
"calibrated" false
"answers" {"move" {"type" "choice" "choice" "left"
"probabilities" {"left" 0.26 "right" 0.24 "up" 0.25 "down" 0.25}
"confidence" 0.81}}}))
(def move (jev/choice flat "move"))
(assert (true? (:near-uniform move)))
;; ADVISORY. A high confidence alongside a flat distribution is exactly the measured failure:
;; the worst working encoding carried the HIGHEST median confidence.
(assert (= 0.81 (:confidence move)))
;; an explicit `calibrated: false` means thresholds tuned elsewhere do NOT transfer
(assert (false? (:calibrated flat)))
(println "near-uniform boundary: ok")
(println "OK")
```
## See also
[Section titled “See also”](#see-also)
* [`toolnexus.classifier/create-classifier`](/api/clojure/judge/classifier/) — A sibling of the client: pre-declared typed questions in, calibrated answers out — no messages, no tool calling, no loop.
* [`toolnexus.classifier/noul-question`](/api/clojure/judge/questions/) — The three question types, the criteria each one needs, and the limits enforced client-side before the request.
* [The encoding rules](/judge/encoding/) — where the 0.05 comes from, and what `near-uniform` cannot see.
* [Backends & configuration](/judge/backends/) — why `calibrated` differs between `systemone` and `llm`.
* [Typed decisions](/judge/) — why a judgment is a different contract from an action.
* [Cookbook: a classifier in the loop](/cookbook/judge/) — the end-to-end recipe.
# toolnexus.classifier/noul-question
> The three question types, the criteria each one needs, and the limits enforced client-side before the request. — Clojure.
**Clojure (JVM) + cljgo** · package `net.clojars.muthuishere/toolnexus` · [SPEC §8B](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [clojure/src/toolnexus/classifier.cljc](https://github.com/muthuishere/toolnexus/blob/main/clojure/src/toolnexus/classifier.cljc)
```clojure
(noul-question instructions) ; => {:type "noul" :instructions …}
(noul-question instructions criteria) ; => {:type "noul" :instructions … :criteria {"true" … "false" …}}
(choice-question instructions criteria) ; => {:type "choice" :instructions … :criteria {"id" "what picking it MEANS"}}
(score-question instructions criteria) ; => {:type "score" :instructions … :criteria ["level 0" "level 1" …]}
(choice-over instructions items) ; => a choice built from (name, description) pairs
(canonical-request model questions) ; => the exact bytes the byte-identity claim covers
(degenerate-criteria criteria) ; => a reason string, or nil
```
A question is a **plain map**, and the map *is* the wire form — there is no record, no builder and no conversion step. The constructors exist so the shell keys are right and so the docstring that explains the encoding obligation sits where you type the call; `(assoc q :instructions "…")` is a perfectly ordinary thing to do to one afterwards.
You pass them as `{caller-key question}` to [`evaluate`](/api/clojure/judge/classifier/). **The keys are addressing, not content**: they are never transmitted to the model, so a key may be a tool, skill or agent name verbatim, and two evaluations differing only in their keys send identical bytes. Questions are **independent** — one answer is never context for another.
A classifier interprets; it never authorises
A question is a request for a reading of intent inside a boundary, never the boundary itself. Schema validity is not correctness. Numeric limits, permission checks and allowlists stay in code; nothing on this page is a security control.
## The three types
[Section titled “The three types”](#the-three-types)
| Constructor | `:criteria` | You get back |
| ----------------- | ------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
| `noul-question` | *optional* `{"true" desc "false" desc}` | one number in `0..1` — **no confidence; the number is the answer** |
| `choice-question` | *required* `{"id" desc}`, **1–255** options | one of **your** ids, a probability for **every** offered id, and a confidence |
| `score-question` | *required* an **ordered** vector, **2–10** levels | a number against the rubric (`1.21` is a real answer), plus a probability per level and the rubric echoed back as a legend |
Two shapes matter inside `:criteria`, and the port is deliberate about both:
* **The shell keys are keywords** (`:type`, `:instructions`, `:criteria`) because the JSON encoder renders a keyword key as its name, so they land as `"type"`, `"instructions"`, `"criteria"` with nothing in between.
* **The keys inside `:criteria` are strings, and must stay strings.** An option id is caller-chosen and may be digit-leading (`"09_digit_lead"`), upper-case (`"Zebra_upper"`) or non-ASCII (`"café"`); a score answer’s legend is keyed `"0"`, `"1"`, … Keywordizing any of those mangles them.
A `score`’s criteria is a **vector and its order is the level numbering**. `score-question` calls `vec` on what you hand it, and nothing anywhere sorts it — a “sort everything” canonicaliser would silently renumber your rubric.
### `criteria` absent is not `criteria` empty
[Section titled “criteria absent is not criteria empty”](#criteria-absent-is-not-criteria-empty)
For a `noul` the two arities are two different values, and the wire carries the difference:
```clojure
(noul-question "Is this a refund request?")
;; => {:type "noul" :instructions "Is this a refund request?"} — no `criteria` key at all
(noul-question "Is this a refund request?" {"true" "" "false" ""})
;; => …:criteria {"true" "" "false" ""} — "criteria":{"false":"","true":""}
```
Omit the arity rather than passing `nil` or `{}`. Absent, empty and populated are three values, all three survive the wire intact, and the byte-identity claim is pinned on exactly that distinction.
## When to use it
[Section titled “When to use it”](#when-to-use-it)
* **`noul`** for *is this true* — is this text from an untrusted source, has the problem already been solved, is the customer asking for money back. One number you threshold.
* **`choice`** for routing — to a skill, a tool, an agent, a move. Use `choice-over` when the options already exist as `(name, description)` pairs; it stringifies each key with `str`, so a map, a vector of pairs or a sequence of tool records all work.
* **`score`** for a magnitude you already have words for — risk, urgency, severity. The rubric is the definition of the scale, and a score between levels is usually the interesting part.
## Why this and not the alternative
[Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative)
A choice with two options is not a noul
They look interchangeable and they are not. A `noul` returns a calibrated probability and *no* confidence — the number is the whole answer. A two-option `choice` returns a pick, a distribution over both ids, **and** a self-reported confidence, and it needs you to describe what each option means. Reach for `noul` when the question genuinely has a true and a false case; reach for `choice` the moment the options are not each other’s negation.
## The encoding obligation — yours, and it is not advice
[Section titled “The encoding obligation — yours, and it is not advice”](#the-encoding-obligation--yours-and-it-is-not-advice)
For a `choice`, **`criteria[id]` is the only thing that differentiates one option from another to the model.** The instructions describe the *question*; the state describes the *situation*; neither tells the model what picking `left` rather than `right` would mean. Passing the id itself, an empty string, or one value repeated is schema-valid, passes validation, returns HTTP 200 and a well-formed distribution — and **ranks at chance**: options described by consequence scored 17, 17, 17 apples; the same state with each description replaced by its own id scored 0, 1, 0, at the floor of a shuffle control (1, 0, 1).
The measurements, the rewrite that moved agreement from 50% to 100%, and why confidence reports on the *question* rather than the answer are all on [The encoding rules](/judge/encoding/). Read that page before you write your first `choice`.
### Degenerate criteria are detected and reported, never repaired
[Section titled “Degenerate criteria are detected and reported, never repaired”](#degenerate-criteria-are-detected-and-reported-never-repaired)
`degenerate-criteria` returns a reason string when **any** of these holds, and `nil` otherwise:
1. every description is empty (empty string or absent), or
2. every description equals its own option id, or
3. every description is identical to every other (`n ≥ 2`).
A single-option choice (`n = 1`) is never reported — there is nothing to differentiate. On detection `evaluate` emits **one** `"classifier.warning"` event into your `:on-metric` sink, **naming the question key**, and sends the request **byte-unchanged**. The text is in `:warning`, never `:error`, so a consumer filtering the §8 sink on “has an error” must not count one. Detection is once per question key per classifier, so a per-turn judge does not flood the sink.
This is detection, not repair: repairing would invent option descriptions you did not write, and the library has no way to know what your options mean.
## The limits are enforced before the request
[Section titled “The limits are enforced before the request”](#the-limits-are-enforced-before-the-request)
`evaluate` validates every question **client-side, before any socket opens**. A `choice` needs 1–255 options; a `score` needs 2–10 ordered levels, and its criteria must be a sequence. The thrown `ex-info` **names the offending question key** and the limit, its `ex-data` carries `{:question :limit :choice-options|:score-levels :count n}`, and **no request is sent** — you find out faster and more legibly than from the backend’s own `400 "Too many choices. Must have at most 255 choices."`, which is still surfaced intact if it arrives. Keys are walked in sorted order, so the same malformed set always names the same key first.
Failures in this port are thrown `ex-info`s — the same shape as the JS, Python, Java and C# ports’ exceptions, unlike Go’s `(T, error)` and Elixir’s tuples.
## Examples
[Section titled “Examples”](#examples)
### 1. The smallest useful call — one question, and the limit that fires before the wire
[Section titled “1. The smallest useful call — one question, and the limit that fires before the wire”](#1-the-smallest-useful-call--one-question-and-the-limit-that-fires-before-the-wire)
```clojure
(require '[toolnexus.classifier :as jev]
'[clojure.string :as str])
(def q (jev/noul-question "Is the customer asking for money to be returned?"))
(assert (= {:type "noul" :instructions "Is the customer asking for money to be returned?"} q))
;; absent is NOT empty — the one-arity form emits no `criteria` key at all
(assert (false? (contains? q :criteria)))
(assert (contains? (jev/noul-question "?" {"true" "" "false" ""}) :criteria))
;; the limits are client-side: this never opens a socket
(def calls (atom 0))
(def judge (jev/create-classifier {:http-client (fn [_ _ _] (swap! calls inc)
{:status 200 :body "{}"})}))
(def too-many
(reduce (fn [m i] (assoc m (str "opt_" i) (str "description " i)))
{} (range (inc jev/max-choice-options)))) ; 256 options
(def err (try (jev/evaluate judge "s" {"department" (jev/choice-question "?" too-many)})
nil
(catch Throwable t t)))
(assert (some? err))
(assert (str/includes? (ex-message err) "department")) ; names the KEY
(assert (str/includes? (ex-message err) "1..255 options"))
(assert (= :choice-options (:limit (ex-data err))))
(assert (zero? @calls)) ; and sends NOTHING
;; a score needs 2..10 ORDERED levels, and the same rule applies
(def score-err (try (jev/evaluate judge "s" {"urgency" (jev/score-question "?" ["only one"])})
nil
(catch Throwable t t)))
(assert (str/includes? (ex-message score-err) "2..10 ordered levels"))
(assert (= :score-levels (:limit (ex-data score-err))))
(println "both limits refused pre-flight, requests sent:" @calls)
(println "OK")
```
### 2. The realistic case — all three types in one call
[Section titled “2. The realistic case — all three types in one call”](#2-the-realistic-case--all-three-types-in-one-call)
Many questions, one round trip, one state ingest. Every `choice` option carries a real sentence saying what picking it would *mean*, all three use the same template, and no arithmetic is pushed onto the model — the host does the counting and hands over the conclusion.
```clojure
(def questions
{"wants_money_back"
(jev/noul-question "Is the customer asking for money to be returned?")
"department"
(jev/choice-question
"Which desk should own this ticket?"
{"billing" "own it here when the problem is money that moved: a duplicate charge, a wrong invoice, a refund owed"
"shipping" "own it here when the problem is a physical parcel: a late delivery, a package damaged in transit"
"technical" "own it here when the problem is the product itself: a login that fails, a feature that errors"})
"urgency"
(jev/score-question
"How fast does this ticket need a human?"
;; the ORDER is the numbering: level 0 first
["the customer is working normally and is waiting on an answer"
"the customer is inconvenienced and will chase if nobody replies today"
"the customer is blocked from working right now and every hour costs them"])})
```
### 3. The full surface — `choice-over`, and the degenerate warning
[Section titled “3. The full surface — choice-over, and the degenerate warning”](#3-the-full-surface--choice-over-and-the-degenerate-warning)
`choice-over` builds a `choice` from any `(name, description)` pairs — a toolkit’s tools, a skill roster, an A2A card’s skills. Keys may be strings, keywords or symbols; each reaches the wire as its **plain name**, so `:billing` sends `billing`, not `":billing"`. The descriptions are still yours, and still the whole ball game: a roster whose descriptions are just the names is exactly what `degenerate-criteria` catches.
```clojure
(require '[toolnexus.classifier :as jev]
'[clojure.string :as str])
;; the routing roster you already have. Option ids are caller-chosen and travel verbatim —
;; a keyword key sends its name, so {:billing "..."} and {"billing" "..."} are the same request.
(def skills {"billing" "route here when money moved: a duplicate charge, a refund owed"
"shipping" "route here when a physical parcel is late or damaged"
"technical" "route here when the product itself fails: a login error, a broken feature"})
(def routing (jev/choice-over "Which skill should handle this turn?" skills))
(assert (= "choice" (:type routing)))
(assert (= #{"billing" "shipping" "technical"} (set (keys (:criteria routing)))))
;; keywords are welcome, and lose the colon on the way out
(assert (= #{"billing" "shipping"}
(set (keys (:criteria (jev/choice-over "Which desk?"
{:billing "route here when money moved"
:shipping "route here when a parcel is late"}))))))
(assert (nil? (jev/degenerate-criteria (:criteria routing)))) ; described by consequence
;; the three degenerate shapes, and the one that is never reported
(assert (some? (jev/degenerate-criteria {"a" "" "b" ""}))) ; all empty
(assert (some? (jev/degenerate-criteria {"a" "a" "b" "b"}))) ; each is its own id
(assert (some? (jev/degenerate-criteria {"a" "same" "b" "same"}))) ; all identical
(assert (nil? (jev/degenerate-criteria {"only" "only"}))) ; n = 1: nothing to differentiate
;; evaluate WARNS, naming the key, and sends the request byte-unchanged
(def events (atom []))
(def sent (atom []))
(def judge
(jev/create-classifier
{:style "static"
:model "m"
:on-metric (fn [ev] (swap! events conj ev))
:decisions [{:state "s"
:questions {"department" (jev/choice-question "?" {"a" "a" "b" "b"})}
:response {"model" "m"
"answers" {"department" {"type" "choice" "choice" "a"
"probabilities" {"a" 0.5 "b" 0.5}
"confidence" 0.5}}}}]}))
(def qs {"department" (jev/choice-question "?" {"a" "a" "b" "b"})})
(jev/evaluate judge "s" qs)
(jev/evaluate judge "s" qs) ; a second call does NOT warn again — once per key per classifier
(def warnings (filterv (fn [ev] (= jev/metric-warning (:event ev))) @events))
(assert (= 1 (count warnings))) ; once per question key
(assert (= "department" (:question (first warnings)))) ; NAMING the key
(assert (str/includes? (:warning (first warnings)) "degenerate criteria"))
(assert (nil? (:error (first warnings)))) ; a warning is NOT a failure
(println "warned once about:" (:question (first warnings)))
(println "OK")
```
## See also
[Section titled “See also”](#see-also)
* [`toolnexus.classifier/create-classifier`](/api/clojure/judge/classifier/) — A sibling of the client: pre-declared typed questions in, calibrated answers out — no messages, no tool calling, no loop.
* [`toolnexus.classifier/parse-decision`](/api/clojure/judge/decision/) — One answer per question under the caller’s own keys, read through typed accessors that fail loudly rather than hand back a zero.
* [The encoding rules](/judge/encoding/) — the measured reason option descriptions decide whether a `choice` works at all.
* [Typed decisions](/judge/) — why a judgment is a different contract from an action.
* [Backends & configuration](/judge/backends/) — `systemone`, `llm`, `custom`, `static`.
* [Cookbook: a classifier in the loop](/cookbook/judge/) — the end-to-end recipe.
# toolnexus.mcp/elicitation->request
> Map an MCP server's elicitation request onto the §10 suspension contract, and map the answer back. — Clojure.
**Clojure (JVM) + cljgo** · package `net.clojars.muthuishere/toolnexus` · [SPEC §2](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [clojure/src/toolnexus/mcp.cljc](https://github.com/muthuishere/toolnexus/blob/main/clojure/src/toolnexus/mcp.cljc)
```clojure
;; MCP `elicitation/create` params -> a §10 Request
(toolnexus.mcp/elicitation->request params)
;; => {:id "elc--"
;; :kind "input" | "authorization"
;; :prompt "…"
;; :url "…" ; URL mode only
;; :data {:schema {…}}} ; form mode only
;; a resolved §10 Answer -> an MCP ElicitResult
(toolnexus.mcp/answer->elicit-result answer)
;; => {:action "accept" :content {…}} | {:action "decline"} | {:action "cancel"}
;; one server-initiated JSON-RPC request -> the reply to write back, or nil
(toolnexus.mcp/server-request-response wait-for msg)
;; => {:jsonrpc "2.0" :id 77 :result {:action "accept" :content {…}}}
;; | {:jsonrpc "2.0" :id 9 :error {:code -32601 :message "Method not found"}}
```
`elicitation/create` is the one place in SPEC §2 where traffic flows **server to client**: a connected server, in the middle of serving your `tools/call`, asks the user something. These three functions are the bridge from that reverse request onto §10 — the same suspension contract a tool of your own would use — and back again.
The mapping is pinned, not improvised. **Form mode** (the default) becomes a `kind:"input"` Request and carries the server’s `requestedSchema` at `data.schema`. **URL mode** becomes a `kind:"authorization"` Request carrying the `url` — and deliberately carries **no** schema, because a credential must never be collected through a form. The Answer maps back onto MCP’s three actions: `ok` accepts (with `data` as the content), `reason "declined"` declines, and anything else — cancelled, expired, no reason at all — cancels.
Both directions are **pure functions**, which is what makes them byte-parity testable against the other ports without a live peer. `server-request-response` is the one that joins them: it takes your `wait-for` and one inbound message and returns the JSON-RPC frame to write back.
The wiring is automatic. Pass `{:wait-for f}` to [`from-config`](/api/clojure/mcp/load/) or `toolnexus.mcp/connect`, and the reader loop answers a server’s elicitation **inline, on the reader thread**, while the `tools/call` that triggered it is still in flight. That is what §10 requires: the call resumes when the answer is written, and nothing re-executes. Without a `wait-for`, `initialize` never advertises `capabilities.elicitation`, so a spec-compliant server simply never asks — and one that asks anyway gets a clean `-32601 Method not found` rather than a hang.
## When to use it
[Section titled “When to use it”](#when-to-use-it)
* **You already pass `:wait-for`** and want to know exactly what your callback will receive and what it is allowed to return. That is the mapping table below.
* **Testing your host’s resolver** — feed `elicitation->request` a params map and assert on the Request, with no server, no socket and no timing.
* **Implementing a different transport or host loop** — `server-request-response` is the whole policy in one call, including the refusal and the throw-becomes-cancel rule.
## Why this and not the alternative
[Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative)
One resolver, not one per source
There is exactly one §10 host slot: [`:wait-for`](/api/clojure/suspension/wait-for/). The same function resolves a suspension raised by your own tool, by a builtin, and by an MCP server — you do not register a separate elicitation handler. So the thing to write is the resolver; these functions exist so you can see and test what crosses it. If you only want to suspend from a tool you wrote, use [`toolnexus.client/suspend`](/api/clojure/suspension/pending/) and never touch this namespace.
Note the deliberate degradation: advertising a capability you would then have to refuse is worse than not advertising it. Absent a `wait-for`, the port promises nothing and the server routes around it.
## The mapping, both directions
[Section titled “The mapping, both directions”](#the-mapping-both-directions)
| MCP `elicitation/create` params | §10 Request |
| -------------------------------------- | ----------------------------------------------------------------------------------- |
| `mode` absent, or anything but `"url"` | `:kind "input"` |
| `mode: "url"` | `:kind "authorization"`, with `:url` copied across |
| `message` | `:prompt` — `""` when absent, never nil |
| `requestedSchema`, form mode | `:data {:schema …}` |
| `requestedSchema`, URL mode | **dropped** — no schema on an authorization request |
| — | `:id`, always: `"elc-"` plus a monotonic reading plus a counter, unique per process |
| §10 Answer | MCP ElicitResult |
| ------------------------------------------------------ | ---------------------------------------------------------------- |
| `{:ok true :data d}` | `{:action "accept" :content d}` |
| `{:ok true}` | `{:action "accept" :content {}}` |
| `{:ok false :reason "declined"}` | `{:action "decline"}` |
| `{:ok false :reason "cancelled"}`, or no reason at all | `{:action "cancel"}` |
| your resolver **throws** | `{:action "cancel"}` — §0.3 isolation holds at this boundary too |
## Examples
[Section titled “Examples”](#examples)
### 1. Turn it on: one option on the load
[Section titled “1. Turn it on: one option on the load”](#1-turn-it-on-one-option-on-the-load)
```clojure
(require '[toolnexus.mcp :as mcp]
'[toolnexus.client :as client])
(defn resolve-request [request]
(case (:kind request)
;; open a browser, wait for the callback, then say yes
"authorization" (client/make-answer (:id request) true)
;; show a form built from (get-in request [:data :schema])
(client/make-answer (:id request) true {:name "Muthu"})))
(def res
(mcp/from-config {:mcpServers {"forms" {:command ["node" "forms-server.js"] :timeout 5000}}}
{:wait-for resolve-request}))
(try
;; a tools/call that triggers an elicitation now completes in ONE call —
;; the answer is written back while the call is still in flight
(:statuses res)
(finally
(mcp/disconnect-all res)))
```
[`toolnexus.core/build`](/api/clojure/toolkit/create/) takes the same `:wait-for` and threads it into the MCP source for you, so an application normally sets it once, there.
### 2. What your resolver actually receives
[Section titled “2. What your resolver actually receives”](#2-what-your-resolver-actually-receives)
```clojure
(require '[toolnexus.mcp :as mcp])
;; form mode
(mcp/elicitation->request
{:message "What is your name?"
:requestedSchema {:type "object" :properties {:name {:type "string"}}}})
;; => {:id "elc-…-1"
;; :kind "input"
;; :prompt "What is your name?"
;; :data {:schema {:type "object" :properties {:name {:type "string"}}}}}
;; URL mode — carries the url, and no schema
(mcp/elicitation->request
{:mode "url" :message "Authorize us"
:url "https://example.test/oauth"
:requestedSchema {:type "object"}})
;; => {:id "elc-…-2" :kind "authorization" :prompt "Authorize us"
;; :url "https://example.test/oauth"}
;; a params map with nothing in it still yields a usable Request
(:prompt (mcp/elicitation->request {})) ;; => ""
```
Ids are opaque and per-process, built from a monotonic reading plus a counter. Uniqueness is the only property anything depends on — and the counter, not the clock, is what guarantees it — so never compare an id across processes or across ports.
### 3. The whole policy in one function
[Section titled “3. The whole policy in one function”](#3-the-whole-policy-in-one-function)
```clojure
(require '[toolnexus.mcp :as mcp])
;; accept
(mcp/server-request-response
(fn [request] {:ok true :data {:name "muthu"}})
{:jsonrpc "2.0" :id 77 :method "elicitation/create" :params {:message "Who are you?"}})
;; => {:jsonrpc "2.0" :id 77 :result {:action "accept" :content {:name "muthu"}}}
;; decline — the session carries on
(mcp/server-request-response
(fn [_] {:ok false :reason "declined"})
{:id 3 :method "elicitation/create" :params {}})
;; => {:jsonrpc "2.0" :id 3 :result {:action "decline"}}
;; a resolver that blows up becomes a cancel, never a dead connection
(mcp/server-request-response
(fn [_] (throw (ex-info "boom" {})))
{:id 4 :method "elicitation/create" :params {}})
;; => {:jsonrpc "2.0" :id 4 :result {:action "cancel"}}
;; anything else the server asks for is refused cleanly
(mcp/server-request-response (fn [_] {:ok true}) {:id 9 :method "sampling/createMessage"})
;; => {:jsonrpc "2.0" :id 9 :error {:code -32601 :message "Method not found"}}
;; and with no resolver there is nothing to answer with
(get-in (mcp/server-request-response nil {:id 1 :method "elicitation/create"}) [:error :code])
;; => -32601
```
It returns `nil` for a message that is not a server-initiated request at all — a response to one of *our* ids, or an id-less notification. Only an inbound message carrying **both** an `id` and a `method` is a reverse request, which is exactly the distinction the reader loop makes before calling this.
The reverse-request channel is wired on the stdio leg, where the reader loop is watching the child’s stdout continuously; the `elicitation` capability itself is advertised on both transports whenever a `wait-for` is present.
## See also
[Section titled “See also”](#see-also)
* [`toolnexus.mcp/from-config`](/api/clojure/mcp/load/) — where the `:wait-for` option is supplied
* [`toolnexus.client/wait-for`](/api/clojure/suspension/wait-for/) — the one §10 host slot
* [`toolnexus.client/make-answer`](/api/clojure/suspension/resume/) — build the Answer this maps back
* [`toolnexus.client/suspend`](/api/clojure/suspension/pending/) — the same contract, raised by your own tool
* [`toolnexus.core/build`](/api/clojure/toolkit/create/) — set `:wait-for` once, for every source
# listMcpTools — inventory without connecting
> List what each configured server would expose, plus per-server status, without wiring it into a toolkit. — Clojure.
**Clojure (JVM) + cljgo** · package `net.clojars.muthuishere/toolnexus` · [SPEC §2](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [clojure/src/toolnexus/mcp.cljc](https://github.com/muthuishere/toolnexus/blob/main/clojure/src/toolnexus/mcp.cljc)
Not available in Clojure
This port ships no inventory call that lists tools without connecting. `toolnexus.mcp/parse-config` validates the config offline, and `toolnexus.mcp/from-config` returns `:statuses` and `:errors` next to `:tools` once the servers are connected.
List what each configured server would expose, plus per-server status, without wiring it into a toolkit.
## What to use instead
[Section titled “What to use instead”](#what-to-use-instead)
Split the question in two, because the two halves have genuinely different costs.
**Is the config sane?** That is offline, free and instant. [`toolnexus.mcp/parse-config`](/api/clojure/mcp/parse-config/) hands back one map per server with the kind inferred, the enablement resolved and the timeout defaulted — no process, no socket. A misspelled server block, a server with neither `command` nor `url`, an entry you thought was enabled and is not: all visible here.
```clojure
(require '[toolnexus.mcp :as mcp])
(def servers (mcp/parse-config (slurp "mcp.json")))
(mapv (juxt :name :kind :enabled :timeout) servers)
;; => [["docs" "remote" true 5000] ["fs" "local" true 30000] ["old" "unknown" false 30000]]
;; a "kind" of "unknown" means neither command nor url — that server can only fail
(filterv #(= "unknown" (:kind %)) servers)
```
**What tools does it actually expose?** That question cannot be answered without talking to the server — MCP has no static manifest, `tools/list` is a live RPC. So the honest answer is [`from-config`](/api/clojure/mcp/load/), which connects and hands you the inventory *and* the per-server outcome in one value:
```clojure
(require '[toolnexus.mcp :as mcp])
(def res (mcp/from-config (slurp "mcp.json")))
(try
;; the inventory
(mapv (juxt :server :remote-name :name) (:tools res))
;; => [["docs" "search" "docs_search"] ["fs" "read file" "fs_read_file"]]
;; the per-server outcome, in the same value
(:statuses res) ;; => {"docs" "connected" "fs" "connected" "old" "disabled"}
(:errors res) ;; => {} when everything worked
;; a doctor command is this and nothing more
(when-let [bad (seq (:errors res))]
(doseq [[server message] bad] (println "FAILED" server "-" message)))
(finally
(mcp/disconnect-all res)))
```
If what you actually want is the **name** a server’s tool will get — to write an allowlist, or to check a collision against a builtin — you can compute that without connecting, because SPEC §0.2 naming is pure:
```clojure
(require '[toolnexus.mcp :as mcp])
(mcp/mcp-tool-name "remote api" "zebra note") ;; => "remote_api_zebra_note"
(mcp/mcp-tool-name "remote api" "alpha/stats") ;; => "remote_api_alpha_stats"
```
The one thing to avoid is treating a connect-and-disconnect as a cheap probe. Connecting spawns a child process or opens an HTTP session per server, serially; always pair it with `mcp/disconnect-all` in a `finally`, and keep the `:timeout` short if the probe is on a startup path.
## See also
[Section titled “See also”](#see-also)
* [`toolnexus.mcp/parse-config`](/api/clojure/mcp/parse-config/) — the offline half of the question
* [`toolnexus.mcp/from-config`](/api/clojure/mcp/load/) — the connected half: `:tools` plus `:statuses` and `:errors`
* [Bounding a load](/api/clojure/mcp/load-with-context/) — `:timeout` per server, and teardown
* [`toolnexus.core/build`](/api/clojure/toolkit/create/) — the same status map, across every source
# toolnexus.mcp/from-config
> Read an mcp.json, connect every local stdio and remote streamable-HTTP server, expose each server tool as a Tool. — Clojure.
**Clojure (JVM) + cljgo** · package `net.clojars.muthuishere/toolnexus` · [SPEC §2](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [clojure/src/toolnexus/mcp.cljc](https://github.com/muthuishere/toolnexus/blob/main/clojure/src/toolnexus/mcp.cljc)
```clojure
(toolnexus.mcp/from-config config) ; config: a JSON string or an already-parsed map
(toolnexus.mcp/from-config config conn-opts) ; conn-opts: {:wait-for (fn [request] answer)}
;; =>
{:tools [{...} {...}] ; §0.1 Tools, sorted by name
:statuses {"docs" "connected" "old" "failed" "off" "disabled"}
:errors {"old" "mcp server \"old\" failed at initialize: transport (connect-failed)"}
:connections [{:name "docs" :status "connected" :transport {...} :server-info {...} :tools [...]}]}
```
The whole MCP source in one call. `from-config` runs [`parse-config`](/api/clojure/mcp/parse-config/) over the config, then connects each server in turn — `initialize`, `notifications/initialized`, `tools/list` (following `nextCursor`) — and turns every listed remote tool into an ordinary [Tool](/api/clojure/types/tool/) named `sanitize(server)_sanitize(tool)`.
**Local stdio and remote streamable-HTTP are the same code path.** A transport here is data — `{:rpc! fn :notify! fn :close! fn …}` — so the two legs differ only in those closures, and everything above them (lifecycle, paging, result shaping, naming) is written exactly once and cannot drift between them.
**Servers are connected serially, in name order.** Serial is slower; it is also deterministic, and determinism is the premise of the whole project. A tool-name collision resolved by whichever server happened to answer first is exactly the drift these ports exist to prevent.
**Nothing throws across this boundary.** SPEC §0.3 requires a bad server to be isolated, and an exception crossing here is how “isolated” quietly becomes “fatal”. Every failure is data with a stable name — `transport`, `http-status`, `malformed-body`, `rpc-error`, `timeout`, `peer-eof`, `closed` — the same vocabulary for both transports, so your retry logic never has to know which leg it is on.
The optional second argument threads the §10 host resolver into every server it connects. With a `:wait-for` present, `initialize` advertises `capabilities.elicitation` and a server may ask the user something mid-`tools/call`; **without one the capability is not advertised at all**, so a spec-compliant server never asks. Promising a capability you would then have to refuse is worse than degrading quietly.
## When to use it
[Section titled “When to use it”](#when-to-use-it)
* **MCP is your only source** — a CLI, a bridge, a test — and you want the tools without the rest of the toolkit machinery.
* **You need the per-server status.** `:statuses` and `:errors` are what a doctor command, a startup health check or a dashboard reads.
* **You want the connections.** `:connections` carries the transport, the peer’s `serverInfo` and, for a failed stdio server, the child’s last stderr lines — usually the only thing that explains why it died.
## Why this and not the alternative
[Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative)
Use core/build when MCP is one source among several
[`toolnexus.core/build`](/api/clojure/toolkit/create/) calls this for you and merges the result with builtins, skills and your own Tools **in the order SPEC §0.11 requires** — MCP registered last so it wins a name collision against a builtin. Calling `from-config` yourself means you own that merge. Do it when MCP is the only source, or when you want the raw connections; use `build` otherwise.
Reach for [`parse-config`](/api/clojure/mcp/parse-config/) instead when you only want to know whether the config is well-formed. It touches no network and starts no process.
## Arguments
[Section titled “Arguments”](#arguments)
| Argument | What it accepts |
| ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `config` | A JSON string, parsed with keyword keys · `{:mcpServers {…}}`, with `servers` and `mcp` as wrapper aliases · or a bare map of servers, in which case the object itself is the server map minus the reserved sibling keys `:builtins`, `:agents`, `:a2a`, `:mcpServer`. |
| `conn-opts` | Optional. `{:wait-for (fn [request] answer)}` — the one §10 host resolver, applied to every server. Omit it and no server is told the client can be elicited. |
## What you get back
[Section titled “What you get back”](#what-you-get-back)
| Key | What it is |
| -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
| `:tools` | Every connected server’s tools, flattened and sorted by name. |
| `:statuses` | `server -> "connected"` / `"disabled"` / `"failed"`. Every configured server appears. |
| `:errors` | `server -> message` for the failed ones only. The message names the phase and the stable error, and appends the child’s stderr when there is any. |
| `:connections` | One connection map per server, for `disconnect` / `disconnect-all` and for `:server-info`, `:phase`, `:error` and `:exit-code`. |
## Examples
[Section titled “Examples”](#examples)
### 1. Connect a config and call one of its tools
[Section titled “1. Connect a config and call one of its tools”](#1-connect-a-config-and-call-one-of-its-tools)
```clojure
(require '[toolnexus.mcp :as mcp]
'[toolnexus.tool :as tool])
(def res (mcp/from-config (slurp "mcp.json")))
(try
(:statuses res) ;; => {"docs" "connected"}
(mapv :name (:tools res)) ;; => ["docs_fetch" "docs_search"] — sorted, server-prefixed
(let [tk (tool/toolkit (:tools res))]
(tool/execute tk "docs_search" {:query "suspension"}))
;; => {:output "…" :isError false}
(finally
(mcp/disconnect-all res)))
```
The tool name is not the remote name: SPEC §0.2 joins the sanitized server name and the sanitized remote name with `_`. `toolnexus.mcp/mcp-tool-name` computes it, so you can predict a name from the config without connecting. Each Tool also keeps `:server` and `:remote-name` for when you need to get back to the original.
### 2. One dead server does not sink the others
[Section titled “2. One dead server does not sink the others”](#2-one-dead-server-does-not-sink-the-others)
```clojure
(require '[toolnexus.mcp :as mcp])
(def res
(mcp/from-config
{:mcpServers {"live" {:url "http://127.0.0.1:8931/mcp" :timeout 5000}
"dead" {:url "http://127.0.0.1:1/mcp" :timeout 2000}
"off" {:url "http://127.0.0.1:8931/mcp" :enabled false}}}))
(:statuses res)
;; => {"live" "connected" "dead" "failed" "off" "disabled"}
;; the live server's tools are all there, and usable
(count (:tools res))
;; the failure is reported with a phase and a stable error name
(get (:errors res) "dead")
;; => "mcp server \"dead\" failed at initialize: transport (connect-failed)"
(->> (:connections res)
(filter #(= "dead" (:name %)))
first
:error :error)
;; => "transport"
(mcp/disconnect-all res)
```
A disabled server is never contacted at all — `disabled:true` and `enabled:false` are the same statement, and both mean no process is spawned and no request is sent.
### 3. Letting a server ask the user, mid-call
[Section titled “3. Letting a server ask the user, mid-call”](#3-letting-a-server-ask-the-user-mid-call)
```clojure
(require '[toolnexus.mcp :as mcp]
'[toolnexus.client :as client])
(def res
(mcp/from-config {:mcpServers {"forms" {:command ["node" "forms-server.js"] :timeout 5000}}}
{:wait-for (fn [request]
;; a real host shows this to a human and blocks
(println (:prompt request))
(client/make-answer (:id request) true {:name "Muthu"}))}))
```
The elicitation is answered **inline**, while the `tools/call` that triggered it is still in flight — nothing re-executes and no suspension reaches your loop. Drop the second argument and the same config connects with a bare `capabilities`, which is the byte-identical behaviour of every version before this option existed. See [`elicitation->request`](/api/clojure/mcp/elicitation/) for what your resolver receives and what it may return.
### 4. Reading a failure properly, including the child’s stderr
[Section titled “4. Reading a failure properly, including the child’s stderr”](#4-reading-a-failure-properly-including-the-childs-stderr)
```clojure
(require '[toolnexus.mcp :as mcp])
(def res (mcp/from-config {:mcpServers {"broken" {:command ["node" "typo.js"] :timeout 5000}}}))
(def conn (first (:connections res)))
(:status conn) ;; => "failed"
(:phase conn) ;; => "initialize" — how far it got
(get-in conn [:error :error]) ;; => a stable name: "transport" / "timeout" / "rpc-error" / …
(:exit-code conn) ;; => the child's exit status, or nil when genuinely unknown
(mcp/stderr conn) ;; => the last stderr lines the child managed to write
(mcp/disconnect-all res)
```
A stdio peer that stops talking gives you EOF, and EOF alone cannot say whether the child exited, crashed, or merely closed stdout. The port waits up to \~250 ms for the process reaper and then reports `peer-exited (status N)` when it knows and `peer-eof (stdout closed, exit status unknown)` when it does not — rather than confidently claiming the peer is still alive. The status says *that* it died; `mcp/stderr` says *why*.
## `:on-server` — use each server as soon as it is ready (0.22.0)
[Section titled “:on-server — use each server as soon as it is ready (0.22.0)”](#on-server--use-each-server-as-soon-as-it-is-ready-0220)
`mcp/from-config` takes `:on-server` in its second argument: `(fn [{:keys [name status tools error]}])`, called once per server the moment it is connected, failed or found disabled. With it set the servers connect concurrently, so one slow server no longer holds up the rest; the returned source is still built in name order and is the same value as without it.
```clojure
(mcp/from-config cfg
{:on-server (fn [{:keys [name status tools error]}]
(println name status (count tools) (or error "")))})
```
For MCP *resources* (as opposed to tools), see [`resource-tools`](/api/clojure/mcp/resource-tools/).
## See also
[Section titled “See also”](#see-also)
* [`toolnexus.mcp/parse-config`](/api/clojure/mcp/parse-config/) — validate the config offline first
* [`toolnexus.mcp/elicitation->request`](/api/clojure/mcp/elicitation/) — the §10 bridge `:wait-for` drives
* [Inventory without connecting](/api/clojure/mcp/list-tools/) — why `:statuses` is the closest thing
* [Bounding a load](/api/clojure/mcp/load-with-context/) — `:timeout`, and how teardown is kept bounded
* [`toolnexus.core/build`](/api/clojure/toolkit/create/) — MCP merged with every other source
* [`toolnexus.tool/tool`](/api/clojure/types/tool/) — the shape each returned tool has
# loadMcpWithContext — cancellable load
> The ctx-aware load: bound connection time and cancel a slow or hung server without leaking a child process. — Clojure.
**Clojure (JVM) + cljgo** · package `net.clojars.muthuishere/toolnexus` · [SPEC §2](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [clojure/src/toolnexus/mcp.cljc](https://github.com/muthuishere/toolnexus/blob/main/clojure/src/toolnexus/mcp.cljc)
Not available in Clojure
There is no context-aware or cancellable load in this port. `toolnexus.mcp/from-config` bounds each server with that server’s own `:timeout` and returns when every server has either connected or failed; there is no handle to cancel the load from outside while it runs.
The ctx-aware load: bound connection time and cancel a slow or hung server without leaking a child process.
## What to use instead
[Section titled “What to use instead”](#what-to-use-instead)
The budget is **per server and per phase**, declared in the config rather than passed at the call site. Each of `initialize` and `tools/list` gets a fresh budget of that server’s `:timeout` milliseconds (each *page* of `tools/list`, in fact), defaulting to `toolnexus.mcp/default-timeout-ms`, which is `30000`. A server that accepts the write and never answers fails with the stable error `"timeout"` and the rest of the config carries on:
```clojure
(require '[toolnexus.mcp :as mcp])
mcp/default-timeout-ms ;; => 30000
(def res
(mcp/from-config
{:mcpServers {"fast" {:command ["node" "fast-server.js"] :timeout 2000}
"slow" {:command ["node" "slow-server.js"] :timeout 15000}
"flaky" {:url "https://example.invalid/mcp"}}})) ; the 30s default
(try
(:statuses res) ;; => {"fast" "connected" "slow" "connected" "flaky" "failed"}
(get (:errors res) "flaky") ;; names the phase and the stable error
(finally
(mcp/disconnect-all res))) ;; the only teardown handle there is
```
Cancellation after the fact is `toolnexus.mcp/disconnect` (one connection) or `toolnexus.mcp/disconnect-all` (everything a `from-config` opened). Both are idempotent and never throw, and for stdio they are **bounded by construction**: they kill the child rather than politely closing its stdin and waiting. That is deliberate — `close-stdin-and-wait` hangs forever on a child that ignores its stdin closing, which is the exact failure teardown is supposed to end. Killing the child closes its stdout, which makes the reader loop’s parked read return, which marks the transport closed, which releases every waiter. One mechanism, no hang.
Two consequences worth knowing:
* **A hung server costs you its timeout, once.** Because servers are connected serially in name order, a server whose `:timeout` is 30 seconds delays the ones after it by up to 30 seconds. If you have a flaky remote in the config, give it a short explicit `:timeout` rather than relying on the default.
* **A dead stdio peer is diagnosed, not guessed.** When a child’s stdout closes, the port waits up to \~250 ms for the process reaper and reports `peer-exited (status N)` when it knows the status and `peer-eof (stdout closed, exit status unknown)` when it does not — rather than confidently claiming the peer is still alive. The connection carries `:exit-code` for retry logic that needs to tell a crash from a hang, and the child’s last stderr lines ride along on the failure, because the status says *that* it died and the stderr says *why*.
If you need a hard outer bound on the whole load, put it where your host language already has one — around the `from-config` call itself — and call `disconnect-all` on the result in a `finally` so no child survives the attempt.
## See also
[Section titled “See also”](#see-also)
* [`toolnexus.mcp/from-config`](/api/clojure/mcp/load/) — the load, with `:timeout` and the status map
* [`toolnexus.mcp/parse-config`](/api/clojure/mcp/parse-config/) — see the resolved `:timeout` per server before connecting
* [`toolnexus.core/build`](/api/clojure/toolkit/create/) — pair with `toolnexus.core/shutdown!` for teardown
# toolnexus.mcp/parse-config
> Parse and validate config without connecting — the fast fail for a malformed or misspelled server block. — Clojure.
**Clojure (JVM) + cljgo** · package `net.clojars.muthuishere/toolnexus` · [SPEC §2](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [clojure/src/toolnexus/mcp.cljc](https://github.com/muthuishere/toolnexus/blob/main/clojure/src/toolnexus/mcp.cljc)
```clojure
(toolnexus.mcp/parse-config config) ; config: a JSON string or an already-parsed map
;; => a vector of server maps, sorted by name
[{:name "docs"
:kind "remote" ; "local" | "remote" | "unknown"
:enabled true
:command []
:cwd nil
:environment {}
:url "https://example.com/mcp"
:raw-headers {"Authorization" "Bearer ${DOCS_TOKEN}"}
:headers {"authorization" "Bearer …"} ; ${ENV} already expanded — never log this
:timeout 30000}]
```
Config in, servers out. No process is spawned, no socket is opened and no `tools/list` is sent — this is the offline half of the MCP source, and everything it decides is decided by SPEC §2’s rules rather than by asking the server.
Three normalizations happen here, and they are the ones worth knowing. **The wrapper is optional**: `mcpServers`, `servers` and `mcp` are all accepted, and with no wrapper the object itself is the server map, minus the reserved sibling keys `:builtins`, `:agents`, `:a2a` and `:mcpServer` — which are config sections, never servers. **The kind is inferred**: an explicit `type` wins, otherwise a `url` means remote and a `command` means local, and a server with neither is `"unknown"` — a server that can only ever fail. **The order is fixed**: the vector is sorted by name, so two runtimes given the same file produce the same sequence.
The result is plain data. You can filter it, print it, diff it in a test, or hand it to `toolnexus.mcp/connect` one server at a time.
## When to use it
[Section titled “When to use it”](#when-to-use-it)
* **A startup or CI check** — assert that every server in `mcp.json` has a kind, is enabled, and carries the timeout you expect, so a typo fails the build instead of failing at 3am as a mysterious `"unknown"`.
* **Deciding what to connect.** Filter the vector, then call `toolnexus.mcp/connect` on the servers you actually want — the per-server allowlist this port does not have as an option.
* **Explaining a config to a human** — a `mcp doctor` command, an admin page, a log line at boot.
## Why this and not the alternative
[Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative)
from-config when you want tools, parse-config when you want answers
[`toolnexus.mcp/from-config`](/api/clojure/mcp/load/) calls this and then connects, which costs a child process or an HTTP session per server, serially. `parse-config` costs a JSON parse. If the question is “is this config well-formed and what did it resolve to”, stop here — connecting cannot tell you anything more about the config than this already has.
One caveat that matters: **`parse-config` reads the environment.** Remote `headers` values expand `${ENV_VAR}` at parse time (SPEC §0.3), so `:headers` in the returned map holds real secret values. Never print, log or serialize that key. When you need to *report* on headers, use `toolnexus.mcp/header-shape`, which is deliberately built to leak nothing: it returns the sorted key names and a boolean saying whether expansion changed anything — never a value, and never a length, because a length leaks a secret’s size.
## What each server map holds
[Section titled “What each server map holds”](#what-each-server-map-holds)
| Key | What it is |
| -------------- | -------------------------------------------------------------------------------------------------------- |
| `:name` | The key from the config, as a string. |
| `:kind` | `"local"`, `"remote"` or `"unknown"`. Explicit `type` wins; else `url` ⇒ remote, `command` ⇒ local. |
| `:enabled` | `false` when the entry had `disabled: true` **or** `enabled: false` — the same statement, two spellings. |
| `:command` | The argv vector for a local server; `[]` for a remote one. |
| `:cwd` | Working directory for a local server, or `nil`. |
| `:environment` | Extra env for the child. `env` is accepted as the documented alias. |
| `:url` | The endpoint for a remote server, or `nil`. |
| `:raw-headers` | The headers exactly as configured, still holding `${ENV_VAR}` placeholders. |
| `:headers` | The same headers with `${ENV_VAR}` expanded. Real secrets — use, never log. |
| `:timeout` | The per-phase budget in ms; `toolnexus.mcp/default-timeout-ms` (30000) when unset. |
## Examples
[Section titled “Examples”](#examples)
### 1. Wrappers, kind inference, and the fixed order
[Section titled “1. Wrappers, kind inference, and the fixed order”](#1-wrappers-kind-inference-and-the-fixed-order)
```clojure
(require '[toolnexus.mcp :as mcp]
'[koine.json :as json])
;; all three wrappers mean the same thing
(mapv :name (mcp/parse-config {:mcpServers {:one {:command ["a"]}}})) ;; => ["one"]
(mapv :name (mcp/parse-config {:servers {:one {:command ["a"]}}})) ;; => ["one"]
(mapv :name (mcp/parse-config {:mcp {:one {:command ["a"]}}})) ;; => ["one"]
(def parsed
(mcp/parse-config
(json/write-str {:mcpServers {"loc" {:command ["x" "y"]}
"rem" {:url "https://example.com/mcp"}
"huh" {:description "neither"}
"over" {:type "remote" :url "https://example.com/mcp"}}})))
(mapv :name parsed) ;; => ["huh" "loc" "over" "rem"] — sorted by name
(mapv :kind parsed) ;; => ["unknown" "local" "remote" "remote"]
(:command (second parsed)) ;; => ["x" "y"]
```
`"huh"` has neither `command` nor `url`, so it is `"unknown"`. That server will fail at the `config` phase the moment you connect, without a transport ever being created — catching it here is the whole point.
### 2. Enablement and the timeout default, as a CI gate
[Section titled “2. Enablement and the timeout default, as a CI gate”](#2-enablement-and-the-timeout-default-as-a-ci-gate)
```clojure
(require '[toolnexus.mcp :as mcp])
(def parsed
(mcp/parse-config {:mcpServers {"a" {:command ["x"] :disabled true}
"b" {:command ["x"] :enabled false}
"c" {:command ["x"]}
"d" {:command ["x"] :timeout 1234}}}))
(mapv (juxt :name :enabled :timeout) parsed)
;; => [["a" false 30000] ["b" false 30000] ["c" true 30000] ["d" true 1234]]
mcp/default-timeout-ms ;; => 30000
;; the gate: nothing may be silently unusable
(def broken (filterv #(and (:enabled %) (= "unknown" (:kind %))) parsed))
(assert (empty? broken) (str "unusable server blocks: " (mapv :name broken)))
```
Also note that a bare map with no wrapper *is* the server map: a config carrying `:builtins`, `:agents` and one real entry parses to exactly one server. The four reserved keys are removed because they are sibling config sections, and this is the only place that distinction matters.
### 3. Headers — expanded for use, never for printing
[Section titled “3. Headers — expanded for use, never for printing”](#3-headers--expanded-for-use-never-for-printing)
```clojure
(require '[toolnexus.mcp :as mcp])
(def parsed
(mcp/parse-config {:mcpServers {"docs" {:url "https://example.com/mcp"
:headers {"Authorization" "Bearer ${DOCS_TOKEN}"
"X-Client" "toolnexus"}}}}))
(def docs (first parsed))
;; SAFE to log: which keys exist, and whether expansion changed anything
(mcp/header-shape (:raw-headers docs) (:headers docs))
;; => {:keys ["Authorization" "X-Client"] :expanded true}
;; NOT safe to log — this map holds the real token
(:headers docs)
```
An unset variable expands to nothing rather than failing, so `:expanded false` on a config you believed used `${…}` is the signal that the variable is missing from the environment. That is the check to write, rather than one that inspects the value.
## See also
[Section titled “See also”](#see-also)
* [`toolnexus.mcp/from-config`](/api/clojure/mcp/load/) — the same parse, then connect
* [Inventory without connecting](/api/clojure/mcp/list-tools/) — how far offline inspection goes
* [Bounding a load](/api/clojure/mcp/load-with-context/) — what `:timeout` actually bounds
* [`toolnexus.core/build`](/api/clojure/toolkit/create/) — where an `:mcp` config is normally handed in
# toolnexus.mcp/resource-tools
> Opt-in list_mcp_resources / read_mcp_resource tools over every connected MCP server. Read-only. — Clojure.
**Clojure (JVM) + cljgo** · package `net.clojars.muthuishere/toolnexus` · [SPEC §2](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [clojure/src/toolnexus/mcp.cljc](https://github.com/muthuishere/toolnexus/blob/main/clojure/src/toolnexus/mcp.cljc)
```clojure
(mcp/resource-tools source) ; source: a from-config result, or a seq of connections
;; => [list_mcp_resources read_mcp_resource] both read-only (:hints {:read-only true …})
;; list_mcp_resources {:server "docs"} ; :server optional, default all
;; read_mcp_resource {:server "docs" :uri "…"}
```
MCP servers expose **resources** (files, records, documents) beside their tools. `resource-tools` returns two tools bound to the *connected* servers of a source: `list_mcp_resources` (uri, name, description, mimeType, optionally for one server) and `read_mcp_resource`. They are opt-in: add them to the toolkit only when the model should browse resources. A source with no connected server still returns both; they report that no server is connected. They stop working once the source is disconnected.
## When to use it
[Section titled “When to use it”](#when-to-use-it)
* **Docs or knowledge servers** that publish content as resources rather than tools.
* **When the model should discover context itself** instead of the host pre-loading it.
## Why this and not the alternative
[Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative)
Read the resource yourself when the host already knows which one
If the host knows the URI the model needs, read it and put it in the prompt — one less round trip. Give the model the tools when it has to choose.
## Examples
[Section titled “Examples”](#examples)
### 1. Two read-only tools, even with nothing connected
[Section titled “1. Two read-only tools, even with nothing connected”](#1-two-read-only-tools-even-with-nothing-connected)
```clojure
(require '[toolnexus.mcp :as mcp]
'[toolnexus.tool :as tool])
(println)
(def rts (mcp/resource-tools []))
(assert (= ["list_mcp_resources" "read_mcp_resource"] (mapv :name rts)))
(assert (every? #(true? (get-in % [:hints :read-only])) rts))
(def r (tool/execute (tool/toolkit rts) "list_mcp_resources" {}))
(println (:output r))
(println "OK")
```
### 2. Added beside the server’s tools
[Section titled “2. Added beside the server’s tools”](#2-added-beside-the-servers-tools)
```clojure
(def src (mcp/from-config (slurp "mcp.json")))
(def tk (tool/toolkit (concat (:tools src) (mcp/resource-tools src))))
```
### 3. Reading one resource directly
[Section titled “3. Reading one resource directly”](#3-reading-one-resource-directly)
```clojure
(tool/execute tk "read_mcp_resource" {:server "docs" :uri "file:///guide.md"})
```
## See also
[Section titled “See also”](#see-also)
* [`mcp/from-config`](/api/clojure/mcp/load/) — the source these tools bind to.
# collectTools — gather decorated functions
> Sweep a module or class and collect every function marked as a tool. — Clojure.
**Clojure (JVM) + cljgo** · package `net.clojars.muthuishere/toolnexus` · [SPEC §6](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [clojure/src/toolnexus/native.cljc](https://github.com/muthuishere/toolnexus/blob/main/clojure/src/toolnexus/native.cljc)
Not available in Clojure
This port has no collector. JS, Java and C# expose one because they have a decorator or attribute to collect; Python, Go and Elixir do not, and neither does Clojure. There is nothing to mark a function *with* here — see [Annotation / reflection tools](/api/clojure/native/reflect/) — so there is nothing to sweep a namespace for.
Sweep a module or class and collect every function marked as a tool.
## What to use instead
[Section titled “What to use instead”](#what-to-use-instead)
A vector. `toolnexus.native/native-tool` returns a plain map, so a namespace of tools is an ordinary `def` that names them all, and the “collection” is the thing you were going to write anyway:
```clojure
(ns myapp.tools
(:require [clojure.string :as str]
[toolnexus.native :as native]))
(def upper
(native/native-tool
{:name "upper"
:description "Uppercase the text"
:input-schema {:type "object" :properties {:text {:type "string"}} :required ["text"]}
:run (fn [args] (str/upper-case (str (:text args))))}))
(def reverse-text
(native/native-tool
{:name "reverse_text"
:description "Reverse the text"
:input-schema {:type "object" :properties {:text {:type "string"}} :required ["text"]}
:run (fn [args] (str/reverse (str (:text args))))}))
(def all
"Every tool this namespace exports. One place to add to, one place to read."
[upper reverse-text])
```
Hand that vector to `toolnexus.core/build` under `:tools`, next to every other source:
```clojure
(require '[toolnexus.core :as toolnexus]
'[myapp.tools :as mytools])
(def tk (toolnexus/build {:mcp "examples/mcp.json"
:skills "examples/skills"
:tools mytools/all}))
(toolnexus/tool-names tk)
;; => ["apply_patch" "bash" "edit" "glob" "grep" "question" "read" "reverse_text"
;; "skill" "todowrite" "upper" "webfetch" "write"]
```
If you really want the namespace scanned rather than listed, `ns-publics` will do it on the JVM — but it is host-specific reflection and this port keeps zero of that in the shared `.cljc` tree, so it would be your code and not the library’s, and it would not run on cljgo. The explicit vector is also the only version in which the tool list is a diff a reviewer can read.
## See also
[Section titled “See also”](#see-also)
* [`toolnexus.native/native-tool`](/api/clojure/native/define-tool/) — Wrap a plain function with a name, description and schema — the shortest path from code you have to a tool the LLM can call.
* [Annotation / reflection tools](/api/clojure/native/reflect/) — why there is no decorator to collect in the first place.
# toolnexus.native/native-tool
> Wrap a plain function with a name, description and schema — the shortest path from code you have to a tool the LLM can call. — Clojure.
**Clojure (JVM) + cljgo** · package `net.clojars.muthuishere/toolnexus` · [SPEC §6](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [clojure/src/toolnexus/native.cljc](https://github.com/muthuishere/toolnexus/blob/main/clojure/src/toolnexus/native.cljc)
```clojure
(native-tool {:name "upper"
:description "Uppercase the text"
:input-schema {:type "object"
:properties {:text {:type "string"}}
:required ["text"]}
:run (fn [args] …) ; (fn [args ctx] …) when :ctx? is true
:ctx? false})
(native-tool tool-name description input-schema run) ; positional, no :ctx?
;; => {:name "upper" :description "Uppercase the text"
;; :input-schema {…} :source "native" :execute (fn ([args]) ([args ctx]))}
```
`native-tool` is the whole native-tool surface of this port: a function, a name, a description and a JSON Schema in, a Tool map out. A Tool is a plain map with one closure in it — no protocol, no record, no deftype, because none of those behave identically on Clojure (JVM) and on cljgo.
Whatever `:run` returns is normalised into a ToolResult. A string becomes the output. `nil` becomes empty output, which is the right answer for a tool that only performs an effect. Anything else is `str`-ed. A map that already has `:output` — one you built with `toolnexus.tool/success` or `toolnexus.tool/failure` — passes through unchanged, except that `:isError` is forced to a real boolean so a truthy non-boolean can never reach the wire.
A throw is not special-cased here on purpose. `toolnexus.tool/execute` already wraps every tool call in `(catch Throwable …)` and converts it to an error ToolResult, so a native tool obeys the same boundary rule as every other source rather than being the one exception to it. Use `toolnexus.native/execute-native` when you hold a Tool without a toolkit and want that same rule applied.
## When to use it
[Section titled “When to use it”](#when-to-use-it)
* **Exposing code you already have** — a lookup, a calculation, a call into your own service. This is the shortest path from a `defn` to something the model can call.
* **Tools that need the loop’s Context** — anything reading `ctx` (the resume answer, a request id, your own tagged data) sets `:ctx? true` and takes two arguments.
* **Adapting a source toolnexus has no module for** — a queue, a database, an internal RPC client. Wrap the call, hand back a string.
* **Tests and fixtures** — a deterministic fake tool is three lines, and the resulting map is data you can inspect field by field.
## Why this and not the alternative
[Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative)
Use http-tool when the body of the function would just be an HTTP call
If `:run` would only build a URL, set headers and return the body, [`toolnexus.http/http-tool`](/api/clojure/http/tool/) already does that — including `{placeholder}` substitution, `${ENV_VAR}` expansion in headers, and non-2xx to `isError` mapping. Reach for `native-tool` when there is real logic in the function; reach for `http-tool` when there is not.
There is no reflection-based variant. Clojure and cljgo share no portable way to ask a function how many arguments it takes — arity introspection is `java.lang.reflect` on the JVM and simply absent on cljgo — so the schema is always explicit and the context is always opt-in via `:ctx?`. See [Annotation / reflection tools](/api/clojure/native/reflect/).
## Examples
[Section titled “Examples”](#examples)
### A one-argument function becomes a tool
[Section titled “A one-argument function becomes a tool”](#a-one-argument-function-becomes-a-tool)
```clojure
(require '[clojure.string :as str]
'[toolnexus.native :as native]
'[toolnexus.tool :as tool])
(def upper
(native/native-tool {:name "upper"
:description "Uppercase the text"
:input-schema {:type "object"
:properties {:text {:type "string"}}
:required ["text"]}
:run (fn [args] (str/upper-case (str (:text args))))}))
(:source upper) ;=> "native"
((:execute upper) {:text "abc"}) ;=> {:output "ABC" :isError false}
;; Register it and call it by name.
(def tk (tool/toolkit [upper]))
(:output (tool/execute tk "upper" {:text "abc"})) ;=> "ABC"
(:isError (tool/execute tk "nosuch" {})) ;=> true
```
Omit `:description` and it defaults to `""`; omit `:input-schema` and it defaults to `{:type "object"}`. Neither can be nil in the emitted adapter payloads.
### Failure that the model is meant to see, and failure that it is not
[Section titled “Failure that the model is meant to see, and failure that it is not”](#failure-that-the-model-is-meant-to-see-and-failure-that-it-is-not)
```clojure
(require '[toolnexus.native :as native]
'[toolnexus.tool :as tool])
;; An expected failure is a VALUE. The model reads it and tries again.
(def charge
(native/native-tool
{:name "charge_card"
:description "Charge the customer's card"
:run (fn [args]
(if (neg? (long (:amount args)))
(tool/failure "amount must be positive")
(tool/success (str "charged " (:amount args)))))}))
((:execute charge) {:amount -1}) ;=> {:output "amount must be positive" :isError true}
;; An unexpected failure is a throw, converted at the boundary — never propagated.
(def boom (native/native-tool {:name "boom" :run (fn [_] (throw (ex-info "kaboom" {})))}))
(native/execute-native boom {}) ;=> {:output "kaboom" :isError true}
(tool/execute (tool/toolkit [boom]) "boom" {}) ;=> {:output "kaboom" :isError true}
```
Build results with `toolnexus.tool/success` and `toolnexus.tool/failure`. They are not named `ok` and `err` because cljgo’s `clojure.core` has both of those and the JVM’s does not — a name that shadows core on one host only is exactly the drift this port exists to prevent.
### Reading the Context, and registering with the rest of the toolkit
[Section titled “Reading the Context, and registering with the rest of the toolkit”](#reading-the-context-and-registering-with-the-rest-of-the-toolkit)
```clojure
(require '[toolnexus.core :as toolnexus]
'[toolnexus.native :as native])
;; :ctx? true is the opt-in. Without it, :run is called with args alone
;; regardless of which execute arity the loop uses.
(def whoami
(native/native-tool
{:name "whoami"
:description "Who is this conversation for"
:input-schema {:type "object"}
:ctx? true
:run (fn [_args ctx] (str "user " (:user-id ctx)))}))
((:execute whoami) {} {:user-id "u-42"}) ;=> {:output "user u-42" :isError false}
((:execute whoami) {}) ;=> {:output "user " :isError false}
;; Native tools go in through :tools, alongside every other source.
(def tk (toolnexus/build {:skills "examples/skills"
:tools [whoami]}))
(toolnexus/tool-names tk)
;; => ["apply_patch" "bash" "edit" "glob" "grep" "question" "read"
;; "skill" "todowrite" "webfetch" "whoami" "write"]
```
| Key | Required | What it does |
| --------------- | -------- | ------------------------------------------------------------------------------------------------------------------------ |
| `:name` | yes | The tool name the model calls. Sanitise it with `toolnexus.tool/sanitize` if it comes from user data. |
| `:description` | no | Defaults to `""`. This is what the model reads to decide whether to call the tool — write it for the model, not for you. |
| `:input-schema` | no | JSON Schema as a Clojure map. Defaults to `{:type "object"}`. Emitted verbatim by all three adapters. |
| `:run` | yes | `(fn [args])`, or `(fn [args ctx])` when `:ctx?` is true. |
| `:ctx?` | no | Defaults to `false`. Set it to receive the Context as a second argument. |
## See also
[Section titled “See also”](#see-also)
* [Annotation / reflection tools](/api/clojure/native/reflect/) — why the schema is always explicit in this port.
* [Gathering decorated functions](/api/clojure/native/collect/) — there is no collector in this port; the alternative is a vector.
* [`toolnexus.http/http-tool`](/api/clojure/http/tool/) — the same idea when the function body would only be an HTTP call.
* [`toolnexus.builtin/builtin-tools`](/api/clojure/builtins/create/) — the ten built-in tools, built the same way.
# Annotation / reflection tools
> Derive the schema from the function signature or annotation instead of writing it by hand. — Clojure.
**Clojure (JVM) + cljgo** · package `net.clojars.muthuishere/toolnexus` · [SPEC §6](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [clojure/src/toolnexus/native.cljc](https://github.com/muthuishere/toolnexus/blob/main/clojure/src/toolnexus/native.cljc)
Not available in Clojure
There is no reflection- or metadata-driven tool builder in this port. Clojure and cljgo share no portable way to introspect a function’s arity or argument names — that is `java.lang.reflect` on the JVM and absent on cljgo — so the schema is always written by hand. The same constraint is why a tool that wants the Context declares `:ctx? true` rather than being detected.
Derive the schema from the function signature or annotation instead of writing it by hand.
## What to use instead
[Section titled “What to use instead”](#what-to-use-instead)
[`toolnexus.native/native-tool`](/api/clojure/native/define-tool/) is the whole native-tool surface here, and it always takes an explicit `:input-schema`. Write the JSON Schema as a Clojure map next to the function:
```clojure
(require '[toolnexus.native :as native])
(def get-weather
(native/native-tool
{:name "get_weather"
:description "Current weather for a city"
:input-schema {:type "object"
:properties {:city {:type "string" :description "City name"}
:units {:type "string" :enum ["c" "f"] :default "c"}}
:required ["city"]}
:run (fn [args] (str "sunny in " (:city args)))}))
((:execute get-weather) {:city "Chennai"}) ;=> {:output "sunny in Chennai" :isError false}
```
A Clojure `fn` carries no argument names and no types at runtime on either host, so there is nothing for reflection to read even if the mechanism existed — a derived schema would be a guess about a dynamically typed function, and the model would be the one to discover the guess was wrong. Writing it out costs a few lines and buys a schema that is exactly what the endpoint accepts.
The schema is a value, so factor it the way you factor any other value when you have more than a handful of tools:
```clojure
(require '[toolnexus.native :as native])
(defn- string-arg [desc] {:type "string" :description desc})
(defn- object-schema [props required]
{:type "object" :properties props :required required})
(def lookup-order
(native/native-tool
{:name "lookup_order"
:description "Fetch one order by id"
:input-schema (object-schema {:order-id (string-arg "The order id")} ["order-id"])
:run (fn [args] (str "order " (:order-id args)))}))
```
The positional arity is the same builder with the map flattened, which reads well when the schema is already a named value: `(native/native-tool "lookup_order" "Fetch one order by id" schema f)`.
## See also
[Section titled “See also”](#see-also)
* [`toolnexus.native/native-tool`](/api/clojure/native/define-tool/) — Wrap a plain function with a name, description and schema — the shortest path from code you have to a tool the LLM can call.
* [Gathering decorated functions](/api/clojure/native/collect/) — the other half of the same constraint: no decorator means no collector.
# toolnexus.agents.home/compose-soul
> Build a persona's system prompt from its home directory: identity, memory, skills. — Clojure.
**Clojure (JVM) + cljgo** · package `net.clojars.muthuishere/toolnexus` · [SPEC §7E](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [clojure/src/toolnexus/agents/home.cljc](https://github.com/muthuishere/toolnexus/blob/main/clojure/src/toolnexus/agents/home.cljc)
Supported on Clojure (JVM) and on cljgo
One `.cljc` source tree, zero reader conditionals, verified in all five execution modes. The byte cap below is measured on real bytes on both hosts — through `koine.fs/read-bytes`, because `String.` is java.lang and Go’s `[]byte`→string is unreachable from portable Clojure.
```clojure
(compose-soul "./personas/ava")
;; => {:soul "## SOUL.md\n\nI am Ava\n\n## MEMORY.md\n\n- …"
;; :found ["SOUL.md" "MEMORY.md"]}
```
The directory **is** the agent. `compose-soul` reads the bootstrap files present in `dir` and returns one string to use as a system prompt, plus the list of files it found. Absent files are skipped entirely — they leave no heading and no empty section.
## The bootstrap order
[Section titled “The bootstrap order”](#the-bootstrap-order)
Identity first, durable memory last. This order is part of the cross-port contract: a persona whose `MEMORY.md` outranked its `SOUL.md` would behave differently in eight languages.
| # | file | role |
| - | -------------- | ------------------------- |
| 1 | `AGENTS.md` | operating instructions |
| 2 | `SOUL.md` | identity / voice |
| 3 | `IDENTITY.md` | who the agent is |
| 4 | `USER.md` | model of the user |
| 5 | `TOOLS.md` | tool guidance |
| 6 | `HEARTBEAT.md` | what to do on a heartbeat |
| 7 | `MEMORY.md` | durable long-term notes |
Each present file becomes a `## ` section with its body trimmed. Each is read with a **2 MiB cap measured in bytes** (`2097152`), not characters — a larger file is injected truncated with a notice, and the file on disk is untouched.
## When to use it
[Section titled “When to use it”](#when-to-use-it)
* **You keep an agent’s identity in files** and want it in the system prompt without hand-rolling read-and-concatenate.
* **You want the identity frozen for a run.** Composition happens once, at session start; the soul does not shift under a long-lived agent, which is what keeps it cache-stable.
## Why this and not the alternative
[Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative)
It returns a string — you decide where it goes
`compose-soul` is deliberately not a client constructor. It hands back a plain string, so it drops into `:system-prompt` on any client — and into an agent definition once the §7D runtime lands here. Nothing about it requires the runtime.
## Examples
[Section titled “Examples”](#examples)
### 1. Compose a persona and use it as a system prompt
[Section titled “1. Compose a persona and use it as a system prompt”](#1-compose-a-persona-and-use-it-as-a-system-prompt)
```clojure
(require '[toolnexus.agents.home :as home]
'[toolnexus.client :as client]
'[clojure.string :as str]
'[koine.fs :as fs])
(def dir (str (fs/temp-dir! "ava")))
(fs/write-file (str dir "/SOUL.md") "I am Ava. I answer in one sentence.")
(fs/write-file (str dir "/MEMORY.md") "- the user prefers Clojure")
(def composed (home/compose-soul dir))
(println "found:" (pr-str (:found composed)))
(println (:soul composed))
;; Bootstrap ORDER, not directory order: SOUL.md outranks MEMORY.md.
(assert (= ["SOUL.md" "MEMORY.md"] (:found composed)))
(assert (< (str/index-of (:soul composed) "## SOUL.md")
(str/index-of (:soul composed) "## MEMORY.md")))
;; It is just a string — hand it to any client.
(def c (client/create-client {:base-url "http://127.0.0.1:1"
:style "openai"
:model "gpt-4o-mini"
:api-key "not-used"
:system-prompt (:soul composed)}))
(assert (str/includes? (:system-prompt c) "I am Ava"))
(fs/delete-tree! dir)
(println "OK")
```
### 2. Absent files leave no trace
[Section titled “2. Absent files leave no trace”](#2-absent-files-leave-no-trace)
A persona with one file gets exactly one section — not seven empty headings.
```clojure
(require '[toolnexus.agents.home :as home]
'[koine.fs :as fs])
(def dir (str (fs/temp-dir! "minimal")))
(fs/write-file (str dir "/SOUL.md") " just me ")
(def composed (home/compose-soul dir))
(println (pr-str (:soul composed)))
(assert (= "## SOUL.md\n\njust me" (:soul composed))) ; body trimmed, nothing else
(assert (= ["SOUL.md"] (:found composed)))
;; An empty directory composes an empty string — never nil.
(def empty-dir (str (fs/temp-dir! "empty")))
(assert (= "" (:soul (home/compose-soul empty-dir))))
(assert (= [] (:found (home/compose-soul empty-dir))))
(fs/delete-tree! dir)
(fs/delete-tree! empty-dir)
(println "OK")
```
### 3. The order is the contract
[Section titled “3. The order is the contract”](#3-the-order-is-the-contract)
```clojure
(require '[toolnexus.agents.home :as home])
(println (pr-str home/bootstrap-order))
(assert (= ["AGENTS.md" "SOUL.md" "IDENTITY.md" "USER.md"
"TOOLS.md" "HEARTBEAT.md" "MEMORY.md"]
home/bootstrap-order))
(assert (= 2097152 home/max-file-bytes)) ; bytes, not characters
(println "OK")
```
## See also
[Section titled “See also”](#see-also)
* [`toolnexus.agents.home/memory-tool`](/toolnexus/api/clojure/persona/memory-tool/) — durable memory the agent edits itself
* [`toolnexus.client/create-client`](/toolnexus/api/clojure/client/create/) — where the soul goes
# fromDir — load a persona from disk
> Point at an agent home directory and get a configured agent back. — Clojure.
**Clojure (JVM) + cljgo** · package `net.clojars.muthuishere/toolnexus` · [SPEC §7E](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md)
Not available in Clojure
There is no agent home in this port, so there is nothing to load one from. What it does read off disk is a **skills** directory — `toolnexus.skill/load-skills` globs `**/SKILL.md` — and that is the whole filesystem-to-capability path here.
Point at an agent home directory and get a configured agent back.
`fromDir` is the one-call form of §7E: a path in, a fully configured agent out — prompt, skills, memory and model all resolved from the directory’s layout. It exists so an operator can add a persona by creating a folder, with no deploy.
## What to use instead
[Section titled “What to use instead”](#what-to-use-instead)
Skills already work exactly that way, and they are the part of a persona that actually needs discovery. A directory of `SKILL.md` files becomes a catalog with no code change:
```clojure
(require '[toolnexus.core :as core]
'[toolnexus.client :as client]
'[koine.fs :as fs]
'[koine.json :as json])
(defn load-persona
"Everything this port can resolve from a directory: skills by discovery,
the prompt by convention, the model from your own config."
[home]
(let [tk (core/build {:skills (str home "/skills")})
cfg (if (fs/exists? (str home "/agent.json"))
(json/read-str (fs/read-file (str home "/agent.json")))
{})]
{:toolkit tk
:client (client/create-client
{:base-url (or (:base-url cfg) "https://api.anthropic.com")
:style (or (:style cfg) "anthropic")
:model (or (:model cfg) "claude-sonnet-4-6")
:max-turns (or (:max-turns cfg) 10)
:system-prompt (when (fs/exists? (str home "/IDENTITY.md"))
(fs/read-file (str home "/IDENTITY.md")))})}))
(let [{:keys [client toolkit]} (load-persona "agents/researcher")]
(client/run client "what changed this week?" {:toolkit toolkit}))
```
Three things make this less than `fromDir`. There is no agreed directory layout, so `IDENTITY.md` and `agent.json` are your conventions and will not match another port’s. There is no memory the agent writes back to. And a missing skills root is only a warning — `load-skills` contributes nothing and carries on, so a typo in the path yields a persona with no capabilities rather than an error.
If the discovery you want is *skills* rather than a whole persona, the port has more than a directory glob: [`toolnexus.core/build`](/api/clojure/toolkit/create/) takes `:skill-defs` for skills supplied as data, `:skill-provider` for a function that produces them at build time, and `:skills-filter` to narrow the catalog per agent. A persona registry backed by a database rather than a filesystem is a `:skill-provider`, not a directory walk.
## See also
[Section titled “See also”](#see-also)
* [`toolnexus.skill/load-skills`](/api/clojure/skills/load/) — the directory discovery that does ship
* [`skills / load-with`](/api/clojure/skills/load-with/) — skills as data, a provider fn, and per-agent filters
* [`toolnexus.client/create-client`](/api/clojure/client/create/) — the model and prompt half
* [`persona / compose-soul`](/api/clojure/persona/compose-soul/) — the prompt assembly this would perform
# toolnexus.agents.home/memory-tool
> The opt-in built-in that lets a persona write durable notes to its own home. — Clojure.
**Clojure (JVM) + cljgo** · package `net.clojars.muthuishere/toolnexus` · [SPEC §7E](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [clojure/src/toolnexus/agents/home.cljc](https://github.com/muthuishere/toolnexus/blob/main/clojure/src/toolnexus/agents/home.cljc)
Supported on Clojure (JVM) and on cljgo
One `.cljc` source tree, zero reader conditionals, verified in all five execution modes. Every example below writes to a real directory and reads it back on both hosts.
```clojure
(memory-tool "./personas/ava")
;; => a Tool named "memory" — add it to a toolkit like any other
```
One tool, three actions, over two files in the persona’s home:
| action | effect |
| --------- | ------------------------------------- |
| `add` | append an entry |
| `replace` | swap an existing substring for `with` |
| `remove` | delete an existing substring |
`target` is `self` (`MEMORY.md`, the default) or `user` (`USER.md`, the agent’s model of you).
**Not a default builtin.** Unlike the §4A tools, `memory` exists only when you wire a home directory — a read-only persona simply never gets it.
## Two rules that surprise people
[Section titled “Two rules that surprise people”](#two-rules-that-surprise-people)
Writes land on disk, but not in this session's prompt
Every action writes to disk immediately. None of them changes the **current** session’s system prompt: persisted memory loads at the start of the *next* session. That is the frozen-snapshot rule that keeps a long-lived persona cache-stable, and the tool’s own description states it, so the model knows too.
A `replace` or `remove` whose substring is absent is a loud `isError` — never a silent no-op. An agent that thinks it edited its memory and did not is worse than one that is told it failed.
## When to use it
[Section titled “When to use it”](#when-to-use-it)
* **A persona should remember across sessions** — preferences, decisions, facts about you.
* **You want the agent to curate its own notes**, not just append forever: `replace` and `remove` are what make `MEMORY.md` maintainable.
* **You are using `:flush-to-memory`** on [`compactor`](/toolnexus/api/clojure/compaction/compactor/) — that reminder tells the model to save durable facts *through this tool* before the transcript head is summarised.
## Examples
[Section titled “Examples”](#examples)
### 1. Add, and read it back off disk
[Section titled “1. Add, and read it back off disk”](#1-add-and-read-it-back-off-disk)
```clojure
(require '[toolnexus.agents.home :as home]
'[toolnexus.native :as native]
'[koine.fs :as fs])
(def dir (str (fs/temp-dir! "ava")))
(def mem (home/memory-tool dir))
(println "tool name:" (:name mem))
(assert (= "memory" (:name mem)))
(def r (native/execute-native mem {:action "add" :text "the user prefers Clojure"}))
(println "result:" (:output r))
(assert (false? (:isError r)))
;; The claim is that it PERSISTS — so read the file, not the return value.
(println "MEMORY.md =>" (pr-str (fs/read-file (str dir "/MEMORY.md"))))
(assert (= "- the user prefers Clojure\n" (fs/read-file (str dir "/MEMORY.md"))))
;; A second add appends; it does not overwrite.
(native/execute-native mem {:action "add" :text "and Go"})
(assert (= "- the user prefers Clojure\n- and Go\n" (fs/read-file (str dir "/MEMORY.md"))))
(fs/delete-tree! dir)
(println "OK")
```
### 2. Curate — replace, remove, and a loud miss
[Section titled “2. Curate — replace, remove, and a loud miss”](#2-curate--replace-remove-and-a-loud-miss)
```clojure
(require '[toolnexus.agents.home :as home]
'[toolnexus.native :as native]
'[clojure.string :as str]
'[koine.fs :as fs])
(def dir (str (fs/temp-dir! "ava")))
(fs/write-file (str dir "/MEMORY.md") "- likes tea\n- ships on Fridays\n")
(def mem (home/memory-tool dir))
(native/execute-native mem {:action "replace" :text "tea" :with "coffee"})
(println (fs/read-file (str dir "/MEMORY.md")))
(assert (str/includes? (fs/read-file (str dir "/MEMORY.md")) "- likes coffee"))
(native/execute-native mem {:action "remove" :text "- ships on Fridays\n"})
(assert (= "- likes coffee\n" (fs/read-file (str dir "/MEMORY.md"))))
;; A substring that is not there is an ERROR, not a quiet success.
(def miss (native/execute-native mem {:action "replace" :text "absent" :with "x"}))
(println "miss =>" (:isError miss) (:output miss))
(assert (true? (:isError miss)))
(assert (str/includes? (:output miss) "not found"))
;; …and the file is untouched by the failed edit.
(assert (= "- likes coffee\n" (fs/read-file (str dir "/MEMORY.md"))))
(fs/delete-tree! dir)
(println "OK")
```
### 3. `target user` writes `USER.md`
[Section titled “3. target user writes USER.md”](#3-target-user-writes-usermd)
```clojure
(require '[toolnexus.agents.home :as home]
'[toolnexus.native :as native]
'[koine.fs :as fs])
(def dir (str (fs/temp-dir! "ava")))
(def mem (home/memory-tool dir))
(native/execute-native mem {:action "add" :target "user" :text "works in IST"})
(assert (= "- works in IST\n" (fs/read-file (str dir "/USER.md"))))
(assert (not (fs/exists? (str dir "/MEMORY.md")))) ; the agent's own notes are untouched
(fs/delete-tree! dir)
(println "OK")
```
### 4. In a toolkit, alongside everything else
[Section titled “4. In a toolkit, alongside everything else”](#4-in-a-toolkit-alongside-everything-else)
The memory tool is an ordinary `Tool` — it goes into `:tools` with your own functions, MCP tools and built-ins.
```clojure
(require '[toolnexus.agents.home :as home]
'[toolnexus.core :as toolnexus]
'[toolnexus.native :as native]
'[koine.fs :as fs])
(def dir (str (fs/temp-dir! "ava")))
(def tk (toolnexus/build
{:tools [(home/memory-tool dir)
(native/native-tool {:name "now"
:description "The current time, as a fixed string."
:input-schema {:type "object" :properties {}}
:run (fn [_] "2026-08-02T00:00:00Z")})]}))
(println "tools:" (pr-str (sort (toolnexus/tool-names tk))))
(assert (some #(= "memory" %) (toolnexus/tool-names tk)))
(fs/delete-tree! dir)
(println "OK")
```
## See also
[Section titled “See also”](#see-also)
* [`toolnexus.agents.home/compose-soul`](/toolnexus/api/clojure/persona/compose-soul/) — the home directory this tool writes into, and how `MEMORY.md` reaches the prompt
* [`toolnexus.agents.compaction/compactor`](/toolnexus/api/clojure/compaction/compactor/) — `:flush-to-memory` points the model at this tool
# AgentDef
> Define a sub-agent with its own toolkit, prompt and budget, callable as a tool by its parent. — Clojure.
**Clojure (JVM) + cljgo** · package `net.clojars.muthuishere/toolnexus` · [SPEC §7D](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [clojure/src/toolnexus/agents/runtime.cljc:494-513](https://github.com/muthuishere/toolnexus/blob/main/clojure/src/toolnexus/agents/runtime.cljc)
```clojure
;; an AgentDef is a PLAIN MAP — no defrecord, no protocol
{:name "writer" ; registry key, and the id prefix a spawned handle gets
:does "writes prose" ; one-line capability blurb, used to compose the `task`
; tool's description for whoever has this agent on :team
:soul "You are a…" ; system prompt (mutually exclusive with :soul-file below)
:soul-file nil ; §7E: read once at spawn, wins over :soul when both are set
:model "gpt-4.1" ; or "inherit" to take the runtime's :llm model
:tools [...] ; this agent's OWN tools, always in its toolkit
:team ["writer" "auditor"] ; registry names this agent may `task`-delegate to —
; ABSENT or empty ⇒ no `task` tool is ever offered (opt-in)
:budget {:max-turns 6 ...} ; see Budget — carved from the parent's pool at spawn
:wait-for (fn [request] answer) ; this agent's §10 interpreter authority, if any
:on-spawn (fn [runtime handle-id] ...) ; fires once, before the first turn
:on-close (fn [runtime handle-id reason] ...) ; fires on close, reason = "closed"|"interrupted"
:hooks {...} ; §8 lifecycle hooks — resolve def-over-runtime, replace not merge
:on-metric (fn [event] ...) ; §8 observability sink — resolved independently of :hooks
:guardrails [(fn [event] nil-or-deny-reason) ...] ; policy checks compiled into one :before-tool
:completion {:verify fn :max-attempts n}} ; the gate that decides "is it actually done"
```
Define a sub-agent with its own toolkit, prompt and budget, callable as a tool by its parent. An `AgentDef` is the thing you register with a runtime — see [`create-runtime`](/api/clojure/runtime/runtime/) — under a name, and it is also what [`spawn`](/api/clojure/runtime/runtime/) resolves that name against every time it creates a new [`Handle`](/api/clojure/runtime/handle/).
## When to use it
[Section titled “When to use it”](#when-to-use-it)
* **Registering a sub-agent’s identity, once** — soul, model, its own tools, and (if it may delegate) its `:team`. Everything a spawned handle does traces back to the `AgentDef` it was spawned from.
* **Deciding whether an agent may delegate at all** — `:team` is the ONLY switch. An `AgentDef` with no `:team` (or an empty one) never sees the `task` tool in its toolkit, no matter how many agents exist in the registry: “delegation, like recursion, is opt-in” (`clojure/src/toolnexus/agents/runtime.cljc:842-850`).
* **Carrying a completion gate or a guardrail policy WITH the def**, so it travels through `task` delegation automatically — a delegated child inherits the same `:completion` and `:guardrails` its parent would have applied to a direct run, because the runtime’s `build-client` compiles `:guardrails` into the client hooks at the point it builds the child’s own client, not at the caller’s site.
* **Setting per-agent budget ceilings** that get carved out of the parent’s shared pool at `spawn` time — see [`Budget`](/api/clojure/runtime/budgets/).
## Why this and not the alternative
[Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative)
A plain map, not a defrecord or protocol — the idiomatic Clojure spelling
Other ports name `AgentDef` as a class or struct (`agents.Agent` in TypeScript/Java, a Go builder, a C# record) because their type systems make that the natural spelling of “a fixed set of named fields with defaults.” Clojure’s `AgentDef` is exactly what its docstring says: a map with the keys above, some required (`:name`, `:model`), most optional. That is deliberate and matches this port’s general style for closed data shapes — the same reasoning documented for `Guardrail` and `Verdict` in [`toolnexus.agents.loop`](/api/clojure/runtime/loop/): a thing that is “a bag of named values” needs no type beyond what a map already is, and a host reads or `merge`s it with `clojure.core` functions rather than a constructor API.
An `AgentDef` on its own does nothing — it only becomes a live agent once handed to [`create-runtime`](/api/clojure/runtime/runtime/)’s `:registry` and named by a [`spawn`](/api/clojure/runtime/runtime/) call, or (for a top-level, non-delegated agent) wrapped directly by [`agent-tool`](/api/clojure/runtime/task-tool/) or driven with `run-agent`. Reach for [`toolnexus.agents.loop`](/api/clojure/runtime/loop/)’s `harness` when the agent has no `:team`, no `:wait-for`, and needs no child delegation at all — the plain §8 loop degenerates to exactly one `client/run` and is the smaller concept for that case.
## Examples
[Section titled “Examples”](#examples)
### 1. The smallest useful call — a registry entry that just answers
[Section titled “1. The smallest useful call — a registry entry that just answers”](#1-the-smallest-useful-call--a-registry-entry-that-just-answers)
```clojure
(require '[toolnexus.agents.runtime :as rt])
(println)
(def agent-def
{:name "writer" :does "writes prose" :model "gpt-4.1"})
(def runtime (rt/create-runtime {:registry {"writer" agent-def}
:llm {:base-url "https://api.example.com" :model "gpt-4.1" :api-key-env "LLM_API_KEY"}}))
(assert (= agent-def (get (:registry runtime) "writer")))
(println "OK")
```
No `:team`, no `:tools`, no `:budget` — this agent can be spawned and woken, but it can never delegate: its toolkit view is empty of `task`.
### 2. The realistic case — a def with a team, a budget, and lifecycle hooks
[Section titled “2. The realistic case — a def with a team, a budget, and lifecycle hooks”](#2-the-realistic-case--a-def-with-a-team-a-budget-and-lifecycle-hooks)
```clojure
(require '[toolnexus.agents.runtime :as rt])
(println)
(def boss-def
{:name "boss"
:does "coordinates the writing team"
:model "gpt-4.1"
:team ["writer" "auditor"] ; the ONLY names it may `task`-delegate to
:budget {:max-tokens 20000 :max-depth 5 :max-children 4}
:on-spawn (fn [_rt id] (println "spawned:" id))
:on-close (fn [_rt id reason] (println id "closed:" reason))})
(def writer-def {:name "writer" :does "writes prose" :model "gpt-4.1"})
(def auditor-def {:name "auditor" :does "checks numbers" :model "gpt-4.1"})
(def runtime
(rt/create-runtime {:registry {"boss" boss-def "writer" writer-def "auditor" auditor-def}
:llm {:base-url "https://api.example.com" :model "gpt-4.1" :api-key-env "LLM_API_KEY"}}))
(assert (= #{"boss" "writer" "auditor"} (set (keys (:registry runtime)))))
(println "OK")
```
The `task` tool’s model-facing description is composed ONLY from `boss`’s own `:team`, sorted by name and rendered from each agent’s `:does` — e.g. `"Available agents — auditor: checks numbers; writer: writes prose"` — never from the whole registry, so an agent outside the team (say, a `"secret"` entry also registered here) is never advertised.
### 3. The full surface — a guarded, gated def whose fields travel through delegation
[Section titled “3. The full surface — a guarded, gated def whose fields travel through delegation”](#3-the-full-surface--a-guarded-gated-def-whose-fields-travel-through-delegation)
```clojure
(require '[toolnexus.agents.loop :as tnloop])
(println)
(def worker-def
{:name "worker"
:does "does the risky part"
:model "gpt-4.1"
:guardrails [(fn [ev] (when (= "delete_file" (:tool ev)) "deletion requires approval"))]
:completion {:verify tnloop/all-todos-done :max-attempts 2}
:budget {:max-turns 6 :max-tool-calls 20 :max-wall-ms 60000}
:wait-for (fn [request] nil)}) ; this level does not interpret §10 requests; they escalate
(assert (fn? (:verify (:completion worker-def))))
(assert (= 2 (:max-attempts (:completion worker-def))))
(assert (fn? (first (:guardrails worker-def))))
(assert (fn? (:wait-for worker-def)))
(println "OK")
```
`:guardrails` and `:completion` here are honoured whether `worker` is spawned directly, or delegated to via a parent’s `task` call — they are compiled into the child’s own client at the point the runtime builds it, so a delegated worker is governed exactly as if it had been driven by hand.
## See also
[Section titled “See also”](#see-also)
* [`create-runtime`](/api/clojure/runtime/runtime/) — The six host verbs that drive sub-agents spawned from a registry of `AgentDef`s, plus the read-only `handles`/`inspect` views.
* [`Handle`](/api/clojure/runtime/handle/) — The live state machine one `AgentDef` produces per `spawn` call.
* [`Budget`](/api/clojure/runtime/budgets/) — The shape and enforcement of the `:budget` field.
* [`task-tool`](/api/clojure/runtime/task-tool/) — The opt-in tool, gated on `:team`, that lets the model itself invoke `spawn`→`wake`→`wait`→`close` against this def’s teammates.
# Budget
> Cap tool calls and wall-clock per agent and per team, enforced while the run is in flight. — Clojure.
**Clojure (JVM) + cljgo** · package `net.clojars.muthuishere/toolnexus` · [SPEC §7D](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [clojure/src/toolnexus/agents/runtime.cljc:512](https://github.com/muthuishere/toolnexus/blob/main/clojure/src/toolnexus/agents/runtime.cljc)
```clojure
;; a Budget is a PLAIN MAP of any of these keys — all optional, money deliberately absent
{:max-turns 6 ; lifetime turn cap for this handle (default 6)
:max-tokens 20000 ; token pool shared by this handle and its whole subtree
:max-tool-calls 50 ; tool-call cap for this handle
:max-wall-ms 60000 ; wall-clock cap since spawn
:max-children 4 ; how many DIRECT children this handle may spawn
:max-concurrent 8 ; how many of this handle's children may be "running" at once (default 8)
:max-depth 3} ; how many delegation levels below THIS handle are allowed (default 3)
;; set on an AgentDef, carved from the parent's pool at spawn time
{:name "worker" :model "m" :budget {:max-tokens 20000 :max-depth 5}}
;; the host callback, given ONLY when a limit would stop the NEXT turn
(fn [info] "stop") ; or "extend" or "suspend" — anything else is read as "stop"
;; info = {:handle id :limit "maxTurns" :prompt "…" :turns n :tokens n :tool-calls n :pool {...}}
```
`Budget` caps tool calls and wall-clock time per agent and per team, **enforced while the run is in flight** — not just checked once at spawn. “A Budget is a map of any of `:max-turns :max-tokens :max-tool-calls :max-wall-ms :max-children :max-concurrent :max-depth`. Money is deliberately absent — it is vendor data, and a host converts tokens to money itself” (`clojure/src/toolnexus/agents/runtime.cljc:512-513`).
## Hierarchical enforcement — the carve is not the whole story
[Section titled “Hierarchical enforcement — the carve is not the whole story”](#hierarchical-enforcement--the-carve-is-not-the-whole-story)
A child’s budget is **carved** from its parent’s shared pool at [`spawn`](/api/clojure/runtime/runtime/) time, but that carve alone would miss a sibling draining the same pool first. “Usage roll-up IS the budget ledger: a child’s spend drains every ancestor’s” pool (`clojure/src/toolnexus/agents/runtime.cljc:~296`), and both `spawn` and `wake` do a **live walk** up the ancestor chain before admitting new work — so a handle whose own carved `:pool-tokens` (visible via [`inspect`](/api/clojure/runtime/handle/)) still looks untouched can still be refused because a sibling elsewhere in the tree already spent the shared pool down. `maxDepth` and `maxChildren` are checked only at `spawn`, against the **spawning parent’s own** effective cap — not an ancestor’s — so a child with no `:budget` of its own inherits a fresh default `maxDepth 3` for ITS OWN children, counted from itself.
Nine named limit strings exist across the whole §7D vocabulary — the seven budget fields above plus `"completion"` and `"timeout"` — spelled once as `toolnexus.agents.runtime/limit-max-turns` etc. and documented in full on [`agents.runtime/statuses / agents.runtime/limits`](/api/clojure/runtime/vocabulary/); `maxChildren` / `maxConcurrent` / `maxDepth` are the three that surface as a **thrown `spawn` refusal** rather than a settled `TaskResult` (A17), because they are admission refusals, not turn stops.
## The `:on-budget` host callback
[Section titled “The :on-budget host callback”](#the-on-budget-host-callback)
`create-runtime`’s optional `:on-budget` is consulted **only** when a limit would stop the next turn — absent, the limit always stops it, byte-identically to every other port. When present, it is `(fn [info] "stop"|"extend"|"suspend")` and the three decisions are:
* **`"stop"`** — settle `{:status "incomplete" :limit ""}`, naming the limit. What happens by default with no hook at all.
* **`"extend"`** — grant **exactly one** more turn past the limit (a one-shot `:budget-grant` flag), then re-run `wake` so gate 2’s concurrency queue is honoured unchanged. The pool itself is not rewritten — the host is asked again before the turn after that. The host owns the loop bound, never the runtime.
* **`"suspend"`** — park the limit as a §10 `"approval"` `Request` (`idle→suspended`, never `running→suspended`, because the suspension happens *before* the turn admits). The classic “402 → top-up → resume” story: the host answers with [`resume`](/api/clojure/runtime/runtime/) and an `Answer`; `ok=true` runs the turn the approval bought, `ok=false` settles the same `"incomplete"` a `"stop"` would have.
Anything else the callback returns — `nil`, a typo, a thrown exception — is read as `"stop"`. An unrecognised decision must never become a silent grant, which is the one way an `:on-budget` hook could turn a bounded run into an unbounded one.
## Defaults
[Section titled “Defaults”](#defaults)
Three ceilings are pinned across every port when an `AgentDef` sets no `:budget` of its own (`clojure/src/toolnexus/agents/runtime.cljc:130-135`):
* `default-max-turns` = **6**
* `default-max-concurrent` = **8** (also the default `:max-concurrent-turns` tree-wide gate on `create-runtime` itself — same number, two different scopes)
* `default-max-depth` = **3**
## When to use it
[Section titled “When to use it”](#when-to-use-it)
* **Bounding how far a delegation tree can grow** — `:max-depth` and `:max-children` stop a runaway `task` chain before it spawns unboundedly.
* **Capping spend per team**, not just per agent — put `:max-tokens` on a coordinator’s def and every child it spawns draws from that one shared pool, live, not just at carve time.
* **Turning a hard stop into a host decision** — set `:on-budget` on `create-runtime` when you want a top-up flow, a one-shot grace turn, or a metered escalation instead of an unconditional `"incomplete"`.
## Why this and not the alternative
[Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative)
A plain map, no money, no separate Budget type
`Budget` is a map like `AgentDef`, `Guardrail`, and `Verdict` elsewhere in this port — no defrecord, because a fixed set of optional numeric ceilings needs no type beyond what a map already is. It deliberately excludes any monetary field: token counts are portable across providers, dollar costs are vendor pricing data the library has no business hardcoding, so a host that wants a dollar ceiling converts tokens to money itself inside its own `:on-budget` callback, where it has the pricing table.
## Examples
[Section titled “Examples”](#examples)
### 1. The smallest useful call — a hard turn cap with no host hook
[Section titled “1. The smallest useful call — a hard turn cap with no host hook”](#1-the-smallest-useful-call--a-hard-turn-cap-with-no-host-hook)
```clojure
(require '[toolnexus.agents.runtime :as rt]
'[koine.json :as json])
(println)
(defn- text-response [t]
{:choices [{:message {:role "assistant" :content t}}]
:usage {:prompt_tokens 10 :completion_tokens 5 :total_tokens 15}})
(def runtime
(rt/create-runtime {:registry {"w" {:name "w" :does "…" :model "wm" :budget {:max-turns 1}}}
:llm {:base-url "http://mock.local" :api-key "test-key" :model "default"}
:http-client (fn [_url _headers _body]
{:status 200 :headers {} :body (json/write-str (text-response "hi"))})}))
(let [h (rt/spawn runtime rt/root "w")]
(rt/wake runtime h "first") (rt/wait runtime h) ; => "done", turn 1 of 1 spent
(let [refusal (rt/wake runtime h "second")]
(assert (false? (:ok refusal)))) ; => false — the SECOND wake exceeds the cap
(let [r (rt/wait runtime h)]
(assert (= "incomplete" (:status r)))
(assert (= "maxTurns" (:limit r)))))
(println "OK")
```
The handle is never closed or crashed by a limit stop — it stays `"idle"`, ready to be woken again once the host raises the budget or spawns a successor.
### 2. The realistic case — a shared pool a sibling can drain out from under you
[Section titled “2. The realistic case — a shared pool a sibling can drain out from under you”](#2-the-realistic-case--a-shared-pool-a-sibling-can-drain-out-from-under-you)
```clojure
(require '[toolnexus.agents.runtime :as rt]
'[koine.json :as json])
(println)
(defn- text-response [t]
{:choices [{:message {:role "assistant" :content t}}]
:usage {:prompt_tokens 10 :completion_tokens 5 :total_tokens 15}})
(def runtime
(rt/create-runtime {:registry {"boss" {:name "boss" :does "…" :model "bm" :budget {:max-tokens 10 :max-depth 5}}
"w" {:name "w" :does "…" :model "wm"}}
:llm {:base-url "http://mock.local" :api-key "test-key" :model "default"}
:http-client (fn [_url _headers _body]
{:status 200 :headers {} :body (json/write-str (text-response "hi"))})}))
(let [boss (rt/spawn runtime rt/root "boss") ; boss's def sets :budget {:max-tokens 10 :max-depth 5}
a (rt/spawn runtime boss "w")
b (rt/spawn runtime boss "w")]
;; b was carved BEFORE a spent anything — its own view still says the full 10
(assert (= 10 (:pool-tokens (rt/inspect runtime b))))
(rt/wake runtime a "burn it") (rt/wait runtime a)
(assert (= 15 (:tokens (rt/inspect runtime a)))) ; spent more than the pool alone allowed
(assert (= 10 (:pool-tokens (rt/inspect runtime b)))) ; UNCHANGED — the carve alone would say "fine"
;; but the LIVE ancestor walk at wake time catches it anyway
(assert (false? (:ok (rt/wake runtime b "my turn"))))
(let [r (rt/wait runtime b)]
(assert (= "incomplete" (:status r)))
(assert (= "maxTokens" (:limit r)))))
(println "OK")
```
### 3. The full surface — an `:on-budget` top-up flow via `"suspend"` and `resume`
[Section titled “3. The full surface — an :on-budget top-up flow via "suspend" and resume”](#3-the-full-surface--an-on-budget-top-up-flow-via-suspend-and-resume)
```clojure
(require '[toolnexus.agents.runtime :as rt]
'[toolnexus.client :as client]
'[koine.json :as json])
(println)
(defn- text-response [t]
{:choices [{:message {:role "assistant" :content t}}]
:usage {:prompt_tokens 10 :completion_tokens 5 :total_tokens 15}})
(def runtime
(rt/create-runtime
{:registry {"w" {:name "w" :does "…" :model "wm" :budget {:max-turns 1}}}
:llm {:base-url "http://mock.local" :api-key "test-key" :model "default"}
:http-client (fn [_url _headers _body]
{:status 200 :headers {} :body (json/write-str (text-response "hi"))})
:on-budget (fn [_info] "suspend")})) ; route every limit hit through §10 approval
(let [h (rt/spawn runtime rt/root "w")]
(rt/wake runtime h "one") (rt/wait runtime h)
(rt/wake runtime h "two") ; parks rather than failing outright
(assert (= "suspended" (:state (rt/inspect runtime h))))
(let [req (:pending (rt/wait runtime h))]
(assert (= "approval" (:kind req)))
;; the host tops up: an Answer with ok=true buys the exact one turn the limit blocked
(rt/resume runtime (client/make-answer (:id req) true))
(let [r (rt/wait runtime h)]
(assert (= "done" (:status r))) ; turns GREW to 2, never reset
(assert (= 2 (:turns r))))))
(println "OK")
```
## See also
[Section titled “See also”](#see-also)
* [`AgentDef`](/api/clojure/runtime/agent/) — Where `:budget` is set per registered agent.
* [`create-runtime`](/api/clojure/runtime/runtime/) — `spawn` carves the budget and walks the ancestor chain; `wake` re-checks it live; `resume` is how a `"suspend"` decision is answered.
* [`agents.runtime/statuses / agents.runtime/limits`](/api/clojure/runtime/vocabulary/) — The nine named limit strings (`limit-max-turns` etc.) a stopped `TaskResult`’s `:limit` field names.
# Handle
> The state machine for one spawned agent: pending, running, suspended, done. — Clojure.
**Clojure (JVM) + cljgo** · package `net.clojars.muthuishere/toolnexus` · [SPEC §7D](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [clojure/src/toolnexus/agents/runtime.cljc:10](https://github.com/muthuishere/toolnexus/blob/main/clojure/src/toolnexus/agents/runtime.cljc)
```clojure
;; a Handle is NOT a value you construct — it is created by `spawn` and
;; referenced everywhere else by its id string, e.g. "root/coordinator.1/explore.2"
(require '[toolnexus.agents.runtime :as rt])
(def h (rt/spawn runtime rt/root "writer")) ; => "root/writer.1", a deterministic, parent-scoped id
;; the two read-only views built on the Handle
(rt/handles runtime) ; => tree-order snapshot, every handle under root
;; => [{:id "root/writer.1" :state "idle" :tokens 15 :inbox 0} ...]
(rt/inspect runtime h) ; => the full detail view for ONE handle
;; => {:id "root/writer.1" :state "idle" :tokens 15 :inbox 0
;; :turns 2 :pool-tokens 4985 :pending nil}
```
“A Handle is a live agent: a state machine (`idle → running → idle|suspended|closed`, and `suspended → running` ONLY via the Answer to its pending Request), an inbox held as AGENT state (never a language mailbox), a carved budget, and a deterministic parent-scoped id” (`clojure/src/toolnexus/agents/runtime.cljc:10-13`). A `Handle` is what every [`create-runtime`](/api/clojure/runtime/runtime/) verb operates on — `spawn` creates one, `wake`/ `wait`/`interrupt`/`close`/`resume` act on one by id, and `handles`/`inspect` are the read-only windows onto its current state.
## The four states
[Section titled “The four states”](#the-four-states)
* **`idle`** — alive, no turn in flight. A freshly spawned handle starts here; a handle that settles (with a `"done"`, `"incomplete"`, `"interrupted"`, or `"error"` result) returns here, never to `"closed"` on its own.
* **`running`** — a turn is in flight, admitted by `wake` through gate 1 (bounded inbox), gate 2 (per-parent concurrency, FIFO-queued past the cap) and gate 3 (a tree-wide cap on LLM calls actually in flight).
* **`suspended`** — parked on a pending §10 `Request` (or a budget limit routed through `"suspend"`). The **only** way out is `resume` with the matching `Answer` — waking or posting to a suspended handle just buffers, it never moves the state.
* **`closed`** — torn down by `close`. Still queryable: `inspect` still answers, `wait` still returns the last recorded result, and a fresh handle may be `spawn`ned in its place from the same registry entry. Close ≠ loss.
## What `inspect` returns
[Section titled “What inspect returns”](#what-inspect-returns)
`(rt/inspect runtime id)` is the detail view of one handle — `view` (`:id`, `:state`, `:tokens`, `:inbox`) plus three extra fields (`clojure/src/toolnexus/agents/runtime.cljc:588-593`):
* **`:turns`** — `:turns-total`, the handle’s lifetime turn count (grows across a `resume`, never resets).
* **`:pool-tokens`** — the token allowance this handle’s OWN carved budget pool still holds. This is the handle’s local carve, not the live ancestor-chain walk `spawn`/`wake` also check — see [`Budget`](/api/clojure/runtime/budgets/) for why the carve alone is not enough to catch a sibling’s overspend.
* **`:pending`** — the §10 `Request` this handle is parked on, when `:state` is `"suspended"`; `nil` otherwise.
`(rt/handles runtime)` is the coarser, tree-order sibling: every handle under a given root (default `rt/root`), each rendered through the same `view` — `:id`, `:state`, `:tokens`, `:inbox` — with no `:turns`/`:pool-tokens`/`:pending`. Reach for `handles` to enumerate a tree or build a dashboard; reach for `inspect` when you already have one id and want its full detail.
## When to use it
[Section titled “When to use it”](#when-to-use-it)
* **Branching on a handle’s `:state`** after a verb call, e.g. deciding whether a `"suspended"` handle needs a host to answer its pending request, or a `"running"` one just needs a longer `wait`.
* **Building an observability view** — a dashboard listing every live sub-agent, its token spend, and its inbox depth, without touching any verb that would change state.
* **Confirming a handle survived `close`** — `inspect` on a closed handle still answers with its last state and result, which is exactly the guarantee that makes “close ≠ loss” true in practice.
## Why this and not the alternative
[Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative)
A handle is an id you hold, not an object you pass around
Other ports return a `Handle` object with its own methods (`handle.wait()`, `handle.close()` in js/python/java/csharp). This port’s `spawn` returns a plain **id string**, and every other verb (`wake`, `wait`, `interrupt`, `close`, `inspect`) takes `runtime` and that id as separate arguments. That is deliberate: “handles are capabilities — post and wake what you hold, wait only on what you spawned” (`runtime.cljc:719-720`), and an id is trivially something you can hold in a set, log, or pass across a boundary without smuggling a live reference to the runtime’s internal state along with it. `wait`’s own `:by` option enforces the capability rule at the verb level — a handle waited on by anyone other than its actual spawner settles with an `"error"` result, never a silent success.
## Examples
[Section titled “Examples”](#examples)
### 1. The smallest useful call — read one handle’s state after spawning it
[Section titled “1. The smallest useful call — read one handle’s state after spawning it”](#1-the-smallest-useful-call--read-one-handles-state-after-spawning-it)
```clojure
(require '[toolnexus.agents.runtime :as rt]
'[koine.json :as json])
(println)
(defn- text-response [t]
{:choices [{:message {:role "assistant" :content t}}]
:usage {:prompt_tokens 10 :completion_tokens 5 :total_tokens 15}})
(def runtime
(rt/create-runtime {:registry {"w" {:name "w" :does "writes" :model "wm"}}
:llm {:base-url "http://mock.local" :api-key "test-key" :model "default"}
:http-client (fn [_url _headers _body]
{:status 200 :headers {} :body (json/write-str (text-response "done"))})}))
(let [h (rt/spawn runtime rt/root "w")]
(assert (= "idle" (:state (rt/inspect runtime h))))) ; => "idle" — a fresh handle is idle, not running
(println "OK")
```
### 2. The realistic case — watch a handle move through running to idle
[Section titled “2. The realistic case — watch a handle move through running to idle”](#2-the-realistic-case--watch-a-handle-move-through-running-to-idle)
```clojure
(require '[toolnexus.agents.runtime :as rt]
'[koine.json :as json])
(println)
(defn- text-response [t]
{:choices [{:message {:role "assistant" :content t}}]
:usage {:prompt_tokens 10 :completion_tokens 5 :total_tokens 15}})
(def runtime
(rt/create-runtime {:registry {"w" {:name "w" :does "writes" :model "wm"}}
:llm {:base-url "http://mock.local" :api-key "test-key" :model "default"}
:http-client (fn [_url _headers _body]
{:status 200 :headers {} :body (json/write-str (text-response "done"))})}))
(let [h (rt/spawn runtime rt/root "w")]
(rt/wake runtime h "go")
(let [r (rt/wait runtime h)]
(assert (= "done" (:status r))))
(assert (= "idle" (:state (rt/inspect runtime h))))) ; a settled handle returns to idle, never "closed"
(println "OK")
```
### 3. The full surface — inspecting a suspended handle’s pending request, then a whole tree
[Section titled “3. The full surface — inspecting a suspended handle’s pending request, then a whole tree”](#3-the-full-surface--inspecting-a-suspended-handles-pending-request-then-a-whole-tree)
```clojure
(require '[toolnexus.agents.runtime :as rt]
'[toolnexus.client :as client]
'[toolnexus.tool :as tool]
'[koine.json :as json])
(println)
(defn- calls-response [calls]
{:choices [{:message {:role "assistant" :content nil
:tool_calls (mapv (fn [c] {:id (:id c) :type "function"
:function {:name (:name c)
:arguments (json/write-str (or (:args c) {}))}})
calls)}}]
:usage {:prompt_tokens 10 :completion_tokens 5 :total_tokens 15}})
(defn- text-response [t]
{:choices [{:message {:role "assistant" :content t}}]
:usage {:prompt_tokens 10 :completion_tokens 5 :total_tokens 15}})
(def script {"bm" [{:calls [{:id "c1" :name "task" :args {:agent "worker" :prompt "risky"}}]}]
"wm" [{:calls [{:id "w1" :name "approve" :args {}}]}]})
(defn- mock-http []
(let [counts (atom {})]
(fn [_url _headers body]
(let [b (json/read-str body)
model (:model b)
n (get (swap! counts update model (fnil inc 0)) model)
spec (get (get script model) (dec n))]
{:status 200 :headers {}
:body (json/write-str (if (seq (:calls spec)) (calls-response (:calls spec)) (text-response "done")))}))))
(def approve-tool
(tool/tool {:name "approve" :description "needs approval"
:execute (fn ([_args] (client/suspend (client/make-request "approval" "Approve the impossible")))
([_args ctx] (tool/success "approved")))}))
(def runtime
(rt/create-runtime
{:registry {"boss" {:name "boss" :does "coordinates" :model "bm" :team ["worker"] :budget {:max-depth 5}}
"worker" {:name "worker" :does "does the risky part" :model "wm" :tools [approve-tool]}}
:llm {:base-url "http://mock.local" :api-key "test-key" :model "default"}
:http-client (mock-http)}))
;; after a §10 approval request suspends the handle mid-turn:
(let [boss (rt/spawn runtime rt/root "boss")]
(rt/wake runtime boss "go")
(rt/wait runtime boss)
(let [worker (str boss "/worker.1")
detail (rt/inspect runtime worker)]
(assert (= "suspended" (:state detail)))
(assert (= "approval" (:kind (:pending detail)))) ; {:kind "approval" :prompt "…" :id "…" :data {...}}
;; enumerate the whole subtree rooted AT boss — includes boss itself, then its descendants
(let [hs (rt/handles runtime boss)]
(assert (= [boss worker] (mapv :id hs)))
(assert (= "suspended" (:state (second hs)))))))
(println "OK")
```
## See also
[Section titled “See also”](#see-also)
* [`create-runtime`](/api/clojure/runtime/runtime/) — The six host verbs (`spawn`, `post`, `wake`, `wait`, `interrupt`, `close`) plus `resume` that create and drive a `Handle`, and the state machine transitions they trigger.
* [`AgentDef`](/api/clojure/runtime/agent/) — The registry entry a `spawn` call turns into a live `Handle`.
* [`Budget`](/api/clojure/runtime/budgets/) — What `:pool-tokens` on `inspect`’s view actually measures, and why a live ancestor walk checks more than the local carve.
# toolnexus.agents.loop
> Agent.Loop(...).Run drives the agent under a Guardrail policy that vets every tool call and a Completion check that decides when the task is done — the gated door beside the plain Agent.Run, with unsupported spec fields (tools, team, waitFor, onMetric) named explicitly rather than silently dropped. — Clojure.
**Clojure (JVM) + cljgo** · package `net.clojars.muthuishere/toolnexus` · [SPEC §7D](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [clojure/src/toolnexus/agents/loop.cljc](https://github.com/muthuishere/toolnexus/blob/main/clojure/src/toolnexus/agents/loop.cljc)
```clojure
(require '[toolnexus.agents.loop :as tnloop])
;; harness is a NAME, not a wrapper — identity function
(tnloop/harness agent-def) ; => agent-def, unchanged
;; open a live execution
(def lp (tnloop/create agent-def options toolkit))
;; => {:def agent-def :options options :toolkit toolkit
;; :status "idle" :turns 0 :history []}
;; run one request — returns [outcome loop']
(let [[outcome lp'] (tnloop/run lp "do the thing")]
outcome) ; a RunResult, same shape client/run returns
;; the built-in completion verifier: reads the shipped `todowrite` builtin's
;; result metadata, requires every item checked
(tnloop/all-todos-done result) ; => {:ok true} | {:ok false :reason "…"}
;; which agent-def fields THIS driver cannot honour, by name (present-only, order-pinned)
(tnloop/loop-unsupported agent-def) ; => (subset of) ["tools" "team" "waitFor" "onMetric"]
tnloop/loop-unsupported-fields ; => ["tools" "team" "waitFor" "onMetric"] — the canonical order
;; shared gate primitive — a client run wrapped with the completion check
(tnloop/run-gated ask prompt completion state)
```
`Agent.Loop(...).Run` drives the agent under a `Guardrail` policy that vets every tool call and a `Completion` check that decides when the task is done — the gated door beside the plain `Agent.Run`, with unsupported spec fields (`tools`, `team`, `waitFor`, `onMetric`) named explicitly rather than silently dropped. See the narrative doc, [Harness & loop](/harness/), for the placement law this namespace encodes — this page is the Clojure API surface for it, not a restatement.
The placement law, briefly: the **harness** (an agent def — tools, soul, team, budget, model, policy, ceilings) answers *“MAY it?”*, fixed per problem. Run options answer *“with WHAT?”* — the model for this call. The **loop** answers *“DID it?”* — status, turns, stop reason — and is observed, never configured; a loop takes no options of its own.
## When to use it
[Section titled “When to use it”](#when-to-use-it)
* **You want a completion gate** — retry the model automatically while a `:completion` check keeps failing, up to `:max-attempts`, feeding back *why* it failed on each retry prompt — without hand-writing that retry loop yourself.
* **You want the built-in `todowrite`-based completion check** (`all-todos-done`) rather than writing your own `:verify` — a plan-using agent is only “done” once every declared todo item is checked off.
* **You want to know, up front, which parts of an agent def a plain loop cannot honour** — `:tools` (child delegation), `:team`, `:wait-for`, `:on-metric` — rather than have them silently ignored. An agent def whose `:team` matters belongs on [`toolnexus.agents.runtime`](/api/clojure/runtime/runtime/) instead, which honours all four.
## Why this and not the alternative
[Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative)
Use the plain client/run when there is nothing to gate
`toolnexus.agents.loop` exists entirely for the completion gate and the loop-unsupported check. If your agent def has no `:completion`, no `:guardrails`, and does not touch `:tools`/`:team`/ `:wait-for`/`:on-metric`, calling `tnloop/run` degenerates to exactly one `client/run` — reach straight for [`client/run`](/api/clojure/client/run/) instead and skip the extra concept. Reach for `toolnexus.agents.runtime` instead of the loop when the agent genuinely needs child delegation, budgets shared across a team, or `waitFor`/metric routing — those four are the loop’s structural ceiling, not a bug to work around.
`Guardrail` and `Verdict`/`Completion` are carried as **plain data** here — `guarded-hooks` compiles a vector of guardrail functions into one `:before-tool` hook (first-deny-wins), and a verdict is just `{:ok bool :reason str}`, not a defrecord or a named protocol. That is deliberate, idiomatic Clojure: a guardrail is “a function from an event to a string or nil,” which needs no type beyond what `fn?` already checks, and a verdict is “two keys,” which needs no type beyond what a map already is. Other ports name these as an interface (`Guardrail` in js/python/java/csharp) or a struct (`Verdict` in js/java/csharp/elixir) because their type systems make that the idiomatic spelling of the same contract — this is a language-shape difference, not a missing feature.
## Examples
[Section titled “Examples”](#examples)
### 1. The smallest useful call — a loop with no gate at all
[Section titled “1. The smallest useful call — a loop with no gate at all”](#1-the-smallest-useful-call--a-loop-with-no-gate-at-all)
```clojure
(require '[toolnexus.agents.loop :as tnloop]
'[toolnexus.tool :as tool]
'[koine.json :as json])
(println)
(defn- text-response [t]
{:choices [{:message {:role "assistant" :content t}}]
:usage {:prompt_tokens 10 :completion_tokens 5 :total_tokens 15}})
(def agent-def (tnloop/harness {:soul "You are concise." :model "gpt-4.1"}))
(def opts {:base-url "http://mock.local" :api-key "test-key"
:http-client (fn [_url _headers _body]
{:status 200 :headers {} :body (json/write-str (text-response "Node offline: rebooted the switch."))})})
(def lp (tnloop/create agent-def opts (tool/toolkit [])))
(let [[outcome lp'] (tnloop/run lp "Summarise the incident in one sentence")]
(assert (= "done" (:status outcome)))
(assert (= "Node offline: rebooted the switch." (:text outcome))))
(println "OK")
```
No `:completion`, no `:guardrails` — this is byte-identical to calling `client/run` directly, which is the point: absent gating costs nothing.
### 2. The realistic case — gate on the built-in todo-completion check
[Section titled “2. The realistic case — gate on the built-in todo-completion check”](#2-the-realistic-case--gate-on-the-built-in-todo-completion-check)
```clojure
(require '[toolnexus.agents.loop :as tnloop]
'[toolnexus.tool :as tool]
'[toolnexus.builtin :as builtin]
'[koine.json :as json])
(println)
(defn- calls-response [calls]
{:choices [{:message {:role "assistant" :content nil
:tool_calls (mapv (fn [c] {:id (:id c) :type "function"
:function {:name (:name c)
:arguments (json/write-str (or (:args c) {}))}})
calls)}}]
:usage {:prompt_tokens 10 :completion_tokens 5 :total_tokens 15}})
(defn- text-response [t]
{:choices [{:message {:role "assistant" :content t}}]
:usage {:prompt_tokens 10 :completion_tokens 5 :total_tokens 15}})
;; turn 1: the model claims done with an OPEN todo — the gate re-prompts;
;; turn 2 (after the re-prompt): it checks the item off, and the gate passes
(def script (atom [{:calls [{:id "t1" :name "todowrite"
:args {:todos [{:text "migrate schema" :completed false}]}}]}
{:text "done, schema migrated"}
{:calls [{:id "t2" :name "todowrite"
:args {:todos [{:text "migrate schema" :completed true}]}}]}
{:text "done, all checked off"}]))
(defn- mock-http []
(let [n (atom 0)]
(fn [_url _headers _body]
(let [i (swap! n inc)
spec (get @script (dec i))]
{:status 200 :headers {}
:body (json/write-str (if (seq (:calls spec)) (calls-response (:calls spec)) (text-response (:text spec))))}))))
(def todowrite-tool (first (filter #(= "todowrite" (:name %)) builtin/builtin-tools)))
(def agent-def
(tnloop/harness
{:soul "You plan your work with todowrite before finishing."
:model "gpt-4.1"
:completion {:verify tnloop/all-todos-done
:max-attempts 3}}))
(def opts {:base-url "http://mock.local" :api-key "test-key" :http-client (mock-http)})
(def lp (tnloop/create agent-def opts (tool/toolkit [todowrite-tool])))
(let [[outcome _] (tnloop/run lp "Migrate the config schema")]
(case (:status outcome)
"done" (println "finished, all todos checked off:" (:text outcome))
"incomplete" (println "gate failed" (:limit outcome) "-" (:text outcome))
(println (:status outcome)))
(assert (= "done" (:status outcome)))
(assert (= "done, all checked off" (:text outcome))))
(println "OK")
```
If the model claims done with open todo items, the gate re-prompts with `"Your work did not verify: item(s) still open: <...>. Fix it and finish."` up to `:max-attempts` times before giving up with `:status "incomplete" :limit "completion"`.
### 3. The full surface — a guardrail policy plus checking what the loop cannot honour
[Section titled “3. The full surface — a guardrail policy plus checking what the loop cannot honour”](#3-the-full-surface--a-guardrail-policy-plus-checking-what-the-loop-cannot-honour)
```clojure
(require '[toolnexus.agents.loop :as tnloop]
'[toolnexus.tool :as tool]
'[koine.json :as json])
(println)
(defn- calls-response [calls]
{:choices [{:message {:role "assistant" :content nil
:tool_calls (mapv (fn [c] {:id (:id c) :type "function"
:function {:name (:name c)
:arguments (json/write-str (or (:args c) {}))}})
calls)}}]
:usage {:prompt_tokens 10 :completion_tokens 5 :total_tokens 15}})
(defn- text-response [t]
{:choices [{:message {:role "assistant" :content t}}]
:usage {:prompt_tokens 10 :completion_tokens 5 :total_tokens 15}})
(def script (atom [{:calls [{:id "d1" :name "delete_file" :args {:path "/tmp/x"}}]}
{:text "could not delete, denied"}]))
(defn- mock-http []
(let [n (atom 0)]
(fn [_url _headers _body]
(let [i (swap! n inc)
spec (get @script (dec i))]
{:status 200 :headers {}
:body (json/write-str (if (seq (:calls spec)) (calls-response (:calls spec)) (text-response (:text spec))))}))))
(def delete-file-tool
(tool/tool {:name "delete_file" :description "deletes a file"
:execute (fn [_args] (tool/success "deleted"))}))
(def child-agent-def {:name "child" :does "helps" :model "m"})
(def agent-def
(tnloop/harness
{:soul "You may read files but never delete them without asking."
:model "gpt-4.1"
:guardrails [(fn [ev] (when (= "delete_file" (:tool ev)) "deletion requires human approval"))]
:completion {:verify tnloop/all-todos-done :max-attempts 2}
;; these two are NOT honoured by a Loop — see loop-unsupported below
:tools [child-agent-def]
:team ["writer" "auditor"]}))
;; find out, before running, which fields this driver will ignore
(def unsupported (tnloop/loop-unsupported agent-def))
;; => ["tools" "team"] -- present on the def, but not honoured by a plain Loop
(assert (= ["tools" "team"] unsupported))
(def opts {:base-url "http://mock.local" :api-key "test-key" :http-client (mock-http)})
(def lp (tnloop/create agent-def opts (tool/toolkit [delete-file-tool])))
(let [[outcome _] (tnloop/run lp "Clean up the temp directory")]
(assert (= "could not delete, denied" (:text outcome))))
;; a guardrail denial never reaches the model as a tool result silently allowed —
;; it comes back as "denied: deletion requires human approval"
(println "OK")
```
`loop-unsupported` returns only the fields **actually present** on the def, in the canonical order pinned by `loop-unsupported-fields` — an agent def with no `:team` and no `:on-metric` reports neither, so the check is additive: it never claims a problem the def does not actually have.
## See also
[Section titled “See also”](#see-also)
* [`agents.runtime/statuses / agents.runtime/limits`](/api/clojure/runtime/vocabulary/) — The canonical status/limit strings `all-todos-done` and a `:completion` verify function read off a run’s result.
* [Harness & loop](/harness/) — The narrative doc: the placement law (MAY it? / with WHAT? / DID it? / is it RIGHT?) this namespace’s split between agent def, run options, and loop encodes.
* [`toolnexus.agents.runtime`](/api/clojure/runtime/runtime/) — The full runtime this loop sits beside: honours `:tools`, `:team`, `:wait-for` and `:on-metric` where a plain `Loop` cannot.
* [`toolnexus.client/run`](/api/clojure/client/run/) — The unwrapped §8 client run a gate-free loop degenerates to.
# create-runtime
> The six host verbs that drive sub-agents, plus the read-only list and inspect views. — Clojure.
**Clojure (JVM) + cljgo** · package `net.clojars.muthuishere/toolnexus` · [SPEC §7D](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [clojure/src/toolnexus/agents/runtime.cljc](https://github.com/muthuishere/toolnexus/blob/main/clojure/src/toolnexus/agents/runtime.cljc)
```clojure
(require '[toolnexus.agents.runtime :as rt])
;; build the runtime — one process-local tree of handles
(def runtime
(rt/create-runtime {:registry {"writer" {:name "writer" :does "writes" :model "gpt-4.1"}}
:llm {:base-url "https://api.example.com" :model "gpt-4.1" :api-key-env "LLM_API_KEY"}
:clock nil ; default: system-clock; pass rt/virtual-clock in tests
:inbox-cap 8 ; gate 1 — per-handle inbox capacity
:max-concurrent-turns 8 ; gate 3 — LLM calls in flight, tree-wide
:shutdown-ms 200 ; graceful-close bound before an abort escalates
:on-budget nil})) ; (fn [info] "stop"|"extend"|"suspend")
;; the six host verbs
(rt/spawn runtime rt/root "writer") ; => child id, or {:error "…"}
(rt/post runtime child-id item) ; => {:ok true} | {:ok false :error "…"}
(rt/wake runtime child-id "prompt") ; => {:ok true} | {:ok false :error "…"} — does NOT block
(rt/wait runtime child-id) ; => TaskResult — blocks for the next result
(rt/interrupt runtime child-id) ; => nil — abort in place, handle stays alive
(rt/close runtime child-id) ; => nil — graceful shutdown, leaf-first
(rt/resume runtime answer) ; => TaskResult of the topmost re-run handle
;; read-only views
(rt/handles runtime) ; => [{:id :state :tokens :inbox} ...] tree order, root excluded
(rt/inspect runtime child-id) ; => {:id :state :tokens :inbox :turns :pool-tokens :pending}
(rt/trace runtime) ; => the §0 conformance artifact: every transition, in order
```
The six host verbs that drive sub-agents — `spawn`, `post`, `wake`, `wait`, `interrupt`, `close` — plus `resume` for §10 durable suspension, and the read-only `handles`/`inspect` views. This is the core of the §7D runtime: everything a host needs to create, drive, observe and tear down a tree of delegated agents, built once as `create-runtime` and threaded through every call after.
## The state machine
[Section titled “The state machine”](#the-state-machine)
Every handle is `idle → running → idle|suspended|closed`, and `suspended → running` **only** via the `Answer` to its pending `Request` (`clojure/src/toolnexus/agents/runtime.cljc:10-11`):
* **`idle`** — alive, no turn in flight. A fresh handle starts here; a settled one returns here.
* **`running`** — `wake` admitted a turn (subject to gate 1’s inbox cap, gate 2’s per-parent concurrency, and gate 3’s tree-wide LLM-call cap).
* **`suspended`** — the turn hit a §10 `Request` (or a budget routed through `"suspend"`); only `resume` with the matching `Answer` moves it, and it resumes with turns/tokens **growing**, never resetting.
* **`closed`** — torn down by `close`; still queryable by `inspect` and `wait` (its last recorded result), and a successor may be spawned fresh from the registry.
`spawn` checks `maxDepth`/`maxChildren` and does a **live** budget walk up the ancestor chain (not just the carve at spawn time — a sibling that has already drained the shared pool stops the next spawn too). `wake` is non-blocking; call `wait` to block for the settled `TaskResult`, optionally with `:timeout-ms` (the child keeps running past a wait timeout) or `:by` (only the spawner may wait on a handle — handles are capabilities). `interrupt` aborts an in-flight turn or cancels a pending request, cooperatively, leaving the handle alive. `close` is graceful and **leaf-first**: children close before their parent, a running turn gets `:shutdown-ms` to finish before being aborted, and `(close rt rt/root)` is stop-all.
## Two delegation mechanisms — do not conflate them
[Section titled “Two delegation mechanisms — do not conflate them”](#two-delegation-mechanisms--do-not-conflate-them)
In-process runtime vs. cross-process A2A — different mechanisms, different page
`toolnexus.agents.runtime` delegates **in-process**: `spawn` creates a handle inside the SAME runtime, sharing its clock, its conversation store, and its turn gate — this is what `task-tool` and `agent-tool` are built on. It has nothing to do with `toolnexus.serve`/`toolnexus.a2a`’s **remote** delegation, where an entire separate process is exposed as a remote A2A agent (Agent Card + JSON-RPC, SPEC §7B) and invoked over the network. Use this runtime when one process needs a tree of sub-agents sharing budgets and a transcript store; reach for the A2A serve/remote-agent path when the “sub-agent” is actually a different deployment, a different team’s service, or anything that must cross a process boundary. The two are not interchangeable and this page covers only the in-process one.
## When to use it
[Section titled “When to use it”](#when-to-use-it)
* **You need a sub-agent tree with real backpressure** — bounded inboxes, per-parent concurrency, a tree-wide cap on LLM calls in flight — rather than hand-rolling coroutines around `client/run`.
* **You need hierarchical budgets enforced live**, not just carved once at spawn: a sibling that overspends the shared pool must stop the next sibling’s `wake`, and `create-runtime`’s `:on-budget` hook lets the host decide `"stop"`/`"extend"`/`"suspend"` instead of always hard stopping. See [`Budget`](/api/clojure/runtime/budgets/).
* **You need the model itself to delegate**, via [`task-tool`](/api/clojure/runtime/task-tool/) — which is `spawn`→`wake`→`wait`→`close` fused into one tool call and reattached by task key on replay.
* **You need a deterministic transition trace** for tests or conformance — `trace` records every transition, refusal and escalation in commit order, which is what §0 conformance is actually measured against, not the return values alone.
## Why this and not the alternative
[Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative)
Reach for the plain client, or the gated loop, when there is no tree
If your agent never delegates — no `:team`, no child handles — `create-runtime` is overhead. [`toolnexus.client/run`](/api/clojure/client/run/) or, when you want a completion gate, [`toolnexus.agents.loop`](/api/clojure/runtime/loop/) is the right size. Reach for this runtime specifically when an agent’s `AgentDef` sets `:team`, `:budget` shared across children, or `:wait-for`/`:on-metric` routing that must cross a handle boundary — those are the loop’s documented structural ceiling (see [`toolnexus.agents.loop`](/api/clojure/runtime/loop/)’s own “Why this and not the alternative”), not a bug to route around by hand.
`create-runtime` throws (`ex-info`) at construction time if more than one of `:http-client`, `:llm`, and `:in-process` (ADR 0030 — a model running in the same process, wired through the same turn gate as any HTTP transport) is set — never silently resolved by precedence, because a runtime with an ambiguous transport is exactly the kind of drift the ports exist to prevent.
## Examples
[Section titled “Examples”](#examples)
### 1. The smallest useful call — spawn, wake, wait
[Section titled “1. The smallest useful call — spawn, wake, wait”](#1-the-smallest-useful-call--spawn-wake-wait)
```clojure
(require '[toolnexus.agents.runtime :as rt]
'[koine.json :as json])
(println)
(defn- text-response [t]
{:choices [{:message {:role "assistant" :content t}}]
:usage {:prompt_tokens 10 :completion_tokens 5 :total_tokens 15}})
(def runtime
(rt/create-runtime {:registry {"w" {:name "w" :does "writes" :model "wm"}}
:llm {:base-url "http://mock.local" :api-key "test-key" :model "default"}
:http-client (fn [_url _headers _body]
{:status 200 :headers {} :body (json/write-str (text-response "done"))})}))
(def h (rt/spawn runtime rt/root "w")) ; => "root/w.1"
(rt/wake runtime h "go") ; => {:ok true}
(let [r (rt/wait runtime h)]
(assert (= "root/w.1" h))
(assert (= "done" (:status r))))
(println "OK")
```
The full trace for this run is exactly three lines — `"root/w.1: spawned (depth 1, tokens Infinity)"`, `"root/w.1: idle→running (wake)"`, `"root/w.1: running→idle (done, turns=1, tokens=15)"` — an extra transition is as wrong as a missing one.
### 2. The realistic case — a bounded wait, then interrupt or close
[Section titled “2. The realistic case — a bounded wait, then interrupt or close”](#2-the-realistic-case--a-bounded-wait-then-interrupt-or-close)
```clojure
(require '[toolnexus.agents.runtime :as rt]
'[koine.json :as json])
(println)
(defn- text-response [t]
{:choices [{:message {:role "assistant" :content t}}]
:usage {:prompt_tokens 10 :completion_tokens 5 :total_tokens 15}})
(def runtime
(rt/create-runtime {:registry {"w" {:name "w" :does "writes" :model "wm"}}
:llm {:base-url "http://mock.local" :api-key "test-key" :model "default"}
:http-client (fn [_url _headers _body]
{:status 200 :headers {} :body (json/write-str (text-response "done"))})}))
(let [h (rt/spawn runtime rt/root "w")]
(rt/wake runtime h "start the long job")
(let [r (rt/wait runtime h {:timeout-ms 5000})]
(if (= "timeout" (:status r))
;; the child keeps running — a wait deadline is the WAITER's deadline
(rt/interrupt runtime h) ; abort in place; handle stays idle, alive
(assert (= "done" (:text r)))))
;; tear the whole tree down gracefully when done — leaf-first, :shutdown-ms grace period
(rt/close runtime rt/root))
(println "OK")
```
### 3. The full surface — spawn errors, capability checks, and the read-only views
[Section titled “3. The full surface — spawn errors, capability checks, and the read-only views”](#3-the-full-surface--spawn-errors-capability-checks-and-the-read-only-views)
```clojure
(require '[toolnexus.agents.runtime :as rt]
'[koine.json :as json])
(println)
(defn- text-response [t]
{:choices [{:message {:role "assistant" :content t}}]
:usage {:prompt_tokens 10 :completion_tokens 5 :total_tokens 15}})
(def runtime
(rt/create-runtime
{:registry {"boss" {:name "boss" :does "coordinates" :model "bm"}
"w" {:name "w" :does "writes" :model "wm"}}
:llm {:base-url "http://mock.local" :api-key "test-key" :model "default"}
:http-client (fn [_url _headers _body]
{:status 200 :headers {} :body (json/write-str (text-response "done"))})}))
(let [boss (rt/spawn runtime rt/root "boss")
a (rt/spawn runtime boss "w")]
(rt/wake runtime a "go")
;; a spawn past maxDepth/maxChildren, or over a live budget walk, is a verb-error map
(assert (rt/verb-error? (rt/spawn runtime boss "unknown-name"))) ; => true
;; handles are capabilities: only the spawner may wait on what it spawned
(assert (= "done" (:status (rt/wait runtime a {:by boss})))) ; ok — boss spawned a
;; only the spawner may wait — anyone else settles with :status "error"
(assert (= "error" (:status (rt/wait runtime a {:by a}))))
;; read-only observability, no verbs involved
(assert (some #(= a (:id %)) (rt/handles runtime boss))) ; tree-order snapshot under boss
(assert (= "idle" (:state (rt/inspect runtime a)))) ; {:id :state :tokens :inbox :turns :pool-tokens :pending}
(assert (seq (rt/trace runtime)))) ; every transition, in commit order
(println "OK")
```
## See also
[Section titled “See also”](#see-also)
* [`AgentDef`](/api/clojure/runtime/agent/) — The registry entries `spawn` resolves names against.
* [`Handle`](/api/clojure/runtime/handle/) — The state machine one `spawn` call produces, and what `handles`/`inspect` read off it.
* [`Budget`](/api/clojure/runtime/budgets/) — The hierarchical, live-enforced limits `spawn` and `wake` check before admitting work.
* [`task-tool`](/api/clojure/runtime/task-tool/) — The opt-in, model-facing fusion of `spawn` + `wake` + `wait` + `close` built on these same six verbs.
# task-tool
> The opt-in tool that lets the model itself spawn a teammate. Default OFF. — Clojure.
**Clojure (JVM) + cljgo** · package `net.clojars.muthuishere/toolnexus` · [SPEC §7D](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [clojure/src/toolnexus/agents/runtime.cljc:1548-1554](https://github.com/muthuishere/toolnexus/blob/main/clojure/src/toolnexus/agents/runtime.cljc)
```clojure
;; NOT called directly — the runtime builds one per handle, IFF the AgentDef has a :team
(toolnexus.agents.runtime/task-tool rt parent-id def-map)
;; => a native Tool named "task", input {:agent "…" :prompt "…"}, :ctx? true
;; the input schema the model sees
{:type "object"
:properties {:agent {:type "string" :description "Team agent to delegate to"}
:prompt {:type "string" :description "The subtask prompt"}}
:required ["agent" "prompt"]}
;; the sibling: an AgentDef exposed AS a Tool, droppable into ANY toolkit
(toolnexus.agents.runtime/agent-tool rt def-name)
;; => a native Tool named after def-name, input {:prompt "…"}
```
The `task {agent, prompt}` tool is `spawn`→`wake`→`wait`→`close`, fused into one tool call — the opt-in surface that lets the **model itself** delegate to a teammate, rather than only the host calling [`spawn`](/api/clojure/runtime/runtime/) by hand.
## Opt-in, and how it turns on
[Section titled “Opt-in, and how it turns on”](#opt-in-and-how-it-turns-on)
`task-tool` is never something you construct and hand to a toolkit yourself — the runtime’s private `toolkit-for` adds it to a handle’s toolkit automatically, and **only** when that handle’s [`AgentDef`](/api/clojure/runtime/agent/) sets a non-empty `:team` (`clojure/src/toolnexus/agents/runtime.cljc:842-850`):
```clojure
;; no :team ⇒ no task tool at all — this agent CANNOT delegate, full stop
{:name "solo" :does "…" :model "m"}
;; :team present ⇒ toolkit-for adds exactly one "task" tool, advertising only these names
{:name "boss" :does "…" :model "m" :team ["writer" "auditor"]}
```
“Delegation, like recursion, is OPT-IN” — an agent with no `:team` is offered no delegation tool at all, no matter how many other agents exist in the runtime’s registry. This is enforced at the toolkit-construction boundary, not by a runtime-wide flag: two agents in the same runtime can have completely different delegation rights, because the switch lives on each `AgentDef`.
The tool’s model-facing **description** is composed only from the caller’s own `:team`, sorted by name, using each teammate’s `:does` — e.g. `"Delegate a subtask to an isolated subagent … — Available agents — auditor: checks numbers; writer: writes prose"` — never from the whole registry. Calling `task` with an `:agent` outside the team is a tool **error** naming the allowed team, not a silent reach into the registry.
## What one `task` call actually does
[Section titled “What one task call actually does”](#what-one-task-call-actually-does)
* **Fresh transcript, one tool message back.** The child runs with no system prompt and no parent history; the parent’s own transcript gains exactly one `tool` message containing the child’s final text — never the child’s turn-by-turn conversation.
* **Usage rolls up.** The child’s token spend adds to the parent’s own `:tokens` on [`inspect`](/api/clojure/runtime/handle/) — a 15-token child turn inside a 30-token parent turn reports 45 on the parent.
* **The child is closed when it settles**, so a later reattach can still answer from its recorded result — “…→CLOSE fused. The child’s final state stays queryable.”
* **Reattachment by task key, not a cache.** A re-invoked call with the same `{agent, prompt}` key finds the existing child by that key on the parent’s own children, rather than spawning a duplicate: settled ⇒ its recorded result, `"suspended"` ⇒ its pending request (or an inline resume when the retry carries the matching `Answer`), `"running"` ⇒ `wait`s on it. This is what makes an upward §10 resume cascade safe when a model replays the same tool call off a rewound transcript.
* **A suspending child presents to its parent as an ordinary suspending tool** — §10’s shape verbatim, one hop at a time, with the handle path already stamped into the request’s `:data`. No new pending type exists for “this came from a delegated child.”
## `agent-tool` — the same axiom, the other direction
[Section titled “agent-tool — the same axiom, the other direction”](#agent-tool--the-same-axiom-the-other-direction)
`task-tool` lets a model **inside** the runtime spawn a teammate; `agent-tool` is “the axiom’s other direction: an AgentDef AS a Tool, droppable into any toolkit’s `:tools`” (`clojure/src/toolnexus/agents/runtime.cljc:1654-1660`). It wraps `run-agent` (one-shot `spawn`→`wake`→`wait`, closing on settlement, leaving a `"pending"` result open for a later `resume`) as `{:name (sanitized def-name) :description (:does d) :input-schema {:prompt}}`, and returns only the agent’s final text plus `{:agent :turns :total-tokens}`. From the outside, a caller sees an ordinary tool; behind it is a whole agent with its own soul, its own toolkit view, and its own budget — the caller cannot tell the difference. There is no separate manifest entry for `agent-tool` because it is the same primitive as `task-tool`, aimed the other way — use `task-tool` when a whole runtime’s worth of agents delegate to each other by team; reach for `agent-tool` when you just want to drop ONE named agent into some other toolkit as a single callable tool.
## When to use it
[Section titled “When to use it”](#when-to-use-it)
* **You want the model to choose which teammate handles a subtask**, rather than the host hard-coding a `spawn` call for every possible delegation.
* **You want an agent usable as a plain tool from outside the runtime’s own toolkit-building** — `agent-tool` for a single named agent, wherever a `Tool` is accepted.
* **You want reattachment for free** across a §10 suspend/resume cascade, rather than hand-rolling idempotency around repeated tool calls on a replayed transcript.
## Why this and not the alternative
[Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative)
Reach for spawn/wake/wait/close directly when the HOST decides, not the model
`task-tool` exists specifically for model-driven delegation — the LLM decides whom to delegate to and with what prompt, inside its own tool-calling turn. When the host itself decides which sub-agent to run and when (not the model), call [`spawn`](/api/clojure/runtime/runtime/)/`wake`/`wait`/`close` directly, or use `run-agent` for the one-shot case — there is no tool-schema, no team-advertising, and no reattachment-by-task-key machinery to reason about, because none of it is needed when nothing is replaying a tool call off a rewound transcript.
## Examples
[Section titled “Examples”](#examples)
### 1. The smallest useful call — a boss delegating to one teammate
[Section titled “1. The smallest useful call — a boss delegating to one teammate”](#1-the-smallest-useful-call--a-boss-delegating-to-one-teammate)
```clojure
(require '[toolnexus.agents.runtime :as rt]
'[koine.json :as json])
(println)
(defn- calls-response [calls]
{:choices [{:message {:role "assistant" :content nil
:tool_calls (mapv (fn [c] {:id (:id c) :type "function"
:function {:name (:name c)
:arguments (json/write-str (or (:args c) {}))}})
calls)}}]
:usage {:prompt_tokens 10 :completion_tokens 5 :total_tokens 15}})
(defn- text-response [t]
{:choices [{:message {:role "assistant" :content t}}]
:usage {:prompt_tokens 10 :completion_tokens 5 :total_tokens 15}})
;; scripted per MODEL: boss (bm) delegates once, then reports done;
;; writer (wm) just answers
(def script {"bm" [{:calls [{:id "c1" :name "task" :args {:agent "writer" :prompt "the intro"}}]}
{:text "shipped"}]
"wm" [{:text "intro written"}]})
(defn- mock-http []
(let [counts (atom {})]
(fn [_url _headers body]
(let [b (json/read-str body)
model (:model b)
n (get (swap! counts update model (fnil inc 0)) model)
spec (get (get script model) (dec n))]
{:status 200 :headers {}
:body (json/write-str (if (seq (:calls spec)) (calls-response (:calls spec)) (text-response (:text spec))))}))))
(def runtime
(rt/create-runtime
{:registry {"boss" {:name "boss" :does "coordinates" :model "bm"
:team ["writer"] :budget {:max-depth 5}}
"writer" {:name "writer" :does "writes" :model "wm"}}
:llm {:base-url "http://mock.local" :api-key "test-key" :model "default"}
:http-client (mock-http)}))
;; the model, inside boss's own turn, calls: task {:agent "writer" :prompt "the intro"}
;; — the runtime does NOT expose that call to you directly; it happens through the
;; client loop when boss's toolkit includes the task tool `toolkit-for` added for it.
(let [h (rt/spawn runtime rt/root "boss")]
(rt/wake runtime h "write me a post")
(let [r (rt/wait runtime h)]
(assert (= "shipped" (:text r)))) ; => whatever boss's final turn says
(assert (= "closed" (:state (rt/inspect runtime (str h "/writer.1"))))))
(println "OK")
```
The child appears as `"root/boss.1/writer.1"`, is `"closed"` once its result settles, and its token spend is already rolled into `boss`’s own totals by the time `wait` returns.
### 2. The realistic case — an out-of-team target is refused, not a silent reach
[Section titled “2. The realistic case — an out-of-team target is refused, not a silent reach”](#2-the-realistic-case--an-out-of-team-target-is-refused-not-a-silent-reach)
```clojure
(require '[toolnexus.agents.runtime :as rt]
'[koine.json :as json])
(println)
(defn- calls-response [calls]
{:choices [{:message {:role "assistant" :content nil
:tool_calls (mapv (fn [c] {:id (:id c) :type "function"
:function {:name (:name c)
:arguments (json/write-str (or (:args c) {}))}})
calls)}}]
:usage {:prompt_tokens 10 :completion_tokens 5 :total_tokens 15}})
(defn- text-response [t]
{:choices [{:message {:role "assistant" :content t}}]
:usage {:prompt_tokens 10 :completion_tokens 5 :total_tokens 15}})
(def script {"bm" [{:calls [{:id "c1" :name "task" :args {:agent "other" :prompt "sneak"}}]}
{:text "gave up"}]})
(defn- mock-http []
(let [counts (atom {})]
(fn [_url _headers body]
(let [b (json/read-str body)
model (:model b)
n (get (swap! counts update model (fnil inc 0)) model)
spec (get (get script model) (dec n))]
{:status 200 :headers {}
:body (json/write-str (if (seq (:calls spec)) (calls-response (:calls spec)) (text-response (:text spec))))}))))
;; boss's :team is ["writer"] only — "other" is registered but not on the team
(def runtime
(rt/create-runtime
{:registry {"boss" {:name "boss" :does "coordinates" :model "bm"
:team ["writer"] :budget {:max-depth 5}}
"writer" {:name "writer" :does "writes" :model "wm"}
"other" {:name "other" :does "?" :model "om"}}
:llm {:base-url "http://mock.local" :api-key "test-key" :model "default"}
:http-client (mock-http)}))
(let [h (rt/spawn runtime rt/root "boss")]
(rt/wake runtime h "go") ; the model's turn calls task {:agent "other" :prompt "sneak"}
(let [r (rt/wait runtime h)]
(assert (= "gave up" (:text r))))
;; nothing was spawned for "other" at all — the refusal is a plain tool error
;; the model sees inline, listing the team it actually has, not a crash
(assert (= [h] (mapv :id (rt/handles runtime))))) ; => only ["root/boss.1"], no "other" handle
(println "OK")
```
### 3. The full surface — a suspending delegation, and `agent-tool` used standalone
[Section titled “3. The full surface — a suspending delegation, and agent-tool used standalone”](#3-the-full-surface--a-suspending-delegation-and-agent-tool-used-standalone)
```clojure
(require '[toolnexus.agents.runtime :as rt]
'[toolnexus.client :as client]
'[toolnexus.tool :as tool]
'[koine.json :as json])
(println)
(defn- calls-response [calls]
{:choices [{:message {:role "assistant" :content nil
:tool_calls (mapv (fn [c] {:id (:id c) :type "function"
:function {:name (:name c)
:arguments (json/write-str (or (:args c) {}))}})
calls)}}]
:usage {:prompt_tokens 10 :completion_tokens 5 :total_tokens 15}})
(defn- text-response [t]
{:choices [{:message {:role "assistant" :content t}}]
:usage {:prompt_tokens 10 :completion_tokens 5 :total_tokens 15}})
(def script {"bm" [{:calls [{:id "c1" :name "task" :args {:agent "worker" :prompt "risky"}}]}]
"wm" [{:calls [{:id "w1" :name "approve" :args {}}]}]})
(defn- mock-http []
(let [counts (atom {})]
(fn [_url _headers body]
(let [b (json/read-str body)
model (:model b)
n (get (swap! counts update model (fnil inc 0)) model)
spec (get (get script model) (dec n))]
{:status 200 :headers {}
:body (json/write-str (if (seq (:calls spec)) (calls-response (:calls spec)) (text-response "done")))}))))
(def approve-tool
(tool/tool {:name "approve" :description "needs approval"
:execute (fn ([_args] (client/suspend (client/make-request "approval" "Approve the impossible")))
([_args ctx] (tool/success "approved")))}))
(def runtime
(rt/create-runtime
{:registry {"boss" {:name "boss" :does "coordinates" :model "bm"
:team ["worker"] :budget {:max-depth 5}}
"worker" {:name "worker" :does "does the risky part" :model "wm" :tools [approve-tool]}}
:llm {:base-url "http://mock.local" :api-key "test-key" :model "default"}
:http-client (mock-http)}))
;; a delegated child whose own tool call suspends on a §10 approval request
;; presents to boss (and boss's own caller) as an ordinary pending tool result:
(let [h (rt/spawn runtime rt/root "boss")]
(rt/wake runtime h "go")
(let [r (rt/wait runtime h)]
(assert (= "pending" (:status r))) ; not an error; both boss and the child are "suspended"
;; the host answers with the matching Answer id, and the cascade resumes both levels
(rt/resume runtime (client/make-answer (:id (:pending r)) true))))
;; agent-tool: drop ONE named agent into a toolkit as a plain callable, outside any team
(def worker-as-tool (rt/agent-tool runtime "worker"))
(def toolkit (tool/toolkit [worker-as-tool]))
;; a caller of `toolkit` sees a tool named "worker" with {:prompt "…"} — nothing about
;; teams, task keys, or reattachment leaks through; it is run-agent under the hood
(assert (= ["worker"] (tool/tool-names toolkit)))
(println "OK")
```
## `as-tool-with` — a child agent under the parent’s gates (0.22.0)
[Section titled “as-tool-with — a child agent under the parent’s gates (0.22.0)”](#as-tool-with--a-child-agent-under-the-parents-gates-0220)
`agents.runtime/as-tool` now closes the child run when the calling turn is cancelled (the ctx’s `:cancel` token) and returns a `cancelled: …` error result. `as-tool-with` builds the runtime options **per call** from the calling tool’s context, so the child gets the same `:hooks` (permission gate — it sees the child’s tool calls) and `:wait-for` (question bridge) as the parent turn. Runtime `:wait-for` answers a child’s suspension when no agent on its chain has its own. A def’s own `:hooks` replaces the runtime’s, so leave it unset when the gate must hold.
```clojure
(rt/as-tool-with writer-def
(fn [tool-ctx]
{:llm llm-opts
:hooks {:before-tool permission-gate}
:wait-for ask-the-user}))
```
The child’s text is not streamed token by token; the tool returns its final text plus `{:agent :turns :total-tokens}`.
## See also
[Section titled “See also”](#see-also)
* [`AgentDef`](/api/clojure/runtime/agent/) — `:team` is the only switch that turns `task-tool` on for a given agent.
* [`create-runtime`](/api/clojure/runtime/runtime/) — The six verbs `task-tool` fuses (`spawn`/`wake`/`wait`/`close`) and the runtime it is built against.
* [`Handle`](/api/clojure/runtime/handle/) — What a delegated child looks like via `inspect` while it runs, suspends, or after `task-tool` closes it.
# agents.runtime/statuses / agents.runtime/limits
> The canonical, byte-identical-across-ports string constants a host branches on: task statuses, run statuses, and the nine stop-limit reasons a task can end on, plus the helper that canonicalizes a limit name. — Clojure.
**Clojure (JVM) + cljgo** · package `net.clojars.muthuishere/toolnexus` · [SPEC §7D](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [clojure/src/toolnexus/agents/runtime.cljc](https://github.com/muthuishere/toolnexus/blob/main/clojure/src/toolnexus/agents/runtime.cljc)
```clojure
;; the §7D TaskResult status vocabulary — SEVEN values
toolnexus.agents.runtime/statuses
;; => #{"done" "pending" "incomplete" "interrupted" "closed" "timeout" "error"}
;; the §7D TaskResult.limit vocabulary — NINE values, WHICH limit stopped the task
toolnexus.agents.runtime/limits
;; => #{"maxTurns" "maxTokens" "maxToolCalls" "maxWallMs"
;; "maxChildren" "maxConcurrent" "maxDepth" "completion" "timeout"}
;; the named values, so a caller never types the string literal
toolnexus.agents.runtime/limit-max-turns ; "maxTurns"
toolnexus.agents.runtime/limit-max-tokens ; "maxTokens"
toolnexus.agents.runtime/limit-max-tool-calls ; "maxToolCalls"
toolnexus.agents.runtime/limit-max-wall-ms ; "maxWallMs"
toolnexus.agents.runtime/limit-max-children ; "maxChildren"
toolnexus.agents.runtime/limit-max-concurrent ; "maxConcurrent"
toolnexus.agents.runtime/limit-max-depth ; "maxDepth"
toolnexus.agents.runtime/limit-completion ; "completion"
toolnexus.agents.runtime/limit-timeout ; "timeout"
;; a SEPARATE, smaller §8 client-level vocabulary — do not confuse the two
toolnexus.client/statuses ; => #{"done" "pending" "incomplete"} — never "timeout"
toolnexus.client/limits ; => #{"maxTurns" "contentPart" "timeout"}
```
The canonical, byte-identical-across-ports string constants a host branches on: task statuses, run statuses, and the nine stop-limit reasons a task can end on. These are the closed vocabularies a [`toolnexus.agents.runtime`](/api/clojure/runtime/runtime/) `TaskResult` — the value `spawn`, `wait` and `close` hand back — reports on its `:status` and (when the status is a limit stop) its `:limit` fields.
Two vocabularies, both spelled status — do not conflate them
`toolnexus.agents.runtime/statuses` is the **task/agent** vocabulary (§7D) — seven values, including `"timeout"` for an expired `wait` (the child keeps running) and `"error"` for a failed turn. `toolnexus.client/statuses` is the **separate, smaller §8 client/run** vocabulary — three values, `"done"`/`"pending"`/`"incomplete"`, and it **never** contains `"timeout"`: a §8 whole-run deadline throws a typed error instead of returning a status. Two different closed sets on two fields both spelled `status` was the collision behind issue #92.1 — the fix was to name both explicitly rather than rename either (D5), so a host reading a value learns from the field’s *owner* which vocabulary it is holding.
The limit sets have the same split: `agents.runtime/limits` is the full nine-value §7D set; `client/limits` is the client’s own three-value set (`maxTurns`, `contentPart`, `timeout`) — it adds `contentPart` for a §8A oversized-content-part stop, a case that only exists at the client layer and has no §7D equivalent.
## When to use it
[Section titled “When to use it”](#when-to-use-it)
* **Branching on a `TaskResult`’s `:status`** returned by `spawn`/`wait`/`interrupt`/`close` on [`toolnexus.agents.runtime`](/api/clojure/runtime/runtime/) — check membership in `statuses` rather than hardcoding the string, so a host fails loudly if it ever meets an eighth value instead of silently falling through a `case`. When the status is `"incomplete"` or `"timeout"`, the same result carries a `:limit` naming *which* budget stopped it — check that against `limits`.
* **Building a dashboard or a log line** that needs to render every possible outcome a spawned agent can reach, exhaustively, without guessing at undocumented strings.
* **Writing a completion/guardrail check** in [`toolnexus.agents.loop`](/api/clojure/runtime/loop/) that inspects `:limit` to decide whether a stop is retryable (e.g. `"maxTurns"` might be worth bumping and re-running; `"maxDepth"` is a structural ceiling, not a transient one).
## Why this and not the alternative
[Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative)
Reach for toolnexus.client/statuses at the §8 client layer instead
If you are inspecting the return of a plain [`client/run`](/api/clojure/client/run/) — not a runtime-spawned task — you want `toolnexus.client/statuses` (three values, no `"timeout"`), not this namespace’s seven-value set. Importing the wrong one and checking `(contains? statuses "timeout")` against a §8 `RunResult` will always be false, because a §8 whole-run deadline is a thrown `ex-info`, never a returned status.
The vocabularies are plain `#{}` sets and named `def`s rather than an enum type or a keyword namespace, matching this port’s general style of carrying closed vocabularies as data (`clojure/src/toolnexus/agents/runtime.cljc:142`, `:174`) — a host checks membership with `contains?` the same way it would check `.contains()` on any other port’s array/list constant, with no additional API to learn.
## Examples
[Section titled “Examples”](#examples)
### 1. The smallest useful call — check a spawned task’s outcome
[Section titled “1. The smallest useful call — check a spawned task’s outcome”](#1-the-smallest-useful-call--check-a-spawned-tasks-outcome)
```clojure
(require '[toolnexus.agents.runtime :as rt]
'[koine.json :as json])
(println)
(defn- text-response [t]
{:choices [{:message {:role "assistant" :content t}}]
:usage {:prompt_tokens 10 :completion_tokens 5 :total_tokens 15}})
(def runtime
(rt/create-runtime
{:registry {"w" {:name "w" :does "writes" :model "wm"}}
:llm {:base-url "http://mock.local" :api-key "test-key" :model "default"}
:http-client (fn [_url _headers _body]
{:status 200 :headers {} :body (json/write-str (text-response "done writing"))})}))
(def h (rt/spawn runtime rt/root "w"))
(rt/wake runtime h "go")
(let [r (rt/wait runtime h)]
(assert (contains? rt/statuses (:status r)) "unrecognised task status")
(case (:status r)
"done" (println (:text r))
"pending" (println "parked, waiting on" (:pending r))
"incomplete" (println "stopped on limit:" (:limit r))
(println "ended:" (:status r))))
(println "OK")
```
### 2. The realistic case — react to WHICH limit stopped the task
[Section titled “2. The realistic case — react to WHICH limit stopped the task”](#2-the-realistic-case--react-to-which-limit-stopped-the-task)
```clojure
(require '[toolnexus.agents.runtime :as rt]
'[koine.json :as json])
(println)
(defn- text-response [t]
{:choices [{:message {:role "assistant" :content t}}]
:usage {:prompt_tokens 10 :completion_tokens 5 :total_tokens 15}})
(defn handle-stop [r]
(when (= "incomplete" (:status r))
(assert (contains? rt/limits (:limit r)) "unrecognised limit reason")
(cond
(= rt/limit-max-turns (:limit r))
(println "hit the turn budget — consider raising it and re-running")
(= rt/limit-max-depth (:limit r))
(println "hit the delegation depth ceiling — this is structural, don't retry blindly")
(= rt/limit-max-wall-ms (:limit r))
(println "ran out of wall-clock time")
:else
(println "stopped on" (:limit r)))))
(def runtime
(rt/create-runtime
{:registry {"w" {:name "w" :does "writes" :model "wm" :budget {:max-turns 1}}}
:llm {:base-url "http://mock.local" :api-key "test-key" :model "default"}
:http-client (fn [_url _headers _body]
{:status 200 :headers {} :body (json/write-str (text-response "ok"))})}))
(def h (rt/spawn runtime rt/root "w"))
(rt/wake runtime h "first")
(rt/wait runtime h)
(rt/wake runtime h "second") ; exceeds the one-turn budget
(def r (rt/wait runtime h))
(assert (= "incomplete" (:status r)))
(assert (= rt/limit-max-turns (:limit r)))
(handle-stop r)
(println "OK")
```
### 3. The full surface — distinguishing the two vocabularies side by side
[Section titled “3. The full surface — distinguishing the two vocabularies side by side”](#3-the-full-surface--distinguishing-the-two-vocabularies-side-by-side)
```clojure
(require '[toolnexus.agents.runtime :as rt]
'[toolnexus.client :as client])
(println)
;; the §7D task-level set (seven statuses, nine limits)
(assert (= #{"done" "pending" "incomplete" "interrupted" "closed" "timeout" "error"}
rt/statuses))
(assert (= #{"maxTurns" "maxTokens" "maxToolCalls" "maxWallMs"
"maxChildren" "maxConcurrent" "maxDepth" "completion" "timeout"}
rt/limits))
;; the §8 client-level set — smaller, and NEVER contains "timeout" as a status
(assert (= #{"done" "pending" "incomplete"} client/statuses))
(assert (= #{"maxTurns" "contentPart" "timeout"} client/limits))
(assert (not (contains? client/statuses "timeout")))
;; a host that gets this wrong learns immediately rather than silently
;; mis-branching: membership checks against the WRONG set simply never match
(assert (not (contains? client/limits "maxDepth"))
"maxDepth is a task-level admission limit; the client run never reports it")
(println "OK")
```
ACCEPTED ASYMMETRY: maxChildren / maxConcurrent / maxDepth (A17)
Those three limit strings are spawn/admission refusals — a host that hits them sees a **throw** from `spawn`, not a settled `TaskResult` carrying `:limit`. The vocabulary still pins their spelling for wherever a port does report such a stop; this batch deliberately does not invent a settle path just to make the strings appear in a returned value.
## See also
[Section titled “See also”](#see-also)
* [`toolnexus.agents.loop`](/api/clojure/runtime/loop/) — `run-gated` drives a run under a completion check and reads these same status/limit strings to decide whether the gate has passed.
* [`toolnexus.agents.runtime`](/api/clojure/runtime/runtime/) — The host verbs (`spawn`, `wait`, `interrupt`, `close`) whose `TaskResult` carries `:status` and, on a limit stop, `:limit`.
# toolnexus.serve/serve
> Publish an Agent Card and answer JSON-RPC over the client loop — your toolkit becomes someone else's remote agent. — Clojure.
**Clojure (JVM) + cljgo** · package `net.clojars.muthuishere/toolnexus` · [SPEC §7B](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [clojure/src/toolnexus/serve.cljc](https://github.com/muthuishere/toolnexus/blob/main/clojure/src/toolnexus/serve.cljc)
```clojure
(toolnexus.serve/serve toolkit
{:port 0 ; 0 ⇒ an OS-assigned port
:host "127.0.0.1"
:a2a {:name "tn-agent" ...} ; §7B profile — omit and the A2A routes do not exist
:mcp {:name "tn-mcp" ...} ; §7C profile — omit and /mcp does not exist
:skills skill-source ; what the Agent Card advertises
:client client ; or :run (fn [text] {:text "..."})
:store "file:./tasks" ; §7B TaskStore, default in-memory
:on-task (fn [t] ...) ; fires on a terminal task state
:on-call (fn [c] ...)}) ; fires per inbound tools/call
;; => {:url "http://127.0.0.1:54321" :port 54321
;; :store :handle :stop! (fn [])}
(toolnexus.serve/stop! handle)
```
`serve` is the inbound half of toolnexus: one HTTP server carrying two independent, opt-in profiles. The §7B `a2a` profile mounts `GET /.well-known/agent-card.json` and a JSON-RPC endpoint at `POST /` speaking `SendMessage` and `GetTask`. The §7C `mcp` profile mounts `POST /mcp` speaking `initialize`, `tools/list` and `tools/call`. Both, either, or neither — routing is per profile.
**Absence is a behaviour.** Omit `:a2a` and the card path and `/` return 404 like any other unknown path; omit `:mcp` and `/mcp` does. Serving with neither profile gives you a server that 404s everything, which is the only reading under which §7B’s “an absent `a2a` means every request 404s” and §7C’s “an MCP-only serve 404s all other paths” are both true.
A `SendMessage` returns immediately with the task in `submitted` state and fulfils it on a background thread: save `working`, run the fulfilment, then save `completed` with the answer as a text artifact — or `failed` carrying `status.message` and no artifacts if the fulfilment threw. Peers see progress by polling `GetTask`. Nothing a handler does can take the server down: a malformed body becomes JSON-RPC `-32700`, an escaped exception becomes `-32603`, and a fulfilment that throws leaves a `failed` task rather than a thread that dies holding the task in `working` forever.
Fulfilment comes from either `:client` (a client map — `serve` calls its `:run` with `{:toolkit tk}`) or `:run` (a bare function of the task text returning a map with `:text`). Configure neither and every task fails loudly rather than hanging.
## When to use it
[Section titled “When to use it”](#when-to-use-it)
* **Making a toolkit reachable across processes or languages.** Another port’s [`remote-agent`](/api/clojure/agents/agent/) points at your card and your skills become its tools.
* **Exposing tools to an MCP client** — an editor, a desktop assistant, anything speaking streamable-HTTP MCP — without writing an MCP server.
* **Both at once, from one process.** The two profiles share a port and a toolkit but nothing else.
## Why this and not the alternative
[Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative)
Serving is not delegating in-process
This port has no §7D sub-agent runtime: there is no way to spawn a child agent inside your process and call it as a tool. `serve` plus [`remote-agent`](/api/clojure/agents/agent/) is the delegation it does ship, and it is cross-process by construction — a real HTTP hop, a real task id, a real store. That costs you a port and a serialisation boundary, and buys restartability and language independence. If you want an in-process child, that capability is recorded parity debt in `clojure/README.md`.
For a plain HTTP endpoint that is not an agent, don’t use `serve` — write a handler over `koine.server` and call `toolnexus.tool/execute` yourself.
## Examples
[Section titled “Examples”](#examples)
### Serve a toolkit as an A2A agent
[Section titled “Serve a toolkit as an A2A agent”](#serve-a-toolkit-as-an-a2a-agent)
```clojure
(require '[toolnexus.core :as core]
'[toolnexus.serve :as serve])
(def tk (core/build {:skills "examples/skills"}))
(def h
(serve/serve tk {:port 0
:a2a {:name "tn-agent" :description "the docs example agent"}
:skills (:skills tk)
:run (fn [text] {:text (str "ran: " text)})}))
(println (:url h)) ;; => http://127.0.0.1:54321
;; GET /.well-known/agent-card.json -> the Agent Card
;; POST / -> SendMessage / GetTask
(serve/stop! h)
```
`:run` is the shortcut for a fulfilment that is not an LLM loop. Pass `:client` instead — a map from [`toolnexus.client/create-client`](/api/clojure/client/create/) — and each task runs the real agent loop with this toolkit attached.
### Both profiles, with durable tasks and observability
[Section titled “Both profiles, with durable tasks and observability”](#both-profiles-with-durable-tasks-and-observability)
```clojure
(require '[toolnexus.core :as core]
'[toolnexus.client :as client]
'[toolnexus.serve :as serve])
(def tk (core/build {:mcp "examples/mcp.json" :skills "examples/skills"}))
(def c (client/create-client {:base-url "https://api.anthropic.com"
:style "anthropic"
:model "claude-sonnet-4-6"}))
(def h
(serve/serve tk
{:port 8080
:a2a {:name "tn-agent" :version "1.0.0"
:provider {:organization "toolnexus"}
:skills ["hello-world"]}
:mcp {:name "tn-mcp" :version "1.0.0" :tools ["skill"]}
:skills (:skills tk)
:client c
:store "file:./tasks"
:on-task (fn [t] (println "task" (:id t) (:state t)))
:on-call (fn [c] (println "tools/call" (:name c) "isError" (:isError c)))}))
```
`:store "file:./tasks"` writes one `.json` per task, so `GetTask` still answers after a restart. See [`toolnexus.serve/file-store`](/api/clojure/serve/task-store/).
### The task lifecycle, from a peer’s side
[Section titled “The task lifecycle, from a peer’s side”](#the-task-lifecycle-from-a-peers-side)
```clojure
(require '[koine.http :as http]
'[koine.json :as json])
(defn rpc! [url method params]
(-> (http/post-json url {} (json/write-str {:jsonrpc "2.0" :id 1
:method method :params params}))
:body json/read-str))
;; 1. submit — returns immediately, state "submitted"
(def task (:result (rpc! (str base "/") "SendMessage"
{:message {:role "user" :messageId "m1"
:parts [{:kind "text" :text "summarise the changelog"}]}
:configuration {:blocking false}})))
;; 2. poll until terminal: completed | failed | canceled
(:result (rpc! (str base "/") "GetTask" {:id (:id task)}))
;; completed => :artifacts [{:parts [{:kind "text" :text "..."}]}]
;; failed => :status {:state "failed" :message {:parts [{:kind "text" :text "..."}]}}
;; unknown id => JSON-RPC error -32001 "Task not found"
;; unknown verb=> -32601 "Method not found"
;; bad body => -32700 "Parse error"
```
## Options
[Section titled “Options”](#options)
| Key | Default | Meaning |
| ---------- | --------------- | ------------------------------------------------------------------------------------------------------------- |
| `:port` | `0` | `0` asks the OS for a free port; read the real one back from `:port` on the handle. |
| `:host` | koine’s default | Bind address. |
| `:a2a` | absent | §7B profile map — see [`agent-card`](/api/clojure/serve/agent-card/) for its keys. Absent ⇒ no card, no `/`. |
| `:mcp` | absent | §7C profile map (`:name`, `:version`, `:protocolVersion`, `:tools`). Absent ⇒ no `/mcp`. |
| `:skills` | none | The SkillSource the Agent Card advertises. Accepts a `list-skills` result, a name→info index, or a plain seq. |
| `:client` | none | A client map; each task runs `((:run client) text {:toolkit tk})`. |
| `:run` | none | A bare fulfilment `(fn [text] {:text "..."})`. Takes precedence over `:client`. |
| `:store` | `"memory"` | `nil` / `"memory"`, `"file:"`, or a store map. See [`resolve-store`](/api/clojure/serve/task-store/). |
| `:on-task` | none | Called with `{:id :task :state :result}` when a task settles. A throw inside it is swallowed. |
| `:on-call` | none | Called with `{:name :source :isError}` per inbound `tools/call`. A throw inside it is swallowed. |
### The handle
[Section titled “The handle”](#the-handle)
| Key | What it is |
| --------- | -------------------------------------------------------------------------------------------- |
| `:url` | `http://127.0.0.1:` — also the base the Agent Card’s `url` is built from. |
| `:port` | The bound port, resolved even when you asked for `0`. |
| `:store` | The resolved TaskStore, so tests and admin code can read tasks directly. |
| `:handle` | The underlying `koine.server` handle. |
| `:stop!` | A zero-arg shutdown fn. Equivalent to `(toolnexus.serve/stop! handle)`; both are idempotent. |
### Routes
[Section titled “Routes”](#routes)
| Method and path | Mounted when | Answers |
| ---------------------------------- | -------------- | ----------------------------------------- |
| `GET /.well-known/agent-card.json` | `:a2a` present | The Agent Card. |
| `POST /` | `:a2a` present | `SendMessage`, `GetTask`. |
| `POST /mcp` | `:mcp` present | `initialize`, `tools/list`, `tools/call`. |
| anything else | — | `404 Not Found`, `text/plain`. |
## See also
[Section titled “See also”](#see-also)
* [`toolnexus.serve/agent-card`](/api/clojure/serve/agent-card/) — the card this serves, buildable standalone
* [`toolnexus.serve/exposed-tools`](/api/clojure/serve/mcp/) — which tools the §7C profile shows *and* allows
* [`toolnexus.serve/file-store`](/api/clojure/serve/task-store/) — tasks that survive a restart
* [`toolnexus.a2a/remote-agent`](/api/clojure/agents/agent/) — the client that consumes what this serves
# toolnexus.serve/agent-card
> Construct the Agent Card that advertises your name, skills and endpoint. — Clojure.
**Clojure (JVM) + cljgo** · package `net.clojars.muthuishere/toolnexus` · [SPEC §7B](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [clojure/src/toolnexus/serve.cljc](https://github.com/muthuishere/toolnexus/blob/main/clojure/src/toolnexus/serve.cljc)
```clojure
(toolnexus.serve/agent-card cfg base skills)
;; cfg the :a2a profile map — :name :description :version :protocolVersion
;; :provider :skills (an allowlist of skill NAMES)
;; base the server's base URL; the card's `url` is (str base "/")
;; skills a SkillSource: a list-skills result, a name->info index, or a seq
;; => {:name "tn-agent" :description "" :version "0.1.0" :protocolVersion "0.3.0"
;; :capabilities {:streaming false :pushNotifications false}
;; :defaultInputModes ["text"] :defaultOutputModes ["text"]
;; :skills [{:id "hello_world" :name "hello-world" :description "..."}]
;; :url "http://127.0.0.1:8080/"}
```
`agent-card` builds the JSON document a peer fetches from `/.well-known/agent-card.json` to discover what you can do. [`serve`](/api/clojure/serve/a2a/) calls it on every card request, so the card always reflects the live skill source; calling it yourself is for tests, static publication and admin views.
The advertised skills come from the toolkit’s **SkillSource** — never from its tools. That is deliberate: a peer asks an A2A agent for capabilities, not for a function catalog, and exposing every `bash` and `grep` on the card would be both noisy and wrong. Pass `:skills` in the profile to narrow it further; unknown names in that allowlist are ignored, matching §7C’s explicit rule for `mcp.tools`.
Two naming rules sit side by side here. A skill’s `id` is **sanitized** — that is the token peers concatenate into a tool name, so it must be safe. Its `name` is passed through untouched, because that is the human-readable label. Skills are emitted sorted by name so two runtimes cannot serve different cards from the same configuration.
`:provider` is present only when configured — absent, not `null`. Everything else has a spec default, which is why `(agent-card {} base skills)` already produces a valid card.
## When to use it
[Section titled “When to use it”](#when-to-use-it)
* **Asserting on the card in a test** without standing up a server or making an HTTP request.
* **Publishing a static card** — writing `/.well-known/agent-card.json` to a CDN or behind a gateway that terminates in front of your process.
* **An admin or registry view** — rendering “here is what this agent advertises” from the same function the wire uses, so the page cannot drift from reality.
## Why this and not the alternative
[Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative)
Serve builds it for you
If you are calling [`toolnexus.serve/serve`](/api/clojure/serve/a2a/) with an `:a2a` profile, the card is already mounted and already rebuilt per request — you do not need this function at all. Call it directly only when you want the card as *data*: in a test, in a static file, or in a page.
Note the `base` argument is yours to supply. `serve` passes its own resolved `http://127.0.0.1:`, which is right for local peers and wrong the moment a reverse proxy or a public hostname is in front — in that case build the card yourself with the externally reachable base.
## Examples
[Section titled “Examples”](#examples)
### The card with every default
[Section titled “The card with every default”](#the-card-with-every-default)
```clojure
(require '[toolnexus.serve :as serve])
(serve/agent-card {} "http://127.0.0.1:8080"
[{:name "hello-world" :description "Greets the world."}])
;; => {:name "toolnexus-agent"
;; :description ""
;; :version "0.1.0"
;; :protocolVersion "0.3.0"
;; :capabilities {:streaming false :pushNotifications false}
;; :defaultInputModes ["text"] :defaultOutputModes ["text"]
;; :skills [{:id "hello_world" :name "hello-world" :description "Greets the world."}]
;; :url "http://127.0.0.1:8080/"}
```
Note `:capabilities` — this port serves neither streaming nor push notifications, and says so truthfully rather than advertising a capability it would then fail to honour.
### Advertise a subset, from a real skill source
[Section titled “Advertise a subset, from a real skill source”](#advertise-a-subset-from-a-real-skill-source)
```clojure
(require '[toolnexus.skill :as skill]
'[toolnexus.serve :as serve])
(def skills (skill/list-skills "examples/skills"))
(serve/agent-card {:name "release-bot"
:description "Cuts releases"
:version "1.2.0"
:provider {:organization "toolnexus"
:url "https://example.invalid"}
;; only these names are advertised; unknown names are ignored
:skills ["hello-world" "no-such-skill"]}
"https://agents.example.com/release-bot"
skills)
```
`agent-card` accepts the `{:skills [...] :skipped [...]}` shape `list-skills` returns, a name→info index, or a plain sequence of maps with `:name` and `:description` — so a data-defined skill list works without touching disk.
### Publish a static card behind a proxy
[Section titled “Publish a static card behind a proxy”](#publish-a-static-card-behind-a-proxy)
```clojure
(require '[koine.fs :as fs]
'[koine.json :as json]
'[toolnexus.serve :as serve])
(fs/write-file "public/.well-known/agent-card.json"
(json/write-str
(serve/agent-card {:name "release-bot" :version "1.2.0"}
"https://agents.example.com" ;; the PUBLIC base
skills)))
```
The card’s `url` becomes `https://agents.example.com/`, which is where peers will POST — so it has to be the address that reaches your `serve` process, not the loopback one it bound to.
## Options (the `:a2a` profile map)
[Section titled “Options (the :a2a profile map)”](#options-the-a2a-profile-map)
| Key | Default | Meaning |
| ------------------ | ------------------- | ---------------------------------------------------------------------------------------- |
| `:name` | `"toolnexus-agent"` | The agent name. Peers sanitize it into the prefix of every tool name they derive. |
| `:description` | `""` | Free text. |
| `:version` | `"0.1.0"` | Your agent’s version. |
| `:protocolVersion` | `"0.3.0"` | The A2A protocol version advertised. |
| `:provider` | absent | Any map, e.g. `{:organization ... :url ...}`. Omitted from the card entirely when unset. |
| `:skills` | all | Allowlist of skill **names**. Unknown names are ignored, not errors. |
### Card fields
[Section titled “Card fields”](#card-fields)
| Field | Source |
| -------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
| `:capabilities` | Always `{:streaming false :pushNotifications false}` — this port implements neither. |
| `:defaultInputModes` / `:defaultOutputModes` | Always `["text"]`. |
| `:skills` | From the SkillSource, filtered, sorted by name. `:id` is sanitized; `:name` is verbatim; `:description` defaults to `""`. |
| `:url` | `(str base "/")` — the JSON-RPC POST endpoint. |
## See also
[Section titled “See also”](#see-also)
* [`toolnexus.serve/serve`](/api/clojure/serve/a2a/) — mounts this card at `/.well-known/agent-card.json`
* [`toolnexus.skill/list-skills`](/api/clojure/skills/list/) — the inventory this card advertises
* [`toolnexus.a2a/remote-agent`](/api/clojure/agents/agent/) — the peer that reads it
# toolnexus.serve/exposed-tools
> The inbound MCP profile: any MCP client can call your tools. — Clojure.
**Clojure (JVM) + cljgo** · package `net.clojars.muthuishere/toolnexus` · [SPEC §7C](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [clojure/src/toolnexus/serve.cljc](https://github.com/muthuishere/toolnexus/blob/main/clojure/src/toolnexus/serve.cljc)
```clojure
(toolnexus.serve/exposed-tools toolkit mcp-cfg)
;; mcp-cfg the :mcp profile map:
;; :tools allowlist of tool NAMES; omitted ⇒ every toolkit tool
;; :name :version serverInfo, defaults "toolnexus" / "0.1.0"
;; :protocolVersion default "2024-11-05"
;; => (Tool ...) sorted by :name
```
`exposed-tools` answers one question: which of a toolkit’s tools does the §7C inbound MCP profile show to a connecting client. Given `:tools` in the profile it keeps exactly those names — unknown names in the list are ignored, which §7C states explicitly — and given no `:tools` it keeps everything. The result is sorted by name so two runtimes serving the same toolkit cannot disagree about order.
It is also **authoritative for calls**, not just for listing. §7C’s text says only that `mcp.tools` filters the list; ported literally, a tool hidden from `tools/list` would still be callable through `tools/call`, turning what every reader takes for an allowlist into a cosmetic filter — on the one surface whose entire job is exposing a toolkit to strangers. `tools/call` therefore resolves out of this same filtered set, and an excluded tool answers `-32602 Unknown tool: ` exactly as a nonexistent one does. That is a deliberate, security-motivated deviation, reported upward rather than left for six ports to each guess at.
Tool names go out **verbatim**. They were already sanitized at registration, and re-sanitizing at a gateway double-mangles names on every hop — so a toolkit holding `calc.sum` serves `calc.sum`, and the string `calc_sum` appears nowhere. This is the opposite of the §7A/§7B rule, where skill ids *are* sanitized, and the asymmetry is intentional.
On the wire, a call’s result becomes one `{:type "text"}` content part carrying the tool’s output, with `isError` propagated. A tool that throws is caught by `toolnexus.tool/execute` and becomes an error result — never an exception on the server thread.
## When to use it
[Section titled “When to use it”](#when-to-use-it)
* **Verifying an allowlist in a test** — assert the exact set a profile exposes without opening a socket or speaking JSON-RPC.
* **Rendering what a client will see** — a startup log line or an admin page listing the served catalog.
* **Deciding what to expose** — diff `(exposed-tools tk {})` against `(exposed-tools tk cfg)` to see precisely what an allowlist removes.
## Why this and not the alternative
[Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative)
Serve mounts the endpoint; this is the policy behind it
You do not call `exposed-tools` to serve MCP — pass `:mcp` to [`toolnexus.serve/serve`](/api/clojure/serve/a2a/) and the `/mcp` endpoint is mounted, calling this internally for both `tools/list` and `tools/call`. Call it directly when you want the *decision* as data. And if the goal is trimming a toolkit for every consumer rather than for one MCP profile, do it at [`toolnexus.core/build`](/api/clojure/toolkit/create/) instead — a tool that was never registered cannot be exposed by any surface.
## Examples
[Section titled “Examples”](#examples)
### What a profile actually exposes
[Section titled “What a profile actually exposes”](#what-a-profile-actually-exposes)
```clojure
(require '[toolnexus.serve :as serve]
'[toolnexus.core :as core])
(def tk (core/build {:mcp "examples/mcp.json" :skills "examples/skills"}))
;; no allowlist ⇒ everything, sorted
(mapv :name (serve/exposed-tools tk {}))
;; an allowlist ⇒ exactly these; "no-such-tool" is ignored, not an error
(mapv :name (serve/exposed-tools tk {:tools ["skill" "no-such-tool"]}))
;; => ["skill"]
```
### Serve the profile and connect to it
[Section titled “Serve the profile and connect to it”](#serve-the-profile-and-connect-to-it)
```clojure
(require '[toolnexus.core :as core]
'[toolnexus.serve :as serve])
(def tk (core/build {:skills "examples/skills"}))
(def h (serve/serve tk {:port 0
:mcp {:name "tn-mcp" :version "1.0.0"
:tools ["skill"]}
:on-call (fn [c] (println "called" (:name c)
"from" (:source c)
"isError" (:isError c)))}))
;; POST /mcp -> initialize | tools/list | tools/call
;; No :a2a profile here, so the Agent Card path and POST / both 404.
(println (str (:url h) "/mcp"))
```
`:on-call` fires once per inbound `tools/call` with `{:name :source :isError}` — enough for an audit log. Result metadata is not on the MCP wire; `:on-call` is where you see it.
### The three MCP methods, from a client’s side
[Section titled “The three MCP methods, from a client’s side”](#the-three-mcp-methods-from-a-clients-side)
```clojure
(require '[koine.http :as http]
'[koine.json :as json])
(defn rpc! [url method params]
(-> (http/post-json url {} (json/write-str {:jsonrpc "2.0" :id 1
:method method :params params}))
:body json/read-str :result))
(rpc! mcp-url "initialize" {})
;; => {:protocolVersion "2024-11-05" :capabilities {:tools {}}
;; :serverInfo {:name "tn-mcp" :version "1.0.0"}}
(rpc! mcp-url "tools/list" {})
;; => {:tools [{:name "skill" :description "..." :inputSchema {...}}]}
(rpc! mcp-url "tools/call" {:name "skill" :arguments {:name "hello-world"}})
;; => {:content [{:type "text" :text ""}] :isError false}
;; a tool the allowlist excluded answers exactly like one that does not exist:
;; JSON-RPC error -32602 "Unknown tool: bash"
```
## Options (the `:mcp` profile map)
[Section titled “Options (the :mcp profile map)”](#options-the-mcp-profile-map)
| Key | Default | Meaning |
| ------------------ | -------------- | ------------------------------------------------------------------------------------------ |
| `:tools` | all | Allowlist of tool names. Unknown names ignored. Governs `tools/list` **and** `tools/call`. |
| `:name` | `"toolnexus"` | `serverInfo.name` returned by `initialize`. |
| `:version` | `"0.1.0"` | `serverInfo.version`. |
| `:protocolVersion` | `"2024-11-05"` | Advertised MCP protocol version. |
### What goes on the wire per tool
[Section titled “What goes on the wire per tool”](#what-goes-on-the-wire-per-tool)
| MCP field | Source |
| ------------- | ---------------------------------------------- |
| `name` | `(:name tool)` — verbatim, never re-sanitized. |
| `description` | `(:description tool)`. |
| `inputSchema` | `(:input-schema tool)`, as registered. |
### Error codes
[Section titled “Error codes”](#error-codes)
| Code | Cause |
| -------- | -------------------------------------------------------------------- |
| `-32700` | The request body was not decodable JSON. Answered on HTTP 200. |
| `-32601` | A method other than `initialize` / `tools/list` / `tools/call`. |
| `-32602` | `tools/call` for a name that is unknown **or** excluded by `:tools`. |
| `-32603` | An internal error that escaped a handler. The server stays up. |
## See also
[Section titled “See also”](#see-also)
* [`toolnexus.serve/serve`](/api/clojure/serve/a2a/) — mounts `/mcp` and calls this
* [`toolnexus.mcp/from-config`](/api/clojure/mcp/load/) — the outbound direction: consuming someone else’s MCP server
* [`toolnexus.core/build`](/api/clojure/toolkit/create/) — filter at registration instead of at the gateway
# toolnexus.mcp-handler/mcp-handler
> An embeddable MCP server as a Ring handler, with per-request hooks for auth, tools and confirmation. — Clojure.
**Clojure (JVM) + cljgo** · package `net.clojars.muthuishere/toolnexus` · [SPEC §7C.1](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [ADR 0039](https://github.com/muthuishere/toolnexus/blob/main/docs/adr/0039-mcp-serve-embeddable-handler.md) · [clojure/src/toolnexus/mcp\_handler.cljc](https://github.com/muthuishere/toolnexus/blob/main/clojure/src/toolnexus/mcp_handler.cljc)
```clojure
(toolnexus.mcp-handler/mcp-handler toolkit cfg)
;; => {:handler (fn [ring-request] ring-response) ...}
(toolnexus.mcp-handler/notify-tools-changed! h) ; list_changed on every open GET stream
;; cfg — every key optional:
;; :name :title :version :instructions static serverInfo ("toolnexus" / "0.1.0")
;; :server-info (fn [ctx] {...}) wins over the static keys
;; :authenticate (fn [req] identity) a throw ⇒ HTTP 401
;; :www-authenticate (fn [req ex] string) the 401's WWW-Authenticate
;; :list-tools (fn [ctx] tools) per request: tools/list AND tools/call lookup
;; :before-call (fn [ctx tool args] {:kind :allow|:deny|:confirm :message :result})
;; :confirm-timeout-ms default 300000
;; :on-call (fn [event]) {:name :source :ms :is-error :err :result-bytes
;; :decision :identity :session-id}
;; :list-resources :read-resource :list-prompts :get-prompt
;; :session-ids {:generate :validate :terminate}
;; :stateless :stateless-session-id
;; :protocol-versions ⊆ ["2025-11-25" "2025-06-18"], default ["2025-06-18"]
;; :max-body-bytes default 4 MiB ⇒ 413
;; ctx = {:identity :request :session-id :meta}
;; (mcp-identity ctx) (mcp-request ctx) (mcp-session-id ctx) (mcp-request-meta ctx)
```
`mcp-handler` is the MCP server you **embed**. [`serve`](/api/clojure/serve/mcp/) owns a listener and exposes one fixed tool list; `mcp-handler` returns a plain Ring handler you mount on your own stack, behind your own TLS, CORS and OAuth. It speaks MCP streamable HTTP (protocol 2025-06-18, opt-in 2025-11-25) and leaves every policy to a hook: toolnexus has no idea what a tenant, a user or a role is — your hooks decide.
`:list-tools` runs **per request** and is authoritative for calls: a name outside the list answers `-32602 tool not found: ` and is never executed, so per-identity catalogs need no second check. Tools may be built inside the hook at runtime. `:before-call` gates each call: `:deny` returns your `:result` or an error carrying `:message`; `:confirm` asks the client through `elicitation/create` on the same POST’s SSE response when the client declared that capability, and otherwise returns your `:result` (for example a “re-call with this nonce” result) or `confirmation required: `.
Hooks and tools built with `:context? true` receive a `ctx` map carrying the authenticated identity, the Ring request, the session id and the request’s `params._meta`.
## When to use it
[Section titled “When to use it”](#when-to-use-it)
* **Multi-tenant MCP** — one endpoint, a different tool list per caller, decided from the identity your `:authenticate` produced.
* **Mounting MCP inside an existing app** — the handler is a Ring fn; put it on a route next to your REST API and share the auth middleware.
* **Destructive tools that need a human** — `:confirm` asks over MCP elicitation where the client can, and falls back to a result you shape where it cannot.
## Why this and not the alternative
[Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative)
serve for a fixed list, mcp-handler for anything per request
If one static toolkit should be reachable on its own port, [`serve`](/api/clojure/serve/mcp/) with an `:mcp` profile is less code. The moment the tool list, the auth, or the decision to run depends on who is calling, use `mcp-handler`: it is the same wire with the policy moved into your hands.
SSE responses (a GET notification stream, a confirm round trip) are **lazy-seq Ring bodies**; mount the handler on an adapter that writes a seq body chunk by chunk. Ring cannot see a client disconnect, so a GET stream ends when its session is DELETEd.
## Examples
[Section titled “Examples”](#examples)
### Per-identity tools with bearer auth
[Section titled “Per-identity tools with bearer auth”](#per-identity-tools-with-bearer-auth)
```clojure
(require '[toolnexus.mcp-handler :as mh]
'[toolnexus.tool :as tool])
(def search (tool/tool {:name "search" :description "Search" :context? true
:annotations {:title "Search" :readOnlyHint true}
:execute (fn [args ctx] (tool/success (str (:q args) " for " (mh/mcp-identity ctx))))}))
(def wipe (tool/tool {:name "wipe" :description "Admins only" :execute (fn [_] (tool/success "wiped"))}))
(def h (mh/mcp-handler (tool/toolkit [])
{:authenticate (fn [req] (case (get-in req [:headers "authorization"])
"Bearer alice" "alice"
"Bearer bob" "bob"
(throw (ex-info "bad token" {}))))
:www-authenticate (fn [_ _] "Bearer resource_metadata=\"https://example.test/.well-known/oauth-protected-resource\"")
:list-tools (fn [ctx] (if (= "alice" (mh/mcp-identity ctx)) [search wipe] [search]))}))
;; mount (:handler h) at /mcp on your Ring server
```
### A confirm gate with a shaped fallback
[Section titled “A confirm gate with a shaped fallback”](#a-confirm-gate-with-a-shaped-fallback)
```clojure
(def h (mh/mcp-handler tk
{:before-call (fn [_ctx t args]
(when (get-in t [:annotations :destructiveHint])
(if (= "n-42" (:nonce args))
{:kind :allow}
{:kind :confirm
:message "Delete the record?"
;; returned when the client cannot elicit:
:result (tool/with-structured {:output "re-call with nonce n-42"} {:nonce "n-42"})})))}))
```
### Audit every call
[Section titled “Audit every call”](#audit-every-call)
```clojure
(def h (mh/mcp-handler tk
{:on-call (fn [{:keys [name decision is-error ms identity]}]
(log/info "mcp call" name decision is-error ms identity))}))
;; a throwing :on-call is isolated — it never changes the caller's result
```
## Error codes and statuses
[Section titled “Error codes and statuses”](#error-codes-and-statuses)
| Answer | Cause |
| -------------------- | ------------------------------------------------------------------------------------------------------------------ |
| HTTP 401 | `:authenticate` threw (with `WWW-Authenticate` when `:www-authenticate` returns one). |
| HTTP 400 | Missing `Mcp-Session-Id`, unsupported `MCP-Protocol-Version`, a parse error (`-32700`) or a batch (`-32600`). |
| HTTP 404 | An unknown or terminated session. |
| HTTP 405 / 415 / 413 | Wrong method (or GET without `text/event-stream`), non-JSON POST, oversized body. |
| `-32601` | Unknown method, or resources/prompts without their hooks. |
| `-32602` | `tool not found: `, or `arguments must be an object` (2025-11-25 turns the latter into an `isError` result). |
## See also
[Section titled “See also”](#see-also)
* [`toolnexus.serve/exposed-tools`](/api/clojure/serve/mcp/) — the listener-owning §7C profile
* [`toolnexus.mcp/from-config`](/api/clojure/mcp/load/) — the outbound direction
# toolnexus.serve/file-store
> Persist inbound A2A tasks so a suspended request survives a restart. — Clojure.
**Clojure (JVM) + cljgo** · package `net.clojars.muthuishere/toolnexus` · [SPEC §7B](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [clojure/src/toolnexus/serve.cljc](https://github.com/muthuishere/toolnexus/blob/main/clojure/src/toolnexus/serve.cljc)
```clojure
(toolnexus.serve/file-store "./tasks")
;; => {:get (fn [id] task-or-nil)
;; :save (fn [task] task)}
(toolnexus.serve/memory-store)
;; => {:get (fn [id] ...) :save (fn [task] ...) :all (fn [] {id task})}
(toolnexus.serve/resolve-store store)
;; nil | "memory" ⇒ (memory-store)
;; "file:" ⇒ (file-store )
;; anything else ⇒ used as-is
```
A §7B TaskStore is deliberately not a protocol and not a record — it is a plain map of two closures, `:get` and `:save`. Protocols and records are the two things guaranteed to differ between Clojure on the JVM and cljgo, so the store contract is expressed in the one shape both hosts agree on. Anything supplying those two keys is a store.
`file-store` writes one `.json` per task into `dir`, creating the directory if it does not exist. `:save` serialises the whole task map; `:get` reads it back, returning `nil` for an unknown id and also for a file that fails to parse — a corrupt task reads as a missing task rather than taking the request down. `memory-store` is the default: an atom of id→task, plus an extra `:all` for tests and admin views that `file-store` does not provide.
`resolve-store` is what [`serve`](/api/clojure/serve/a2a/) calls on the `:store` option, so in practice you pass the string `"file:./tasks"` rather than calling `file-store` yourself. Calling it directly matters when you want to *read* the store — a status endpoint, a test, a cleanup job — or when you are writing your own.
Durability here means `GetTask` keeps answering across a restart, not that in-flight work resumes. Fulfilment runs on a background thread; a process that dies mid-task leaves that task saved as `working` forever, because nothing is left to finish it. Treat a long-stale `working` as failed.
## When to use it
[Section titled “When to use it”](#when-to-use-it)
* **Tasks outlive the process** — a peer polls `GetTask` for minutes and your service redeploys in the middle.
* **You want to inspect tasks out of band** — one JSON file per id is greppable, diffable and trivially exported.
* **Several processes read the same tasks** — a shared volume gives reads across instances (writes are last-writer-wins; there is no locking).
## Why this and not the alternative
[Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative)
Memory is the right default
[`memory-store`](/api/clojure/serve/task-store/) costs nothing, needs no disk, and gives you `:all` for free — take it for tests, short-lived tasks, and any single-process agent whose peers poll for seconds rather than minutes. `file-store` buys survival across a restart at the cost of a JSON write per state transition (three per task: `submitted`, `working`, then terminal). Reach for it when a lost task id is a real failure, not a re-run.
For anything beyond a directory — Postgres, Redis, S3 — do not subclass anything: hand `serve` a map with your own `:get` and `:save`, and `resolve-store` will pass it straight through.
## Examples
[Section titled “Examples”](#examples)
### Durable tasks, via the config string
[Section titled “Durable tasks, via the config string”](#durable-tasks-via-the-config-string)
```clojure
(require '[toolnexus.core :as core]
'[toolnexus.serve :as serve])
(def tk (core/build {:skills "examples/skills"}))
(def h (serve/serve tk {:port 8080
:a2a {:name "tn-agent"}
:skills (:skills tk)
:store "file:./tasks"
:run (fn [text] {:text (str "ran: " text)})}))
;; ./tasks/.json now holds the task, rewritten at each state change.
;; The resolved store is on the handle, ready to read:
((:get (:store h)) "some-task-id")
```
### Read the store back after a restart
[Section titled “Read the store back after a restart”](#read-the-store-back-after-a-restart)
```clojure
(require '[toolnexus.serve :as serve])
(def store (serve/file-store "./tasks"))
(let [t ((:get store) "3f2c...-task-id")]
(case (get-in t [:status :state])
"completed" (->> (:artifacts t) (mapcat :parts) (map :text))
"failed" (get-in t [:status :message :parts 0 :text])
"working" :still-running-or-orphaned
nil :unknown-task))
```
An unknown id and an unreadable file both give `nil`, so an id you never issued and a task whose JSON got truncated are handled by the same branch.
### Your own store
[Section titled “Your own store”](#your-own-store)
```clojure
(require '[toolnexus.serve :as serve])
(defn sql-store [conn]
{:get (fn [id] (fetch-task conn id))
:save (fn [task] (upsert-task conn task) task)})
;; passed straight through by resolve-store
(serve/serve tk {:port 8080 :a2a {} :store (sql-store conn) :run run-fn})
;; the two strings resolve-store special-cases:
(serve/resolve-store nil) ;; => an in-memory store
(serve/resolve-store "memory") ;; => an in-memory store
(serve/resolve-store "file:./tasks") ;; => (file-store "./tasks")
```
`:save` must return the task — `serve` uses the returned value.
## The store contract
[Section titled “The store contract”](#the-store-contract)
| Key | Signature | Contract |
| ------- | ------------- | ------------------------------------------------------------------------------------------ |
| `:get` | `(fn [id])` | The saved task map, or `nil` when absent or unreadable. Must not throw. |
| `:save` | `(fn [task])` | Persists by `(:id task)` and **returns the task**. |
| `:all` | `(fn [])` | Optional. `memory-store` only — the whole id→task map. `file-store` does not implement it. |
### Task shape as stored
[Section titled “Task shape as stored”](#task-shape-as-stored)
| Field | Present when |
| ------------------ | ------------------------------------------------------------------------------ |
| `:id` | Always. |
| `:status` | Always — `{:state "submitted"\|"working"\|"completed"\|"failed"}`. |
| `:artifacts` | Completed tasks only: `[{:artifactId ... :parts [{:kind "text" :text ...}]}]`. |
| `:status :message` | Failed tasks only — the error text, as an agent-role message. |
## See also
[Section titled “See also”](#see-also)
* [`toolnexus.serve/serve`](/api/clojure/serve/a2a/) — the `:store` option, and `:store` on the handle
* [`toolnexus.a2a/remote-agent`](/api/clojure/agents/agent/) — the peer whose `GetTask` polls read this
* [`toolnexus.client/in-memory-store`](/api/clojure/client/store/) — the unrelated conversation store on the client side
# toolnexus.skill/list-skills
> Enumerate discovered skills and, crucially, the ones that were skipped and why. — Clojure.
**Clojure (JVM) + cljgo** · package `net.clojars.muthuishere/toolnexus` · [SPEC §3](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [clojure/src/toolnexus/skill.cljc](https://github.com/muthuishere/toolnexus/blob/main/clojure/src/toolnexus/skill.cljc)
```clojure
(list-skills input)
;; input: every shape load-skills takes — a root, a seq of roots, skill
;; definitions, or the options map. :filter is accepted and IGNORED.
;;
;; => {:skills [{:name … :description … :location … :content …
;; :dir … :base … :origin "fs" | "logical"}]
;; :skipped [{:location "…/SKILL.md" :reason "malformed-frontmatter"}]}
```
`list-skills` runs the same discovery and parsing as [`toolnexus.skill/load-skills`](/api/clojure/skills/load/) and hands the result back as a plain report: the skills that parsed, and the candidates that were rejected with a typed reason. No `skill` tool is built, no prompt is rendered, nothing is left open.
The `:skipped` vector is the reason this function exists. `load-skills` quietly drops a malformed `SKILL.md` — it is not going to refuse to build a toolkit over one bad file — so nothing tells you that a capability you wrote is missing from the model’s catalog. `list-skills` names the path and the reason, which is what turns that into a build failure.
It is deliberately **unfiltered**: `:filter` is stripped before discovery runs. The inventory is what you author an allowlist from, so filtering it would be circular. It also omits `:by-name`, the key `skill-tool` and `execute-skill` read — so a report cannot be handed to them by accident.
## When to use it
[Section titled “When to use it”](#when-to-use-it)
* **A CI gate** — assert `:skipped` is empty, so a typo in frontmatter fails the build instead of vanishing a skill.
* **Authoring an allowlist** — enumerate everything that exists, then feed names into `load-skills`’s `:filter`. This is what the unfiltered inventory is for.
* **Checking the frontmatter subset** — this port’s parser is stricter than the other ports’ (see below), and this is how you find out which of your skills fall outside it.
* **Tooling** — a `skills list` command, an admin page, a doctor command. The report is data with no side effects, so it is safe to call from anywhere.
## Why this and not the alternative
[Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative)
Use load-skills when you want a tool, not a report
[`toolnexus.skill/load-skills`](/api/clojure/skills/load/) is the runtime path: it returns `:by-name` for lookup, applies `:filter`, carries `:sample-limit` forward, and is what `skill-tool` and `skills-prompt` consume. Use `list-skills` when you want to *inspect* — the failures, or the full unfiltered set — and are not about to call an LLM.
Both take the same input, so one configuration value can be validated and then loaded without being written twice. Neither caches; both re-read the tree on every call, so call this once at startup rather than per request.
## Examples
[Section titled “Examples”](#examples)
### Inventory the shared fixture
[Section titled “Inventory the shared fixture”](#inventory-the-shared-fixture)
```clojure
(require '[clojure.string :as str]
'[toolnexus.skill :as skill])
(def report (skill/list-skills "examples/skills"))
(:skipped report) ;=> []
(map :name (:skills report)) ;=> ("hello-world")
(let [hello (first (:skills report))]
(:origin hello) ;=> "fs"
(:location hello) ;=> "examples/skills/hello-world/SKILL.md"
;; the instruction body is already parsed and available
(str/includes? (:content hello) "# Hello World Skill")) ;=> true
```
### The four skip reasons, and a gate over them
[Section titled “The four skip reasons, and a gate over them”](#the-four-skip-reasons-and-a-gate-over-them)
```clojure
(require '[toolnexus.skill :as skill])
(def report (skill/list-skills ["project/skills" "team/skills"]))
(map (juxt :reason :location) (:skipped report))
;; => (["malformed-frontmatter" "project/skills/broken/SKILL.md"]
;; ["missing-name" "project/skills/nameless/SKILL.md"]
;; ["duplicate-name" "team/skills/deploy/SKILL.md"])
(defn check-skills!
"Fail the build rather than ship an agent that silently lost a capability."
[roots]
(let [{:keys [skills skipped]} (skill/list-skills roots)]
(when (seq skipped)
(throw (ex-info "skills failed to load" {:skipped skipped})))
(count skills)))
```
| Reason | Cause |
| ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
| `"missing-name"` | Frontmatter parsed, but `name` is absent or blank. A file with no frontmatter at all lands here too, as does a data definition without a `:name`. |
| `"malformed-frontmatter"` | The `---` fences are present and what sits between them is outside `toolnexus.frontmatter`’s accepted subset. |
| `"duplicate-name"` | An earlier candidate already claimed that name. First wins. |
| `"unreadable"` | The `SKILL.md` bytes could not be read — permissions, a dangling symlink. |
duplicate-name is decided by traversal order
Roots are visited in the order given, sorted within each root, and data definitions come after every root. Which of two same-named skills survives is therefore a fact about where they sit, not a preference you expressed. Treat a `duplicate-name` skip as a build failure rather than a default.
### Validate a whole configuration, then load it
[Section titled “Validate a whole configuration, then load it”](#validate-a-whole-configuration-then-load-it)
```clojure
(require '[toolnexus.skill :as skill])
(def config
{:dirs ["examples/skills"]
:skills [{:name "triage" :description "Triage an incoming bug report" :content "1. Reproduce it."}
{:name "release" :description "Cut a release" :content "1. Bump the version."}
;; no :name — a skip, not a crash
{:description "Anonymous" :content "…"}]})
(def report (skill/list-skills config))
(sort (map :name (:skills report))) ;=> ("hello-world" "release" "triage")
(map :reason (:skipped report)) ;=> ("missing-name")
;; Data-sourced skills carry a logical base and never touch disk.
(map (juxt :name :origin) (:skills report))
;; => (["hello-world" "fs"] ["triage" "logical"] ["release" "logical"])
;; Now author the allowlist from the inventory and load only what this agent gets.
(def loaded (skill/load-skills (assoc config :filter {"hello-world" true "triage" true})))
(sort (map :name (:skills loaded))) ;=> ("hello-world" "triage")
;; list-skills stays UNFILTERED even when the filter is present — that is the point.
(count (:skills (skill/list-skills (assoc config :filter {"hello-world" true})))) ;=> 3
```
| Return key | What it is |
| ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `:skills` | Parsed skills in discovery order, deduped by name, never filtered. Each carries `:name`, `:description`, `:location`, `:content`, `:dir`, `:base` and `:origin`. |
| `:skipped` | One `{:location :reason}` map per rejected candidate. |
## See also
[Section titled “See also”](#see-also)
* [`toolnexus.skill/load-skills`](/api/clojure/skills/load/) — Glob a skills directory and expose one skill tool with progressive disclosure.
* [Data skills, filters and sampling](/api/clojure/skills/load-with/) — the option map both functions accept.
# toolnexus.skill/load-skills
> Glob a skills directory and expose one skill tool with progressive disclosure. — Clojure.
**Clojure (JVM) + cljgo** · package `net.clojars.muthuishere/toolnexus` · [SPEC §3](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [clojure/src/toolnexus/skill.cljc](https://github.com/muthuishere/toolnexus/blob/main/clojure/src/toolnexus/skill.cljc)
```clojure
(load-skills "examples/skills") ; one root
(load-skills ["project/skills" "team/skills"]) ; several, earlier wins
;; => {:skills [{:name "hello-world"
;; :description "A tiny example skill."
;; :location "examples/skills/hello-world/SKILL.md"
;; :content "# Hello World Skill\n…"
;; :dir "examples/skills/hello-world"
;; :base "file://examples/skills/hello-world"
;; :origin "fs"}]
;; :by-name {"hello-world" {…}}
;; :skipped [{:location "…/SKILL.md" :reason "duplicate-name"}]
;; :filter-unmatched []
;; :sample-limit 0}
```
`load-skills` walks every root for `**/SKILL.md`, parses the `---`-fenced frontmatter, and returns the whole discovery result as one map. It opens nothing, starts nothing, and never throws: a `SKILL.md` that cannot be read or cannot be parsed becomes a typed entry in `:skipped` rather than an exception that takes the rest of the tree down with it.
The returned map is an input, not a finished product. Pass it to `toolnexus.skill/skill-tool` to build the single `skill` tool the model calls, and to `toolnexus.skill/skills-prompt` for the `## Available Skills` block that goes into the system prompt. `toolnexus.core/build` does both for you when you give it `:skills`.
Name collisions resolve by discovery order, first wins. Roots are visited in the order given, and `koine.fs/find-files` sorts within a root — so the winner is the same on Clojure (JVM) and on cljgo. That ordering is load-bearing, not incidental: one `.cljc` tree has to produce identical bytes on both hosts, and a first-wins rule over an unsorted walk would not.
The same function also takes an options map, which is where skills supplied as data, the per-agent allowlist and the file-sampling limit live — see [data skills, filters and sampling](/api/clojure/skills/load-with/). This page is the plain-roots half.
## When to use it
[Section titled “When to use it”](#when-to-use-it)
* **Wiring skills into a toolkit** — the normal path, usually through `toolnexus.core/build`’s `:skills` option rather than by calling this directly.
* **Layering skill trees** — a project root plus a shared team root, where the project’s copy of a skill must shadow the team’s. Put the project root first.
* **Reloading while authoring** — nothing caches, so calling again after you edit a `SKILL.md` picks the change up.
* **Before you trust the catalog** — read `:skipped`. A skill that failed to parse is simply not in the model’s list, and this map is the only place that says so.
## Why this and not the alternative
[Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative)
Use list-skills when you want a report, not a tool
[`toolnexus.skill/list-skills`](/api/clojure/skills/list/) runs exactly the same discovery and hands back only `:skills` and `:skipped` — no `:by-name`, so a report cannot be fed to `skill-tool` by mistake. Reach for `load-skills` when the next thing you do is call an LLM, and for `list-skills` when the next thing you do is fail a build.
Nothing here caches. Calling `load-skills` twice re-reads the tree, which is what you want while authoring skills and what you should avoid on a hot path — hold the returned map for the life of the agent.
## Examples
[Section titled “Examples”](#examples)
### Load one tree and build the `skill` tool
[Section titled “Load one tree and build the skill tool”](#load-one-tree-and-build-the-skill-tool)
```clojure
(require '[toolnexus.skill :as skill]
'[toolnexus.tool :as tool])
(def loaded (skill/load-skills "examples/skills"))
(map :name (:skills loaded)) ;=> ("hello-world")
(:skipped loaded) ;=> []
;; The single tool the model sees, plus the catalog for the system prompt.
(def tk (tool/toolkit [(skill/skill-tool loaded)]))
(tool/tool-names tk) ;=> ["skill"]
(:output (tool/execute tk "skill" {"name" "hello-world"}))
;; => "\n# Skill: hello-world\n…"
(skill/skills-prompt loaded)
;; => "Skills provide specialized instructions and workflows for specific tasks.\n…"
```
An unknown skill name is not a throw. `execute-skill` returns `(toolnexus.tool/failure "Skill \"x\" not found. Available skills: hello-world")`, so the model sees its own mistake and can retry. Progressive disclosure is the point of the output: the instructions are inlined, and the skill’s sibling files are *listed* rather than read, so the model asks for what it actually needs.
### Layer a project root over a shared team root
[Section titled “Layer a project root over a shared team root”](#layer-a-project-root-over-a-shared-team-root)
```clojure
(require '[toolnexus.skill :as skill])
;; Earlier root wins. The project's "deploy" shadows the team's, and the
;; shadowed one shows up as a skip rather than disappearing quietly.
(def loaded (skill/load-skills ["project/skills" "team/skills"]))
(map :name (:skills loaded))
;; => ("deploy" "triage" "postmortem")
(map (juxt :reason :location) (:skipped loaded))
;; => (["duplicate-name" "team/skills/deploy/SKILL.md"])
```
A root that does not exist contributes nothing and is not an error, which makes an optional team root safe to list unconditionally. Paths under `node_modules` or `.git` are ignored.
### Into a toolkit alongside every other source
[Section titled “Into a toolkit alongside every other source”](#into-a-toolkit-alongside-every-other-source)
```clojure
(require '[toolnexus.core :as toolnexus])
;; :skills takes exactly what this function takes.
(def tk (toolnexus/build {:mcp "examples/mcp.json"
:skills "examples/skills"}))
(toolnexus/tool-names tk)
;; => ["apply_patch" "bash" "edit" "glob" "grep" "question" "read"
;; "skill" "todowrite" "webfetch" "write"]
(toolnexus/skills-prompt tk) ; the loaded map is kept on the toolkit under :skills
(:skipped (:skills tk)) ;=> []
(toolnexus/shutdown! tk) ; closes MCP connections; skills hold nothing open
```
| Return key | What it is |
| ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
| `:skills` | Parsed skills in discovery order, deduped by name. |
| `:by-name` | The same skills keyed by name — what `execute-skill` looks up. |
| `:skipped` | One `{:location :reason}` map per rejected candidate. Reasons: `"missing-name"`, `"malformed-frontmatter"`, `"duplicate-name"`, `"unreadable"`. |
| `:filter-unmatched` | Allowlist names that matched no skill. Always `[]` from the plain-roots form. |
| `:sample-limit` | The sibling-file limit, carried into `skill-tool`. `0` from the plain-roots form, which means the default of 10. |
| Skill key | What it is |
| -------------- | ------------------------------------------------------------------------------------------------- |
| `:name` | From the frontmatter. Required — a blank one is a `"missing-name"` skip. |
| `:description` | From the frontmatter. `nil` when absent, and only described skills appear in `skills-prompt`. |
| `:location` | The `SKILL.md` path. |
| `:content` | The instruction body below the frontmatter, already parsed. |
| `:dir` | The skill’s directory — what gets sampled for the `` block. |
| `:base` | `file://` plus the directory. It is the `Base directory for this skill:` line in the tool output. |
| `:origin` | `"fs"` for a skill discovered on disk. |
A narrower frontmatter parser than the other ports
`toolnexus.frontmatter` accepts a strict subset of YAML: plain scalars, no block scalars. A `SKILL.md` written as `description: >` loads in the six full-tier ports and is skipped here as `"malformed-frontmatter"`. The shared `examples/` fixture uses plain scalars only, so conformance bytes match — but a skill of your own written that way will not load. Check `:skipped` after every load.
## See also
[Section titled “See also”](#see-also)
* [`toolnexus.skill/list-skills`](/api/clojure/skills/list/) — Enumerate discovered skills and, crucially, the ones that were skipped and why.
* [Data skills, filters and sampling](/api/clojure/skills/load-with/) — the same function’s option-map form: skills without a filesystem, a per-agent allowlist, and the sibling-file limit.
# toolnexus.skill/load-skills
> Supply skills as data, narrow them per agent with an allowlist, and cap the sampled file list. — Clojure.
**Clojure (JVM) + cljgo** · package `net.clojars.muthuishere/toolnexus` · [SPEC §3](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [clojure/src/toolnexus/skill.cljc](https://github.com/muthuishere/toolnexus/blob/main/clojure/src/toolnexus/skill.cljc)
```clojure
(load-skills {:dirs ["project/skills"] ; roots, as usual
:skills [{:name "release"
:description "Cut a release"
:content "1. Bump the version."
:resources ["checklist.md"]
:base "skill://release/"}]
:filter {"release" true "deploy" true}
:sample-limit 10})
;; => {:skills [{… :origin "fs" | "logical"}]
;; :by-name {"release" {…}}
;; :skipped [{:location "skill://" :reason "missing-name"}]
;; :filter-unmatched ["typo"]
;; :sample-limit 10}
```
There is one function, not two. `load-skills` takes a root, a seq of roots, **or** an options map, and the map is where everything beyond the filesystem lives: skills supplied as data, the per-agent allowlist, and the sibling-file sampling limit. A map counts as options when it carries any of `:dirs`, `:skills`, `:filter` or `:sample-limit`; a map without them is read as a single skill definition, so `(load-skills {:name "release" :content "…"})` also works.
Sources are collected in a fixed order — every root, in the order given, then the data definitions — and the existing first-name-wins rule resolves collisions across them without a second rule. A disk skill therefore shadows a data skill of the same name, and the data one comes back as a `"duplicate-name"` skip.
A data skill never touches a disk. Its `:origin` is `"logical"` and its `:base` is `skill:///` unless you supply one, so no host path can leak into the model’s context from a source that was never on a filesystem. Its `` block lists the `:resources` you supplied, in the order supplied, and is omitted entirely when it has none — an on-disk skill, by contrast, emits the block even when the directory holds nothing but the `SKILL.md`.
## When to use it
[Section titled “When to use it”](#when-to-use-it)
* **Skills that are not files** — a database row, an HTTP response, an EDN blob shipped with the app. Build the maps, pass them as `:skills`.
* **Several agents, one tree** — load once per agent with a different `:filter` and each sees a different subset of the same skills.
* **Trimming the disclosed file list** — `:sample-limit` caps the `` block or removes it, which matters for a skill sitting in a directory full of assets.
* **Combining the two** — a shared on-disk tree plus a few per-tenant skills assembled at runtime, in one catalog, under one collision rule.
## Why this and not the alternative
[Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative)
Plain roots stay plain
If everything is on disk and every agent sees everything, [`(load-skills "examples/skills")`](/api/clojure/skills/load/) is the whole call. The option map is not a different function and not a different result shape, so moving to it later changes one argument and nothing else.
There is **no lazy provider slot** — nothing takes a function and calls it during load. Resolve your source first and pass the result to `:skills`. The difference that matters is isolation: in the other ports a throwing provider is caught and warned about, so here the `try` is yours to write.
```clojure
(def defs (try (skills-from-registry) (catch Throwable _ [])))
(skill/load-skills {:dirs ["examples/skills"] :skills defs})
```
## Examples
[Section titled “Examples”](#examples)
### Skills assembled in process
[Section titled “Skills assembled in process”](#skills-assembled-in-process)
```clojure
(require '[toolnexus.skill :as skill])
(def loaded
(skill/load-skills
{:skills [{:name "release"
:description "Cut a release"
:content "1. Bump the version.\n2. Tag it.\n3. Push the tag."
:resources ["checklist.md" "changelog-template.md"]}
{:name "triage"
:description "Triage an incoming bug report"
:content "1. Reproduce it."}
;; No :name — a typed skip, not a crash.
{:description "Anonymous" :content "…"}]}))
(map (juxt :name :origin :base) (:skills loaded))
;; => (["release" "logical" "skill://release/"] ["triage" "logical" "skill://triage/"])
(:skipped loaded) ;=> [{:location "skill://" :reason "missing-name"}]
;; Supplied resources are what gets disclosed; nothing is globbed.
(:output (skill/execute-skill loaded "release"))
;; => "…\nchecklist.md\nchangelog-template.md\n…"
;; An instruction-only skill has nothing to disclose, so it emits no block at all.
(:output (skill/execute-skill loaded "triage"))
```
Supply `:base` yourself when the resources are addressable somewhere real — an `https://` prefix, say — and it replaces both the logical base and the skill’s `:location`.
### One tree, two agents, two allowlists
[Section titled “One tree, two agents, two allowlists”](#one-tree-two-agents-two-allowlists)
```clojure
(require '[toolnexus.skill :as skill])
(def base {:dirs ["team/skills"]})
(def deployer (skill/load-skills (assoc base :filter {"deploy" true "rollback" true})))
(def support (skill/load-skills (assoc base :filter {"deploy" false})))
(map :name (:skills deployer)) ;=> ("deploy" "rollback")
(map :name (:skills support)) ;=> ("rollback" "triage" "postmortem")
;; A name that matched nothing is not an error — but it is returned and warned,
;; so an allowlist typo narrows the agent visibly instead of silently.
(:filter-unmatched (skill/load-skills (assoc base :filter {"deply" true})))
;; => ["deply"]
```
| `:filter` value | Meaning |
| ----------------------- | ----------------------------------------------------------- |
| `nil` or `{}` | Every discovered skill. |
| at least one `true` | Allowlist — exactly the true-mapped names survive. |
| only `false` values | Drop-list over the all-on baseline. |
| a name matching nothing | Ignored, returned in `:filter-unmatched`, warned on stdout. |
Keys may be strings or keywords; both are read as the skill’s name. Note that this is an allowlist-when-any-true rule, which is *not* how the builtins toggle behaves — see the caution on [`enabled-builtins`](/api/clojure/builtins/select/).
### Capping and disabling the disclosed file list
[Section titled “Capping and disabling the disclosed file list”](#capping-and-disabling-the-disclosed-file-list)
```clojure
(require '[toolnexus.skill :as skill]
'[toolnexus.tool :as tool])
;; 0 (the default) => 10 files. n > 0 => at most n. -1 => no block at all.
(def loaded (skill/load-skills {:dirs ["examples/skills"] :sample-limit 3}))
;; The limit rides on the loaded map, so the tool built from it inherits it.
(def tk (tool/toolkit [(skill/skill-tool loaded)]))
(:output (tool/execute tk "skill" {"name" "hello-world"}))
;; …or override per call.
(:output (skill/execute-skill loaded "hello-world" -1))
;; the block AND its "Note: file list is sampled." line are both gone
```
Sampled files are sorted, exclude every `SKILL.md`, and skip `node_modules` and `.git`. The sort is this port’s choice: the shipped ports slice raw directory-read order, which two runtimes are not guaranteed to agree on, and a port whose whole claim is byte-identical output on two hosts cannot depend on it.
| Option | Default | What it does |
| --------------- | ------- | ---------------------------------------------------------------------------------------------------------------------------- |
| `:dirs` | `[]` | Filesystem roots walked for `**/SKILL.md`. A bare string is accepted as a single root. |
| `:skills` | `[]` | Skill definitions as maps: `:name` (required), `:description`, `:content`, `:resources`, `:base`. Collected after the roots. |
| `:filter` | `nil` | Per-agent allowlist or drop-list, applied after discovery. |
| `:sample-limit` | `0` | Sibling-file cap. `0` means 10, `n > 0` means `n`, `-1` disables the block. |
| Return key | What it is |
| ------------------- | ------------------------------------------------------------------------------------- |
| `:skills` | Surviving skills in collection order, after `:filter`. |
| `:by-name` | The same skills keyed by name — what `execute-skill` looks up. |
| `:skipped` | Rejected candidates, including nameless data definitions and cross-source duplicates. |
| `:filter-unmatched` | Sorted filter names that matched no skill. |
| `:sample-limit` | The limit as given, carried into `skill-tool`. |
`toolnexus.core/build` passes `:skills` straight through, so the option map works there too:
```clojure
(require '[toolnexus.core :as toolnexus])
(toolnexus/build {:skills {:dirs ["examples/skills"]
:skills [{:name "release" :content "1. Bump the version."}]
:filter {"hello-world" true "release" true}}})
```
## See also
[Section titled “See also”](#see-also)
* [`toolnexus.skill/load-skills` with plain roots](/api/clojure/skills/load/) — the short form, and how the loaded map becomes the `skill` tool.
* [`toolnexus.skill/list-skills`](/api/clojure/skills/list/) — the same discovery as a report; it ignores `:filter`, because the inventory is what you author an allowlist from.
# answer-declined
> Wraps a human's refusal into the Answer a suspended run resumes with, carrying a reason string that defaults to "declined". — Clojure.
**Clojure (JVM) + cljgo** · package `net.clojars.muthuishere/toolnexus` · [SPEC §10](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [clojure/src/toolnexus/client.cljc](https://github.com/muthuishere/toolnexus/blob/main/clojure/src/toolnexus/client.cljc)
```clojure
(answer-declined id) ; :reason defaults to "declined"
(answer-declined id reason)
;; => {:id id :ok false} ; no reason given
;; => {:id id :ok false :reason "no today"} ; reason given
```
Wraps a human’s refusal into the `Answer` a suspended run resumes with, carrying a reason string that defaults to `"declined"`. It is the decline counterpart to [`answer-output`](/api/clojure/suspension/answer-output/), and both are canonical, shipped in all eight ports (addendum A9): before these constructors existed, a host hand-built the `Answer` map and a mistyped `:ok`/`:reason` key was a silent wrong-outcome bug rather than a construction error.
`:reason` is carried **only** on a decline (R1) — `answer-output` never sets it, because the loop’s resume rule branches on `:ok` alone and never reads `:reason` to decide anything; it exists purely so the model (and any log line) can see *why* the human said no.
## When to use it
[Section titled “When to use it”](#when-to-use-it)
* **The human explicitly refuses** a request — “no, don’t do that”, “cancel”, a rejected confirmation.
* **A suspension times out unanswered** and the host decides to resolve it as a decline rather than leave the run parked forever — pass a reason like `"expired"` or `"no reply within 5m"`.
* **A `:wait-for` hook that cannot route the request at all** (an unrecognised `:kind`, a channel that is down) should decline with a reason naming the problem, rather than throw or silently grant — the run keeps going and the model sees why the tool call was blocked.
## Why this and not the alternative
[Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative)
Use answer-output when there IS a value to hand back
`answer-declined` carries no payload beyond the reason string — `:data` is never set. If the human actually supplied something usable (a typed answer, a picked option, a granted login), wrap it with [`answer-output`](/api/clojure/suspension/answer-output/) instead, even if the value itself reads as negative (“no thanks” typed into a text box is still a string output, not a protocol-level decline). Reach for `answer-declined` when there is nothing for the tool to act on at all.
A declined answer is not a crash: the loop turns it into an error `ToolResult` fed back to the model (`"declined/expired: "`), and the run continues — the model can explain the situation to the user instead of the whole run dying on a refusal.
## Examples
[Section titled “Examples”](#examples)
### 1. The smallest useful call — the default reason
[Section titled “1. The smallest useful call — the default reason”](#1-the-smallest-useful-call--the-default-reason)
```clojure
(require '[toolnexus.client :as client])
(println)
(def a (client/answer-declined "sus-1"))
;; => {:id "sus-1" :ok false}
(assert (= {:id "sus-1" :ok false} a))
(println "OK")
```
Note there is no `:reason` key at all here — `make-answer` only assigns it when one is given, so a bare decline is not `{:reason "declined"}` on the wire; it is simply `:ok false` with no reason.
### 2. The realistic case — a human explicitly says no, inside :wait-for
[Section titled “2. The realistic case — a human explicitly says no, inside :wait-for”](#2-the-realistic-case--a-human-explicitly-says-no-inside-wait-for)
```clojure
(require '[toolnexus.client :as client]
'[toolnexus.tool :as tool]
'[koine.json :as json])
(println)
(defn- calls-response [calls]
{:choices [{:message {:role "assistant" :content nil
:tool_calls (mapv (fn [c] {:id (:id c) :type "function"
:function {:name (:name c)
:arguments (json/write-str (or (:args c) {}))}})
calls)}}]
:usage {:prompt_tokens 10 :completion_tokens 5 :total_tokens 15}})
(defn- text-response [t]
{:choices [{:message {:role "assistant" :content t}}]
:usage {:prompt_tokens 10 :completion_tokens 5 :total_tokens 15}})
(def script (atom [{:calls [{:id "c1" :name "confirm_delete" :args {}}]}
{:text "OK, I left the branches alone."}]))
(defn- mock-http []
(let [n (atom 0)]
(fn [_url _headers _body]
(let [i (swap! n inc)
spec (get @script (dec i))]
{:status 200 :headers {}
:body (json/write-str (if (seq (:calls spec)) (calls-response (:calls spec)) (text-response (:text spec))))}))))
(def confirm-delete-tool
(tool/tool {:name "confirm_delete" :description "confirm branch deletion"
:execute (fn ([_args] (client/suspend (client/make-request "approval" "Delete the stale branches?")))
([_args ctx] (if (get-in ctx [:answer :ok])
(tool/success "deleted")
(tool/failure (str "declined: " (get-in ctx [:answer :reason]))))))}))
(defn ask-human! [_prompt] {:approved? false})
(def llm
(client/create-client
{:base-url "http://mock.local" :api-key "test-key"
:style "openai"
:model "gpt-4o-mini"
:http-client (mock-http)
:wait-for (fn [request]
(let [reply (ask-human! (:prompt request))]
(if (:approved? reply)
(client/answer-output (:id request) (:value reply))
(client/answer-declined (:id request) "not today"))))}))
(def rr
(client/run llm "Should I delete the stale branches?"
{:toolkit (tool/toolkit [confirm-delete-tool])}))
(assert (= "done" (:status rr)))
(assert (= "OK, I left the branches alone." (:text rr)))
(println "OK")
```
### 3. The full surface — an unroutable request, declined with a diagnostic reason
[Section titled “3. The full surface — an unroutable request, declined with a diagnostic reason”](#3-the-full-surface--an-unroutable-request-declined-with-a-diagnostic-reason)
```clojure
(require '[toolnexus.client :as client]
'[toolnexus.tool :as tool]
'[koine.json :as json])
(println)
(defn- calls-response [calls]
{:choices [{:message {:role "assistant" :content nil
:tool_calls (mapv (fn [c] {:id (:id c) :type "function"
:function {:name (:name c)
:arguments (json/write-str (or (:args c) {}))}})
calls)}}]
:usage {:prompt_tokens 10 :completion_tokens 5 :total_tokens 15}})
(defn- text-response [t]
{:choices [{:message {:role "assistant" :content t}}]
:usage {:prompt_tokens 10 :completion_tokens 5 :total_tokens 15}})
(def script (atom [{:calls [{:id "c1" :name "mystery" :args {}}]}
{:text "I could not route that request."}]))
(defn- mock-http []
(let [n (atom 0)]
(fn [_url _headers _body]
(let [i (swap! n inc)
spec (get @script (dec i))]
{:status 200 :headers {}
:body (json/write-str (if (seq (:calls spec)) (calls-response (:calls spec)) (text-response (:text spec))))}))))
(defn route-to-oauth! [_req] nil)
(defn route-to-form! [_req] nil)
;; a suspending tool whose :kind is not one the host recognises
(def mystery-tool
(tool/tool {:name "mystery" :description "an unroutable kind"
:execute (fn ([_args] (client/suspend (client/make-request "carrier-pigeon" "deliver a message")))
([_args ctx] (tool/success "n/a")))}))
(def llm
(client/create-client
{:base-url "http://mock.local" :api-key "test-key"
:style "openai"
:model "gpt-4o-mini"
:http-client (mock-http)
:wait-for (fn [request]
(case (:kind request)
"authorization" (route-to-oauth! request)
"input" (route-to-form! request)
;; an unknown kind is declined, not thrown — the run keeps going
(client/answer-declined
(:id request)
(str "unsupported suspension kind: " (:kind request)))))}))
(def rr (client/run llm "do the thing" {:toolkit (tool/toolkit [mystery-tool])}))
(assert (= "done" (:status rr)))
(assert (= "I could not route that request." (:text rr)))
;; a string-keyed Answer a host round-trips through JSON is normalised the same
;; way regardless of which constructor built it
(assert (= false (:ok (client/as-answer {"id" "x" "ok" false "reason" "no"}))))
(println "OK")
```
A string-keyed `Answer` a host round-trips through JSON is normalised the same way regardless of which constructor built it — `(client/as-answer {"id" "x" "ok" false "reason" "no"})` reads `:ok false` correctly, which is exactly the bug (#89, ADR 0026 §5) that made a granted answer look declined before normalisation existed.
## See also
[Section titled “See also”](#see-also)
* [`toolnexus.client/suspend`](/api/clojure/suspension/pending/) — Return a Pending from a tool to park the run until someone answers.
* [`answer-output`](/api/clojure/suspension/answer-output/) — The success counterpart: wraps a human’s typed string reply into an `Answer`.
* [`toolnexus.client/auth-required`](/api/clojure/suspension/auth-required/) — The auth-shaped suspension: hand back a URL, resume once the user has granted access.
* [`toolnexus.client/create-client`](/api/clojure/suspension/wait-for/) — The single hook where the host resolves a suspension — in-process prompt or durable queue, same contract.
* [`toolnexus.client/pending-of`](/api/clojure/suspension/pending-of/) — Detect that a RunResult is parked rather than finished, and get the Request that parked it.
# answer-output
> Wraps a human's typed string reply into the Answer a suspended run resumes with — the success counterpart to answerDeclined. — Clojure.
**Clojure (JVM) + cljgo** · package `net.clojars.muthuishere/toolnexus` · [SPEC §10](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [clojure/src/toolnexus/client.cljc](https://github.com/muthuishere/toolnexus/blob/main/clojure/src/toolnexus/client.cljc)
```clojure
(answer-output id output)
(answer-output id output is-error)
;; => {:id id :ok true :data {:output "…"}}
;; => {:id id :ok true :data {:output "…" :isError true}} ; is-error true
;; a non-string output THROWS — it never silently degrades to ""
(answer-output "sus-1" {:city "Chennai"})
;; => ExceptionInfo "toolnexus: Answer output must be a string"
```
Wraps a human’s typed string reply into the `Answer` a suspended run resumes with — the success counterpart to [`answer-declined`](/api/clojure/suspension/answer-declined/). It exists so a host building an `Answer` by hand never has a key to get wrong (ADR 0026): before this constructor shipped, the recognised payload shape had to be reconstructed at every call site, and a mistyped key there is silent — the loop would treat a malformed answer as an unrecognised one rather than failing loudly.
`output` **must be a string**. A non-string argument throws immediately rather than being coerced or blanked to `""`: degrading a wrong answer to an empty string is exactly how a bad payload used to reach the model quietly, with no error and no log — the class of defect ADR 0026 exists to close.
`is-error` (default `false`) sets `:isError true` in the payload, the same flag a [`ToolResult`](/api/clojure/types/tool-result/) uses to mark its own output as a failure — a host resuming a tool that itself failed while the human was answering a *different* question can carry that failure through in the same shape.
## When to use it
[Section titled “When to use it”](#when-to-use-it)
* **Resuming a `"input"`-kind suspension** where the human typed or picked something the tool needs — a city, a confirmation code, a chosen account — and you have it as a string in hand.
* **Inside a `:wait-for` hook**, to build the return value once you have resolved a human’s reply (from a chat message, a form submission, a durable queue) into a plain string.
* **Not** for an `"authorization"` suspension where the answer carries no meaningful payload — see [`auth-required`](/api/clojure/suspension/auth-required/), whose retry arity typically checks `(:ok answer)` alone and never reads `:data`.
## Why this and not the alternative
[Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative)
Use answer-declined when the human refuses, not an empty answer-output
An empty or placeholder string passed to `answer-output` still reads as `:ok true` — the tool’s retry arity will see a granted answer with vacuous data, not a refusal. If the human said no, cancelled, or the request timed out unanswered, build an `answer-declined` instead: the loop branches on `:ok` alone, and only a decline carries a `:reason` (R1) that explains why.
Hand-building `{:id id :ok true :data {:output s}}` yourself works too — `answer-output` is not a new mechanism, just the one call site that cannot typo a key or forget the string check. Prefer it over hand-rolling the map for exactly the reason ADR 0026 exists.
## Examples
[Section titled “Examples”](#examples)
### 1. The smallest useful call — answer a plain question
[Section titled “1. The smallest useful call — answer a plain question”](#1-the-smallest-useful-call--answer-a-plain-question)
```clojure
(require '[toolnexus.client :as client])
(println)
(def a (client/answer-output "sus-1" "Chennai"))
;; => {:id "sus-1" :ok true :data {:output "Chennai"}}
(assert (= {:id "sus-1" :ok true :data {:output "Chennai"}} a))
(println "OK")
```
### 2. The realistic case — inside a :wait-for hook, resolving a queued human reply
[Section titled “2. The realistic case — inside a :wait-for hook, resolving a queued human reply”](#2-the-realistic-case--inside-a-wait-for-hook-resolving-a-queued-human-reply)
```clojure
(require '[toolnexus.client :as client]
'[toolnexus.tool :as tool]
'[koine.json :as json])
(println)
(defn- calls-response [calls]
{:choices [{:message {:role "assistant" :content nil
:tool_calls (mapv (fn [c] {:id (:id c) :type "function"
:function {:name (:name c)
:arguments (json/write-str (or (:args c) {}))}})
calls)}}]
:usage {:prompt_tokens 10 :completion_tokens 5 :total_tokens 15}})
(defn- text-response [t]
{:choices [{:message {:role "assistant" :content t}}]
:usage {:prompt_tokens 10 :completion_tokens 5 :total_tokens 15}})
(def script (atom [{:calls [{:id "c1" :name "ask_city" :args {}}]}
{:text "The weather in Chennai is sunny."}]))
(defn- mock-http []
(let [n (atom 0)]
(fn [_url _headers _body]
(let [i (swap! n inc)
spec (get @script (dec i))]
{:status 200 :headers {}
:body (json/write-str (if (seq (:calls spec)) (calls-response (:calls spec)) (text-response (:text spec))))}))))
;; a suspending tool: no answer yet -> park; ctx carries the answer on retry
(def ask-city-tool
(tool/tool {:name "ask_city" :description "which city"
:execute (fn ([_args] (client/suspend (client/make-request "input" "Which city?")))
([_args ctx] (tool/success (str (get-in ctx [:answer :data :output])))))}))
(defn block-until-chat-reply! [_id] "Chennai")
(def llm
(client/create-client
{:base-url "http://mock.local" :api-key "test-key"
:style "openai"
:model "gpt-4o-mini"
:http-client (mock-http)
:wait-for (fn [request]
;; blocks until a human answers via chat, then wraps the reply
(let [reply (block-until-chat-reply! (:id request))]
(client/answer-output (:id request) reply)))}))
(def rr
(client/run llm "Which city should I check the weather for?"
{:toolkit (tool/toolkit [ask-city-tool])}))
(assert (= "done" (:status rr)))
(assert (= "The weather in Chennai is sunny." (:text rr)))
(println "OK")
```
### 3. The full surface — marking the resumed answer itself as an error result
[Section titled “3. The full surface — marking the resumed answer itself as an error result”](#3-the-full-surface--marking-the-resumed-answer-itself-as-an-error-result)
```clojure
(require '[toolnexus.client :as client])
(println)
;; the tool being resumed will see :isError true on the answer's :data,
;; so its retry arity can distinguish "granted, but the value failed validation"
;; from "granted, and the value is good"
(def a (client/answer-output "sus-2" "not-a-real-city" true))
;; => {:id "sus-2" :ok true :data {:output "not-a-real-city" :isError true}}
(assert (= {:id "sus-2" :ok true :data {:output "not-a-real-city" :isError true}} a))
;; a non-string output is refused rather than silently coerced
(def refused
(try
(client/answer-output "sus-3" 42)
(catch Throwable e
(ex-message e))))
;; => "toolnexus: Answer output must be a string"
(assert (= "toolnexus: Answer output must be a string" refused))
(println "OK")
```
## See also
[Section titled “See also”](#see-also)
* [`toolnexus.client/suspend`](/api/clojure/suspension/pending/) — Return a Pending from a tool to park the run until someone answers.
* [`answer-declined`](/api/clojure/suspension/answer-declined/) — The refusal counterpart: wraps a human’s decline into an `Answer` carrying a `:reason`.
* [`toolnexus.client/auth-required`](/api/clojure/suspension/auth-required/) — The auth-shaped suspension: hand back a URL, resume once the user has granted access.
* [`toolnexus.client/create-client`](/api/clojure/suspension/wait-for/) — The single hook where the host resolves a suspension — in-process prompt or durable queue, same contract.
* [`toolnexus.client/pending-of`](/api/clojure/suspension/pending-of/) — Detect that a RunResult is parked rather than finished, and get the Request that parked it.
# toolnexus.client/auth-required
> The auth-shaped suspension: hand back a URL, resume once the user has granted access. — Clojure.
**Clojure (JVM) + cljgo** · package `net.clojars.muthuishere/toolnexus` · [SPEC §10](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [clojure/src/toolnexus/client.cljc](https://github.com/muthuishere/toolnexus/blob/main/clojure/src/toolnexus/client.cljc)
```clojure
(auth-required url)
(auth-required url prompt)
;; => {:output "Login required: "
;; :isError true
;; :metadata {:pending {:id "sus-…" :kind "authorization"
;; :prompt "" :url ""}}}
```
`auth-required` is [`suspend`](/api/clojure/suspension/pending/) with the authorization shape filled in: `kind` is `"authorization"`, the URL rides on the `Request`’s `:url`, and the transcript output is `"Login required: "` whatever prompt you pass. One argument gives you a default prompt of the same text; two lets you say something the user will actually understand.
The `"authorization"` kind is the one case where the answer’s payload is irrelevant. The host’s job is to get the human to the URL; when they come back and the answer is `ok`, the world changed out of band — the session is simply valid now — and your tool re-runs and succeeds without reading `answer.data`. That is why the retry arity typically only checks `(get-in ctx [:answer :ok])`.
The URL is the whole point: a chat front end renders it as a link, a CLI prints it, a web app redirects to it. Everything else about the suspension is identical to any other.
## When to use it
[Section titled “When to use it”](#when-to-use-it)
* **A token expired mid-run** and the user must re-consent — OAuth, an SSO session, a device-code flow.
* **A tool hits 401 or 403** against an upstream API it can authorize interactively.
* **Not for a missing server-side key** — if your process is misconfigured, no human click fixes it. Return `toolnexus.tool/failure` and let the run end honestly.
## Why this and not the alternative
[Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative)
Use suspend directly when the answer carries data
`auth-required` is a shorthand, not a separate mechanism. If you need the human to supply a value — a workspace to connect, an account to pick, a scope to grant — build the request yourself with `make-request` of kind `"input"`, passing `:url` and a `:data` schema, so the answer’s `data` has somewhere to land. Reach for `auth-required` when “they logged in” is the entire answer.
Both produce the same `ToolResult` shape, so a host that handles one handles the other.
## Examples
[Section titled “Examples”](#examples)
### A tool that needs a live session
[Section titled “A tool that needs a live session”](#a-tool-that-needs-a-live-session)
```clojure
(require '[toolnexus.tool :as tool]
'[toolnexus.client :as client])
(def list-repos
(tool/tool
{:name "list_repos"
:description "List the user's repositories"
:execute
(fn ([_args]
(if (session-valid?)
(tool/success (fetch-repos!))
(client/auth-required "https://example.com/oauth/authorize?scope=repo"
"Connect your GitHub account to continue")))
([_args ctx]
;; the retry: the answer carries no payload — the session is simply valid now
(if (get-in ctx [:answer :ok])
(tool/success (fetch-repos!))
(tool/failure "still not connected"))))}))
```
Re-check the session in the retry arity rather than trusting `ok` blindly if a failed login is plausible in your flow; the loop will not call you a third time either way.
### Pushing the link to the user, then resolving it
[Section titled “Pushing the link to the user, then resolving it”](#pushing-the-link-to-the-user-then-resolving-it)
The `pending` event fires **before** `:wait-for` runs, which is what lets a front end show the link at the moment the tool parks rather than after the human is already waiting.
```clojure
(def llm
(client/create-client
{:base-url "https://api.anthropic.com"
:style "anthropic"
:model "claude-sonnet-4-5"
;; blocks the loop until the human comes back
:wait-for (fn [request]
(if (= "authorization" (:kind request))
(let [ok? (block-until-oauth-callback! (:id request) (:url request))]
(client/make-answer (:id request) ok? nil
(when-not ok? "the user cancelled")))
(client/make-answer (:id request) false nil "unsupported")))}))
(client/run llm "Which of my repos have failing builds?"
{:toolkit (tool/toolkit [list-repos])
:on-event (fn [ev]
(when (= "pending" (:type ev))
(send-to-chat! (str (get-in ev [:request :prompt]) "\n"
(get-in ev [:request :url])))))})
```
A declined answer is not a crash: the loop feeds `"declined/expired: "` back to the model as an error result and keeps going, so the model can explain the situation instead of the run dying.
### Handling it without blocking the run
[Section titled “Handling it without blocking the run”](#handling-it-without-blocking-the-run)
Omit `:wait-for` and the run returns as soon as the tool parks, with everything needed to resume later.
```clojure
(let [r (client/run llm "Which repos have failing builds?"
{:toolkit (tool/toolkit [list-repos])})]
(when (= "pending" (:status r))
(let [req (:pending r)]
(queue-for-human! {:id (:id req) ; correlate the eventual answer
:kind (:kind req) ; "authorization"
:url (:url req) ; where to send them
:prompt (:prompt req)})
;; :messages holds the transcript up to and including the parked call
(persist-transcript! (:messages r)))))
```
There is no cross-process resume in this port: when the human is done, start a **new** run with the saved `:messages` as `:history` and a `:wait-for` that now answers immediately. See [resume](/api/clojure/suspension/resume/) for the shape of that gap.
## Arities
[Section titled “Arities”](#arities)
| Call | `prompt` | Output written to the transcript |
| ---------------------------- | ------------------------- | -------------------------------- |
| `(auth-required url)` | `"Login required: "` | `"Login required: "` |
| `(auth-required url prompt)` | your text | `"Login required: "` |
The output is fixed on purpose: the model should see a consistent, unmistakable marker, while the prompt is what a human reads.
## See also
[Section titled “See also”](#see-also)
* [`toolnexus.client/suspend`](/api/clojure/suspension/pending/) — Return a Pending from a tool to park the run until someone answers.
* [`toolnexus.client/create-client`](/api/clojure/suspension/wait-for/) — The single hook where the host resolves a suspension — in-process prompt or durable queue, same contract.
* [`toolnexus.client/pending-of`](/api/clojure/suspension/pending-of/) — Detect that a RunResult is parked rather than finished, and get the Request that parked it.
# toolnexus.client/suspend
> Return a Pending from a tool to park the run until someone answers. — Clojure.
**Clojure (JVM) + cljgo** · package `net.clojars.muthuishere/toolnexus` · [SPEC §10](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [clojure/src/toolnexus/client.cljc](https://github.com/muthuishere/toolnexus/blob/main/clojure/src/toolnexus/client.cljc)
```clojure
(suspend request) ; output defaults to the request's :prompt
(suspend request output)
;; => {:output "…" :isError true :metadata {:pending request}}
(make-request kind prompt)
(make-request kind prompt opts) ; :url, :data, :expiresAt
;; => {:id "sus-…" :kind "input" :prompt "Which city?"
;; :url "…" :data {…} :expiresAt "2026-01-01T00:00:00Z"}
;; absent optionals are OMITTED, never emitted as null
(new-request-id) ; => "sus--", unique per suspension
```
A suspension is not a new return type. It is an ordinary `ToolResult` whose `metadata.pending` holds a `Request` — that is the whole mechanism, and it is why `execute`’s signature never changed. Any tool can suspend at any time, and a toolkit, an adapter or a host that knows nothing about §10 still sees a well-formed result.
`make-request` builds the `Request`. Its keys are **pinned across every port** because they cross the wire: `id`, `kind`, `prompt`, and the optional `url`, `data` and `expiresAt`. That is why `expiresAt` is camelCase in a kebab-case language — `koine.json/write-str` emits a keyword’s name verbatim, so the keyword *is* the wire key, and renaming it here would be exactly the cross-port drift the ports exist to prevent.
`kind` is a free string and the loop never branches on it; hosts do. The four in use across the ports are `"authorization"` (see [`auth-required`](/api/clojure/suspension/auth-required/)), `"input"`, `"approval"` and `"question"` — the last being what the `question` builtin emits.
What happens next depends entirely on the client. With [`:wait-for`](/api/clojure/suspension/wait-for/) configured the loop resolves the request and re-executes your tool once with the answer in Context. Without it the run returns cleanly with `:status "pending"` and the `Request` on `:pending`.
## When to use it
[Section titled “When to use it”](#when-to-use-it)
* **A tool needs something only a human has** — an approval, a value the model cannot know, a choice between options.
* **A credential expired mid-run** — reach for [`auth-required`](/api/clojure/suspension/auth-required/), which is `suspend` with the authorization shape pre-filled.
* **Not for a failure** — if the tool simply could not do its job, return `toolnexus.tool/failure`. The model sees that and adapts; a suspension asks a *person* for something.
## Why this and not the alternative
[Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative)
Return failure when nobody needs to be asked
`toolnexus.tool/failure` is for “this did not work” — bad arguments, a 404, a parse error. The model reads it and tries something else, and the run never leaves the loop. `suspend` is for “this cannot proceed until a human acts”, and it either blocks the run in `:wait-for` or ends it with `:status "pending"`. Using a suspension where a failure would do turns a recoverable step into a stall.
A suspension’s `:isError` is `true` on purpose: to a host that does not implement §10 — a plain adapter, an older port, a debug dump — the result still reads as “this call did not produce a value”, which is the truthful degradation.
## Examples
[Section titled “Examples”](#examples)
### A tool that asks for a value
[Section titled “A tool that asks for a value”](#a-tool-that-asks-for-a-value)
The tool must be **multi-arity**. `toolnexus.tool/execute` calls `(f args)` when there is no Context and `(f args ctx)` when there is one, so the first execution and the post-answer retry land on different arities of the same function.
```clojure
(require '[toolnexus.tool :as tool]
'[toolnexus.client :as client]
'[koine.json :as json])
(def book-flight
(tool/tool
{:name "book_flight"
:description "Book a flight for the user"
:execute
(fn ([_args]
;; first call: we do not know the destination, so ask
(client/suspend (client/make-request "input" "Which city?")
"Input required: Which city?"))
([args ctx]
;; the retry: the answer is in Context
(let [city (get-in ctx [:answer :data :city])]
(tool/success (str "booked " (:flight args) " to " city)))))}))
```
The second argument to `suspend` is the text that goes into the transcript. Default it to the prompt unless the model benefits from something more explicit.
### Carrying a schema so a generic host can render the prompt
[Section titled “Carrying a schema so a generic host can render the prompt”](#carrying-a-schema-so-a-generic-host-can-render-the-prompt)
`:data` is yours. The convention across the ports is that `data.schema` holds a JSON Schema for the expected answer, which lets a web front end or a chat bot build a form without knowing what your tool does.
```clojure
(require '[koine.time :as ktime])
(def city-schema
{:type "object"
:properties {:city {:type "string"}}
:required ["city"]})
(client/suspend
(client/make-request "input" "Which city?"
{:data {:schema city-schema}
;; RFC3339; a host may drop the request once it passes
:expiresAt (ktime/iso-str (+ (ktime/now-ms) 300000))})
"Input required: Which city?")
;; => {:output "Input required: Which city?"
;; :isError true
;; :metadata {:pending {:id "sus-1754006400000-1"
;; :kind "input"
;; :prompt "Which city?"
;; :data {:schema {…}}
;; :expiresAt "2026-08-01T00:05:00Z"}}}
```
`:expiresAt` is advisory — the loop does not enforce it. It is there so a host queueing requests for a human can expire one instead of holding a run open forever.
### An approval gate, end to end
[Section titled “An approval gate, end to end”](#an-approval-gate-end-to-end)
```clojure
(def deploy
(tool/tool
{:name "deploy"
:description "Deploy the current branch to an environment"
:execute
(fn ([args]
(if (= "production" (:env args))
(client/suspend
(client/make-request "approval"
(str "Deploy " (:branch args) " to production?")
{:data {:branch (:branch args) :env (:env args)}}))
(tool/success (str "deployed to " (:env args)))))
([args ctx]
(if (get-in ctx [:answer :ok])
(tool/success (str "deployed " (:branch args) " to production"))
;; the loop already turns a declined answer into an error result;
;; reaching here means it was approved
(tool/failure "approval missing"))))}))
;; Without :wait-for the run returns instead of blocking:
(let [r (client/run llm "Ship release/1.4 to production"
{:toolkit (tool/toolkit [deploy])})]
(when (= "pending" (:status r))
(println (get-in r [:pending :kind])) ;; "approval"
(println (get-in r [:pending :prompt])) ;; "Deploy release/1.4 to production?"
(println (get-in r [:pending :id])))) ;; correlate the human's answer with this
```
If several tools in the same turn suspend, the **first in tool-call order** wins and the later ones never enter the transcript. That makes concurrent suspensions deterministic rather than a function of thread scheduling, and the ones that were dropped simply suspend again on the next run.
### The Request
[Section titled “The Request”](#the-request)
| Key | Required | What it is |
| ------------ | -------- | ---------------------------------------------------------------------------------- |
| `:id` | yes | Correlation key, `"sus--"` unless you pass `:id` in `opts`. |
| `:kind` | yes | `"authorization"` · `"input"` · `"approval"` · `"question"` — a host-facing label. |
| `:prompt` | yes | What to show the human. Also the transcript placeholder on a durable halt. |
| `:url` | no | Where the human must go. Set by `auth-required`. |
| `:data` | no | Arbitrary payload; `data.schema` is the convention for the answer’s shape. |
| `:expiresAt` | no | RFC3339. Advisory — the loop does not enforce it. |
## See also
[Section titled “See also”](#see-also)
* [`toolnexus.client/auth-required`](/api/clojure/suspension/auth-required/) — The auth-shaped suspension: hand back a URL, resume once the user has granted access.
* [`toolnexus.client/create-client`](/api/clojure/suspension/wait-for/) — The single hook where the host resolves a suspension — in-process prompt or durable queue, same contract.
* [`toolnexus.client/pending-of`](/api/clojure/suspension/pending-of/) — Detect that a RunResult is parked rather than finished, and get the Request that parked it.
* [`toolnexus.client/run`](/api/clojure/client/run/) — where a suspension becomes `:status "pending"` or a `pending` event.
# toolnexus.client/pending-of
> Detect that a RunResult is parked rather than finished, and get the Request that parked it. — Clojure.
**Clojure (JVM) + cljgo** · package `net.clojars.muthuishere/toolnexus` · [SPEC §10](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [clojure/src/toolnexus/client.cljc](https://github.com/muthuishere/toolnexus/blob/main/clojure/src/toolnexus/client.cljc)
```clojure
(pending-of result) ; result is a ToolResult
;; => the §10 Request iff this result is a suspension, else nil
;; equivalent to (get-in result [:metadata :pending])
```
`pending-of` is the reader half of [`suspend`](/api/clojure/suspension/pending/). Hand it a `ToolResult` and it returns the `Request` buried in `metadata.pending`, or `nil` for an ordinary result. That is the entire function, and it is the only supported way to ask “did this suspend?” — `:isError` is `true` on a suspension as well as on a plain failure, so it cannot answer the question.
Note the argument type. `pending-of` takes a **ToolResult**, not a RunResult. On a `RunResult` the parked request is already a top-level key: check `(= "pending" (:status r))` and read `(:pending r)`. Use `pending-of` when you are holding a tool’s own return value — inside a wrapper, in a test, or when calling a tool directly without the client loop.
## When to use it
[Section titled “When to use it”](#when-to-use-it)
* **Wrapping a tool** — a decorator that logs, retries or vetoes must not treat a suspension as a failure; `pending-of` is how it tells them apart.
* **Testing a tool in isolation** — call `:execute` yourself and assert on the `Request` without standing up a client or a fake LLM.
* **Executing tools without the client loop** — if you drive your own provider calls, this is how you detect that a result needs a human before you feed it back to the model.
## Why this and not the alternative
[Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative)
On a RunResult, read :status and :pending instead
`run` has already done this work: a parked run comes back with `:status "pending"` and the `Request` on `:pending`, and its `:text` is the unanswered prompt. Calling `pending-of` on a RunResult returns `nil` — the key is not nested under `:metadata` there. Reach for `pending-of` one level down, where tool results live.
Reading `(get-in result [:metadata :pending])` by hand does the same thing, but the accessor is the part of the contract other ports also expose, and it keeps the key path in one place.
## Examples
[Section titled “Examples”](#examples)
### Testing a suspending tool without a client
[Section titled “Testing a suspending tool without a client”](#testing-a-suspending-tool-without-a-client)
```clojure
(require '[toolnexus.tool :as tool]
'[toolnexus.client :as client])
(def result ((:execute book-flight) {:flight "AI-503"}))
(client/pending-of result)
;; => {:id "sus-1754006400000-1" :kind "input" :prompt "Which city?"
;; :data {:schema {:type "object" …}}}
(:isError result) ;; => true — a suspension always reads as "no value produced"
(:output result) ;; => "Input required: Which city?"
;; an ordinary result has no pending
(client/pending-of (tool/success "plain")) ;; => nil
```
Asserting on the `Request` directly is the cheapest test of a suspending tool: no server, no model, no loop.
### A wrapper that must not mistake a suspension for a failure
[Section titled “A wrapper that must not mistake a suspension for a failure”](#a-wrapper-that-must-not-mistake-a-suspension-for-a-failure)
```clojure
(defn retried
"Retry a tool once on failure — but never on a suspension, which is not a failure."
[t]
(update t :execute
(fn [f]
(fn
([args]
(let [r (f args)]
(if (and (:isError r) (nil? (client/pending-of r)))
(f args) ; a real error: one more go
r))) ; success, or a parked request: hand it straight back
([args ctx] (f args ctx))))))
```
Retrying a suspension would ask the human the same question twice and discard the first `Request` id, which is exactly the bug this accessor exists to prevent.
### Driving tools yourself, with no client loop
[Section titled “Driving tools yourself, with no client loop”](#driving-tools-yourself-with-no-client-loop)
```clojure
(require '[toolnexus.core :as tn])
(defn call-tool [toolkit name args]
(let [r (tn/execute toolkit name args)]
(if-let [req (client/pending-of r)]
;; park it: the model gets the placeholder, the human gets the prompt
{:for-model (:output r)
:for-human (select-keys req [:id :kind :prompt :url :data :expiresAt])}
{:for-model (:output r)})))
(call-tool toolkit "list_repos" {})
;; => {:for-model "Login required: https://example.com/oauth/authorize"
;; :for-human {:id "sus-…" :kind "authorization"
;; :prompt "Connect your GitHub account to continue"
;; :url "https://example.com/oauth/authorize"}}
```
Once the human answers, re-execute the same tool with the same args and the answer in Context — `(tn/execute toolkit name args {:answer answer})` — which is precisely what the client loop does internally.
### RunResult vs ToolResult
[Section titled “RunResult vs ToolResult”](#runresult-vs-toolresult)
| You hold | Suspension check | The Request |
| -------------------------------------- | ------------------------------- | -------------------------------------------- |
| A `ToolResult` (a tool’s return value) | `(some? (client/pending-of r))` | `(client/pending-of r)` |
| A `RunResult` (from `run`) | `(= "pending" (:status r))` | `(:pending r)` |
| A `tool_result` event from `:on-event` | not exposed | use the earlier `pending` event’s `:request` |
## See also
[Section titled “See also”](#see-also)
* [`toolnexus.client/suspend`](/api/clojure/suspension/pending/) — Return a Pending from a tool to park the run until someone answers.
* [`toolnexus.client/auth-required`](/api/clojure/suspension/auth-required/) — The auth-shaped suspension: hand back a URL, resume once the user has granted access.
* [`toolnexus.client/create-client`](/api/clojure/suspension/wait-for/) — The single hook where the host resolves a suspension — in-process prompt or durable queue, same contract.
* [`toolnexus.client/run`](/api/clojure/client/run/) — where `:status "pending"` and `:pending` come from.
# Relay tools — declaration-only
> Declare a tool the host executes, not the library: the call rides out on the suspension. — Clojure.
**Clojure (JVM) + cljgo** · package `net.clojars.muthuishere/toolnexus` · [SPEC §10](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [clojure/src/toolnexus/client.cljc](https://github.com/muthuishere/toolnexus/blob/main/clojure/src/toolnexus/client.cljc)
Not available in Clojure
There is no relay tool in this port. The SPEC §10 addendum landed as a Go-only preview (`golang/relay.go`); none of the other six ports ship one, and neither does Clojure.
Declare a tool the host executes, not the library: the call rides out on the suspension.
## What to use instead
[Section titled “What to use instead”](#what-to-use-instead)
Build the tool normally and have its `:execute` suspend instead of doing the work. The arguments the model supplied are already in your hands, so putting them on the `Request`’s `:data` gives you the same thing a relay does — a declared, schema’d tool whose execution happens somewhere else.
```clojure
(require '[toolnexus.tool :as tool]
'[toolnexus.client :as client]
'[koine.json :as json])
(defn relayed
"A tool the model can call but this process cannot run: the call is handed
out on the suspension and the answer comes back as the result."
[nm description schema]
(tool/tool
{:name nm
:description description
:input-schema schema
:execute
(fn ([args]
;; nothing is executed here — the call itself is the payload
(client/suspend
(client/make-request "input" (str "Execute " nm " on the device")
{:data {:tool nm :args args :schema schema}})
(str "Awaiting host execution of " nm)))
([_args ctx]
;; whatever the host ran, verbatim
(tool/success (json/write-str (get-in ctx [:answer :data])))))}))
(def device-toolkit
(tool/toolkit
[(relayed "read_sensor" "Read a sensor on the paired device"
{:type "object"
:properties {:sensor {:type "string"}}
:required ["sensor"]})]))
```
The host side is the `:wait-for` slot, or the `"pending"` outcome if the executor is out of process:
```clojure
(def llm
(client/create-client
{:base-url "https://api.openai.com/v1"
:model "gpt-4.1"
:wait-for (fn [request]
(let [{:keys [tool args]} (:data request)]
;; ship the call to whoever can actually run it, block for the reply
(client/make-answer (:id request) true
(invoke-on-device! tool args))))}))
(client/run llm "What is the boiler temperature?" {:toolkit device-toolkit})
```
Two differences from a real relay are worth knowing before you rely on this. The tool still needs a local `:execute`, so the declaration is not quite free — that is a few lines, not a design problem. More importantly, only the **first** suspension in a turn survives when there is no `:wait-for`: later ones are dropped from the transcript and re-suspend on the next run. With `:wait-for` set, every suspension in the turn resolves, sequentially in call order, so a batch of relayed calls works — but a fire-and-forget host that relies on collecting several parked calls at once will only ever see one.
## See also
[Section titled “See also”](#see-also)
* [`toolnexus.client/suspend`](/api/clojure/suspension/pending/) — Return a Pending from a tool to park the run until someone answers.
* [`toolnexus.client/auth-required`](/api/clojure/suspension/auth-required/) — The auth-shaped suspension: hand back a URL, resume once the user has granted access.
* [`toolnexus.client/create-client`](/api/clojure/suspension/wait-for/) — The single hook where the host resolves a suspension — in-process prompt or durable queue, same contract.
* [`toolnexus.client/pending-of`](/api/clojure/suspension/pending-of/) — Detect that a RunResult is parked rather than finished, and get the Request that parked it.
# AgentRuntime.resume — resume a suspended sub-agent
> Route an Answer to the deepest suspended sub-agent handle and resume it in place. — Clojure.
**Clojure (JVM) + cljgo** · package `net.clojars.muthuishere/toolnexus` · [SPEC §7D](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [clojure/src/toolnexus/client.cljc](https://github.com/muthuishere/toolnexus/blob/main/clojure/src/toolnexus/client.cljc)
Not available in Clojure
This port has no sub-agent runtime to resume into — there is no agent runtime, no sub-agent handle and no `resume`. A suspension is resolved **in process** by the `:wait-for` slot on `toolnexus.client/create-client`. Durable, cross-process resume is recorded parity debt; Go additionally ships its own SPEC §10 preview for it, outside the §0 conformance contract.
Route an Answer to the deepest suspended sub-agent handle and resume it in place.
## What to use instead
[Section titled “What to use instead”](#what-to-use-instead)
Resume the run yourself from the transcript. `run` hands back everything needed: when no `:wait-for` is configured, a suspension ends the run cleanly with `:status "pending"`, the `Request` on `:pending`, and `:messages` holding every call up to and including the parked one. Persist those two things, and when the human answers, start a **new** run whose `:wait-for` already knows the answer.
```clojure
(require '[toolnexus.client :as client])
;; --- pass 1: no :wait-for, so the run returns instead of blocking -------------
(def llm (client/create-client {:base-url "https://api.anthropic.com"
:style "anthropic"
:model "claude-sonnet-4-5"}))
(let [r (client/run llm "Deploy release/1.4 to production" {:toolkit toolkit})]
(when (= "pending" (:status r))
(save-job! "job-77" {:request (:pending r) ; id, kind, prompt, url, data
:messages (:messages r)}) ; the transcript so far
(ask-the-human! (:pending r))))
;; --- pass 2: minutes or days later, in a different process -------------------
(defn resume-job! [job-id answer-data approved?]
(let [{:keys [request messages]} (load-job! job-id)
;; a :wait-for that answers the ONE request this job is parked on
resumed (client/create-client
{:base-url "https://api.anthropic.com"
:style "anthropic"
:model "claude-sonnet-4-5"
:wait-for (fn [req]
(if (= (:id req) (:id request))
(client/make-answer (:id req) approved? answer-data
(when-not approved? "declined by operator"))
;; a NEW suspension in this pass: park it again
(client/make-answer (:id req) false nil "not this job's request")))})]
(client/run resumed "Continue." {:toolkit toolkit :history messages})))
```
Two things make this work rather than merely look like it works. The transcript in `:messages` is the provider’s own message shape, so it goes straight back in as `:history` with no translation. And the model re-issues the tool call on the new run, at which point the tool suspends again with a **fresh** request id — which is why the `:wait-for` above compares against the saved request and declines anything else rather than answering blindly.
The cost, stated plainly: the second pass replays the turn rather than continuing mid-turn, so you pay for the prompt again and any side effects your tools performed before the parked call are not repeated only because the transcript records them as done. Keep tools that run alongside a suspending one idempotent.
If the human is reachable from the same thread, none of this is necessary — configure [`:wait-for`](/api/clojure/suspension/wait-for/) on the first client and the loop blocks, resolves, and re-executes the tool once, in place.
## See also
[Section titled “See also”](#see-also)
* [`toolnexus.client/suspend`](/api/clojure/suspension/pending/) — Return a Pending from a tool to park the run until someone answers.
* [`toolnexus.client/auth-required`](/api/clojure/suspension/auth-required/) — The auth-shaped suspension: hand back a URL, resume once the user has granted access.
* [`toolnexus.client/create-client`](/api/clojure/suspension/wait-for/) — The single hook where the host resolves a suspension — in-process prompt or durable queue, same contract.
* [`toolnexus.client/pending-of`](/api/clojure/suspension/pending-of/) — Detect that a RunResult is parked rather than finished, and get the Request that parked it.
# toolnexus.client/create-client
> The single hook where the host resolves a suspension — in-process prompt or durable queue, same contract. — Clojure.
**Clojure (JVM) + cljgo** · package `net.clojars.muthuishere/toolnexus` · [SPEC §10](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [clojure/src/toolnexus/client.cljc](https://github.com/muthuishere/toolnexus/blob/main/clojure/src/toolnexus/client.cljc)
```clojure
(create-client {:base-url "…" :model "…"
:wait-for (fn [request] answer)}) ; the ONE host slot of SPEC §10
(make-answer id ok)
(make-answer id ok data)
(make-answer id ok data reason)
;; => {:id "sus-…" :ok true :data {…} :reason "…"}
;; :data only when non-nil; :reason ONLY when ok is false
```
`:wait-for` is a plain, blocking `(fn [request] answer)`. The loop calls it when a tool suspends, hands it the `Request`, and expects an `Answer` back. What happens in between is entirely yours: read a line from a terminal, post to Slack and poll, park a row in Postgres and block a worker thread — the contract is the same in all three cases.
There is no channel, no `core.async` and no executor anywhere in this rule. §10’s async framing assumes a function-colouring problem Clojure does not have, and adding one would buy nothing.
The three outcomes are fixed. An `ok` answer re-executes the **same tool with the same args exactly once**, with `Context.answer` set, and that result is what the model sees. A not-ok answer feeds `"declined/expired: "` back as an error result and the loop continues — the model decides what to do about it. If the retry suspends *again*, the loop writes `"unresolved: "` rather than looping forever on one request.
Resolution is sequential, in tool-call order, even though the tools themselves ran in parallel. That is what makes concurrent suspensions deterministic instead of a function of thread scheduling.
Why not \`await\`
SPEC §10 names this slot “never `await`” because `await` is reserved in JavaScript, Python and C#. In Clojure it is not reserved — it is `clojure.core/await`, and it exists on **both** hosts, so defining one would shadow a core name, and cljgo’s static interop scan can reject a whole namespace for it. Same answer, sharper teeth.
## When to use it
[Section titled “When to use it”](#when-to-use-it)
* **Any run where a tool may need a human** — without it, the first suspension ends the run with `:status "pending"`.
* **An interactive session** — a CLI, a chat bot, an operator console, where blocking the run while a person answers is exactly the desired behaviour.
* **Not in a request handler with a short timeout** — a blocking `:wait-for` holds the run open for as long as the human takes. Omit it there and handle `"pending"` yourself.
## Why this and not the alternative
[Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative)
Omit it when the human is not on the other end of this call
With no `:wait-for` the run returns cleanly with `:status "pending"` and the `Request` on `:pending`, having recorded every tool call up to and including the parked one. That is the right shape for a web request, a queue worker, or anything where the answer arrives minutes later through a different process. `:wait-for` is for when the answering party is reachable *now*, from this thread.
Whichever you pick, the tool is written once. It suspends, and the client decides whether that becomes a block or a return.
## Examples
[Section titled “Examples”](#examples)
### Asking on the terminal
[Section titled “Asking on the terminal”](#asking-on-the-terminal)
```clojure
(require '[toolnexus.client :as client])
(defn console-wait-for [request]
(println)
(println (:prompt request))
(when-let [url (:url request)] (println " ->" url))
(let [line (read-line)]
(case (:kind request)
"authorization" (client/make-answer (:id request) true)
"approval" (if (= "y" (clojure.string/lower-case (str line)))
(client/make-answer (:id request) true)
(client/make-answer (:id request) false nil "declined at the console"))
;; "input" / "question": the answer IS the payload
(client/make-answer (:id request) true {:city line}))))
(def llm (client/create-client {:base-url "https://api.openai.com/v1"
:model "gpt-4.1"
:wait-for console-wait-for}))
(client/run llm "Book me a flight tomorrow" {:toolkit toolkit})
```
Note `:reason` — it is only populated when `ok` is false, and the loop branches on `ok` alone and never reads it. It exists for your own logs and for whatever you show the user.
### Answering out of process, with a deadline
[Section titled “Answering out of process, with a deadline”](#answering-out-of-process-with-a-deadline)
`:wait-for` is where a durable queue plugs in. It blocks the run, but the *answer* can come from anywhere.
```clojure
(require '[koine.time :as ktime])
(defn queued-wait-for
"Park the request for a human and poll until they answer or the deadline passes."
[request]
(enqueue-for-human! request) ; e.g. a DB row, a Slack message
(let [deadline (+ (ktime/now-ms) 120000)]
(loop []
(if-let [reply (poll-answer! (:id request))]
(client/make-answer (:id request) (:approved? reply) (:data reply)
(when-not (:approved? reply) (:note reply)))
(if (< (ktime/now-ms) deadline)
(do (ktime/sleep! 1000) (recur))
;; a timeout is a not-ok answer, not a throw — the model sees it and moves on
(client/make-answer (:id request) false nil "timed out waiting for a human"))))))
(def llm (client/create-client {:base-url "https://api.anthropic.com"
:style "anthropic"
:model "claude-sonnet-4-5"
:wait-for queued-wait-for}))
```
Correlate on `(:id request)` — it is unique per suspension and is the only thing tying a human’s click to the run that is waiting for it. Respect `:expiresAt` if the request carries one; the loop does not enforce it.
### Routing by kind, and seeing every path
[Section titled “Routing by kind, and seeing every path”](#routing-by-kind-and-seeing-every-path)
```clojure
(defn router [request]
(case (:kind request)
;; the world changed out of band; the answer carries no payload
"authorization" (client/make-answer (:id request) (open-browser-and-wait! (:url request)))
;; the answer IS the payload; `data.schema` tells you what shape to collect
"input" (client/make-answer (:id request) true
(collect-form! (get-in request [:data :schema])))
;; the `question` builtin's shape: one answer per question
"question" (client/make-answer (:id request) true
{:answers (ask-each! (get-in request [:data :questions]))})
"approval" (client/make-answer (:id request) (approve? (:prompt request)))
;; unknown kinds decline rather than guess
(client/make-answer (:id request) false nil (str "unsupported kind: " (:kind request)))))
(def llm (client/create-client {:base-url base-url :model "gpt-4.1" :api-key-env "LLM_API_KEY" :wait-for router}))
(let [r (client/run llm "Ship it" {:toolkit toolkit})]
;; with :wait-for set, a run never ends in "pending" — it resolves or reports
(doseq [c (:tool-calls r)]
(println (:name c) "->" (:output c))))
```
Three outputs tell you which path a call took: your tool’s own result (resolved), `"declined/expired: "` (not ok), and `"unresolved: "` (the retry suspended again).
### The Answer
[Section titled “The Answer”](#the-answer)
| Key | Required | Notes |
| --------- | -------- | -------------------------------------------------------------------------------- |
| `:id` | yes | Must match the `Request`’s `:id`. |
| `:ok` | yes | A JSON boolean. The loop branches on this alone. |
| `:data` | no | The payload the retry reads as `(get-in ctx [:answer :data])`. Omitted when nil. |
| `:reason` | no | Populated **only** when `ok` is false. Never read by the loop. |
### The three outcomes
[Section titled “The three outcomes”](#the-three-outcomes)
| `:wait-for` | Answer | What the model sees |
| ----------- | ---------------------------------- | -------------------------------------------------------------------- |
| set | `ok` | Your tool’s result from a single re-execution with `Context.answer`. |
| set | `ok`, but the retry suspends again | `"unresolved: "`, `isError` true. |
| set | not ok | `"declined/expired: "`, `isError` true. |
| unset | — | The run ends: `:status "pending"`, `:pending` is the `Request`. |
## `:wait-for-ctx` — a pending ask that ends with its turn (0.22.0)
[Section titled “:wait-for-ctx — a pending ask that ends with its turn (0.22.0)”](#wait-for-ctx--a-pending-ask-that-ends-with-its-turn-0220)
`:wait-for` cannot tell that the turn it is blocking was cancelled. `:wait-for-ctx` is the same slot with the turn’s context first: `(fn [ctx request] answer)`, where `ctx` is `{:cancel :cancelled? :call-id :turn}`. It wins over `:wait-for` when both are set. Pass a `:cancel` token to `run` / `ask` (an atom that is cancelled once it holds a truthy value, or a 0-arg fn); throw from the resolver when `(:cancelled? ctx)` says so and the ask ends with the turn.
```clojure
(def cancel (atom nil))
(def llm (client/create-client
{:base-url "https://api.openai.com/v1" :model "gpt-4.1"
:wait-for-ctx (fn [ctx request]
(loop []
(cond
((:cancelled? ctx)) (throw (ex-info "turn cancelled" {}))
(realized? answer-promise) @answer-promise
:else (do (ktime/sleep! 50) (recur)))))}))
(future (client/run llm "Deploy" {:toolkit toolkit :cancel cancel}))
(reset! cancel "user pressed stop") ; the pending ask ends, the run returns the cancellation
```
Cancellation is cooperative: it is checked before each model call and after each batch of tool calls, so an HTTP call already in flight is not aborted.
## See also
[Section titled “See also”](#see-also)
* [`toolnexus.client/suspend`](/api/clojure/suspension/pending/) — Return a Pending from a tool to park the run until someone answers.
* [`toolnexus.client/auth-required`](/api/clojure/suspension/auth-required/) — The auth-shaped suspension: hand back a URL, resume once the user has granted access.
* [`toolnexus.client/pending-of`](/api/clojure/suspension/pending-of/) — Detect that a RunResult is parked rather than finished, and get the Request that parked it.
* [`toolnexus.client/create-client`](/api/clojure/client/create/) — the rest of the client’s options.
# toolnexus.core/build
> Collapse MCP servers, skills, built-ins, A2A agents and your own functions into one tool list. — Clojure.
**Clojure (JVM) + cljgo** · package `net.clojars.muthuishere/toolnexus` · [SPEC §4](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [clojure/src/toolnexus/core.cljc](https://github.com/muthuishere/toolnexus/blob/main/clojure/src/toolnexus/core.cljc)
```clojure
(toolnexus.core/build
{:mcp "…mcp.json…" ; a JSON string or an already-parsed map (§2/§0.3)
:skills "./skills" ; a root, a seq of roots, or a seq of skill defs (§3)
:skill-defs [{:name "triage" :description "…" :content "…"}] ; skills as data (§3 S1)
:skill-provider (fn [] [{...}]) ; a 0-arg fn producing more defs (§3 S1)
:skills-filter {"triage" true} ; name -> bool over the catalog (§3 S2)
:skill-sample-limit 0 ; 0 default · n>0 cap · -1 no (§3 S5)
:disable-skills ["legacy"] ; sugar over a :skills-filter drop-list
:builtins {} ; false | {:disabled ..} | {:enabled ..} | {:tools {..}}
:agents [{:card "…/agent-card.json" :timeout 3000}] ; remote A2A (§7A)
:tools [a-tool] ; extra Tools of your own
:disable-tools ["bash" "mine"] ; final exposed names to drop, across every source
:wait-for (fn [request] answer)}) ; the ONE §10 host resolver
;; =>
{:tools {"read" {...} "skill" {...} "docs_search" {...}} ; name -> Tool
:sources {"builtin" "connected"}
:skills {...} ; the loaded skill catalog
:statuses {"builtin" "connected" "docs" "connected" "old" "failed"}
:errors {"old" "mcp server \"old\" failed at initialize: transport (connect-failed)"}
:connections [{...}] ; live MCP connections
:wait-for #function[...]} ; carried through for the client
```
One call, every source, one flat namespace of tools. `build` is the integration layer: it folds every skill-shaped option into a single catalog and one `skill` tool, resolves A2A agent cards, connects MCP servers, adds the builtins and your own Tools, applies the drop-list, and hands back a toolkit that `toolnexus.core/execute` and the adapters consume unchanged.
**Registration order is the contract, not a detail.** `toolnexus.tool/add-tools` lets a later tool win a name collision, and SPEC §0.11 requires MCP to take precedence over a builtin of the same name — so the order is builtins, then the `skill` tool, then agents, then your `:tools`, then MCP last. `:disable-tools` is applied after all of them, by final exposed name, so it can drop anything from any source.
**Every source is isolated.** A source that fails contributes an entry in `:errors` and a status in `:statuses`, never an exception: a malformed `:mcp` string, a server that will not start, a `:skill-provider` that throws, an unreachable agent card. Each leaves you with a working toolkit containing everything that did load. That is the whole point of the status map.
## When to use it
[Section titled “When to use it”](#when-to-use-it)
* **The normal entry point for an application.** With more than one source, this is the call that gets precedence, filtering and isolation right for you.
* **When you want the skills preamble.** `toolnexus.core/skills-prompt` reads the catalog `build` attached, so the system prompt and the `skill` tool cannot disagree about what exists.
* **When you are about to hand the tools to a provider.** `to-openai`, `to-anthropic` and `to-gemini` are re-exported here, so a caller needs one `require` rather than four.
## Why this and not the alternative
[Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative)
Call the source namespace directly when you only have one source
[`toolnexus.mcp/from-config`](/api/clojure/mcp/load/), `toolnexus.skill/load-skills` and [`toolnexus.native/native-tool`](/api/clojure/native/define-tool/) each work standalone and return plain data. Use them when you want exactly one source and control over the merge — a CLI that only talks to MCP, a test that needs one native Tool. `build` earns its keep the moment two sources can collide on a name, or you want one dead source not to sink the rest.
Note that `build` **opens things**: a connected MCP server is a child process or a live HTTP session. Pair it with `toolnexus.core/shutdown!`, which closes every connection the toolkit opened and is idempotent.
## Options
[Section titled “Options”](#options)
| Option | Effect |
| --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `:mcp` | An `mcp.json` string or a parsed map. Servers are connected serially in name order — see [`from-config`](/api/clojure/mcp/load/). Registered **last**, so MCP wins a name collision (§0.11). A top-level `agents` block on this map is honoured too, mirroring how `mcpServers` is read off the same object. |
| `:skills` | A skills root, a seq of roots, or a seq of skill defs. |
| `:skill-defs` | Skills supplied as data, bypassing the filesystem (§3 S1). |
| `:skill-provider` | A 0-arg fn producing skill defs, resolved during the build. One that throws contributes nothing and every other source still loads. |
| `:skills-filter` | `name -> bool` over the catalog (§3 S2). One or more `true` entries make it an allowlist; a `false` entry drops that name. |
| `:skill-sample-limit` | `0` ⇒ the default of 10 · `n>0` ⇒ cap · `-1` ⇒ omit the `` block entirely (§3 S5). |
| `:disable-skills` | Names to drop from the catalog. Folded in **first**, so an explicit `:skills-filter` entry overrides it — the merge order is the rule. |
| `:builtins` | The §0.11 toggle. `false`, `{:disabled true}` or `{:enabled false}` turn the whole source off; `{:tools {:bash false}}` drops individual tools from the all-on baseline of ten. Absent means on. |
| `:agents` | Remote A2A agents (§7A). Each advertised skill becomes a tool named `sanitize(card-name)_sanitize(skill-id)`. A failing agent contributes no tools and never a throw. |
| `:tools` | Extra Tools of your own — native, HTTP, A2A, anything. |
| `:disable-tools` | Final exposed names to drop, applied after the merge across **every** source. An empty vector changes nothing. |
| `:wait-for` | The one §10 host resolver, `(fn [request] answer)`. Threaded into the MCP source so a connected server may elicit input mid-`tools/call`; absent, the `elicitation` capability is never advertised. |
The three skill sources compose: `:skills`, `:skill-defs` and `:skill-provider` are resolved in that order into one catalog, and the existing first-name-wins rule settles collisions across them without a second rule.
## What you get back
[Section titled “What you get back”](#what-you-get-back)
| Key | What it is |
| -------------- | ------------------------------------------------------------------------------------------------------------------ |
| `:tools` | `name -> Tool`, the merged namespace after `:disable-tools`. `toolnexus.core/tool-names` returns the sorted names. |
| `:sources` | The source-status map the builtin toolkit was seeded with. |
| `:skills` | The loaded skill catalog — read by `skills-prompt` and by the `skill` tool. `{}` when no skill option was given. |
| `:statuses` | `"builtin"` plus one entry per MCP server: `"connected"`, `"disabled"` or `"failed"`. |
| `:errors` | `server -> message` for the failures only, including `""` when `:mcp` itself could not be parsed. |
| `:connections` | The live MCP connections, for `shutdown!`. |
| `:wait-for` | The resolver you passed, carried through so the client can use the same one. |
## Examples
[Section titled “Examples”](#examples)
### 1. Three sources at once, in one flat namespace
[Section titled “1. Three sources at once, in one flat namespace”](#1-three-sources-at-once-in-one-flat-namespace)
```clojure
(require '[toolnexus.core :as tn]
'[toolnexus.tool :as tool])
(def tk
(tn/build {:skills "./skills"
:builtins {:tools {:bash false}} ; nine builtins, not ten
:tools [(tool/tool {:name "mine" :source "native"
:execute (fn [_] (tool/success "ok"))})]}))
(contains? (:tools tk) "skill") ;; => true — the §3 progressive-disclosure tool
(contains? (:tools tk) "read") ;; => true — a builtin
(contains? (:tools tk) "mine") ;; => true — yours
(contains? (:tools tk) "bash") ;; => false — dropped by the per-tool toggle
(get (:statuses tk) "builtin") ;; => "connected"
```
Turning the source off entirely is `:builtins false`, and then the `:tools` map is never consulted at all — a whole-source-off short-circuits, so `{:disabled true :tools {:bash true}}` still yields nothing.
### 2. Shaping the catalog: data, a provider, and a filter
[Section titled “2. Shaping the catalog: data, a provider, and a filter”](#2-shaping-the-catalog-data-a-provider-and-a-filter)
```clojure
(require '[toolnexus.core :as tn])
(def tk
(tn/build {:builtins false
:skills "./skills"
:skill-defs [{:name "triage" :description "Triage a bug report"
:content "1. Reproduce it."}]
:skill-provider (fn [] [{:name "release" :description "Cut a release"
:content "1. Bump the version."}])
:disable-skills ["legacy"]}))
;; the prompt catalog is the union of all three sources, minus the drop-list
(tn/skills-prompt tk)
;; => "## Available Skills\n- **hello-world**: …\n- **triage**: …\n- **release**: …"
;; the `skill` tool and the prompt always agree — a dropped skill is not loadable
(:isError (tn/execute tk "skill" {"name" "legacy"})) ;; => true
;; an explicit filter entry beats :disable-skills, because the filter is merged second
(def tk2 (tn/build {:builtins false :skill-defs [{:name "legacy" :description "d" :content "b"}]
:disable-skills ["legacy"] :skills-filter {"legacy" true}}))
(:isError (tn/execute tk2 "skill" {"name" "legacy"})) ;; => false
```
A `:skill-provider` that throws is isolated exactly the way a dead MCP server is: it contributes nothing, and the directory and data sources still load.
### 3. The whole pipeline — build, prompt, adapt, shut down
[Section titled “3. The whole pipeline — build, prompt, adapt, shut down”](#3-the-whole-pipeline--build-prompt-adapt-shut-down)
```clojure
(require '[toolnexus.core :as tn]
'[toolnexus.client :as client])
(def tk
(tn/build {:mcp (slurp "mcp.json")
:skills "./skills"
:builtins {}
:agents [{:card "https://peer.example/.well-known/agent-card.json"
:timeout 3000}]
:disable-tools ["bash"]
:wait-for (fn [request] (client/make-answer (:id request) true))}))
(try
;; the §3/§0.6 preamble for the system prompt, derived from the same catalog
;; the `skill` tool reads — the two cannot drift
(tn/skills-prompt tk)
;; one tool set, three wire formats; all three agree on contents AND on order
(count (tn/to-openai tk))
(count (tn/to-anthropic tk))
(count (:functionDeclarations (first (tn/to-gemini tk))))
(tn/tool-names tk) ;; sorted, so two runtimes emit the same array
(tn/execute tk "read" {:path "README.md"})
(finally
(tn/shutdown! tk))) ;; closes every MCP connection; idempotent
```
`shutdown!` matters more than it looks. A connected stdio server is a child process, and while this port is careful never to hold your process open itself — no `future` in library code, ever — the child is yours to close.
## See also
[Section titled “See also”](#see-also)
* [`toolnexus.mcp/from-config`](/api/clojure/mcp/load/) — the MCP source on its own
* [`toolnexus.mcp/parse-config`](/api/clojure/mcp/parse-config/) — validate an `mcp.json` without connecting
* [`toolnexus.tool/tool`](/api/clojure/types/tool/) — the shape everything in `:tools` has
* [`toolnexus.native/native-tool`](/api/clojure/native/define-tool/) — build the Tools you pass as `:tools`
* [`toolnexus.client/wait-for`](/api/clojure/suspension/wait-for/) — the §10 resolver `:wait-for` expects
* [`toolnexus.mcp/elicitation->request`](/api/clojure/mcp/elicitation/) — what that resolver receives from a server
* [OpenAI adapter](/api/clojure/adapters/openai/) — what `to-openai` emits
# toolnexus.translate/openai-messages-to-anthropic
> Convert an OpenAI-shaped transcript into Anthropic shape, including the tool-result merging a flattening translator gets wrong. — Clojure.
**Clojure (JVM) + cljgo** · package `net.clojars.muthuishere/toolnexus` · [SPEC §11](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [clojure/src/toolnexus/translate.cljc](https://github.com/muthuishere/toolnexus/blob/main/clojure/src/toolnexus/translate.cljc)
```clojure
(toolnexus.translate/openai-messages-to-anthropic messages)
;; => {:messages [{:role "user"|"assistant" :content ...}]
;; :system "hoisted system/developer text"}
(toolnexus.translate/openai-tools-to-anthropic tools)
;; => [{:name "..." :description "..." :input_schema {...}}]
(toolnexus.translate/openai-tool-choice-to-anthropic choice)
;; => {:type "any"|"none"|"tool" :name "..."} or nil
```
This is the inbound half of the §11 translator, exposed rather than hidden: the functions that take OpenAI-shaped input and produce Anthropic-native shapes. [`translate`](/api/clojure/translate/translate/) calls them on the anthropic path, and they are public because a transcript conversion is useful on its own — for tests, for a gateway that batches, for anything that needs the mapping without a provider call.
The hard part is structure, not text. `openai-messages-to-anthropic` preserves exactly what a naive text-flattening translator destroys:
* An assistant turn’s `tool_calls` become `tool_use` blocks, with `arguments` re-parsed from its JSON string back into an **object**.
* A `tool`-role result becomes a `tool_result` block keyed by its `tool_call_id`, and **consecutive results are merged into one user turn** — providers expect a single result-bearing turn answering the preceding assistant turn, not one turn per result. Get this wrong and parallel tool calls fail on the second result.
* `system` and `developer` messages are **hoisted out** entirely, because Anthropic takes system separately; several of them join with a blank line and come back on `:system`.
Content is tolerant on the way in: a string, or a parts array, both flatten. An assistant turn with neither text nor tool calls is dropped rather than sent (a provider would reject it), and a user parts-array carrying no text — images, say — passes through untouched instead of being flattened away.
## When to use it
[Section titled “When to use it”](#when-to-use-it)
* **Asserting on the conversion in a test** — the transcript shape is the thing most likely to regress, and this checks it without a network call or an API key.
* **Building a request body yourself** — you want the provider call, headers or streaming under your own control but not the message mapping.
* **Migrating a stored transcript** — an archive of OpenAI-shaped conversations replayed against an Anthropic-style provider.
## Why this and not the alternative
[Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative)
Use translate when you want the round trip
[`translate`](/api/clojure/translate/translate/) already calls all three of these, sends the request, and converts the response back into OpenAI shape — including reading `tool_use` blocks into `:toolCalls`. Reach for the inbound functions directly only when you are *not* making the call: a test, a migration, a hand-built body. If you want a turn’s worth of work done, use `translate`.
Note the direction. These are OpenAI → Anthropic. The other direction, declaring a toolkit *out* to any of the three providers, is [`toolnexus.adapter`](/api/clojure/adapters/anthropic/); reading a response back into OpenAI shape happens inside `translate` and is not separately exposed.
## Examples
[Section titled “Examples”](#examples)
### Tool calls and results survive the round trip
[Section titled “Tool calls and results survive the round trip”](#tool-calls-and-results-survive-the-round-trip)
```clojure
(require '[toolnexus.translate :as translate])
(translate/openai-messages-to-anthropic
[{:role "system" :content "You are terse."}
{:role "user" :content "weather in Chennai and Kochi?"}
{:role "assistant" :content ""
:tool_calls [{:id "c1" :type "function"
:function {:name "get_weather" :arguments "{\"city\":\"Chennai\"}"}}
{:id "c2" :type "function"
:function {:name "get_weather" :arguments "{\"city\":\"Kochi\"}"}}]}
{:role "tool" :tool_call_id "c1" :content "33C"}
{:role "tool" :tool_call_id "c2" :content "29C"}])
;; => {:system "You are terse."
;; :messages
;; [{:role "user" :content "weather in Chennai and Kochi?"}
;; {:role "assistant"
;; :content [{:type "tool_use" :id "c1" :name "get_weather" :input {:city "Chennai"}}
;; {:type "tool_use" :id "c2" :name "get_weather" :input {:city "Kochi"}}]}
;; ;; ONE user turn carrying BOTH results — not two turns
;; {:role "user"
;; :content [{:type "tool_result" :tool_use_id "c1" :content "33C"}
;; {:type "tool_result" :tool_use_id "c2" :content "29C"}]}]}
```
The assistant turn had empty text, so no `text` block was emitted — only the two `tool_use` blocks. And `arguments` arrived as a JSON string and left as a map, because that is what Anthropic’s `input` field is.
### Convert tool declarations and a tool choice
[Section titled “Convert tool declarations and a tool choice”](#convert-tool-declarations-and-a-tool-choice)
```clojure
(require '[toolnexus.translate :as translate])
(translate/openai-tools-to-anthropic
[{:type "function"
:function {:name "get_weather"
:description "Current weather for a city"
:parameters {:type "object"
:properties {:city {:type "string"}}
:required ["city"]}}}
;; no parameters ⇒ an empty object schema, not a missing key
{:type "function" :function {:name "ping"}}])
;; => [{:name "get_weather" :description "Current weather for a city"
;; :input_schema {:type "object" :properties {:city {:type "string"}} :required ["city"]}}
;; {:name "ping" :input_schema {:type "object" :properties {}}}]
(translate/openai-tool-choice-to-anthropic "required") ;; => {:type "any"}
(translate/openai-tool-choice-to-anthropic "none") ;; => {:type "none"}
(translate/openai-tool-choice-to-anthropic
{:type "function" :function {:name "get_weather"}}) ;; => {:type "tool" :name "get_weather"}
(translate/openai-tool-choice-to-anthropic "auto") ;; => nil — send nothing, provider decides
```
An entry that is already Anthropic-native (it has a top-level `:name` and no `:function`) passes through; anything unrecognized is skipped rather than raised.
### Build the provider body yourself
[Section titled “Build the provider body yourself”](#build-the-provider-body-yourself)
```clojure
(require '[toolnexus.translate :as translate]
'[toolnexus.adapter :as adapter]
'[koine.env :as env]
'[koine.json :as json]
'[koine.http :as http])
(let [{:keys [messages system]} (translate/openai-messages-to-anthropic openai-transcript)
body (cond-> {:model "claude-sonnet-4-6" :max_tokens 4096 :messages messages}
(not= "" system) (assoc :system system)
true (assoc :tools (adapter/to-anthropic tk)))]
(http/request {:method :post
:url "https://api.anthropic.com/v1/messages"
:headers {"x-api-key" (env/get-env "ANTHROPIC_API_KEY")
"anthropic-version" "2023-06-01"
"content-type" "application/json"}
:body (json/write-str body)}))
```
You lose the shared retry/backoff and the `llm` metric event by going around [`translate`](/api/clojure/translate/translate/) — that is the trade for owning the transport.
## The conversion rules
[Section titled “The conversion rules”](#the-conversion-rules)
| OpenAI input | Anthropic output |
| ------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `system` / `developer` message | Hoisted to `:system`. Multiple ones join with a blank line. Empty content contributes nothing. |
| `user` message, string or parts | `{:role "user" :content }`; a text-free parts array passes through unchanged. |
| `assistant` message | `{:role "assistant" :content [blocks]}` — a `text` block when there is text, then one `tool_use` per `tool_calls` entry. Dropped entirely when it would be empty. |
| `tool` / `function` message | A `tool_result` block with `:tool_use_id` from `tool_call_id`, buffered and flushed as one `user` turn. |
| anything not a map | Ignored. |
### The three inbound functions
[Section titled “The three inbound functions”](#the-three-inbound-functions)
| Function | In | Out |
| --------------------------------- | -------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
| `openai-messages-to-anthropic` | OpenAI `messages` | `{:messages [...] :system "..."}` |
| `openai-tools-to-anthropic` | OpenAI `tools` | Anthropic declarations with `:input_schema`. Missing `parameters` becomes `{:type "object" :properties {}}`; a nameless entry is skipped. |
| `openai-tool-choice-to-anthropic` | OpenAI `tool_choice` | `{:type "any"\|"none"\|"tool"}` or `nil` for absent / `"auto"` / unrecognized. |
Supporting readers in the same namespace: `content-text` (flatten a content value), `tool-calls-of` (read `tool_calls` off an assistant message), `has-system-message?`, `args-object` and `args-string`.
## See also
[Section titled “See also”](#see-also)
* [`toolnexus.translate/translate`](/api/clojure/translate/translate/) — the round trip that calls all of these
* [`toolnexus.adapter/to-anthropic`](/api/clojure/adapters/anthropic/) — the outbound direction: a toolkit as declarations
* [`toolnexus.client/run`](/api/clojure/client/run/) — when toolnexus should own the conversation instead
# toolnexus.translate/translate
> Declare a toolkit to a provider and translate one request/response without executing anything or keeping state. — Clojure.
**Clojure (JVM) + cljgo** · package `net.clojars.muthuishere/toolnexus` · [SPEC §11](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [clojure/src/toolnexus/translate.cljc](https://github.com/muthuishere/toolnexus/blob/main/clojure/src/toolnexus/translate.cljc)
```clojure
(toolnexus.translate/translate client
{:messages [{:role "user" :content "what is the weather in Chennai?"}]
:tools [...] ; OpenAI `tools` array, verbatim — declaration only
:toolkit tk ; declared through the §5 adapters, NEVER executed
:toolChoice "auto" ; OpenAI `tool_choice`, mapped for anthropic
:system "You are terse."
:maxTokens 1024})
;; => {:text ""
;; :toolCalls [{:id "call_1" :name "get_weather" :arguments "{\"city\":\"Chennai\"}"}]
;; :finishReason "tool_calls" ; stop | tool_calls | length | content_filter
;; :usage {:prompt-tokens 42 :completion-tokens 18 :total-tokens 60}
;; :model "claude-sonnet-4-6"
;; :raw {...}} ; the provider's decoded response
```
`translate` is toolnexus used as a pure **wire-format translator**: OpenAI shapes in, exactly one provider call, OpenAI shapes out. No agent loop, no tool execution, no conversation state — so it is safe to call statelessly and concurrently, and the caller keeps full ownership of the transcript.
Against an OpenAI-style client it is near-passthrough: `messages`, `tools` and `tool_choice` go out as given. Against an Anthropic-style client it does the real work — the transcript is restructured by [`openai-messages-to-anthropic`](/api/clojure/translate/inbound/), tools are converted to `input_schema` declarations, `tool_choice` is mapped, and the response’s `tool_use` blocks are read back into OpenAI `tool_calls` with `arguments` as a JSON **string**. One shape in, one shape out, whichever provider is behind it.
**Nothing executes, ever.** There is no execution path in this namespace. A `:toolkit` is declared through the adapters and never run — that is a property of the design, not of configuration. What comes back is the model’s *intent* to call a tool; running it, appending the result and calling again is the caller’s loop.
Retries and backoff, request-param merging and the `llm` observability event are shared with the agent loop through the same internal provider call, so a translating caller loses neither resilience nor metrics.
The camelCase keys are deliberate. `:toolCalls`, `:finishReason`, `:toolChoice` and `:maxTokens` are the cross-port §11 contract, and a keyword’s name is the wire key — renaming them into kebab-case would be exactly the drift the ports exist to prevent. `:usage` alone uses the port’s idiomatic §8 shape, because Usage never crosses the wire.
## When to use it
[Section titled “When to use it”](#when-to-use-it)
* **The caller owns the conversation.** The standard OpenAI function-calling posture: every request carries the full history including prior tool results, and your code decides what runs.
* **You are a proxy or a gateway.** Accept OpenAI-shaped requests from existing clients and serve them from an Anthropic-style provider without touching either side.
* **Stateless and concurrent.** Nothing is stored between calls, so N in-flight translations from one client map are fine.
## Why this and not the alternative
[Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative)
Use client/run when toolnexus should own the loop
[`toolnexus.client/run`](/api/clojure/client/run/) is the other posture: it executes tools, appends results, loops to `:max-turns`, honours §10 suspension through `:wait-for` and can persist a transcript. Take it when you want an agent. Take `translate` when you want a translator — when the thing on the other side is already a conforming OpenAI client and toolnexus’s job is the wire format, not the control flow. Two postures, two mechanisms; mixing them means two places own the history.
## Examples
[Section titled “Examples”](#examples)
### One turn against an Anthropic-style provider, in OpenAI shape
[Section titled “One turn against an Anthropic-style provider, in OpenAI shape”](#one-turn-against-an-anthropic-style-provider-in-openai-shape)
```clojure
(require '[toolnexus.client :as client]
'[toolnexus.translate :as translate])
(def c (client/create-client {:base-url "https://api.anthropic.com"
:style "anthropic"
:model "claude-sonnet-4-6"}))
(def r (translate/translate c {:messages [{:role "user" :content "Say hi."}]}))
(:text r) ;; => "Hi."
(:finishReason r) ;; => "stop"
(:usage r) ;; => {:prompt-tokens ... :completion-tokens ... :total-tokens ...}
```
### Declare a toolkit, run the tool yourself, translate again
[Section titled “Declare a toolkit, run the tool yourself, translate again”](#declare-a-toolkit-run-the-tool-yourself-translate-again)
```clojure
(require '[toolnexus.core :as core]
'[toolnexus.client :as client]
'[toolnexus.translate :as translate])
(def tk (core/build {:skills "examples/skills"}))
(def c (client/create-client {:base-url "https://api.openai.com/v1"
:model "gpt-4.1"}))
(def turn1
(translate/translate c {:messages [{:role "user" :content "load the hello-world skill"}]
:toolkit tk}))
;; The model asked for a tool. translate did NOT run it — you do.
(def call (first (:toolCalls turn1)))
(def result (core/execute tk (:name call) (translate/args-object (:arguments call))))
;; Append the assistant turn and the tool result, then translate the next turn.
(def turn2
(translate/translate c
{:messages [{:role "user" :content "load the hello-world skill"}
{:role "assistant" :content (:text turn1)
:tool_calls (translate/tool-calls-json turn1)}
{:role "tool" :tool_call_id (:id call) :content (:output result)}]
:toolkit tk}))
(:text turn2)
```
`tool-calls-json` renders `:toolCalls` back into the OpenAI `tool_calls` array shape, and `args-object` parses `:arguments` — which is a JSON string on the wire — into a map for execution.
### A gateway: OpenAI in, whichever provider out
[Section titled “A gateway: OpenAI in, whichever provider out”](#a-gateway-openai-in-whichever-provider-out)
```clojure
(require '[toolnexus.translate :as translate]
'[koine.json :as json])
(defn handle [req openai-client anthropic-client]
(let [body (json/read-str (:body req))
c (if (= "anthropic" (:upstream body)) anthropic-client openai-client)
r (translate/translate c {:messages (:messages body)
:tools (:tools body)
:toolChoice (:tool_choice body)
:maxTokens (:max_tokens body)})]
{:status 200
:headers {"content-type" "application/json"}
:body (json/write-str
{:model (:model r)
:choices [{:index 0
:finish_reason (:finishReason r)
:message (cond-> {:role "assistant" :content (:text r)}
(seq (:toolCalls r))
(assoc :tool_calls (translate/tool-calls-json r)))}]})}))
```
Callers keep speaking OpenAI. Swapping the upstream is one binding.
## Request keys
[Section titled “Request keys”](#request-keys)
| Key | Meaning |
| ------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `:messages` | The OpenAI `messages` array, verbatim — including assistant turns with `tool_calls` and `tool`-role results carrying `tool_call_id`. |
| `:tools` | The OpenAI `tools` array, verbatim. Declaration only. |
| `:toolkit` | An ordinary toolkit, declared via the §5 adapters and never executed. Composes with `:tools`; toolkit declarations come first. |
| `:toolChoice` | The OpenAI `tool_choice`, verbatim. `"required"`/`"any"` → Anthropic `any`, `"none"` → `none`, a named function → `tool`; `"auto"` and anything unrecognized send nothing. |
| `:system` | Overrides the client’s system prompt. On the OpenAI path it is prepended as a `system` message only when the transcript has none. |
| `:maxTokens` | Overrides the per-provider default. Anthropic requires one, so it defaults to `4096` there. |
### Return keys
[Section titled “Return keys”](#return-keys)
| Key | Type | What it is |
| --------------- | ------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `:text` | string | The assistant text. `""` when the turn was tool calls only. |
| `:toolCalls` | vector | `[{:id :name :arguments}]`. `:arguments` is a **JSON string**, the OpenAI wire form. |
| `:finishReason` | string | `"stop"`, `"tool_calls"`, `"length"` or `"content_filter"`. Tool calls always win; on the OpenAI path the provider’s own `finish_reason` is preferred when it sent one. |
| `:usage` | map | `{:prompt-tokens :completion-tokens :total-tokens}`. |
| `:model` | string | The client’s model. |
| `:raw` | map | The provider’s decoded response, untouched — read anything not surfaced above from here. |
### Helpers in the same namespace
[Section titled “Helpers in the same namespace”](#helpers-in-the-same-namespace)
| Function | Use |
| ------------------- | ------------------------------------------------------------------------------------------------ |
| `tool-calls-json` | Render `:toolCalls` as an OpenAI `tool_calls` array for an assistant message. |
| `args-object` | Parse a call’s `arguments` into a map. Accepts the object form too; malformed input yields `{}`. |
| `args-string` | The reverse — render arguments as the JSON string form. |
| `finish-reason-for` | The mapping rule on its own, if you are assembling a response by hand. |
## See also
[Section titled “See also”](#see-also)
* [`toolnexus.translate/openai-messages-to-anthropic`](/api/clojure/translate/inbound/) — the transcript conversion underneath the anthropic path
* [`toolnexus.client/run`](/api/clojure/client/run/) — the other posture: toolnexus owns the loop
* [`toolnexus.adapter/to-openai`](/api/clojure/adapters/openai/) — the declaration-only half, without a provider call
# attach
> The authoring side of multimodal content: constructors that turn a path, bytes, a blob, a data URL, or a remote URL into the ContentPart a prompt or tool result carries — the write half of the read-only ContentPart shape. — Clojure.
**Clojure (JVM) + cljgo** · package `net.clojars.muthuishere/toolnexus` · [SPEC §1B](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [clojure/src/toolnexus/content.cljc](https://github.com/muthuishere/toolnexus/blob/main/clojure/src/toolnexus/content.cljc)
```clojure
(require '[toolnexus.content :as content])
;; the edge: dispatches on the SHAPE of what you hand it
(content/attach source) ; a data: URL | an http(s) URL | else: a filesystem path
(content/attach source opts) ; opts = {:mimeType "…" :name "…" :max-part-bytes n}
;; the named constructors attach dispatches to internally
(content/from-file path) ; read now, base64 now; mime from the fixed extension table
(content/from-file path opts)
(content/image-file path) ; from-file, but errors if the result is not an "image" part
(content/from-bytes bs mime) ; byte array | seq of byte values | string (its UTF-8 bytes)
(content/from-bytes bs mime opts)
(content/from-data-url u) ; "data:;base64," -> {:mimeType … :data …}
(content/from-url u) ; "http(s)://…" -> {:mimeType … :url …}
(content/text-part s) ; {:type "text" :text s} — not attach; there is no non-text part for text
;; a failure is a VALUE, never a throw
{:type "error" :code "unsupported-source" :error "unsupported byte source: …"}
;; on the CLIENT: what to do when a part's style can't represent it
(client/create-client {:on-unsupported-part "error"}) ; or "text" — see below
```
The authoring side of multimodal content: constructors that turn a path, bytes, a blob, a data URL, or a remote URL into the `ContentPart` a prompt or tool result carries — the write half of the read-only [`ContentPart`](/api/clojure/types/content-part/) shape that page documents in full. This page is the dedicated reference for the constructors themselves — `attach` and its five named siblings — while `content-part.mdx` covers the *shape* every one of them produces.
This page and content-part.mdx both document attach — on purpose
`toolnexus.content/attach` is small enough that the sibling `content-part.mdx` page already walks through it as part of explaining the union type. This page exists as the dedicated per-symbol reference (matching the split every other port with a `types/attach.mdx` — python, java, csharp, elixir, golang — already has) so `attach` and its five siblings (`from-file`, `image-file`, `from-bytes`, `from-data-url`, `from-url`) each get worked examples in one place, without repeating the union-type overview. Read `content-part.mdx` first if you have not seen the `ContentPart` shape at all; come here once you know the shape and want the constructor that builds it from what you actually have in hand.
`attach` never carries the path or the raw bytes forward into the part — a path is read and base64’d *now*, at construction, so the resulting part survives a persisted transcript, an MCP hop, or the process boundary of a durable resume, none of which can dereference a filesystem path that made sense only on the machine that built it.
## When to use it
[Section titled “When to use it”](#when-to-use-it)
* **Attaching a file, image or audio clip to a prompt** — `(client/run c [(content/text-part "…") (content/attach "diagram.png")] {…})`. A prompt is a string *or* a vector of parts; `attach` is how you build the non-text elements of that vector.
* **You already have bytes in hand** (read from a database blob, downloaded, generated) rather than a path on disk — use `from-bytes` directly with an explicit mime type, since bytes carry no extension to read one from.
* **You have a remote URL and want the provider to fetch it** rather than downloading and re-uploading the bytes yourself — `from-url` keeps it as a `:url` part.
* **Not** for turning a string into its own UTF-8 bytes — `attach` on a bare string always reads it as either a `data:`/`http(s):` URL or a filesystem path, never as text-to-encode. Call `from-bytes` directly for that, since `attach` cannot tell a payload from a filename apart.
## Why this and not the alternative
[Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative)
Reach for the named constructor when you already know the source shape
`attach` is a convenience dispatcher for when the source’s shape is not yet known (a config value that might be a path or a URL, say). If you already know exactly what you have — a `path`, an in-memory byte array, a `data:` URL string, a remote URL — call `from-file`, `from-bytes`, `from-data-url` or `from-url` directly. It reads the same either way, but skips a shape-sniffing step you already know the answer to, and it is the only way to attach a *string* as bytes rather than as a path (`from-bytes`) or force a path to be validated as an image specifically (`image-file`).
Shorter accepted-source list than the other six ports — structurally, not an oversight
SPEC §1B asks every port to accept the file/byte objects its users already hold — `File`/`Blob` in js, a `BinaryIO` in python, an `io.Reader` in golang, `InputStream` in java, a `Stream` in csharp, `File.Stream` in elixir. This port’s list is **shorter**: a filesystem path string, a `data:` URL, an `http(s):` URL, a native byte array (JVM `byte[]` / Go `[]byte`), or any sequence of byte values (vector, list, lazy seq — the shapes ordinary Clojure code actually produces). The reason is structural: this is ONE `.cljc` file, zero reader conditionals, compiled for **two** hosts (the JVM and cljgo/Go). Naming `java.io.InputStream` here would make the namespace unloadable on the Go host, and koine — the only host-neutral layer available — has no stream/reader abstraction to stand in for one. A host object handed to `attach` or `from-bytes` (a `java.io.File`, an `InputStream`, a Go `*os.File`) is refused **by name**, as an `{:type "error" :code "unsupported-source" ...}` value naming the type and telling you to pass a path or bytes instead — never a cast exception, and never a silent misread.
## Examples
[Section titled “Examples”](#examples)
### 1. The smallest useful call — attach a local file to a prompt
[Section titled “1. The smallest useful call — attach a local file to a prompt”](#1-the-smallest-useful-call--attach-a-local-file-to-a-prompt)
```clojure
(require '[toolnexus.client :as client]
'[toolnexus.content :as content]
'[toolnexus.tool :as tool]
'[koine.fs :as fs]
'[koine.json :as json])
(println)
;; the shared cross-language fixture lives at the repo root; walk up to it so
;; the example runs from wherever it happens to sit inside the tree
(defn find-up [rel]
(->> (iterate (fn [p] (str "../" p)) rel) (take 8) (filter fs/exists?) first))
(def png (find-up "examples/media/fixture.png"))
(defn- text-response [t]
{:choices [{:message {:role "assistant" :content t}}]
:usage {:prompt_tokens 10 :completion_tokens 5 :total_tokens 15}})
(def llm
(client/create-client
{:base-url "http://mock.local" :api-key "test-key" :style "openai" :model "gpt-4o-mini"
:http-client (fn [_url _headers _body]
{:status 200 :headers {} :body (json/write-str (text-response "a diagram"))})}))
(def rr
(client/run llm
[(content/text-part "What is in this image?")
(content/attach png)]
{:toolkit (tool/toolkit [])}))
(assert (= "a diagram" (:text rr)))
(println "OK")
```
`attach` sees a string that is neither a `data:` nor an `http(s):` URL, so it reads the file off disk now, base64-encodes it, and looks its mime type up in the fixed extension table (`image/png`) — the path itself never reaches the resulting part.
### 2. The realistic case — bytes already in hand, from a database or a generated buffer
[Section titled “2. The realistic case — bytes already in hand, from a database or a generated buffer”](#2-the-realistic-case--bytes-already-in-hand-from-a-database-or-a-generated-buffer)
```clojure
(require '[toolnexus.content :as content])
(println)
(defn attach-generated-chart [png-bytes]
;; bytes carry no extension, so the mime type is required
(content/from-bytes png-bytes "image/png"))
;; a byte array, a seq of byte values, or a string are all accepted, and a
;; byte array and its own seq encode identically:
(def p1 (content/from-bytes (byte-array [137 80 78 71]) "image/png"))
(def p2 (content/from-bytes (seq (byte-array [137 80 78 71])) "image/png"))
(def p3 (content/from-bytes "raw text payload" "text/plain")) ; taken as its UTF-8 bytes
(assert (= "image" (:type p1)))
(assert (= (:data p1) (:data p2)))
(assert (= "text/plain" (:mimeType p3)))
(assert (= "image" (:type (attach-generated-chart (byte-array [137 80 78 71])))))
(println "OK")
```
### 3. The full surface — every constructor, plus the unsupported-part policy on the client
[Section titled “3. The full surface — every constructor, plus the unsupported-part policy on the client”](#3-the-full-surface--every-constructor-plus-the-unsupported-part-policy-on-the-client)
```clojure
(require '[toolnexus.client :as client]
'[toolnexus.content :as content]
'[koine.fs :as fs])
(println)
(defn find-up [rel]
(->> (iterate (fn [p] (str "../" p)) rel) (take 8) (filter fs/exists?) first))
(def png (find-up "examples/media/fixture.png"))
;; every path attach can take, named explicitly
(def f1 (content/from-file png)) ; read + base64'd now
(def f2 (content/image-file png)) ; from-file, but errors if not an image
(def f3 (content/from-data-url "data:image/png;base64,iVBORw0KGgo="))
(def f4 (content/from-url "https://example.com/diagram.webp")) ; kept as :url, never downloaded here
(def f5 (content/attach {:a 1} {:mimeType "image/png"})) ; unsupported shape -> error part, no throw
(assert (= "image" (:type f1)))
(assert (= "image" (:type f2)))
(assert (= "image/png" (:mimeType f3)))
(assert (= "https://example.com/diagram.webp" (:url f4)))
(assert (= "error" (:type f5)))
(assert (= "unsupported-source" (:code f5)))
;; a client that DECLARES how to handle a part its style cannot send at all
(def llm
(client/create-client
{:base-url "https://api.openai.com/v1"
:style "openai"
:model "gpt-4.1"
:max-part-bytes 5000000 ; §1B — reject a part over this many DECODED bytes
:on-unsupported-part "text"})) ; degrade to a text placeholder + warn once, instead of stopping the run
(assert (= "text" (:on-unsupported-part llm)))
(println "OK")
```
`:on-unsupported-part` governs the provenance rule uniformly: unset, an **attached** part the style genuinely cannot represent (say, an OpenAI Chat Completions `file` part with a `:url` rather than inline data) stops the run; a **tool-derived** part degrades to a text placeholder and warns once either way. Setting `"error"` or `"text"` overrides that default in both directions rather than leaving the provenance-dependent behavior in place.
## See also
[Section titled “See also”](#see-also)
* [`toolnexus.content/attach`](/api/clojure/types/content-part/) — The non-text half of a message: `text | image | file | audio`, carrying base64 bytes or a URL plus a `mimeType` — never a path. Read this first for the full union-type shape these constructors build.
* [`toolnexus.tool/tool`](/api/clojure/types/tool/) — The uniform shape every tool source collapses to: name, description, JSON-Schema parameters, execute.
* [`toolnexus.tool/success`](/api/clojure/types/tool-result/) — The result envelope a tool returns; its `:parts` carries any non-text `ContentPart` a tool hands back, built the same way as a prompt-side attachment.
# toolnexus.content/attach
> The non-text half of a message: text | image | file | audio, carrying base64 bytes or a URL plus a mimeType — never a path. — Clojure.
**Clojure (JVM) + cljgo** · package `net.clojars.muthuishere/toolnexus` · [SPEC §1B](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [clojure/src/toolnexus/content.cljc](https://github.com/muthuishere/toolnexus/blob/main/clojure/src/toolnexus/content.cljc)
```clojure
;; There is no ContentPart type. A part is a plain map, and the keys ARE the wire
;; keys — :mimeType, not :mime-type, the same rule that puts :isError in
;; toolnexus.tool and :input_schema in toolnexus.adapter.
{:type "text" :text "What is in this image?"}
{:type "image" :mimeType "image/png" :data "iVBORw0KGgo…"} ; base64, standard + padded
{:type "image" :mimeType "image/webp" :url "https://…/diagram.webp"}
{:type "file" :mimeType "application/pdf" :data "JVBERi0x…" :name "report.pdf"}
{:type "audio" :mimeType "audio/wav" :data "UklGRi…"}
;; exactly one of :data / :url — both, or neither, is a construction error
;; the edge constructors — the path and the raw bytes never enter the part
(toolnexus.content/attach source) ; path | data: URL | http(s) URL | bytes
(toolnexus.content/attach source opts) ; opts = {:mimeType "…" :name "…" :max-part-bytes n}
(toolnexus.content/from-file path) ; read now, base64 now; mime from the fixed table
(toolnexus.content/image-file path) ; from-file, pinned to an "image" part
(toolnexus.content/from-bytes bs mime) ; byte array | seq of byte values | string (UTF-8)
(toolnexus.content/from-data-url u) ; data:;base64, -> {:mimeType :data}
(toolnexus.content/from-url u) ; http(s) -> {:url …}
(toolnexus.content/text-part s)
;; a failure is a VALUE with a stable :code — never a throw
{:type "error" :code "unsupported-source" :error "unsupported byte source: …"}
;; reading a part back
(toolnexus.content/validate-part part) ; part, or an error part
(toolnexus.content/validate-part part max-bytes)
(toolnexus.content/error-part? x)
(toolnexus.content/part-bytes part) ; DECODED bytes, never the +33% base64
(toolnexus.content/describe-part part) ; {:type :mimeType :bytes} — never :data
(toolnexus.content/summarize-part part) ; "image (image/png, 82 bytes)"
```
The non-text half of a message: `text | image | file | audio`, carrying base64 bytes or a URL plus a `mimeType` — never a path. One union covers all three directions: a part you attach to a prompt, a part a tool hands back under [`:parts`](/api/clojure/types/tool-result/), and a part that arrives from an MCP server.
## When to use it
[Section titled “When to use it”](#when-to-use-it)
* **Attaching an image, a PDF or an audio clip to a run.** A prompt is a string *or* a vector of parts: `(client/run c [(content/text-part "What changed?") (content/attach "shot.png")] {…})`. The vector is the canonical transcript shape; the provider blocks are built on the way out, per style, by `toolnexus.content/build-wire`.
* **Returning one from a tool.** A screenshot tool sets `:output` to a description and the image goes in `:parts` via [`toolnexus.tool/with-parts`](/api/clojure/types/tool-result/).
* **Reading one back** — `part-bytes` for size, `summarize-part` for the one-line, payload-free rendering §1B pins byte-identically across all eight ports.
## Why this and not the alternative
[Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative)
A part never holds a path — and this port takes bytes, not handles
**No paths.** `(attach "shot.png")` reads the file and base64s it *now*; the path never lands in the part. A part is persisted into a transcript, replayed later, and crossed over the MCP and A2A process boundaries — three places where a path means nothing, or worse, means a different file. So the edge constructors normalise at construction and store bytes-or-URL only.
**No stream or handle type.** SPEC §1B asks each port to accept the file objects its users already hold — `File`/`Blob` in js, `BinaryIO` in python, `io.Reader` in golang, `java.io.File` in java. **This port’s list is deliberately shorter.** It is one `.cljc` with no reader conditionals, compiled for two hosts, and naming `java.io.File` would make the namespace unloadable on cljgo — the one property the Clojure port exists to have. Host differences reach this file only through koine, and koine has no byte-stream abstraction to hide one behind: its byte surface is `fs/read-bytes` (whole file) and `codec/encode`, and `koine.stream` is Server-Sent Events, not bytes. So a host-neutral source is accepted instead — a **path**, a **native byte array** (JVM `byte[]` *and* Go `[]byte`), **any seq of byte values** (vector, list, lazy seq; signed or unsigned), or a string as its UTF-8 bytes. A handle-shaped source gets a **named error** saying so and saying what to pass, rather than the cast error it used to produce.
**Failures are data, never throws** — the rule `toolnexus.tool/execute` and `toolnexus.mcp` already set for this port. A bad part is an *error part* (`{:type "error" :code … :error …}`) that travels as a value; if one reaches request assembly, the loop stops the run and reports it as a `RunResult` with `:status "incomplete"` and `:limit "contentPart"`. No `ex-info` crosses the source boundary, so nothing above a tool has to catch.
## Examples
[Section titled “Examples”](#examples)
### 1. A text part and an image part
[Section titled “1. A text part and an image part”](#1-a-text-part-and-an-image-part)
```clojure
(require '[toolnexus.content :as content]
'[toolnexus.client :as client]
'[clojure.string :as str]
'[koine.fs :as fs])
;; The shared cross-language fixture lives at the repo root. Walk up to it so the
;; example runs from wherever you happen to be inside the tree.
(defn find-up [rel]
(->> (iterate (fn [p] (str "../" p)) rel) (take 8) (filter fs/exists?) first))
(def png (find-up "examples/media/fixture.png"))
;; the COMMITTED golden — trimmed, because a text file ends in a newline and base64 does not
(def golden (str/trim (str (fs/read-file (find-up "examples/media/fixture.png.base64")))))
(def prompt [(content/text-part "What is in this image?")
(content/attach png)])
(def img (second prompt))
(println "part:" (content/summarize-part img))
(assert (= "image" (:type img)))
(assert (= "image/png" (:mimeType img)))
(assert (= golden (:data img))) ; base64d at construction, byte-for-byte the golden
(assert (nil? (:url img))) ; exactly one of :data / :url
(assert (not (str/includes? (pr-str img) "fixture.png"))) ; the PATH is gone
;; §1B pins this string byte-identically in all eight ports; is DECODED
(assert (= "image (image/png, 82 bytes)" (content/summarize-part img)))
(assert (= 82 (content/part-bytes img)))
;; a log line or a §9 event renders a part WITHOUT its payload
(assert (= {:type "image" :mimeType "image/png" :bytes 82} (content/describe-part img)))
;; that vector is a prompt — this is the shape client/run turns it into
(def msg (client/user-message prompt))
(assert (= "user" (:role msg)))
(assert (content/part-array? (:content msg)))
(println "OK")
```
### 2. Every source this port accepts folds to the same bytes
[Section titled “2. Every source this port accepts folds to the same bytes”](#2-every-source-this-port-accepts-folds-to-the-same-bytes)
```clojure
(require '[toolnexus.content :as content]
'[toolnexus.tool :as tool]
'[clojure.string :as str]
'[koine.fs :as fs])
(defn find-up [rel]
(->> (iterate (fn [p] (str "../" p)) rel) (take 8) (filter fs/exists?) first))
(def png (find-up "examples/media/fixture.png"))
(def golden (str/trim (str (fs/read-file (find-up "examples/media/fixture.png.base64")))))
;; 1. a path — read now, base64 now
(def from-path (content/attach png))
;; 2. a native byte array: JVM byte[] on one host, Go []byte on the other, and
;; exactly what koine.fs/read-bytes hands back on both.
(def raw (fs/read-bytes png))
(def from-array (content/from-bytes raw "image/png"))
;; 3. a SEQ of byte values — the shape ordinary Clojure code produces. Signed
;; (straight off a JVM array) and unsigned (0-255) fold to the same base64.
(def signed (vec (seq raw)))
(def unsigned (map (fn [b] (bit-and (long b) 255)) (seq raw)))
(def from-vec (content/from-bytes signed "image/png"))
(def from-lazy (content/from-bytes unsigned "image/png"))
;; 4. a data: URL — parsed into {:mimeType :data}, NEVER kept as a :url
(def from-data-url (content/attach (str "data:image/png;base64," golden)))
(assert (= golden
(:data from-path) (:data from-array)
(:data from-vec) (:data from-lazy)
(:data from-data-url)))
(assert (= "image/png" (:mimeType from-data-url)))
(assert (nil? (:url from-data-url)))
;; An http(s) URL is the one source kept AS a url — nothing is fetched here.
(def remote (content/from-url "https://example.com/diagram.webp"))
(assert (= {:type "image" :mimeType "image/webp" :url "https://example.com/diagram.webp"} remote))
(assert (= 0 (content/part-bytes remote))) ; a url part carries no bytes
;; The table drives the part KIND, not just the mime: pdf is a "file", wav is "audio".
(assert (= "file" (:type (content/from-url "https://example.com/spec.pdf"))))
(assert (= "audio" (:type (content/from-url "https://example.com/clip.wav"))))
;; Returning one from a tool: :output stays the text every transcript sees.
(def shot
(tool/tool {:name "screenshot"
:description "Take a screenshot"
:execute (fn [_]
(let [p (content/image-file png)]
(tool/with-parts (tool/success (content/summarize-part p)) [p])))}))
(def r ((:execute shot) {}))
(println "tool output:" (:output r))
(assert (= "image (image/png, 82 bytes)" (:output r)))
(assert (= [from-path] (:parts r)))
(println "OK")
```
### 3. Every failure is a value with a name
[Section titled “3. Every failure is a value with a name”](#3-every-failure-is-a-value-with-a-name)
```clojure
(require '[toolnexus.content :as content]
'[clojure.string :as str])
;; --- both :data and :url, and neither -------------------------------------
(def conflict (content/validate-part
{:type "image" :mimeType "image/png" :data "AQ==" :url "https://x/y.png"}))
(println "conflict =>" (:code conflict) "-" (:error conflict))
(assert (content/error-part? conflict))
(assert (= "source-conflict" (:code conflict)))
(assert (= "source-missing"
(:code (content/validate-part {:type "image" :mimeType "image/png"}))))
;; --- an unknown extension is refused BY NAME, never sniffed ----------------
(def unknown (content/from-file "notes.xyz"))
(println "unknown =>" (:code unknown) "-" (:error unknown))
(assert (= "unknown-extension" (:code unknown)))
(assert (str/includes? (:error unknown) "xyz"))
;; …and an explicit :mimeType is the way through, exactly as the message says
(assert (nil? (:code (content/from-url "https://x/y.bin" {:mimeType "application/octet-stream"}))))
;; --- a handle-shaped source: this port says so instead of pretending -------
(def handle (content/from-bytes {:a :handle} "image/png"))
(println "handle =>" (:code handle))
(assert (= "unsupported-source" (:code handle)))
(assert (str/includes? (:error handle) "java.io.File")) ; names what you are likely holding
(assert (str/includes? (:error handle) "koine.fs/read-bytes")) ; and names the way out
;; the SOURCE is reported before a missing mime — a mime would not have fixed it
(assert (= "unsupported-source" (:code (content/from-bytes {:a :handle} ""))))
;; attach refuses it identically rather than throwing
(assert (= "unsupported-source" (:code (content/attach {:a :handle} {:mimeType "image/png"}))))
;; --- :max-part-bytes — DECODED bytes, enforced at request assembly ---------
(def big (content/from-bytes (repeat 4096 65) "image/png"))
(assert (= 4096 (content/part-bytes big))) ; the base64 is 5464 chars; the limit is not that
;; A part the CALLER attached that busts the limit stops the run before any HTTP call.
(def stopped (content/encode-parts [big] {:style "openai" :provenance "attached"
:max-part-bytes 1024}))
(println "assembly =>" (:code stopped) "-" (:error stopped))
(assert (= "too-large" (:code stopped)))
;; That :error / :code pair is what client/run reports as
;; {:status "incomplete" :limit "contentPart"} — a stopped RunResult, not an exception.
;; A part DERIVED from a tool or MCP result degrades to a placeholder and warns
;; once instead, so a remote server cannot fail a run you never asked to risk.
(content/reset-unsupported-warnings!)
(def degraded (content/encode-parts [big] {:style "openai" :provenance "derived"
:max-part-bytes 1024}))
(assert (nil? (:error degraded)))
(assert (= [{:type "text" :text "[unsupported image part (image/png, 4096 bytes)]"}]
(:blocks degraded)))
;; Under the limit, it is an ordinary image block.
(def fine (content/encode-parts [big] {:style "openai" :provenance "attached"
:max-part-bytes 8192}))
(assert (= "image_url" (:type (first (:blocks fine)))))
;; Nothing above threw. That is the contract, not a coincidence.
(println "OK")
```
## See also
[Section titled “See also”](#see-also)
* [`toolnexus.tool/success`](/api/clojure/types/tool-result/) — the result envelope whose `:parts` carries these
* [`toolnexus.tool/tool`](/api/clojure/types/tool/) — The uniform shape every tool source collapses to: name, description, JSON-Schema parameters, execute.
* [`toolnexus.builtin/builtin-tools`](/api/clojure/builtins/create/) — the `read` builtin returns a part for a known media file
# ToolContext — what execute receives
> Optional per-call context handed to execute: cancellation, identity, and host-supplied state. — Clojure.
**Clojure (JVM) + cljgo** · package `net.clojars.muthuishere/toolnexus` · [SPEC §1](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [clojure/src/toolnexus/tool.cljc](https://github.com/muthuishere/toolnexus/blob/main/clojure/src/toolnexus/tool.cljc)
Not available in Clojure
There is no ToolContext type in this port. The context is a plain map, passed as the optional third argument to `toolnexus.tool/execute`, and a tool receives it only if it was written to. A native tool opts in with `:ctx? true`.
Optional per-call context handed to execute: cancellation, identity, and host-supplied state.
## What to use instead
[Section titled “What to use instead”](#what-to-use-instead)
In Clojure an *optional trailing argument* is **arity**, not a nullable parameter. `toolnexus.tool/execute` calls `(f args)` when no context was supplied and `(f args ctx)` when one was, so a tool that wants the context is written multi-arity:
```clojure
(require '[toolnexus.tool :as tool])
(def t
(tool/tool {:name "answerer"
:execute (fn ([args] (tool/success "no context"))
([args ctx] (tool/success (str "answer: " (:answer ctx)))))}))
(def tk (tool/toolkit [t]))
(tool/execute tk "answerer" {}) ;; => {:output "no context" :isError false}
(tool/execute tk "answerer" {} {:answer "yes"}) ;; => {:output "answer: yes" :isError false}
```
The context is whatever map the caller passes. The port itself puts exactly one thing there: when a tool suspends and a `:wait-for` resolves it, the client re-executes the same call with `{:answer answer}` as the context. That is the resume path of SPEC §10 — the first execution has no context, the retry does, which is why the two-arity form is the portable contract here.
[`toolnexus.native/native-tool`](/api/clojure/native/define-tool/) takes a single-argument `:run` function by default and declares the context with a flag:
```clojure
(require '[toolnexus.native :as native])
;; no context — the common case
(native/native-tool {:name "upper"
:description "Uppercase the text"
:run (fn [args] (clojure.string/upper-case (str (:text args))))})
;; opts in: :run now receives (args ctx)
(native/native-tool {:name "confirm"
:description "Act on a resolved answer"
:ctx? true
:run (fn [args ctx]
(if (:answer ctx)
(str "confirmed " (:id args))
"no answer yet"))})
```
The flag exists because the two hosts share no portable way to ask a function how many arguments it takes: arity introspection is `java.lang.reflect` on the JVM and absent on cljgo. JavaScript, Python and Go can hand `run` both values and let the callee ignore the extra one; Clojure cannot. Passing two arguments to a one-argument `fn` is an arity error, not a silently ignored extra — so the flag is the honest version of what the other ports get for free. It costs one keyword and never guesses wrong.
There is no cancellation handle and no identity carried in the context by the port. If you need either, put it in the map yourself at the call site: the context is opaque to `execute`, which only decides whether to pass it.
## `:call-id` (0.22.0)
[Section titled “:call-id (0.22.0)”](#call-id-0220)
A tool run by the client loop gets `:call-id` on its context — the model’s id for that call (empty outside the loop). One message’s calls run concurrently, so this is how a tool tells sibling calls apart. It reaches a tool only through the two-argument arity with `:context? true` (every `native-tool` sets it); a one-argument tool is called exactly as before. The context also carries the run’s `:cancel` token.
## See also
[Section titled “See also”](#see-also)
* [`toolnexus.tool/tool`](/api/clojure/types/tool/) — the Tool whose `:execute` may take the context
* [`toolnexus.tool/success`](/api/clojure/types/tool-result/) — what `:execute` returns either way
* [`toolnexus.native/native-tool`](/api/clojure/native/define-tool/) — the `:ctx? true` opt-in
* [`toolnexus.client/wait-for`](/api/clojure/suspension/wait-for/) — the one place the port supplies a context of its own
# toolnexus.tool/tool
> The uniform shape every tool source collapses to: name, description, JSON-Schema parameters, execute. — Clojure.
**Clojure (JVM) + cljgo** · package `net.clojars.muthuishere/toolnexus` · [SPEC §1](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [clojure/src/toolnexus/tool.cljc](https://github.com/muthuishere/toolnexus/blob/main/clojure/src/toolnexus/tool.cljc)
```clojure
(toolnexus.tool/tool
{:name "get_weather" ; required
:description "Current weather" ; optional — "" when absent
:input-schema {:type "object"} ; optional — {:type "object"} when absent
:source "native" ; optional — "custom" when absent
:execute (fn [args] ...)}) ; required — may also take [args ctx]
;; =>
{:name "get_weather"
:description "Current weather"
:input-schema {:type "object"}
:source "native"
:execute #function[...]}
```
A Tool in this port is **a plain map with one closure in it**. No protocol, no record, no `deftype` — a map of data plus a `fn` needs nothing but `fn`, `get` and `assoc`, and that is the only shape that behaves identically on Clojure (JVM) and on cljgo, the two runtimes this one `.cljc` tree runs on unchanged. Everything MCP, skills, builtins, HTTP and A2A produce is this same map; `tool` is the constructor you call when the value comes from somewhere the port has no builder for.
The constructor does exactly three things: it asserts (`:pre`) that `:name` is present and `:execute` is a function, it `str`s the name, and it fills the three defaults above. There is no validation of `:input-schema` — it goes through to the adapters verbatim.
`:execute` returns a **ToolResult map**, not a string. Build it with [`toolnexus.tool/success`](/api/clojure/types/tool-result/) or `toolnexus.tool/failure`. If it throws instead, `toolnexus.tool/execute` turns the throw into a failure result: a misbehaving tool must not take down the loop (SPEC §0.8).
## When to use it
[Section titled “When to use it”](#when-to-use-it)
* **Adapting a source the port does not build for you** — a queue consumer, an in-process service, a fixture. Anything that can answer a map with a map can be a Tool.
* **Overriding a tool by name.** Registration order *is* the precedence rule: `toolnexus.tool/add-tools` lets a later tool win a name collision, which is exactly how SPEC §0.11 gives MCP precedence over a builtin of the same name.
* **Tests** — a two-line Tool with a deterministic result exercises the toolkit, the adapters and the client loop without a network or a child process.
## Why this and not the alternative
[Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative)
Reach for native-tool when you are wrapping a plain function
[`toolnexus.native/native-tool`](/api/clojure/native/define-tool/) wraps an ordinary function and **normalizes whatever it returns**: a string becomes the output, `nil` becomes empty output, and a map that already has `:output` passes through with `:isError` forced to a boolean. `toolnexus.tool/tool` does none of that — your `:execute` must return the ToolResult itself. Use `tool` when you are already producing results (writing a source adapter); use `native-tool` when you have a function that just returns a value.
`tool` also does **not** sanitize the name. SPEC §0.2 naming is applied by the sources that derive a name from a remote one — `toolnexus.mcp/mcp-tool-name` calls `toolnexus.tool/sanitize` for you. A name you write by hand is used as written, so keep it inside `[a-zA-Z0-9_-]` yourself, or run it through `sanitize`.
## Options
[Section titled “Options”](#options)
| Key | Required | Default | What it does |
| --------------- | -------- | ------------------ | -------------------------------------------------------------------------------------------------------------------------- |
| `:name` | yes | — | The name the model calls. `str`ed, never sanitized. |
| `:execute` | yes | — | `(fn [args])`, or a two-arity `fn` that also accepts `ctx`. Returns a ToolResult map. |
| `:description` | no | `""` | Handed to the model verbatim by the adapters. |
| `:input-schema` | no | `{:type "object"}` | JSON Schema for the arguments; passed through untouched. |
| `:source` | no | `"custom"` | Provenance label — `"mcp"`, `"native"`, `"builtin"`, `"a2a"`, `"http"`. Not behavioural; useful for filtering and metrics. |
## Examples
[Section titled “Examples”](#examples)
### 1. A tool written by hand, run through a toolkit
[Section titled “1. A tool written by hand, run through a toolkit”](#1-a-tool-written-by-hand-run-through-a-toolkit)
```clojure
(require '[toolnexus.tool :as tool])
(def greet
(tool/tool {:name "greet"
:description "Greet someone by name"
:input-schema {:type "object"
:properties {:name {:type "string"}}
:required ["name"]}
:source "native"
:execute (fn [args] (tool/success (str "Hello, " (:name args))))}))
(def tk (tool/toolkit [greet]))
(tool/execute tk "greet" {:name "Muthu"})
;; => {:output "Hello, Muthu" :isError false}
(tool/tool-names tk)
;; => ["greet"] — sorted, always: two runtimes must not disagree on order
```
### 2. Later wins — the mechanism behind MCP precedence
[Section titled “2. Later wins — the mechanism behind MCP precedence”](#2-later-wins--the-mechanism-behind-mcp-precedence)
```clojure
(require '[toolnexus.tool :as tool])
(def builtin-read
(tool/tool {:name "read" :source "builtin" :execute (fn [_] (tool/success "builtin"))}))
(def mcp-read
(tool/tool {:name "read" :source "mcp" :execute (fn [_] (tool/success "mcp"))}))
(def tk (-> (tool/toolkit [builtin-read])
(tool/add-tools [mcp-read])))
(count (:tools tk)) ;; => 1 — one name, one slot
(:output (tool/execute tk "read" {})) ;; => "mcp"
```
This is why [`toolnexus.core/build`](/api/clojure/toolkit/create/) registers builtins **first** and MCP **last**. The order in that function is load-bearing, not stylistic.
### 3. A throwing tool is still a well-behaved tool
[Section titled “3. A throwing tool is still a well-behaved tool”](#3-a-throwing-tool-is-still-a-well-behaved-tool)
```clojure
(require '[toolnexus.tool :as tool])
(def tk
(tool/toolkit [(tool/tool {:name "boom"
:execute (fn [_] (throw (ex-info "kaboom" {})))})]))
(tool/execute tk "boom" {})
;; => {:output "kaboom" :isError true}
(tool/execute tk "nope" {})
;; => {:output "unknown tool: nope" :isError true}
```
`toolnexus.tool/execute` catches `Throwable` — a bare symbol, which is the one catch target both hosts accept — so SPEC §0.8 is written once and holds on the JVM and on cljgo alike. An unknown name is the same kind of answer: a value the model can read and retry from, never an exception thrown at your loop.
## See also
[Section titled “See also”](#see-also)
* [`toolnexus.tool/success`](/api/clojure/types/tool-result/) — the result envelope `:execute` must return
* [The context argument](/api/clojure/types/context/) — the optional third argument to `execute`
* [`toolnexus.native/native-tool`](/api/clojure/native/define-tool/) — wrap a plain function instead
* [`toolnexus.core/build`](/api/clojure/toolkit/create/) — where Tools from every source are merged
# toolnexus.tool/success
> The result envelope: output text, optional error flag, optional non-text parts, and optional metadata that can carry a suspension. — Clojure.
**Clojure (JVM) + cljgo** · package `net.clojars.muthuishere/toolnexus` · [SPEC §1](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [clojure/src/toolnexus/tool.cljc](https://github.com/muthuishere/toolnexus/blob/main/clojure/src/toolnexus/tool.cljc)
```clojure
(toolnexus.tool/success output) ; => {:output "…" :isError false}
(toolnexus.tool/success output metadata) ; => {:output "…" :isError false :metadata {…}}
(toolnexus.tool/failure output) ; => {:output "…" :isError true}
(toolnexus.tool/failure output metadata) ; => {:output "…" :isError true :metadata {…}}
(toolnexus.tool/with-parts result parts) ; => result + {:parts [ContentPart …]}
;; an EMPTY or absent parts list leaves the result byte-identical to what it was
```
There is **no ToolResult type** in this port. A result is a plain map with two keys and two optional ones, built by one of these two functions. `output` is `str`ed on the way in — the model reads text, so `(success 42)` yields `"42"` rather than a number that each host would print differently. `metadata` is omitted from the map entirely when it is `nil`, so `(success "hi")` is exactly `{:output "hi" :isError false}` and nothing else.
They are named `success` and `failure`, not `ok` and `err`. cljgo’s `clojure.core` **has** `ok` and `err`; the JVM’s does not. Defining `ok` would shadow a core name on one host only — a hazard that fires on one runtime and not the other, which is precisely the class of drift this port exists to prevent. The suite asserts it: no public name in `toolnexus.tool` resolves in `clojure.core`, on either host.
`failure` is not an error-handling mechanism for *your* code. A tool error is a **value handed back to the model**, which is meant to read it and try something else. Throwing works too — `toolnexus.tool/execute` converts a throw into `(failure (ex-message e))` — but then the model gets whatever the exception happened to say. Call `failure` when you have a message worth writing.
## When to use it
[Section titled “When to use it”](#when-to-use-it)
* **Every `:execute` you write by hand.** [`toolnexus.tool/tool`](/api/clojure/types/tool/) does not normalize return values; the map you hand back is the map the loop sees.
* **Telling the model it went wrong, in words you chose** — a validation failure, a missing argument, a 404 from an upstream service.
* **Returning an image, a PDF or an audio clip** — `with-parts` puts it under `:parts`, while `:output` stays the text the transcript sees.
* **Attaching out-of-band data** via `metadata`. The loop never shows metadata to the model; it is read by the port itself (suspension) and by your own observability code.
## Why this and not the alternative
[Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative)
Let native-tool do it when your function just returns a value
[`toolnexus.native/native-tool`](/api/clojure/native/define-tool/) normalizes for you: a string becomes `success`, `nil` becomes `success` with empty output, and a map that already carries `:output` passes through with `:isError` coerced to a boolean. You only need `success` / `failure` explicitly when you want an **error** result, or metadata, or when you are constructing a Tool with `toolnexus.tool/tool` directly.
## What you get back
[Section titled “What you get back”](#what-you-get-back)
| Key | Type | Always present | What it is |
| ----------- | ------- | -------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
| `:output` | string | yes | The text the model sees. Non-strings are `str`ed. |
| `:isError` | boolean | yes | `false` from `success`, `true` from `failure`. |
| `:parts` | vector | no | Non-text output — §1B [ContentParts](/api/clojure/types/content-part/). Present only when `with-parts` was given a non-empty list. |
| `:metadata` | map | no | Present only when you passed one. `{:pending Request}` is the reserved §10 use. |
## Examples
[Section titled “Examples”](#examples)
### 1. Both results, and why output is always text
[Section titled “1. Both results, and why output is always text”](#1-both-results-and-why-output-is-always-text)
```clojure
(require '[toolnexus.tool :as tool])
(tool/success "hi") ;; => {:output "hi" :isError false}
(tool/failure "no such row") ;; => {:output "no such row" :isError true}
(:output (tool/success 42)) ;; => "42" — a number would print differently per host
(:metadata (tool/success "x" {:name "s"})) ;; => {:name "s"}
(contains? (tool/success "x") :metadata) ;; => false — omitted, not nil
```
### 2. A failure is a value the loop keeps going with
[Section titled “2. A failure is a value the loop keeps going with”](#2-a-failure-is-a-value-the-loop-keeps-going-with)
```clojure
(require '[toolnexus.tool :as tool])
(def lookup
(tool/tool {:name "lookup"
:description "Look a customer up by id"
:execute (fn [args]
(if-let [id (:id args)]
(tool/success (str "customer " id))
;; the model can read this and call again with an id
(tool/failure "missing required argument: id")))}))
(def tk (tool/toolkit [lookup]))
(tool/execute tk "lookup" {}) ;; => {:output "missing required argument: id" :isError true}
(tool/execute tk "lookup" {:id "42"}) ;; => {:output "customer 42" :isError false}
```
Nothing here throws, and nothing above the tool has to catch. That is the point of §0.8: the toolkit boundary converts every failure mode — a thrown exception, an unknown tool name, a dead MCP server — into this same two-key map.
### 3. Metadata carries a suspension
[Section titled “3. Metadata carries a suspension”](#3-metadata-carries-a-suspension)
SPEC §10 does not add a return type for “I need the human first”. A suspension is an ordinary failure result whose `metadata` holds a `:pending` Request, which is why every tool source can suspend without changing `:execute`’s signature.
```clojure
(require '[toolnexus.tool :as tool]
'[toolnexus.client :as client])
(def approval-request
(client/make-request "input" "Approve the refund?" {}))
(def result (tool/failure "Waiting for a response." {:pending approval-request}))
;; => {:output "Waiting for a response." :isError true :metadata {:pending {...}}}
(client/pending-of result) ;; => the Request — this result IS a suspension
(client/pending-of (tool/success "done")) ;; => nil
```
The client loop branches on `metadata.pending` alone and never on `:isError`, so the flag stays `true` here (the call did not produce a usable answer) without the run being counted as a tool error — the `tool` observability event carries `isError:false` plus a pending marker so error-rate metrics and circuit breakers do not fire.
### 4. Non-text output goes in `:parts` — and `:output` stays required
[Section titled “4. Non-text output goes in :parts — and :output stays required”](#4-non-text-output-goes-in-parts--and-output-stays-required)
`:output` is **not** optional when a part is present: it is what the transcript, compaction, token estimation and any text-only provider see. A tool returning an image sets `:output` to a description and `:parts` to the image. `with-parts` uses `cond->`, so a part-free result is byte-identical to what `success` / `failure` produced before `:parts` existed — nothing in an existing text-only tool moves by a byte.
```clojure
(require '[toolnexus.tool :as tool]
'[toolnexus.content :as content]
'[clojure.string :as str]
'[koine.fs :as fs])
(defn find-up [rel]
(->> (iterate (fn [p] (str "../" p)) rel) (take 8) (filter fs/exists?) first))
(def png (find-up "examples/media/fixture.png"))
(def shot
(tool/tool {:name "screenshot"
:description "Take a screenshot"
:execute (fn [_]
(let [p (content/image-file png)]
;; :output describes it; :parts carries it
(tool/with-parts (tool/success (content/summarize-part p)) [p])))}))
(def r ((:execute shot) {}))
(println "output:" (:output r))
(assert (= "image (image/png, 82 bytes)" (:output r)))
(assert (false? (:isError r)))
(assert (= 1 (count (:parts r))))
(assert (= "image/png" (:mimeType (first (:parts r)))))
;; An empty or absent parts list changes NOTHING — the compatibility story in one line.
(assert (= {:output "hi" :isError false} (tool/with-parts (tool/success "hi") [])))
(assert (= {:output "hi" :isError false} (tool/with-parts (tool/success "hi") nil)))
(assert (false? (contains? (tool/with-parts (tool/success "hi") []) :parts)))
;; A failure carries parts too — a rendering tool that failed halfway still has the frame.
(def bad (tool/with-parts (tool/failure "render timed out") [(content/text-part "partial")]))
(assert (true? (:isError bad)))
(assert (= 1 (count (:parts bad))))
(println "OK")
```
## See also
[Section titled “See also”](#see-also)
* [`toolnexus.content/attach`](/api/clojure/types/content-part/) — build the parts `:parts` carries
* [`toolnexus.tool/tool`](/api/clojure/types/tool/) — the Tool whose `:execute` returns this map
* [The context argument](/api/clojure/types/context/) — the optional third argument to `execute`
* [`toolnexus.client/suspend`](/api/clojure/suspension/pending/) — build the suspension result for you
* [`toolnexus.client/pending-of`](/api/clojure/suspension/pending-of/) — read the Request back out
# API Reference — C#
> The Toolnexus (NuGet) public surface — toolkit, client, skills, MCP, adapters — with each symbol's equivalents in the other five ports.
Package **`Toolnexus`** (NuGet, .NET). See the [combined API Reference](/api/) for every symbol shown in eight-language synced tabs.
## Toolkit
[Section titled “Toolkit”](#toolkit)
* `Toolkit.CreateAsync(Toolkit.Options opts)`
* `Options`: `McpConfig`, `SkillsDir`, `Skills`, `SkillProvider`, `SkillsFilter`, `SkillSampleLimit`, `Builtins`, `Agents`, `ExtraTools`, `WaitFor` (plus `With…` fluent setters).
* Equivalents: `createToolkit` (js) · `create_toolkit` (py) · `CreateToolkit` (go) · `Toolkit.create` (java) · `Toolnexus.create_toolkit` (elixir).
## Skills
[Section titled “Skills”](#skills)
* `SkillSource.Load(params string[] dirs)` — back-compat.
* `SkillSource.LoadWith(SkillSource.LoadOptions opts)`
* `SkillSource.ListSkills(LoadOptions opts) -> SkillInventory` — `.Skills`, `.Skipped` (`SkillSkip.Reason`; constants `SkillSkip.MissingName`, `Malformed`, `DuplicateName`, `Unreadable`).
* `record SkillDef(Name, Description, Content, Resources = null, Base = null)`
* `LoadOptions { Dirs, Skills, Filter, SampleLimit }`
* `SkillProvider` is a `Func>`.
* Equivalents: `loadSkills` / `listSkills` (js) · `load_skills` / `list_skills` (py) · `LoadSkillsWith` / `ListSkills` (go) · `SkillSource.loadWith` / `listSkills` (java) · `Toolnexus.Skill.load` / `Toolnexus.Skill.list` (elixir).
## MCP · Adapters · Client
[Section titled “MCP · Adapters · Client”](#mcp--adapters--client)
* `McpSource.LoadAsync(config)`
* `Adapters.ToOpenAI(tk)` · `ToAnthropic(tk)` · `ToGemini(tk)`
* `Client.Create(Client.Options)` → `await client.AskAsync(prompt, tk)`.
## Tools you build — native, HTTP, built-ins
[Section titled “Tools you build — native, HTTP, built-ins”](#tools-you-build--native-http-built-ins)
* `NativeTool.Of(name, description, schema, fn)` · `NativeTool.OfAsync(...)` — `(args, ToolContext)` overloads.
* `HttpTool.Of(HttpTool.Options)` — `{ Name, Description, Method, Url, Headers, Query, Body, ResultMode, Timeout }`; `Timeout` `long?` ms.
* `Builtins` toggle → ten built-ins: `bash · read · write · edit · grep · glob · webfetch · question · apply_patch · todowrite`. (`BuiltinTools.Create()` / `.Select(cfg)`.)
* Equivalents: `defineTool` / `httpTool` (js) · `define_tool` / `http_tool` (py) · `NativeTool` / `HTTPTool` (go) · `NativeTool.of` / `HttpTool.of` (java) · `Toolnexus.Native.define_tool` / `Toolnexus.Http.tool` (elixir).
## Sub-agents & teams
[Section titled “Sub-agents & teams”](#sub-agents--teams)
* `new Agent(name, new AgentSpec { Does, Uses, Soul, SoulFile, Team, Budget, Model, WaitFor })`.
* `Agent.RunAsync(RuntimeOptions, prompt): Task` — `.Status`, `.Text`, `.TotalTokens`.
* `Agent.AsTool(RuntimeOptions): ITool` · `Home.ComposeSoul(dir): (Soul, Found)`.
* Equivalents: `agents.agent` / `.run` / `.asTool` / `composeSoul` (js) · `agent` (py) · `agents.New` (go) · `Agents.agent` (java/elixir).
## A2A — serve & remote agents
[Section titled “A2A — serve & remote agents”](#a2a--serve--remote-agents)
* `tk.ServeAsync(addr, new Toolkit.ServeOptions { Client, A2A, OnTask, Mcp, OnCall }): Task`.
* `new Agent { Card, Headers, Timeout, PollEvery }` + `Options { Agents }` / `await tk.AddAgentAsync(cardUrl)`.
* Equivalents: `tk.serve` / `tk.addAgent` (js) · `serve` / `add_agent` (py) · `Serve` / `AddAgent` (go) · `serve` / `addAgent` (java) · `Toolkit.serve` / `Toolkit.add_agent` (elixir).
## Suspension & the human loop
[Section titled “Suspension & the human loop”](#suspension--the-human-loop)
* `Options.WithWaitFor(Func>)` (toolkit option).
* `record Request { Id, Kind, Prompt, Url?, Data?, ExpiresAt? }` · `record Answer { Id, Ok, Data?, Reason? }`.
* `ToolResult.Pending(Request)` · `ToolResult.AuthRequired(url, prompt?)` · `ToolResult.PendingOf(result): Request?`.
* Equivalents: `waitFor` / `pending` / `authRequired` / `pendingOf` (js) · `wait_for` / `pending` (py) · `WaitFor` / `Pending` (go) · `waitFor` / `ToolResult.pending` (java) · `wait_for` / `ToolResult.pending?` (elixir).
## Media generation
[Section titled “Media generation”](#media-generation)
Guide: [Media generation](/toolnexus/media/) · recipe: [Generate images, video and speech](/toolnexus/cookbook/media/) · SPEC §7G.
* `MediaStyle.Load(string path)` · `MediaStyle.Parse(string json)` · `MediaStyle.FromDictionary(dict)`.
* `Media.Create(MediaStyle style, MediaOptions? options = null)` · `Media.Create(IEnumerable styles, MediaOptions? options = null)` — a verb resolves to the first style that declares it.
* `MediaOptions { OnProgress: Action?, Progress: IProgress?, HttpClient: HttpClient?, HttpHandler: HttpMessageHandler? }` — `MediaProgress(Verb, JobId, Status)` after every poll.
* Verbs, each `Task XAsync(XInput input, CancellationToken cancellationToken = default)`: `ImageAsync(new ImageInput(prompt) { Width, Height, AspectRatio, Seed, ProviderOptions })` · `VideoAsync(new VideoInput(prompt) { Image, Seconds, AspectRatio, ProviderOptions })` · `SpeechAsync(new SpeechInput(text) { Voice, ProviderOptions })` · `TranscribeAsync(new TranscribeInput(audio) { ProviderOptions })` · `CloneVoiceAsync(new CloneVoiceInput(name, samples) { ProviderOptions })` · `TalkingAsync(new TalkingInput(image, audio) { ProviderOptions })`. `null` properties are not sent.
* `GenerateAsync(string verb, IDictionary? args, IDictionary? providerOptions = null, CancellationToken cancellationToken = default)` — the raw call.
* `MediaResult(IReadOnlyList Files, string Status, string? Text = null, string? JobId = null)` · `MediaFile(Type, MimeType, Url)` (`.ToPart()` → `ContentPart`).
* `media.Tools(): IReadOnlyList` (source `"media"`), or `Toolkit.Options.Media` / `.WithMedia(media)`.
* `RunResult.Files: IReadOnlyList` — `RunFile(Type, MimeType, Url, Data, From, CallId)`, in call order; the same parts are on `ToolCalls[i].Parts`.
* Errors: the function face throws `MediaException` (message is the exact §7G string); cancellation throws `OperationCanceledException`. A media tool never throws (`IsError`).
* Equivalents: `createMedia` (js) · `create_media` (py) · `CreateMedia` (go) · `Media.create` (java) · `Toolnexus.Media.new` (elixir) · `toolnexus.media/create-media` (clojure) · `create_media` (rust).
# Adapters.ToAnthropic
> Render any tool list as Anthropic tool-use schema. C#.
**C#** · package `Toolnexus` · [SPEC §4](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [Adapters.cs](https://github.com/muthuishere/toolnexus/blob/main/csharp/src/Toolnexus/Adapters.cs)
```csharp
public static List> ToAnthropic(IEnumerable tools)
```
Turns tools into the `tools` array an Anthropic Messages request expects. Same tools, same execution — only the schema envelope differs from OpenAI.
The Anthropic shape is the **flattest** of the three: one entry per tool, carrying `name`, `description` and `input_schema`, with no `type: "function"` wrapper around it.
## When to use it
[Section titled “When to use it”](#when-to-use-it)
When you are calling `POST /v1/messages` yourself — through the official SDK, a raw `HttpClient`, or a gateway that speaks the Anthropic wire format (Bedrock, Vertex) — and need schema to put in the request body.
## Why this and not the alternative
[Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative)
Skip this entirely if you use LlmClient.Create
[`LlmClient.Create`](/api/csharp/client/create/) picks the adapter from its style option and runs the whole tool-calling loop for you. Reach for `ToAnthropic` when you already own the loop and want toolnexus only for tool *aggregation*. And if you are talking to an OpenAI-compatible endpoint, use [`Adapters.ToOpenAI`](/api/csharp/adapters/openai/) instead — the two envelopes are not interchangeable.
## Examples
[Section titled “Examples”](#examples)
### 1. One tool to Anthropic schema
[Section titled “1. One tool to Anthropic schema”](#1-one-tool-to-anthropic-schema)
```csharp
using Toolnexus;
var weather = NativeTool.Of(
"get_weather",
"Current weather for a city",
new Dictionary
{
["type"] = "object",
["properties"] = new Dictionary { ["city"] = new Dictionary { ["type"] = "string" } },
["required"] = new[] { "city" },
},
(IDictionary a) => $"sunny in {a["city"]}");
var schema = Adapters.ToAnthropic(new ITool[] { weather });
if (schema.Count != 1) throw new Exception("expected 1 entry");
if (schema[0]["name"] as string != "get_weather") throw new Exception("name");
if (schema[0]["description"] as string != "Current weather for a city") throw new Exception("description");
// Flat: no "type"/"function" wrapper, and the schema key is input_schema.
if (schema[0].ContainsKey("function")) throw new Exception("there is no function wrapper here");
if (!schema[0].ContainsKey("input_schema")) throw new Exception("input_schema");
Console.WriteLine($"ok: {schema[0]["name"]}");
```
`ITool.InputSchema` becomes `input_schema` — snake\_case, because that is what the Anthropic wire format uses. Compare with OpenAI, where the same dictionary is nested under `function.parameters`.
### 2. Feeding it straight into a Messages request body
[Section titled “2. Feeding it straight into a Messages request body”](#2-feeding-it-straight-into-a-messages-request-body)
The output is plain dictionaries, so `System.Text.Json` serializes it with no converter.
```csharp
using System.Text.Json;
using Toolnexus;
ITool Mk(string name, string desc) => NativeTool.Of(
name, desc,
new Dictionary { ["type"] = "object", ["properties"] = new Dictionary() },
(IDictionary a) => name);
var tools = new[] { Mk("search", "Search the docs"), Mk("ping", "Health check") };
var body = new Dictionary
{
["model"] = "claude-sonnet-4-5",
["max_tokens"] = 1024,
["messages"] = new[] { new Dictionary { ["role"] = "user", ["content"] = "search for adapters" } },
["tools"] = Adapters.ToAnthropic(tools),
};
var entries = (List>)body["tools"]!;
if (entries.Count != 2) throw new Exception("expected 2 tools");
// Order is preserved.
var names = entries.Select(e => (string)e["name"]!).ToList();
if (names[0] != "search" || names[1] != "ping") throw new Exception($"order: {string.Join(",", names)}");
var json = JsonSerializer.Serialize(body);
if (!json.Contains("\"input_schema\"")) throw new Exception("expected input_schema on the wire");
Console.WriteLine($"ok: {string.Join(", ", names)}");
```
The key never goes in the body
The API key belongs in the `x-api-key` header and is read from the environment — `Environment.GetEnvironmentVariable("ANTHROPIC_API_KEY")`. Never write a real one into code, a test, or a docs example.
### 3. Round-tripping a `tool_use` block back to the tool
[Section titled “3. Round-tripping a tool\_use block back to the tool”](#3-round-tripping-a-tool_use-block-back-to-the-tool)
Schema out, tool call in. The `name` you advertised is the `name` the model returns.
```csharp
using System.Text.Json;
using Toolnexus;
var weather = NativeTool.Of(
"get_weather",
"Current weather for a city",
new Dictionary
{
["type"] = "object",
["properties"] = new Dictionary { ["city"] = new Dictionary { ["type"] = "string" } },
["required"] = new[] { "city" },
},
(IDictionary a) => $"sunny in {a["city"]}");
var tools = new ITool[] { weather };
if (Adapters.ToAnthropic(tools)[0]["name"] as string != "get_weather") throw new Exception("advertised name");
// What a model would send back: a content block whose `input` is a real JSON OBJECT.
const string block = """
{"type":"tool_use","id":"toolu_01","name":"get_weather","input":{"city":"Chennai"}}
""";
var parsed = JsonSerializer.Deserialize>(block)!;
var calledName = parsed["name"].GetString()!;
// NB: `args` is already the implicit parameter of a top-level program — pick another name.
var callArgs = parsed["input"].EnumerateObject()
.ToDictionary(p => p.Name, p => (object?)p.Value.ToString());
var called = tools.FirstOrDefault(t => t.Name == calledName)
?? throw new Exception("the advertised name should resolve back to the tool");
var res = await called.ExecuteAsync(callArgs);
if (res.IsError || res.Output != "sunny in Chennai") throw new Exception(res.Output);
// An empty tool list is valid — it just means "no tools this turn".
if (Adapters.ToAnthropic(Array.Empty()).Count != 0) throw new Exception("expected empty");
Console.WriteLine($"ok: {calledName} -> {res.Output}");
```
Caution
Unlike OpenAI, Anthropic sends `input` as a **JSON object**, not a JSON string — do not deserialize it a second time. You send the answer back as a `tool_result` content block carrying the matching `tool_use_id`; `ToolResult.IsError` maps to that block’s `is_error`.
## Shape
[Section titled “Shape”](#shape)
| Path | From | Notes |
| -------------------- | ------------------- | ----------------------------------------- |
| `[]["name"]` | `ITool.Name` | What the model calls back with. |
| `[]["description"]` | `ITool.Description` | |
| `[]["input_schema"]` | `ITool.InputSchema` | Renamed — `InputSchema` → `input_schema`. |
There is no wrapper key and no wrapper entry: an empty tool list produces an empty list.
## See also
[Section titled “See also”](#see-also)
* [`Adapters.ToOpenAI`](/api/csharp/adapters/openai/) · [`Adapters.ToGemini`](/api/csharp/adapters/gemini/)
* [`Toolkit.CreateAsync`](/api/csharp/toolkit/create/) — the toolkit exposes this applied to its own tools
* [`LlmClient.Create`](/api/csharp/client/create/) — calls the adapter for you
* [`ITool`](/api/csharp/types/tool/) — the four members the adapter reads
# Adapters.ToGemini
> Render any tool list as Gemini function-declaration schema. C#.
**C#** · package `Toolnexus` · [SPEC §4](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [Adapters.cs](https://github.com/muthuishere/toolnexus/blob/main/csharp/src/Toolnexus/Adapters.cs)
```csharp
public static List> ToGemini(IEnumerable tools)
```
Turns tools into the `tools` array a Gemini `generateContent` request expects. Same tools, same execution — only the schema envelope differs.
Gemini is the odd one out: it groups **all** tools inside a single wrapper object under `functionDeclarations`. So the returned list always has exactly **one** entry, no matter how many tools you passed.
## When to use it
[Section titled “When to use it”](#when-to-use-it)
When you are driving `generateContent` yourself — the Google GenAI SDK, a raw `HttpClient`, or Vertex AI — and need schema for the request body.
## Why this and not the alternative
[Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative)
Skip this entirely if you use LlmClient.Create
[`LlmClient.Create`](/api/csharp/client/create/) picks the adapter from its style option and runs the whole tool-calling loop for you. Reach for `ToGemini` when you already own the loop and want toolnexus only for tool *aggregation*. For an OpenAI-compatible endpoint — including Gemini’s own OpenAI compatibility layer — use [`Adapters.ToOpenAI`](/api/csharp/adapters/openai/) instead; the envelopes are not interchangeable.
## Examples
[Section titled “Examples”](#examples)
### 1. One tool to Gemini schema
[Section titled “1. One tool to Gemini schema”](#1-one-tool-to-gemini-schema)
```csharp
using Toolnexus;
var weather = NativeTool.Of(
"get_weather",
"Current weather for a city",
new Dictionary
{
["type"] = "object",
["properties"] = new Dictionary { ["city"] = new Dictionary { ["type"] = "string" } },
["required"] = new[] { "city" },
},
(IDictionary a) => $"sunny in {a["city"]}");
var schema = Adapters.ToGemini(new ITool[] { weather });
// ONE wrapper entry, always — the tools live inside it.
if (schema.Count != 1) throw new Exception($"expected 1 wrapper, got {schema.Count}");
var decls = (List