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-poster.jpg)](/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)schema[0]["functionDeclarations"]!; if (decls.Count != 1) throw new Exception("expected 1 declaration"); var fn = (IDictionary)decls[0]!; if (fn["name"] as string != "get_weather") throw new Exception("name"); if (fn["description"] as string != "Current weather for a city") throw new Exception("description"); if (!fn.ContainsKey("parameters")) throw new Exception("parameters"); Console.WriteLine($"ok: {fn["name"]}"); ``` Note the nesting and the cast: the wrapper’s value is a `List`, and each element is an `IDictionary`. `ITool.InputSchema` becomes `parameters` — same rename as OpenAI, one nesting level shallower. ### 2. Feeding it straight into a generateContent body [Section titled “2. Feeding it straight into a generateContent body”](#2-feeding-it-straight-into-a-generatecontent-body) The output is plain dictionaries and lists, 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 { ["contents"] = new[] { new Dictionary { ["role"] = "user", ["parts"] = new[] { new Dictionary { ["text"] = "search for adapters" } }, }, }, ["tools"] = Adapters.ToGemini(tools), }; // Two tools still means ONE wrapper. var wrappers = (List>)body["tools"]!; if (wrappers.Count != 1) throw new Exception($"expected 1 wrapper, got {wrappers.Count}"); var decls = (List)wrappers[0]["functionDeclarations"]!; // Order is preserved. var names = decls.Select(d => (string)((IDictionary)d!)["name"]!).ToList(); if (names[0] != "search" || names[1] != "ping") throw new Exception($"order: {string.Join(",", names)}"); var json = JsonSerializer.Serialize(body); if (!json.Contains("\"functionDeclarations\"")) throw new Exception("expected functionDeclarations on the wire"); Console.WriteLine($"ok: {string.Join(", ", names)}"); ``` camelCase, not snake\_case `functionDeclarations` is camelCase — that is the Gemini REST convention, and it is emitted literally. Do not run the body through a snake\_case naming policy. ### 3. Round-tripping a `functionCall` back, and the empty-list trap [Section titled “3. Round-tripping a functionCall back, and the empty-list trap”](#3-round-tripping-a-functioncall-back-and-the-empty-list-trap) ```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 }; // What a model would send back: a part with `args` as a real JSON OBJECT. const string part = """ {"functionCall":{"name":"get_weather","args":{"city":"Chennai"}}} """; var call = JsonSerializer.Deserialize>(part)!["functionCall"]; var calledName = call.GetProperty("name").GetString()!; // NB: `args` is already the implicit parameter of a top-level program — pick another name. var callArgs = call.GetProperty("args").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); // The trap: NO tools still yields one wrapper, with an empty declaration list — // unlike ToOpenAI/ToAnthropic, which return an empty list. Omit `tools` yourself // when you have none. var empty = Adapters.ToGemini(Array.Empty()); if (empty.Count != 1) throw new Exception($"expected 1 wrapper, got {empty.Count}"); if (((List)empty[0]["functionDeclarations"]!).Count != 0) throw new Exception("expected 0 declarations"); Console.WriteLine($"ok: {calledName} -> {res.Output} | empty wrappers: {empty.Count}"); ``` Caution Gemini returns a `functionCall` part whose `args` is a **JSON object**, not a JSON string. The reply goes back as a `functionResponse` part with the matching `name`. ## Shape [Section titled “Shape”](#shape) | Path | From | Notes | | ----------------------------- | ------------------- | -------------------------------------------------------------- | | `[0]` | — | The single wrapper entry. Always present, even for zero tools. | | `[0]["functionDeclarations"]` | — | `List` — one element per tool, in order. | | `…[i]["name"]` | `ITool.Name` | What the model calls back with. | | `…[i]["description"]` | `ITool.Description` | | | `…[i]["parameters"]` | `ITool.InputSchema` | Renamed — `InputSchema` → `parameters`. | ## See also [Section titled “See also”](#see-also) * [`Adapters.ToOpenAI`](/api/csharp/adapters/openai/) · [`Adapters.ToAnthropic`](/api/csharp/adapters/anthropic/) * [`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.ToOpenAI > Render any tool list as OpenAI function-calling 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> ToOpenAI(IEnumerable tools) ``` Turns tools into the `tools` array an OpenAI-shaped chat completion expects. This is the bridge between “toolnexus knows about these tools” and “the model can call them”. It takes an `IEnumerable`, so a LINQ query or a filtered `tk.Tools()` passes straight in without materialising a list first. ## When to use it [Section titled “When to use it”](#when-to-use-it) When you are driving the LLM call yourself and need schema to put in the request body. Every OpenAI-compatible endpoint takes this shape — OpenAI, OpenRouter, Groq, Together, a local Ollama, or your own gateway. ## Why this and not the client [Section titled “Why this and not the client”](#why-this-and-not-the-client) Skip this entirely if you use LlmClient.Create [`LlmClient.Create`](/api/csharp/client/create/) calls the right adapter for you based on its style option and runs the whole tool-calling loop. Reach for `ToOpenAI` when you already have your own loop or framework and only want toolnexus for tool *aggregation*. ## Examples [Section titled “Examples”](#examples) ### 1. One tool to OpenAI schema [Section titled “1. One tool to OpenAI schema”](#1-one-tool-to-openai-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.ToOpenAI(new ITool[] { weather }); if (schema.Count != 1) throw new Exception("expected 1 entry"); if (schema[0]["type"] as string != "function") throw new Exception("type"); var fn = (IDictionary)schema[0]["function"]!; if (fn["name"] as string != "get_weather") throw new Exception("name"); if (fn["description"] as string != "Current weather for a city") throw new Exception("description"); Console.WriteLine($"ok: {fn["name"]}"); ``` Note the nesting: OpenAI wraps each tool in `{"type":"function","function":{...}}`. The `InputSchema` on an `ITool` becomes `function.parameters` — the key is renamed. ### 2. Feeding it straight into a request body [Section titled “2. Feeding it straight into a request body”](#2-feeding-it-straight-into-a-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"] = "gpt-4o-mini", ["messages"] = new[] { new Dictionary { ["role"] = "user", ["content"] = "search for adapters" } }, ["tools"] = Adapters.ToOpenAI(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)((IDictionary)e["function"]!)["name"]!) .ToList(); if (names[0] != "search" || names[1] != "ping") throw new Exception($"order: {string.Join(",", names)}"); // Plain JSON — no custom converter needed. if (JsonSerializer.Serialize(body).Length == 0) throw new Exception("serialize"); Console.WriteLine($"ok: {string.Join(", ", names)}"); ``` ### 3. Round-tripping a call back to the tool [Section titled “3. Round-tripping a call back to the tool”](#3-round-tripping-a-call-back-to-the-tool) Schema out, tool call in. The `Name` the model returns is the same `Name` you look up. ```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 }; // What a model would send back. OpenAI encodes arguments as a JSON STRING. const string rawArgs = """{"city":"Chennai"}"""; const string calledName = "get_weather"; var parsed = JsonSerializer.Deserialize>(rawArgs)!; // NB: `args` is already the implicit parameter of a top-level program — pick another name. var callArgs = parsed.ToDictionary(kv => kv.Key, kv => (object?)kv.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.ToOpenAI(Array.Empty()).Count != 0) throw new Exception("expected empty"); Console.WriteLine($"ok: {calledName} -> {res.Output}"); ``` Caution OpenAI sends `arguments` as a **JSON string**, not an object. Deserialize it before handing it to `ExecuteAsync`, and note that `System.Text.Json` gives you `JsonElement` values — convert them to the shapes your tool expects. ## Shape [Section titled “Shape”](#shape) | Path | From | Notes | | ------------------------------- | ------------------- | --------------------------------------- | | `[]["type"]` | — | Always the literal `"function"`. | | `[]["function"]["name"]` | `ITool.Name` | What the model calls back with. | | `[]["function"]["description"]` | `ITool.Description` | | | `[]["function"]["parameters"]` | `ITool.InputSchema` | Renamed — `InputSchema` → `parameters`. | ## See also [Section titled “See also”](#see-also) * [`Adapters.ToAnthropic`](/api/csharp/adapters/anthropic/) · [`Adapters.ToGemini`](/api/csharp/adapters/gemini/) * [`Toolkit.CreateAsync`](/api/csharp/toolkit/create/) — `tk.ToOpenAI()` is this, applied to the toolkit * [`LlmClient.Create`](/api/csharp/client/create/) — calls the adapter for you # A2A.Agent > Point at a remote agent's card and use it exactly like a local tool. — C#. **C#** · package `Toolnexus` · [SPEC §7A](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [A2A.cs](https://github.com/muthuishere/toolnexus/blob/main/csharp/src/Toolnexus/A2A.cs) ```csharp public sealed class Agent { public string Card { get; set; } = ""; public IDictionary? Headers { get; set; } public long? Timeout { get; set; } // ms, default 300000 public long? PollEvery { get; set; } // ms, default 1000 } ``` A descriptor pointing at a remote peer’s Agent Card URL — not a factory function. Build one with an object initializer (`new Agent { Card = url }`) and hand it to [`A2A.AgentTools`](/api/csharp/agents/agent-tools/) directly, or drop it into `Toolkit.Options.Agents`/`Toolkit.AddAgentAsync` to have the toolkit resolve, isolate, and register its skills for you. ## When to use it [Section titled “When to use it”](#when-to-use-it) You have (or are given) the URL of another A2A agent’s card — your own, a teammate’s toolkit, or any third-party A2A peer — and want its skills to show up in your model’s tool list exactly like a native or MCP tool. `Card` is the only required field; `Headers`, `Timeout`, and `PollEvery` tune auth and the submit→poll budget per peer. ## Why this and not the alternative [Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative) Prefer Toolkit.Options.Agents / AddAgentAsync in an app `Agent` on its own is inert — nothing is fetched until you pass it to [`A2A.AgentTools`](/api/csharp/agents/agent-tools/) or a `Toolkit`. Reach for the `Toolkit` path (`Options.Agents` at startup, `AddAgentAsync` at runtime) when you want a **failing peer isolated** (logged, zero tools, never fatal) alongside your MCP servers and skills. Call `A2A.AgentTools` directly only when you want the resolution to **throw** on a bad card, e.g. to fail startup loudly for one agent you know must be reachable. ## Examples [Section titled “Examples”](#examples) ### 1. The smallest useful call — register a descriptor on a toolkit [Section titled “1. The smallest useful call — register a descriptor on a toolkit”](#1-the-smallest-useful-call--register-a-descriptor-on-a-toolkit) ```csharp using Toolnexus; // --- the peer: a toolkit exposing one skill, served as an A2A agent --- using var peerLlm = new Stub(ctx => Stub.Json(ctx, 200, """ {"choices":[{"message":{"content":"hi"}}],"usage":{"prompt_tokens":1,"completion_tokens":1,"total_tokens":2}} """)); await using var peer = await Toolkit.CreateAsync(new Toolkit.Options { Builtins = false, Skills = new List { new("greet", "says hello", "Say a friendly hello.") }, }); var peerClient = LlmClient.Create(new LlmClient.Options { BaseUrl = peerLlm.BaseUrl, Style = "openai", Model = "mock", ApiKey = "k", }); var handle = await peer.ServeAsync("127.0.0.1:0", new Toolkit.ServeOptions { Client = peerClient, A2A = new A2AConfig { Name = "desk" }, }); // --- the caller: a plain descriptor, registered on ITS OWN toolkit --- await using var tk = await Toolkit.CreateAsync(new Toolkit.Options { Builtins = false, Agents = new List { new() { Card = handle.Url + "/.well-known/agent-card.json" } }, }); var names = tk.Tools().Select(t => t.Name).ToList(); if (names.Count != 1 || names[0] != "desk_greet") throw new Exception(string.Join(",", names)); await handle.StopAsync(); Console.WriteLine($"ok: {string.Join(",", names)}"); sealed class Stub : IDisposable { readonly System.Net.HttpListener _listener = new(); readonly CancellationTokenSource _cts = new(); public int Port { get; } public string BaseUrl => $"http://127.0.0.1:{Port}"; public Stub(Action handler) { var probe = new System.Net.Sockets.TcpListener(System.Net.IPAddress.Loopback, 0); probe.Start(); Port = ((System.Net.IPEndPoint)probe.LocalEndpoint).Port; probe.Stop(); _listener.Prefixes.Add($"http://127.0.0.1:{Port}/"); _listener.Start(); _ = Task.Run(async () => { while (!_cts.IsCancellationRequested) { System.Net.HttpListenerContext ctx; try { ctx = await _listener.GetContextAsync(); } catch { break; } try { handler(ctx); } catch { } } }); } public static void Json(System.Net.HttpListenerContext ctx, int status, string body) { var bytes = System.Text.Encoding.UTF8.GetBytes(body); ctx.Response.StatusCode = status; ctx.Response.ContentType = "application/json"; ctx.Response.ContentLength64 = bytes.Length; ctx.Response.OutputStream.Write(bytes, 0, bytes.Length); ctx.Response.OutputStream.Close(); } public void Dispose() { _cts.Cancel(); try { _listener.Stop(); } catch { } try { _listener.Close(); } catch { } } } ``` ### 2. The realistic case — call the tool, round-trip through the real peer [Section titled “2. The realistic case — call the tool, round-trip through the real peer”](#2-the-realistic-case--call-the-tool-round-trip-through-the-real-peer) ```csharp using Toolnexus; using var peerLlm = new Stub(ctx => Stub.Json(ctx, 200, """ {"choices":[{"message":{"content":"hello, friend"}}],"usage":{"prompt_tokens":1,"completion_tokens":1,"total_tokens":2}} """)); await using var peer = await Toolkit.CreateAsync(new Toolkit.Options { Builtins = false, Skills = new List { new("greet", "says hello", "Say a friendly hello.") }, }); var peerClient = LlmClient.Create(new LlmClient.Options { BaseUrl = peerLlm.BaseUrl, Style = "openai", Model = "mock", ApiKey = "k", }); var handle = await peer.ServeAsync("127.0.0.1:0", new Toolkit.ServeOptions { Client = peerClient, A2A = new A2AConfig { Name = "desk" }, }); await using var tk = await Toolkit.CreateAsync(new Toolkit.Options { Builtins = false, Agents = new List { new() { Card = handle.Url + "/.well-known/agent-card.json", PollEvery = 25 }, }, }); var tool = tk.Get("desk_greet") ?? throw new Exception("tool not registered"); var result = await tool.ExecuteAsync(new Dictionary { ["task"] = "say hi" }); if (result.IsError) throw new Exception(result.Output); if (result.Output != "hello, friend") throw new Exception(result.Output); await handle.StopAsync(); Console.WriteLine($"ok: {result.Output}"); sealed class Stub : IDisposable { readonly System.Net.HttpListener _listener = new(); readonly CancellationTokenSource _cts = new(); public int Port { get; } public string BaseUrl => $"http://127.0.0.1:{Port}"; public Stub(Action handler) { var probe = new System.Net.Sockets.TcpListener(System.Net.IPAddress.Loopback, 0); probe.Start(); Port = ((System.Net.IPEndPoint)probe.LocalEndpoint).Port; probe.Stop(); _listener.Prefixes.Add($"http://127.0.0.1:{Port}/"); _listener.Start(); _ = Task.Run(async () => { while (!_cts.IsCancellationRequested) { System.Net.HttpListenerContext ctx; try { ctx = await _listener.GetContextAsync(); } catch { break; } try { handler(ctx); } catch { } } }); } public static void Json(System.Net.HttpListenerContext ctx, int status, string body) { var bytes = System.Text.Encoding.UTF8.GetBytes(body); ctx.Response.StatusCode = status; ctx.Response.ContentType = "application/json"; ctx.Response.ContentLength64 = bytes.Length; ctx.Response.OutputStream.Write(bytes, 0, bytes.Length); ctx.Response.OutputStream.Close(); } public void Dispose() { _cts.Cancel(); try { _listener.Stop(); } catch { } try { _listener.Close(); } catch { } } } ``` ### 3. Full surface — Timeout/PollEvery tuning, and a bad peer is isolated, not fatal [Section titled “3. Full surface — Timeout/PollEvery tuning, and a bad peer is isolated, not fatal”](#3-full-surface--timeoutpollevery-tuning-and-a-bad-peer-is-isolated-not-fatal) ```csharp using Toolnexus; using var peerLlm = new Stub(ctx => Stub.Json(ctx, 200, """ {"choices":[{"message":{"content":"pong"}}],"usage":{"prompt_tokens":1,"completion_tokens":1,"total_tokens":2}} """)); await using var peer = await Toolkit.CreateAsync(new Toolkit.Options { Builtins = false, Skills = new List { new("ping", "says pong", "Reply pong.") }, }); var peerClient = LlmClient.Create(new LlmClient.Options { BaseUrl = peerLlm.BaseUrl, Style = "openai", Model = "mock", ApiKey = "k", }); var handle = await peer.ServeAsync("127.0.0.1:0", new Toolkit.ServeOptions { Client = peerClient, A2A = new A2AConfig { Name = "reachable" }, }); // A reachable peer with a tuned poll budget, alongside one whose card can never be fetched — // registered together the way a real app would declare its `agents` config. await using var tk = await Toolkit.CreateAsync(new Toolkit.Options { Builtins = false, Agents = new List { new() { Card = handle.Url + "/.well-known/agent-card.json", Timeout = 5_000, PollEvery = 25 }, new() { Card = "http://127.0.0.1:1/.well-known/agent-card.json" }, // nothing listens on :1 }, }); // The bad agent is isolated: no exception, its skills just never appear. var names = tk.Tools().Select(t => t.Name).ToList(); if (names.Count != 1 || names[0] != "reachable_ping") throw new Exception(string.Join(",", names)); var result = await tk.ExecuteAsync("reachable_ping", new Dictionary { ["task"] = "ping" }); if (result.IsError || result.Output != "pong") throw new Exception(result.Output); await handle.StopAsync(); Console.WriteLine($"ok: {string.Join(",", names)} -> {result.Output}"); sealed class Stub : IDisposable { readonly System.Net.HttpListener _listener = new(); readonly CancellationTokenSource _cts = new(); public int Port { get; } public string BaseUrl => $"http://127.0.0.1:{Port}"; public Stub(Action handler) { var probe = new System.Net.Sockets.TcpListener(System.Net.IPAddress.Loopback, 0); probe.Start(); Port = ((System.Net.IPEndPoint)probe.LocalEndpoint).Port; probe.Stop(); _listener.Prefixes.Add($"http://127.0.0.1:{Port}/"); _listener.Start(); _ = Task.Run(async () => { while (!_cts.IsCancellationRequested) { System.Net.HttpListenerContext ctx; try { ctx = await _listener.GetContextAsync(); } catch { break; } try { handler(ctx); } catch { } } }); } public static void Json(System.Net.HttpListenerContext ctx, int status, string body) { var bytes = System.Text.Encoding.UTF8.GetBytes(body); ctx.Response.StatusCode = status; ctx.Response.ContentType = "application/json"; ctx.Response.ContentLength64 = bytes.Length; ctx.Response.OutputStream.Write(bytes, 0, bytes.Length); ctx.Response.OutputStream.Close(); } public void Dispose() { _cts.Cancel(); try { _listener.Stop(); } catch { } try { _listener.Close(); } catch { } } } ``` Caution `Card` must be the Agent Card URL itself (`.../.well-known/agent-card.json`), not the base origin — `A2A.AgentTools` does a plain `GET` on `Card`. The JSON-RPC endpoint peers `POST` to comes from the fetched card’s own `url` field, which may differ from `Card`’s origin. ## Fields [Section titled “Fields”](#fields) | Field | Type | What it is | | ----------- | ------------------------------ | ---------------------------------------------------------------------------------------------------------------------- | | `Card` | `string` | The Agent Card URL (`GET`). Required. | | `Headers` | `IDictionary?` | Sent on both the card `GET` and every JSON-RPC `POST`. `${ENV_VAR}` values are expanded at call time and never logged. | | `Timeout` | `long?` | Overall poll budget in ms. Default `300000`. | | `PollEvery` | `long?` | Interval between `GetTask` polls in ms. Default `1000`. | ## See also [Section titled “See also”](#see-also) * [`A2A.AgentTools`](/api/csharp/agents/agent-tools/) — Expand a remote agent card into one tool per advertised skill. * [`A2A.ParseAgentsConfig`](/api/csharp/agents/parse-config/) — Declare remote peers in config the way MCP servers are declared, with precedence rules. * [`A2AServer.Start`](/api/csharp/serve/a2a/) — The inbound counterpart: serve your own toolkit as the peer being called here. # A2A.AgentTools > Expand a remote agent card into one tool per advertised skill. — C#. **C#** · package `Toolnexus` · [SPEC §7A](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [A2A.cs](https://github.com/muthuishere/toolnexus/blob/main/csharp/src/Toolnexus/A2A.cs) ```csharp public static Task> AgentTools(Agent ag) ``` Fetches `ag.Card`, reads the Agent Card’s `skills[]`, and produces one `ITool` per skill — each named `sanitize(card.name) + "_" + sanitize(skill.id ?? skill.name)`, `Source == "a2a"`, with the uniform `{ task: string }` input schema. This is the resolution step underneath [`A2A.Agent`](/api/csharp/agents/agent/): a descriptor in, live tools out. ## When to use it [Section titled “When to use it”](#when-to-use-it) You want the list of tools a peer advertises *right now* — to inspect them, filter them, or wire them into a toolkit yourself — rather than letting `Toolkit.Options.Agents` do it silently at startup. It is also the one place a bad card surfaces as an **exception**, which is useful when a missing/unreachable peer should fail your build loudly instead of just vanishing. ## Why this and not the alternative [Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative) Toolkit isolates failures; AgentTools does not Called directly, `AgentTools` throws on an unreachable card or a non-2xx response — there is no `Status` map to check, unlike [`McpSource.LoadAsync`](/api/csharp/mcp/load/). `Toolkit.CreateAsync` and `AddAgentAsync` wrap this call in a try/catch and log-and-skip instead, which is almost always what you want for a fleet of peers. Reach for `AgentTools` directly when you specifically want that throw. ## Examples [Section titled “Examples”](#examples) ### 1. The smallest useful call — list a peer’s tools [Section titled “1. The smallest useful call — list a peer’s tools”](#1-the-smallest-useful-call--list-a-peers-tools) ```csharp using Toolnexus; using var peerLlm = new Stub(ctx => Stub.Json(ctx, 200, """ {"choices":[{"message":{"content":"ok"}}],"usage":{"prompt_tokens":1,"completion_tokens":1,"total_tokens":2}} """)); await using var peer = await Toolkit.CreateAsync(new Toolkit.Options { Builtins = false, Skills = new List { new("greet", "says hello", "Say a friendly hello.") }, }); var peerClient = LlmClient.Create(new LlmClient.Options { BaseUrl = peerLlm.BaseUrl, Style = "openai", Model = "mock", ApiKey = "k", }); var handle = await peer.ServeAsync("127.0.0.1:0", new Toolkit.ServeOptions { Client = peerClient, A2A = new A2AConfig { Name = "desk" }, }); var tools = await A2A.AgentTools(new Agent { Card = handle.Url + "/.well-known/agent-card.json" }); if (tools.Count != 1) throw new Exception($"expected 1 tool, got {tools.Count}"); var tool = tools[0]; if (tool.Name != "desk_greet") throw new Exception(tool.Name); if (tool.Source != "a2a") throw new Exception(tool.Source); var props = (IDictionary)tool.InputSchema["properties"]!; if (!props.ContainsKey("task")) throw new Exception("expected a \"task\" input property"); await handle.StopAsync(); Console.WriteLine($"ok: {tool.Name} ({tool.Source})"); sealed class Stub : IDisposable { readonly System.Net.HttpListener _listener = new(); readonly CancellationTokenSource _cts = new(); public int Port { get; } public string BaseUrl => $"http://127.0.0.1:{Port}"; public Stub(Action handler) { var probe = new System.Net.Sockets.TcpListener(System.Net.IPAddress.Loopback, 0); probe.Start(); Port = ((System.Net.IPEndPoint)probe.LocalEndpoint).Port; probe.Stop(); _listener.Prefixes.Add($"http://127.0.0.1:{Port}/"); _listener.Start(); _ = Task.Run(async () => { while (!_cts.IsCancellationRequested) { System.Net.HttpListenerContext ctx; try { ctx = await _listener.GetContextAsync(); } catch { break; } try { handler(ctx); } catch { } } }); } public static void Json(System.Net.HttpListenerContext ctx, int status, string body) { var bytes = System.Text.Encoding.UTF8.GetBytes(body); ctx.Response.StatusCode = status; ctx.Response.ContentType = "application/json"; ctx.Response.ContentLength64 = bytes.Length; ctx.Response.OutputStream.Write(bytes, 0, bytes.Length); ctx.Response.OutputStream.Close(); } public void Dispose() { _cts.Cancel(); try { _listener.Stop(); } catch { } try { _listener.Close(); } catch { } } } ``` ### 2. The realistic case — execute the resolved tool (SendMessage + poll GetTask) [Section titled “2. The realistic case — execute the resolved tool (SendMessage + poll GetTask)”](#2-the-realistic-case--execute-the-resolved-tool-sendmessage--poll-gettask) ```csharp using Toolnexus; using var peerLlm = new Stub(ctx => Stub.Json(ctx, 200, """ {"choices":[{"message":{"content":"42"}}],"usage":{"prompt_tokens":1,"completion_tokens":1,"total_tokens":2}} """)); await using var peer = await Toolkit.CreateAsync(new Toolkit.Options { Builtins = false, Skills = new List { new("answer", "answers questions", "Answer tersely.") }, }); var peerClient = LlmClient.Create(new LlmClient.Options { BaseUrl = peerLlm.BaseUrl, Style = "openai", Model = "mock", ApiKey = "k", }); var handle = await peer.ServeAsync("127.0.0.1:0", new Toolkit.ServeOptions { Client = peerClient, A2A = new A2AConfig { Name = "oracle" }, }); var tools = await A2A.AgentTools(new Agent { Card = handle.Url + "/.well-known/agent-card.json", PollEvery = 25, }); var tool = tools.Single(t => t.Name == "oracle_answer"); var result = await tool.ExecuteAsync(new Dictionary { ["task"] = "what is the answer?" }); if (result.IsError || result.Output != "42") throw new Exception(result.Output); // metadata carries the A2A bookkeeping: which agent, which task id, how many polls it took. if (result.Metadata?["agent"] as string != "oracle") throw new Exception("metadata.agent"); if (result.Metadata?["state"] as string != "completed") throw new Exception("metadata.state"); await handle.StopAsync(); Console.WriteLine($"ok: {result.Output} (polls={result.Metadata?["polls"]})"); sealed class Stub : IDisposable { readonly System.Net.HttpListener _listener = new(); readonly CancellationTokenSource _cts = new(); public int Port { get; } public string BaseUrl => $"http://127.0.0.1:{Port}"; public Stub(Action handler) { var probe = new System.Net.Sockets.TcpListener(System.Net.IPAddress.Loopback, 0); probe.Start(); Port = ((System.Net.IPEndPoint)probe.LocalEndpoint).Port; probe.Stop(); _listener.Prefixes.Add($"http://127.0.0.1:{Port}/"); _listener.Start(); _ = Task.Run(async () => { while (!_cts.IsCancellationRequested) { System.Net.HttpListenerContext ctx; try { ctx = await _listener.GetContextAsync(); } catch { break; } try { handler(ctx); } catch { } } }); } public static void Json(System.Net.HttpListenerContext ctx, int status, string body) { var bytes = System.Text.Encoding.UTF8.GetBytes(body); ctx.Response.StatusCode = status; ctx.Response.ContentType = "application/json"; ctx.Response.ContentLength64 = bytes.Length; ctx.Response.OutputStream.Write(bytes, 0, bytes.Length); ctx.Response.OutputStream.Close(); } public void Dispose() { _cts.Cancel(); try { _listener.Stop(); } catch { } try { _listener.Close(); } catch { } } } ``` ### 3. Full surface — multiple skills, and a bad card throws (unlike the Toolkit path) [Section titled “3. Full surface — multiple skills, and a bad card throws (unlike the Toolkit path)”](#3-full-surface--multiple-skills-and-a-bad-card-throws-unlike-the-toolkit-path) ```csharp using Toolnexus; using var peerLlm = new Stub(ctx => Stub.Json(ctx, 200, """ {"choices":[{"message":{"content":"ok"}}],"usage":{"prompt_tokens":1,"completion_tokens":1,"total_tokens":2}} """)); await using var peer = await Toolkit.CreateAsync(new Toolkit.Options { Builtins = false, Skills = new List { new("greet", "says hello", "Say a friendly hello."), new("farewell", "says goodbye", "Say a friendly goodbye."), }, }); var peerClient = LlmClient.Create(new LlmClient.Options { BaseUrl = peerLlm.BaseUrl, Style = "openai", Model = "mock", ApiKey = "k", }); var handle = await peer.ServeAsync("127.0.0.1:0", new Toolkit.ServeOptions { Client = peerClient, A2A = new A2AConfig { Name = "desk" }, }); var tools = await A2A.AgentTools(new Agent { Card = handle.Url + "/.well-known/agent-card.json" }); var names = tools.Select(t => t.Name).OrderBy(n => n, StringComparer.Ordinal).ToList(); if (!names.SequenceEqual(new[] { "desk_farewell", "desk_greet" })) throw new Exception(string.Join(",", names)); await handle.StopAsync(); // Called directly (not via Toolkit.CreateAsync/AddAgentAsync), a bad card THROWS — no isolation. var threw = false; try { await A2A.AgentTools(new Agent { Card = "http://127.0.0.1:1/.well-known/agent-card.json" }); } catch { threw = true; } if (!threw) throw new Exception("expected AgentTools to throw on an unreachable card"); Console.WriteLine($"ok: {string.Join(",", names)}; direct call threw on a bad card as expected"); sealed class Stub : IDisposable { readonly System.Net.HttpListener _listener = new(); readonly CancellationTokenSource _cts = new(); public int Port { get; } public string BaseUrl => $"http://127.0.0.1:{Port}"; public Stub(Action handler) { var probe = new System.Net.Sockets.TcpListener(System.Net.IPAddress.Loopback, 0); probe.Start(); Port = ((System.Net.IPEndPoint)probe.LocalEndpoint).Port; probe.Stop(); _listener.Prefixes.Add($"http://127.0.0.1:{Port}/"); _listener.Start(); _ = Task.Run(async () => { while (!_cts.IsCancellationRequested) { System.Net.HttpListenerContext ctx; try { ctx = await _listener.GetContextAsync(); } catch { break; } try { handler(ctx); } catch { } } }); } public static void Json(System.Net.HttpListenerContext ctx, int status, string body) { var bytes = System.Text.Encoding.UTF8.GetBytes(body); ctx.Response.StatusCode = status; ctx.Response.ContentType = "application/json"; ctx.Response.ContentLength64 = bytes.Length; ctx.Response.OutputStream.Write(bytes, 0, bytes.Length); ctx.Response.OutputStream.Close(); } public void Dispose() { _cts.Cancel(); try { _listener.Stop(); } catch { } try { _listener.Close(); } catch { } } } ``` Caution Skill ordering on the card is whatever the peer’s `SkillSource` iterates — treat the resulting tool list as a set, not a guaranteed order, unless you sort it yourself (as example 3 does). ## Parameters [Section titled “Parameters”](#parameters) | Parameter | Type | What it is | | --------- | ------- | ------------------------------------------------------------------------- | | `ag` | `Agent` | The descriptor to resolve — see [`A2A.Agent`](/api/csharp/agents/agent/). | ## See also [Section titled “See also”](#see-also) * [`A2A.Agent`](/api/csharp/agents/agent/) — Point at a remote agent’s card and use it exactly like a local tool. * [`A2A.ParseAgentsConfig`](/api/csharp/agents/parse-config/) — Declare remote peers in config the way MCP servers are declared, with precedence rules. # A2A.ParseAgentsConfig > Declare remote peers in config the way MCP servers are declared, with precedence rules. — C#. **C#** · package `Toolnexus` · [SPEC §7A](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [A2A.cs](https://github.com/muthuishere/toolnexus/blob/main/csharp/src/Toolnexus/A2A.cs) ```csharp public static List ParseAgentsConfig(object? block) ``` Parses an `agents` config block — a map of id → agent config, mirroring the `mcpServers` shape (§2) — into a list of [`Agent`](/api/csharp/agents/agent/) descriptors, skipping disabled entries. The config **key is only an identifier**; a tool’s name prefix always comes from the fetched card’s own `name`, never from the config key. ## When to use it [Section titled “When to use it”](#when-to-use-it) You’re loading a shared config file (or a parsed `Dictionary`) that declares remote peers the way it declares MCP servers, and want the same `enabled`/`disabled` precedence and shape validation `McpSource.ParseConfig` gives you for `mcpServers`. `Toolkit.CreateAsync` already calls this for you when a top-level `agents` key is present on `McpConfig` — reach for it directly when you want the parsed list before deciding what to do with it. ## Why this and not the alternative [Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative) Parsing never does I/O — building tools does `ParseAgentsConfig` is pure and synchronous: no card is fetched, no server touched. It just validates shape and precedence. Pass the result to [`A2A.AgentTools`](/api/csharp/agents/agent-tools/) (or `Toolkit.Options.Agents`) to actually resolve peers — the same “validate first, connect second” split [`McpSource.ParseConfig`](/api/csharp/mcp/parse-config/) uses for `mcpServers`. ## Examples [Section titled “Examples”](#examples) ### 1. The smallest useful call — one entry, defaults applied [Section titled “1. The smallest useful call — one entry, defaults applied”](#1-the-smallest-useful-call--one-entry-defaults-applied) ```csharp using Toolnexus; var block = new Dictionary { ["oracle"] = new Dictionary { ["card"] = "http://localhost:9/.well-known/agent-card.json" }, }; var agents = A2A.ParseAgentsConfig(block); if (agents.Count != 1) throw new Exception($"expected 1 agent, got {agents.Count}"); if (agents[0].Card != "http://localhost:9/.well-known/agent-card.json") throw new Exception(agents[0].Card); // Timeout/PollEvery unset here — the caller (or AgentTools) applies its own defaults. if (agents[0].Timeout != null) throw new Exception("expected no explicit timeout"); Console.WriteLine($"ok: {agents[0].Card}"); ``` ### 2. The realistic case — disabled entries skipped, key is not the prefix [Section titled “2. The realistic case — disabled entries skipped, key is not the prefix”](#2-the-realistic-case--disabled-entries-skipped-key-is-not-the-prefix) ```csharp using Toolnexus; var block = new Dictionary { // The config key "billing_desk" is just an identifier — it never becomes the tool prefix. ["billing_desk"] = new Dictionary { ["card"] = "http://localhost:9/.well-known/agent-card.json", ["headers"] = new Dictionary { ["Authorization"] = "Bearer ${DESK_TOKEN}" }, ["timeout"] = 15_000, ["pollEvery"] = 250, }, // MCP's isEnabled precedence: disabled:true wins over enabled. ["retired"] = new Dictionary { ["card"] = "http://localhost:9/retired-card.json", ["disabled"] = true, }, // enabled:false also skips. ["paused"] = new Dictionary { ["card"] = "http://localhost:9/paused-card.json", ["enabled"] = false, }, // No "card" ⇒ not a valid agent entry, silently skipped. ["malformed"] = new Dictionary { ["timeout"] = 1000 }, }; var agents = A2A.ParseAgentsConfig(block); if (agents.Count != 1) throw new Exception($"expected 1 agent, got {agents.Count}"); var a = agents[0]; if (a.Timeout != 15_000 || a.PollEvery != 250) throw new Exception($"{a.Timeout},{a.PollEvery}"); // Header VALUES are preserved raw here — ${ENV} expansion happens later, at call time. if (a.Headers?["Authorization"] != "Bearer ${DESK_TOKEN}") throw new Exception(a.Headers?["Authorization"]); Console.WriteLine($"ok: {agents.Count} enabled agent, headers unexpanded until call time"); ``` ### 3. Full surface — feeding the parsed list into a live Toolkit [Section titled “3. Full surface — feeding the parsed list into a live Toolkit”](#3-full-surface--feeding-the-parsed-list-into-a-live-toolkit) ```csharp using Toolnexus; using var peerLlm = new Stub(ctx => Stub.Json(ctx, 200, """ {"choices":[{"message":{"content":"reported"}}],"usage":{"prompt_tokens":1,"completion_tokens":1,"total_tokens":2}} """)); await using var peer = await Toolkit.CreateAsync(new Toolkit.Options { Builtins = false, Skills = new List { new("status", "reports status", "Report status.") }, }); var peerClient = LlmClient.Create(new LlmClient.Options { BaseUrl = peerLlm.BaseUrl, Style = "openai", Model = "mock", ApiKey = "k", }); var handle = await peer.ServeAsync("127.0.0.1:0", new Toolkit.ServeOptions { Client = peerClient, A2A = new A2AConfig { Name = "monitor" }, }); // A whole McpConfig-shaped document — mcpServers + a sibling top-level "agents" block, exactly // how Toolkit.CreateAsync reads it (McpSource.ParseConfig handles "mcpServers"; A2A.ParseAgentsConfig // handles "agents"). var config = new Dictionary { ["mcpServers"] = new Dictionary(), ["agents"] = new Dictionary { ["monitor"] = new Dictionary { ["card"] = handle.Url + "/.well-known/agent-card.json" }, }, }; var parsed = A2A.ParseAgentsConfig(((IDictionary)config)["agents"]); await using var tk = await Toolkit.CreateAsync(new Toolkit.Options { Builtins = false, Agents = parsed }); var tool = tk.Get("monitor_status") ?? throw new Exception("expected monitor_status to be registered"); var result = await tool.ExecuteAsync(new Dictionary { ["task"] = "how's it going?" }); if (result.IsError || result.Output != "reported") throw new Exception(result.Output); await handle.StopAsync(); Console.WriteLine($"ok: {tool.Name} -> {result.Output}"); sealed class Stub : IDisposable { readonly System.Net.HttpListener _listener = new(); readonly CancellationTokenSource _cts = new(); public int Port { get; } public string BaseUrl => $"http://127.0.0.1:{Port}"; public Stub(Action handler) { var probe = new System.Net.Sockets.TcpListener(System.Net.IPAddress.Loopback, 0); probe.Start(); Port = ((System.Net.IPEndPoint)probe.LocalEndpoint).Port; probe.Stop(); _listener.Prefixes.Add($"http://127.0.0.1:{Port}/"); _listener.Start(); _ = Task.Run(async () => { while (!_cts.IsCancellationRequested) { System.Net.HttpListenerContext ctx; try { ctx = await _listener.GetContextAsync(); } catch { break; } try { handler(ctx); } catch { } } }); } public static void Json(System.Net.HttpListenerContext ctx, int status, string body) { var bytes = System.Text.Encoding.UTF8.GetBytes(body); ctx.Response.StatusCode = status; ctx.Response.ContentType = "application/json"; ctx.Response.ContentLength64 = bytes.Length; ctx.Response.OutputStream.Write(bytes, 0, bytes.Length); ctx.Response.OutputStream.Close(); } public void Dispose() { _cts.Cancel(); try { _listener.Stop(); } catch { } try { _listener.Close(); } catch { } } } ``` Caution `block` that isn’t a `IDictionary` (null, a list, a scalar) returns an **empty list**, never a throw — matching `McpSource.ParseConfig`’s tolerance for a config document that simply has no `agents` key. ## Parameters [Section titled “Parameters”](#parameters) | Parameter | Type | What it is | | --------- | --------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | | `block` | `object?` | The `agents` value off a parsed config document — a map of id → `{ card, headers?, timeout?, pollEvery?, enabled?/disabled? }`. Anything else ⇒ empty list. | ## See also [Section titled “See also”](#see-also) * [`A2A.Agent`](/api/csharp/agents/agent/) — Point at a remote agent’s card and use it exactly like a local tool. * [`A2A.AgentTools`](/api/csharp/agents/agent-tools/) — Expand a remote agent card into one tool per advertised skill. # BackgroundShells > 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. — C#. **C#** · package `Toolnexus` · [SPEC §4A](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [BuiltinExtras.cs](https://github.com/muthuishere/toolnexus/blob/main/csharp/src/Toolnexus/BuiltinExtras.cs) ```csharp public sealed class BackgroundShells { public List List(); public Task KillAsync(string id); public Task CloseAllAsync(); public void CloseAll(); } public sealed record BackgroundShellInfo(string Id, string Command, DateTimeOffset Started, bool Running, int ExitCode, bool Killed); // Turned on through the builtins config: new Dictionary { ["background"] = new BackgroundShells() } ``` Opt-in. With a `BackgroundShells` under the builtins config key `background`: * `bash` gains `run_in_background: true`, which returns at once with `Metadata["backgroundId"]`. * **`bash_output {id}`** returns the output produced since the last read, then `[status: running|killed|exited with code N]` (`Metadata["status"]`, `Metadata["exitCode"]`). N is `-1` for a shell killed by a signal it did not get from `kill_shell`. * **`kill_shell {id}`** stops the whole process group: SIGTERM, `killGraceMs` (default 2000), SIGKILL. A background command is not tied to the turn that started it, so the host owns the registry and calls `CloseAll` on shutdown. POSIX only. The other opt-in builtins, `webSearch` and `notebookEdit`, are on [`BuiltinTools.Create`](/api/csharp/builtins/create/). ## When to use it [Section titled “When to use it”](#when-to-use-it) A coding agent that starts a dev server, a watcher or a long build and keeps working while it runs, checking the output later. ## Why this and not the alternative [Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative) Background shells, not a long bash timeout A plain `bash` call blocks the turn until the command ends or times out, and a server never ends. Use plain `bash` for anything that finishes; background shells for anything that should outlive the call. ## Examples [Section titled “Examples”](#examples) ### 1. Off by default, two more tools when on [Section titled “1. Off by default, two more tools when on”](#1-off-by-default-two-more-tools-when-on) ```csharp using Toolnexus; var plainNames = BuiltinTools.Create().Select(t => t.Name).ToList(); if (plainNames.Contains("bash_output")) throw new Exception("background tools must be opt-in"); var bg = new BackgroundShells(); var names = BuiltinTools.Create(new Dictionary { ["background"] = bg }).Select(t => t.Name).ToList(); if (!names.Contains("bash_output") || !names.Contains("kill_shell")) throw new Exception(string.Join(",", names)); bg.CloseAll(); Console.WriteLine($"ok: {plainNames.Count} default builtins, {names.Count} with background shells"); ``` ### 2. Start a command, read its output as it arrives [Section titled “2. Start a command, read its output as it arrives”](#2-start-a-command-read-its-output-as-it-arrives) ```csharp using Toolnexus; var bg = new BackgroundShells(); try { var tools = BuiltinTools.Create(new Dictionary { ["background"] = bg }); var bash = tools.Single(t => t.Name == "bash"); var output = tools.Single(t => t.Name == "bash_output"); var started = await bash.ExecuteAsync(new Dictionary { ["command"] = "echo one; sleep 0.2; echo two", ["run_in_background"] = true }); var id = (string)started.Metadata!["backgroundId"]!; var seen = ""; var deadline = DateTime.UtcNow.AddSeconds(5); while (true) { var o = await output.ExecuteAsync(new Dictionary { ["id"] = id }); seen += o.Output.Split("\n[status:")[0]; if ((string)o.Metadata!["status"]! == "exited") break; if (DateTime.UtcNow > deadline) throw new Exception("never exited"); await Task.Delay(50); } if (!seen.Contains("one") || !seen.Contains("two")) throw new Exception(seen); Console.WriteLine($"ok: {id} produced {seen.Replace("\n", " ").Trim()}"); } finally { bg.CloseAll(); } ``` ### 3. Kill a stubborn job, and close everything on shutdown [Section titled “3. Kill a stubborn job, and close everything on shutdown”](#3-kill-a-stubborn-job-and-close-everything-on-shutdown) ```csharp using Toolnexus; var bg = new BackgroundShells(); var tools = BuiltinTools.Create(new Dictionary { ["background"] = bg, ["killGraceMs"] = 100L }); var bash = tools.Single(t => t.Name == "bash"); var kill = tools.Single(t => t.Name == "kill_shell"); // Ignores SIGTERM and has a child: kill_shell still stops the whole group after killGraceMs. var r = await bash.ExecuteAsync(new Dictionary { ["command"] = "trap '' TERM; sleep 30 & wait", ["run_in_background"] = true }); var id = (string)r.Metadata!["backgroundId"]!; await Task.Delay(100); var k = await kill.ExecuteAsync(new Dictionary { ["id"] = id }); if (k.Metadata!["killed"] is not true) throw new Exception(k.Output); await bash.ExecuteAsync(new Dictionary { ["command"] = "sleep 30", ["run_in_background"] = true }); await bg.CloseAllAsync(); // what a host calls on shutdown if (bg.List().Any(j => j.Running)) throw new Exception("still running after CloseAll"); Console.WriteLine($"ok: {bg.List().Count} jobs, none running"); ``` ## See also [Section titled “See also”](#see-also) * [`BuiltinTools.Select`](/api/csharp/builtins/select/) — Pick which built-in file/shell/web tools are in play, by name or by source toggle. * [`BuiltinTools.Create`](/api/csharp/builtins/create/) — Construct every built-in tool directly, outside a toolkit. # BuiltinTools.Create > Construct every built-in tool directly, outside a toolkit. — C#. **C#** · package `Toolnexus` · [SPEC §4A](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [BuiltinTools.cs](https://github.com/muthuishere/toolnexus/blob/main/csharp/src/Toolnexus/BuiltinTools.cs) ```csharp public static List Create() ``` Builds all ten built-in tools — opencode’s set, ported with identical names and input schemas across every language — with **no config and no filtering**. Fixed order for parity: `bash`, `read`, `write`, `edit`, `grep`, `glob`, `webfetch`, `question`, `apply_patch`, `todowrite`. Every one obeys the uniform [`ITool`](/api/csharp/types/tool/)/[`ToolResult`](/api/csharp/types/tool-result/) contract — a failure is a `ToolResult` with `IsError: true`, never a thrown exception across the tool boundary — and each reports `Source == "builtin"`. Paths resolve relative to the process working directory unless absolute. ## When to use it [Section titled “When to use it”](#when-to-use-it) You want the full built-in set, unconditionally — no config value to honor, nothing to turn off. Useful for inspecting the whole set (schemas, names, count), or for building your own filtering on top of it instead of the `tools`-map shape `Select` understands. ## Why this and not the alternative [Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative) Select(cfg) when a config value exists `Create` always returns all ten. The moment you have a `builtins` config value — from `mcp.json`, from an agent definition, from anywhere — reach for [`BuiltinTools.Select`](/api/csharp/builtins/select/) instead; it calls `Create` internally and applies that config’s on/off/allowlist semantics. ## Examples [Section titled “Examples”](#examples) ### 1. All ten, in the fixed order [Section titled “1. All ten, in the fixed order”](#1-all-ten-in-the-fixed-order) ```csharp using Toolnexus; var tools = BuiltinTools.Create(); var names = tools.Select(t => t.Name).ToList(); var expected = new[] { "bash", "read", "write", "edit", "grep", "glob", "webfetch", "question", "apply_patch", "todowrite" }; if (!names.SequenceEqual(expected)) throw new Exception(string.Join(",", names)); if (tools.Any(t => t.Source != "builtin")) throw new Exception("expected every tool to be builtin"); Console.WriteLine($"ok: {tools.Count} built-ins in fixed order"); ``` ### 2. Calling one directly — read/write round-trip [Section titled “2. Calling one directly — read/write round-trip”](#2-calling-one-directly--readwrite-round-trip) ```csharp using Toolnexus; var tools = BuiltinTools.Create(); var write = tools.Single(t => t.Name == "write"); var read = tools.Single(t => t.Name == "read"); var path = Path.Combine(Path.GetTempPath(), $"toolnexus-docs-{Guid.NewGuid():N}.txt"); try { var wr = await write.ExecuteAsync(new Dictionary { ["path"] = path, ["content"] = "hello builtins" }); if (wr.IsError) throw new Exception(wr.Output); var rr = await read.ExecuteAsync(new Dictionary { ["path"] = path }); if (rr.IsError || rr.Output != "hello builtins") throw new Exception(rr.Output); // A missing required argument is a ToolResult error, never a thrown exception. var missing = await read.ExecuteAsync(new Dictionary()); if (!missing.IsError) throw new Exception("expected an error result for a missing path"); Console.WriteLine($"ok: wrote and read back \"{rr.Output}\""); } finally { File.Delete(path); } ``` ### 3. Feeding the full set into an adapter, outside any toolkit [Section titled “3. Feeding the full set into an adapter, outside any toolkit”](#3-feeding-the-full-set-into-an-adapter-outside-any-toolkit) ```csharp using Toolnexus; var tools = BuiltinTools.Create(); var schema = Adapters.ToOpenAI(tools); if (schema.Count != 10) throw new Exception($"expected 10, got {schema.Count}"); var fnNames = schema.Select(e => (string)((IDictionary)e["function"]!)["name"]!).ToList(); if (fnNames[0] != "bash" || fnNames[^1] != "todowrite") throw new Exception(string.Join(",", fnNames)); Console.WriteLine($"ok: {schema.Count} built-ins as OpenAI schema, first={fnNames[0]}, last={fnNames[^1]}"); ``` ## The ten built-ins [Section titled “The ten built-ins”](#the-ten-built-ins) | Name | What it does | | ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `bash` | Run a shell command; returns combined stdout+stderr. | | `read` | Read a file. A recognised media file (png/jpg/jpeg/gif/webp/pdf/mp3/wav) comes back as a [`ContentPart`](/api/csharp/types/content-part/); otherwise UTF-8 text, optionally windowed by offset/limit. Undecodable bytes are an `IsError` result — it decodes strictly, because `File.ReadAllText` silently substitutes U+FFFD and would hand the model garbage. | | `write` | Write (create/overwrite) a file, creating parent directories. | | `edit` | Exact-string replace, single-occurrence by default. | | `grep` | Regex search over file contents under a directory. | | `glob` | List files matching a glob under a directory. | | `webfetch` | HTTP GET a URL as text, markdown, or html. | | `question` | Ask the host a question; suspends via §10 (`kind:"question"`). | | `apply_patch` | Apply a Begin/End Patch grammar patch; atomic. | | `todowrite` | Replace the session todo list; returns the rendered list. | ## Opt-in builtins and 0.22 additions [Section titled “Opt-in builtins and 0.22 additions”](#opt-in-builtins-and-022-additions) Absent unless the builtins config turns them on; the default ten are unchanged. | Config key | Adds | | ------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- | | `background` (a [`BackgroundShells`](/api/csharp/builtins/background-shells/)) | `run_in_background` on `bash`, plus `bash_output` and `kill_shell` | | `webSearch` (a `WebSearchFn`: `(query, limit, ct) => Task>`) | `websearch` over the host’s provider (the library ships none) | | `notebookEdit` (`true`) | `notebook_edit`: replace / insert / delete an `.ipynb` cell by id or index | ```csharp WebSearchFn search = async (query, limit, ct) => await mySearch.QueryAsync(query, limit, ct); var tools = BuiltinTools.Create(new Dictionary { ["webSearch"] = search, ["notebookEdit"] = true, }); ``` Also new: `grep` takes `context`, `before` and `after` (match lines `path:N:text`, context lines `path-N-text`, `--` between groups). `read` and `grep` report a cut in metadata, the text stays the same: a `read` window that stops early sets `truncated`, `totalLines`, `nextOffset`; a `grep` cut by `limit` sets `truncated`, `total`. Every builtin carries [tool hints](/api/csharp/client/hooks/). ## See also [Section titled “See also”](#see-also) * [`BuiltinTools.Select`](/api/csharp/builtins/select/) — Pick which built-in file/shell/web tools are in play, by name or by source toggle. # BuiltinTools.Select > Pick which built-in file/shell/web tools are in play, by name or by source toggle. — C#. **C#** · package `Toolnexus` · [SPEC §4A](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [BuiltinTools.cs](https://github.com/muthuishere/toolnexus/blob/main/csharp/src/Toolnexus/BuiltinTools.cs) ```csharp public static List Select(object? cfg) ``` Resolves the active built-in tools for a config value. `cfg` is the same shape MCP servers use for `isEnabled`-style precedence: * `null` — default on: all ten built-ins. * `bool` — the whole source on (`true`) or off (`false`). * `IDictionary` with `disabled`/`enabled`/`tools` keys — `disabled: true` wins over everything; else `enabled: false` turns the whole source off; otherwise a `tools` name→bool map drops individual tools from the all-on baseline (`false`-mapped names are dropped, `true`/absent stay on, unknown names are ignored). Whole-source-off returns an empty list. Otherwise `Select` calls [`BuiltinTools.Create`](/api/csharp/builtins/create/) and filters it. ## When to use it [Section titled “When to use it”](#when-to-use-it) Wiring `Toolkit.Options.Builtins` — this is what the toolkit calls internally to decide which built-ins to expose. Call it directly when you want the filtered `ITool` list itself, outside a toolkit: for inspection, for a custom aggregation, or to report which built-ins a given config would turn on before you actually build anything. ## Why this and not the alternative [Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative) Create() for everything, Select(cfg) for the config-shaped subset [`BuiltinTools.Create`](/api/csharp/builtins/create/) always returns all ten, unconditionally — no config, no filtering. Reach for `Select` the moment you need to honor a user- or agent-supplied `builtins` config value, including turning the whole source off. ## Examples [Section titled “Examples”](#examples) ### 1. `null` means all ten are on [Section titled “1. null means all ten are on”](#1-null-means-all-ten-are-on) ```csharp using Toolnexus; var tools = BuiltinTools.Select(null); var names = tools.Select(t => t.Name).ToList(); var expected = new[] { "bash", "read", "write", "edit", "grep", "glob", "webfetch", "question", "apply_patch", "todowrite" }; if (!names.SequenceEqual(expected)) throw new Exception(string.Join(",", names)); Console.WriteLine($"ok: {tools.Count} built-ins, order: {string.Join(", ", names)}"); ``` ### 2. `false` turns the whole source off [Section titled “2. false turns the whole source off”](#2-false-turns-the-whole-source-off) ```csharp using Toolnexus; var off = BuiltinTools.Select(false); if (off.Count != 0) throw new Exception($"expected 0 tools, got {off.Count}"); // A dict with disabled:true wins even if enabled:true is also present. var alsoOff = BuiltinTools.Select(new Dictionary { ["enabled"] = true, ["disabled"] = true }); if (alsoOff.Count != 0) throw new Exception($"expected 0 tools, got {alsoOff.Count}"); Console.WriteLine("ok: false and disabled:true both turn every built-in off"); ``` ### 3. A `tools` map drops individual built-ins from the all-on baseline [Section titled “3. A tools map drops individual built-ins from the all-on baseline”](#3-a-tools-map-drops-individual-built-ins-from-the-all-on-baseline) ```csharp using Toolnexus; var cfg = new Dictionary { ["tools"] = new Dictionary { ["bash"] = false, // dropped ["apply_patch"] = false, // dropped ["read"] = true, // stays on (redundant with the baseline) // everything else is unmapped -> stays on (all-on baseline) }, }; var tools = BuiltinTools.Select(cfg); var names = tools.Select(t => t.Name).ToList(); if (names.Contains("bash") || names.Contains("apply_patch")) throw new Exception(string.Join(",", names)); if (!names.Contains("read") || !names.Contains("grep")) throw new Exception(string.Join(",", names)); if (names.Count != 8) throw new Exception($"expected 8, got {names.Count}: {string.Join(",", names)}"); Console.WriteLine($"ok: {names.Count} of 10 built-ins survive: {string.Join(", ", names)}"); ``` ## `cfg` shapes [Section titled “cfg shapes”](#cfg-shapes) | `cfg` | Result | | ----------------------------- | ------------------------------------------------------------------------- | | `null` | All ten built-ins. | | `false` | Empty list. | | `true` | All ten built-ins. | | `{ "disabled": true, ... }` | Empty list — wins over everything else. | | `{ "enabled": false, ... }` | Empty list, unless `disabled` is also set (same outcome). | | `{ "tools": { name: bool } }` | All-on baseline, minus any name mapped to `false`. Unknown names ignored. | ## See also [Section titled “See also”](#see-also) * [`BuiltinTools.Create`](/api/csharp/builtins/create/) — Construct every built-in tool directly, outside a toolkit. # 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. — C#. **C#** · package `Toolnexus` · [SPEC §8](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) Not available in C# 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) C# ships an ACP client, [`AcpClient`](https://github.com/muthuishere/toolnexus/blob/main/csharp/src/Toolnexus/Acp/AcpClient.cs), without the Go host hooks. What it does today: * **Permissions** are answered automatically with the first `allow*` option (`AutoAnswerPermission`, default `true`). There is no callback to choose an option or ask a human. To limit what the agent may do, start it in a restrictive mode with `SetModeAsync`. * **Updates**: only the agent’s message text forms the reply; `session/update` notifications are not surfaced to the host. * **Cancellation**: the `CancellationToken` on `PromptAsync` stops the wait; no `session/cancel` is sent. `PromptTimeout` (default 10 s) bounds a turn. * **Streaming**: none. Plug `acp.Generate` into an in-process client and use [`RunAsync`](/api/csharp/client/run/). ```csharp using var acp = await AcpClient.StartAsync("my-agent", new[] { "acp" }); acp.PromptTimeout = TimeSpan.FromMinutes(5); var text = await acp.PromptAsync("Summarize the repo", cancellationToken); ``` Go has all four hooks today (`ACPOptions.OnPermission`, `ACPOptions.OnUpdate`, `ACPClient.GenerateCtx`, `ACPClient.GenerateStream`); see the [Go page](/api/go/client/acp-hooks/). The C# port is tracked in `openspec/changes/add-go-host-asks`, task 2.6d. ## See also [Section titled “See also”](#see-also) * [`LlmClient.Create`](/api/csharp/client/create/) — The unified client: system prompt, skills injection, parallel and chained tool calls, retries, memory. * [`LlmClient.RunAsync`](/api/csharp/client/run/) — Send a prompt, let the loop call tools until the model stops, get a RunResult. * [`LlmClient.StreamAsync`](/api/csharp/client/stream/) — The streaming loop: text deltas, tool-call events, and suspension events as they happen. * [`LlmClient.Hooks`](/api/csharp/client/hooks/) — Intercept before/after model calls and tool calls: audit, redact, veto, or rewrite. # Options.ApiKeyEnv > 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. — C#. **C#** · package `Toolnexus` · [SPEC §8](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [LlmClientHost.cs](https://github.com/muthuishere/toolnexus/blob/main/csharp/src/Toolnexus/LlmClientHost.cs) ```csharp public sealed class LlmClient.Options { public string? ApiKey { get; set; } // used as-is when set public string? ApiKeyEnv { get; set; } // the NAME of an env var, never the value // ... } ``` Which key the client sends, and to whom. In order: 1. `ApiKey` set ⇒ only that. 2. Else `ApiKeyEnv` set ⇒ the env var it names. 3. Else ONLY the env var that belongs to the `BaseUrl` host, and only over https: `api.openai.com` → `OPENAI_API_KEY`, `api.anthropic.com` → `ANTHROPIC_API_KEY`, `openrouter.ai` (and subdomains) → `OPENROUTER_API_KEY`. 4. Any other host, or plain http, with neither set ⇒ `InvalidOperationException("No API key: set ApiKey or ApiKeyEnv …")`. There is no cross-provider fallback. The same rule applies to [`LlmClient.ListModelsAsync`](/api/csharp/client/list-models/). The key value is never logged. BREAKING in 0.22.0 Before 0.22.0, a client with no `ApiKey` took the first of `OPENROUTER_API_KEY`, `OPENAI_API_KEY`, `ANTHROPIC_API_KEY` that was set and sent it to whatever `BaseUrl` was configured. A host pointed at a local server (Ollama, llama.cpp, LM Studio) or a proxy that relied on that must now set `ApiKey` (any placeholder the server accepts) or `ApiKeyEnv`. ## When to use it [Section titled “When to use it”](#when-to-use-it) Set `ApiKey` when the host already holds the key (a secret store, a config value). Set `ApiKeyEnv` when the key lives in an env var whose name is not the provider’s own, which is the usual case for a local server, a corporate proxy, or a second OpenAI-compatible provider. Leave both unset only for the three hosted providers above, where the matching env var is read. ## Why this and not the alternative [Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative) ApiKeyEnv, not ApiKey = Environment.GetEnvironmentVariable(...) Both work. `ApiKeyEnv` keeps the value out of your own code and config objects (it is read at request time), so an options dump shows a name, not a secret. Reach for `ApiKey` when the key does not come from the environment at all. ## Examples [Section titled “Examples”](#examples) ### 1. A local server with an explicit key [Section titled “1. A local server with an explicit key”](#1-a-local-server-with-an-explicit-key) ```csharp using System.Net; using System.Text; using Toolnexus; string? auth = null; using var stub = new Stub((ctx, body) => { auth = ctx.Request.Headers["Authorization"]; Stub.Json(ctx, 200, """ {"id":"c1","choices":[{"message":{"role":"assistant","content":"hi"},"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":1,"total_tokens":4}} """); }); // Local / custom BaseUrl: the env is never consulted, so pass a key (any placeholder the server accepts). var client = LlmClient.Create(new LlmClient.Options { BaseUrl = stub.BaseUrl, Style = "openai", Model = "local-model", ApiKey = "YOUR_KEY_HERE", }); await using var tk = await Toolkit.CreateAsync(new Toolkit.Options()); var result = await client.RunAsync("hi", tk); if (auth != "Bearer YOUR_KEY_HERE") throw new Exception(auth); Console.WriteLine($"ok: {result.Text} with an explicit key"); sealed class Stub : IDisposable { readonly HttpListener _listener = new(); readonly CancellationTokenSource _cts = new(); public int Port { get; } public string BaseUrl => $"http://127.0.0.1:{Port}"; public Stub(Action handler) { var probe = new System.Net.Sockets.TcpListener(IPAddress.Loopback, 0); probe.Start(); Port = ((IPEndPoint)probe.LocalEndpoint).Port; probe.Stop(); _listener.Prefixes.Add($"http://127.0.0.1:{Port}/"); _listener.Start(); _ = Task.Run(async () => { while (!_cts.IsCancellationRequested) { HttpListenerContext ctx; try { ctx = await _listener.GetContextAsync(); } catch { break; } var body = new StreamReader(ctx.Request.InputStream).ReadToEnd(); try { handler(ctx, body); } catch { } } }); } public static void Json(HttpListenerContext ctx, int status, string body) { var bytes = Encoding.UTF8.GetBytes(body); ctx.Response.StatusCode = status; ctx.Response.ContentType = "application/json"; ctx.Response.ContentLength64 = bytes.Length; ctx.Response.OutputStream.Write(bytes, 0, bytes.Length); ctx.Response.OutputStream.Close(); } public void Dispose() { _cts.Cancel(); try { _listener.Stop(); } catch { } try { _listener.Close(); } catch { } } } ``` ### 2. No key for a custom host is an error, not a leak [Section titled “2. No key for a custom host is an error, not a leak”](#2-no-key-for-a-custom-host-is-an-error-not-a-leak) ```csharp using System.Net; using System.Text; using Toolnexus; using var stub = new Stub((ctx, body) => Stub.Json(ctx, 200, """ {"id":"c1","choices":[{"message":{"role":"assistant","content":"hi"},"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":1,"total_tokens":4}} """)); // Even with OPENAI_API_KEY set, a local host never receives it. Environment.SetEnvironmentVariable("OPENAI_API_KEY", "YOUR_KEY_HERE"); var client = LlmClient.Create(new LlmClient.Options { BaseUrl = stub.BaseUrl, Style = "openai", Model = "m" }); await using var tk = await Toolkit.CreateAsync(new Toolkit.Options()); try { await client.RunAsync("hi", tk); throw new Exception("expected a missing-key error"); } catch (InvalidOperationException e) when (e.Message.Contains("ApiKeyEnv")) { Console.WriteLine("ok: refused to send a provider key to a custom host"); } sealed class Stub : IDisposable { readonly HttpListener _listener = new(); readonly CancellationTokenSource _cts = new(); public int Port { get; } public string BaseUrl => $"http://127.0.0.1:{Port}"; public Stub(Action handler) { var probe = new System.Net.Sockets.TcpListener(IPAddress.Loopback, 0); probe.Start(); Port = ((IPEndPoint)probe.LocalEndpoint).Port; probe.Stop(); _listener.Prefixes.Add($"http://127.0.0.1:{Port}/"); _listener.Start(); _ = Task.Run(async () => { while (!_cts.IsCancellationRequested) { HttpListenerContext ctx; try { ctx = await _listener.GetContextAsync(); } catch { break; } var body = new StreamReader(ctx.Request.InputStream).ReadToEnd(); try { handler(ctx, body); } catch { } } }); } public static void Json(HttpListenerContext ctx, int status, string body) { var bytes = Encoding.UTF8.GetBytes(body); ctx.Response.StatusCode = status; ctx.Response.ContentType = "application/json"; ctx.Response.ContentLength64 = bytes.Length; ctx.Response.OutputStream.Write(bytes, 0, bytes.Length); ctx.Response.OutputStream.Close(); } public void Dispose() { _cts.Cancel(); try { _listener.Stop(); } catch { } try { _listener.Close(); } catch { } } } ``` ### 3. `ApiKeyEnv` names the variable to read [Section titled “3. ApiKeyEnv names the variable to read”](#3-apikeyenv-names-the-variable-to-read) ```csharp using System.Net; using System.Text; using Toolnexus; string? auth = null; using var stub = new Stub((ctx, body) => { auth = ctx.Request.Headers["Authorization"]; Stub.Json(ctx, 200, """ {"id":"c1","choices":[{"message":{"role":"assistant","content":"hi"},"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":1,"total_tokens":4}} """); }); // In real code this is set outside the process; the client reads it at request time. Environment.SetEnvironmentVariable("MY_PROXY_KEY", "YOUR_KEY_HERE"); var client = LlmClient.Create(new LlmClient.Options { BaseUrl = stub.BaseUrl, Style = "openai", Model = "m", ApiKeyEnv = "MY_PROXY_KEY", }); await using var tk = await Toolkit.CreateAsync(new Toolkit.Options()); await client.RunAsync("hi", tk); if (auth != "Bearer YOUR_KEY_HERE") throw new Exception(auth); Console.WriteLine("ok: key read from MY_PROXY_KEY"); sealed class Stub : IDisposable { readonly HttpListener _listener = new(); readonly CancellationTokenSource _cts = new(); public int Port { get; } public string BaseUrl => $"http://127.0.0.1:{Port}"; public Stub(Action handler) { var probe = new System.Net.Sockets.TcpListener(IPAddress.Loopback, 0); probe.Start(); Port = ((IPEndPoint)probe.LocalEndpoint).Port; probe.Stop(); _listener.Prefixes.Add($"http://127.0.0.1:{Port}/"); _listener.Start(); _ = Task.Run(async () => { while (!_cts.IsCancellationRequested) { HttpListenerContext ctx; try { ctx = await _listener.GetContextAsync(); } catch { break; } var body = new StreamReader(ctx.Request.InputStream).ReadToEnd(); try { handler(ctx, body); } catch { } } }); } public static void Json(HttpListenerContext ctx, int status, string body) { var bytes = Encoding.UTF8.GetBytes(body); ctx.Response.StatusCode = status; ctx.Response.ContentType = "application/json"; ctx.Response.ContentLength64 = bytes.Length; ctx.Response.OutputStream.Write(bytes, 0, bytes.Length); ctx.Response.OutputStream.Close(); } public void Dispose() { _cts.Cancel(); try { _listener.Stop(); } catch { } try { _listener.Close(); } catch { } } } ``` ## Fields [Section titled “Fields”](#fields) | Field | Type | What it is | | ----------- | --------- | ------------------------------------------------------- | | `ApiKey` | `string?` | The key itself. Wins over everything. | | `ApiKeyEnv` | `string?` | The name of the env var to read when `ApiKey` is empty. | ## See also [Section titled “See also”](#see-also) * [`LlmClient.Create`](/api/csharp/client/create/) — The unified client: system prompt, skills injection, parallel and chained tool calls, retries, memory. * [`LlmClient.RunAsync`](/api/csharp/client/run/) — Send a prompt, let the loop call tools until the model stops, get a RunResult. * [`LlmClient.StreamAsync`](/api/csharp/client/stream/) — The streaming loop: text deltas, tool-call events, and suspension events as they happen. * [`LlmClient.Hooks`](/api/csharp/client/hooks/) — Intercept before/after model calls and tool calls: audit, redact, veto, or rewrite. # LlmClient.Conversation > Keep a transcript across turns so the model remembers what it already did. — C#. **C#** · package `Toolnexus` · [SPEC §8](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [LlmClient.cs](https://github.com/muthuishere/toolnexus/blob/main/csharp/src/Toolnexus/LlmClient.cs) ```csharp public LlmClient.Conversation NewConversation(Toolkit toolkit) public sealed class Conversation { public Task SendAsync(string prompt, CancellationToken cancellationToken = default); public List Messages { get; } public void Reset(); } ``` An in-process object that carries a transcript forward for you. `client.NewConversation(toolkit)` returns one bound to that `Toolkit`; each `SendAsync` call runs [`RunAsync`](/api/csharp/client/run/) with the accumulated `Messages` as history, then replaces `Messages` with the result’s updated transcript — so the *next* `SendAsync` on the same instance continues where the last one left off, with no id and no store plumbing. `Reset()` drops the transcript and starts over. ## When to use it [Section titled “When to use it”](#when-to-use-it) A short-lived, single-process multi-turn exchange — a REPL, a CLI session, a single request handler juggling one chat — where the transcript only needs to live as long as the `Conversation` object does. ## Why this and not the alternative [Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative) Reach for AskAsync/IConversationStore when the transcript must outlive the process `Conversation` keeps its transcript as a private field — it vanishes when the object does. If a conversation needs to survive a process restart, or several processes need to share one thread by id, drive it through [`LlmClient.AskAsync`](/api/csharp/client/create/) with an `id` and a real [`IConversationStore`](/api/csharp/client/store/) instead. `Conversation` is the zero-plumbing in-memory shortcut for everything else. ## Examples [Section titled “Examples”](#examples) ### 1. The smallest useful conversation — two turns, one object [Section titled “1. The smallest useful conversation — two turns, one object”](#1-the-smallest-useful-conversation--two-turns-one-object) ```csharp using System.Net; using System.Text; using Toolnexus; var turn = 0; using var stub = new Stub(ctx => { turn++; var text = turn == 1 ? "hi there" : "still here"; var body = """ {"id":"cN","choices":[{"message":{"role":"assistant","content":"TEXT"},"finish_reason":"stop"}],"usage":{"prompt_tokens":5,"completion_tokens":2,"total_tokens":7}} """.Replace("cN", "c" + turn).Replace("TEXT", text); Stub.Json(ctx, 200, body); }); var client = LlmClient.Create(new LlmClient.Options { BaseUrl = stub.BaseUrl, Style = "openai", Model = "gpt-4o-mini", ApiKey = "test-key", }); await using var tk = await Toolkit.CreateAsync(new Toolkit.Options()); var conv = client.NewConversation(tk); var first = await conv.SendAsync("hello"); var second = await conv.SendAsync("still there?"); if (first.Text != "hi there") throw new Exception(first.Text); if (second.Text != "still here") throw new Exception(second.Text); Console.WriteLine($"ok: {first.Text} -> {second.Text}"); sealed class Stub : IDisposable { readonly HttpListener _listener = new(); readonly CancellationTokenSource _cts = new(); public int Port { get; } public string BaseUrl => $"http://127.0.0.1:{Port}"; public Stub(Action handler) { var probe = new System.Net.Sockets.TcpListener(IPAddress.Loopback, 0); probe.Start(); Port = ((IPEndPoint)probe.LocalEndpoint).Port; probe.Stop(); _listener.Prefixes.Add($"http://127.0.0.1:{Port}/"); _listener.Start(); _ = Task.Run(async () => { while (!_cts.IsCancellationRequested) { HttpListenerContext ctx; try { ctx = await _listener.GetContextAsync(); } catch { break; } try { handler(ctx); } catch { } } }); } public static void Json(HttpListenerContext ctx, int status, string body) { var bytes = Encoding.UTF8.GetBytes(body); ctx.Response.StatusCode = status; ctx.Response.ContentType = "application/json"; ctx.Response.ContentLength64 = bytes.Length; ctx.Response.OutputStream.Write(bytes, 0, bytes.Length); ctx.Response.OutputStream.Close(); } public void Dispose() { _cts.Cancel(); try { _listener.Stop(); } catch { } try { _listener.Close(); } catch { } } } ``` ### 2. Inspecting `Messages`, then `Reset()` [Section titled “2. Inspecting Messages, then Reset()”](#2-inspecting-messages-then-reset) ```csharp using System.Net; using System.Text; using Toolnexus; using var stub = new Stub(ctx => { Stub.Json(ctx, 200, """ {"id":"c1","choices":[{"message":{"role":"assistant","content":"noted"},"finish_reason":"stop"}],"usage":{"prompt_tokens":4,"completion_tokens":1,"total_tokens":5}} """); }); var client = LlmClient.Create(new LlmClient.Options { BaseUrl = stub.BaseUrl, Style = "openai", Model = "gpt-4o-mini", ApiKey = "test-key", SystemPrompt = "You are terse.", }); await using var tk = await Toolkit.CreateAsync(new Toolkit.Options()); var conv = client.NewConversation(tk); await conv.SendAsync("remember: the sky is blue"); // system + user + assistant = 3 messages after one turn. if (conv.Messages.Count != 3) throw new Exception($"expected 3 messages, got {conv.Messages.Count}"); conv.Reset(); if (conv.Messages.Count != 0) throw new Exception("Reset should drop the transcript entirely"); Console.WriteLine($"ok: 3 messages before reset, {conv.Messages.Count} after"); sealed class Stub : IDisposable { readonly HttpListener _listener = new(); readonly CancellationTokenSource _cts = new(); public int Port { get; } public string BaseUrl => $"http://127.0.0.1:{Port}"; public Stub(Action handler) { var probe = new System.Net.Sockets.TcpListener(IPAddress.Loopback, 0); probe.Start(); Port = ((IPEndPoint)probe.LocalEndpoint).Port; probe.Stop(); _listener.Prefixes.Add($"http://127.0.0.1:{Port}/"); _listener.Start(); _ = Task.Run(async () => { while (!_cts.IsCancellationRequested) { HttpListenerContext ctx; try { ctx = await _listener.GetContextAsync(); } catch { break; } try { handler(ctx); } catch { } } }); } public static void Json(HttpListenerContext ctx, int status, string body) { var bytes = Encoding.UTF8.GetBytes(body); ctx.Response.StatusCode = status; ctx.Response.ContentType = "application/json"; ctx.Response.ContentLength64 = bytes.Length; ctx.Response.OutputStream.Write(bytes, 0, bytes.Length); ctx.Response.OutputStream.Close(); } public void Dispose() { _cts.Cancel(); try { _listener.Stop(); } catch { } try { _listener.Close(); } catch { } } } ``` ### 3. Full surface — several turns, a tool call in the middle, and independent conversations [Section titled “3. Full surface — several turns, a tool call in the middle, and independent conversations”](#3-full-surface--several-turns-a-tool-call-in-the-middle-and-independent-conversations) ```csharp using System.Net; using System.Text; using Toolnexus; var turn = 0; using var stub = new Stub(ctx => { turn++; if (turn == 1) { Stub.Json(ctx, 200, """ {"id":"c1","choices":[{"message":{"role":"assistant","content":"hi Ada"},"finish_reason":"stop"}],"usage":{"prompt_tokens":5,"completion_tokens":2,"total_tokens":7}} """); } else if (turn == 2) { Stub.Json(ctx, 200, """ {"id":"c2","choices":[{"message":{"role":"assistant","content":null,"tool_calls":[{"id":"call_1","type":"function","function":{"name":"double","arguments":"{\"n\":21}"}}]},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":10,"completion_tokens":4,"total_tokens":14}} """); } else { Stub.Json(ctx, 200, """ {"id":"c3","choices":[{"message":{"role":"assistant","content":"double of 21 is 42"},"finish_reason":"stop"}],"usage":{"prompt_tokens":14,"completion_tokens":5,"total_tokens":19}} """); } }); var doubleTool = NativeTool.Of("double", "Double a number", null, (IDictionary a) => (Convert.ToInt32(a["n"]) * 2).ToString()); var client = LlmClient.Create(new LlmClient.Options { BaseUrl = stub.BaseUrl, Style = "openai", Model = "gpt-4o-mini", ApiKey = "test-key", }); await using var tk = await Toolkit.CreateAsync(new Toolkit.Options { ExtraTools = new List { doubleTool } }); // Two independent conversations on the same client/toolkit never share a transcript. var alice = client.NewConversation(tk); var bob = client.NewConversation(tk); var r1 = await alice.SendAsync("I'm Ada"); if (r1.Text != "hi Ada") throw new Exception(r1.Text); var r2 = await alice.SendAsync("what's double 21?"); // spans two stub turns (tool call + answer) if (r2.Text != "double of 21 is 42") throw new Exception(r2.Text); if (r2.ToolCallCount != 1 || r2.ToolCalls[0].Output != "42") throw new Exception(r2.ToolCalls[0].Output); if (bob.Messages.Count != 0) throw new Exception("a fresh Conversation must start with no history"); if (alice.Messages.Count == 0) throw new Exception("alice's transcript should have accumulated"); Console.WriteLine($"ok: {r2.Text} | alice has {alice.Messages.Count} messages, bob has {bob.Messages.Count}"); sealed class Stub : IDisposable { readonly HttpListener _listener = new(); readonly CancellationTokenSource _cts = new(); public int Port { get; } public string BaseUrl => $"http://127.0.0.1:{Port}"; public Stub(Action handler) { var probe = new System.Net.Sockets.TcpListener(IPAddress.Loopback, 0); probe.Start(); Port = ((IPEndPoint)probe.LocalEndpoint).Port; probe.Stop(); _listener.Prefixes.Add($"http://127.0.0.1:{Port}/"); _listener.Start(); _ = Task.Run(async () => { while (!_cts.IsCancellationRequested) { HttpListenerContext ctx; try { ctx = await _listener.GetContextAsync(); } catch { break; } try { handler(ctx); } catch { } } }); } public static void Json(HttpListenerContext ctx, int status, string body) { var bytes = Encoding.UTF8.GetBytes(body); ctx.Response.StatusCode = status; ctx.Response.ContentType = "application/json"; ctx.Response.ContentLength64 = bytes.Length; ctx.Response.OutputStream.Write(bytes, 0, bytes.Length); ctx.Response.OutputStream.Close(); } public void Dispose() { _cts.Cancel(); try { _listener.Stop(); } catch { } try { _listener.Close(); } catch { } } } ``` Caution `Conversation` is a thin client-side wrapper, not a durable store — it never touches `IConversationStore`. Two `NewConversation(tk)` calls on the same `LlmClient` are always independent transcripts, even if you never call `Reset()`. ## Members [Section titled “Members”](#members) | Member | Type | What it is | | -------------------------------------- | ----------------- | -------------------------------------------------------------------------------------------------------- | | `SendAsync(prompt, cancellationToken)` | `Task` | Run one turn with the accumulated history, then replace `Messages` with the result’s updated transcript. | | `Messages` | `List` | The current transcript, in provider message shape. Read-only view — mutate via `SendAsync`/`Reset`. | | `Reset()` | `void` | Drop the transcript; the next `SendAsync` starts fresh. | ## See also [Section titled “See also”](#see-also) * [`LlmClient.Create`](/api/csharp/client/create/) — The unified client: system prompt, skills injection, parallel and chained tool calls, retries, memory. * [`LlmClient.RunAsync`](/api/csharp/client/run/) — Send a prompt, let the loop call tools until the model stops, get a RunResult. * [`LlmClient.StreamAsync`](/api/csharp/client/stream/) — The streaming loop: text deltas, tool-call events, and suspension events as they happen. * [`LlmClient.Hooks`](/api/csharp/client/hooks/) — Intercept before/after model calls and tool calls: audit, redact, veto, or rewrite. # LlmClient.Create > The unified client: system prompt, skills injection, parallel and chained tool calls, retries, memory. — C#. **C#** · package `Toolnexus` · [SPEC §8](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) ```csharp public sealed class LlmClient { public static LlmClient Create(Options opts); public sealed class Options { public string BaseUrl { get; set; } = ""; public string Style { get; set; } = "openai"; // "openai" | "anthropic" public string Model { get; set; } = ""; public string? ApiKey { get; set; } // see "API key by host" below public string? ApiKeyEnv { get; set; } // NAME of the env var to read public IDictionary? Headers { get; set; } public string? SystemPrompt { get; set; } public int? MaxTurns { get; set; } // default 10 public Hooks? Hooks { get; set; } // Resilience — see LlmClient.ErrorInfo public int? Retries { get; set; } // default 2 public IReadOnlyCollection? RetryableStatuses { get; set; } public int? RetryBaseMs { get; set; } // default 500 public long? TimeoutMs { get; set; } // whole-run deadline; null = none public Func? OnError { get; set; } // Memory — see LlmClient.Conversation / IConversationStore public IConversationStore? Store { get; set; } // Observability — see MetricEvent public Action? OnMetric { get; set; } // Suspension (§10) — see LlmClient.WaitFor public Func>? WaitFor { get; set; } public Func>? WaitForCtx { get; set; } // Prompt cache + tool tiers (0.22) public PromptCacheOptions? PromptCache { get; set; } public ToolTierOptions? ToolTiers { get; set; } // Request shaping (§8 Gap 1/2) public IReadOnlyDictionary? RequestParams { get; set; } public Func, IDictionary?>? BodyTransform { get; set; } public HttpClient? HttpClient { get; set; } public HttpMessageHandler? HttpHandler { get; set; } // Multimodal parts (§1B / §8A) public long? MaxPartBytes { get; set; } public string? OnUnsupportedPart { get; set; } } } ``` The unified client: system prompt, skills injection, parallel and chained tool calls, retries, memory. `LlmClient.Create` is the one entry point that turns a `BaseUrl` + `Style` + `Model` into a client that can run a [`Toolkit`](/api/csharp/toolkit/create/) to completion — everything else on `Options` is either required wiring (the four fields above) or an opt-in override of a documented default, with its own page for the deep dive. API key by host (BREAKING in 0.22.0) With no `ApiKey`, the client reads `ApiKeyEnv` if set, else ONLY the env var of the `BaseUrl` host over https (`api.openai.com` → `OPENAI_API_KEY`, `api.anthropic.com` → `ANTHROPIC_API_KEY`, `openrouter.ai` → `OPENROUTER_API_KEY`). A local or custom `BaseUrl` now needs `ApiKey` or `ApiKeyEnv`; there is no cross-provider fallback. See [API key by host](/api/csharp/client/api-key/). Also new in 0.22.0: [`PromptCache`](/api/csharp/client/prompt-cache/), [`ToolTiers`](/api/csharp/client/tool-tiers/), [`WaitForCtx`](/api/csharp/suspension/wait-for/) and [`LlmClient.ListModelsAsync`](/api/csharp/client/list-models/). ## When to use it [Section titled “When to use it”](#when-to-use-it) Reach for `LlmClient.Create` whenever you need to talk to an LLM through the tool-calling loop — which is almost always, since [`RunAsync`](/api/csharp/client/run/) and [`StreamAsync`](/api/csharp/client/stream/) both hang off the client it returns. Point it at any OpenAI-shape endpoint (OpenAI itself, OpenRouter, a local vLLM/Ollama server) with `Style = "openai"`, or at Anthropic’s Messages API with `Style = "anthropic"` — the client speaks both wire formats from the same options shape, so switching providers is a two-field change, not a rewrite. ## Why this and not the alternative [Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative) Calling the provider's HTTP API directly Skip `LlmClient` only when you genuinely want a raw request/response with no tool-calling loop — a one-shot completion, a health check, a place where you’d rather hand-roll retries yourself. The moment tools, streaming, suspension, or a multi-turn conversation enter the picture, those are exactly what `LlmClient` exists to give you for free: `Options` only ever *adds* behavior to a byte-identical baseline (no `Store` ⇒ default in-memory history; no `OnError` ⇒ the default retry-vs-fail classifier; no `WaitFor` ⇒ a suspension returns `Status = "pending"` instead of hanging), so leaving every optional field unset is itself a valid, fully-supported configuration. ## Examples [Section titled “Examples”](#examples) ### 1. The smallest useful call — required fields only [Section titled “1. The smallest useful call — required fields only”](#1-the-smallest-useful-call--required-fields-only) ```csharp using Toolnexus; var client = LlmClient.Create(new LlmClient.Options { BaseUrl = "https://openrouter.ai/api/v1", Style = "openai", // or "anthropic" Model = "anthropic/claude-3.5-sonnet", ApiKey = Environment.GetEnvironmentVariable("OPENROUTER_API_KEY"), }); await using var tk = await Toolkit.CreateAsync(new Toolkit.Options()); var res = await client.RunAsync("List the files in the current folder, then summarise the README.", tk); Console.WriteLine(res.Text); ``` ### 2. The realistic case — a bounded, observable run [Section titled “2. The realistic case — a bounded, observable run”](#2-the-realistic-case--a-bounded-observable-run) ```csharp using Toolnexus; var client = LlmClient.Create(new LlmClient.Options { BaseUrl = "https://api.openai.com/v1", Style = "openai", Model = "gpt-4o-mini", ApiKey = Environment.GetEnvironmentVariable("OPENAI_API_KEY"), SystemPrompt = "You are a careful, concise coding assistant.", MaxTurns = 6, Retries = 3, TimeoutMs = 30_000, OnMetric = ev => Console.Error.WriteLine($"[metric] {ev.Kind} {ev.Model}"), }); await using var tk = await Toolkit.CreateAsync(new Toolkit.Options { McpConfig = "mcp.json", SkillsDir = new[] { "skills" }, }); var res = await client.RunAsync("Triage the open issues and propose a fix plan.", tk); Console.WriteLine(res.Text); ``` ### 3. The full surface — every optional slot named [Section titled “3. The full surface — every optional slot named”](#3-the-full-surface--every-optional-slot-named) ```csharp using Toolnexus; var client = LlmClient.Create(new LlmClient.Options { BaseUrl = "https://api.anthropic.com/v1", Style = "anthropic", Model = "claude-sonnet-4-5", ApiKey = Environment.GetEnvironmentVariable("ANTHROPIC_API_KEY"), Headers = new Dictionary { ["anthropic-version"] = "2023-06-01" }, SystemPrompt = "You are the on-call triage agent.", MaxTurns = 12, // resilience — see LlmClient.ErrorInfo Retries = 3, RetryBaseMs = 500, RetryableStatuses = new[] { 520, 521 }, // widen the default 429/500/502/503/504/529 set TimeoutMs = 60_000, OnError = info => info.Status == 402 ? LlmClient.Tier.Fail : LlmClient.Tier.Retry, // memory — see LlmClient.Conversation / IConversationStore Store = new MyDurableStore(), // observability — see MetricEvent OnMetric = ev => Metrics.Record(ev), // suspension (§10) — see LlmClient.WaitFor WaitFor = async request => await MyApprovalQueue.ResolveAsync(request), // request shaping — see the "See also" pages below RequestParams = new Dictionary { ["max_tokens"] = 8192 }, BodyTransform = body => { body["metadata"] = new { team = "triage" }; return body; }, HttpClient = MySharedHttpClient, // multimodal parts (§1B/§8A) MaxPartBytes = 5 * 1024 * 1024, OnUnsupportedPart = "text", }); await using var tk = await Toolkit.CreateAsync(new Toolkit.Options { McpConfig = "mcp.json" }); var res = await client.RunAsync("go", tk); Console.WriteLine(res.Text); ``` ## See also [Section titled “See also”](#see-also) * [`Toolkit.CreateAsync`](/api/csharp/toolkit/create/) — Collapse MCP servers, skills, built-ins, A2A agents and your own functions into one tool list — the `tk` every example above passes to `RunAsync`. * [`LlmClient.RunAsync`](/api/csharp/client/run/) — Send a prompt, let the loop call tools until the model stops, get a RunResult. * [`LlmClient.StreamAsync`](/api/csharp/client/stream/) — The streaming loop: text deltas, tool-call events, and suspension events as they happen. * [`LlmClient.ErrorInfo`](/api/csharp/client/resilience/) — Classify an LLM error into retry/fail, bound a run with a deadline, and cancel cleanly. * [`LlmClient.IConversationStore`](/api/csharp/client/store/) — Swap in-memory history for your own store so a conversation survives a process restart. * [`LlmClient.Conversation`](/api/csharp/client/conversation/) — Keep a transcript across turns so the model remembers what it already did. * [`MetricEvent`](/api/csharp/client/metrics/) — Emit one metric event per model call and per tool call: tokens, latency, outcome. * [`LlmClient.Hooks`](/api/csharp/client/hooks/) — Intercept before/after model calls and tool calls: audit, redact, veto, or rewrite. * [`ProviderException`](/api/csharp/client/provider-error/) — The typed error a failed provider call raises, including the port-specific `RetryAfterMs` (milliseconds). # LlmClient.Hooks > Intercept before/after model calls and tool calls: audit, redact, veto, or rewrite. — C#. **C#** · package `Toolnexus` · [SPEC §8](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [LlmClient.cs](https://github.com/muthuishere/toolnexus/blob/main/csharp/src/Toolnexus/LlmClient.cs) ```csharp public sealed class Hooks { public Func? BeforeLLM { get; set; } public Action? AfterLLM { get; set; } public Func? BeforeTool { get; set; } public Func? AfterTool { get; set; } } ``` Four lifecycle interception points, set on `LlmClient.Options.Hooks` and invoked on every turn of [`RunAsync`](/api/csharp/client/run/) / [`StreamAsync`](/api/csharp/client/stream/): * **`BeforeLLM`** — runs just before each model call. Return an `LLMOverride` to rewrite the `Messages`/`Tools` that will be sent (e.g. redact a message, trim history); return `null` to send them unchanged. * **`AfterLLM`** — runs just after each model call, given the raw response. Observation only — nothing to override. * **`BeforeTool`** — runs just before a tool executes. Return `ToolOverride.WithArgs(...)` to rewrite the arguments, `ToolOverride.WithResult(...)` to skip execution entirely and substitute a result (deny/cache/dry-run), or `null` to run the tool as called. * **`AfterTool`** — runs just after a tool executes (skipped for a §10 suspension — that path isn’t a real result yet). Return `ToolOverride.WithResult(...)` to replace the result, or `null` to keep it. ## When to use it [Section titled “When to use it”](#when-to-use-it) Audit logging, PII redaction before a message reaches the model, a policy gate that denies a dangerous tool call outright, or a cache that short-circuits a repeated tool call without hitting the real implementation. ## Why this and not the alternative [Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative) Hooks wrap the loop; a NativeTool wraps one function Hooks see *every* LLM/tool call the loop makes, across every tool in the toolkit, without touching each tool’s own code. If the interception is really specific to one tool’s own logic (e.g. it should always validate its own input a certain way), write that directly into the [`NativeTool.Of`](/api/csharp/native/define-tool/) function instead — a hook is the cross-cutting layer, not a replacement for tool-local logic. ## Examples [Section titled “Examples”](#examples) ### 1. The smallest useful hook — observe every model call [Section titled “1. The smallest useful hook — observe every model call”](#1-the-smallest-useful-hook--observe-every-model-call) ```csharp using System.Net; using System.Text; using Toolnexus; using var stub = new Stub(ctx => { Stub.Json(ctx, 200, """ {"id":"c1","choices":[{"message":{"role":"assistant","content":"hi"},"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":1,"total_tokens":4}} """); }); var afterLlmCalls = 0; var client = LlmClient.Create(new LlmClient.Options { BaseUrl = stub.BaseUrl, Style = "openai", Model = "gpt-4o-mini", ApiKey = "test-key", Hooks = new LlmClient.Hooks { AfterLLM = _ => afterLlmCalls++ }, }); await using var tk = await Toolkit.CreateAsync(new Toolkit.Options()); var result = await client.RunAsync("hi", tk); if (afterLlmCalls != 1) throw new Exception($"expected 1 AfterLLM call, got {afterLlmCalls}"); if (result.Text != "hi") throw new Exception(result.Text); Console.WriteLine($"ok: AfterLLM fired {afterLlmCalls} time(s)"); sealed class Stub : IDisposable { readonly HttpListener _listener = new(); readonly CancellationTokenSource _cts = new(); public int Port { get; } public string BaseUrl => $"http://127.0.0.1:{Port}"; public Stub(Action handler) { var probe = new System.Net.Sockets.TcpListener(IPAddress.Loopback, 0); probe.Start(); Port = ((IPEndPoint)probe.LocalEndpoint).Port; probe.Stop(); _listener.Prefixes.Add($"http://127.0.0.1:{Port}/"); _listener.Start(); _ = Task.Run(async () => { while (!_cts.IsCancellationRequested) { HttpListenerContext ctx; try { ctx = await _listener.GetContextAsync(); } catch { break; } try { handler(ctx); } catch { } } }); } public static void Json(HttpListenerContext ctx, int status, string body) { var bytes = Encoding.UTF8.GetBytes(body); ctx.Response.StatusCode = status; ctx.Response.ContentType = "application/json"; ctx.Response.ContentLength64 = bytes.Length; ctx.Response.OutputStream.Write(bytes, 0, bytes.Length); ctx.Response.OutputStream.Close(); } public void Dispose() { _cts.Cancel(); try { _listener.Stop(); } catch { } try { _listener.Close(); } catch { } } } ``` ### 2. BeforeTool denies a dangerous call without running it [Section titled “2. BeforeTool denies a dangerous call without running it”](#2-beforetool-denies-a-dangerous-call-without-running-it) ```csharp using System.Net; using System.Text; using Toolnexus; using var stub = new Stub(ctx => { Stub.Json(ctx, 200, """ {"id":"c1","choices":[{"message":{"role":"assistant","content":null,"tool_calls":[{"id":"call_1","type":"function","function":{"name":"delete_all","arguments":"{}"}}]},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":8,"completion_tokens":4,"total_tokens":12}} """); }); var executed = false; var deleteAll = NativeTool.Of("delete_all", "Deletes everything", null, (IDictionary a) => { executed = true; return "deleted"; }); var client = LlmClient.Create(new LlmClient.Options { BaseUrl = stub.BaseUrl, Style = "openai", Model = "gpt-4o-mini", ApiKey = "test-key", Hooks = new LlmClient.Hooks { BeforeTool = ev => ev.Name == "delete_all" ? LlmClient.ToolOverride.WithResult(ToolResult.Error("denied by policy")) : null, }, }); await using var tk = await Toolkit.CreateAsync(new Toolkit.Options { ExtraTools = new List { deleteAll } }); var result = await client.RunAsync("clean up everything", tk); if (executed) throw new Exception("the real tool must never run once BeforeTool denies it"); if (result.ToolCalls[0].Output != "denied by policy") throw new Exception(result.ToolCalls[0].Output); if (!result.ToolCalls[0].IsError) throw new Exception("expected the denial to be an error result"); Console.WriteLine($"ok: denied without executing (executed={executed})"); sealed class Stub : IDisposable { readonly HttpListener _listener = new(); readonly CancellationTokenSource _cts = new(); public int Port { get; } public string BaseUrl => $"http://127.0.0.1:{Port}"; public Stub(Action handler) { var probe = new System.Net.Sockets.TcpListener(IPAddress.Loopback, 0); probe.Start(); Port = ((IPEndPoint)probe.LocalEndpoint).Port; probe.Stop(); _listener.Prefixes.Add($"http://127.0.0.1:{Port}/"); _listener.Start(); _ = Task.Run(async () => { while (!_cts.IsCancellationRequested) { HttpListenerContext ctx; try { ctx = await _listener.GetContextAsync(); } catch { break; } try { handler(ctx); } catch { } } }); } public static void Json(HttpListenerContext ctx, int status, string body) { var bytes = Encoding.UTF8.GetBytes(body); ctx.Response.StatusCode = status; ctx.Response.ContentType = "application/json"; ctx.Response.ContentLength64 = bytes.Length; ctx.Response.OutputStream.Write(bytes, 0, bytes.Length); ctx.Response.OutputStream.Close(); } public void Dispose() { _cts.Cancel(); try { _listener.Stop(); } catch { } try { _listener.Close(); } catch { } } } ``` ### 3. Full surface — all four hooks working together [Section titled “3. Full surface — all four hooks working together”](#3-full-surface--all-four-hooks-working-together) ```csharp using System.Net; using System.Text; using Toolnexus; var calls = 0; using var stub = new Stub(ctx => { calls++; if (calls == 1) { Stub.Json(ctx, 200, """ {"id":"c1","choices":[{"message":{"role":"assistant","content":null,"tool_calls":[{"id":"call_1","type":"function","function":{"name":"lookup","arguments":"{\"key\":\"secret\"}"}}]},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":10,"completion_tokens":5,"total_tokens":15}} """); } else { Stub.Json(ctx, 200, """ {"id":"c2","choices":[{"message":{"role":"assistant","content":"done"},"finish_reason":"stop"}],"usage":{"prompt_tokens":12,"completion_tokens":2,"total_tokens":14}} """); } }); var lookup = NativeTool.Of("lookup", "Look up a key", null, (IDictionary a) => $"value-for-{a["key"]}"); var log = new List(); var client = LlmClient.Create(new LlmClient.Options { BaseUrl = stub.BaseUrl, Style = "openai", Model = "gpt-4o-mini", ApiKey = "test-key", Hooks = new LlmClient.Hooks { BeforeLLM = ev => { log.Add($"beforeLLM:turn{ev.Turn}"); return null; }, AfterLLM = ev => log.Add($"afterLLM:turn{ev.Turn}"), // Rewrite the argument before it reaches the tool — "secret" -> "redacted". BeforeTool = ev => ev.Name == "lookup" ? LlmClient.ToolOverride.WithArgs(new Dictionary { ["key"] = "redacted" }) : null, // Uppercase whatever the tool returned. AfterTool = ev => LlmClient.ToolOverride.WithResult(ToolResult.Ok(ev.Result.Output.ToUpperInvariant())), }, }); await using var tk = await Toolkit.CreateAsync(new Toolkit.Options { ExtraTools = new List { lookup } }); var result = await client.RunAsync("look up the secret", tk); if (result.ToolCalls[0].Output != "VALUE-FOR-REDACTED") throw new Exception(result.ToolCalls[0].Output); if (!log.Contains("beforeLLM:turn0") || !log.Contains("afterLLM:turn0")) throw new Exception(string.Join(",", log)); if (!log.Contains("beforeLLM:turn1") || !log.Contains("afterLLM:turn1")) throw new Exception(string.Join(",", log)); if (result.Text != "done") throw new Exception(result.Text); Console.WriteLine($"ok: {result.ToolCalls[0].Output} | log={string.Join(",", log)}"); sealed class Stub : IDisposable { readonly HttpListener _listener = new(); readonly CancellationTokenSource _cts = new(); public int Port { get; } public string BaseUrl => $"http://127.0.0.1:{Port}"; public Stub(Action handler) { var probe = new System.Net.Sockets.TcpListener(IPAddress.Loopback, 0); probe.Start(); Port = ((IPEndPoint)probe.LocalEndpoint).Port; probe.Stop(); _listener.Prefixes.Add($"http://127.0.0.1:{Port}/"); _listener.Start(); _ = Task.Run(async () => { while (!_cts.IsCancellationRequested) { HttpListenerContext ctx; try { ctx = await _listener.GetContextAsync(); } catch { break; } try { handler(ctx); } catch { } } }); } public static void Json(HttpListenerContext ctx, int status, string body) { var bytes = Encoding.UTF8.GetBytes(body); ctx.Response.StatusCode = status; ctx.Response.ContentType = "application/json"; ctx.Response.ContentLength64 = bytes.Length; ctx.Response.OutputStream.Write(bytes, 0, bytes.Length); ctx.Response.OutputStream.Close(); } public void Dispose() { _cts.Cancel(); try { _listener.Stop(); } catch { } try { _listener.Close(); } catch { } } } ``` Caution `AfterTool` never fires for a §10 suspension (`ToolResult.PendingOf(result) != null`) — a suspension is not a completed result yet. It fires once the suspension is resolved (or declined), after `WaitFor` re-executes the tool. ## Tool hints and permission gates [Section titled “Tool hints and permission gates”](#tool-hints-and-permission-gates) `BeforeToolEvent` carries `Hints` (`ToolHints(ReadOnly, Destructive, OpenWorld)`, null = unknown; treat unknown as mutating). Every builtin sets them (`read`, `grep`, `glob`, `question`, `todowrite` are read-only; `write`, `edit`, `apply_patch` destructive; `bash` destructive and open-world), and MCP tools map them from their annotations. Hints are advisory and never sent to the model. A permission gate is a `BeforeTool` hook: return `null` to allow, a `WithResult(ToolResult.Error(...))` to deny, or a [`pending`](/api/csharp/suspension/pending/) result to ask, which `WaitFor` / `WaitForCtx` answers. For a [`ToolTiers`](/api/csharp/client/tool-tiers/) `call_tool` call, the event names the real tool, with its own args and hints. ```csharp Hooks = new LlmClient.Hooks { BeforeTool = ev => ev.Hints?.ReadOnly == true ? null // allow reads : LlmClient.ToolOverride.WithResult(ToolResult.Error($"{ev.Name} needs approval")), }, ``` ## Fields [Section titled “Fields”](#fields) | Field | Type | What it is | | ------------ | --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | | `BeforeLLM` | `Func?` | Before each model call. Return `LLMOverride` to rewrite `Messages`/`Tools`, `null` to leave them. | | `AfterLLM` | `Action?` | After each model call — observation only, given the raw response. | | `BeforeTool` | `Func?` | Before a tool executes. `ToolOverride.WithArgs(...)` rewrites args, `ToolOverride.WithResult(...)` skips execution, `null` runs as called. | | `AfterTool` | `Func?` | After a tool executes (not on a suspension). `ToolOverride.WithResult(...)` replaces the result, `null` keeps it. | ## See also [Section titled “See also”](#see-also) * [`LlmClient.Create`](/api/csharp/client/create/) — The unified client: system prompt, skills injection, parallel and chained tool calls, retries, memory. * [`LlmClient.RunAsync`](/api/csharp/client/run/) — Send a prompt, let the loop call tools until the model stops, get a RunResult. * [`LlmClient.StreamAsync`](/api/csharp/client/stream/) — The streaming loop: text deltas, tool-call events, and suspension events as they happen. * [`LlmClient.Conversation`](/api/csharp/client/conversation/) — Keep a transcript across turns so the model remembers what it already did. # LlmClient.ListModelsAsync > List a provider's models (id, display name, context length when reported), sorted by id, under the same key rules as the client. — C#. **C#** · package `Toolnexus` · [SPEC §8](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [LlmClientHost.cs](https://github.com/muthuishere/toolnexus/blob/main/csharp/src/Toolnexus/LlmClientHost.cs) ```csharp public static Task> LlmClient.ListModelsAsync( LlmClient.Options opts, CancellationToken cancellationToken = default); public sealed record ModelInfo(string Id, string? Name = null, int ContextLength = 0); // 0 = unknown ``` Lists the models a provider serves, sorted by id. openai style: `GET {BaseUrl}/models` (OpenRouter’s `context_length` is read when present). anthropic style: `GET {BaseUrl}/v1/models`, following `has_more` / `last_id`. One GET per page, no retries. It uses the same key rules as the client ([`ApiKey` / `ApiKeyEnv`](/api/csharp/client/api-key/)) and the same `HttpClient` / `HttpHandler` seam. A non-2xx throws `ProviderException`. ## When to use it [Section titled “When to use it”](#when-to-use-it) A model picker in a host UI, or a startup check that the configured `Model` exists. OpenRouter’s `ContextLength` also gives you the window to feed `ToolTierOptions.ContextWindow`. ## Why this and not the alternative [Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative) ListModelsAsync, not a hand-rolled GET A raw GET would need its own key resolution, and that is exactly where a key can leak to the wrong host. `ListModelsAsync` reuses the client’s host-checked key and HTTP seam. ## Examples [Section titled “Examples”](#examples) ### 1. An OpenAI-compatible server, sorted by id [Section titled “1. An OpenAI-compatible server, sorted by id”](#1-an-openai-compatible-server-sorted-by-id) ```csharp using System.Net; using System.Text; using Toolnexus; using var stub = new Stub((ctx, body) => Stub.Json(ctx, 200, """ {"data":[{"id":"z-large","name":"Z Large","context_length":131072},{"id":"a-small"}]} """)); var models = await LlmClient.ListModelsAsync(new LlmClient.Options { BaseUrl = stub.BaseUrl, Style = "openai", ApiKey = "YOUR_KEY_HERE", }); if (models[0].Id != "a-small" || models[1].ContextLength != 131072) throw new Exception(string.Join(",", models)); Console.WriteLine($"ok: {string.Join(", ", models.Select(m => $"{m.Id} ({m.ContextLength})"))}"); sealed class Stub : IDisposable { readonly HttpListener _listener = new(); readonly CancellationTokenSource _cts = new(); public int Port { get; } public string BaseUrl => $"http://127.0.0.1:{Port}"; public Stub(Action handler) { var probe = new System.Net.Sockets.TcpListener(IPAddress.Loopback, 0); probe.Start(); Port = ((IPEndPoint)probe.LocalEndpoint).Port; probe.Stop(); _listener.Prefixes.Add($"http://127.0.0.1:{Port}/"); _listener.Start(); _ = Task.Run(async () => { while (!_cts.IsCancellationRequested) { HttpListenerContext ctx; try { ctx = await _listener.GetContextAsync(); } catch { break; } var body = new StreamReader(ctx.Request.InputStream).ReadToEnd(); try { handler(ctx, body); } catch { } } }); } public static void Json(HttpListenerContext ctx, int status, string body) { var bytes = Encoding.UTF8.GetBytes(body); ctx.Response.StatusCode = status; ctx.Response.ContentType = "application/json"; ctx.Response.ContentLength64 = bytes.Length; ctx.Response.OutputStream.Write(bytes, 0, bytes.Length); ctx.Response.OutputStream.Close(); } public void Dispose() { _cts.Cancel(); try { _listener.Stop(); } catch { } try { _listener.Close(); } catch { } } } ``` ### 2. Anthropic, following pages [Section titled “2. Anthropic, following pages”](#2-anthropic-following-pages) ```csharp using System.Net; using System.Text; using Toolnexus; using var stub = new Stub((ctx, body) => { var after = ctx.Request.QueryString["after_id"]; Stub.Json(ctx, 200, after == null ? """{"data":[{"id":"claude-b","display_name":"Claude B"}],"has_more":true,"last_id":"claude-b"}""" : """{"data":[{"id":"claude-a","display_name":"Claude A"}],"has_more":false,"last_id":"claude-a"}"""); }); var models = await LlmClient.ListModelsAsync(new LlmClient.Options { BaseUrl = stub.BaseUrl, Style = "anthropic", ApiKey = "YOUR_KEY_HERE", }); if (models.Count != 2 || models[0].Name != "Claude A") throw new Exception(string.Join(",", models)); Console.WriteLine($"ok: {models.Count} models over two pages, first = {models[0].Id}"); sealed class Stub : IDisposable { readonly HttpListener _listener = new(); readonly CancellationTokenSource _cts = new(); public int Port { get; } public string BaseUrl => $"http://127.0.0.1:{Port}"; public Stub(Action handler) { var probe = new System.Net.Sockets.TcpListener(IPAddress.Loopback, 0); probe.Start(); Port = ((IPEndPoint)probe.LocalEndpoint).Port; probe.Stop(); _listener.Prefixes.Add($"http://127.0.0.1:{Port}/"); _listener.Start(); _ = Task.Run(async () => { while (!_cts.IsCancellationRequested) { HttpListenerContext ctx; try { ctx = await _listener.GetContextAsync(); } catch { break; } var body = new StreamReader(ctx.Request.InputStream).ReadToEnd(); try { handler(ctx, body); } catch { } } }); } public static void Json(HttpListenerContext ctx, int status, string body) { var bytes = Encoding.UTF8.GetBytes(body); ctx.Response.StatusCode = status; ctx.Response.ContentType = "application/json"; ctx.Response.ContentLength64 = bytes.Length; ctx.Response.OutputStream.Write(bytes, 0, bytes.Length); ctx.Response.OutputStream.Close(); } public void Dispose() { _cts.Cancel(); try { _listener.Stop(); } catch { } try { _listener.Close(); } catch { } } } ``` ### 3. Same key rules as the client [Section titled “3. Same key rules as the client”](#3-same-key-rules-as-the-client) ```csharp using Toolnexus; try { // A local host with no ApiKey / ApiKeyEnv: refused before any request is made. await LlmClient.ListModelsAsync(new LlmClient.Options { BaseUrl = "http://127.0.0.1:1", Style = "openai" }); throw new Exception("expected a missing-key error"); } catch (InvalidOperationException e) when (e.Message.StartsWith("No API key")) { Console.WriteLine("ok: no key, no request"); } ``` ## See also [Section titled “See also”](#see-also) * [`LlmClient.Create`](/api/csharp/client/create/) — The unified client: system prompt, skills injection, parallel and chained tool calls, retries, memory. * [`LlmClient.RunAsync`](/api/csharp/client/run/) — Send a prompt, let the loop call tools until the model stops, get a RunResult. * [`LlmClient.StreamAsync`](/api/csharp/client/stream/) — The streaming loop: text deltas, tool-call events, and suspension events as they happen. * [`LlmClient.Hooks`](/api/csharp/client/hooks/) — Intercept before/after model calls and tool calls: audit, redact, veto, or rewrite. # MetricEvent > Emit one metric event per model call and per tool call: tokens, latency, outcome. — C#. **C#** · package `Toolnexus` · [SPEC §8](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [Metrics.cs](https://github.com/muthuishere/toolnexus/blob/main/csharp/src/Toolnexus/Metrics.cs) ```csharp public sealed record MetricEvent { public required string Event { get; init; } // "llm" | "tool" | "run" public string? Model { get; init; } public string? Status { get; init; } // "ok" | "error" (llm) public long PromptTokens { get; init; } public long CompletionTokens { get; init; } public string? Tool { get; init; } public string? Source { get; init; } public bool IsError { get; init; } public bool Pending { get; init; } // §10: a suspension, never IsError public int Turns { get; init; } public int ToolCalls { get; init; } public long TotalTokens { get; init; } public string? Error { get; init; } public long Ms { get; init; } } ``` MetricEvent, not MetricEvent The C# public API names this type `MetricEvent` — a top-level record in the `Toolnexus` namespace, defined in `Metrics.cs`. There is no wrapping `Metrics` class; every other port also ships a bare `MetricEvent` (or, for Java, a nested `LlmClient.MetricEvent`). Set `LlmClient.Options.OnMetric` to an `Action` to receive one of these per model call and per tool call. A discriminated event — `Event` is `"llm"`, `"tool"`, or `"run"`, and only the fields for that kind are populated. Every `RunAsync`/`StreamAsync`/`TranslateAsync` call feeds the same events to two places at once: your `OnMetric` sink (if set) and the client’s own cumulative Prometheus registry, rendered as text by `client.Metrics()`. `MetricEvent`’s C# fields are PascalCase (idiomatic for this port) — only the *rendered Prometheus text* from `Metrics()` is byte-identical across ports; the semantic event shape is per-language idiomatic. ## When to use it [Section titled “When to use it”](#when-to-use-it) Forwarding call-level observability to wherever your app already sends it — statsd, structured logs, OpenTelemetry — without scraping `/metrics`. It’s also how you’d build a live “tokens spent this session” counter or alert on a rising `tool` error rate, since each event lands the instant the call it describes finishes. ## Why this and not the alternative [Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative) Reach for client.Metrics() when a scrape endpoint is enough `OnMetric` gives you a *push* — one event per call, in real time, shaped however you want to route it. [`client.Metrics()`](/api/csharp/client/create/) gives you a *pull* — the same underlying counters/histograms as Prometheus text exposition, ready to serve from a `/metrics` route. Use `OnMetric` when something needs to react per-call; use `Metrics()` when a scraper is already polling. ## Examples [Section titled “Examples”](#examples) ### 1. The smallest useful sink — count `llm` events [Section titled “1. The smallest useful sink — count llm events”](#1-the-smallest-useful-sink--count-llm-events) ```csharp using System.Net; using System.Text; using Toolnexus; using var stub = new Stub(ctx => { Stub.Json(ctx, 200, """ {"id":"c1","choices":[{"message":{"role":"assistant","content":"hi"},"finish_reason":"stop"}],"usage":{"prompt_tokens":5,"completion_tokens":2,"total_tokens":7}} """); }); var events = new List(); var client = LlmClient.Create(new LlmClient.Options { BaseUrl = stub.BaseUrl, Style = "openai", Model = "gpt-4o-mini", ApiKey = "test-key", OnMetric = ev => events.Add(ev), }); await using var tk = await Toolkit.CreateAsync(new Toolkit.Options()); await client.RunAsync("hi", tk); // One "llm" event and one "run" event for a single tool-free turn. if (!events.Any(e => e.Event == "llm" && e.Status == "ok")) throw new Exception("missing llm ok event"); if (!events.Any(e => e.Event == "run")) throw new Exception("missing run event"); Console.WriteLine($"ok: {events.Count} event(s): {string.Join(",", events.Select(e => e.Event))}"); sealed class Stub : IDisposable { readonly HttpListener _listener = new(); readonly CancellationTokenSource _cts = new(); public int Port { get; } public string BaseUrl => $"http://127.0.0.1:{Port}"; public Stub(Action handler) { var probe = new System.Net.Sockets.TcpListener(IPAddress.Loopback, 0); probe.Start(); Port = ((IPEndPoint)probe.LocalEndpoint).Port; probe.Stop(); _listener.Prefixes.Add($"http://127.0.0.1:{Port}/"); _listener.Start(); _ = Task.Run(async () => { while (!_cts.IsCancellationRequested) { HttpListenerContext ctx; try { ctx = await _listener.GetContextAsync(); } catch { break; } try { handler(ctx); } catch { } } }); } public static void Json(HttpListenerContext ctx, int status, string body) { var bytes = Encoding.UTF8.GetBytes(body); ctx.Response.StatusCode = status; ctx.Response.ContentType = "application/json"; ctx.Response.ContentLength64 = bytes.Length; ctx.Response.OutputStream.Write(bytes, 0, bytes.Length); ctx.Response.OutputStream.Close(); } public void Dispose() { _cts.Cancel(); try { _listener.Stop(); } catch { } try { _listener.Close(); } catch { } } } ``` ### 2. A `tool` event, and telling a suspension apart from a real error [Section titled “2. A tool event, and telling a suspension apart from a real error”](#2-a-tool-event-and-telling-a-suspension-apart-from-a-real-error) ```csharp using System.Net; using System.Text; using Toolnexus; var calls = 0; using var stub = new Stub(ctx => { calls++; if (calls == 1) { Stub.Json(ctx, 200, """ {"id":"c1","choices":[{"message":{"role":"assistant","content":null,"tool_calls":[{"id":"call_1","type":"function","function":{"name":"add","arguments":"{\"a\":1,\"b\":1}"}}]},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":8,"completion_tokens":4,"total_tokens":12}} """); } else { Stub.Json(ctx, 200, """ {"id":"c2","choices":[{"message":{"role":"assistant","content":"1+1=2"},"finish_reason":"stop"}],"usage":{"prompt_tokens":12,"completion_tokens":3,"total_tokens":15}} """); } }); var add = NativeTool.Of("add", "Add two integers", null, (IDictionary a) => (Convert.ToInt32(a["a"]) + Convert.ToInt32(a["b"])).ToString()); var toolEvents = new List(); var client = LlmClient.Create(new LlmClient.Options { BaseUrl = stub.BaseUrl, Style = "openai", Model = "gpt-4o-mini", ApiKey = "test-key", OnMetric = ev => { if (ev.Event == "tool") toolEvents.Add(ev); }, }); await using var tk = await Toolkit.CreateAsync(new Toolkit.Options { ExtraTools = new List { add } }); await client.RunAsync("stop after adding 1+1", tk); if (toolEvents.Count != 1) throw new Exception($"expected 1 tool event, got {toolEvents.Count}"); var ev0 = toolEvents[0]; if (ev0.Tool != "add" || ev0.Source != "native") throw new Exception($"{ev0.Tool}/{ev0.Source}"); // A real success: not an error, not pending. if (ev0.IsError || ev0.Pending) throw new Exception("expected a clean success, not error/pending"); Console.WriteLine($"ok: tool={ev0.Tool} source={ev0.Source} ms>={ev0.Ms >= 0}"); sealed class Stub : IDisposable { readonly HttpListener _listener = new(); readonly CancellationTokenSource _cts = new(); public int Port { get; } public string BaseUrl => $"http://127.0.0.1:{Port}"; public Stub(Action handler) { var probe = new System.Net.Sockets.TcpListener(IPAddress.Loopback, 0); probe.Start(); Port = ((IPEndPoint)probe.LocalEndpoint).Port; probe.Stop(); _listener.Prefixes.Add($"http://127.0.0.1:{Port}/"); _listener.Start(); _ = Task.Run(async () => { while (!_cts.IsCancellationRequested) { HttpListenerContext ctx; try { ctx = await _listener.GetContextAsync(); } catch { break; } try { handler(ctx); } catch { } } }); } public static void Json(HttpListenerContext ctx, int status, string body) { var bytes = Encoding.UTF8.GetBytes(body); ctx.Response.StatusCode = status; ctx.Response.ContentType = "application/json"; ctx.Response.ContentLength64 = bytes.Length; ctx.Response.OutputStream.Write(bytes, 0, bytes.Length); ctx.Response.OutputStream.Close(); } public void Dispose() { _cts.Cancel(); try { _listener.Stop(); } catch { } try { _listener.Close(); } catch { } } } ``` ### 3. Full surface — the same events feed `client.Metrics()`’s Prometheus text [Section titled “3. Full surface — the same events feed client.Metrics()’s Prometheus text”](#3-full-surface--the-same-events-feed-clientmetricss-prometheus-text) ```csharp using System.Net; using System.Text; using Toolnexus; using var stub = new Stub(ctx => { Stub.Json(ctx, 200, """ {"id":"c1","choices":[{"message":{"role":"assistant","content":"ok"},"finish_reason":"stop"}],"usage":{"prompt_tokens":6,"completion_tokens":3,"total_tokens":9}} """); }); var events = new List(); var client = LlmClient.Create(new LlmClient.Options { BaseUrl = stub.BaseUrl, Style = "openai", Model = "gpt-4o-mini", ApiKey = "test-key", OnMetric = ev => events.Add(ev), }); await using var tk = await Toolkit.CreateAsync(new Toolkit.Options()); // Before any activity, Metrics() is still valid — just the HELP/TYPE header lines. var before = client.Metrics(); if (!before.Contains("# HELP toolnexus_llm_requests_total")) throw new Exception("missing header before any calls"); var result = await client.RunAsync("say ok", tk); var runEvent = events.Single(e => e.Event == "run"); if (runEvent.Model != "gpt-4o-mini") throw new Exception(runEvent.Model); if (runEvent.TotalTokens != result.Usage.TotalTokens) throw new Exception($"{runEvent.TotalTokens} vs {result.Usage.TotalTokens}"); if (runEvent.Error != null) throw new Exception("expected a clean run, no Error"); // The exact same events also accumulated into the built-in Prometheus registry. var after = client.Metrics(); if (!after.Contains("toolnexus_llm_requests_total{model=\"gpt-4o-mini\",status=\"ok\"} 1")) throw new Exception(after); if (!after.Contains("toolnexus_run_errors_total")) throw new Exception("missing run_errors header"); Console.WriteLine($"ok: {events.Count} events, run tokens={runEvent.TotalTokens}, Metrics() has {after.Split('\n').Length} lines"); sealed class Stub : IDisposable { readonly HttpListener _listener = new(); readonly CancellationTokenSource _cts = new(); public int Port { get; } public string BaseUrl => $"http://127.0.0.1:{Port}"; public Stub(Action handler) { var probe = new System.Net.Sockets.TcpListener(IPAddress.Loopback, 0); probe.Start(); Port = ((IPEndPoint)probe.LocalEndpoint).Port; probe.Stop(); _listener.Prefixes.Add($"http://127.0.0.1:{Port}/"); _listener.Start(); _ = Task.Run(async () => { while (!_cts.IsCancellationRequested) { HttpListenerContext ctx; try { ctx = await _listener.GetContextAsync(); } catch { break; } try { handler(ctx); } catch { } } }); } public static void Json(HttpListenerContext ctx, int status, string body) { var bytes = Encoding.UTF8.GetBytes(body); ctx.Response.StatusCode = status; ctx.Response.ContentType = "application/json"; ctx.Response.ContentLength64 = bytes.Length; ctx.Response.OutputStream.Write(bytes, 0, bytes.Length); ctx.Response.OutputStream.Close(); } public void Dispose() { _cts.Cancel(); try { _listener.Stop(); } catch { } try { _listener.Close(); } catch { } } } ``` Caution `Pending` is `true` only for a §10 suspension — a tool call still awaiting out-of-band resolution. It is deliberately kept out of `IsError`: a suspended tool call is not a failure, so counting it as one would poison error-rate alerts. Filter on `Pending` separately if you need to track it. ## Fields [Section titled “Fields”](#fields) | Field | Applies to | What it is | | ------------------------------------- | -------------- | ---------------------------------------------------------------------------- | | `Event` | all | Discriminator: `"llm"`, `"tool"`, or `"run"`. | | `Model` | llm, run | Model name. | | `Status` | llm | `"ok"` or `"error"`. | | `PromptTokens` / `CompletionTokens` | llm | Per-call token counts. | | `Tool` / `Source` | tool | Tool name and its `ITool.Source` (`"native"`, `"mcp"`, …). | | `IsError` | tool | Whether the tool call failed. Never `true` for a suspension — see `Pending`. | | `Pending` | tool | §10: `true` iff this call suspended awaiting out-of-band resolution. | | `Turns` / `ToolCalls` / `TotalTokens` | run | Totals for the whole run. | | `Error` | run | Set once, if the run threw. | | `Ms` | llm, tool, run | Duration in milliseconds. | ## See also [Section titled “See also”](#see-also) * [`LlmClient.Create`](/api/csharp/client/create/) — The unified client: system prompt, skills injection, parallel and chained tool calls, retries, memory. * [`LlmClient.RunAsync`](/api/csharp/client/run/) — Send a prompt, let the loop call tools until the model stops, get a RunResult. * [`LlmClient.StreamAsync`](/api/csharp/client/stream/) — The streaming loop: text deltas, tool-call events, and suspension events as they happen. * [`LlmClient.Hooks`](/api/csharp/client/hooks/) — Intercept before/after model calls and tool calls: audit, redact, veto, or rewrite. # Options.PromptCache > Mark the stable prompt prefix for the provider (Anthropic cache breakpoints, OpenAI prompt_cache_key) and read cacheRead/cacheWrite tokens from usage. — C#. **C#** · package `Toolnexus` · [SPEC §8](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [LlmClientHost.cs](https://github.com/muthuishere/toolnexus/blob/main/csharp/src/Toolnexus/LlmClientHost.cs) ```csharp public sealed class LlmClient.Options { public PromptCacheOptions? PromptCache { get; set; } // null ⇒ request body byte-identical } public sealed class PromptCacheOptions { public string? Key { get; set; } // openai prompt_cache_key (one per session) public bool LongTtl { get; set; } // anthropic ttl:"1h" } public sealed class LlmClient.Usage { public long PromptTokens, CompletionTokens, TotalTokens; public long CacheReadTokens; // served from the cache public long CacheWriteTokens; // written to the cache (anthropic) } ``` Marks the stable prefix of every request for the provider’s prompt cache: * **anthropic** — `cache_control: {type:"ephemeral"}` (plus `ttl:"1h"` with `LongTtl`) on the last tool, on the system prompt (a string becomes one text block) and on the last content block of the latest message. * **openai** — `prompt_cache_key = Key` when `Key` is set (OpenAI caches long prefixes on its own). * **gemini** — nothing yet (explicit `cachedContent` is not built). It is applied before `RequestParams` and `BodyTransform`, so both can override it. The transcript itself is never mutated. Usage reports `CacheReadTokens` / `CacheWriteTokens` either way. Context size under anthropic Anthropic’s `input_tokens` excludes cached tokens, so the real context sent is `PromptTokens + CacheReadTokens + CacheWriteTokens`. Under openai `PromptTokens` already includes `CacheReadTokens`; do not add it again. ## When to use it [Section titled “When to use it”](#when-to-use-it) Long sessions with a big, stable system prompt and tool list: a coding agent, a support agent with a skills catalog. The second and later requests read the prefix from the cache, which is cheaper and faster. Pair it with [`ToolTiers`](/api/csharp/client/tool-tiers/) so registering a tool mid-session does not change the tools array and break the cached prefix. ## Why this and not the alternative [Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative) PromptCache, not a hand-written BodyTransform You could add `cache_control` yourself in `BodyTransform`, but you would have to find the last tool, convert a string system prompt to a block and copy the latest message without mutating the transcript, per provider. Use `BodyTransform` only for a provider shape `PromptCache` does not cover. ## Examples [Section titled “Examples”](#examples) ### 1. OpenAI: a session key, and cache hits in usage [Section titled “1. OpenAI: a session key, and cache hits in usage”](#1-openai-a-session-key-and-cache-hits-in-usage) ```csharp using System.Net; using System.Text; using Toolnexus; string sent = ""; using var stub = new Stub((ctx, body) => { sent = body; Stub.Json(ctx, 200, """ {"id":"c1","choices":[{"message":{"role":"assistant","content":"hi"},"finish_reason":"stop"}],"usage":{"prompt_tokens":1200,"completion_tokens":1,"total_tokens":1201,"prompt_tokens_details":{"cached_tokens":1024}}} """); }); var client = LlmClient.Create(new LlmClient.Options { BaseUrl = stub.BaseUrl, Style = "openai", Model = "gpt-4o-mini", ApiKey = "YOUR_KEY_HERE", PromptCache = new PromptCacheOptions { Key = "session-42" }, }); await using var tk = await Toolkit.CreateAsync(new Toolkit.Options()); var result = await client.RunAsync("hi", tk); if (!sent.Contains("\"prompt_cache_key\":\"session-42\"")) throw new Exception(sent); if (result.Usage.CacheReadTokens != 1024) throw new Exception($"{result.Usage.CacheReadTokens}"); Console.WriteLine($"ok: prompt_cache_key sent, {result.Usage.CacheReadTokens} cached of {result.Usage.PromptTokens}"); sealed class Stub : IDisposable { readonly HttpListener _listener = new(); readonly CancellationTokenSource _cts = new(); public int Port { get; } public string BaseUrl => $"http://127.0.0.1:{Port}"; public Stub(Action handler) { var probe = new System.Net.Sockets.TcpListener(IPAddress.Loopback, 0); probe.Start(); Port = ((IPEndPoint)probe.LocalEndpoint).Port; probe.Stop(); _listener.Prefixes.Add($"http://127.0.0.1:{Port}/"); _listener.Start(); _ = Task.Run(async () => { while (!_cts.IsCancellationRequested) { HttpListenerContext ctx; try { ctx = await _listener.GetContextAsync(); } catch { break; } var body = new StreamReader(ctx.Request.InputStream).ReadToEnd(); try { handler(ctx, body); } catch { } } }); } public static void Json(HttpListenerContext ctx, int status, string body) { var bytes = Encoding.UTF8.GetBytes(body); ctx.Response.StatusCode = status; ctx.Response.ContentType = "application/json"; ctx.Response.ContentLength64 = bytes.Length; ctx.Response.OutputStream.Write(bytes, 0, bytes.Length); ctx.Response.OutputStream.Close(); } public void Dispose() { _cts.Cancel(); try { _listener.Stop(); } catch { } try { _listener.Close(); } catch { } } } ``` ### 2. Anthropic: breakpoints on tools, system prompt and latest message [Section titled “2. Anthropic: breakpoints on tools, system prompt and latest message”](#2-anthropic-breakpoints-on-tools-system-prompt-and-latest-message) ```csharp using System.Net; using System.Text; using Toolnexus; string sent = ""; using var stub = new Stub((ctx, body) => { sent = body; Stub.Json(ctx, 200, """ {"id":"m1","type":"message","role":"assistant","content":[{"type":"text","text":"hi"}],"stop_reason":"end_turn","usage":{"input_tokens":12,"output_tokens":1,"cache_read_input_tokens":900,"cache_creation_input_tokens":40}} """); }); var echo = NativeTool.Of("echo", "Echo text", null, (IDictionary a) => "x"); var client = LlmClient.Create(new LlmClient.Options { BaseUrl = stub.BaseUrl, Style = "anthropic", Model = "claude-sonnet", ApiKey = "YOUR_KEY_HERE", SystemPrompt = "You are a careful assistant.", PromptCache = new PromptCacheOptions(), }); await using var tk = await Toolkit.CreateAsync(new Toolkit.Options { ExtraTools = new List { echo } }); var result = await client.RunAsync("hi", tk); var marks = sent.Split("\"cache_control\"").Length - 1; if (marks != 3) throw new Exception($"expected 3 breakpoints, got {marks}: {sent}"); var u = result.Usage; var context = u.PromptTokens + u.CacheReadTokens + u.CacheWriteTokens; Console.WriteLine($"ok: {marks} cache breakpoints; context = {context} tokens ({u.CacheReadTokens} read, {u.CacheWriteTokens} written)"); sealed class Stub : IDisposable { readonly HttpListener _listener = new(); readonly CancellationTokenSource _cts = new(); public int Port { get; } public string BaseUrl => $"http://127.0.0.1:{Port}"; public Stub(Action handler) { var probe = new System.Net.Sockets.TcpListener(IPAddress.Loopback, 0); probe.Start(); Port = ((IPEndPoint)probe.LocalEndpoint).Port; probe.Stop(); _listener.Prefixes.Add($"http://127.0.0.1:{Port}/"); _listener.Start(); _ = Task.Run(async () => { while (!_cts.IsCancellationRequested) { HttpListenerContext ctx; try { ctx = await _listener.GetContextAsync(); } catch { break; } var body = new StreamReader(ctx.Request.InputStream).ReadToEnd(); try { handler(ctx, body); } catch { } } }); } public static void Json(HttpListenerContext ctx, int status, string body) { var bytes = Encoding.UTF8.GetBytes(body); ctx.Response.StatusCode = status; ctx.Response.ContentType = "application/json"; ctx.Response.ContentLength64 = bytes.Length; ctx.Response.OutputStream.Write(bytes, 0, bytes.Length); ctx.Response.OutputStream.Close(); } public void Dispose() { _cts.Cancel(); try { _listener.Stop(); } catch { } try { _listener.Close(); } catch { } } } ``` ### 3. The one-hour lifetime [Section titled “3. The one-hour lifetime”](#3-the-one-hour-lifetime) ```csharp using System.Net; using System.Text; using Toolnexus; string sent = ""; using var stub = new Stub((ctx, body) => { sent = body; Stub.Json(ctx, 200, """ {"id":"m1","type":"message","role":"assistant","content":[{"type":"text","text":"hi"}],"stop_reason":"end_turn","usage":{"input_tokens":12,"output_tokens":1}} """); }); var client = LlmClient.Create(new LlmClient.Options { BaseUrl = stub.BaseUrl, Style = "anthropic", Model = "claude-sonnet", ApiKey = "YOUR_KEY_HERE", SystemPrompt = "Long, stable instructions.", PromptCache = new PromptCacheOptions { LongTtl = true }, }); await using var tk = await Toolkit.CreateAsync(new Toolkit.Options()); await client.RunAsync("hi", tk); if (!sent.Contains("\"ttl\":\"1h\"")) throw new Exception(sent); Console.WriteLine("ok: cache_control carries ttl 1h"); sealed class Stub : IDisposable { readonly HttpListener _listener = new(); readonly CancellationTokenSource _cts = new(); public int Port { get; } public string BaseUrl => $"http://127.0.0.1:{Port}"; public Stub(Action handler) { var probe = new System.Net.Sockets.TcpListener(IPAddress.Loopback, 0); probe.Start(); Port = ((IPEndPoint)probe.LocalEndpoint).Port; probe.Stop(); _listener.Prefixes.Add($"http://127.0.0.1:{Port}/"); _listener.Start(); _ = Task.Run(async () => { while (!_cts.IsCancellationRequested) { HttpListenerContext ctx; try { ctx = await _listener.GetContextAsync(); } catch { break; } var body = new StreamReader(ctx.Request.InputStream).ReadToEnd(); try { handler(ctx, body); } catch { } } }); } public static void Json(HttpListenerContext ctx, int status, string body) { var bytes = Encoding.UTF8.GetBytes(body); ctx.Response.StatusCode = status; ctx.Response.ContentType = "application/json"; ctx.Response.ContentLength64 = bytes.Length; ctx.Response.OutputStream.Write(bytes, 0, bytes.Length); ctx.Response.OutputStream.Close(); } public void Dispose() { _cts.Cancel(); try { _listener.Stop(); } catch { } try { _listener.Close(); } catch { } } } ``` ## Fields [Section titled “Fields”](#fields) | Field | Type | What it is | | ---------------------------- | --------- | ------------------------------------------------ | | `PromptCacheOptions.Key` | `string?` | openai `prompt_cache_key`; ignored by anthropic. | | `PromptCacheOptions.LongTtl` | `bool` | anthropic one-hour cache lifetime. | | `Usage.CacheReadTokens` | `long` | Prompt tokens served from the cache. | | `Usage.CacheWriteTokens` | `long` | Prompt tokens written to the cache (anthropic). | ## See also [Section titled “See also”](#see-also) * [`LlmClient.Create`](/api/csharp/client/create/) — The unified client: system prompt, skills injection, parallel and chained tool calls, retries, memory. * [`LlmClient.RunAsync`](/api/csharp/client/run/) — Send a prompt, let the loop call tools until the model stops, get a RunResult. * [`LlmClient.StreamAsync`](/api/csharp/client/stream/) — The streaming loop: text deltas, tool-call events, and suspension events as they happen. * [`LlmClient.Hooks`](/api/csharp/client/hooks/) — Intercept before/after model calls and tool calls: audit, redact, veto, or rewrite. # ProviderException > 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. — C#. **C#** · package `Toolnexus` · [SPEC §8](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) ```csharp public sealed class ProviderException : InvalidOperationException { public int Status { get; } // the HTTP status the provider returned public string Body { get; } // REDACTED but UNCAPPED — the raw shape, minus account ids public string? RetryAfter { get; } // the RAW Retry-After header, verbatim public ProviderException(int status, string body, string? retryAfter, string message); } // Shared redaction policy (ADR 0027), also usable directly: public static readonly IReadOnlyList RedactedBodyKeys; // user_id, account_id, org_id, organization public const string RedactionToken = "«redacted»"; public const int ErrorBodyCap = 200; ``` A non-2xx response from the model endpoint raises a typed `ProviderException` carrying the status code, a redacted+capped body, and the raw `Retry-After` header — never a bare unstructured exception. Before this type existed the only interface to a failed call was `InvalidOperationException.Message`, so a host that wanted to log the status or honor a retry delay had to parse prose. `ProviderException` still derives from `InvalidOperationException`, so existing `catch` blocks and message matching keep working unchanged. ## When to use it [Section titled “When to use it”](#when-to-use-it) Catch it wherever a run against a live provider can fail with a non-2xx response — a 429 rate limit, a 402 out of credit, a 500 upstream outage — and you need the *status* or the *raw body* as data rather than a formatted sentence. `Message` is the redacted, capped rendering that is safe to log by default; `Body` and `Status` are there for a host that genuinely wants the whole thing (an alerting rule keyed on status, a support tool that needs the provider’s real error payload). ## Why this and not the alternative [Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative) RetryAfter is the RAW header — identical in all eight ports `RetryAfter` is the `Retry-After` header value **exactly as the provider sent it**, or `null` when the response sent no such header. It is deliberately not pre-parsed into a number: `Retry-After` may legitimately be an HTTP-date, or fractional, signed, out-of-range or unparseable, and a numeric field would have to null out 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. An HTTP-date therefore still produces a backoff wait *and* still arrives verbatim on `RetryAfter`. This field previously spelled itself `RetryAfterMs` (a `long?` in milliseconds) in this port only. That was a divergence from the other six ports and is now corrected — see the changelog. Prefer OnError (client/resilience) for retry-vs-fail policy — ProviderException is the escape hatch Most callers never need to catch `ProviderException` directly: the default retry classifier and `Options.OnError` (see [`LlmClient.ErrorInfo`](/api/csharp/client/resilience/)) already see every failed attempt and decide `Retry` or `Fail` before an exception ever reaches your code. Catch `ProviderException` when you need the raw status/body *after* the client has given up — logging, alerting, or a bespoke UI message — not as a substitute for `OnError`’s classification. ## Examples [Section titled “Examples”](#examples) ### 1. The smallest useful call — catch it and read the typed fields [Section titled “1. The smallest useful call — catch it and read the typed fields”](#1-the-smallest-useful-call--catch-it-and-read-the-typed-fields) ```csharp using System.Net; using System.Text; using Toolnexus; using var stub = new Stub(ctx => Stub.Json(ctx, 429, """{"error":{"message":"rate limited"}}""")); var client = LlmClient.Create(new LlmClient.Options { BaseUrl = stub.BaseUrl, Style = "openai", Model = "gpt-4o-mini", ApiKey = "test-key", Retries = 0, }); await using var tk = await Toolkit.CreateAsync(new Toolkit.Options()); LlmClient.ProviderException? e = null; try { await client.RunAsync("go", tk); } catch (LlmClient.ProviderException ex) { e = ex; } if (e is null) throw new Exception("expected a ProviderException"); if (e.Status != 429) throw new Exception($"status: {e.Status}"); Console.WriteLine($"ok: {e.Status} — {e.Message}"); sealed class Stub : IDisposable { readonly HttpListener _listener = new(); readonly CancellationTokenSource _cts = new(); public int Port { get; } public string BaseUrl => $"http://127.0.0.1:{Port}"; public Stub(Action handler) { var probe = new System.Net.Sockets.TcpListener(IPAddress.Loopback, 0); probe.Start(); Port = ((IPEndPoint)probe.LocalEndpoint).Port; probe.Stop(); _listener.Prefixes.Add($"http://127.0.0.1:{Port}/"); _listener.Start(); _ = Task.Run(async () => { while (!_cts.IsCancellationRequested) { HttpListenerContext ctx; try { ctx = await _listener.GetContextAsync(); } catch { break; } try { handler(ctx); } catch { } } }); } public static void Json(HttpListenerContext ctx, int status, string body) { var bytes = Encoding.UTF8.GetBytes(body); ctx.Response.StatusCode = status; ctx.Response.ContentType = "application/json"; ctx.Response.ContentLength64 = bytes.Length; ctx.Response.OutputStream.Write(bytes, 0, bytes.Length); ctx.Response.OutputStream.Close(); } public void Dispose() { _cts.Cancel(); try { _listener.Stop(); } catch { } try { _listener.Close(); } catch { } } } ``` ### 2. The realistic case — the raw Retry-After, and a body redacted but not capped [Section titled “2. The realistic case — the raw Retry-After, and a body redacted but not capped”](#2-the-realistic-case--the-raw-retry-after-and-a-body-redacted-but-not-capped) ```csharp using System.Net; using System.Text; using Toolnexus; // A leaking body: 96 bytes, well inside the 200-char message cap, carrying account identifiers. const string leaky = "{\"error\":{\"message\":\"no credit\",\"user_id\":\"user_2abcdefghijkl\"," + "\"org_id\":\"org_9\",\"account_id\":\"acct_7\",\"organization\":\"acme\"}}"; using var stub = new Stub(ctx => { ctx.Response.Headers.Add("Retry-After", "3"); // a 3-SECOND header value Stub.Json(ctx, 402, leaky); }); var client = LlmClient.Create(new LlmClient.Options { BaseUrl = stub.BaseUrl, Style = "openai", Model = "gpt-4o-mini", ApiKey = "test-key", Retries = 0, }); await using var tk = await Toolkit.CreateAsync(new Toolkit.Options()); LlmClient.ProviderException? caught = null; try { await client.RunAsync("p", tk); } catch (LlmClient.ProviderException e) { caught = e; } if (caught is null) throw new Exception("expected a ProviderException"); // The header said "3" — the field hands back that string verbatim, in every port. if (caught.RetryAfter != "3") throw new Exception($"RetryAfter: {caught.RetryAfter}"); // The typed Body field is REDACTED (account keys replaced) but UNCAPPED — the full shape survives. if (caught.Body.Contains("user_2abcdefghijkl")) throw new Exception("account id leaked into Body"); if (!caught.Body.Contains(LlmClient.RedactionToken)) throw new Exception("Body must show the redaction token"); if (!caught.Body.Contains("no credit")) throw new Exception("the real cause must survive redaction"); // Message is the redacted AND capped rendering — the one that reaches logs by default. if (caught.Message.Contains("user_2abcdefghijkl")) throw new Exception("account id leaked into Message"); Console.WriteLine($"ok: status={caught.Status} retryAfter={caught.RetryAfter} body-len={caught.Body.Length}"); sealed class Stub : IDisposable { readonly HttpListener _listener = new(); readonly CancellationTokenSource _cts = new(); public int Port { get; } public string BaseUrl => $"http://127.0.0.1:{Port}"; public Stub(Action handler) { var probe = new System.Net.Sockets.TcpListener(IPAddress.Loopback, 0); probe.Start(); Port = ((IPEndPoint)probe.LocalEndpoint).Port; probe.Stop(); _listener.Prefixes.Add($"http://127.0.0.1:{Port}/"); _listener.Start(); _ = Task.Run(async () => { while (!_cts.IsCancellationRequested) { HttpListenerContext ctx; try { ctx = await _listener.GetContextAsync(); } catch { break; } try { handler(ctx); } catch { } } }); } public static void Json(HttpListenerContext ctx, int status, string body) { var bytes = Encoding.UTF8.GetBytes(body); ctx.Response.StatusCode = status; ctx.Response.ContentType = "application/json"; ctx.Response.ContentLength64 = bytes.Length; ctx.Response.OutputStream.Write(bytes, 0, bytes.Length); ctx.Response.OutputStream.Close(); } public void Dispose() { _cts.Cancel(); try { _listener.Stop(); } catch { } try { _listener.Close(); } catch { } } } ``` ### 3. Full surface — the 401/403 rule, and the ADR 0027 account-field redaction list [Section titled “3. Full surface — the 401/403 rule, and the ADR 0027 account-field redaction list”](#3-full-surface--the-401403-rule-and-the-adr-0027-account-field-redaction-list) ```csharp using System.Net; using System.Text; using Toolnexus; // (a) A 401/403 body is NEVER echoed — it routinely reflects the credential that was sent, // so it never reaches Message OR Body at all. using (var stub401 = new Stub(ctx => Stub.Json(ctx, 401, "{\"error\":\"invalid key sk-live-DEADBEEF\"}"))) { var client = LlmClient.Create(new LlmClient.Options { BaseUrl = stub401.BaseUrl, Style = "openai", Model = "gpt-4o-mini", ApiKey = "test-key", Retries = 0, }); await using var tk = await Toolkit.CreateAsync(new Toolkit.Options()); LlmClient.ProviderException? e = null; try { await client.RunAsync("p", tk); } catch (LlmClient.ProviderException ex) { e = ex; } if (e is null) throw new Exception("expected a ProviderException"); if (e.Message != "LLM 401") throw new Exception(e.Message); if (e.Body.Length != 0) throw new Exception("401 body must never be echoed"); } // (b) The redacted key list (ADR 0027) is pinned and public — a host can check against it. foreach (var key in new[] { "user_id", "account_id", "org_id", "organization" }) if (!LlmClient.RedactedBodyKeys.Contains(key)) throw new Exception($"missing key: {key}"); if (LlmClient.RedactionToken != "«redacted»") throw new Exception(LlmClient.RedactionToken); if (LlmClient.ErrorBodyCap != 200) throw new Exception(LlmClient.ErrorBodyCap.ToString()); Console.WriteLine("ok: 401 never echoed; redaction constants pinned"); sealed class Stub : IDisposable { readonly HttpListener _listener = new(); readonly CancellationTokenSource _cts = new(); public int Port { get; } public string BaseUrl => $"http://127.0.0.1:{Port}"; public Stub(Action handler) { var probe = new System.Net.Sockets.TcpListener(IPAddress.Loopback, 0); probe.Start(); Port = ((IPEndPoint)probe.LocalEndpoint).Port; probe.Stop(); _listener.Prefixes.Add($"http://127.0.0.1:{Port}/"); _listener.Start(); _ = Task.Run(async () => { while (!_cts.IsCancellationRequested) { HttpListenerContext ctx; try { ctx = await _listener.GetContextAsync(); } catch { break; } try { handler(ctx); } catch { } } }); } public static void Json(HttpListenerContext ctx, int status, string body) { var bytes = Encoding.UTF8.GetBytes(body); ctx.Response.StatusCode = status; ctx.Response.ContentType = "application/json"; ctx.Response.ContentLength64 = bytes.Length; ctx.Response.OutputStream.Write(bytes, 0, bytes.Length); ctx.Response.OutputStream.Close(); } public void Dispose() { _cts.Cancel(); try { _listener.Stop(); } catch { } try { _listener.Close(); } catch { } } } ``` ## Fields [Section titled “Fields”](#fields) | Field | Type | What it is | | --------------------- | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | | `Status` | `int` | The HTTP status the provider returned. | | `Body` | `string` | The response body, redacted but **uncapped** (ADR 0027 A5). Empty on 401/403, where the body may reflect the credential that was sent. | | `RetryAfter` | `string?` | The raw `Retry-After` header, verbatim, or `null` when the response sent none. Not pre-parsed — see the note above. Identical in all eight ports. | | `Message` (inherited) | `string` | The redacted **and capped** rendering — the one that reaches logs by default. | ## Redaction policy (ADR 0027) [Section titled “Redaction policy (ADR 0027)”](#redaction-policy-adr-0027) | Member | What it is | | ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `RedactedBodyKeys` | `user_id`, `account_id`, `org_id`, `organization` — replaced with `RedactionToken`, never dropped, so the body’s shape survives. Identical across all eight ports. | | `RedactionToken` | `"«redacted»"` — byte-identical across all eight ports. | | `ErrorBodyCap` | `200` — applied to `Message` only, never to `Body`. A cap is not redaction: the leaking body that motivated ADR 0027 was 96 bytes and would have sailed through a 200-char cap untouched had redaction not run first. | ## See also [Section titled “See also”](#see-also) * [`LlmClient.ErrorInfo`](/api/csharp/client/resilience/) — Classify an LLM error into retry/fail — the decision that runs *before* a `ProviderException` ever reaches your `catch` block. * [`LlmClient.Create`](/api/csharp/client/create/) — The unified client: system prompt, skills injection, parallel and chained tool calls, retries, memory. * [`LlmClient.RunAsync`](/api/csharp/client/run/) — Send a prompt, let the loop call tools until the model stops, get a RunResult. * [`LlmClient.Hooks`](/api/csharp/client/hooks/) — Intercept before/after model calls and tool calls: audit, redact, veto, or rewrite. # LlmClient.ErrorInfo > Classify an LLM error into retry / fail, bound a run with a deadline, and cancel cleanly. — C#. **C#** · package `Toolnexus` · [SPEC §8](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [LlmClient.cs](https://github.com/muthuishere/toolnexus/blob/main/csharp/src/Toolnexus/LlmClient.cs) ```csharp public sealed record ErrorInfo(Exception? Error, int? Status, int Attempt, bool Retryable); public enum Tier { Retry, Fail } // on Options: public Func? OnError { get; set; } public int? Retries { get; set; } // default 2 public int? RetryBaseMs { get; set; } // default 500 public long? TimeoutMs { get; set; } // whole-run deadline; null = none public IReadOnlyCollection? RetryableStatuses { get; set; } // ADDED to the default set ``` Four independent knobs on `LlmClient.Options` govern how a failed LLM call is handled: * **`OnError`** — classify each failed attempt into `Tier.Retry` or `Tier.Fail`. Called with an `ErrorInfo`: `Status` is set for a non-2xx HTTP response, `Error` for a transport/network throw, `Attempt` is zero-based, `Retryable` is whether the default classifier (429/500/502/503/504/529, or any network error) would already retry it. `null` ⇒ the default classifier (`Retryable ? Retry : Fail`) — byte-identical to not setting it at all. A `Tier.Retry` is always bounded by `Retries` — the classifier cannot loop forever. * **`Retries` / `RetryBaseMs`** — retry budget and exponential backoff base (`baseMs * 2^attempt` plus jitter), honoring a server’s `Retry-After` header when present. * **`RetryableStatuses`** — extra HTTP statuses to classify as retryable, **added** to the default set and never replacing it, so it can only widen the set and a host cannot accidentally drop `429` and lose `Retry-After` handling with it. The motivating case is a Cloudflare-fronted origin answering `520`–`527`. It sets the *default classification* only — `OnError` still runs on every failed attempt and has the final say, so an `OnError` returning `Tier.Fail` overrides a status the host itself listed. `Retry-After` handling is unaffected. ```csharp var client = LlmClient.Create(new LlmClient.Options { Model = model, ApiKey = apiKey, RetryableStatuses = new[] { 520, 521, 522, 523, 524, 525, 526, 527 }, }); ``` * **`TimeoutMs`** — a whole-run deadline. Exceeding it throws `LlmClient.RunTimeoutException`, distinct from an external `CancellationToken` cancel (`OperationCanceledException`) — so a caller can always tell “the deadline elapsed” from “someone cancelled me” without inspecting exception internals beyond the type. ## When to use it [Section titled “When to use it”](#when-to-use-it) The default classifier retries exactly `429`, `500`, `502`, `503`, `504`, `529` and network errors, and gives up on everything else — that is an enumeration, not “429 plus any 5xx”, so a `501`, a `507` or a Cloudflare `520` is terminal by default. Fine for most callers. Reach for `RetryableStatuses` when the upstream emits a transient status outside that set, for `OnError` when a provider needs a non-standard rule (e.g. treat a `402` as retryable after a top-up, or never retry a specific model’s `400`s), and reach for `TimeoutMs` any time a run must not hang past a caller-facing SLA. ## Why this and not the alternative [Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative) OnError classifies retry/fail — it is not the §10 suspension path `OnError` only ever returns `Tier.Retry` or `Tier.Fail` for a failed LLM HTTP call — there is no “suspend” tier here. A tool that needs to pause for a human/out-of-band answer uses the §10 suspension contract (`Options.WaitFor`, see [`LlmClient.RunAsync`](/api/csharp/client/run/)’s `Status == "pending"`), which is a different mechanism entirely: that’s a *tool* pausing, this is an *LLM call* failing. ## Examples [Section titled “Examples”](#examples) ### 1. The default classifier retries a transient 500 [Section titled “1. The default classifier retries a transient 500”](#1-the-default-classifier-retries-a-transient-500) ```csharp using System.Net; using System.Text; using Toolnexus; var calls = 0; using var stub = new Stub(ctx => { calls++; if (calls == 1) { Stub.Json(ctx, 500, """{"error":"upstream hiccup"}"""); return; } Stub.Json(ctx, 200, """ {"id":"c1","choices":[{"message":{"role":"assistant","content":"recovered"},"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":1,"total_tokens":4}} """); }); var client = LlmClient.Create(new LlmClient.Options { BaseUrl = stub.BaseUrl, Style = "openai", Model = "gpt-4o-mini", ApiKey = "test-key", RetryBaseMs = 1, // keep the docs example fast — no OnError needed for the default behavior }); await using var tk = await Toolkit.CreateAsync(new Toolkit.Options()); var result = await client.RunAsync("try again", tk); if (calls != 2) throw new Exception($"expected 1 retry (2 calls), got {calls}"); if (result.Text != "recovered") throw new Exception(result.Text); Console.WriteLine($"ok: {result.Text} after {calls} call(s)"); sealed class Stub : IDisposable { readonly HttpListener _listener = new(); readonly CancellationTokenSource _cts = new(); public int Port { get; } public string BaseUrl => $"http://127.0.0.1:{Port}"; public Stub(Action handler) { var probe = new System.Net.Sockets.TcpListener(IPAddress.Loopback, 0); probe.Start(); Port = ((IPEndPoint)probe.LocalEndpoint).Port; probe.Stop(); _listener.Prefixes.Add($"http://127.0.0.1:{Port}/"); _listener.Start(); _ = Task.Run(async () => { while (!_cts.IsCancellationRequested) { HttpListenerContext ctx; try { ctx = await _listener.GetContextAsync(); } catch { break; } try { handler(ctx); } catch { } } }); } public static void Json(HttpListenerContext ctx, int status, string body) { var bytes = Encoding.UTF8.GetBytes(body); ctx.Response.StatusCode = status; ctx.Response.ContentType = "application/json"; ctx.Response.ContentLength64 = bytes.Length; ctx.Response.OutputStream.Write(bytes, 0, bytes.Length); ctx.Response.OutputStream.Close(); } public void Dispose() { _cts.Cancel(); try { _listener.Stop(); } catch { } try { _listener.Close(); } catch { } } } ``` ### 2. `OnError` overrides the classifier — retry a status the default gives up on [Section titled “2. OnError overrides the classifier — retry a status the default gives up on”](#2-onerror-overrides-the-classifier--retry-a-status-the-default-gives-up-on) ```csharp using System.Net; using System.Text; using Toolnexus; var calls = 0; var seen = new List(); using var stub = new Stub(ctx => { calls++; // 402 is NOT in the default retryable set (429/500/502/503/504/529) — RunAsync would normally // fail on the first attempt. OnError below retries it anyway. if (calls == 1) { Stub.Json(ctx, 402, """{"error":"payment required"}"""); return; } Stub.Json(ctx, 200, """ {"id":"c1","choices":[{"message":{"role":"assistant","content":"topped up"},"finish_reason":"stop"}],"usage":{"prompt_tokens":2,"completion_tokens":1,"total_tokens":3}} """); }); var client = LlmClient.Create(new LlmClient.Options { BaseUrl = stub.BaseUrl, Style = "openai", Model = "gpt-4o-mini", ApiKey = "test-key", RetryBaseMs = 1, OnError = info => { seen.Add(info); if (info.Status == 402) return LlmClient.Tier.Retry; // custom rule return info.Retryable ? LlmClient.Tier.Retry : LlmClient.Tier.Fail; }, }); await using var tk = await Toolkit.CreateAsync(new Toolkit.Options()); var result = await client.RunAsync("pay and retry", tk); if (calls != 2) throw new Exception($"expected 2 calls, got {calls}"); if (result.Text != "topped up") throw new Exception(result.Text); if (seen.Count != 1 || seen[0].Status != 402 || seen[0].Attempt != 0) throw new Exception($"{seen.Count}"); if (seen[0].Retryable) throw new Exception("402 should NOT be in the default retryable set"); Console.WriteLine($"ok: {result.Text} (classified status={seen[0].Status}, defaultRetryable={seen[0].Retryable})"); sealed class Stub : IDisposable { readonly HttpListener _listener = new(); readonly CancellationTokenSource _cts = new(); public int Port { get; } public string BaseUrl => $"http://127.0.0.1:{Port}"; public Stub(Action handler) { var probe = new System.Net.Sockets.TcpListener(IPAddress.Loopback, 0); probe.Start(); Port = ((IPEndPoint)probe.LocalEndpoint).Port; probe.Stop(); _listener.Prefixes.Add($"http://127.0.0.1:{Port}/"); _listener.Start(); _ = Task.Run(async () => { while (!_cts.IsCancellationRequested) { HttpListenerContext ctx; try { ctx = await _listener.GetContextAsync(); } catch { break; } try { handler(ctx); } catch { } } }); } public static void Json(HttpListenerContext ctx, int status, string body) { var bytes = Encoding.UTF8.GetBytes(body); ctx.Response.StatusCode = status; ctx.Response.ContentType = "application/json"; ctx.Response.ContentLength64 = bytes.Length; ctx.Response.OutputStream.Write(bytes, 0, bytes.Length); ctx.Response.OutputStream.Close(); } public void Dispose() { _cts.Cancel(); try { _listener.Stop(); } catch { } try { _listener.Close(); } catch { } } } ``` ### 3. Full surface — `Fail` stops immediately, and `TimeoutMs` bounds a stalled call [Section titled “3. Full surface — Fail stops immediately, and TimeoutMs bounds a stalled call”](#3-full-surface--fail-stops-immediately-and-timeoutms-bounds-a-stalled-call) ```csharp using System.Net; using System.Text; using Toolnexus; // --- Part A: Tier.Fail skips remaining retries even though the status is normally retryable. var callsA = 0; using (var stubA = new Stub(ctx => { callsA++; Stub.Json(ctx, 500, """{"error":"down"}"""); })) { var clientA = LlmClient.Create(new LlmClient.Options { BaseUrl = stubA.BaseUrl, Style = "openai", Model = "gpt-4o-mini", ApiKey = "test-key", Retries = 5, RetryBaseMs = 1, OnError = _ => LlmClient.Tier.Fail, // never retry, regardless of status }); await using var tkA = await Toolkit.CreateAsync(new Toolkit.Options()); try { await clientA.RunAsync("give up fast", tkA); throw new Exception("expected the call to throw"); } catch (InvalidOperationException) { // expected: a non-2xx response surfaces once OnError says Fail. } if (callsA != 1) throw new Exception($"Tier.Fail must skip retries entirely, got {callsA} call(s)"); } // --- Part B: TimeoutMs bounds a run against a server that never answers in time. var stubB = new Stub(ctx => { Thread.Sleep(1500); // far longer than the 200ms deadline below Stub.Json(ctx, 200, """{"id":"late","choices":[{"message":{"role":"assistant","content":"too late"},"finish_reason":"stop"}]}"""); }); using (stubB) { var clientB = LlmClient.Create(new LlmClient.Options { BaseUrl = stubB.BaseUrl, Style = "openai", Model = "gpt-4o-mini", ApiKey = "test-key", Retries = 0, TimeoutMs = 200, }); await using var tkB = await Toolkit.CreateAsync(new Toolkit.Options()); try { await clientB.RunAsync("this will stall", tkB); throw new Exception("expected a RunTimeoutException"); } catch (LlmClient.RunTimeoutException) { // expected: the whole-run deadline fired before the server answered. } } Console.WriteLine("ok: Tier.Fail stopped after 1 call, and TimeoutMs bounded the stalled call"); sealed class Stub : IDisposable { readonly HttpListener _listener = new(); readonly CancellationTokenSource _cts = new(); public int Port { get; } public string BaseUrl => $"http://127.0.0.1:{Port}"; public Stub(Action handler) { var probe = new System.Net.Sockets.TcpListener(IPAddress.Loopback, 0); probe.Start(); Port = ((IPEndPoint)probe.LocalEndpoint).Port; probe.Stop(); _listener.Prefixes.Add($"http://127.0.0.1:{Port}/"); _listener.Start(); _ = Task.Run(async () => { while (!_cts.IsCancellationRequested) { HttpListenerContext ctx; try { ctx = await _listener.GetContextAsync(); } catch { break; } try { handler(ctx); } catch { } } }); } public static void Json(HttpListenerContext ctx, int status, string body) { var bytes = Encoding.UTF8.GetBytes(body); ctx.Response.StatusCode = status; ctx.Response.ContentType = "application/json"; ctx.Response.ContentLength64 = bytes.Length; ctx.Response.OutputStream.Write(bytes, 0, bytes.Length); ctx.Response.OutputStream.Close(); } public void Dispose() { _cts.Cancel(); try { _listener.Stop(); } catch { } try { _listener.Close(); } catch { } } } ``` Caution An **external** cancel (the `CancellationToken` passed into `RunAsync`/`StreamAsync`) is never wrapped in `RunTimeoutException` — it surfaces as a plain `OperationCanceledException`, classified by token state (SPEC §7D). Only the `TimeoutMs` deadline expiring becomes `LlmClient.RunTimeoutException`. Aborts of either kind bypass `OnError` entirely — the classifier only ever sees a completed non-2xx response or a transport-level throw. ## Fields — `ErrorInfo` [Section titled “Fields — ErrorInfo”](#fields--errorinfo) | Field | Type | What it is | | ----------- | ------------ | -------------------------------------------------------------------------------------------------------------------------------------------- | | `Error` | `Exception?` | Set on a transport/network throw; `null` for a completed non-2xx response. | | `Status` | `int?` | The HTTP status, when the call completed with a non-2xx response. | | `Attempt` | `int` | Zero-based attempt number for this call. | | `Retryable` | `bool` | Whether the default classifier (429/500/502/503/504/529, or any network error, plus anything in `RetryableStatuses`) would already retry it. | ## Options that shape resilience [Section titled “Options that shape resilience”](#options-that-shape-resilience) | Option | Type | Default | What it does | | ------------------- | --------------------------- | ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `OnError` | `Func?` | default classifier | Retry-vs-fail decision per failed attempt. | | `Retries` | `int?` | `2` | Retry budget — always caps a `Tier.Retry`. | | `RetryBaseMs` | `int?` | `500` | Backoff base for `baseMs * 2^attempt + jitter`, unless a `Retry-After` header says otherwise. | | `RetryableStatuses` | `IReadOnlyCollection?` | `null` | Extra statuses to classify as retryable, **added** to the default `429`/`500`/`502`/`503`/`504`/`529`. Widens only, never shrinks; `OnError` still has the final say. | | `TimeoutMs` | `long?` | `null` (none) | Whole-run deadline; exceeding it throws `RunTimeoutException`. | ### 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: `ClassifierOptions.RetryBaseMs` (default `500`, `base * 2^attempt`, no jitter, `Retry-After` still wins). See [`Classifier`](/api/csharp/judge/classifier/). ## See also [Section titled “See also”](#see-also) * [`LlmClient.Create`](/api/csharp/client/create/) — The unified client: system prompt, skills injection, parallel and chained tool calls, retries, memory. * [`LlmClient.RunAsync`](/api/csharp/client/run/) — Send a prompt, let the loop call tools until the model stops, get a RunResult. * [`LlmClient.StreamAsync`](/api/csharp/client/stream/) — The streaming loop: text deltas, tool-call events, and suspension events as they happen. * [`LlmClient.Hooks`](/api/csharp/client/hooks/) — Intercept before/after model calls and tool calls: audit, redact, veto, or rewrite. # LlmClient.RunAsync > Send a prompt, let the loop call tools until the model stops, get a RunResult. — C#. **C#** · package `Toolnexus` · [SPEC §8](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [LlmClient.cs](https://github.com/muthuishere/toolnexus/blob/main/csharp/src/Toolnexus/LlmClient.cs) ```csharp public Task RunAsync(string prompt, Toolkit toolkit, List? history = null, CancellationToken cancellationToken = default) ``` One turn of the agent loop: send `prompt` (plus any `history`) to the model, execute every tool call the model asks for, feed the results back, and repeat until the model stops calling tools or `Options.MaxTurns` is hit. Returns a `RunResult` — the final text, the full message transcript, every `ToolCall` made along the way, and a `Status` (`"done"`, `"pending"`, or `"incomplete"`). `RunAsync` picks the OpenAI or Anthropic wire format from `Options.Style` — the loop shape (system prompt, tool schema, parallel tool execution, retries, `Metrics()`) is identical either way. ## When to use it [Section titled “When to use it”](#when-to-use-it) You want a complete answer, not the intermediate tokens — a batch job, a Slack bot reply, a script that just needs the final text and the tool calls that produced it. It’s also what [`LlmClient.Conversation.SendAsync`](/api/csharp/client/conversation/) calls underneath. ## Why this and not the alternative [Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative) Reach for StreamAsync when the caller needs live tokens `RunAsync` blocks until the whole turn is settled — no partial text, no tool-call events as they happen. If a UI needs to show the model “typing” or wants to react to a tool call the instant it fires, use [`LlmClient.StreamAsync`](/api/csharp/client/stream/) instead; both drive the exact same loop and return the same `RunResult` shape at the end. ## Examples [Section titled “Examples”](#examples) ### 1. The smallest useful call [Section titled “1. The smallest useful call”](#1-the-smallest-useful-call) ```csharp using System.Net; using System.Text; using Toolnexus; using var stub = new Stub(ctx => { Stub.Json(ctx, 200, """ {"id":"c1","choices":[{"message":{"role":"assistant","content":"hello from the stub"},"finish_reason":"stop"}],"usage":{"prompt_tokens":5,"completion_tokens":3,"total_tokens":8}} """); }); var client = LlmClient.Create(new LlmClient.Options { BaseUrl = stub.BaseUrl, Style = "openai", Model = "gpt-4o-mini", ApiKey = "test-key", }); await using var tk = await Toolkit.CreateAsync(new Toolkit.Options()); var result = await client.RunAsync("say hello", tk); if (result.Status != "done") throw new Exception($"status: {result.Status}"); if (result.Text != "hello from the stub") throw new Exception(result.Text); Console.WriteLine($"ok: {result.Text}"); sealed class Stub : IDisposable { readonly HttpListener _listener = new(); readonly CancellationTokenSource _cts = new(); public int Port { get; } public string BaseUrl => $"http://127.0.0.1:{Port}"; public Stub(Action handler) { var probe = new System.Net.Sockets.TcpListener(IPAddress.Loopback, 0); probe.Start(); Port = ((IPEndPoint)probe.LocalEndpoint).Port; probe.Stop(); _listener.Prefixes.Add($"http://127.0.0.1:{Port}/"); _listener.Start(); _ = Task.Run(async () => { while (!_cts.IsCancellationRequested) { HttpListenerContext ctx; try { ctx = await _listener.GetContextAsync(); } catch { break; } try { handler(ctx); } catch { } } }); } public static void Json(HttpListenerContext ctx, int status, string body) { var bytes = Encoding.UTF8.GetBytes(body); ctx.Response.StatusCode = status; ctx.Response.ContentType = "application/json"; ctx.Response.ContentLength64 = bytes.Length; ctx.Response.OutputStream.Write(bytes, 0, bytes.Length); ctx.Response.OutputStream.Close(); } public void Dispose() { _cts.Cancel(); try { _listener.Stop(); } catch { } try { _listener.Close(); } catch { } } } ``` ### 2. A tool call, then a final answer [Section titled “2. A tool call, then a final answer”](#2-a-tool-call-then-a-final-answer) ```csharp using System.Net; using System.Text; using Toolnexus; var calls = 0; using var stub = new Stub(ctx => { calls++; if (calls == 1) { // Turn 1: the model asks to call "add". Stub.Json(ctx, 200, """ {"id":"c1","choices":[{"message":{"role":"assistant","content":null,"tool_calls":[{"id":"call_1","type":"function","function":{"name":"add","arguments":"{\"a\":2,\"b\":3}"}}]},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":10,"completion_tokens":5,"total_tokens":15}} """); } else { // Turn 2: the model reads the tool result and answers. Stub.Json(ctx, 200, """ {"id":"c2","choices":[{"message":{"role":"assistant","content":"2 + 3 = 5"},"finish_reason":"stop"}],"usage":{"prompt_tokens":15,"completion_tokens":6,"total_tokens":21}} """); } }); var client = LlmClient.Create(new LlmClient.Options { BaseUrl = stub.BaseUrl, Style = "openai", Model = "gpt-4o-mini", ApiKey = "test-key", }); var add = NativeTool.Of( "add", "Add two integers", new Dictionary { ["type"] = "object", ["properties"] = new Dictionary { ["a"] = new Dictionary { ["type"] = "integer" }, ["b"] = new Dictionary { ["type"] = "integer" }, }, }, (IDictionary a) => (Convert.ToInt32(a["a"]) + Convert.ToInt32(a["b"])).ToString()); await using var tk = await Toolkit.CreateAsync(new Toolkit.Options { ExtraTools = new List { add } }); var result = await client.RunAsync("what is 2 + 3?", tk); if (result.Status != "done") throw new Exception($"status: {result.Status}"); if (result.ToolCallCount != 1) throw new Exception($"expected 1 tool call, got {result.ToolCallCount}"); if (result.ToolCalls[0].Name != "add" || result.ToolCalls[0].Output != "5") throw new Exception(result.ToolCalls[0].Output); if (result.Text != "2 + 3 = 5") throw new Exception(result.Text); Console.WriteLine($"ok: {result.Text} (turns={result.Turns}, tools={result.ToolCallCount})"); sealed class Stub : IDisposable { readonly HttpListener _listener = new(); readonly CancellationTokenSource _cts = new(); public int Port { get; } public string BaseUrl => $"http://127.0.0.1:{Port}"; public Stub(Action handler) { var probe = new System.Net.Sockets.TcpListener(IPAddress.Loopback, 0); probe.Start(); Port = ((IPEndPoint)probe.LocalEndpoint).Port; probe.Stop(); _listener.Prefixes.Add($"http://127.0.0.1:{Port}/"); _listener.Start(); _ = Task.Run(async () => { while (!_cts.IsCancellationRequested) { HttpListenerContext ctx; try { ctx = await _listener.GetContextAsync(); } catch { break; } try { handler(ctx); } catch { } } }); } public static void Json(HttpListenerContext ctx, int status, string body) { var bytes = Encoding.UTF8.GetBytes(body); ctx.Response.StatusCode = status; ctx.Response.ContentType = "application/json"; ctx.Response.ContentLength64 = bytes.Length; ctx.Response.OutputStream.Write(bytes, 0, bytes.Length); ctx.Response.OutputStream.Close(); } public void Dispose() { _cts.Cancel(); try { _listener.Stop(); } catch { } try { _listener.Close(); } catch { } } } ``` ### 3. Full surface — history, usage, and the run’s metadata [Section titled “3. Full surface — history, usage, and the run’s metadata”](#3-full-surface--history-usage-and-the-runs-metadata) ```csharp using System.Net; using System.Text; using Toolnexus; using var stub = new Stub(ctx => { Stub.Json(ctx, 200, """ {"id":"c1","choices":[{"message":{"role":"assistant","content":"continuing the thread"},"finish_reason":"stop"}],"usage":{"prompt_tokens":20,"completion_tokens":4,"total_tokens":24}} """); }); var client = LlmClient.Create(new LlmClient.Options { BaseUrl = stub.BaseUrl, Style = "openai", Model = "gpt-4o-mini", ApiKey = "test-key", SystemPrompt = "You are terse.", MaxTurns = 4, }); await using var tk = await Toolkit.CreateAsync(new Toolkit.Options()); // Seed history as if a prior turn already happened — RunAsync appends to it rather than // starting a fresh system+user pair. var history = new List { new Dictionary { ["role"] = "system", ["content"] = "You are terse." }, new Dictionary { ["role"] = "user", ["content"] = "part one" }, new Dictionary { ["role"] = "assistant", ["content"] = "ack" }, }; var result = await client.RunAsync("part two", tk, history); if (result.Status != "done") throw new Exception($"status: {result.Status}"); if (result.Turns != 1) throw new Exception($"expected 1 turn, got {result.Turns}"); if (result.Usage.TotalTokens != 24) throw new Exception($"usage: {result.Usage.TotalTokens}"); // The returned Messages carries the full transcript forward — feed it to the next RunAsync call. if (result.Messages.Count != history.Count + 2) throw new Exception($"messages: {result.Messages.Count}"); if (result.Model != "gpt-4o-mini") throw new Exception(result.Model); Console.WriteLine($"ok: {result.Text} (usage={result.Usage.TotalTokens}, messages={result.Messages.Count})"); sealed class Stub : IDisposable { readonly HttpListener _listener = new(); readonly CancellationTokenSource _cts = new(); public int Port { get; } public string BaseUrl => $"http://127.0.0.1:{Port}"; public Stub(Action handler) { var probe = new System.Net.Sockets.TcpListener(IPAddress.Loopback, 0); probe.Start(); Port = ((IPEndPoint)probe.LocalEndpoint).Port; probe.Stop(); _listener.Prefixes.Add($"http://127.0.0.1:{Port}/"); _listener.Start(); _ = Task.Run(async () => { while (!_cts.IsCancellationRequested) { HttpListenerContext ctx; try { ctx = await _listener.GetContextAsync(); } catch { break; } try { handler(ctx); } catch { } } }); } public static void Json(HttpListenerContext ctx, int status, string body) { var bytes = Encoding.UTF8.GetBytes(body); ctx.Response.StatusCode = status; ctx.Response.ContentType = "application/json"; ctx.Response.ContentLength64 = bytes.Length; ctx.Response.OutputStream.Write(bytes, 0, bytes.Length); ctx.Response.OutputStream.Close(); } public void Dispose() { _cts.Cancel(); try { _listener.Stop(); } catch { } try { _listener.Close(); } catch { } } } ``` Caution `RunAsync` never throws for a `"pending"` result — a §10 suspension (a tool waiting on out-of-band resolution) surfaces as `Status == "pending"` with `Pending` set, not as an exception. Check `Status` before trusting `Text`. ## Parameters [Section titled “Parameters”](#parameters) | Parameter | Type | What it is | | ------------------- | ------------------- | ------------------------------------------------------------------------------------------------------------------------------- | | `prompt` | `string` | The user turn to send. | | `toolkit` | `Toolkit` | Tools available to the model this run. | | `history` | `List?` | Prior transcript to continue, in provider message shape. `null`/empty ⇒ start fresh with the system prompt. | | `cancellationToken` | `CancellationToken` | External cancellation — surfaces as `OperationCanceledException`, distinct from a `TimeoutMs` deadline (`RunTimeoutException`). | ## Cache tokens in usage [Section titled “Cache tokens in usage”](#cache-tokens-in-usage) `RunResult.Usage` has `CacheReadTokens` and `CacheWriteTokens` (anthropic `cache_read_input_tokens` / `cache_creation_input_tokens`; openai `prompt_tokens_details.cached_tokens`). Under anthropic the real context is `PromptTokens + CacheReadTokens + CacheWriteTokens`; under openai `PromptTokens` already includes the cached tokens. See [`PromptCache`](/api/csharp/client/prompt-cache/). ## See also [Section titled “See also”](#see-also) * [`LlmClient.Create`](/api/csharp/client/create/) — The unified client: system prompt, skills injection, parallel and chained tool calls, retries, memory. * [`LlmClient.StreamAsync`](/api/csharp/client/stream/) — The streaming loop: text deltas, tool-call events, and suspension events as they happen. * [`LlmClient.Hooks`](/api/csharp/client/hooks/) — Intercept before/after model calls and tool calls: audit, redact, veto, or rewrite. * [`LlmClient.Conversation`](/api/csharp/client/conversation/) — Keep a transcript across turns so the model remembers what it already did. # LlmClient.IConversationStore > Swap in-memory history for your own store so a conversation survives a process restart. — C#. **C#** · package `Toolnexus` · [SPEC §8](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [LlmClient.cs](https://github.com/muthuishere/toolnexus/blob/main/csharp/src/Toolnexus/LlmClient.cs) ```csharp public interface IConversationStore { Task?> GetAsync(string id); Task SaveAsync(string id, List messages); } public sealed class InMemoryConversationStore : IConversationStore { /* default */ } ``` Where [`AskAsync`](/api/csharp/client/create/) and [`StreamAsync`](/api/csharp/client/stream/) remember a conversation by `id`: `GetAsync` loads a transcript before a turn runs (`null` ⇒ no prior history — start fresh), `SaveAsync` persists the updated transcript after. `LlmClient.Create` uses `InMemoryConversationStore` — a `ConcurrentDictionary` scoped to the client’s own lifetime — unless `Options.Store` supplies your own. `client.ConversationStore()` returns whichever one is active, so a caller can read/write it directly without keeping a shadow copy. ## When to use it [Section titled “When to use it”](#when-to-use-it) A conversation needs to outlive the process (a chat that survives a restart or deploy), or several processes/instances need to share one thread by `id` (a horizontally-scaled API in front of one Postgres/Redis-backed store). Implement `IConversationStore` against whatever you already run — a file, a database, Redis — and hand it to `Options.Store`. ## Why this and not the alternative [Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative) Reach for Conversation when the process itself is the lifetime If nothing needs the transcript to survive past this process, or to be looked up by id from somewhere else, [`LlmClient.Conversation`](/api/csharp/client/conversation/) is the same in-process bookkeeping with zero store to implement. `IConversationStore` earns its keep the moment “restart-safe” or “shared across instances” becomes a real requirement. ## Examples [Section titled “Examples”](#examples) ### 1. The default — `InMemoryConversationStore`, read back directly [Section titled “1. The default — InMemoryConversationStore, read back directly”](#1-the-default--inmemoryconversationstore-read-back-directly) ```csharp using System.Net; using System.Text; using Toolnexus; using var stub = new Stub(ctx => { Stub.Json(ctx, 200, """ {"id":"c1","choices":[{"message":{"role":"assistant","content":"got it"},"finish_reason":"stop"}],"usage":{"prompt_tokens":4,"completion_tokens":2,"total_tokens":6}} """); }); var client = LlmClient.Create(new LlmClient.Options { BaseUrl = stub.BaseUrl, Style = "openai", Model = "gpt-4o-mini", ApiKey = "test-key", }); await using var tk = await Toolkit.CreateAsync(new Toolkit.Options()); await client.AskAsync("remember: I like tea", tk, id: "user-42"); // No custom Store was supplied — the client fell back to InMemoryConversationStore, and // ConversationStore() hands back that exact instance. var store = client.ConversationStore(); var saved = await store.GetAsync("user-42"); if (saved == null || saved.Count == 0) throw new Exception("expected a saved transcript for user-42"); if (await store.GetAsync("nobody") != null) throw new Exception("unknown id must return null"); Console.WriteLine($"ok: {saved.Count} messages saved for user-42"); sealed class Stub : IDisposable { readonly HttpListener _listener = new(); readonly CancellationTokenSource _cts = new(); public int Port { get; } public string BaseUrl => $"http://127.0.0.1:{Port}"; public Stub(Action handler) { var probe = new System.Net.Sockets.TcpListener(IPAddress.Loopback, 0); probe.Start(); Port = ((IPEndPoint)probe.LocalEndpoint).Port; probe.Stop(); _listener.Prefixes.Add($"http://127.0.0.1:{Port}/"); _listener.Start(); _ = Task.Run(async () => { while (!_cts.IsCancellationRequested) { HttpListenerContext ctx; try { ctx = await _listener.GetContextAsync(); } catch { break; } try { handler(ctx); } catch { } } }); } public static void Json(HttpListenerContext ctx, int status, string body) { var bytes = Encoding.UTF8.GetBytes(body); ctx.Response.StatusCode = status; ctx.Response.ContentType = "application/json"; ctx.Response.ContentLength64 = bytes.Length; ctx.Response.OutputStream.Write(bytes, 0, bytes.Length); ctx.Response.OutputStream.Close(); } public void Dispose() { _cts.Cancel(); try { _listener.Stop(); } catch { } try { _listener.Close(); } catch { } } } ``` ### 2. A custom store — a plain file per conversation id [Section titled “2. A custom store — a plain file per conversation id”](#2-a-custom-store--a-plain-file-per-conversation-id) ```csharp using System.Net; using System.Text; using System.Text.Json; using Toolnexus; using var stub = new Stub(ctx => { Stub.Json(ctx, 200, """ {"id":"c1","choices":[{"message":{"role":"assistant","content":"saved to disk"},"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":2,"total_tokens":5}} """); }); var dir = Path.Combine(Path.GetTempPath(), "toolnexus-docs-store-" + Guid.NewGuid()); Directory.CreateDirectory(dir); var client = LlmClient.Create(new LlmClient.Options { BaseUrl = stub.BaseUrl, Style = "openai", Model = "gpt-4o-mini", ApiKey = "test-key", Store = new FileConversationStore(dir), }); await using var tk = await Toolkit.CreateAsync(new Toolkit.Options()); var result = await client.AskAsync("remember: deadline is Friday", tk, id: "thread-7"); var path = Path.Combine(dir, "thread-7.json"); if (!File.Exists(path)) throw new Exception($"expected {path} to exist"); if (result.Text != "saved to disk") throw new Exception(result.Text); Console.WriteLine($"ok: {Path.GetFileName(path)} written by a custom IConversationStore"); Directory.Delete(dir, recursive: true); sealed class FileConversationStore : IConversationStore { readonly string _dir; public FileConversationStore(string dir) => _dir = dir; string PathFor(string id) => Path.Combine(_dir, $"{id}.json"); public async Task?> GetAsync(string id) { var path = PathFor(id); if (!File.Exists(path)) return null; var json = await File.ReadAllTextAsync(path); return JsonSerializer.Deserialize>(json); } public async Task SaveAsync(string id, List messages) => await File.WriteAllTextAsync(PathFor(id), JsonSerializer.Serialize(messages)); } sealed class Stub : IDisposable { readonly HttpListener _listener = new(); readonly CancellationTokenSource _cts = new(); public int Port { get; } public string BaseUrl => $"http://127.0.0.1:{Port}"; public Stub(Action handler) { var probe = new System.Net.Sockets.TcpListener(IPAddress.Loopback, 0); probe.Start(); Port = ((IPEndPoint)probe.LocalEndpoint).Port; probe.Stop(); _listener.Prefixes.Add($"http://127.0.0.1:{Port}/"); _listener.Start(); _ = Task.Run(async () => { while (!_cts.IsCancellationRequested) { HttpListenerContext ctx; try { ctx = await _listener.GetContextAsync(); } catch { break; } try { handler(ctx); } catch { } } }); } public static void Json(HttpListenerContext ctx, int status, string body) { var bytes = Encoding.UTF8.GetBytes(body); ctx.Response.StatusCode = status; ctx.Response.ContentType = "application/json"; ctx.Response.ContentLength64 = bytes.Length; ctx.Response.OutputStream.Write(bytes, 0, bytes.Length); ctx.Response.OutputStream.Close(); } public void Dispose() { _cts.Cancel(); try { _listener.Stop(); } catch { } try { _listener.Close(); } catch { } } } ``` ### 3. Full surface — two client instances sharing one store, continuing across a “restart” [Section titled “3. Full surface — two client instances sharing one store, continuing across a “restart””](#3-full-surface--two-client-instances-sharing-one-store-continuing-across-a-restart) ```csharp using System.Net; using System.Text; using Toolnexus; var turn = 0; using var stub = new Stub(ctx => { turn++; var text = turn == 1 ? "hi, first client" : "still remember you, second client"; var body = """ {"id":"cN","choices":[{"message":{"role":"assistant","content":"TEXT"},"finish_reason":"stop"}],"usage":{"prompt_tokens":5,"completion_tokens":3,"total_tokens":8}} """.Replace("cN", "c" + turn).Replace("TEXT", text); Stub.Json(ctx, 200, body); }); // One store instance, shared by two DIFFERENT LlmClient instances — simulating a process // restart where a new client is built but the store (a real db/file/Redis) persists. var shared = new InMemoryConversationStore(); var clientA = LlmClient.Create(new LlmClient.Options { BaseUrl = stub.BaseUrl, Style = "openai", Model = "gpt-4o-mini", ApiKey = "test-key", Store = shared, }); await using var tk = await Toolkit.CreateAsync(new Toolkit.Options()); var r1 = await clientA.AskAsync("I'm here", tk, id: "durable-thread"); var clientB = LlmClient.Create(new LlmClient.Options { BaseUrl = stub.BaseUrl, Style = "openai", Model = "gpt-4o-mini", ApiKey = "test-key", Store = shared, }); var r2 = await clientB.AskAsync("are you still there?", tk, id: "durable-thread"); if (r1.Text != "hi, first client") throw new Exception(r1.Text); if (r2.Text != "still remember you, second client") throw new Exception(r2.Text); // clientB picked up clientA's transcript through the shared store, not through in-process state. if (clientA.ConversationStore() != clientB.ConversationStore()) throw new Exception("expected the same store instance"); var transcript = await shared.GetAsync("durable-thread"); if (transcript == null || transcript.Count < 4) throw new Exception($"expected an accumulated transcript, got {transcript?.Count}"); Console.WriteLine($"ok: {r1.Text} | {r2.Text} | {transcript.Count} messages total"); sealed class Stub : IDisposable { readonly HttpListener _listener = new(); readonly CancellationTokenSource _cts = new(); public int Port { get; } public string BaseUrl => $"http://127.0.0.1:{Port}"; public Stub(Action handler) { var probe = new System.Net.Sockets.TcpListener(IPAddress.Loopback, 0); probe.Start(); Port = ((IPEndPoint)probe.LocalEndpoint).Port; probe.Stop(); _listener.Prefixes.Add($"http://127.0.0.1:{Port}/"); _listener.Start(); _ = Task.Run(async () => { while (!_cts.IsCancellationRequested) { HttpListenerContext ctx; try { ctx = await _listener.GetContextAsync(); } catch { break; } try { handler(ctx); } catch { } } }); } public static void Json(HttpListenerContext ctx, int status, string body) { var bytes = Encoding.UTF8.GetBytes(body); ctx.Response.StatusCode = status; ctx.Response.ContentType = "application/json"; ctx.Response.ContentLength64 = bytes.Length; ctx.Response.OutputStream.Write(bytes, 0, bytes.Length); ctx.Response.OutputStream.Close(); } public void Dispose() { _cts.Cancel(); try { _listener.Stop(); } catch { } try { _listener.Close(); } catch { } } } ``` Caution `IConversationStore` is only consulted by `id`-based calls (`AskAsync`/`StreamAsync` with `id` set). `RunAsync` and `LlmClient.Conversation` never touch it — they either take history explicitly or keep it in a private field. ## Members [Section titled “Members”](#members) | Member | Type | What it is | | ------------------------- | ---------------------- | --------------------------------------------------------- | | `GetAsync(id)` | `Task?>` | Return the stored transcript for `id`, or `null` if none. | | `SaveAsync(id, messages)` | `Task` | Persist the (updated) transcript for `id`. | ## See also [Section titled “See also”](#see-also) * [`LlmClient.Create`](/api/csharp/client/create/) — The unified client: system prompt, skills injection, parallel and chained tool calls, retries, memory. * [`LlmClient.RunAsync`](/api/csharp/client/run/) — Send a prompt, let the loop call tools until the model stops, get a RunResult. * [`LlmClient.StreamAsync`](/api/csharp/client/stream/) — The streaming loop: text deltas, tool-call events, and suspension events as they happen. * [`LlmClient.Hooks`](/api/csharp/client/hooks/) — Intercept before/after model calls and tool calls: audit, redact, veto, or rewrite. # LlmClient.StreamAsync > The streaming loop: text deltas, tool-call events, and suspension events as they happen. — C#. **C#** · package `Toolnexus` · [SPEC §8](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [LlmClient.cs](https://github.com/muthuishere/toolnexus/blob/main/csharp/src/Toolnexus/LlmClient.cs) ```csharp public Task StreamAsync(string prompt, Toolkit toolkit, Action onEvent, string? id = null, CancellationToken cancellationToken = default) ``` The same agent loop as [`RunAsync`](/api/csharp/client/run/), but `onEvent` is called live as the turn unfolds: a `StreamEvent` per text delta (`StreamKind.Text`), per tool call issued (`StreamKind.ToolCall`), per tool result (`StreamKind.ToolResult`), per usage update (`StreamKind.Usage`), per §10 suspension (`StreamKind.Pending`), and finally one `StreamKind.Done` carrying the same `RunResult` that `StreamAsync` returns. With an `id`, the transcript is loaded from the client’s `IConversationStore` before streaming and saved back once the run terminates — the same statefulness as `AskAsync`, but with live events. ## When to use it [Section titled “When to use it”](#when-to-use-it) The caller needs to react as the model works — render tokens into a chat UI, show a spinner while a tool runs, or push a §10 `Pending` request to a channel the instant it appears (before `WaitFor` even runs). Anywhere the final answer alone isn’t enough. ## Why this and not the alternative [Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative) Reach for RunAsync when you only need the final answer `StreamAsync` costs you an `Action` callback and SSE parsing on every call; if nothing downstream cares about intermediate tokens or tool-call timing, [`RunAsync`](/api/csharp/client/run/) returns the identical `RunResult` with less code to write. ## Examples [Section titled “Examples”](#examples) ### 1. The smallest useful call — collect text deltas [Section titled “1. The smallest useful call — collect text deltas”](#1-the-smallest-useful-call--collect-text-deltas) ```csharp using System.Net; using System.Text; using Toolnexus; using var stub = new Stub(ctx => { Stub.Sse(ctx, new[] { """{"choices":[{"delta":{"content":"Hel"}}]}""", """{"choices":[{"delta":{"content":"lo!"}}]}""", """{"choices":[{"delta":{}}],"usage":{"prompt_tokens":5,"completion_tokens":2,"total_tokens":7}}""", "[DONE]", }); }); var client = LlmClient.Create(new LlmClient.Options { BaseUrl = stub.BaseUrl, Style = "openai", Model = "gpt-4o-mini", ApiKey = "test-key", }); await using var tk = await Toolkit.CreateAsync(new Toolkit.Options()); var deltas = new List(); var result = await client.StreamAsync("say hello", tk, ev => { if (ev.Type == LlmClient.StreamKind.Text && ev.Delta != null) deltas.Add(ev.Delta); }); if (string.Concat(deltas) != "Hello!") throw new Exception(string.Concat(deltas)); if (result.Status != "done" || result.Text != "Hello!") throw new Exception(result.Text); Console.WriteLine($"ok: {string.Concat(deltas)}"); sealed class Stub : IDisposable { readonly HttpListener _listener = new(); readonly CancellationTokenSource _cts = new(); public int Port { get; } public string BaseUrl => $"http://127.0.0.1:{Port}"; public Stub(Action handler) { var probe = new System.Net.Sockets.TcpListener(IPAddress.Loopback, 0); probe.Start(); Port = ((IPEndPoint)probe.LocalEndpoint).Port; probe.Stop(); _listener.Prefixes.Add($"http://127.0.0.1:{Port}/"); _listener.Start(); _ = Task.Run(async () => { while (!_cts.IsCancellationRequested) { HttpListenerContext ctx; try { ctx = await _listener.GetContextAsync(); } catch { break; } try { handler(ctx); } catch { } } }); } public static void Sse(HttpListenerContext ctx, IEnumerable dataLines) { ctx.Response.StatusCode = 200; ctx.Response.ContentType = "text/event-stream"; ctx.Response.SendChunked = true; using var writer = new StreamWriter(ctx.Response.OutputStream, Encoding.UTF8) { AutoFlush = true }; foreach (var d in dataLines) writer.Write($"data: {d}\n\n"); ctx.Response.OutputStream.Close(); } public void Dispose() { _cts.Cancel(); try { _listener.Stop(); } catch { } try { _listener.Close(); } catch { } } } ``` ### 2. A streamed tool call, then a streamed final answer [Section titled “2. A streamed tool call, then a streamed final answer”](#2-a-streamed-tool-call-then-a-streamed-final-answer) ```csharp using System.Net; using System.Text; using Toolnexus; var calls = 0; using var stub = new Stub(ctx => { calls++; if (calls == 1) { // Turn 1: the tool call arrives as accumulating deltas keyed by index. Stub.Sse(ctx, new[] { """{"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_1","type":"function","function":{"name":"add","arguments":""}}]}}]}""", """{"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\"a\":2,\"b\":3}"}}]}}]}""", """{"choices":[{"delta":{},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":10,"completion_tokens":5,"total_tokens":15}}""", "[DONE]", }); } else { // Turn 2: the model reads the tool result and streams the final text. Stub.Sse(ctx, new[] { """{"choices":[{"delta":{"content":"2 + 3 = 5"}}]}""", """{"choices":[{"delta":{}}],"usage":{"prompt_tokens":15,"completion_tokens":6,"total_tokens":21}}""", "[DONE]", }); } }); var client = LlmClient.Create(new LlmClient.Options { BaseUrl = stub.BaseUrl, Style = "openai", Model = "gpt-4o-mini", ApiKey = "test-key", }); var add = NativeTool.Of( "add", "Add two integers", new Dictionary { ["type"] = "object", ["properties"] = new Dictionary { ["a"] = new Dictionary { ["type"] = "integer" }, ["b"] = new Dictionary { ["type"] = "integer" }, }, }, (IDictionary a) => (Convert.ToInt32(a["a"]) + Convert.ToInt32(a["b"])).ToString()); await using var tk = await Toolkit.CreateAsync(new Toolkit.Options { ExtraTools = new List { add } }); var toolCallSeen = false; var toolResultSeen = false; var result = await client.StreamAsync("what is 2 + 3?", tk, ev => { if (ev.Type == LlmClient.StreamKind.ToolCall && ev.Name == "add") toolCallSeen = true; if (ev.Type == LlmClient.StreamKind.ToolResult && ev.Output == "5") toolResultSeen = true; }); if (!toolCallSeen) throw new Exception("expected a ToolCall event for 'add'"); if (!toolResultSeen) throw new Exception("expected a ToolResult event with output '5'"); if (result.Status != "done" || result.Text != "2 + 3 = 5") throw new Exception(result.Text); Console.WriteLine($"ok: {result.Text} (toolCall={toolCallSeen}, toolResult={toolResultSeen})"); sealed class Stub : IDisposable { readonly HttpListener _listener = new(); readonly CancellationTokenSource _cts = new(); public int Port { get; } public string BaseUrl => $"http://127.0.0.1:{Port}"; public Stub(Action handler) { var probe = new System.Net.Sockets.TcpListener(IPAddress.Loopback, 0); probe.Start(); Port = ((IPEndPoint)probe.LocalEndpoint).Port; probe.Stop(); _listener.Prefixes.Add($"http://127.0.0.1:{Port}/"); _listener.Start(); _ = Task.Run(async () => { while (!_cts.IsCancellationRequested) { HttpListenerContext ctx; try { ctx = await _listener.GetContextAsync(); } catch { break; } try { handler(ctx); } catch { } } }); } public static void Sse(HttpListenerContext ctx, IEnumerable dataLines) { ctx.Response.StatusCode = 200; ctx.Response.ContentType = "text/event-stream"; ctx.Response.SendChunked = true; using var writer = new StreamWriter(ctx.Response.OutputStream, Encoding.UTF8) { AutoFlush = true }; foreach (var d in dataLines) writer.Write($"data: {d}\n\n"); ctx.Response.OutputStream.Close(); } public void Dispose() { _cts.Cancel(); try { _listener.Stop(); } catch { } try { _listener.Close(); } catch { } } } ``` ### 3. Full surface — every StreamEvent kind, plus `id` for stateful streaming [Section titled “3. Full surface — every StreamEvent kind, plus id for stateful streaming”](#3-full-surface--every-streamevent-kind-plus-id-for-stateful-streaming) ```csharp using System.Net; using System.Text; using Toolnexus; using var stub = new Stub(ctx => { Stub.Sse(ctx, new[] { """{"choices":[{"delta":{"content":"noted"}}]}""", """{"choices":[{"delta":{}}],"usage":{"prompt_tokens":8,"completion_tokens":1,"total_tokens":9}}""", "[DONE]", }); }); var client = LlmClient.Create(new LlmClient.Options { BaseUrl = stub.BaseUrl, Style = "openai", Model = "gpt-4o-mini", ApiKey = "test-key", }); await using var tk = await Toolkit.CreateAsync(new Toolkit.Options()); var kinds = new List(); LlmClient.RunResult? doneResult = null; var result = await client.StreamAsync("remember this", tk, ev => { kinds.Add(ev.Type); if (ev.Type == LlmClient.StreamKind.Done) doneResult = ev.Result; }, id: "thread-1"); // Text, Usage and Done all fire; Usage carries the running totals, Done carries the RunResult. if (!kinds.Contains(LlmClient.StreamKind.Text)) throw new Exception("missing Text event"); if (!kinds.Contains(LlmClient.StreamKind.Usage)) throw new Exception("missing Usage event"); if (!kinds.Contains(LlmClient.StreamKind.Done)) throw new Exception("missing Done event"); if (doneResult?.Text != "noted") throw new Exception(doneResult?.Text); // With `id`, the transcript is now durable in the client's ConversationStore — the next // StreamAsync/RunAsync call with the same id continues it. var store = client.ConversationStore(); var saved = await store.GetAsync("thread-1"); if (saved == null || saved.Count == 0) throw new Exception("expected the transcript to be persisted"); Console.WriteLine($"ok: kinds={string.Join(",", kinds.Distinct())} | saved={saved.Count} messages"); sealed class Stub : IDisposable { readonly HttpListener _listener = new(); readonly CancellationTokenSource _cts = new(); public int Port { get; } public string BaseUrl => $"http://127.0.0.1:{Port}"; public Stub(Action handler) { var probe = new System.Net.Sockets.TcpListener(IPAddress.Loopback, 0); probe.Start(); Port = ((IPEndPoint)probe.LocalEndpoint).Port; probe.Stop(); _listener.Prefixes.Add($"http://127.0.0.1:{Port}/"); _listener.Start(); _ = Task.Run(async () => { while (!_cts.IsCancellationRequested) { HttpListenerContext ctx; try { ctx = await _listener.GetContextAsync(); } catch { break; } try { handler(ctx); } catch { } } }); } public static void Sse(HttpListenerContext ctx, IEnumerable dataLines) { ctx.Response.StatusCode = 200; ctx.Response.ContentType = "text/event-stream"; ctx.Response.SendChunked = true; using var writer = new StreamWriter(ctx.Response.OutputStream, Encoding.UTF8) { AutoFlush = true }; foreach (var d in dataLines) writer.Write($"data: {d}\n\n"); ctx.Response.OutputStream.Close(); } public void Dispose() { _cts.Cancel(); try { _listener.Stop(); } catch { } try { _listener.Close(); } catch { } } } ``` Caution An SSE-shaped stub must send `data: {...}` lines terminated by a blank line, and a final `data: [DONE]` — `LlmClient` reads the wire raw, line by line, exactly as a real OpenAI/Anthropic endpoint sends it. A `StreamKind.Pending` event (§10 suspension) fires **before** `Options.WaitFor` runs, so a host can push the link/prompt to a channel immediately rather than after resolution. ## Parameters [Section titled “Parameters”](#parameters) | Parameter | Type | What it is | | ------------------- | --------------------- | ------------------------------------------------------------------------------------------------------------------------------- | | `prompt` | `string` | The user turn to send. | | `toolkit` | `Toolkit` | Tools available to the model this run. | | `onEvent` | `Action` | Called synchronously for every event: `Text`, `ToolCall`, `ToolResult`, `Usage`, `Pending`, `Done`. | | `id` | `string?` | Conversation id. Set ⇒ load history from `IConversationStore` before streaming, save after. `null` ⇒ stateless. | | `cancellationToken` | `CancellationToken` | External cancellation — surfaces as `OperationCanceledException`, distinct from a `TimeoutMs` deadline (`RunTimeoutException`). | ## `Via` on tool events [Section titled “Via on tool events”](#via-on-tool-events) With [`ToolTiers`](/api/csharp/client/tool-tiers/), a model call to `call_tool` streams as the real tool: `ToolCall` / `ToolResult` events carry the real `Name` and inner `Args`, with `Via = "call_tool"`. `Via` is `null` for an ordinary call. `ToolResult` events also carry the result’s `Metadata` (edit diffs, truncation). ## See also [Section titled “See also”](#see-also) * [`LlmClient.Create`](/api/csharp/client/create/) — The unified client: system prompt, skills injection, parallel and chained tool calls, retries, memory. * [`LlmClient.RunAsync`](/api/csharp/client/run/) — Send a prompt, let the loop call tools until the model stops, get a RunResult. * [`LlmClient.Hooks`](/api/csharp/client/hooks/) — Intercept before/after model calls and tool calls: audit, redact, veto, or rewrite. * [`LlmClient.Conversation`](/api/csharp/client/conversation/) — Keep a transcript across turns so the model remembers what it already did. # Options.ToolTiers > 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. — C#. **C#** · package `Toolnexus` · [SPEC §8](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [LlmClientHost.cs](https://github.com/muthuishere/toolnexus/blob/main/csharp/src/Toolnexus/LlmClientHost.cs) ```csharp public sealed class LlmClient.Options { public ToolTierOptions? ToolTiers { get; set; } // null ⇒ every tool is sent, as before } public sealed class ToolTierOptions { public IReadOnlyList? Core { get; set; } // always sent with full schema public IReadOnlyList? Deferred { get; set; } // named only; the rest are "listed" public int BudgetTokens { get; set; } // 0 ⇒ 5% of ContextWindow; both 0 ⇒ no budget public int ContextWindow { get; set; } public Func? UsageCount { get; set; } // host usage stat, read when the plan is built public Func? CountTokens { get; set; } // default ceil(bytes/4) } public enum ToolTier { Core, Listed, Deferred } public IReadOnlyDictionary? LlmClient.ToolTierPlan(Toolkit toolkit); public void LlmClient.ResetToolTiers(Toolkit toolkit); ``` With `ToolTiers` set, the tools array is the **core** tools (sorted by name), then two meta tools: * **`tool_search {query, limit?}`** — its description lists every other tool: *listed* tools as `- name — first line` (cut to 120 bytes of valid UTF-8), *deferred* tools by name only. `select:a,b` loads exact names; anything else is keyword-scored (default 5 results). The schemas come back as JSON in the **tool result**, so the tools array never changes. * **`call_tool {name, arguments}`** — calls a loaded tool. The loop unwraps it *before* hooks, so `BeforeTool`, hints and permission gates see the real tool, and stream events name the real tool with `Via = "call_tool"`. The model may also call a loaded tool by its own name. The plan is built on a toolkit’s first request and then **frozen**; a tool registered later is deferred and found by search. Over budget, the least-used core tools move to listed, then the least-used listed tools to deferred. `ResetToolTiers` drops the plan so the next request rebuilds it. ## When to use it [Section titled “When to use it”](#when-to-use-it) A toolkit with dozens of tools (several MCP servers, the builtins, your own): sending every schema on every request costs tokens and, with [`PromptCache`](/api/csharp/client/prompt-cache/), a tool added mid-session would change the tools array and break the cached prefix. Tiers keep the up-front schemas small and the prefix stable. ## Why this and not the alternative [Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative) ToolTiers, not DisableTools `DisableTools` removes a tool from the model entirely. Tiers keep every tool reachable, just not pre-loaded. Use `DisableTools` for tools the model must never call, tiers for tools it rarely needs. ## Examples [Section titled “Examples”](#examples) ### 1. Only core schemas, plus the two meta tools [Section titled “1. Only core schemas, plus the two meta tools”](#1-only-core-schemas-plus-the-two-meta-tools) ```csharp using System.Net; using System.Text; using Toolnexus; string sent = ""; using var stub = new Stub((ctx, body) => { sent = body; Stub.Json(ctx, 200, """ {"id":"c1","choices":[{"message":{"role":"assistant","content":"hi"},"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":1,"total_tokens":4}} """); }); var tools = new List { NativeTool.Of("read_note", "Read a note", null, (IDictionary a) => "note"), NativeTool.Of("weather", "Weather for a city", null, (IDictionary a) => "sunny"), NativeTool.Of("translate", "Translate text", null, (IDictionary a) => "hola"), }; var client = LlmClient.Create(new LlmClient.Options { BaseUrl = stub.BaseUrl, Style = "openai", Model = "m", ApiKey = "YOUR_KEY_HERE", ToolTiers = new ToolTierOptions { Core = new[] { "read_note" } }, }); await using var tk = await Toolkit.CreateAsync(new Toolkit.Options { ExtraTools = tools }); await client.RunAsync("hi", tk); var json = System.Text.Json.JsonDocument.Parse(sent).RootElement; var names = json.GetProperty("tools").EnumerateArray() .Select(t => t.GetProperty("function").GetProperty("name").GetString()).ToList(); if (string.Join(",", names) != "read_note,tool_search,call_tool") throw new Exception(string.Join(",", names)); Console.WriteLine($"ok: tools sent = {string.Join(",", names)}"); sealed class Stub : IDisposable { readonly HttpListener _listener = new(); readonly CancellationTokenSource _cts = new(); public int Port { get; } public string BaseUrl => $"http://127.0.0.1:{Port}"; public Stub(Action handler) { var probe = new System.Net.Sockets.TcpListener(IPAddress.Loopback, 0); probe.Start(); Port = ((IPEndPoint)probe.LocalEndpoint).Port; probe.Stop(); _listener.Prefixes.Add($"http://127.0.0.1:{Port}/"); _listener.Start(); _ = Task.Run(async () => { while (!_cts.IsCancellationRequested) { HttpListenerContext ctx; try { ctx = await _listener.GetContextAsync(); } catch { break; } var body = new StreamReader(ctx.Request.InputStream).ReadToEnd(); try { handler(ctx, body); } catch { } } }); } public static void Json(HttpListenerContext ctx, int status, string body) { var bytes = Encoding.UTF8.GetBytes(body); ctx.Response.StatusCode = status; ctx.Response.ContentType = "application/json"; ctx.Response.ContentLength64 = bytes.Length; ctx.Response.OutputStream.Write(bytes, 0, bytes.Length); ctx.Response.OutputStream.Close(); } public void Dispose() { _cts.Cancel(); try { _listener.Stop(); } catch { } try { _listener.Close(); } catch { } } } ``` ### 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) ```csharp using System.Net; using System.Text; using Toolnexus; var turn = 0; var requests = new List(); using var stub = new Stub((ctx, body) => { requests.Add(body); turn++; var reply = turn switch { 1 => """ {"id":"c1","choices":[{"message":{"role":"assistant","content":null,"tool_calls":[{"id":"s1","type":"function","function":{"name":"tool_search","arguments":"{\"query\":\"select:weather\"}"}}]},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":5,"completion_tokens":1,"total_tokens":6}} """, 2 => """ {"id":"c2","choices":[{"message":{"role":"assistant","content":null,"tool_calls":[{"id":"w1","type":"function","function":{"name":"call_tool","arguments":"{\"name\":\"weather\",\"arguments\":{\"city\":\"Paris\"}}"}}]},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":5,"completion_tokens":1,"total_tokens":6}} """, _ => """ {"id":"c3","choices":[{"message":{"role":"assistant","content":"sunny"},"finish_reason":"stop"}],"usage":{"prompt_tokens":5,"completion_tokens":1,"total_tokens":6}} """, }; Stub.Json(ctx, 200, reply); }); var ran = ""; var weather = NativeTool.Of("weather", "Weather for a city", null, (IDictionary a) => { ran = $"{a["city"]}"; return "sunny"; }); var seenByHook = new List(); var client = LlmClient.Create(new LlmClient.Options { BaseUrl = stub.BaseUrl, Style = "openai", Model = "m", ApiKey = "YOUR_KEY_HERE", ToolTiers = new ToolTierOptions { Core = Array.Empty() }, Hooks = new LlmClient.Hooks { BeforeTool = ev => { seenByHook.Add(ev.Name); return null; } }, }); await using var tk = await Toolkit.CreateAsync(new Toolkit.Options { ExtraTools = new List { weather } }); var result = await client.RunAsync("weather in Paris?", tk); if (ran != "Paris") throw new Exception("weather never ran"); if (!seenByHook.Contains("weather")) throw new Exception(string.Join(",", seenByHook)); // the real name, not call_tool // The tools array is the same on every request: the loaded schema arrived as a tool result. var arrays = requests.Select(r => System.Text.Json.JsonDocument.Parse(r).RootElement.GetProperty("tools").GetRawText()).Distinct().Count(); if (arrays != 1) throw new Exception("tools array changed mid-session"); Console.WriteLine($"ok: {result.Text}; hook saw [{string.Join(",", seenByHook)}]; tools array stable across {requests.Count} requests"); sealed class Stub : IDisposable { readonly HttpListener _listener = new(); readonly CancellationTokenSource _cts = new(); public int Port { get; } public string BaseUrl => $"http://127.0.0.1:{Port}"; public Stub(Action handler) { var probe = new System.Net.Sockets.TcpListener(IPAddress.Loopback, 0); probe.Start(); Port = ((IPEndPoint)probe.LocalEndpoint).Port; probe.Stop(); _listener.Prefixes.Add($"http://127.0.0.1:{Port}/"); _listener.Start(); _ = Task.Run(async () => { while (!_cts.IsCancellationRequested) { HttpListenerContext ctx; try { ctx = await _listener.GetContextAsync(); } catch { break; } var body = new StreamReader(ctx.Request.InputStream).ReadToEnd(); try { handler(ctx, body); } catch { } } }); } public static void Json(HttpListenerContext ctx, int status, string body) { var bytes = Encoding.UTF8.GetBytes(body); ctx.Response.StatusCode = status; ctx.Response.ContentType = "application/json"; ctx.Response.ContentLength64 = bytes.Length; ctx.Response.OutputStream.Write(bytes, 0, bytes.Length); ctx.Response.OutputStream.Close(); } public void Dispose() { _cts.Cancel(); try { _listener.Stop(); } catch { } try { _listener.Close(); } catch { } } } ``` ### 3. Inspect, budget and reset the plan [Section titled “3. Inspect, budget and reset the plan”](#3-inspect-budget-and-reset-the-plan) ```csharp using Toolnexus; var tools = Enumerable.Range(0, 6) .Select(i => (ITool)NativeTool.Of($"tool_{i}", $"Tool number {i}", null, (IDictionary a) => "ok")) .ToList(); var usage = new Dictionary { ["tool_0"] = 50, ["tool_1"] = 10 }; var client = LlmClient.Create(new LlmClient.Options { BaseUrl = "http://127.0.0.1:1", Style = "openai", Model = "m", ApiKey = "YOUR_KEY_HERE", ToolTiers = new ToolTierOptions { Core = tools.Take(5).Select(t => t.Name).ToList(), // ask for five as core... Deferred = new[] { "tool_5" }, BudgetTokens = 450, // ...but cap the up-front schemas CountTokens = _ => 100, // every definition "costs" 100 here, for a stable demo UsageCount = n => usage.GetValueOrDefault(n), }, }); await using var tk = await Toolkit.CreateAsync(new Toolkit.Options { ExtraTools = tools, Builtins = false }); var plan = client.ToolTierPlan(tk)!; // built now, frozen until reset foreach (var (name, tier) in plan.OrderBy(p => p.Key)) Console.WriteLine($" {name}: {tier}"); // 2 meta tools + 2 core = 400 <= 450: the three least-used core tools moved down to listed. if (plan["tool_0"] != ToolTier.Core || plan["tool_1"] != ToolTier.Core || plan["tool_2"] != ToolTier.Listed) throw new Exception("the most-used tools should stay core"); if (plan["tool_5"] != ToolTier.Deferred) throw new Exception("tool_5 was asked to be deferred"); usage["tool_4"] = 99; client.ResetToolTiers(tk); // e.g. at a new session start, after updating usage stats var replanned = client.ToolTierPlan(tk)!; Console.WriteLine($"ok: {plan.Count(p => p.Value == ToolTier.Core)} core before reset, tool_4 now {replanned["tool_4"]}"); ``` ## See also [Section titled “See also”](#see-also) * [`LlmClient.Create`](/api/csharp/client/create/) — The unified client: system prompt, skills injection, parallel and chained tool calls, retries, memory. * [`LlmClient.RunAsync`](/api/csharp/client/run/) — Send a prompt, let the loop call tools until the model stops, get a RunResult. * [`LlmClient.StreamAsync`](/api/csharp/client/stream/) — The streaming loop: text deltas, tool-call events, and suspension events as they happen. * [`LlmClient.Hooks`](/api/csharp/client/hooks/) — Intercept before/after model calls and tool calls: audit, redact, veto, or rewrite. # Compaction.Compactor > Summarise older turns when the transcript grows, so a long-lived agent stays inside its context window. — C#. **C#** · package `Toolnexus` · [SPEC §7F](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [Agents/Compaction.cs](https://github.com/muthuishere/toolnexus/blob/main/csharp/src/Toolnexus/Agents/Compaction.cs) ```csharp namespace Toolnexus.Agents; public static class Compaction { public static Func Compactor(Options opts); public delegate string SummarizeFn(IReadOnlyList older); public delegate int CountTokensFn(IReadOnlyList messages); public sealed class Options { public required int MaxTokens { get; init; } public int? KeepTail { get; init; } // default MaxTokens / 2 public required SummarizeFn Summarize { get; init; } public CountTokensFn? CountTokens { get; init; } // default ceil(chars/4) per message public bool FlushToMemory { get; init; } } } ``` `Compactor` builds a `BeforeLLM` hook (§8): every turn, it estimates the transcript’s token count, and — only when that estimate exceeds `MaxTokens` — replaces the working transcript with `[system, summary, tail]`, where `tail` is the most recent turns kept in full. Below the budget it is a **byte-identical no-op**: no compactor behaves exactly like this compactor under budget. ## When to use it [Section titled “When to use it”](#when-to-use-it) A persona or agent that runs for a long time (many turns, a `heartbeat`, a chat that never really ends) will eventually overflow the model’s context window. Hand `Compactor`’s result to `LlmClient.Options.Hooks.BeforeLLM` (a bare client) or an agent’s `AgentSpec.Hooks.BeforeLLM` (§7D) and the loop keeps itself under budget automatically — no change to how you call `RunAsync`. ## Why this and not the alternative [Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative) Reach for a bigger context window before reaching for this Compaction is lossy — the older transcript becomes a summary, not a replay. If the model you’re using simply has room, raising `MaxTurns` or switching models is cheaper than compacting. Reach for `Compactor` when the run is genuinely long-lived (a heartbeat persona, a support desk that runs for days) and unbounded growth is the actual constraint, not a one-off long conversation. ## Examples [Section titled “Examples”](#examples) ### 1. The smallest useful call — under budget is a no-op [Section titled “1. The smallest useful call — under budget is a no-op”](#1-the-smallest-useful-call--under-budget-is-a-no-op) ```csharp using Toolnexus; using Toolnexus.Agents; var compact = Compaction.Compactor(new Compaction.Options { MaxTokens = 1000, Summarize = older => $"summary of {older.Count} messages", }); var messages = new List { new Dictionary { ["role"] = "system", ["content"] = "You are terse." }, new Dictionary { ["role"] = "user", ["content"] = "hello" }, new Dictionary { ["role"] = "assistant", ["content"] = "hi" }, }; var ev = new LlmClient.BeforeLLMEvent(messages, new List>(), "gpt-4o-mini", 1); var over = compact(ev); if (over != null) throw new Exception("under budget must be a byte-identical no-op"); Console.WriteLine("ok: no-op under budget"); ``` ### 2. Over budget — tool-pair safety at the retained tail’s boundary [Section titled “2. Over budget — tool-pair safety at the retained tail’s boundary”](#2-over-budget--tool-pair-safety-at-the-retained-tails-boundary) ```csharp using Toolnexus; using Toolnexus.Agents; object? Msg(string role, string content) => new Dictionary { ["role"] = role, ["content"] = content }; object? ToolCallMsg(string id, string name) => new Dictionary { ["role"] = "assistant", ["content"] = null, ["tool_calls"] = new List { new Dictionary { ["id"] = id, ["type"] = "function", ["function"] = new Dictionary { ["name"] = name, ["arguments"] = "{}" } } }, }; object? ToolResultMsg(string id, string output) => new Dictionary { ["role"] = "tool", ["tool_call_id"] = id, ["content"] = output }; var messages = new List { Msg("system", "You are terse."), Msg("user", "turn one"), Msg("assistant", "turn one answer"), Msg("user", "turn two — check the weather"), ToolCallMsg("c1", "weather"), ToolResultMsg("c1", "31C, humid"), Msg("assistant", "It's 31C and humid."), }; var summarized = ""; var compact = Compaction.Compactor(new Compaction.Options { MaxTokens = 6, KeepTail = 3, CountTokens = msgs => msgs.Count, // one "token" per message — deterministic for the test Summarize = older => { summarized = $"{older.Count} older turns folded"; return summarized; }, }); var ev = new LlmClient.BeforeLLMEvent(messages, new List>(), "gpt-4o-mini", 5); var over = compact(ev); if (over?.Messages == null) throw new Exception("expected compaction (7 messages > MaxTokens=6)"); var result = over.Messages; // Leading system prompt preserved verbatim. var head = (IDictionary)result[0]!; if (head["role"] as string != "system" || head["content"] as string != "You are terse.") throw new Exception("system prompt must be preserved verbatim"); // A summary message follows, carrying Summarize()'s output. var summaryMsg = (IDictionary)result[1]!; if (!((summaryMsg["content"] as string) ?? "").Contains(summarized)) throw new Exception("summary missing"); // The retained tail begins at a `user` turn — the `tool` result for "c1" is never orphaned from // the assistant turn carrying its tool_calls. var tailStart = (IDictionary)result[2]!; if (tailStart["role"] as string != "user") throw new Exception($"tail must start at user, got {tailStart["role"]}"); Console.WriteLine($"ok: {result.Count} messages, tail starts at '{tailStart["role"]}'"); ``` ### 3. Full surface — `FlushToMemory`’s pre-compact reminder [Section titled “3. Full surface — FlushToMemory’s pre-compact reminder”](#3-full-surface--flushtomemorys-pre-compact-reminder) ```csharp using Toolnexus; using Toolnexus.Agents; object? Msg(string role, string content) => new Dictionary { ["role"] = role, ["content"] = content }; var messages = new List { Msg("system", "identity"), Msg("user", "one"), Msg("assistant", "ack one"), Msg("user", "two"), Msg("assistant", "ack two"), Msg("user", "three"), }; var compact = Compaction.Compactor(new Compaction.Options { MaxTokens = 3, KeepTail = 1, FlushToMemory = true, CountTokens = msgs => msgs.Count, Summarize = older => $"folded {older.Count} messages", }); var ev = new LlmClient.BeforeLLMEvent(messages, new List>(), "gpt-4o-mini", 3); var over = compact(ev); if (over?.Messages == null) throw new Exception("expected compaction (6 messages > MaxTokens=3)"); var result = over.Messages; // [system, summary, flush-reminder, ...tail] — the reminder tells the model to save durable facts // via the memory tool BEFORE the head is summarized away. var reminder = (IDictionary)result[2]!; if (reminder["role"] as string != "system" || !((reminder["content"] as string) ?? "").Contains("memory tool")) throw new Exception("expected the pre-compact flush-to-memory reminder"); var tail = (IDictionary)result[3]!; if (tail["content"] as string != "three") throw new Exception("expected the most recent user turn as tail"); Console.WriteLine($"ok: {result.Count} messages, reminder='{reminder["content"]}'"); ``` Caution `FlushToMemory` only injects the reminder message — it does not call the `memory` tool itself, and it has no effect unless the agent actually has a `memory` tool wired (e.g. via [`Home.FromDir`](/api/csharp/persona/from-dir/)). Without one, the model has nowhere to put what the reminder asks it to save. ## Fields (`Options`) [Section titled “Fields (Options)”](#fields-options) | Field | Type | What it is | | --------------- | ------------------------ | --------------------------------------------------------------------------------------------------------- | | `MaxTokens` | `int` (required) | Compact only when the estimate exceeds this; at/below ⇒ no-op. | | `KeepTail` | `int?` | Minimum tokens of the most recent tail to keep. Default `MaxTokens / 2`. | | `Summarize` | `SummarizeFn` (required) | Produces the summary of the older messages. MAY call an LLM — the library never calls one on your behalf. | | `CountTokens` | `CountTokensFn?` | Token estimator. Default: `ceil(len(JSON) / 4)` summed per message — an estimator, not a tokenizer. | | `FlushToMemory` | `bool` | Inject a pre-compact system reminder to persist durable facts via the §7E `memory` tool. Default `false`. | ## `OnCompact` — tell the UI [Section titled “OnCompact — tell the UI”](#oncompact--tell-the-ui) ```csharp public Action? OnCompact { get; init; } public sealed record CompactInfo(int TokensBefore, int TokensAfter, int MessagesBefore, int MessagesAfter, int Summarized, string Summary, IReadOnlyList Folded); ``` Called after each compaction, never on a no-op, so a host can show “context compacted”. `Folded` is the summarized messages verbatim, for a host that persists them (the loop saves only the compacted transcript). It gets no cancellation token (the C# `BeforeLLM` hook carries none). Since 0.22.0 the compactor also keeps a turn’s sole user prompt when the rest is tool traffic, and does nothing when the result would not be smaller. ```csharp var compactor = Compaction.Compactor(new Compaction.Options { MaxTokens = 8000, Summarize = older => Summarize(older), OnCompact = info => Console.WriteLine($"context compacted: {info.TokensBefore} → {info.TokensAfter} tokens"), }); ``` ## See also [Section titled “See also”](#see-also) * [`Home.MemoryTool`](/api/csharp/persona/memory-tool/) — What `FlushToMemory`’s reminder points the model at. * [`LlmClient.Hooks`](/api/csharp/client/hooks/) — The `BeforeLLM` seam `Compactor`’s result plugs into. * [`Agent`](/api/csharp/runtime/agent/) — `AgentSpec.Hooks` is where a §7D agent wires its own compactor, independent of the runtime-wide one. # HttpTool.Of > Declare a REST endpoint as a tool — URL placeholders, query args, JSON or form bodies, and ${ENV} headers. C#. **C#** · package `Toolnexus` · [SPEC §7](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [HttpTool.cs](https://github.com/muthuishere/toolnexus/blob/main/csharp/src/Toolnexus/HttpTool.cs) ```csharp public static HttpTool Of(HttpTool.Options opts) ``` Declares an HTTP endpoint as an [`ITool`](/api/csharp/types/tool/) with `Source` set to `"http"`. The model’s arguments become URL placeholders, query parameters or a request body according to the options, the response body becomes the tool output, and a non-2xx status becomes a tool error. No `HttpClient` of your own, no request-building code, no per-endpoint wrapper class — the endpoint becomes a tool by description. ## When to use it [Section titled “When to use it”](#when-to-use-it) When the capability you want to expose is already a REST call: an internal microservice, a public JSON API, a webhook, an admin endpoint. Declaring it beats writing a `NativeTool` around `HttpClient`, because argument routing and error mapping are handled for you. ## Why this and not the alternative [Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative) Use NativeTool.Of when the call needs real logic [`NativeTool.Of`](/api/csharp/native/define-tool/) is the right choice the moment you need to reshape the response, paginate, sign a request, retry with backoff, or call two endpoints and combine them. `HttpTool` deliberately does one request and hands back the body verbatim. And if the remote side already speaks MCP, use [`McpSource.LoadAsync`](/api/csharp/mcp/load/) instead — you get its whole tool list, described by the server, rather than one endpoint you described yourself. ## Examples [Section titled “Examples”](#examples) ### 1. A GET endpoint, arguments as query parameters [Section titled “1. A GET endpoint, arguments as query parameters”](#1-a-get-endpoint-arguments-as-query-parameters) For `GET` (and `HEAD`), **every** leftover argument goes to the query string — you do not need to list them in `Query`. ```csharp using System.Net; using System.Net.Sockets; using System.Text; using Toolnexus; static int FreePort() { var probe = new TcpListener(IPAddress.Loopback, 0); probe.Start(); var p = ((IPEndPoint)probe.LocalEndpoint).Port; probe.Stop(); return p; } // A throwaway loopback server so this example is hermetic — no public network. var port = FreePort(); var server = new HttpListener(); server.Prefixes.Add($"http://127.0.0.1:{port}/"); server.Start(); var serving = Task.Run(async () => { var c = await server.GetContextAsync(); var body = Encoding.UTF8.GetBytes($$"""{"seen":"{{c.Request.Url!.PathAndQuery}}"}"""); c.Response.StatusCode = 200; c.Response.ContentType = "application/json"; c.Response.ContentLength64 = body.Length; await c.Response.OutputStream.WriteAsync(body); c.Response.Close(); }); try { var search = HttpTool.Of(new HttpTool.Options { Name = "search_docs", Description = "Search the documentation index", Method = "GET", Url = $"http://127.0.0.1:{port}/search", InputSchema = new Dictionary { ["type"] = "object", ["properties"] = new Dictionary { ["q"] = new Dictionary { ["type"] = "string" } }, ["required"] = new[] { "q" }, }, }); if (search.Source != "http") throw new Exception(search.Source); var res = await search.ExecuteAsync(new Dictionary { ["q"] = "http tool" }); if (res.IsError) throw new Exception(res.Output); // Values are URL-encoded on the way out. if (!res.Output.Contains("/search?q=http%20tool")) throw new Exception(res.Output); // Metadata always carries the HTTP status. if (Convert.ToInt32(res.Metadata?["status"]) != 200) throw new Exception("status metadata"); await serving; Console.WriteLine($"ok: {res.Output}"); } finally { server.Stop(); } ``` ### 2. A POST with a URL placeholder, a JSON body and an `${ENV}` header [Section titled “2. A POST with a URL placeholder, a JSON body and an ${ENV} header”](#2-a-post-with-a-url-placeholder-a-json-body-and-an-env-header) `{placeholder}` segments in the URL are filled from the arguments and **consumed** — they do not also appear in the body. Whatever is left becomes the JSON body. ```csharp using System.Net; using System.Net.Sockets; using System.Text; using Toolnexus; static int FreePort() { var probe = new TcpListener(IPAddress.Loopback, 0); probe.Start(); var p = ((IPEndPoint)probe.LocalEndpoint).Port; probe.Stop(); return p; } var port = FreePort(); var server = new HttpListener(); server.Prefixes.Add($"http://127.0.0.1:{port}/"); server.Start(); string seenPath = "", seenAuth = "", seenBody = "", seenType = ""; var serving = Task.Run(async () => { var c = await server.GetContextAsync(); seenPath = c.Request.Url!.PathAndQuery; seenAuth = c.Request.Headers["Authorization"] ?? ""; seenType = c.Request.ContentType ?? ""; using (var reader = new StreamReader(c.Request.InputStream, Encoding.UTF8)) seenBody = await reader.ReadToEndAsync(); var body = Encoding.UTF8.GetBytes("""{"ok":true}"""); c.Response.StatusCode = 201; c.Response.ContentLength64 = body.Length; await c.Response.OutputStream.WriteAsync(body); c.Response.Close(); }); // The token is read from the environment at CALL time and never appears in the tool // definition. YOUR_KEY_HERE is an obvious placeholder, not a credential. Environment.SetEnvironmentVariable("DOCS_DEMO_TOKEN", "YOUR_KEY_HERE"); try { var comment = HttpTool.Of(new HttpTool.Options { Name = "add_comment", Description = "Add a comment to an issue", Method = "POST", Url = $"http://127.0.0.1:{port}/issues/{{issue}}/comments", Headers = new Dictionary { ["Authorization"] = "Bearer ${DOCS_DEMO_TOKEN}" }, Body = "json", InputSchema = new Dictionary { ["type"] = "object", ["properties"] = new Dictionary { ["issue"] = new Dictionary { ["type"] = "string" }, ["text"] = new Dictionary { ["type"] = "string" }, }, ["required"] = new[] { "issue", "text" }, }, }); var res = await comment.ExecuteAsync(new Dictionary { ["issue"] = "42", ["text"] = "looks good", }); if (res.IsError) throw new Exception(res.Output); if (res.Output != """{"ok":true}""") throw new Exception(res.Output); if (Convert.ToInt32(res.Metadata?["status"]) != 201) throw new Exception("status"); await serving; // `issue` was consumed by the URL; only `text` survived into the body. if (seenPath != "/issues/42/comments") throw new Exception(seenPath); if (seenBody != """{"text":"looks good"}""") throw new Exception(seenBody); if (seenType != "application/json") throw new Exception(seenType); // ${DOCS_DEMO_TOKEN} was expanded from the environment. if (seenAuth != "Bearer YOUR_KEY_HERE") throw new Exception("header expansion"); Console.WriteLine($"ok: {seenPath} <- {seenBody}"); } finally { server.Stop(); } ``` Header values are use-only `${VAR}` is expanded from the process environment on every call, so rotating a key needs no code change — and the literal value never lives in your config, your repo, or a log line. An unset variable expands to the **empty string**, not an error: check for a 401 rather than assuming the header was populated. ### 3. Failures, explicit `Query` on a POST, and `ResultMode` [Section titled “3. Failures, explicit Query on a POST, and ResultMode”](#3-failures-explicit-query-on-a-post-and-resultmode) A non-2xx response is a tool **error** whose output is `HTTP : ` — the model reads it and can correct itself. Network failures and timeouts land the same way. ```csharp using System.Net; using System.Net.Sockets; using System.Text; using Toolnexus; static int FreePort() { var probe = new TcpListener(IPAddress.Loopback, 0); probe.Start(); var p = ((IPEndPoint)probe.LocalEndpoint).Port; probe.Stop(); return p; } var port = FreePort(); var server = new HttpListener(); server.Prefixes.Add($"http://127.0.0.1:{port}/"); server.Start(); var seenPaths = new List(); var seenBodies = new List(); var serving = Task.Run(async () => { for (var i = 0; i < 2; i++) { var c = await server.GetContextAsync(); seenPaths.Add(c.Request.Url!.PathAndQuery); using (var reader = new StreamReader(c.Request.InputStream, Encoding.UTF8)) seenBodies.Add(await reader.ReadToEndAsync()); var notFound = c.Request.Url!.AbsolutePath.StartsWith("/missing"); var body = Encoding.UTF8.GetBytes(notFound ? """{"error":"no such record"}""" : """{"id":9,"state":"queued"}"""); c.Response.StatusCode = notFound ? 404 : 200; c.Response.ContentLength64 = body.Length; await c.Response.OutputStream.WriteAsync(body); c.Response.Close(); } }); try { // Query names are routed to the querystring even on a POST; the rest becomes the body. var enqueue = HttpTool.Of(new HttpTool.Options { Name = "enqueue_job", Description = "Enqueue a background job", Method = "POST", Url = $"http://127.0.0.1:{port}/jobs", Query = new List { "priority" }, Body = "json", ResultMode = "status+text", Timeout = 5_000, }); var ok = await enqueue.ExecuteAsync(new Dictionary { ["priority"] = "high", ["kind"] = "reindex", }); if (ok.IsError) throw new Exception(ok.Output); // ResultMode "status+text" prefixes the status code and a newline. if (ok.Output != "200\n{\"id\":9,\"state\":\"queued\"}") throw new Exception(ok.Output); var missing = HttpTool.Of(new HttpTool.Options { Name = "get_record", Description = "Fetch a record", Method = "GET", Url = $"http://127.0.0.1:{port}/missing", }); var bad = await missing.ExecuteAsync(new Dictionary()); if (!bad.IsError) throw new Exception("expected IsError"); if (bad.Output != """HTTP 404: {"error":"no such record"}""") throw new Exception(bad.Output); // The status is on the metadata of the error result too. if (Convert.ToInt32(bad.Metadata?["status"]) != 404) throw new Exception("status metadata"); await serving; if (seenPaths[0] != "/jobs?priority=high") throw new Exception(seenPaths[0]); if (seenBodies[0] != """{"kind":"reindex"}""") throw new Exception(seenBodies[0]); Console.WriteLine($"ok: {seenPaths[0]} | error: {bad.Output}"); } finally { server.Stop(); } ``` Note A `ToolContext.TimeoutMs` supplied by the caller **overrides** `Options.Timeout`, which itself defaults to 30 000 ms. A timeout or a DNS failure returns an error result carrying the exception message — `HttpTool` never throws out of `ExecuteAsync`. ## Options [Section titled “Options”](#options) | Field | Type | Default | What it does | | ------------- | ------------------------------- | ------------ | ----------------------------------------------------------------------------------------- | | `Name` | `string` | `""` | Tool name the model calls. Must match `[a-zA-Z0-9_-]`. | | `Description` | `string` | `""` | What the model reads to decide whether to call it. | | `Method` | `string` | `"GET"` | Upper-cased internally, so `"post"` works. | | `Url` | `string` | `""` | May contain `{placeholder}` segments filled from the arguments. | | `Headers` | `IDictionary?` | `null` | Values expand `${ENV_VAR}` at call time; never logged. | | `Query` | `List?` | `null` | Argument names routed to the querystring. Ignored for GET — everything goes there anyway. | | `Body` | `string?` | `"json"` | `"json"`, `"form"`, or `"raw"` (sends the `body` argument verbatim). | | `InputSchema` | `IDictionary?` | empty object | The JSON Schema the model sees. | | `Timeout` | `long?` | `30000` | Milliseconds. Overridden by `ToolContext.TimeoutMs`. | | `ResultMode` | `string?` | text | `"json"` re-serializes the parsed body; `"status+text"` prefixes `"\n"`. | Argument routing, in order: URL placeholders first, then query names (or everything, for GET), and whatever remains becomes the body. `GET` and `HEAD` never send a body. ## See also [Section titled “See also”](#see-also) * [`NativeTool.Of`](/api/csharp/native/define-tool/) — when the call needs logic around it * [`McpSource.LoadAsync`](/api/csharp/mcp/load/) — when the remote side already speaks MCP * [`Toolkit.CreateAsync`](/api/csharp/toolkit/create/) — register the tool alongside every other source * [`ITool`](/api/csharp/types/tool/) · [`ToolResult`](/api/csharp/types/tool-result/) · [`ToolContext`](/api/csharp/types/context/) # Classifier.Create > A sibling of the client: pre-declared typed questions in, calibrated answers out — no messages, no tool calling, no loop. — C#. **C#** · package `Toolnexus` · [SPEC §8B](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [Classifier.cs](https://github.com/muthuishere/toolnexus/blob/main/csharp/src/Toolnexus/Classifier.cs) ```csharp public sealed class Classifier { public Classifier(ClassifierOptions? options = null); public static Classifier Create(ClassifierOptions? options = null); public Task EvaluateAsync( object? state, IReadOnlyDictionary questions, CancellationToken cancellationToken = default); public ClassifierStyle Style { get; } public string BaseUrl { get; } public string Model { get; } public string ApiKeyEnv { get; } public TimeSpan Timeout { get; } public const string DefaultBaseUrl = "https://api.typesafe.ai/v1"; public const string DefaultModel = "jev-latest"; public const string DefaultApiKeyEnv = "TYPESAFE_API_KEY"; public static readonly TimeSpan DefaultTimeout = TimeSpan.FromSeconds(10); public const int MaxChoiceOptions = 255; public const int MinScoreLevels = 2; public const int MaxScoreLevels = 10; public const double NearUniformTolerance = 0.05; public const string MetricEvaluate = "classifier.evaluate"; public const string MetricWarning = "classifier.warning"; public static bool NearUniform(IReadOnlyDictionary probabilities); public static byte[] CanonicalRequest(string model, IReadOnlyDictionary questions); public static string CanonicalRequestString(string model, IReadOnlyDictionary questions); } ``` A sibling of the client: pre-declared typed questions in, calibrated answers out — no messages, no tool calling, no loop. `ITool` is the contract for an **action**; `Classifier` is the contract for a **judgment**. It never enters the client loop and is never selected as a model for `RunAsync`. `state` is whatever the host already has — a `string`, a dictionary, a list. `questions` is a map from **caller-chosen keys** to question definitions; the keys are addressing, not content, and are never transmitted, so a key may be a tool, skill or agent name verbatim. Questions are **independent**: one answer is never context for another. A classifier interprets; it never authorises The output is a reading of intent inside a boundary, never the boundary. Numeric limits, permission checks and allowlists stay in code. Schema validity is not correctness — a decision can be confidently wrong, and “cannot hallucinate” means only that the returned value is inside the declared schema. Nothing here is a security control. See [Typed decisions](/judge/). ## When to use it [Section titled “When to use it”](#when-to-use-it) Reach for a `Classifier` when a step in your agent produces a **judgment that steers something else** rather than an action that changes the world: is this shell command risky, which desk owns this ticket, does this turn need the billing skill, how urgent is this. Those questions do not need a model that can call tools and write prose — they need one number you can threshold, and the System One wire returns it in a single POST with no free text. Use the [client](/api/csharp/client/create/) instead whenever the step must *act*. ## Why this and not the alternative [Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative) Reach for a plain \`if\` when the rule is a rule A threshold on a price, a permission check, an allowlist — those are code, and a classifier is a strictly worse `if`: slower, non-deterministic, and it cannot be audited. A classifier earns its place only where the input is meaning that no regex can reach. And when you do use one, pin `Model` (e.g. `jev-1.13.0`) once a threshold is tuned: `Decision.Model` echoes what actually answered, which may be more specific than what you asked for, and [a threshold tuned against one backend does not transfer to another](/judge/backends/). ## The four backends [Section titled “The four backends”](#the-four-backends) Set by `ClassifierOptions.Style`; the full comparison lives on [Backends](/judge/backends/). | `ClassifierStyle` | What answers | Needs | | ----------------------- | ---------------------------------------------------------------------------------------------- | -------------------- | | `SystemOne` *(default)* | one `POST {BaseUrl}/systemone` with the canonical body | a key in `ApiKeyEnv` | | `Llm` | the three question types rendered as **one** JSON-schema structured-output call on a §8 client | `Client` | | `Custom` | your own function — a fine-tuned encoder, a rules engine, a cache | `Evaluate` | | `Static` | a recorded corpus, keyed by the canonical request **and** the state | `Decisions` | `Static` 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. Every example on this page uses it. ## Examples [Section titled “Examples”](#examples) ### 1. The smallest useful call — one question, one recorded decision, no network [Section titled “1. The smallest useful call — one question, one recorded decision, no network”](#1-the-smallest-useful-call--one-question-one-recorded-decision-no-network) ```csharp using Toolnexus; var ticket = "My card was charged twice for the annual plan and the second charge has not been refunded."; var questions = new Dictionary { ["wants_money_back"] = new NoulQuestion { Instructions = "Is the customer asking for money to be returned?" }, }; // `static` replays a recorded body, matched on the canonical request AND the state. var judge = Classifier.Create(new ClassifierOptions { Style = ClassifierStyle.Static, Model = "typesafe/jev-1.13", Decisions = new[] { new RecordedDecision { State = ticket, Questions = questions, Response = """ {"model":"typesafe/jev-1.13-20260917", "answers":{"wants_money_back":{"type":"noul","noul":0.99}}, "usage":{"input_tokens":112,"output_tokens":9}} """, }, }, }); var decision = await judge.EvaluateAsync(ticket, questions); // A noul carries NO confidence — the number IS the answer. var noul = decision.Noul("wants_money_back").Noul; if (noul < 0.9) throw new Exception($"noul: {noul}"); // The authority stays in code. The classifier only supplied the reading. var route = noul > 0.8 ? "billing" : "general"; Console.WriteLine($"ok: model={decision.Model} noul={noul} calibrated={decision.Calibrated} route={route}"); ``` ### 2. The realistic case — a `Custom` backend, so a test never touches a wire at all [Section titled “2. The realistic case — a Custom backend, so a test never touches a wire at all”](#2-the-realistic-case--a-custom-backend-so-a-test-never-touches-a-wire-at-all) ```csharp using Toolnexus; var questions = new Dictionary { ["department"] = new ChoiceQuestion { Instructions = "Which desk should own this ticket?", Criteria = new Dictionary { // Criteria[id] is the ONLY thing that differentiates one option from another to the // model. Passing the id itself is schema-valid — and ranks at chance. See /judge/encoding/. ["billing"] = "own it here when the problem is money that moved: a duplicate charge, a refund owed", ["technical"] = "own it here when the problem is the product itself: a login that fails, a feature that errors", }, }, }; var calls = 0; var judge = Classifier.Create(new ClassifierOptions { Style = ClassifierStyle.Custom, // Every wire option is ignored under `custom`. The host owns the whole evaluation. Evaluate = (state, qs, ct) => { calls++; var billing = state is string s && s.Contains("charged", StringComparison.OrdinalIgnoreCase); return Task.FromResult(new Decision { Model = "rules-engine-v1", // A backend that cannot guarantee independent questions reports Calibrated = false, // which carries BOTH caveats: the numbers and any threshold tuned on them. Calibrated = false, Answers = new Dictionary { ["department"] = new ChoiceAnswer { Choice = billing ? "billing" : "technical", Probabilities = billing ? new Dictionary { ["billing"] = 0.9, ["technical"] = 0.1 } : new Dictionary { ["billing"] = 0.1, ["technical"] = 0.9 }, Confidence = 0.9, NearUniform = false, }, }, }); }, }); var decision = await judge.EvaluateAsync("I was charged twice", questions); if (calls != 1) throw new Exception($"calls: {calls}"); if (decision.Choice("department").Choice != "billing") throw new Exception("routed wrong"); if (decision.Calibrated) throw new Exception("a rules engine is not calibrated"); Console.WriteLine($"ok: {decision.Choice("department").Choice} via {decision.Model} (calibrated={decision.Calibrated})"); ``` ### 3. Full surface — every option spelled out, and the degenerate-criteria warning observed [Section titled “3. Full surface — every option spelled out, and the degenerate-criteria warning observed”](#3-full-surface--every-option-spelled-out-and-the-degenerate-criteria-warning-observed) ```csharp using Toolnexus; // Degenerate criteria: every value equals its own key. The library DETECTS and REPORTS this, // once per question key, and sends the request BYTE-UNCHANGED. It never repairs it — repairing // would invent option descriptions you did not write. var questions = new Dictionary { ["lane"] = new ChoiceQuestion { Instructions = "Which lane?", Criteria = new Dictionary { ["left"] = "left", ["right"] = "right" }, }, }; var warnings = new List(); var errors = 0; var judge = Classifier.Create(new ClassifierOptions { Style = ClassifierStyle.Static, // default: SystemOne BaseUrl = "https://api.typesafe.ai/v1", // the default, spelled out so it is visible Model = "typesafe/jev-1.13", // default "jev-latest"; PIN it once a threshold is tuned ApiKeyEnv = "TYPESAFE_API_KEY", // the NAME of an env var, never the value Timeout = TimeSpan.FromSeconds(10), // bounds ONE request; a classifier has no loop to bound Retries = 2, RetryableStatuses = new[] { 418 }, // ADDS to the defaults; it can never remove one OnError = info => info.Retryable ? LlmClient.Tier.Retry : LlmClient.Tier.Fail, RequestParams = new Dictionary { ["x_tenant"] = "acme" }, BodyTransform = body => body, // base body -> RequestParams merge -> BodyTransform -> marshal OnMetric = ev => { // The warning travels in `Warning`, never in `Error`: a consumer filtering the §8 sink on // "has an error" must NOT count one. if (ev.Event == Classifier.MetricWarning) warnings.Add(ev.Question ?? ""); if (!string.IsNullOrEmpty(ev.Error)) errors++; }, Decisions = new[] { new RecordedDecision { State = "merging", Questions = questions, Response = """ {"model":"typesafe/jev-1.13-20260917", "answers":{"lane":{"type":"choice","choice":"left","probabilities":{"left":0.51,"right":0.49},"confidence":0.5}}, "usage":{"input_tokens":40,"output_tokens":6}} """, }, }, }); await judge.EvaluateAsync("merging", questions); await judge.EvaluateAsync("merging", questions); // detection is ONCE per question key per classifier if (warnings.Count != 1 || warnings[0] != "lane") throw new Exception(string.Join(",", warnings)); if (errors != 0) throw new Exception("a warning is not a failure"); // And the symptom the warning predicts: a flat distribution. var choice = judge.Style == ClassifierStyle.Static ? (await judge.EvaluateAsync("merging", questions)).Choice("lane") : null!; Console.WriteLine($"ok: warned once on \"{warnings[0]}\", errors={errors}, nearUniform={choice.NearUniform}"); ``` ## `ClassifierOptions` [Section titled “ClassifierOptions”](#classifieroptions) Mirrors [`LlmClient.Options`](/api/csharp/client/create/) field-for-field wherever a field makes sense, so a host that has configured one has configured the other. | Option | Type | Default | What it does | | ------------------- | ------------------------------------------------------------------------------------------ | ---------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `Style` | `ClassifierStyle` | `SystemOne` | Which backend answers: `SystemOne`, `Llm`, `Custom`, `Static`. | | `BaseUrl` | `string?` | `https://api.typesafe.ai/v1` | The System One endpoint’s base. OpenRouter serves the same wire at `https://openrouter.ai/api/v1`; self-hosted and open-weights implementations speak it too. | | `Model` | `string?` | `"jev-latest"` | The model asked for. Pin it once thresholds are tuned; `Decision.Model` echoes what actually answered. | | `ApiKeyEnv` | `string?` | `"TYPESAFE_API_KEY"` | The **name** of an env var, read at call time and never logged. Deliberately not a value, unlike §8’s `ApiKey`. | | `Headers` | `IDictionary?` | — | Extra headers; values expand `${ENV_VAR}` from the environment at call time and are never logged. | | `Timeout` | `TimeSpan?` | 10 s | Bounds **one request**, not a run — a classifier has no loop to bound. | | `HttpClient` | `HttpClient?` | — | (§8 Gap 2) Overrides the transport. Scope is the classifier path only. | | `Retries` | `int?` | `2` | Attempts after the first, on a retryable status or a network fault. | | `RetryBaseMs` | `int?` | `500` | Base of the retry backoff, in ms: the delay is `base * 2^attempt`, no jitter, and a `Retry-After` header still wins. `null` or `<= 0` falls back to the default. The classifier’s half of the §8 `Options.RetryBaseMs` it mirrors. | | `RetryableStatuses` | `IReadOnlyCollection?` | — | **Adds** to the default set; it can never remove one, so a host cannot drop `429` and lose `Retry-After` handling with it. | | `OnError` | `Func?` | — | Reuses the §8 error classifier verbatim, and decides each attempt. There is no `Suspend` tier here. | | `RequestParams` | `IReadOnlyDictionary?` | — | Merged into the body the classifier builds; a `RequestParams` key **wins** on collision. | | `BodyTransform` | `Func, IDictionary?>?` | — | Last stop before marshalling: base body → `RequestParams` merge → `BodyTransform` → wire. | | `OnMetric` | `Action?` | — | Emits `classifier.evaluate` (latency, tokens, model, status; `Error` set only on a failed evaluate) and `classifier.warning` into the **same** §8 sink. | | `Client` | `LlmClient?` | — | `Style = Llm` only — the §8 client to emulate over. | | `Evaluate` | `Func, CancellationToken, Task>?` | — | `Style = Custom` only — your own function. Every wire option is ignored. | **Port-local extras** (not in the cross-language options manifest, C# only): | Option | Type | What it does | | ------------- | --------------------- | ---------------------------------------------------------------------------------------- | | `HttpHandler` | `HttpMessageHandler?` | A handler to build the classifier’s `HttpClient` from. Ignored when `HttpClient` is set. | `Decisions` (`IReadOnlyList?`) — the recorded corpus for `Style = Static` — is **not** port-local: it is named in §8B and gated at **core** tier by the cross-language options manifest, since it is the backend CI runs on. Each port spells it idiomatically. Every option also has a fluent `With…` setter (`new ClassifierOptions().WithStyle(…).WithModel(…)`). ## Retries [Section titled “Retries”](#retries) The default retryable set is `{408, 429, 500, 502, 503, 504, 529}` plus network faults — note the **`408`**, which is the classifier’s one addition over the §8 client’s set, because a classifier is a single bounded request where a request timeout is worth one more attempt. `Retry-After` is honoured with the same `delay-seconds` rule as §8. There is no second retry policy in the library. ## Secrets [Section titled “Secrets”](#secrets) The credential resolves at call time from the **named** environment variable, 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: an authentication failure names the status and the endpoint and nothing else. Constructing no `Classifier` leaves behaviour byte-identical to a build without §8B, and constructing one alters no request the client loop makes. ## See also [Section titled “See also”](#see-also) * [`NoulQuestion`](/api/csharp/judge/questions/) — The three question types, the criteria each one needs, and the limits enforced client-side before the request. * [`Decision`](/api/csharp/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. * [Encoding a choice](/judge/encoding/) — The measured cost of undescribed options. * [Backends](/judge/backends/) — What each of the four actually costs. * [Cookbook: a judge in the loop](/cookbook/judge/) — The end-to-end recipe. # Decision > One answer per question under the caller's own keys, read through typed accessors that fail loudly rather than hand back a zero. — C#. **C#** · package `Toolnexus` · [SPEC §8B](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [Classifier.cs](https://github.com/muthuishere/toolnexus/blob/main/csharp/src/Toolnexus/Classifier.cs) ```csharp public sealed record Decision { public string Model { get; init; } // what ACTUALLY answered public IReadOnlyDictionary Answers { get; init; } // keyed by YOUR keys public ClassifierUsage Usage { get; init; } public bool Calibrated { get; init; } public NoulAnswer Noul(string key); // throws ClassifierException on a wrong type or a missing key public ChoiceAnswer Choice(string key); public ScoreAnswer Score(string key); } public abstract record DecisionAnswer { public abstract string AnswerType { get; } } public sealed record NoulAnswer : DecisionAnswer { public double Noul { get; init; } // 0..1, and NO confidence } public sealed record ChoiceAnswer : DecisionAnswer { public string Choice { get; init; } public IReadOnlyDictionary Probabilities { get; init; } // one entry per OFFERED option public double Confidence { get; init; } public bool NearUniform { get; init; } // DERIVED, never read from the wire } public sealed record ScoreAnswer : DecisionAnswer { public double Score { get; init; } // MAY fall between levels public IReadOnlyDictionary Legend { get; init; } // the rubric, echoed back public IReadOnlyDictionary Probabilities { get; init; } // per level index public double Confidence { get; init; } public IReadOnlyList Levels(); // the legend in level order } public sealed record ClassifierUsage { public long InputTokens { get; init; } public long OutputTokens { get; init; } public double? Cost { get; init; } // null ⇒ NOT REPORTED by this backend, which is not zero } public static bool Classifier.NearUniform(IReadOnlyDictionary probabilities); ``` One answer per question under the caller’s own keys, read through typed accessors that fail loudly rather than hand back a zero. ## When to use it [Section titled “When to use it”](#when-to-use-it) Always read a decision through `Noul` / `Choice` / `Score`. They resolve the key **and** the answer’s type in one step and throw a `ClassifierException` on either mistake, so a question renamed on one side of your code can never silently become a `0.0` that quietly routes every ticket to the same desk. `Answers` stays public for the cases the accessors do not cover — iterating every answer, or switching on `DecisionAnswer` with a pattern match when the shape is genuinely dynamic. ## Why this and not the alternative [Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative) Do not cast out of \`Answers\` by hand `decision.Answers["urgency"] as ScoreAnswer` hands back `null` on a mismatch, and `null` becomes a `NullReferenceException` three frames away from the mistake — or worse, a `?? 0` that routes on a fabricated zero. `decision.Score("urgency")` throws immediately, naming the key and the type it actually found. ## Examples [Section titled “Examples”](#examples) ### 1. The smallest useful call — the typed accessors [Section titled “1. The smallest useful call — the typed accessors”](#1-the-smallest-useful-call--the-typed-accessors) ```csharp using Toolnexus; var questions = new Dictionary { ["urgency"] = new ScoreQuestion { Instructions = "How fast does this ticket need a human?", Criteria = new[] { "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", }, }, }; var judge = Classifier.Create(new ClassifierOptions { Style = ClassifierStyle.Static, Model = "typesafe/jev-1.13", Decisions = new[] { new RecordedDecision { State = "the checkout page 500s for every customer", Questions = questions, Response = """ {"model":"typesafe/jev-1.13-20260917", "answers":{"urgency":{"type":"score","score":1.87, "legend":{"0":"the customer is working normally and is waiting on an answer","1":"the customer is inconvenienced and will chase if nobody replies today","2":"the customer is blocked from working right now and every hour costs them"}, "probabilities":{"0":0.02,"1":0.09,"2":0.89},"confidence":0.84}}, "usage":{"input_tokens":121,"output_tokens":18,"cost":0.0000118}} """, }, }, }); var d = await judge.EvaluateAsync("the checkout page 500s for every customer", questions); var urgency = d.Score("urgency"); // A score MAY fall between levels — 1.87 is a real answer, not a rounding artefact. var level = (int)Math.Round(urgency.Score, MidpointRounding.AwayFromZero); // Cost is a gateway field: null means NOT REPORTED, which is not the same as free. var cost = d.Usage.Cost is { } c ? $"${c}" : "not reported"; Console.WriteLine($"ok: model={d.Model} score={urgency.Score} level={level} " + $"\"{urgency.Levels()[level]}\" confidence={urgency.Confidence} " + $"tokens={d.Usage.InputTokens}/{d.Usage.OutputTokens} cost={cost}"); ``` ### 2. The realistic case — how a typed read FAILS [Section titled “2. The realistic case — how a typed read FAILS”](#2-the-realistic-case--how-a-typed-read-fails) ```csharp using Toolnexus; var questions = new Dictionary { ["risky"] = new NoulQuestion { Instructions = "Is this command risky?" }, }; var judge = Classifier.Create(new ClassifierOptions { Style = ClassifierStyle.Static, Model = "typesafe/jev-1.13", Decisions = new[] { new RecordedDecision { State = "rm -rf /var/log", Questions = questions, Response = """ {"model":"typesafe/jev-1.13-20260917", "answers":{"risky":{"type":"noul","noul":0.94}}, "usage":{"input_tokens":33,"output_tokens":5}} """, }, }, }); var d = await judge.EvaluateAsync("rm -rf /var/log", questions); // A noul carries NO confidence: the number IS the answer, and there is no `Confidence` to read. if (d.Noul("risky").Noul < 0.9) throw new Exception("expected risky"); string Caught(Func read) { try { read(); } catch (ClassifierException e) { return e.Message; } throw new Exception("expected a ClassifierException"); } var wrongType = Caught(() => d.Choice("risky")); // it is a noul, not a choice var missing = Caught(() => d.Noul("urgency")); // never asked if (!wrongType.Contains("not choice")) throw new Exception(wrongType); if (!missing.Contains("no answer")) throw new Exception(missing); // The authority is the `if`, not the probability. The classifier only read the intent. var allowed = !"rm -rf /var/log".StartsWith("rm ", StringComparison.Ordinal); Console.WriteLine($"ok: {wrongType} | {missing} | allowed-by-code={allowed}"); ``` ### 3. Full surface — `NearUniform` and `Calibrated`, and what neither of them detects [Section titled “3. Full surface — NearUniform and Calibrated, and what neither of them detects”](#3-full-surface--nearuniform-and-calibrated-and-what-neither-of-them-detects) ```csharp using Toolnexus; // nearUniform ⇔ max over i of |p_i − 1/n| ≤ 0.05. // · n is the number of ENTRIES IN THE MAP; an offered option absent from the map counts as 0 by // not being an entry. // · the values are taken AS RETURNED — never renormalised, sorted or rounded. // · the tolerance is ABSOLUTE and the comparison is INCLUSIVE (a deviation of exactly 0.05 IS // near-uniform). It is computed in double precision, which is why the shared fixtures never // place a deviation within 1e-9 of the tolerance — and why you should not either. // · n == 1 is trivially uniform; an EMPTY map has no distribution at all and is false. var flat = new Dictionary { ["left"] = 0.5, ["right"] = 0.5 }; // n = 4, so 1/n = 0.25 and the largest deviation here is 0.05 — AT the tolerance, and inside it. var boundary = new Dictionary { ["n"] = 0.30, ["e"] = 0.20, ["s"] = 0.25, ["w"] = 0.25 }; var outside = new Dictionary { ["left"] = 0.56, ["right"] = 0.44 }; // deviation 0.06 var decided = new Dictionary { ["left"] = 0.8, ["right"] = 0.2 }; var single = new Dictionary { ["only"] = 1.0 }; var none = new Dictionary(); if (!Classifier.NearUniform(flat)) throw new Exception("flat"); if (!Classifier.NearUniform(boundary)) throw new Exception("the comparison is inclusive"); if (Classifier.NearUniform(outside)) throw new Exception("0.06 is past the tolerance"); if (Classifier.NearUniform(decided)) throw new Exception("decided"); if (!Classifier.NearUniform(single)) throw new Exception("n == 1 is trivially uniform"); if (Classifier.NearUniform(none)) throw new Exception("an empty map is not a distribution"); // On an answer it is DERIVED from the response, never read from the wire: no wire change, no // request change, no fixture change. var questions = new Dictionary { ["lane"] = new ChoiceQuestion { Instructions = "Which lane?", Criteria = new Dictionary { ["left"] = "left", ["right"] = "right" }, }, }; var judge = Classifier.Create(new ClassifierOptions { Style = ClassifierStyle.Static, Model = "typesafe/jev-1.13", Decisions = new[] { new RecordedDecision { State = "merging", Questions = questions, Response = """ {"model":"typesafe/jev-1.13-20260917", "answers":{"lane":{"type":"choice","choice":"left","probabilities":{"left":0.51,"right":0.49},"confidence":0.5}}, "usage":{"input_tokens":40,"output_tokens":6},"calibrated":true} """, }, }, }); var lane = (await judge.EvaluateAsync("merging", questions)).Choice("lane"); if (!lane.NearUniform) throw new Exception("0.51/0.49 is flat"); Console.WriteLine($"ok: choice={lane.Choice} nearUniform={lane.NearUniform} confidence={lane.Confidence}"); ``` ## `Calibrated` [Section titled “Calibrated”](#calibrated) Every decision reports it. The `SystemOne` backend reports **true**. The `Llm` backend reports **false** unless it derived its probabilities from provider token probabilities. A backend that cannot guarantee the questions were answered independently also reports false. **A response that omits `calibrated`, or sends `null`, decodes as `true`** — only the literal `false` is false. The systemone wire reports calibration by being itself, and a backend that is not calibrated says so explicitly, so a missing field is not a missing guarantee. Neither flag is a correctness signal `NearUniform` 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 distinguish 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 the numbers came from a calibrated backend, not that they are right. Both are **advisory**. And **a threshold tuned against one backend does not transfer to another**. Re-tune when you change `Style`, `BaseUrl` or `Model` — which is why `Decision.Model` echoes what actually answered rather than what you asked for. ## See also [Section titled “See also”](#see-also) * [`Classifier.Create`](/api/csharp/judge/classifier/) — A sibling of the client: pre-declared typed questions in, calibrated answers out — no messages, no tool calling, no loop. * [`NoulQuestion`](/api/csharp/judge/questions/) — The three question types, the criteria each one needs, and the limits enforced client-side before the request. * [Typed decisions](/judge/) — Why a judgment is a different contract from an action. * [Encoding a choice](/judge/encoding/) — The measurement `NearUniform` was calibrated against. * [Backends](/judge/backends/) — Where `Calibrated` comes from, per backend. * [Cookbook: a judge in the loop](/cookbook/judge/) — The end-to-end recipe. # NoulQuestion > The three question types, the criteria each one needs, and the limits enforced client-side before the request. — C#. **C#** · package `Toolnexus` · [SPEC §8B](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [Classifier.cs](https://github.com/muthuishere/toolnexus/blob/main/csharp/src/Toolnexus/Classifier.cs) ```csharp public abstract record Question { internal Question(); // the set is CLOSED — these three only public required string Instructions { get; init; } } public sealed record NoulCriteria { public string True { get; init; } = ""; public string False { get; init; } = ""; } /// the probability that a statement holds, one number in 0..1 public sealed record NoulQuestion : Question { public NoulCriteria? Criteria { get; init; } // null ⇒ the field is ABSENT from the request } /// one option from a named set, 1..255 options public sealed record ChoiceQuestion : Question { public IReadOnlyDictionary Criteria { get; init; } // option id ⇒ what picking it would MEAN } /// a rating against an ORDERED rubric of 2..10 levels — the list order IS the numbering public sealed record ScoreQuestion : Question { public IReadOnlyList Criteria { get; init; } } ``` The three question types, the criteria each one needs, and the limits enforced client-side before the request. The set is closed: `Question`’s constructor is `internal`, so `NoulQuestion`, `ChoiceQuestion` and `ScoreQuestion` are the only shapes that exist. They differ only in what `criteria` is on the wire — absent, an object, or an ordered array. C# records with \`required init\` properties — there is no positional form Every question is a record with **`required` init-only properties and no positional parameter list**, so `new NoulQuestion("Is it risky?")` does **not** compile (CS1729). Always use an object initialiser: `new NoulQuestion { Instructions = "Is it risky?" }`. The compiler enforces `Instructions`, which is why a question can never reach the wire without one. ## When to use it [Section titled “When to use it”](#when-to-use-it) Pick the type by the **shape of the answer you need to threshold**, not by the shape of the question: | You need | Type | `Criteria` | The answer | | -------------------------------- | ---------------- | ------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------- | | a probability you will threshold | `NoulQuestion` | `NoulCriteria?` — optional labels for the true and false case | `NoulAnswer.Noul`, `0..1`, **no confidence** | | one branch out of a named set | `ChoiceQuestion` | `IReadOnlyDictionary`, 1–255 entries — **required, and it is the whole encoding** | `ChoiceAnswer` — the pick, a probability for *every* offered option, a confidence | | a rating on an ordered scale | `ScoreQuestion` | `IReadOnlyList`, 2–10 levels, **order is the numbering** | `ScoreAnswer.Score`, which MAY fall between levels (`1.21` is a real answer) | A `noul` reports **no** confidence because the number *is* the answer. A `choice` and a `score` both report one — but confidence reports on the **question**, not on the answer. ## Why this and not the alternative [Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative) Do not fake a choice with several nouls Three nouls are three unrelated probabilities that do not sum to anything; a `choice` returns one distribution over the options you offered, which is what you actually want to rank or threshold. Equally, do not fake a score with a choice over `"low" | "medium" | "high"` — a `score` rubric is **ordered**, so “between level 1 and 2” is expressible, and the order survives canonicalisation untouched (arrays are never reordered, so a “sort everything” canonicaliser cannot silently renumber your rubric). ## The encoding obligation on a `choice` — yours, and it is not advice [Section titled “The encoding obligation on a choice — yours, and it is not advice”](#the-encoding-obligation-on-a-choice--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 and 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**. The measurement behind that claim, and the rewrite that fixes it, are on [Encoding a choice](/judge/encoding/). The library **detects and reports** degenerate criteria — every value empty, every value equal to its own key, or every value identical — as one `classifier.warning` per question key, naming the key, with the request sent byte-unchanged. It never repairs them: repairing would invent option descriptions you did not write. ## Examples [Section titled “Examples”](#examples) ### 1. The smallest useful call — all three types in one round trip [Section titled “1. The smallest useful call — all three types in one round trip”](#1-the-smallest-useful-call--all-three-types-in-one-round-trip) ```csharp using Toolnexus; var ticket = "Ticket 4021: my card was charged twice for the annual plan on Tuesday, and the second " + "charge has not been refunded. I am not blocked from working."; // The keys are ADDRESSING, not content: they are never transmitted, so two evaluations differing // only in their keys send identical bytes. A key may be a tool or skill name verbatim. var questions = new Dictionary { ["wants_money_back"] = new NoulQuestion { Instructions = "Is the customer asking for money to be returned?", }, ["department"] = new ChoiceQuestion { Instructions = "Which desk should own this ticket?", Criteria = new Dictionary { ["billing"] = "own it here when the problem is money that moved: a duplicate charge, a refund owed", ["shipping"] = "own it here when the problem is a physical parcel: a late delivery, damage in transit", ["technical"] = "own it here when the problem is the product itself: a login that fails, a feature that errors", }, }, ["urgency"] = new ScoreQuestion { Instructions = "How fast does this ticket need a human?", // ORDER IS THE NUMBERING: index 0 is the bottom of the scale. Criteria = new[] { "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", }, }, }; var judge = Classifier.Create(new ClassifierOptions { Style = ClassifierStyle.Static, Model = "typesafe/jev-1.13", Decisions = new[] { new RecordedDecision { State = ticket, Questions = questions, Response = """ {"model":"typesafe/jev-1.13-20260917", "answers":{ "wants_money_back":{"type":"noul","noul":0.99}, "department":{"type":"choice","choice":"billing","probabilities":{"billing":1,"shipping":0,"technical":0},"confidence":1}, "urgency":{"type":"score","score":0.49, "legend":{"0":"the customer is working normally and is waiting on an answer","1":"the customer is inconvenienced and will chase if nobody replies today","2":"the customer is blocked from working right now and every hour costs them"}, "probabilities":{"0":0.52,"1":0.48,"2":0},"confidence":0.27}}, "usage":{"input_tokens":516,"output_tokens":72}} """, }, }, }); var d = await judge.EvaluateAsync(ticket, questions); var urgency = d.Score("urgency"); if (urgency.Levels().Count != 3) throw new Exception("rubric lost its order"); Console.WriteLine($"ok: noul={d.Noul("wants_money_back").Noul} " + $"choice={d.Choice("department").Choice} " + $"score={urgency.Score} of 0..{urgency.Legend.Count - 1}"); ``` ### 2. The limits are enforced client-side — the error names the key, and no request is sent [Section titled “2. The limits are enforced client-side — the error names the key, and no request is sent”](#2-the-limits-are-enforced-client-side--the-error-names-the-key-and-no-request-is-sent) ```csharp using Toolnexus; // A `static` classifier with NO recorded decisions: if a request were ever built, this would fail // with "no recorded decision". It does not — validation happens first, before any backend runs. var judge = Classifier.Create(new ClassifierOptions { Style = ClassifierStyle.Static, Decisions = Array.Empty(), }); async Task Rejected(Dictionary qs) { try { await judge.EvaluateAsync("anything", qs); } catch (ClassifierException e) { return e.Message; } throw new Exception("expected a ClassifierException"); } // A choice needs 1..255 options. var noOptions = await Rejected(new Dictionary { ["desk"] = new ChoiceQuestion { Instructions = "which desk?", Criteria = new Dictionary() }, }); // A score needs 2..10 ordered levels — one level is not a scale, eleven is not a rubric. var oneLevel = await Rejected(new Dictionary { ["urgency"] = new ScoreQuestion { Instructions = "how urgent?", Criteria = new[] { "only level" } }, }); // The error names the OFFENDING QUESTION KEY and the limit, so a caller finds out faster and more // legibly than from the backend's own 400. if (!noOptions.Contains("\"desk\"") || !noOptions.Contains("255")) throw new Exception(noOptions); if (!oneLevel.Contains("\"urgency\"") || !oneLevel.Contains("2..10")) throw new Exception(oneLevel); Console.WriteLine($"ok: {noOptions}\n {oneLevel}"); ``` ### 3. Full surface — `Criteria` absent is not `Criteria` empty [Section titled “3. Full surface — Criteria absent is not Criteria empty”](#3-full-surface--criteria-absent-is-not-criteria-empty) ```csharp using Toolnexus; // For a noul, ABSENT and EMPTY are different values and BOTH are preserved on the wire. var absent = new Dictionary { ["risky"] = new NoulQuestion { Instructions = "Is this command risky?" }, // no criteria at all }; var empty = new Dictionary { ["risky"] = new NoulQuestion { Instructions = "Is this command risky?", Criteria = new NoulCriteria() }, }; var labelled = new Dictionary { ["risky"] = new NoulQuestion { Instructions = "Is this command risky?", Criteria = new NoulCriteria { True = "it deletes, overwrites or transmits data that cannot be recovered", False = "it only reads, lists or prints", }, }, }; // The canonical request is what every port emits byte-identically for the same questions + model: // keys sorted recursively in ASCII order, arrays never reordered, compact separators, <>&'" and // non-ASCII transmitted RAW. `state` is explicitly outside that claim — it is sent verbatim. var a = Classifier.CanonicalRequestString("jev-latest", absent); var e = Classifier.CanonicalRequestString("jev-latest", empty); var l = Classifier.CanonicalRequestString("jev-latest", labelled); if (a.Contains("criteria")) throw new Exception("absent must not emit the field"); if (!e.Contains("""{"false":"","true":""}""")) throw new Exception(e); if (a == e) throw new Exception("absent and empty must differ on the wire"); if (!l.Contains("cannot be recovered")) throw new Exception(l); Console.WriteLine($"ok: absent={a}\n empty={e}"); ``` ## See also [Section titled “See also”](#see-also) * [`Classifier.Create`](/api/csharp/judge/classifier/) — A sibling of the client: pre-declared typed questions in, calibrated answers out — no messages, no tool calling, no loop. * [`Decision`](/api/csharp/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. * [Encoding a choice](/judge/encoding/) — Why `Criteria[id]` is the whole ball game, with the numbers. * [Backends](/judge/backends/) — Which backend answers, and what each one costs. * [Cookbook: a judge in the loop](/cookbook/judge/) — The end-to-end recipe. # McpSource.ElicitationToRequest > Map an MCP server's elicitation request onto the §10 suspension contract, and map the answer back. — C#. **C#** · package `Toolnexus` · [SPEC §2](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [McpSource.cs](https://github.com/muthuishere/toolnexus/blob/main/csharp/src/Toolnexus/McpSource.cs) ```csharp public static Request ElicitationToRequest(ElicitRequestParams p) public static ElicitResult AnswerToElicitResult(Answer answer) ``` The bridge between MCP’s `elicitation/create` (a server asking **you** for input mid-`tools/call`) and toolnexus’s own §10 suspension contract (`Request` / `Answer`, in [`Suspension.cs`](https://github.com/muthuishere/toolnexus/blob/main/csharp/src/Toolnexus/Suspension.cs)) that a host resolves through `waitFor`. Two pure, byte-parity-tested mappings, always used as a pair: * `ElicitationToRequest` — an MCP `ElicitRequestParams` becomes a `Request`. Form mode (a JSON-Schema prompt) maps to `Kind == "input"` with the schema carried in `Data["schema"]`. URL mode maps to `Kind == "authorization"` with the `Url` set and no `Data`. * `AnswerToElicitResult` — the resolved `Answer` maps back to MCP’s `ElicitResult`: `Ok == true` → `"accept"` (with `Data` as the result content); `Ok == false, Reason == "declined"` → `"decline"`; anything else (cancelled, expired, no reason) → `"cancel"`. Both are used internally by [`McpSource.LoadAsync`](/api/csharp/mcp/load/)’s `waitFor` wiring — you call them directly only when building your own elicitation handler or testing the mapping itself. ## When to use it [Section titled “When to use it”](#when-to-use-it) You are wiring a **custom** MCP client options / elicitation handler and want the exact same `Request`/`Answer` shape the built-in `waitFor` bridge produces — so a UI or approval flow written against §10 handles MCP elicitation identically to every other suspension source (the `question` builtin, HTTP auth, anything else that suspends). ## Why this and not the alternative [Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative) Pass waitFor to LoadAsync for the ordinary path If you just want elicitation to work, pass a `waitFor` resolver to [`McpSource.LoadAsync`](/api/csharp/mcp/load/) — it calls both of these mappings for you. Reach for `ElicitationToRequest` / `AnswerToElicitResult` directly only when you’re building the client options yourself, or unit-testing the mapping in isolation. ## Examples [Section titled “Examples”](#examples) ### 1. Form mode maps to `kind:"input"` with the schema [Section titled “1. Form mode maps to kind:"input" with the schema”](#1-form-mode-maps-to-kindinput-with-the-schema) ```csharp using ModelContextProtocol.Protocol; using Toolnexus; var schema = new ElicitRequestParams.RequestSchema { Properties = new Dictionary { ["name"] = new ElicitRequestParams.StringSchema(), }, Required = new List { "name" }, }; var req = McpSource.ElicitationToRequest(new ElicitRequestParams { Message = "Your name?", RequestedSchema = schema }); if (req.Kind != "input") throw new Exception(req.Kind); if (req.Prompt != "Your name?") throw new Exception(req.Prompt); if (req.Url != null) throw new Exception("form mode carries no url"); if (!ReferenceEquals(req.Data!["schema"], schema)) throw new Exception("schema not carried through"); Console.WriteLine($"ok: {req.Kind} — {req.Prompt}"); ``` ### 2. URL mode maps to `kind:"authorization"` with the URL [Section titled “2. URL mode maps to kind:"authorization" with the URL”](#2-url-mode-maps-to-kindauthorization-with-the-url) ```csharp using ModelContextProtocol.Protocol; using Toolnexus; var req = McpSource.ElicitationToRequest(new ElicitRequestParams { Mode = "url", Message = "Log in to continue", Url = "https://example.com/auth", }); if (req.Kind != "authorization") throw new Exception(req.Kind); if (req.Url != "https://example.com/auth") throw new Exception(req.Url); if (req.Data != null) throw new Exception("url mode carries no schema"); Console.WriteLine($"ok: {req.Kind} -> {req.Url}"); ``` ### 3. Answer round-trips to accept / decline / cancel [Section titled “3. Answer round-trips to accept / decline / cancel”](#3-answer-round-trips-to-accept--decline--cancel) ```csharp using Toolnexus; var accept = McpSource.AnswerToElicitResult(new Answer { Id = "1", Ok = true, Data = new Dictionary { ["name"] = "Ada" }, }); if (accept.Action != "accept") throw new Exception(accept.Action); if (accept.Content!["name"].GetString() != "Ada") throw new Exception("content not carried through"); var declined = McpSource.AnswerToElicitResult(new Answer { Id = "1", Ok = false, Reason = "declined" }); if (declined.Action != "decline") throw new Exception(declined.Action); // Anything that isn't an explicit "declined" reason — including no reason at all — is a cancel. var expired = McpSource.AnswerToElicitResult(new Answer { Id = "1", Ok = false, Reason = "expired" }); var noReason = McpSource.AnswerToElicitResult(new Answer { Id = "1", Ok = false }); if (expired.Action != "cancel" || noReason.Action != "cancel") throw new Exception($"{expired.Action},{noReason.Action}"); Console.WriteLine($"ok: {accept.Action} / {declined.Action} / {expired.Action}"); ``` ## Mapping [Section titled “Mapping”](#mapping) | MCP `ElicitRequestParams` | `Request` | | | ------------------------- | ------------------------ | ---------------------------------- | | `Mode` absent / `"form"` | `Kind = "input"` | `Data["schema"] = RequestedSchema` | | `Mode = "url"` | `Kind = "authorization"` | `Url = Url`, no `Data` | | `Message` | `Prompt` | | | `Answer` | MCP `ElicitResult.Action` | | --------------------------------- | ----------------------------------------- | | `Ok = true` | `"accept"` (`Content` from `Answer.Data`) | | `Ok = false, Reason = "declined"` | `"decline"` | | `Ok = false`, any other/no reason | `"cancel"` | ## See also [Section titled “See also”](#see-also) * [`McpSource.LoadAsync`](/api/csharp/mcp/load/) — Read an mcp.json, connect every local stdio and remote streamable-HTTP server, expose each server tool as a Tool. * [`McpSource.LoadAsync`](/api/csharp/mcp/load-with-context/) — The ctx-aware load: bound connection time and cancel a slow or hung server without leaking a child process. * [`McpSource.ListMcpToolsAsync`](/api/csharp/mcp/list-tools/) — List what each configured server would expose, plus per-server status, without wiring it into a toolkit. * [`McpSource.ParseConfig`](/api/csharp/mcp/parse-config/) — Parse and validate config without connecting — the fast fail for a malformed or misspelled server block. # McpSource.ListMcpToolsAsync > List what each configured server would expose, plus per-server status, without wiring it into a toolkit. — C#. **C#** · package `Toolnexus` · [SPEC §2](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [McpSource.cs](https://github.com/muthuishere/toolnexus/blob/main/csharp/src/Toolnexus/McpSource.cs) ```csharp public static Task ListMcpToolsAsync(object input, CancellationToken cancellationToken = default) ``` Connects to every enabled server, lists its tool definitions, and **disconnects before returning** — no toolkit, no `ExecuteAsync` wiring, nothing left running. The result is a plain `McpInventory { Tools, Status }`: * `Tools` — `server → IReadOnlyList`, each `ToolInfo(Name, Description, InputSchema)` using the **original, unprefixed** tool name (not the `server_tool` name a connected [`McpSource`](/api/csharp/mcp/load/) would expose). * `Status` — `server → "connected" | "disabled" | "failed"`, same vocabulary as `LoadAsync`. The returned inventory is **unfiltered** by any per-server `tools` allowlist — it exists to author or validate that allowlist, so it needs to see everything a server offers. ## When to use it [Section titled “When to use it”](#when-to-use-it) Building a config UI or a CLI that shows “here’s what `mcp.json` would give you” without paying for a live toolkit. Also the right call for authoring a per-server `tools` allowlist: list first, see the real names, then write the allowlist against them. ## Why this and not the alternative [Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative) Use LoadAsync when you actually want to call the tools `ListMcpToolsAsync` never leaves a connection open — every client it opens is disposed before the method returns. Reach for [`McpSource.LoadAsync`](/api/csharp/mcp/load/) when you intend to execute tools, not just inventory them. ## Examples [Section titled “Examples”](#examples) ### 1. A disabled server never connects, and reports no tools [Section titled “1. A disabled server never connects, and reports no tools”](#1-a-disabled-server-never-connects-and-reports-no-tools) ```csharp using Toolnexus; var config = new Dictionary { ["mcpServers"] = new Dictionary { ["docs_demo"] = new Dictionary { ["type"] = "local", ["command"] = new[] { "echo", "hi" }, ["enabled"] = false, }, }, }; var inventory = await McpSource.ListMcpToolsAsync(config); if (inventory.Status["docs_demo"] != "disabled") throw new Exception(inventory.Status["docs_demo"]); if (inventory.Tools.ContainsKey("docs_demo")) throw new Exception("a disabled server has no tool list"); Console.WriteLine($"ok: {inventory.Status["docs_demo"]}"); ``` ### 2. A server that can’t spawn is “failed”, not fatal [Section titled “2. A server that can’t spawn is “failed”, not fatal”](#2-a-server-that-cant-spawn-is-failed-not-fatal) ```csharp using Toolnexus; var config = new Dictionary { ["mcpServers"] = new Dictionary { ["broken"] = new Dictionary { ["type"] = "local", ["command"] = new[] { "toolnexus-docs-nonexistent-binary-xyz" }, ["timeout"] = 2_000, }, }, }; var inventory = await McpSource.ListMcpToolsAsync(config); if (inventory.Status["broken"] != "failed") throw new Exception(inventory.Status["broken"]); if (inventory.Tools.ContainsKey("broken")) throw new Exception("a failed server has no tool list"); Console.WriteLine($"ok: {inventory.Status["broken"]}"); ``` ### 3. Inventory the shared fixture, gated for a hermetic run [Section titled “3. Inventory the shared fixture, gated for a hermetic run”](#3-inventory-the-shared-fixture-gated-for-a-hermetic-run) ```csharp using Toolnexus; var repo = Environment.GetEnvironmentVariable("TOOLNEXUS_REPO") ?? "."; var fixture = Path.Combine(repo, "examples", "mcp.json"); // Parse the real fixture, then gate every server off so this doc example never dials out — // the shape (parse -> gate -> inventory) is what an "author the allowlist" tool would run. var config = McpSource.ParseConfig(fixture); foreach (var (_, value) in config) ((IDictionary)value!)["enabled"] = false; var inventory = await McpSource.ListMcpToolsAsync(config); if (inventory.Status.Values.Any(s => s != "disabled")) throw new Exception($"unexpected: {string.Join(",", inventory.Status.Values)}"); if (inventory.Tools.Count != 0) throw new Exception("no server connected, so no tool lists"); Console.WriteLine($"ok: {string.Join(", ", inventory.Status.Keys.OrderBy(k => k))} all disabled"); ``` ## Fields [Section titled “Fields”](#fields) | Field | Type | What it is | | ---------------------- | ------------------------------------------------------ | ----------------------------------------------------------- | | `McpInventory.Tools` | `IReadOnlyDictionary>` | Per-server tool defs, unfiltered by any allowlist. | | `McpInventory.Status` | `IReadOnlyDictionary` | `server → "connected" \| "disabled" \| "failed"`. | | `ToolInfo.Name` | `string` | The original, unprefixed MCP tool name. | | `ToolInfo.Description` | `string` | | | `ToolInfo.InputSchema` | `IDictionary` | The built JSON-Schema object (same shape `LoadAsync` uses). | ## See also [Section titled “See also”](#see-also) * [`McpSource.LoadAsync`](/api/csharp/mcp/load/) — Read an mcp.json, connect every local stdio and remote streamable-HTTP server, expose each server tool as a Tool. * [`McpSource.LoadAsync`](/api/csharp/mcp/load-with-context/) — The ctx-aware load: bound connection time and cancel a slow or hung server without leaking a child process. * [`McpSource.ParseConfig`](/api/csharp/mcp/parse-config/) — Parse and validate config without connecting — the fast fail for a malformed or misspelled server block. * [`McpSource.ElicitationToRequest`](/api/csharp/mcp/elicitation/) — Map an MCP server’s elicitation request onto the §10 suspension contract, and map the answer back. # McpSource.LoadAsync > Read an mcp.json, connect every local stdio and remote streamable-HTTP server, expose each server tool as a Tool. — C#. **C#** · package `Toolnexus` · [SPEC §2](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [McpSource.cs](https://github.com/muthuishere/toolnexus/blob/main/csharp/src/Toolnexus/McpSource.cs) ```csharp public static Task LoadAsync(object input, Func>? waitFor = null, CancellationToken cancellationToken = default) ``` Parses `input` (a path, a raw JSON string, or a parsed config dictionary — same acceptance rules as [`ParseConfig`](/api/csharp/mcp/parse-config/)), then connects to every **enabled** server concurrently: stdio for `local`, streamable-HTTP for `remote`. Each listed server tool becomes a uniform [`ITool`](/api/csharp/types/tool/) with `Source == "mcp"`. The result is an `IAsyncDisposable` `McpSource` carrying `.Tools` and `.Status` (`server → "connected" | "disabled" | "failed"`). A bad server never sinks the whole call — its failure is isolated to `Status[name] == "failed"` and every other server still loads. This page covers `input` and per-server status; the `waitFor` and `cancellationToken` parameters are covered on [the ctx-aware page](/api/csharp/mcp/load-with-context/). ## When to use it [Section titled “When to use it”](#when-to-use-it) At startup, once you have a real (or user-supplied) `mcp.json` and want live, callable tools — not just the config. This is the “connect for real” half of the pair; [`ListMcpToolsAsync`](/api/csharp/mcp/list-tools/) is the “look, don’t touch” half. ## Why this and not the alternative [Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative) Prefer Toolkit.CreateAsync in an app `LoadAsync` is what [`Toolkit.CreateAsync`](/api/csharp/toolkit/create/) calls internally to add MCP tools alongside skills, builtins, and your own. Reach for `McpSource.LoadAsync` directly when you want **only** MCP tools, or need the `McpSource` object itself (its `.Status` map, its lifetime) rather than a merged toolkit. Unlike [`ParseConfig`](/api/csharp/mcp/parse-config/), this method does real I/O — it spawns child processes and opens HTTP connections — so it is `async` and returns a resource you must dispose (`await using`). ## Examples [Section titled “Examples”](#examples) ### 1. A disabled server never connects [Section titled “1. A disabled server never connects”](#1-a-disabled-server-never-connects) ```csharp using Toolnexus; var config = new Dictionary { ["mcpServers"] = new Dictionary { ["docs_demo"] = new Dictionary { ["type"] = "local", ["command"] = new[] { "echo", "hi" }, ["enabled"] = false, }, }, }; await using var mcp = await McpSource.LoadAsync(config); if (mcp.Status["docs_demo"] != "disabled") throw new Exception($"status: {mcp.Status["docs_demo"]}"); if (mcp.Tools.Count != 0) throw new Exception("expected no tools from a disabled server"); Console.WriteLine($"ok: {mcp.Status["docs_demo"]}"); ``` ### 2. A server that can’t spawn is isolated, not fatal [Section titled “2. A server that can’t spawn is isolated, not fatal”](#2-a-server-that-cant-spawn-is-isolated-not-fatal) ```csharp using Toolnexus; var config = new Dictionary { ["mcpServers"] = new Dictionary { ["broken"] = new Dictionary { ["type"] = "local", // No such binary — spawning fails immediately. No network involved. ["command"] = new[] { "toolnexus-docs-nonexistent-binary-xyz" }, ["timeout"] = 2_000, }, }, }; await using var mcp = await McpSource.LoadAsync(config); // LoadAsync itself never throws for a bad server — the failure lands in Status. if (mcp.Status["broken"] != "failed") throw new Exception($"status: {mcp.Status["broken"]}"); if (mcp.Tools.Count != 0) throw new Exception("expected no tools from a failed server"); Console.WriteLine($"ok: {mcp.Status["broken"]}"); ``` ### 3. The realistic flow: parse from disk, then gate, then load [Section titled “3. The realistic flow: parse from disk, then gate, then load”](#3-the-realistic-flow-parse-from-disk-then-gate-then-load) ```csharp using Toolnexus; var repo = Environment.GetEnvironmentVariable("TOOLNEXUS_REPO") ?? "."; var fixture = Path.Combine(repo, "examples", "mcp.json"); // Parse first (cheap, no I/O beyond the read) — same pattern as ParseConfig's "validate before // loading" example. This doc forces every server off so it never dials out in CI, but the shape // (parse -> gate -> LoadAsync) is exactly what a real startup path runs. var config = McpSource.ParseConfig(fixture); foreach (var (_, value) in config) ((IDictionary)value!)["enabled"] = false; await using var mcp = await McpSource.LoadAsync(config); var statuses = mcp.Status.OrderBy(kv => kv.Key).Select(kv => $"{kv.Key}={kv.Value}").ToList(); if (mcp.Status.Values.Any(s => s != "disabled")) throw new Exception($"unexpected: {string.Join(",", statuses)}"); Console.WriteLine($"ok: {string.Join(", ", statuses)}"); ``` Caution `LoadAsync` connects to every **enabled** server concurrently — one slow server does not block the others. A per-server `timeout` config value bounds that one server; see [the ctx-aware page](/api/csharp/mcp/load-with-context/) for how it interacts with the caller’s `CancellationToken`. ## Per-server readiness — `McpLoadOptions.OnServer` [Section titled “Per-server readiness — McpLoadOptions.OnServer”](#per-server-readiness--mcploadoptionsonserver) ```csharp public static Task LoadAsync(object input, McpLoadOptions options, CancellationToken ct = default); public sealed class McpLoadOptions { public Func>? WaitFor { get; set; } public Action? OnServer { get; set; } } public sealed record McpServerResult(string Name, string Status, IReadOnlyList Tools, Exception? Error); // Status: "connected" | "failed" | "disabled" ``` `OnServer` fires once per server the moment it connects, fails or is found disabled, so a host can offer one server’s tools without waiting for a slow one. Calls are serialized. The load still returns after every server finishes. MCP tool annotations become [`ITool.Hints`](/api/csharp/client/hooks/), and [`McpSource.ResourceTools`](/api/csharp/mcp/resource-tools/) adds tools for MCP resources. ```csharp await using var src = await McpSource.LoadAsync("mcp.json", new McpLoadOptions { OnServer = r => Console.WriteLine($"{r.Name}: {r.Status} ({r.Tools.Count} tools){(r.Error is null ? "" : " " + r.Error.Message)}"), }); ``` ## Parameters [Section titled “Parameters”](#parameters) | Parameter | Type | What it is | | ------------------- | ------------------------------ | ------------------------------------------------------------------------------------------------- | | `input` | `object` | Path, raw JSON string, or parsed config — same as [`ParseConfig`](/api/csharp/mcp/parse-config/). | | `waitFor` | `Func>?` | §10 resolver for MCP elicitation. `null` ⇒ elicitation not advertised. | | `cancellationToken` | `CancellationToken` | Covered on [the ctx-aware page](/api/csharp/mcp/load-with-context/). | ## See also [Section titled “See also”](#see-also) * [`McpSource.LoadAsync`](/api/csharp/mcp/load-with-context/) — The ctx-aware load: bound connection time and cancel a slow or hung server without leaking a child process. * [`McpSource.ListMcpToolsAsync`](/api/csharp/mcp/list-tools/) — List what each configured server would expose, plus per-server status, without wiring it into a toolkit. * [`McpSource.ParseConfig`](/api/csharp/mcp/parse-config/) — Parse and validate config without connecting — the fast fail for a malformed or misspelled server block. * [`McpSource.ElicitationToRequest`](/api/csharp/mcp/elicitation/) — Map an MCP server’s elicitation request onto the §10 suspension contract, and map the answer back. # McpSource.LoadAsync > The ctx-aware load: bound connection time and cancel a slow or hung server without leaking a child process. — C#. **C#** · package `Toolnexus` · [SPEC §2](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [McpSource.cs](https://github.com/muthuishere/toolnexus/blob/main/csharp/src/Toolnexus/McpSource.cs) ```csharp public static Task LoadAsync(object input, Func>? waitFor = null, CancellationToken cancellationToken = default) ``` Same method as [`McpSource.LoadAsync`](/api/csharp/mcp/load/) — this page scopes to the `cancellationToken` parameter and its relationship to each server’s own `timeout` config value (§2 Gap 3). Two bounds are in play at once: * Each server’s own `timeout` (config, milliseconds) bounds **that server’s** connect + list. A server that blows its own timeout is marked `Status[name] == "failed"` — the overall load continues. * The caller’s `cancellationToken` is linked into every server’s wait. Cancel it (or let a deadline built from it expire) and the **whole load** aborts: any clients that did connect are disposed, and `LoadAsync` throws `OperationCanceledException` — not a per-server failure. ## When to use it [Section titled “When to use it”](#when-to-use-it) Pass a real `CancellationToken` whenever `LoadAsync` runs inside a request, a job with its own deadline, or anything that can be told “stop now” — a shutdown signal, a client disconnect, a user-cancelled operation. Without it, a hung server is still bounded by its own `timeout`, but you have no way to cut the whole load short from outside. ## Why this and not the alternative [Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative) Per-server timeout vs. caller cancellation are not the same knob Set `timeout` in `mcp.json` to bound one flaky server without touching your code. Pass a `CancellationToken` when the caller — not the server config — decides the load should stop; for example, a web request whose client disconnected mid-startup. ## Examples [Section titled “Examples”](#examples) ### 1. Passing the token explicitly [Section titled “1. Passing the token explicitly”](#1-passing-the-token-explicitly) ```csharp using Toolnexus; var config = new Dictionary { ["mcpServers"] = new Dictionary { ["docs_demo"] = new Dictionary { ["type"] = "local", ["command"] = new[] { "echo", "hi" }, ["enabled"] = false, }, }, }; // CancellationToken.None means "no external deadline" — the same as omitting the parameter. await using var mcp = await McpSource.LoadAsync(config, waitFor: null, cancellationToken: CancellationToken.None); if (mcp.Status["docs_demo"] != "disabled") throw new Exception(mcp.Status["docs_demo"]); Console.WriteLine($"ok: {mcp.Status["docs_demo"]}"); ``` ### 2. A per-server timeout bounds only that server [Section titled “2. A per-server timeout bounds only that server”](#2-a-per-server-timeout-bounds-only-that-server) ```csharp using Toolnexus; var config = new Dictionary { ["mcpServers"] = new Dictionary { ["hung"] = new Dictionary { ["type"] = "local", // Spawns fine but never speaks MCP on stdout — the handshake just hangs. ["command"] = new[] { "sleep", "30" }, ["timeout"] = 300, // ms — bounds THIS server only, no network involved. }, }, }; var sw = System.Diagnostics.Stopwatch.StartNew(); await using var mcp = await McpSource.LoadAsync(config); sw.Stop(); if (mcp.Status["hung"] != "failed") throw new Exception($"status: {mcp.Status["hung"]}"); // Bounded by the server's own 300ms timeout, not left hanging for the caller's lifetime. if (sw.ElapsedMilliseconds > 15_000) throw new Exception($"took too long: {sw.ElapsedMilliseconds}ms"); Console.WriteLine($"ok: {mcp.Status["hung"]} in {sw.ElapsedMilliseconds}ms"); ``` ### 3. Parent cancellation aborts the whole load [Section titled “3. Parent cancellation aborts the whole load”](#3-parent-cancellation-aborts-the-whole-load) ```csharp using Toolnexus; var config = new Dictionary { ["mcpServers"] = new Dictionary { ["docs_demo"] = new Dictionary { ["type"] = "local", ["command"] = new[] { "sleep", "30" }, ["timeout"] = 30_000, // longer than the parent deadline below — the parent wins }, }, }; using var cts = new CancellationTokenSource(); cts.Cancel(); // already cancelled — simulates a caller-imposed deadline that already expired var threw = false; try { await McpSource.LoadAsync(config, cancellationToken: cts.Token); } catch (OperationCanceledException) { // Parent cancellation aborts the WHOLE build, unlike a per-server timeout which only // marks that one server "failed" and lets the rest continue. threw = true; } if (!threw) throw new Exception("expected OperationCanceledException"); Console.WriteLine("ok: parent cancellation aborted the whole load"); ``` ## Parameters [Section titled “Parameters”](#parameters) | Parameter | Type | What it is | | ------------------------- | ------------------- | ----------------------------------------------------------------------------------------------------------------- | | `cancellationToken` | `CancellationToken` | Linked into every server’s connect+list wait. Cancel/expire ⇒ the whole load throws `OperationCanceledException`. | | server `timeout` (config) | milliseconds | Bounds only that server; a timeout marks it `"failed"` and the load continues. | ## See also [Section titled “See also”](#see-also) * [`McpSource.LoadAsync`](/api/csharp/mcp/load/) — Read an mcp.json, connect every local stdio and remote streamable-HTTP server, expose each server tool as a Tool. * [`McpSource.ListMcpToolsAsync`](/api/csharp/mcp/list-tools/) — List what each configured server would expose, plus per-server status, without wiring it into a toolkit. * [`McpSource.ParseConfig`](/api/csharp/mcp/parse-config/) — Parse and validate config without connecting — the fast fail for a malformed or misspelled server block. * [`McpSource.ElicitationToRequest`](/api/csharp/mcp/elicitation/) — Map an MCP server’s elicitation request onto the §10 suspension contract, and map the answer back. # McpSource.ParseConfig > Parse and validate an mcp.json without connecting to anything — the fast fail for a malformed or misspelled server block. C#. **C#** · package `Toolnexus` · [SPEC §2](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [McpSource.cs](https://github.com/muthuishere/toolnexus/blob/main/csharp/src/Toolnexus/McpSource.cs) ```csharp public static Dictionary ParseConfig(object input) ``` Reads MCP server configuration and normalises it into a flat dictionary of server name → config. It **connects to nothing** — no child processes, no HTTP. That is the whole point: it is the cheap check you can run before paying for [`LoadAsync`](/api/csharp/mcp/load/). It is ParseConfig, not ParseMcpConfig C# drops the `Mcp` from the method name because it already lives on `McpSource`. Java does the same for the same reason — [`McpSource.parseConfig`](/api/java/mcp/parse-config/). The remaining ports spell it as a free function: `parseMcpConfig` / `parse_mcp_config` / `ParseMcpConfig` / `parse-config`. ## When to use it [Section titled “When to use it”](#when-to-use-it) * **Validate at startup or in a test**, so a typo in `mcp.json` fails immediately rather than halfway through connecting to five servers. * **Inspect or modify config before loading** — filter servers by environment, inject a header, disable one in CI. * **Accept config from somewhere other than a file** — a database, an env var, an API response. ## Why this and not just calling LoadAsync [Section titled “Why this and not just calling LoadAsync”](#why-this-and-not-just-calling-loadasync) LoadAsync already parses — use this when you need the config itself [`McpSource.LoadAsync`](/api/csharp/mcp/load/) parses internally, so you do not need to call this first in the ordinary path. Reach for it when you want the *config as data*: to check it, change it, or report on it without launching anything. It is **synchronous** — unlike almost everything else on `McpSource`, there is no `Async` suffix, because there is no I/O beyond an optional file read. ## Examples [Section titled “Examples”](#examples) ### 1. Parse the shared fixture from disk [Section titled “1. Parse the shared fixture from disk”](#1-parse-the-shared-fixture-from-disk) This is `examples/mcp.json`, the fixture every port is tested against. ```csharp using Toolnexus; // TOOLNEXUS_REPO is set by the docs test runner; in your own code just use a path. var repo = Environment.GetEnvironmentVariable("TOOLNEXUS_REPO") ?? "."; var fixture = Path.Combine(repo, "examples", "mcp.json"); var config = McpSource.ParseConfig(fixture); // The `mcpServers` wrapper is unwrapped — you get the servers directly. var names = config.Keys.OrderBy(k => k).ToList(); if (names.Count != 2 || names[0] != "everything" || names[1] != "example-remote") throw new Exception($"unexpected servers: {string.Join(",", names)}"); // Disabled servers are still returned — parsing does not filter. Console.WriteLine($"ok: {string.Join(", ", names)}"); ``` Caution Parsing does **not** drop disabled servers. `enabled: false` is preserved in the returned config and is honoured later, by `LoadAsync`. If you are counting servers, count the enabled ones yourself. ### 2. Wrapper spellings and a raw JSON string [Section titled “2. Wrapper spellings and a raw JSON string”](#2-wrapper-spellings-and-a-raw-json-string) `mcpServers`, `servers` and `mcp` all mean the same thing, and a raw JSON string is accepted too. ```csharp using Toolnexus; foreach (var raw in new[] { """{"mcpServers":{"a":{"type":"local","command":["echo","hi"]}}}""", """{"servers":{"a":{"type":"local","command":["echo","hi"]}}}""", """{"mcp":{"a":{"type":"local","command":["echo","hi"]}}}""", }) { var config = McpSource.ParseConfig(raw); if (config.Count != 1) throw new Exception($"expected one server, got {config.Count}"); if (!config.ContainsKey("a")) throw new Exception("expected server 'a'"); } Console.WriteLine("ok: 3 spellings -> a"); ``` ### 3. Validate before loading, and fail loudly [Section titled “3. Validate before loading, and fail loudly”](#3-validate-before-loading-and-fail-loudly) The pattern this method exists for — check the config, then decide whether to connect. ```csharp using Toolnexus; (List Enabled, List Problems) Validate(string raw) { var config = McpSource.ParseConfig(raw); var enabled = new List(); var problems = new List(); foreach (var pair in config) { // Values come back as nested Dictionary, NOT JsonElement. var cfg = (IDictionary)pair.Value!; object? Get(string k) => cfg.TryGetValue(k, out var v) ? v : null; // Disabled either way round: `enabled: false` or `disabled: true`. if (Get("disabled") is bool db && db) continue; if (Get("enabled") is bool en && !en) continue; enabled.Add(pair.Key); var type = Get("type") as string; if (type == "remote") { if (Get("url") is not string u || string.IsNullOrEmpty(u)) problems.Add($"{pair.Key}: remote server without a url"); } else { var command = Get("command") as System.Collections.IEnumerable; var count = command?.Cast().Count() ?? 0; if (count == 0) problems.Add($"{pair.Key}: local server without a command"); } } enabled.Sort(); problems.Sort(); return (enabled, problems); } var good = Validate(""" {"mcpServers":{ "ok_local":{"type":"local","command":["npx","server"]}, "ok_remote":{"type":"remote","url":"https://example.com/mcp"}, "off":{"type":"local","command":["x"],"enabled":false}}} """); if (good.Enabled.Count != 2 || good.Problems.Count != 0) throw new Exception($"unexpected: {string.Join(",", good.Enabled)} / {good.Problems.Count}"); var bad = Validate(""" {"mcpServers":{ "broken_remote":{"type":"remote"}}} """); if (bad.Problems.Count != 1) throw new Exception($"expected 1 problem, got {bad.Problems.Count}"); // An empty local command fails the parse itself, naming the server (SPEC §2). var rejected = false; try { McpSource.ParseConfig("""{"mcpServers":{"broken_local":{"type":"local","command":[]}}}"""); } catch (ArgumentException e) { rejected = e.Message.Contains("broken_local"); } if (!rejected) throw new Exception("expected an invalid-command failure"); Console.WriteLine($"ok: {string.Join(",", good.Enabled)} | problems: {bad.Problems.Count}"); ``` Note Values in the returned dictionary are **nested `Dictionary`**, not `JsonElement` — cast to `IDictionary` and index, rather than reaching for `TryGetProperty`. ## Accepted input [Section titled “Accepted input”](#accepted-input) | Input | Behaviour | | ------------------- | ----------------------------------- | | `"./mcp.json"` | A path — read from disk and parsed. | | A raw JSON `string` | Parsed directly. | | A parsed dictionary | Normalised. | Wrapped under `mcpServers`, `servers` or `mcp` — all three are unwrapped. ## See also [Section titled “See also”](#see-also) * [`McpSource.LoadAsync`](/api/csharp/mcp/load/) — parse *and* connect * [`McpSource.ListMcpToolsAsync`](/api/csharp/mcp/list-tools/) — what each server would expose * [`Toolkit.CreateAsync`](/api/csharp/toolkit/create/) — takes config in exactly these forms # McpSource.ResourceTools > Opt-in list_mcp_resources / read_mcp_resource tools over every connected MCP server. Read-only. — C#. **C#** · package `Toolnexus` · [SPEC §2](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [McpResources.cs](https://github.com/muthuishere/toolnexus/blob/main/csharp/src/Toolnexus/McpResources.cs) ```csharp public List McpSource.ResourceTools(); // [0] list_mcp_resources { server? } → "server uri — name (mimeType): description" per line // [1] read_mcp_resource { server, uri } → the resource's text ``` Two read-only tools (`Hints.ReadOnly = true`) bound to the source’s connected servers. Opt-in: they are not added to a toolkit on their own; register them with `ExtraTools`. They stop working once the `McpSource` is disposed. A source with no connected server still returns both; they report that no server is connected. An unknown server or uri is an error result, not an exception. ## When to use it [Section titled “When to use it”](#when-to-use-it) An MCP server exposes data as *resources* (files, records, docs) rather than tools, and you want the model to browse and read them itself. ## Why this and not the alternative [Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative) ResourceTools, not a NativeTool per resource Wrapping each resource in your own tool freezes the list at build time. These two tools ask the servers live, so new resources appear without a rebuild. Read a resource yourself, outside the model, when the host already knows which one it needs. ## Examples [Section titled “Examples”](#examples) ### 1. The two tools, even with no server [Section titled “1. The two tools, even with no server”](#1-the-two-tools-even-with-no-server) ```csharp using Toolnexus; await using var src = await McpSource.LoadAsync("""{"mcp":{}}"""); var tools = src.ResourceTools(); if (string.Join(",", tools.Select(t => t.Name)) != "list_mcp_resources,read_mcp_resource") throw new Exception("names"); if (!tools.All(t => t.Hints!.ReadOnly)) throw new Exception("expected read-only hints"); Console.WriteLine($"ok: {string.Join(", ", tools.Select(t => t.Name))} (read-only)"); ``` ### 2. List and read a server’s resource [Section titled “2. List and read a server’s resource”](#2-list-and-read-a-servers-resource) ```csharp using Toolnexus; // TOOLNEXUS_REPO is set by the docs test runner; in your own code use your server's path. var repo = Environment.GetEnvironmentVariable("TOOLNEXUS_REPO") ?? "."; var server = Path.Combine(repo, "examples", "mcp-progress-server.mjs"); var cfg = "{\"mcp\":{\"progress\":{\"command\":\"node\",\"args\":[" + System.Text.Json.JsonSerializer.Serialize(server) + "]}}}"; await using var src = await McpSource.LoadAsync(cfg); var (list, read) = (src.ResourceTools()[0], src.ResourceTools()[1]); var listed = await list.ExecuteAsync(new Dictionary()); var text = await read.ExecuteAsync(new Dictionary { ["server"] = "progress", ["uri"] = "fixture://hello" }); var missing = await read.ExecuteAsync(new Dictionary { ["server"] = "progress", ["uri"] = "fixture://nope" }); if (text.Output != "hello resource" || !missing.IsError) throw new Exception(text.Output); Console.WriteLine($"ok: {listed.Output} → \"{text.Output}\""); ``` ### 3. Hand them to the model through a toolkit [Section titled “3. Hand them to the model through a toolkit”](#3-hand-them-to-the-model-through-a-toolkit) ```csharp using Toolnexus; var repo = Environment.GetEnvironmentVariable("TOOLNEXUS_REPO") ?? "."; var server = Path.Combine(repo, "examples", "mcp-progress-server.mjs"); var cfg = "{\"mcp\":{\"progress\":{\"command\":\"node\",\"args\":[" + System.Text.Json.JsonSerializer.Serialize(server) + "]}}}"; await using var src = await McpSource.LoadAsync(cfg); await using var tk = await Toolkit.CreateAsync(new Toolkit.Options { ExtraTools = src.ResourceTools() }); var r = await tk.ExecuteAsync("read_mcp_resource", new Dictionary { ["server"] = "progress", ["uri"] = "fixture://hello" }); if (r.Output != "hello resource") throw new Exception(r.Output); Console.WriteLine($"ok: the toolkit offers {string.Join(", ", tk.Tools().Select(t => t.Name))}"); ``` ## See also [Section titled “See also”](#see-also) * [`McpSource.LoadAsync`](/api/csharp/mcp/load/) — Read an mcp.json, connect every local stdio and remote streamable-HTTP server, expose each server tool as a Tool. * [`McpSource.LoadAsync`](/api/csharp/mcp/load-with-context/) — The ctx-aware load: bound connection time and cancel a slow or hung server without leaking a child process. * [`McpSource.ListMcpToolsAsync`](/api/csharp/mcp/list-tools/) — List what each configured server would expose, plus per-server status, without wiring it into a toolkit. * [`McpSource.ParseConfig`](/api/csharp/mcp/parse-config/) — Parse and validate config without connecting — the fast fail for a malformed or misspelled server block. # Tools.FromObject > Sweep a module or class and collect every function marked as a tool. — C#. **C#** · package `Toolnexus` · [SPEC §6](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [Tools.cs](https://github.com/muthuishere/toolnexus/blob/main/csharp/src/Toolnexus/Tools.cs) ```csharp public static List FromObject(object target) ``` It is Tools.FromObject, not Tools.FromObject The C# public API names this method `FromObject`, not `Collect` — there is no `Tools.FromObject` symbol in the port. `FromObject` is what plays the role the other ports call “collect”: reflect over `target`’s public instance methods, keep the ones carrying [`[ToolMethod]`](/api/csharp/native/reflect/), and turn each into an [`ITool`](/api/csharp/types/tool/) with `Source == "native"` — the schema inferred from the method’s parameters and any `[Param]` attributes. Order follows `GetMethods(BindingFlags.Public | BindingFlags.Instance)` — reflection’s own method enumeration order, not source order. Methods without `[ToolMethod]` are skipped entirely, including every inherited `object` method (`ToString`, `Equals`, `GetHashCode`, `GetType`). ## When to use it [Section titled “When to use it”](#when-to-use-it) You have a class — a service, a repository, a facade over some subsystem — with several methods already marked `[ToolMethod]`, and you want the whole set as one call rather than building each tool by hand. It’s also how you’d expose the *same* instance’s tools to more than one toolkit or client without re-annotating anything. ## Why this and not the alternative [Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative) One call per instance, not per method Call `FromObject` once per instance — it returns every annotated method on that object in one list. For a single standalone function that has nothing to do with a class, skip attributes entirely and use [`NativeTool.Of`](/api/csharp/native/define-tool/) directly. ## Examples [Section titled “Examples”](#examples) ### 1. Sweep a class with several annotated methods [Section titled “1. Sweep a class with several annotated methods”](#1-sweep-a-class-with-several-annotated-methods) ```csharp using Toolnexus; var svc = new FileOpsService(); var tools = Tools.FromObject(svc); var names = tools.Select(t => t.Name).OrderBy(n => n, StringComparer.Ordinal).ToList(); if (!names.SequenceEqual(new[] { "list_files", "read_file" })) throw new Exception(string.Join(",", names)); if (tools.Any(t => t.Source != "native")) throw new Exception("expected every tool to be native"); Console.WriteLine($"ok: {string.Join(", ", names)}"); class FileOpsService { [ToolMethod(Name = "read_file", Description = "Read a file's contents")] public string ReadFile(string path) => $"contents of {path}"; [ToolMethod(Name = "list_files", Description = "List files under a directory")] public string ListFiles(string dir) => $"files under {dir}"; // No [ToolMethod] — never becomes a tool, no matter how public. public string Helper() => "not a tool"; } ``` ### 2. Unannotated methods, including inherited ones, are skipped [Section titled “2. Unannotated methods, including inherited ones, are skipped”](#2-unannotated-methods-including-inherited-ones-are-skipped) ```csharp using Toolnexus; var empty = new PlainService(); var tools = Tools.FromObject(empty); // PlainService has public methods (ToString, Equals, DoWork) but none carry [ToolMethod]. if (tools.Count != 0) throw new Exception($"expected 0 tools, got {tools.Count}: {string.Join(",", tools.Select(t => t.Name))}"); Console.WriteLine("ok: an unannotated class collects 0 tools"); class PlainService { public string DoWork() => "done"; // not marked — not a tool } ``` ### 3. The collected tools drop straight into a toolkit [Section titled “3. The collected tools drop straight into a toolkit”](#3-the-collected-tools-drop-straight-into-a-toolkit) ```csharp using Toolnexus; var svc = new MathService(); var collected = Tools.FromObject(svc); // Same path NativeTool tools take: ExtraTools on Toolkit.Options. The toolkit also carries the // built-in tools by default, so don't assert on a total count. await using var tk = await Toolkit.CreateAsync(new Toolkit.Options { ExtraTools = collected }); if (tk.Get("add") == null) throw new Exception($"toolkit missing 'add': {string.Join(",", tk.Tools().Select(t => t.Name))}"); var res = await tk.Get("add")!.ExecuteAsync(new Dictionary { ["a"] = 2, ["b"] = 3 }); if (res.Output != "5") throw new Exception(res.Output); Console.WriteLine($"ok: add(2,3) -> {res.Output}"); class MathService { [ToolMethod(Name = "add", Description = "Add two integers")] public string Add(int a, int b) => (a + b).ToString(); } ``` ## Parameters [Section titled “Parameters”](#parameters) | Parameter | Type | What it is | | --------- | -------- | -------------------------------------------------------------------------------------------- | | `target` | `object` | Any instance. Its public instance methods carrying `[ToolMethod]` become the returned tools. | ## See also [Section titled “See also”](#see-also) * [`NativeTool.Of`](/api/csharp/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. * [`ToolMethodAttribute`](/api/csharp/native/reflect/) — Derive the schema from the function signature or annotation instead of writing it by hand. # NativeTool.Of > Wrap a plain function as a tool — name, description, JSON Schema, done. C#. **C#** · package `Toolnexus` · [SPEC §6](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [NativeTool.cs](https://github.com/muthuishere/toolnexus/blob/main/csharp/src/Toolnexus/NativeTool.cs) ```csharp // synchronous, ignores the context public static NativeTool Of(string name, string description, IDictionary? inputSchema, Func, object?> fn) // synchronous, receives the context public static NativeTool Of(string name, string description, IDictionary? inputSchema, Func, ToolContext?, object?> fn) // asynchronous public static NativeTool OfAsync(string name, string description, IDictionary? inputSchema, Func, ToolContext?, Task> fn) ``` The shortest path from code you already have to a tool the model can call. Give it a name, a description, a JSON-Schema object and a function; you get back an [`ITool`](/api/csharp/types/tool/) with `Source` set to `"native"`, indistinguishable from an MCP tool as far as the LLM is concerned. ## When to use it [Section titled “When to use it”](#when-to-use-it) For anything that is *your* logic rather than someone else’s server: a database lookup, a calculation, a call into an internal service, a lookup in a dictionary you have in memory. It is also the fastest way to add one tool to a toolkit that is otherwise all MCP and skills. ## Why this and not the alternative [Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative) Use \[ToolMethod] + Tools.FromObject when you have many Writing the schema by hand is right for one or two tools and tedious for ten. [`ToolMethodAttribute`](/api/csharp/native/reflect/) derives the schema from the method signature, and [`Tools.FromObject`](/api/csharp/native/collect/) sweeps a whole class of them in one call. Go the other way — implement [`ITool`](/api/csharp/types/tool/) directly — only when you are generating tools from data and need control over the type itself. `NativeTool` is a sealed class with a private constructor; `Of` / `OfAsync` are the only way in. ## Examples [Section titled “Examples”](#examples) ### 1. A function with a schema [Section titled “1. A function with a schema”](#1-a-function-with-a-schema) ```csharp using Toolnexus; var add = NativeTool.Of( "add", "Add two numbers", new Dictionary { ["type"] = "object", ["properties"] = new Dictionary { ["a"] = new Dictionary { ["type"] = "number" }, ["b"] = new Dictionary { ["type"] = "number" }, }, ["required"] = new[] { "a", "b" }, }, // The explicit parameter type picks the no-context overload. (IDictionary a) => (Convert.ToDouble(a["a"]) + Convert.ToDouble(a["b"])).ToString()); if (add.Name != "add") throw new Exception(add.Name); if (add.Source != "native") throw new Exception(add.Source); var res = await add.ExecuteAsync(new Dictionary { ["a"] = 2, ["b"] = 3 }); if (res.IsError || res.Output != "5") throw new Exception(res.Output); Console.WriteLine($"ok: {add.Name} -> {res.Output}"); ``` Annotate the lambda parameter `Of` is overloaded on the delegate type, so a bare `a => …` is ambiguous and will not compile. Write `(IDictionary a) => …` for the simple form, or `(IDictionary a, ToolContext? c) => …` when you want the context. Arguments arrive as whatever your JSON layer produced — `long`, `double`, `string`, `JsonElement`. Convert defensively (`Convert.ToDouble`, `?.ToString()`) rather than casting. ### 2. Return values, failure, and no arguments at all [Section titled “2. Return values, failure, and no arguments at all”](#2-return-values-failure-and-no-arguments-at-all) The return value is coerced: a `string` becomes the output verbatim, a `ToolResult` is passed through untouched, `null` becomes `""`, and anything else is JSON-serialized. A **thrown exception becomes an error result** — it never escapes to your loop. ```csharp using Toolnexus; // A non-string return is JSON-serialized for you. var reading = NativeTool.Of("reading", "Latest sensor reading", null, (IDictionary a) => new Dictionary { ["celsius"] = 21.5, ["ok"] = true }); var r1 = await reading.ExecuteAsync(new Dictionary()); if (r1.IsError || r1.Output != """{"celsius":21.5,"ok":true}""") throw new Exception(r1.Output); // Passing null for the schema gives the canonical empty-object schema. if (reading.InputSchema["type"] as string != "object") throw new Exception("type"); if (reading.InputSchema["additionalProperties"] is not false) throw new Exception("additionalProperties"); // Return a ToolResult when you want metadata, or a deliberate error. var lookup = NativeTool.Of("lookup", "Look a user up by id", null, (IDictionary a) => a["id"] as string == "42" ? ToolResult.Ok("Ada", new Dictionary { ["title"] = "user 42" }) : ToolResult.Error("no such user")); var hit = await lookup.ExecuteAsync(new Dictionary { ["id"] = "42" }); if (hit.Metadata?["title"] as string != "user 42") throw new Exception("metadata"); var missing = await lookup.ExecuteAsync(new Dictionary { ["id"] = "7" }); if (!missing.IsError || missing.Output != "no such user") throw new Exception(missing.Output); // A throw is caught and turned into an error result — the model sees the message and can retry. var risky = NativeTool.Of("risky", "Throws on purpose", null, (IDictionary a) => throw new InvalidOperationException("upstream is down")); var boom = await risky.ExecuteAsync(new Dictionary()); if (!boom.IsError || boom.Output != "upstream is down") throw new Exception(boom.Output); Console.WriteLine($"ok: {r1.Output} | {hit.Output} | {boom.Output}"); ``` ### 3. Async work, the context, and secrets [Section titled “3. Async work, the context, and secrets”](#3-async-work-the-context-and-secrets) `OfAsync` for anything that awaits. The `ToolContext` is nullable and carries a timeout and a cancellation token — honour them for long work. ```csharp using Toolnexus; // Real credentials are read from the environment at call time and never logged or // baked into the tool. YOUR_KEY_HERE is a placeholder, not a key. Environment.SetEnvironmentVariable("DOCS_DEMO_TOKEN", "YOUR_KEY_HERE"); var fetchReport = NativeTool.OfAsync( "fetch_report", "Fetch a report by id", new Dictionary { ["type"] = "object", ["properties"] = new Dictionary { ["id"] = new Dictionary { ["type"] = "string" } }, ["required"] = new[] { "id" }, }, async (a, ctx) => { var token = Environment.GetEnvironmentVariable("DOCS_DEMO_TOKEN"); if (string.IsNullOrEmpty(token)) return ToolResult.Error("DOCS_DEMO_TOKEN is not set"); // Cooperative cancellation: the loop can stop a slow tool. await Task.Delay(1, ctx?.CancellationToken ?? default); if (ctx?.IsCancelled == true) return ToolResult.Error("cancelled"); return $"report {a["id"]} (authenticated)"; }); var ok = await fetchReport.ExecuteAsync(new Dictionary { ["id"] = "q3" }, new ToolContext(timeoutMs: 5_000)); if (ok.IsError || ok.Output != "report q3 (authenticated)") throw new Exception(ok.Output); if (ok.Output.Contains("YOUR_KEY_HERE")) throw new Exception("never echo a credential back to the model"); // The context overload of Of() covers the synchronous case. var whoami = NativeTool.Of("whoami", "Report the deadline the caller imposed", null, (IDictionary a, ToolContext? ctx) => $"timeout={ctx?.TimeoutMs?.ToString() ?? "none"}"); var withCtx = await whoami.ExecuteAsync(new Dictionary(), new ToolContext(timeoutMs: 1_500)); if (withCtx.Output != "timeout=1500") throw new Exception(withCtx.Output); // ctx is optional — callers may omit it entirely. var noCtx = await whoami.ExecuteAsync(new Dictionary()); if (noCtx.Output != "timeout=none") throw new Exception(noCtx.Output); // A native tool goes into a toolkit alongside every other source, via ExtraTools. // (The toolkit also carries the built-in tools by default, so don't assert on a count.) await using var tk = await Toolkit.CreateAsync(new Toolkit.Options { ExtraTools = new List { fetchReport, whoami } }); if (tk.Get("fetch_report") == null || tk.Get("whoami") == null) throw new Exception($"toolkit: {string.Join(",", tk.Tools().Select(t => t.Name))}"); Console.WriteLine($"ok: {ok.Output} | {withCtx.Output} | toolkit has {tk.Tools().Count} tools"); ``` ## Parameters [Section titled “Parameters”](#parameters) | Parameter | Type | What it is | | ------------- | ------------------------------- | ---------------------------------------------------------------------------------------------------- | | `name` | `string` | What the model calls. Must match `[a-zA-Z0-9_-]` — see `Tools.Sanitize`. | | `description` | `string` | What the model reads to decide whether to call it. Write it for the model. | | `inputSchema` | `IDictionary?` | A JSON-Schema **object**. `null` ⇒ `{"type":"object","properties":{},"additionalProperties":false}`. | | `fn` | delegate | Your function. Return `string`, `ToolResult`, `null`, or anything JSON-serializable. | ## See also [Section titled “See also”](#see-also) * [`ToolMethodAttribute`](/api/csharp/native/reflect/) — derive the schema from the method signature * [`Tools.FromObject`](/api/csharp/native/collect/) — sweep a class for annotated methods * [`ITool`](/api/csharp/types/tool/) · [`ToolResult`](/api/csharp/types/tool-result/) · [`ToolContext`](/api/csharp/types/context/) * [`HttpTool.Of`](/api/csharp/http/tool/) — the same idea when the logic is a REST call # ToolMethodAttribute > Derive the schema from the function signature or annotation instead of writing it by hand. — C#. **C#** · package `Toolnexus` · [SPEC §6](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [Annotations.cs](https://github.com/muthuishere/toolnexus/blob/main/csharp/src/Toolnexus/Annotations.cs) ```csharp [AttributeUsage(AttributeTargets.Method)] public sealed class ToolMethodAttribute : Attribute { public string Name { get; set; } = ""; // defaults to the method name public string Description { get; set; } = ""; } [AttributeUsage(AttributeTargets.Parameter)] public sealed class ParamAttribute : Attribute { public string Name { get; set; } = ""; // defaults to the parameter name public string Description { get; set; } = ""; public bool Required { get; set; } = true; } ``` Mark an ordinary public instance method `[ToolMethod]` and it becomes a tool: the JSON-Schema `InputSchema` is inferred from the method’s own parameter types, `[Param]` adds a per-parameter description or an explicit name/required flag, and a `ToolContext` parameter is recognized and injected rather than turned into a schema property. Nothing here builds a tool by itself — the attributes only *mark* the method; [`Tools.FromObject`](/api/csharp/native/collect/) is what reads the attributes and produces the `ITool` list. ## When to use it [Section titled “When to use it”](#when-to-use-it) You already have (or are writing) a plain C# class whose methods are the logic you want the model to call — a service, a repository, a set of internal operations. Annotating each method is less ceremony than hand-writing a `NativeTool.Of` schema for every one of them, and the schema can’t drift from the real parameter list because it’s read from that list. ## Why this and not the alternative [Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative) NativeTool.Of for one or two tools, attributes for a whole class [`NativeTool.Of`](/api/csharp/native/define-tool/) is right for a handful of standalone functions. `[ToolMethod]` + [`Tools.FromObject`](/api/csharp/native/collect/) pays off once you have a class with several methods that should all become tools — you annotate once, per method, and sweep the whole class in one call. No Elixir equivalent Elixir has no reflection-based tool builder — there is no dynamic type inspection to read parameter names/types from at runtime the way .NET, JVM, and V8 reflection allow. Use `Toolnexus.Native.define_tool/1` with an explicit `input_schema` there instead. ## Examples [Section titled “Examples”](#examples) ### 1. The smallest annotated method [Section titled “1. The smallest annotated method”](#1-the-smallest-annotated-method) ```csharp using Toolnexus; var service = new WeatherService(); var tools = Tools.FromObject(service); if (tools.Count != 1) throw new Exception($"expected 1 tool, got {tools.Count}"); // A bare [ToolMethod] with no Name uses the method's own name verbatim — no casing change. if (tools[0].Name != "GetWeather") throw new Exception(tools[0].Name); if (tools[0].Source != "native") throw new Exception(tools[0].Source); var res = await tools[0].ExecuteAsync(new Dictionary { ["city"] = "Chennai" }); if (res.IsError || res.Output != "sunny in Chennai") throw new Exception(res.Output); Console.WriteLine($"ok: {tools[0].Name} -> {res.Output}"); class WeatherService { [ToolMethod(Description = "Current weather for a city")] public string GetWeather(string city) => $"sunny in {city}"; } ``` Set Name explicitly for a model-friendly tool name `Name` defaults to `method.Name` verbatim — `GetWeather`, not `get_weather`. If you want a lowercase/underscored tool name (as most fixtures use), set `[ToolMethod(Name = "...")]`, as the next example does. ### 2. Custom name, `[Param]` descriptions, and an optional parameter [Section titled “2. Custom name, \[Param\] descriptions, and an optional parameter”](#2-custom-name-param-descriptions-and-an-optional-parameter) ```csharp using Toolnexus; var svc = new SearchService(); var tools = Tools.FromObject(svc); var search = tools.Single(t => t.Name == "search_docs"); var props = (IDictionary)search.InputSchema["properties"]!; var query = (IDictionary)props["query"]!; if (query["description"] as string != "What to search for") throw new Exception("query description"); var required = (IEnumerable)search.InputSchema["required"]!; // "limit" is optional (Required = false) — only "query" is required. if (!required.Contains("query") || required.Contains("limit")) throw new Exception("required set"); var withLimit = await search.ExecuteAsync(new Dictionary { ["query"] = "toolnexus", ["limit"] = 1 }); if (withLimit.Output != "1 results for toolnexus") throw new Exception(withLimit.Output); // Omitting an optional argument does NOT run the method's own C# default — reflection always // passes an explicit value, coerced from "missing" to the parameter type's zero value (0 for int). var omitted = await search.ExecuteAsync(new Dictionary { ["query"] = "toolnexus" }); if (omitted.Output != "0 results for toolnexus") throw new Exception(omitted.Output); Console.WriteLine($"ok: {withLimit.Output} | {omitted.Output}"); class SearchService { [ToolMethod(Name = "search_docs", Description = "Search the documentation")] public string Search( [Param(Description = "What to search for")] string query, [Param(Description = "Max results", Required = false)] int limit) => $"{limit} results for {query}"; } ``` ### 3. `ToolContext` injection, alongside a plain `NativeTool` [Section titled “3. ToolContext injection, alongside a plain NativeTool”](#3-toolcontext-injection-alongside-a-plain-nativetool) ```csharp using Toolnexus; var ops = new OpsService(); var reflected = Tools.FromObject(ops); var whoami = reflected.Single(t => t.Name == "Whoami"); var res = await whoami.ExecuteAsync(new Dictionary(), new ToolContext(timeoutMs: 2_500)); if (res.Output != "timeout=2500") throw new Exception(res.Output); // A method's ToolContext parameter is never part of the JSON schema — only real args are. if (((IDictionary)whoami.InputSchema["properties"]!).Count != 0) throw new Exception("ctx parameter leaked into the schema"); // Reflected tools sit alongside hand-built NativeTool instances in the same list — the model // can't tell them apart; both are Source == "native". var manual = NativeTool.Of("ping", "Health check", null, (IDictionary a) => "pong"); var all = new List(reflected) { manual }; if (all.Select(t => t.Source).Distinct().Single() != "native") throw new Exception("source mismatch"); Console.WriteLine($"ok: {res.Output} | {string.Join(", ", all.Select(t => t.Name))}"); class OpsService { [ToolMethod(Description = "Report the deadline the caller imposed")] public string Whoami(ToolContext? ctx) => $"timeout={ctx?.TimeoutMs?.ToString() ?? "none"}"; } ``` ## Members [Section titled “Members”](#members) | Member | Applies to | What it is | | --------------------------------- | ---------- | --------------------------------------------------------------------------------- | | `ToolMethodAttribute.Name` | method | Tool name; defaults to `method.Name`. | | `ToolMethodAttribute.Description` | method | What the model reads to decide whether to call it. | | `ParamAttribute.Name` | parameter | Schema property name; defaults to the parameter name. | | `ParamAttribute.Description` | parameter | Schema `description` for that property. | | `ParamAttribute.Required` | parameter | Defaults to `true`. `false` drops it from `required` and lets a C# default apply. | | `ToolContext` parameter | parameter | Recognized by type and injected — never becomes a schema property. | ## See also [Section titled “See also”](#see-also) * [`NativeTool.Of`](/api/csharp/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. * [`Tools.FromObject`](/api/csharp/native/collect/) — Sweep a module or class and collect every function marked as a tool. # Home.ComposeSoul > Build a persona's system prompt from its home directory: identity, memory, skills. — C#. **C#** · package `Toolnexus` · [SPEC §7E](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [Agents/Home.cs](https://github.com/muthuishere/toolnexus/blob/main/csharp/src/Toolnexus/Agents/Home.cs) ```csharp namespace Toolnexus.Agents; public static class Home { public static readonly IReadOnlyList BootstrapOrder; // AGENTS, SOUL, IDENTITY, USER, TOOLS, HEARTBEAT, MEMORY.md public const int MaxFileBytes = 2 * 1024 * 1024; public static (string Soul, List Found) ComposeSoul(string dir); } ``` Reads a persona’s bootstrap files off disk — `AGENTS.md`, `SOUL.md`, `IDENTITY.md`, `USER.md`, `TOOLS.md`, `HEARTBEAT.md`, `MEMORY.md`, **in that order** — and joins the ones that exist into one soul string, each as a `## ` section. Absent files are silently skipped. Each file is capped at 2 MB, measured in UTF-8 **bytes**; a larger file is truncated (at a rune boundary, so no multibyte character is split) with a trailing notice — the file on disk is never touched. This is the primitive [`Home.FromDir`](/api/csharp/persona/from-dir/) is built on: `FromDir` calls `ComposeSoul` once and hands the result to `AgentSpec.Soul` as the frozen system-prompt snapshot. ## When to use it [Section titled “When to use it”](#when-to-use-it) You’re building your own persona wiring — a bespoke `AgentSpec`, a variant of `FromDir` with different tool assembly — and want the *identity-file → soul-string* half of §7E without the rest of `FromDir`’s defaults (memory tool, model, `Does`). It’s also the right thing to call again **at the start of a new session**, since the composed soul is a frozen snapshot: a `memory` tool write mid-session updates disk, not the string already sitting in a live prompt. ## Why this and not the alternative [Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative) Reach for FromDir when you just want a runnable Agent `ComposeSoul` returns a string and a list of filenames — nothing else. If you want a ready-to-run [`Agent`](/api/csharp/runtime/agent/) (soul wired into `AgentSpec.Soul`, the `memory` tool attached, a name derived from the directory), skip straight to [`Home.FromDir`](/api/csharp/persona/from-dir/), which calls `ComposeSoul` internally. Reach for `ComposeSoul` directly only when you need the soul text on its own — inspecting it, feeding it into a hand-built `AgentSpec`, or diffing it across sessions. ## Examples [Section titled “Examples”](#examples) ### 1. The smallest useful call — one file [Section titled “1. The smallest useful call — one file”](#1-the-smallest-useful-call--one-file) ```csharp using Toolnexus.Agents; var dir = Directory.CreateTempSubdirectory("toolnexus-soul-").FullName; File.WriteAllText(Path.Combine(dir, "SOUL.md"), "You are Kavi, a terse research assistant."); var (soul, found) = Home.ComposeSoul(dir); if (found.Count != 1 || found[0] != "SOUL.md") throw new Exception($"found: {string.Join(",", found)}"); if (!soul.Contains("## SOUL.md")) throw new Exception(soul); if (!soul.Contains("You are Kavi")) throw new Exception(soul); Console.WriteLine($"ok: {found[0]} -> {soul.Length} chars"); ``` ### 2. Bootstrap order wins over write order, absent files are skipped [Section titled “2. Bootstrap order wins over write order, absent files are skipped”](#2-bootstrap-order-wins-over-write-order-absent-files-are-skipped) ```csharp using System.Linq; using Toolnexus.Agents; var dir = Directory.CreateTempSubdirectory("toolnexus-soul-").FullName; // Written to disk out of order — ComposeSoul must still emit them in BootstrapOrder. File.WriteAllText(Path.Combine(dir, "MEMORY.md"), "- learned the user prefers terse replies"); File.WriteAllText(Path.Combine(dir, "AGENTS.md"), "Always answer in one sentence."); File.WriteAllText(Path.Combine(dir, "IDENTITY.md"), "Kavi, a research assistant."); // SOUL.md, USER.md, TOOLS.md, HEARTBEAT.md are absent — skipped, not an error. var (soul, found) = Home.ComposeSoul(dir); if (!found.SequenceEqual(new[] { "AGENTS.md", "IDENTITY.md", "MEMORY.md" })) throw new Exception($"order: {string.Join(",", found)}"); var agentsAt = soul.IndexOf("## AGENTS.md", StringComparison.Ordinal); var identityAt = soul.IndexOf("## IDENTITY.md", StringComparison.Ordinal); var memoryAt = soul.IndexOf("## MEMORY.md", StringComparison.Ordinal); if (!(agentsAt < identityAt && identityAt < memoryAt)) throw new Exception("bootstrap order violated"); Console.WriteLine($"ok: {string.Join(" -> ", found)}"); ``` ### 3. Full surface — the 2 MB byte cap, truncated without touching disk [Section titled “3. Full surface — the 2 MB byte cap, truncated without touching disk”](#3-full-surface--the-2-mb-byte-cap-truncated-without-touching-disk) ```csharp using System.Linq; using System.Text; using Toolnexus.Agents; var dir = Directory.CreateTempSubdirectory("toolnexus-soul-").FullName; // One byte over the cap — written WITHOUT a BOM so byte length == char length (all-ASCII). var oversized = new string('a', Home.MaxFileBytes + 1); File.WriteAllText(Path.Combine(dir, "TOOLS.md"), oversized, new UTF8Encoding(false)); File.WriteAllText(Path.Combine(dir, "SOUL.md"), "short and under the cap"); var (soul, found) = Home.ComposeSoul(dir); if (!found.SequenceEqual(new[] { "SOUL.md", "TOOLS.md" })) throw new Exception(string.Join(",", found)); if (!soul.Contains("[truncated: exceeds 2 MB bootstrap cap]")) throw new Exception("expected truncation notice"); // The cap only bounds the composed SOUL — the file on disk is left exactly as written. if (new FileInfo(Path.Combine(dir, "TOOLS.md")).Length != Home.MaxFileBytes + 1) throw new Exception("disk file must be untouched by the cap"); Console.WriteLine($"ok: soul is {soul.Length} chars; on-disk TOOLS.md stays {oversized.Length + 1} bytes"); ``` Caution The 2 MB cap is measured in UTF-8 **bytes**, not `string.Length` (UTF-16 chars). A file dense in multibyte characters (e.g. Tamil, emoji) hits the cap well before 2M chars — and truncation backs off any dangling continuation byte so it never splits a rune mid-character. ## Parameters [Section titled “Parameters”](#parameters) | Parameter | Type | What it is | | --------- | -------- | ---------------------------------------------------------- | | `dir` | `string` | The persona’s home directory to read bootstrap files from. | ## Returns [Section titled “Returns”](#returns) | Field | Type | What it is | | ------- | -------------- | --------------------------------------------------------------------------------------------------- | | `Soul` | `string` | The composed system prompt: present files only, in `BootstrapOrder`, each as a `## ` section. | | `Found` | `List` | Which bootstrap filenames were actually present, in `BootstrapOrder`. | ## See also [Section titled “See also”](#see-also) * [`Home.FromDir`](/api/csharp/persona/from-dir/) — Point at an agent home directory and get a configured agent back. * [`Home.MemoryTool`](/api/csharp/persona/memory-tool/) — The opt-in built-in that lets a persona write durable notes to its own home. # Home.FromDir > Point at an agent home directory and get a configured agent back. — C#. **C#** · package `Toolnexus` · [SPEC §7E](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [Agents/Home.cs](https://github.com/muthuishere/toolnexus/blob/main/csharp/src/Toolnexus/Agents/Home.cs) ```csharp namespace Toolnexus.Agents; public static class Home { public static Agent FromDir(string dir, FromDirOptions? opts = null); } public sealed class FromDirOptions { public string? Does { get; set; } // routing description; default "persona agent from " public string? Name { get; set; } // agent name; default the directory's base name public string? Model { get; set; } // default "inherit" public List? Tools { get; set; } // extra tools beyond the memory builtin public bool Memory { get; set; } = true; // set false to omit the memory tool (read-only persona) } ``` The directory **is** the agent (SPEC §7E). `FromDir` calls [`Home.ComposeSoul`](/api/csharp/persona/compose-soul/) on `dir` to build the frozen system-prompt snapshot, wires [`Home.MemoryTool`](/api/csharp/persona/memory-tool/) over the same directory (unless `Memory = false`), and returns a plain [`Agent`](/api/csharp/runtime/agent/) — `RunAsync` it like any agent, or hand it to `Home.StartAgent` for a heartbeat. ## When to use it [Section titled “When to use it”](#when-to-use-it) You keep a persona’s identity in files on disk — `SOUL.md` for voice, `AGENTS.md` for operating instructions, `MEMORY.md` for what it has learned — and want one call that turns that folder into a runnable `Agent` with its durable-notes tool already attached. This is the on-ramp for every long-lived persona: a support bot, a research assistant, anything that should remember things across sessions without you hand-wiring `AgentSpec` yourself. ## Why this and not the alternative [Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative) Reach for a hand-built AgentSpec when the defaults don't fit `FromDir` picks sensible defaults — the directory’s base name, `Model = "inherit"`, the memory tool on. If you need a `Team` (sub-agent delegation), a `Budget`, or a `WaitFor` override, build an `AgentSpec` directly and pass `Soul = Home.ComposeSoul(dir).Soul` yourself. `FromDir` is the convention-over-configuration front door, not the only door. ## Examples [Section titled “Examples”](#examples) ### 1. The smallest useful call [Section titled “1. The smallest useful call”](#1-the-smallest-useful-call) ```csharp using Toolnexus; using Toolnexus.Agents; var dir = Directory.CreateTempSubdirectory("toolnexus-home-").FullName; File.WriteAllText(Path.Combine(dir, "SOUL.md"), "You are Kavi, a terse research assistant."); var kavi = Home.FromDir(dir); var llm = new MockLlm(_ => Task.FromResult(MockLlm.Text("hi from Kavi"))); var rtOpts = new RuntimeOptions { ApiKey = "test-key", Handler = llm, BaseUrl = "http://runtime.invalid", Style = "openai", Model = "mock" }; var result = await kavi.RunAsync(rtOpts, "say hi"); if (result.Status != "done") throw new Exception(result.Status); if (result.Text != "hi from Kavi") throw new Exception(result.Text); if (kavi.Name != Path.GetFileName(dir)) throw new Exception(kavi.Name); // default name = directory base name Console.WriteLine($"ok: {kavi.Name} -> {result.Text}"); sealed class MockLlm : System.Net.Http.HttpMessageHandler { readonly Func> _respond; public MockLlm(Func> respond) => _respond = respond; protected override Task SendAsync( System.Net.Http.HttpRequestMessage request, CancellationToken ct) => _respond(request); public static System.Net.Http.HttpResponseMessage Text(string content) => Json(System.Text.Json.JsonSerializer.Serialize(new { choices = new[] { new { message = new { role = "assistant", content } } }, usage = new { prompt_tokens = 5, completion_tokens = 3, total_tokens = 8 }, })); public static System.Net.Http.HttpResponseMessage ToolCall(string id, string name, object args) => Json(System.Text.Json.JsonSerializer.Serialize(new { choices = new[] { new { message = new { role = "assistant", content = (string?)null, tool_calls = new[] { new { id, type = "function", function = new { name, arguments = System.Text.Json.JsonSerializer.Serialize(args) } } } } }, }, usage = new { prompt_tokens = 10, completion_tokens = 5, total_tokens = 15 }, })); static System.Net.Http.HttpResponseMessage Json(string body) => new(System.Net.HttpStatusCode.OK) { Content = new System.Net.Http.StringContent(body, System.Text.Encoding.UTF8, "application/json"), }; } ``` ### 2. The realistic case — the memory tool is wired in by default [Section titled “2. The realistic case — the memory tool is wired in by default”](#2-the-realistic-case--the-memory-tool-is-wired-in-by-default) ```csharp using System.Linq; using Toolnexus; using Toolnexus.Agents; var dir = Directory.CreateTempSubdirectory("toolnexus-home-").FullName; File.WriteAllText(Path.Combine(dir, "SOUL.md"), "You are Kavi."); var kavi = Home.FromDir(dir); // Memory defaults to true — the `memory` tool is auto-attached if (kavi.Spec.Uses is null || !kavi.Spec.Uses.Any(t => t.Name == "memory")) throw new Exception("expected the memory tool to be included by default"); var calls = 0; var llm = new MockLlm(_ => { calls++; return Task.FromResult(calls == 1 ? MockLlm.ToolCall("c1", "memory", new { action = "add", text = "the user prefers terse replies" }) : MockLlm.Text("noted")); }); var rtOpts = new RuntimeOptions { ApiKey = "test-key", Handler = llm, BaseUrl = "http://runtime.invalid", Style = "openai", Model = "mock" }; var result = await kavi.RunAsync(rtOpts, "remember that I like short answers"); if (result.Status != "done") throw new Exception(result.Status); if (result.Text != "noted") throw new Exception(result.Text); // The memory tool writes straight to disk — MEMORY.md now carries the entry. var memoryFile = Path.Combine(dir, "MEMORY.md"); if (!File.Exists(memoryFile)) throw new Exception("MEMORY.md was not written"); if (!File.ReadAllText(memoryFile).Contains("the user prefers terse replies")) throw new Exception("entry missing"); Console.WriteLine($"ok: {result.Text} (wrote {new FileInfo(memoryFile).Length} bytes to MEMORY.md)"); sealed class MockLlm : System.Net.Http.HttpMessageHandler { readonly Func> _respond; public MockLlm(Func> respond) => _respond = respond; protected override Task SendAsync( System.Net.Http.HttpRequestMessage request, CancellationToken ct) => _respond(request); public static System.Net.Http.HttpResponseMessage Text(string content) => Json(System.Text.Json.JsonSerializer.Serialize(new { choices = new[] { new { message = new { role = "assistant", content } } }, usage = new { prompt_tokens = 5, completion_tokens = 3, total_tokens = 8 }, })); public static System.Net.Http.HttpResponseMessage ToolCall(string id, string name, object args) => Json(System.Text.Json.JsonSerializer.Serialize(new { choices = new[] { new { message = new { role = "assistant", content = (string?)null, tool_calls = new[] { new { id, type = "function", function = new { name, arguments = System.Text.Json.JsonSerializer.Serialize(args) } } } } }, }, usage = new { prompt_tokens = 10, completion_tokens = 5, total_tokens = 15 }, })); static System.Net.Http.HttpResponseMessage Json(string body) => new(System.Net.HttpStatusCode.OK) { Content = new System.Net.Http.StringContent(body, System.Text.Encoding.UTF8, "application/json"), }; } ``` ### 3. Full surface — every `FromDirOptions` field [Section titled “3. Full surface — every FromDirOptions field”](#3-full-surface--every-fromdiroptions-field) ```csharp using System.Linq; using Toolnexus; using Toolnexus.Agents; var dir = Directory.CreateTempSubdirectory("toolnexus-home-").FullName; File.WriteAllText(Path.Combine(dir, "SOUL.md"), "You are a base identity."); var extra = NativeTool.Of("ping", "replies pong", new Dictionary { ["type"] = "object", ["properties"] = new Dictionary() }, (IDictionary _) => "pong"); var agent = Home.FromDir(dir, new Home.FromDirOptions { Does = "a fully configured persona", Name = "custom-name", Model = "gpt-4o-mini", Tools = new List { extra }, Memory = false, // opt out of the memory builtin — a read-only persona }); if (agent.Name != "custom-name") throw new Exception(agent.Name); if (agent.Spec.Does != "a fully configured persona") throw new Exception(agent.Spec.Does); if (agent.Spec.Model != "gpt-4o-mini") throw new Exception(agent.Spec.Model); if (agent.Spec.Uses is null || agent.Spec.Uses.Count != 1 || agent.Spec.Uses[0].Name != "ping") throw new Exception("expected exactly the extra tool, no memory tool"); if (agent.Spec.Soul is null || !agent.Spec.Soul.Contains("You are a base identity.")) throw new Exception(agent.Spec.Soul); Console.WriteLine($"ok: {agent.Name} does '{agent.Spec.Does}' with tools [{string.Join(",", agent.Spec.Uses.Select(t => t.Name))}]"); ``` Caution `Memory = false` is the only way to get a read-only persona — otherwise `FromDir` always attaches the `memory` tool, even if none of the bootstrap files mention it. If a persona should never write to its own directory (a shared, read-only identity), set it explicitly. ## Fields (`FromDirOptions`) [Section titled “Fields (FromDirOptions)”](#fields-fromdiroptions) | Field | Type | What it is | | -------- | -------------- | ---------------------------------------------------------------------------- | | `Does` | `string?` | Routing description; defaults to `"persona agent from "`. | | `Name` | `string?` | Agent name; defaults to the directory’s base name. | | `Model` | `string?` | Model id; defaults to `"inherit"` (the runtime’s default). | | `Tools` | `List?` | Extra tools beyond the memory builtin. | | `Memory` | `bool` | Set `false` to omit the `memory` tool (a read-only persona). Default `true`. | ## See also [Section titled “See also”](#see-also) * [`Home.ComposeSoul`](/api/csharp/persona/compose-soul/) — Build a persona’s system prompt from its home directory: identity, memory, skills. * [`Home.MemoryTool`](/api/csharp/persona/memory-tool/) — The opt-in built-in that lets a persona write durable notes to its own home. * [`Agent`](/api/csharp/runtime/agent/) — What `FromDir` returns: its own toolkit, prompt, and budget, runnable standalone or as a tool. # Home.MemoryTool > The opt-in built-in that lets a persona write durable notes to its own home. — C#. **C#** · package `Toolnexus` · [SPEC §7E](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [Agents/Home.cs](https://github.com/muthuishere/toolnexus/blob/main/csharp/src/Toolnexus/Agents/Home.cs) ```csharp namespace Toolnexus.Agents; public static class Home { public static ITool MemoryTool(string dir); } ``` A `memory` tool over a persona’s home directory — **not** one of the default builtin tools; it only exists when a home is wired ([`Home.FromDir`](/api/csharp/persona/from-dir/) attaches it automatically, or add it to `Uses`/`ExtraTools` yourself). One tool, three actions — `add` appends an entry, `replace` swaps an existing substring, `remove` deletes one — against `target = "self"` (`MEMORY.md`, the default) or `target = "user"` (`USER.md`). **Every action writes to disk.** A `replace`/`remove` whose substring is not found is a loud `IsError` result the model can read and recover from. ## When to use it [Section titled “When to use it”](#when-to-use-it) You’re giving a long-lived persona somewhere to put durable facts — “the user is in IST”, “prefers terse replies” — that should survive past the current session. Writes land on disk immediately, but **not** in the live prompt: the frozen-snapshot rule (§7E) means a mid-session write is only visible the *next* time [`Home.ComposeSoul`](/api/csharp/persona/compose-soul/) runs, at the start of a new session. That is deliberate — it keeps a long conversation’s system prompt cache-stable. ## Why this and not the alternative [Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative) Reach for a bespoke ITool when memory needs structure `MemoryTool` is plain-text, append/replace/remove over two Markdown files — enough for a persona’s running notes. If you need structured records (a database, a vector store, per-fact expiry), write your own `NativeTool` and skip this one; `FromDir`’s `Memory = false` lets you opt out while keeping everything else. ## Examples [Section titled “Examples”](#examples) ### 1. The smallest useful call — add an entry [Section titled “1. The smallest useful call — add an entry”](#1-the-smallest-useful-call--add-an-entry) ```csharp using Toolnexus.Agents; var dir = Directory.CreateTempSubdirectory("toolnexus-memory-").FullName; var memory = Home.MemoryTool(dir); var result = await memory.ExecuteAsync(new Dictionary { ["action"] = "add", ["text"] = "the user is in IST", }); if (result.IsError) throw new Exception(result.Output); var memoryFile = Path.Combine(dir, "MEMORY.md"); if (!File.ReadAllText(memoryFile).Contains("the user is in IST")) throw new Exception("entry missing"); Console.WriteLine($"ok: {result.Output}"); ``` ### 2. `replace`/`remove`, `target = "user"`, and the loud error on a miss [Section titled “2. replace/remove, target = "user", and the loud error on a miss”](#2-replaceremove-target--user-and-the-loud-error-on-a-miss) ```csharp using Toolnexus.Agents; var dir = Directory.CreateTempSubdirectory("toolnexus-memory-").FullName; var memory = Home.MemoryTool(dir); await memory.ExecuteAsync(new Dictionary { ["action"] = "add", ["text"] = "prefers Tamil greetings", ["target"] = "user" }); var replaced = await memory.ExecuteAsync(new Dictionary { ["action"] = "replace", ["text"] = "prefers Tamil greetings", ["with"] = "prefers English greetings", ["target"] = "user", }); if (replaced.IsError) throw new Exception(replaced.Output); var userFile = Path.Combine(dir, "USER.md"); var body = File.ReadAllText(userFile); if (!body.Contains("prefers English greetings") || body.Contains("Tamil")) throw new Exception(body); // A replace/remove of a substring that isn't there is a LOUD error — the model can read this back // and try something else, rather than the write silently no-op'ing. var miss = await memory.ExecuteAsync(new Dictionary { ["action"] = "remove", ["text"] = "not present anywhere", ["target"] = "user" }); if (!miss.IsError) throw new Exception("expected a loud error on a missing substring"); Console.WriteLine($"ok: {body.Trim()} | miss={miss.Output}"); ``` ### 3. Full surface — writes hit disk, but a live snapshot is frozen until next session [Section titled “3. Full surface — writes hit disk, but a live snapshot is frozen until next session”](#3-full-surface--writes-hit-disk-but-a-live-snapshot-is-frozen-until-next-session) ```csharp using Toolnexus.Agents; var dir = Directory.CreateTempSubdirectory("toolnexus-memory-").FullName; File.WriteAllText(Path.Combine(dir, "SOUL.md"), "You are Kavi."); // Compose once — this is the "frozen snapshot" a live session's system prompt would carry. var (soulBefore, _) = Home.ComposeSoul(dir); if (soulBefore.Contains("MEMORY.md")) throw new Exception("no MEMORY.md should exist yet"); var memory = Home.MemoryTool(dir); var unknown = await memory.ExecuteAsync(new Dictionary { ["action"] = "bogus", ["text"] = "x" }); if (!unknown.IsError) throw new Exception("an unknown action must be a loud error"); var added = await memory.ExecuteAsync(new Dictionary { ["action"] = "add", ["text"] = "loves terse answers" }); if (added.IsError) throw new Exception(added.Output); if (!added.Output.Contains("loads next session")) throw new Exception(added.Output); // the tool says so, in its own words // The write hit disk, but the snapshot taken BEFORE the write can't see it — the cache-stability // rule (SPEC §7E): mid-session writes only take effect on the NEXT ComposeSoul. if (soulBefore.Contains("loves terse answers")) throw new Exception("stale snapshot must not see the write"); var (soulAfter, foundAfter) = Home.ComposeSoul(dir); if (!soulAfter.Contains("loves terse answers")) throw new Exception("a FRESH compose must see the write"); if (!foundAfter.Contains("MEMORY.md")) throw new Exception(string.Join(",", foundAfter)); Console.WriteLine($"ok: frozen snapshot unaffected; next session sees {foundAfter[^1]}"); ``` Caution The tool never mutates the current session’s prompt — it says so in its own description, so the model doesn’t expect a write to be visible immediately. Don’t build a persona that asks the model to “check what you just remembered” in the same turn; it won’t be there until the next session. ## Actions [Section titled “Actions”](#actions) | Action | Effect | Target file | | --------- | ------------------------------------------------------------------------------------ | -------------------------------------------------- | | `add` | Appends an entry as a new list item. | `self` → `MEMORY.md` (default), `user` → `USER.md` | | `replace` | Swaps an existing substring (`text`) for `with`. Loud `IsError` if `text` is absent. | same | | `remove` | Deletes an existing substring (`text`). Loud `IsError` if `text` is absent. | same | ## Parameters [Section titled “Parameters”](#parameters) | Parameter | Type | What it is | | --------- | -------- | ------------------------------------------------------------------------------ | | `dir` | `string` | The persona’s home directory — the same one passed to `ComposeSoul`/`FromDir`. | ## See also [Section titled “See also”](#see-also) * [`Home.ComposeSoul`](/api/csharp/persona/compose-soul/) — Build a persona’s system prompt from its home directory: identity, memory, skills. * [`Home.FromDir`](/api/csharp/persona/from-dir/) — Point at an agent home directory and get a configured agent back. # Agent > Define a sub-agent with its own toolkit, prompt and budget, callable as a tool by its parent. — C#. **C#** · package `Toolnexus` · [SPEC §7D](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [Agents/Agent.cs](https://github.com/muthuishere/toolnexus/blob/main/csharp/src/Toolnexus/Agents/Agent.cs) ```csharp namespace Toolnexus.Agents; public sealed class Agent { public Agent(string name, AgentSpec spec); public Task RunAsync(RuntimeOptions rtOpts, string prompt); public ITool AsTool(RuntimeOptions rtOpts); } public sealed class AgentSpec { public string Does { get; set; } = ""; // routing description, required public List? Uses { get; set; } // the toolkit VIEW for this agent public string? Soul { get; set; } // identity / system prompt public string? SoulFile { get; set; } // path form, read when the registry is built public List? Team { get; set; } // task-tool targets; null = no task tool public Budget? Budget { get; set; } public string? Model { get; set; } // null/"inherit" = the runtime default public Func>? WaitFor { get; set; } public Func? OnSpawn { get; set; } public Func? OnClose { get; set; } public LlmClient.Hooks? Hooks { get; set; } public Action? OnMetric { get; set; } } ``` The SPEC §7D axiom made concrete: an `Agent` is (a system prompt × a filtered toolkit view × the §8 client loop). `RunAsync` runs it one-shot to completion, building and tearing down its own [`AgentRuntime`](/api/csharp/runtime/runtime/). `AsTool` is the axiom’s *other* direction — it bridges the agent into a plain `ITool`, so a classic `LlmClient`/`Toolkit` caller can drop it into `ExtraTools` and delegate to it exactly like any native tool. ## When to use it [Section titled “When to use it”](#when-to-use-it) You want a named, reusable unit — its own `does` description, its own scoped tools (`Uses`), its own identity (`Soul`/`SoulFile`), its own budget — that either runs standalone (`RunAsync`) or plugs into a bigger toolkit as one more callable (`AsTool`). `Team` is the subagent wiring itself: listing other `Agent`s there is what makes the parent’s model able to delegate to them via the [`task`](/api/csharp/runtime/task-tool/) tool. ## Why this and not the alternative [Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative) Reach for AgentRuntime directly for anything longer-lived `Agent.RunAsync` is deliberately one-shot: spawn, wake, wait, close, done — no host verbs exposed. Reach for [`AgentRuntime`](/api/csharp/runtime/runtime/) directly when you need to `Post` to an agent’s inbox mid-run, `Interrupt` it, keep several handles alive across many turns, or inspect the tree with `List`/`Inspect`. `Agent` is the ergonomic front door; `AgentRuntime` is the full host surface underneath it. ## Examples [Section titled “Examples”](#examples) ### 1. The smallest useful call — one-shot, no tools [Section titled “1. The smallest useful call — one-shot, no tools”](#1-the-smallest-useful-call--one-shot-no-tools) ```csharp using Toolnexus.Agents; var llm = new MockLlm(_ => Task.FromResult(MockLlm.Text("hello from the sub-agent"))); var greeter = new Agent("greeter", new AgentSpec { Does = "says hello" }); var rtOpts = new RuntimeOptions { ApiKey = "test-key", Handler = llm, BaseUrl = "http://runtime.invalid", Style = "openai", Model = "mock" }; var result = await greeter.RunAsync(rtOpts, "say hi"); if (result.Status != "done") throw new Exception(result.Status); if (result.Text != "hello from the sub-agent") throw new Exception(result.Text); Console.WriteLine($"ok: {result.Text} (turns={result.Turns})"); sealed class MockLlm : System.Net.Http.HttpMessageHandler { readonly Func> _respond; public MockLlm(Func> respond) => _respond = respond; protected override Task SendAsync( System.Net.Http.HttpRequestMessage request, CancellationToken ct) => _respond(request); public static System.Net.Http.HttpResponseMessage Text(string content) => Json(System.Text.Json.JsonSerializer.Serialize(new { choices = new[] { new { message = new { role = "assistant", content } } }, usage = new { prompt_tokens = 5, completion_tokens = 3, total_tokens = 8 }, })); public static System.Net.Http.HttpResponseMessage ToolCall(string id, string name, object args) => Json(System.Text.Json.JsonSerializer.Serialize(new { choices = new[] { new { message = new { role = "assistant", content = (string?)null, tool_calls = new[] { new { id, type = "function", function = new { name, arguments = System.Text.Json.JsonSerializer.Serialize(args) } } } } }, }, usage = new { prompt_tokens = 10, completion_tokens = 5, total_tokens = 15 }, })); static System.Net.Http.HttpResponseMessage Json(string body) => new(System.Net.HttpStatusCode.OK) { Content = new System.Net.Http.StringContent(body, System.Text.Encoding.UTF8, "application/json"), }; } ``` ### 2. The realistic case — a tool call, then a final answer [Section titled “2. The realistic case — a tool call, then a final answer”](#2-the-realistic-case--a-tool-call-then-a-final-answer) ```csharp using Toolnexus; using Toolnexus.Agents; var calls = 0; var llm = new MockLlm(_ => { calls++; return Task.FromResult(calls == 1 ? MockLlm.ToolCall("c1", "add", new { a = 2, b = 3 }) : MockLlm.Text("2 + 3 = 5")); }); var add = NativeTool.Of("add", "add two integers", new Dictionary { ["type"] = "object", ["properties"] = new Dictionary { ["a"] = new Dictionary { ["type"] = "integer" }, ["b"] = new Dictionary { ["type"] = "integer" }, }, }, (IDictionary a) => (Convert.ToInt32(a["a"]) + Convert.ToInt32(a["b"])).ToString()); var mathTutor = new Toolnexus.Agents.Agent("math-tutor", new AgentSpec { Does = "answers arithmetic questions", Soul = "You are a terse math tutor.", Uses = new List { add }, }); var rtOpts = new RuntimeOptions { ApiKey = "test-key", Handler = llm, BaseUrl = "http://runtime.invalid", Style = "openai", Model = "mock" }; var result = await mathTutor.RunAsync(rtOpts, "what is 2 + 3?"); if (result.Status != "done") throw new Exception(result.Status); if (result.Text != "2 + 3 = 5") throw new Exception(result.Text); if (result.Turns != 2) throw new Exception($"expected 2 turns, got {result.Turns}"); Console.WriteLine($"ok: {result.Text} (turns={result.Turns}, tokens={result.TotalTokens})"); sealed class MockLlm : System.Net.Http.HttpMessageHandler { readonly Func> _respond; public MockLlm(Func> respond) => _respond = respond; protected override Task SendAsync( System.Net.Http.HttpRequestMessage request, CancellationToken ct) => _respond(request); public static System.Net.Http.HttpResponseMessage Text(string content) => Json(System.Text.Json.JsonSerializer.Serialize(new { choices = new[] { new { message = new { role = "assistant", content } } }, usage = new { prompt_tokens = 5, completion_tokens = 3, total_tokens = 8 }, })); public static System.Net.Http.HttpResponseMessage ToolCall(string id, string name, object args) => Json(System.Text.Json.JsonSerializer.Serialize(new { choices = new[] { new { message = new { role = "assistant", content = (string?)null, tool_calls = new[] { new { id, type = "function", function = new { name, arguments = System.Text.Json.JsonSerializer.Serialize(args) } } } } }, }, usage = new { prompt_tokens = 10, completion_tokens = 5, total_tokens = 15 }, })); static System.Net.Http.HttpResponseMessage Json(string body) => new(System.Net.HttpStatusCode.OK) { Content = new System.Net.Http.StringContent(body, System.Text.Encoding.UTF8, "application/json"), }; } ``` ### 3. Full surface — the axiom’s other direction: `AsTool` bridges into a plain client loop [Section titled “3. Full surface — the axiom’s other direction: AsTool bridges into a plain client loop”](#3-full-surface--the-axioms-other-direction-astool-bridges-into-a-plain-client-loop) ```csharp using Toolnexus; using Agent = Toolnexus.Agents.Agent; using Toolnexus.Agents; var subCalls = 0; var subLlm = new MockLlm(_ => { subCalls++; return Task.FromResult(MockLlm.Text("Chennai: 31C, humid")); }); var subRtOpts = new RuntimeOptions { ApiKey = "test-key", Handler = subLlm, BaseUrl = "http://runtime.invalid", Style = "openai", Model = "mock" }; var weatherAgent = new Agent("weather", new AgentSpec { Does = "reports the weather for a city" }); var weatherTool = weatherAgent.AsTool(subRtOpts); // an Agent, dropped into ExtraTools like any ITool // A completely ordinary top-level client + toolkit — it doesn't know "weather" is a whole sub-agent. var parentCalls = 0; var parentLlm = new MockLlm(_ => { parentCalls++; return Task.FromResult(parentCalls == 1 ? MockLlm.ToolCall("p1", "weather", new { prompt = "how's Chennai?" }) : MockLlm.Text("It's 31C and humid in Chennai.")); }); var client = LlmClient.Create(new LlmClient.Options { HttpHandler = parentLlm, BaseUrl = "http://runtime.invalid", Style = "openai", Model = "mock", ApiKey = "k", }); await using var tk = await Toolkit.CreateAsync(new Toolkit.Options { Builtins = false, ExtraTools = new List { weatherTool } }); var result = await client.RunAsync("what's the weather in Chennai?", tk); if (result.Status != "done") throw new Exception(result.Status); if (result.Text != "It's 31C and humid in Chennai.") throw new Exception(result.Text); // the parent's transcript gains exactly ONE tool message for the whole sub-agent run if (result.ToolCallCount != 1) throw new Exception($"expected 1 tool call, got {result.ToolCallCount}"); if (result.ToolCalls[0].Output != "Chennai: 31C, humid") throw new Exception(result.ToolCalls[0].Output); Console.WriteLine($"ok: {result.Text} (sub-agent ran {subCalls} turn(s) inside 1 parent tool call)"); sealed class MockLlm : System.Net.Http.HttpMessageHandler { readonly Func> _respond; public MockLlm(Func> respond) => _respond = respond; protected override Task SendAsync( System.Net.Http.HttpRequestMessage request, CancellationToken ct) => _respond(request); public static System.Net.Http.HttpResponseMessage Text(string content) => Json(System.Text.Json.JsonSerializer.Serialize(new { choices = new[] { new { message = new { role = "assistant", content } } }, usage = new { prompt_tokens = 5, completion_tokens = 3, total_tokens = 8 }, })); public static System.Net.Http.HttpResponseMessage ToolCall(string id, string name, object args) => Json(System.Text.Json.JsonSerializer.Serialize(new { choices = new[] { new { message = new { role = "assistant", content = (string?)null, tool_calls = new[] { new { id, type = "function", function = new { name, arguments = System.Text.Json.JsonSerializer.Serialize(args) } } } } }, }, usage = new { prompt_tokens = 10, completion_tokens = 5, total_tokens = 15 }, })); static System.Net.Http.HttpResponseMessage Json(string body) => new(System.Net.HttpStatusCode.OK) { Content = new System.Net.Http.StringContent(body, System.Text.Encoding.UTF8, "application/json"), }; } ``` Caution `AgentSpec.Team` (a `List`) is what wires the `task` tool — see [the task tool](/api/csharp/runtime/task-tool/). Two different `Agent` types exist in this namespace and `Toolnexus.A2A`’s outbound descriptor of the same name — bring both `using Toolnexus;` and `using Toolnexus.Agents;` into scope and the bare name is ambiguous; alias one (`using Agent = Toolnexus.Agents.Agent;`) as example 3 does. ## Fields (`AgentSpec`) [Section titled “Fields (AgentSpec)”](#fields-agentspec) | Field | Type | What it is | | -------------------- | ------------------------------ | ------------------------------------------------------------------------------------------- | | `Does` | `string` | Required — the routing description a delegating model (or `task` tool listing) sees. | | `Uses` | `List?` | The scoped toolkit view — this agent gets exactly these tools, nothing more. | | `Soul` / `SoulFile` | `string?` | Identity → this agent’s system prompt, inline or read from a file when the registry builds. | | `Team` | `List?` | `task`-tool targets. `null` ⇒ no `task` tool (recursion is opt-in). | | `Budget` | `Budget?` | See [Budgets](/api/csharp/runtime/budgets/). | | `Model` | `string?` | `null`/`"inherit"` ⇒ the runtime’s default model. | | `WaitFor` | `Func>?` | This agent’s §10 interpreter authority. | | `Hooks` / `OnMetric` | — | Per-agent §8 seams — replace the runtime-wide ones for this agent only. | ## `AsToolWith` — a child under the parent’s gates [Section titled “AsToolWith — a child under the parent’s gates”](#astoolwith--a-child-under-the-parents-gates) ```csharp public ITool AsToolWith(Func perCall); // RuntimeOptions.WaitFor: answers a child's suspension when no agent on its chain has its own ``` `AsTool(opts)` is `AsToolWith(_ => opts)`. Both stop the child when the calling turn is cancelled (the tool returns a cancelled error result). `AsToolWith` builds the runtime options per call from the calling tool’s context, so the child gets the parent turn’s `Hooks` (the same `BeforeTool` permission gate, which also sees the child’s tool calls) and `WaitFor` (the same question bridge). A `Spec.Hooks` on the agent replaces `Hooks`, so leave it unset when the gate must hold. There is no token-by-token stream of the child’s text. ```csharp var tool = researcher.AsToolWith(ctx => new RuntimeOptions { BaseUrl = baseUrl, Style = "openai", Model = model, ApiKey = "YOUR_KEY_HERE", Hooks = parentHooks, // the same permission gate as the parent turn WaitFor = parentWaitFor, // the child's questions reach the same UI }); ``` ## See also [Section titled “See also”](#see-also) * [`AgentRuntime`](/api/csharp/runtime/runtime/) — The six host verbs that drive sub-agents, plus the read-only list and inspect views. * [`Handle`](/api/csharp/runtime/handle/) — The state machine for one spawned agent: pending, running, suspended, done. * [`AgentTypes.Budget`](/api/csharp/runtime/budgets/) — Cap tool calls and wall-clock per agent and per team, enforced while the run is in flight. * [The task tool](/api/csharp/runtime/task-tool/) — What `Team` wires up: the model-facing delegation tool. # AgentTypes.Budget > Cap tool calls and wall-clock per agent and per team, enforced while the run is in flight. — C#. **C#** · package `Toolnexus` · [SPEC §7D](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [Agents/AgentTypes.cs](https://github.com/muthuishere/toolnexus/blob/main/csharp/src/Toolnexus/Agents/AgentTypes.cs) ```csharp namespace Toolnexus.Agents; public sealed class Budget { public int? MaxTurns { get; set; } // default 6 when unset public long? MaxTokens { get; set; } public long? MaxToolCalls { get; set; } public long? MaxWallMs { get; set; } public int? MaxChildren { get; set; } public int? MaxConcurrent { get; set; } // default 8 when unset public int? MaxDepth { get; set; } // default 3 when unset } ``` Not nested under a type named AgentTypes Despite the page title (kept for cross-port symbol parity), the real C# type is simply `Toolnexus.Agents.Budget` — a top-level class in `Agents/AgentTypes.cs`, not nested inside anything called `AgentTypes`. Construct it as `new Budget { ... }`. Hierarchical, **live-enforced** limits (SPEC §7D). A child’s budget is *carved* at spawn — `effective = min(own value, parent's remaining)` for every pool — and then checked again by a live ancestor-chain walk **before every turn and every spawn**, because carving alone misses spend by siblings. Any limit stop surfaces as `AgentResult.Status == "incomplete"` with the limit named — never a silent `"done"`, never a crash. Money is deliberately excluded (vendor-specific pricing); convert token/call counts to cost in your own `onBudget`/telemetry if you need it. ## When to use it [Section titled “When to use it”](#when-to-use-it) You’re delegating to a sub-agent (or a whole team) and want a hard ceiling it cannot exceed — tokens for a cost cap, tool calls to stop a runaway loop, wall-clock for a latency SLA, or `MaxChildren`/`MaxConcurrent`/`MaxDepth` to bound how wide and deep the delegation tree can grow. Set `Budget` on an `AgentDef`/`AgentSpec`, or pass an override to `AgentRuntime.Spawn` for a one-off tighter cap on a single spawn. ## Why this and not the alternative [Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative) Carve at spawn, but the walk is what actually protects you A budget carved once at spawn time can’t see a sibling that already spent from the same parent pool. That’s why the runtime re-checks the **live** ancestor chain before every turn and spawn — the carved value is just the starting ceiling, not the enforcement mechanism. ## Examples [Section titled “Examples”](#examples) ### 1. The smallest useful call — `MaxTurns` caps a loop that never finishes [Section titled “1. The smallest useful call — MaxTurns caps a loop that never finishes”](#1-the-smallest-useful-call--maxturns-caps-a-loop-that-never-finishes) ```csharp using Toolnexus; using Toolnexus.Agents; var lookup = NativeTool.Of("lookup", "look something up", new Dictionary { ["type"] = "object", ["properties"] = new Dictionary() }, (IDictionary _) => "data"); var calls = 0; var llm = new MockLlm(_ => { calls++; // always another tool call — a runaway loop with no final answer. return Task.FromResult(MockLlm.ToolCall($"c{calls}", "lookup", new { })); }); var rt = new AgentRuntime(new RuntimeOptions { ApiKey = "test-key", Handler = llm, BaseUrl = "http://runtime.invalid", Style = "openai", Model = "mock", Registry = new Dictionary { ["looper"] = new() { Name = "looper", Does = "loops forever", Tools = new List { lookup }, Budget = new Budget { MaxTurns = 1 } }, }, }); var h = rt.Spawn(rt.Root, "looper").Handle!; var wait = rt.WaitAsync(h); rt.Wake(h, "look something up, repeatedly"); var result = await wait; if (result.Status != "incomplete") throw new Exception(result.Status); if (result.Text != "hit maxTurns without a final answer") throw new Exception(result.Text); await rt.CloseAsync(rt.Root); Console.WriteLine($"ok: {result.Status} — {result.Text}"); sealed class MockLlm : System.Net.Http.HttpMessageHandler { readonly Func> _respond; public MockLlm(Func> respond) => _respond = respond; protected override Task SendAsync( System.Net.Http.HttpRequestMessage request, CancellationToken ct) => _respond(request); public static System.Net.Http.HttpResponseMessage ToolCall(string id, string name, object args) => Json(System.Text.Json.JsonSerializer.Serialize(new { choices = new[] { new { message = new { role = "assistant", content = (string?)null, tool_calls = new[] { new { id, type = "function", function = new { name, arguments = System.Text.Json.JsonSerializer.Serialize(args) } } } } }, }, usage = new { prompt_tokens = 10, completion_tokens = 5, total_tokens = 15 }, })); static System.Net.Http.HttpResponseMessage Json(string body) => new(System.Net.HttpStatusCode.OK) { Content = new System.Net.Http.StringContent(body, System.Text.Encoding.UTF8, "application/json"), }; } ``` ### 2. The realistic case — an exhausted pool stops a turn before it calls the model at all [Section titled “2. The realistic case — an exhausted pool stops a turn before it calls the model at all”](#2-the-realistic-case--an-exhausted-pool-stops-a-turn-before-it-calls-the-model-at-all) ```csharp using Toolnexus.Agents; // The mock is never actually invoked in this example — the budget check runs BEFORE the HTTP call. var llm = new MockLlm(_ => throw new Exception("should never be called — budget is pre-exhausted")); var rt = new AgentRuntime(new RuntimeOptions { ApiKey = "test-key", Handler = llm, BaseUrl = "http://runtime.invalid", Style = "openai", Model = "mock", Registry = new Dictionary { ["capped"] = new() { Name = "capped", Does = "has zero tool-call budget", Budget = new Budget { MaxToolCalls = 0 } }, }, }); var h = rt.Spawn(rt.Root, "capped").Handle!; if (h.PoolToolCalls != 0) throw new Exception($"expected a carved pool of 0, got {h.PoolToolCalls}"); var result = await rt.RunTurnAsync(h, "do anything"); if (result.Status != "incomplete") throw new Exception(result.Status); if (result.Text != "budget exhausted (toolCalls); partial work preserved") throw new Exception(result.Text); await rt.CloseAsync(rt.Root); Console.WriteLine($"ok: {result.Status} — {result.Text}"); sealed class MockLlm : System.Net.Http.HttpMessageHandler { readonly Func> _respond; public MockLlm(Func> respond) => _respond = respond; protected override Task SendAsync( System.Net.Http.HttpRequestMessage request, CancellationToken ct) => _respond(request); } ``` ### 3. Full surface — hierarchical carve, then the live ledger draining every ancestor [Section titled “3. Full surface — hierarchical carve, then the live ledger draining every ancestor”](#3-full-surface--hierarchical-carve-then-the-live-ledger-draining-every-ancestor) ```csharp using Toolnexus.Agents; var llm = new MockLlm(_ => Task.FromResult(MockLlm.Text("filed"))); var rt = new AgentRuntime(new RuntimeOptions { ApiKey = "test-key", Handler = llm, BaseUrl = "http://runtime.invalid", Style = "openai", Model = "mock", Registry = new Dictionary { ["coordinator"] = new() { Name = "coordinator", Does = "coordinates", Budget = new Budget { MaxTokens = 100 } }, ["helper"] = new() { Name = "helper", Does = "helps", Budget = new Budget { MaxTokens = 1_000 } }, // wider than the parent }, }); var coordinator = rt.Spawn(rt.Root, "coordinator").Handle!; var helper = rt.Spawn(coordinator, "helper").Handle!; // Carve: effective = min(own, parent remaining) — helper asked for 1000 but the parent only has 100. if (helper.PoolTokens != 100) throw new Exception($"expected the carve to cap at 100, got {helper.PoolTokens}"); await rt.RunTurnAsync(helper, "help out"); // The mock spends 8 tokens (usage.total_tokens) — the roll-up drains EVERY ancestor's pool live. if (helper.PoolTokens != 92) throw new Exception($"helper: expected 92, got {helper.PoolTokens}"); if (coordinator.PoolTokens != 92) throw new Exception($"coordinator: expected 92 (rolled up), got {coordinator.PoolTokens}"); if (coordinator.UsageTotal != 8) throw new Exception($"expected the coordinator's ledger to see the child's spend, got {coordinator.UsageTotal}"); await rt.CloseAsync(rt.Root); Console.WriteLine($"ok: carved to {helper.PoolTokens + 8}, drained to helper={helper.PoolTokens} coordinator={coordinator.PoolTokens}"); sealed class MockLlm : System.Net.Http.HttpMessageHandler { readonly Func> _respond; public MockLlm(Func> respond) => _respond = respond; protected override Task SendAsync( System.Net.Http.HttpRequestMessage request, CancellationToken ct) => _respond(request); public static System.Net.Http.HttpResponseMessage Text(string content) => new(System.Net.HttpStatusCode.OK) { Content = new System.Net.Http.StringContent(System.Text.Json.JsonSerializer.Serialize(new { choices = new[] { new { message = new { role = "assistant", content } } }, usage = new { prompt_tokens = 5, completion_tokens = 3, total_tokens = 8 }, }), System.Text.Encoding.UTF8, "application/json"), }; } ``` Caution `MaxTurns`, `MaxConcurrent`, and `MaxDepth` fall back to `6`, `8`, and `3` respectively when unset — every OTHER field (`MaxTokens`, `MaxToolCalls`, `MaxWallMs`, `MaxChildren`) is **unlimited** when unset. An agent with no `Budget` at all still gets the turn-loop defaults; it just has no token/call/wall/children ceiling. ## Fields [Section titled “Fields”](#fields) | Field | Type | Unset default | What it caps | | --------------- | ------- | ------------- | ------------------------------------------------------------------------------------------- | | `MaxTurns` | `int?` | `6` | The client loop’s turns for one spawn’s turn — hit without a final answer ⇒ `"incomplete"`. | | `MaxTokens` | `long?` | unlimited | Total tokens across this handle’s turns; ledger rolls up to every ancestor. | | `MaxToolCalls` | `long?` | unlimited | Total tool calls across this handle’s turns. | | `MaxWallMs` | `long?` | unlimited | Wall-clock deadline from spawn time, min’d with the parent’s. | | `MaxChildren` | `int?` | unlimited | Live children this handle may hold at once. | | `MaxConcurrent` | `int?` | `8` | Children of this handle allowed to run a turn simultaneously (excess wakes queue FIFO). | | `MaxDepth` | `int?` | `3` | How many spawn levels deep this handle’s subtree may go. | ## See also [Section titled “See also”](#see-also) * [`Agent`](/api/csharp/runtime/agent/) — Define a sub-agent with its own toolkit, prompt and budget, callable as a tool by its parent. * [`AgentRuntime`](/api/csharp/runtime/runtime/) — The six host verbs that drive sub-agents, plus the read-only list and inspect views. * [`Handle`](/api/csharp/runtime/handle/) — The state machine for one spawned agent: pending, running, suspended, done. # Handle > The state machine for one spawned agent: pending, running, suspended, done. — C#. **C#** · package `Toolnexus` · [SPEC §7D](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [Agents/Handle.cs](https://github.com/muthuishere/toolnexus/blob/main/csharp/src/Toolnexus/Agents/Handle.cs) ```csharp public sealed class Handle { public string Id { get; } public AgentDef Def { get; } public Handle? Parent { get; } public int Depth { get; } public string State { get; } // "idle" | "running" | "suspended" | "closed" public long PoolTokens { get; } public long PoolToolCalls { get; } public DateTimeOffset? WallDeadline { get; } public long UsageTotal { get; } public int TurnsTotal { get; } public long ToolCallsTotal { get; } public Request? PendingRequest { get; } public AgentResult? LastResult { get; } public int InboxCount { get; } public IReadOnlyList Children { get; } public string ConvId { get; } // == Id — the conversation store key } ``` One live spawned agent. `Handle`s are never constructed directly — `AgentRuntime.Spawn` returns them — and every mutable field is a read-only view onto state the runtime owns under its lock. State machine: `idle → running → (idle | suspended | closed)`; `suspended → running` **only** via the `Answer` to `PendingRequest`. Ids are deterministic and parent-scoped (`root/coordinator.1/explore.2`), never random. ## When to use it [Section titled “When to use it”](#when-to-use-it) You hold a `Handle` — from `Spawn`, from a `Team` graph, from `List()`/`Inspect()` — and want to read its live state: is it `idle`/`running`/`suspended`/`closed`, how many tokens/tool calls has it spent, what is it waiting on, how many children does it have. `Handle`s are **capabilities**: you may `Post`/`Wake`/`Interrupt`/`Close` a handle you hold, and `Wait` only on one you spawned yourself. ## Why this and not the alternative [Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative) Read the state; drive it through AgentRuntime `Handle` itself exposes no verbs — every state transition goes through the six [`AgentRuntime`](/api/csharp/runtime/runtime/) methods, which take a `Handle` as their first argument. Treat `Handle` as the read model and `AgentRuntime` as the write model, the same split `HandleView` (from `List`/`Inspect`) offers at arm’s length for read-only tooling. ## Examples [Section titled “Examples”](#examples) ### 1. The smallest useful call — the state machine, idle → running → idle [Section titled “1. The smallest useful call — the state machine, idle → running → idle”](#1-the-smallest-useful-call--the-state-machine-idle--running--idle) ```csharp using Toolnexus.Agents; var llm = new MockLlm(_ => Task.FromResult(MockLlm.Text("all clear"))); var rt = new AgentRuntime(new RuntimeOptions { ApiKey = "test-key", Handler = llm, BaseUrl = "http://runtime.invalid", Style = "openai", Model = "mock", Registry = new Dictionary { ["sentry"] = new() { Name = "sentry", Does = "checks status" } }, }); var h = rt.Spawn(rt.Root, "sentry").Handle!; if (h.State != "idle") throw new Exception(h.State); if (h.Id != "root/sentry.1") throw new Exception(h.Id); // deterministic, parent-scoped var wait = rt.WaitAsync(h); rt.Wake(h, "status check"); var result = await wait; if (h.State != "idle") throw new Exception(h.State); // running -> idle once the turn settles if (h.TurnsTotal != 1) throw new Exception($"expected 1 turn, got {h.TurnsTotal}"); if (h.LastResult?.Text != "all clear") throw new Exception(h.LastResult?.Text); var settledState = h.State; await rt.CloseAsync(rt.Root); Console.WriteLine($"ok: {h.Id} idle -> running -> {settledState} ({h.TurnsTotal} turn)"); sealed class MockLlm : System.Net.Http.HttpMessageHandler { readonly Func> _respond; public MockLlm(Func> respond) => _respond = respond; protected override Task SendAsync( System.Net.Http.HttpRequestMessage request, CancellationToken ct) => _respond(request); public static System.Net.Http.HttpResponseMessage Text(string content) => new(System.Net.HttpStatusCode.OK) { Content = new System.Net.Http.StringContent(System.Text.Json.JsonSerializer.Serialize(new { choices = new[] { new { message = new { role = "assistant", content } } }, usage = new { prompt_tokens = 5, completion_tokens = 3, total_tokens = 8 }, }), System.Text.Encoding.UTF8, "application/json"), }; } ``` ### 2. The realistic case — a live ledger: usage rolls up as the turn runs [Section titled “2. The realistic case — a live ledger: usage rolls up as the turn runs”](#2-the-realistic-case--a-live-ledger-usage-rolls-up-as-the-turn-runs) ```csharp using Toolnexus.Agents; var llm = new MockLlm(_ => Task.FromResult(MockLlm.Text("report filed"))); var rt = new AgentRuntime(new RuntimeOptions { ApiKey = "test-key", Handler = llm, BaseUrl = "http://runtime.invalid", Style = "openai", Model = "mock", Registry = new Dictionary { ["clerk"] = new() { Name = "clerk", Does = "files reports", Budget = new Budget { MaxTokens = 1_000 } }, }, }); var h = rt.Spawn(rt.Root, "clerk").Handle!; if (h.PoolTokens != 1_000) throw new Exception($"expected the carved pool, got {h.PoolTokens}"); await rt.RunTurnAsync(h, "file the quarterly report"); // The mock's usage (8 tokens) drained live out of the pool — the roll-up IS the ledger. if (h.PoolTokens != 992) throw new Exception($"expected 992 remaining, got {h.PoolTokens}"); if (h.UsageTotal != 8) throw new Exception($"expected 8 spent, got {h.UsageTotal}"); if (h.LastResult?.Status != "done") throw new Exception(h.LastResult?.Status); await rt.CloseAsync(rt.Root); Console.WriteLine($"ok: pool {h.PoolTokens}/1000 remaining after {h.UsageTotal} tokens spent"); sealed class MockLlm : System.Net.Http.HttpMessageHandler { readonly Func> _respond; public MockLlm(Func> respond) => _respond = respond; protected override Task SendAsync( System.Net.Http.HttpRequestMessage request, CancellationToken ct) => _respond(request); public static System.Net.Http.HttpResponseMessage Text(string content) => new(System.Net.HttpStatusCode.OK) { Content = new System.Net.Http.StringContent(System.Text.Json.JsonSerializer.Serialize(new { choices = new[] { new { message = new { role = "assistant", content } } }, usage = new { prompt_tokens = 5, completion_tokens = 3, total_tokens = 8 }, }), System.Text.Encoding.UTF8, "application/json"), }; } ``` ### 3. Full surface — `Interrupt` mid-turn restores the inbox (never a kill) [Section titled “3. Full surface — Interrupt mid-turn restores the inbox (never a kill)”](#3-full-surface--interrupt-mid-turn-restores-the-inbox-never-a-kill) ```csharp using Toolnexus.Agents; // A mutable holder so the mock can be re-armed for the SECOND turn with a fresh gate — reusing // one already-raced TaskCompletionSource.Task across turns is not the pattern; a fresh gate per // turn is (mirrors the port's own AgentRuntimeTests.cs). var gate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var llm = new MockLlm(async ct => { // parks here until the test lets it through, or Interrupt cancels this very token await gate.Task.WaitAsync(ct); return MockLlm.Text("finished late"); }); var rt = new AgentRuntime(new RuntimeOptions { ApiKey = "test-key", Handler = llm, BaseUrl = "http://runtime.invalid", Style = "openai", Model = "mock", Registry = new Dictionary { ["stalled"] = new() { Name = "stalled", Does = "gets interrupted" } }, }); var h = rt.Spawn(rt.Root, "stalled").Handle!; rt.Post(h, new InboxItem("external", "webhook", "urgent update")); var wait = rt.WaitAsync(h); rt.Wake(h, "start working"); await Task.Delay(60); // let the turn genuinely reach the still-parked mock LLM call if (h.State != "running") throw new Exception($"expected the turn mid-flight, got {h.State}"); rt.Interrupt(h); // NOT a kill: running -> idle, drained inbox restored var result = await wait; if (result.Status != "interrupted") throw new Exception(result.Status); if (h.State != "idle") throw new Exception(h.State); // the posted item was drained INTO the aborted turn, then restored on interrupt — never lost. if (h.InboxCount != 1) throw new Exception($"expected the inbox item restored, got {h.InboxCount}"); // Not a kill — a subsequent wake runs normally. Re-arm with a fresh, already-released gate. gate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); gate.TrySetResult(); var result2 = await rt.RunTurnAsync(h, "work again"); if (result2.Status != "done" || result2.Text != "finished late") throw new Exception($"{result2.Status} {result2.Text}"); await rt.CloseAsync(rt.Root); Console.WriteLine($"ok: interrupted -> idle (inbox intact), then a normal wake -> {result2.Status}"); sealed class MockLlm : System.Net.Http.HttpMessageHandler { readonly Func> _respond; public MockLlm(Func> respond) => _respond = respond; protected override Task SendAsync( System.Net.Http.HttpRequestMessage request, CancellationToken ct) => _respond(ct); public static System.Net.Http.HttpResponseMessage Text(string content) => new(System.Net.HttpStatusCode.OK) { Content = new System.Net.Http.StringContent(System.Text.Json.JsonSerializer.Serialize(new { choices = new[] { new { message = new { role = "assistant", content } } }, usage = new { prompt_tokens = 5, completion_tokens = 3, total_tokens = 8 }, }), System.Text.Encoding.UTF8, "application/json"), }; } ``` Caution On a `suspended` handle, `Interrupt` means something different: it cancels the pending `Request` itself (the operator’s escape hatch out of a stuck approval/auth wait) and returns straight to `idle` — there is no in-flight HTTP call to abort in that state. ## Fields [Section titled “Fields”](#fields) | Field | Type | What it is | | ------------------------------ | ----------------------- | --------------------------------------------------------------------------- | | `Id` | `string` | Deterministic, parent-scoped (`root/coordinator.1/explore.2`). | | `State` | `string` | `"idle"` \| `"running"` \| `"suspended"` \| `"closed"`. | | `PoolTokens` / `PoolToolCalls` | `long` | Remaining budget — live ledger, drains on every ancestor as usage rolls up. | | `WallDeadline` | `DateTimeOffset?` | From `Budget.MaxWallMs` at spawn, min’d against the parent’s. | | `PendingRequest` | `Request?` | Set only while `State == "suspended"`. | | `LastResult` | `AgentResult?` | The most recently completed turn’s outcome. | | `Children` | `IReadOnlyList` | Direct children only. | | `ConvId` | `string` | `== Id` — the key into the runtime’s `ConversationStore`. | ## See also [Section titled “See also”](#see-also) * [`Agent`](/api/csharp/runtime/agent/) — Define a sub-agent with its own toolkit, prompt and budget, callable as a tool by its parent. * [`AgentRuntime`](/api/csharp/runtime/runtime/) — The six host verbs that drive sub-agents, plus the read-only list and inspect views. * [`AgentTypes.Budget`](/api/csharp/runtime/budgets/) — Cap tool calls and wall-clock per agent and per team, enforced while the run is in flight. # 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. — C#. **C#** · package `Toolnexus` · [SPEC §7D](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) ```csharp namespace Toolnexus.Agents; public delegate string? Guardrail(LlmClient.BeforeToolEvent ev); // "allow"/null permits; any other string denies with that reason public sealed record Verdict(bool Ok, string Reason = ""); public sealed class Completion { public Func Verify { get; set; } public int MaxAttempts { get; set; } // REQUIRED — an unbounded verify loop is a DoS on the bill } public sealed class LoopRunOptions { public string? Model { get; set; } // per-call model override; null = the agent's } public sealed record Outcome( string Text, string Status, string? StoppedBy, int Attempts, int Turns, LlmClient.RunResult? Result); public sealed class Loop { public const string InheritModel = "inherit"; public string Status { get; } // observed only: "idle" | "running" | "error" | ... public int Turns { get; } public IReadOnlyList Unsupported { get; } // = LoopSupport.LoopUnsupported(agent.Spec) public Task RunAsync(string prompt, LoopRunOptions? opts = null); } // Opened via the extension on Agent: public sealed class Agent { public Loop Loop(LlmClient.Options options, Toolkit toolkit); } public static class LoopSupport { public static LlmClient.Hooks? GuardedHooks(List? guardrails, LlmClient.Hooks? hooks); public static IReadOnlyList LoopUnsupported(AgentSpec? spec); // "tools" | "team" | "waitFor" | "onMetric" public static Verdict AllTodosDone(LlmClient.RunResult result); // the built-in completion verifier } ``` `agent.Loop(options, toolkit).RunAsync(prompt)` 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.RunAsync`](/api/csharp/runtime/agent/), with unsupported spec fields (`tools`, `team`, `waitFor`, `onMetric`) named explicitly on `Loop.Unsupported` rather than silently dropped. The placement law this encodes (see the narrative [Harness & loop](/harness/) page for the full rationale): the `AgentSpec` answers *“may it?”* — capability, ceilings, fixed per problem; `LoopRunOptions` answers *“with what?”* — the model for this one call; the `Loop` answers *“did it?”* — status, turns, stop reason, purely observed. None of them answers *“is it right?”* — that is what `Completion.Verify` is for. ## When to use it [Section titled “When to use it”](#when-to-use-it) Reach for `agent.Loop(options, toolkit)` instead of `agent.RunAsync(...)` when a task needs a **completion gate**: the model claiming “done” is not enough, and you need the run retried (with the failure reason fed back as the next prompt) until an independent check passes, up to `Completion.MaxAttempts`. The built-in [`LoopSupport.AllTodosDone`](#membership) verifier is the common case — it reads the shipped `todowrite` tool’s plan and requires every item checked before letting the run report `"done"`. Reach for `Guardrails` on the same spec when you need a *policy* check (“may this tool run at all?”) rather than a completion check (“is the finished work correct?”) — the two compose: guardrails run on every tool call, `Completion` runs once the model stops calling tools. ## Why this and not the alternative [Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative) C# has no standalone harness() factory function Five of the eight ports (js, python, golang, elixir, clojure) ship a `harness(spec)`/`Harness(spec)` identity function that returns its argument unchanged — a name for something that already exists, so a spec built through it and one written inline are indistinguishable. **C# and Java skip it.** `AgentSpec` is a plain class built through object initializers (or a fluent builder), and that construction style already makes an identity-wrapper redundant: there is nothing `harness(spec)` could do here that `new AgentSpec { ... }` does not already do just as plainly. This is a deliberate per-language idiom difference, not a missing feature — see the [Harness & loop](/harness/) narrative page, which documents both ports as intentionally skipping the factory. Loop.Unsupported is advisory, not enforced — nothing throws at construction `Team`, `Uses` (tools), `WaitFor` and `OnMetric` describe a toolkit and a runtime that `Loop` does not own — it is driven over a toolkit and client the **caller** built, so a spec’s `Team` declared for delegation silently cannot delegate when driven through `Loop` instead of the §7D `AgentRuntime`. `Loop.Unsupported` names exactly which of those fields the spec set that this Loop cannot honour — but by decision, nothing throws at construction, because a construction-time error would break every existing caller. A host that cares checks `Unsupported` and decides; one that doesn’t, doesn’t have to. Honoured on the Loop path regardless: `Soul`/`SoulFile`, `Guardrails`, `Hooks`, `Completion`, `Model`, `Budget.MaxTurns`. ## Examples [Section titled “Examples”](#examples) ### 1. The smallest useful call — a plain agent with no completion gate [Section titled “1. The smallest useful call — a plain agent with no completion gate”](#1-the-smallest-useful-call--a-plain-agent-with-no-completion-gate) ```csharp using Toolnexus; using Toolnexus.Agents; var handler = new Scripted(Say("hello")); var toolkitOpts = new Toolkit.Options { Builtins = false }; await using var tk = await Toolkit.CreateAsync(toolkitOpts); var agent = new Toolnexus.Agents.Agent("plain", new AgentSpec { Does = "answers" }); var clientOpts = new LlmClient.Options { BaseUrl = "http://scripted.invalid", Style = "openai", Model = "test-model", ApiKey = "unused", HttpHandler = handler, }; var outcome = await agent.Loop(clientOpts, tk).RunAsync("hi"); if (outcome.Status != "done") throw new Exception(outcome.Status); if (outcome.Text != "hello") throw new Exception(outcome.Text); if (outcome.Attempts != 1) throw new Exception($"attempts: {outcome.Attempts}"); // no Completion => one attempt if (outcome.StoppedBy != null) throw new Exception("no gate => no StoppedBy reason"); Console.WriteLine($"ok: {outcome.Status} after {outcome.Attempts} attempt(s), turns={outcome.Turns}"); static string Say(string content) => $"{{\"role\":\"assistant\",\"content\":\"{content}\"}}"; sealed class Scripted : HttpMessageHandler { readonly string[] _messages; int _i; public Scripted(params string[] messages) => _messages = messages; protected override async Task SendAsync( HttpRequestMessage request, CancellationToken cancellationToken) { var message = _messages[Math.Min(_i, _messages.Length - 1)]; _i++; var finish = message.Contains("tool_calls") ? "tool_calls" : "stop"; var json = "{\"choices\":[{\"index\":0,\"message\":" + message + ",\"finish_reason\":\"" + finish + "\"}]," + "\"usage\":{\"prompt_tokens\":1,\"completion_tokens\":1,\"total_tokens\":2}}"; return new HttpResponseMessage(System.Net.HttpStatusCode.OK) { Content = new StringContent(json, System.Text.Encoding.UTF8, "application/json"), }; } } ``` ### 2. The realistic case — `Completion.Verify` gates on the built-in todo plan, and retries [Section titled “2. The realistic case — Completion.Verify gates on the built-in todo plan, and retries”](#2-the-realistic-case--completionverify-gates-on-the-built-in-todo-plan-and-retries) ```csharp using Toolnexus; using Toolnexus.Agents; // Attempt 1 ends with an open item ("proofread" not done). The gate fails it, feeds the reason // back as the next prompt, and attempt 2's fully-checked plan passes. var handler = new Scripted( CallTodo(("1", "draft", true), ("2", "proofread", false)), Say("I think I am finished"), CallTodo(("1", "draft", true), ("2", "proofread", true)), Say("all done")); await using var tk = await Toolkit.CreateAsync(new Toolkit.Options { Builtins = new Dictionary { ["tools"] = new Dictionary { ["todowrite"] = true, ["bash"] = false, ["read"] = false, ["write"] = false, ["edit"] = false, ["glob"] = false, ["grep"] = false, ["webfetch"] = false, ["apply_patch"] = false, ["question"] = false, }, }, }); var agent = new Toolnexus.Agents.Agent("gated", new AgentSpec { Does = "plans", // AllTodosDone is structural, not domain: it counts unchecked boxes in the LATEST todowrite // call, judged against the ACCUMULATED tool calls across every attempt. Completion = new Completion { Verify = LoopSupport.AllTodosDone, MaxAttempts = 3 }, }); var clientOpts = new LlmClient.Options { BaseUrl = "http://scripted.invalid", Style = "openai", Model = "test-model", ApiKey = "unused", HttpHandler = handler, }; var outcome = await agent.Loop(clientOpts, tk).RunAsync("do the thing"); if (outcome.Status != "done") throw new Exception(outcome.Status); if (outcome.Attempts < 2) throw new Exception($"expected a retry, got {outcome.Attempts}"); Console.WriteLine($"ok: {outcome.Status} after {outcome.Attempts} attempts (one retry on the open todo)"); static string Say(string content) => $"{{\"role\":\"assistant\",\"content\":\"{content}\"}}"; static string CallTodo(params (string Id, string Text, bool Done)[] todos) { var items = string.Join(",", todos.Select(t => $"{{\\\"id\\\":\\\"{t.Id}\\\",\\\"text\\\":\\\"{t.Text}\\\",\\\"completed\\\":{(t.Done ? "true" : "false")}}}")); return "{\"role\":\"assistant\",\"tool_calls\":[{\"id\":\"t1\",\"type\":\"function\"," + "\"function\":{\"name\":\"todowrite\",\"arguments\":\"{\\\"todos\\\":[" + items + "]}\"}}]}"; } sealed class Scripted : HttpMessageHandler { readonly string[] _messages; int _i; public Scripted(params string[] messages) => _messages = messages; protected override async Task SendAsync( HttpRequestMessage request, CancellationToken cancellationToken) { var message = _messages[Math.Min(_i, _messages.Length - 1)]; _i++; var finish = message.Contains("tool_calls") ? "tool_calls" : "stop"; var json = "{\"choices\":[{\"index\":0,\"message\":" + message + ",\"finish_reason\":\"" + finish + "\"}]," + "\"usage\":{\"prompt_tokens\":1,\"completion_tokens\":1,\"total_tokens\":2}}"; return new HttpResponseMessage(System.Net.HttpStatusCode.OK) { Content = new StringContent(json, System.Text.Encoding.UTF8, "application/json"), }; } } ``` ### 3. Full surface — an unverifiable run stops loudly, `MaxAttempts` is required, and `Unsupported` names the gap [Section titled “3. Full surface — an unverifiable run stops loudly, MaxAttempts is required, and Unsupported names the gap”](#3-full-surface--an-unverifiable-run-stops-loudly-maxattempts-is-required-and-unsupported-names-the-gap) ```csharp using Toolnexus; using Toolnexus.Agents; await using var tk = await Toolkit.CreateAsync(new Toolkit.Options { Builtins = false }); var clientOpts = new LlmClient.Options { BaseUrl = "http://scripted.invalid", Style = "openai", Model = "test-model", ApiKey = "unused", HttpHandler = new Scripted(Say("done!")), }; // (a) A completion check that never passes exhausts MaxAttempts and reports "incomplete" with // Limit == "completion" — structured, so a caller branches on Limit rather than parsing StoppedBy. var never = new Toolnexus.Agents.Agent("never", new AgentSpec { Does = "never verifies", Completion = new Completion { Verify = _ => new Verdict(false, "always red"), MaxAttempts = 2 }, }); var outcome = await never.Loop(clientOpts, tk).RunAsync("go"); if (outcome.Status != "incomplete") throw new Exception(outcome.Status); if (outcome.Attempts != 2) throw new Exception($"attempts: {outcome.Attempts}"); if (outcome.Result!.Limit != "completion") throw new Exception(outcome.Result.Limit); if (!outcome.StoppedBy!.Contains("always red")) throw new Exception(outcome.StoppedBy); // (b) MaxAttempts is REQUIRED — an unbounded verify loop is a denial-of-service on the caller's bill. var bad = new Toolnexus.Agents.Agent("bad", new AgentSpec { Does = "x", Completion = new Completion { Verify = _ => new Verdict(true), MaxAttempts = 0 }, }); try { await bad.Loop(clientOpts, tk).RunAsync("go"); throw new Exception("expected ArgumentException for MaxAttempts = 0"); } catch (ArgumentException) { /* expected */ } // (c) Unsupported names exactly which spec fields THIS Loop cannot honour — advisory, not thrown. var delegator = new Toolnexus.Agents.Agent("delegator", new AgentSpec { Does = "x", Team = new List { new("child", new AgentSpec { Does = "helps" }) }, }); var unsupported = delegator.Loop(clientOpts, tk).Unsupported; if (!unsupported.Contains("team")) throw new Exception(string.Join(",", unsupported)); Console.WriteLine($"ok: incomplete after {outcome.Attempts} attempts (limit={outcome.Result.Limit}); " + $"MaxAttempts=0 rejected; delegator's Loop cannot honour: {string.Join(", ", unsupported)}"); static string Say(string content) => $"{{\"role\":\"assistant\",\"content\":\"{content}\"}}"; sealed class Scripted : HttpMessageHandler { readonly string[] _messages; int _i; public Scripted(params string[] messages) => _messages = messages; protected override async Task SendAsync( HttpRequestMessage request, CancellationToken cancellationToken) { var message = _messages[Math.Min(_i, _messages.Length - 1)]; _i++; var finish = message.Contains("tool_calls") ? "tool_calls" : "stop"; var json = "{\"choices\":[{\"index\":0,\"message\":" + message + ",\"finish_reason\":\"" + finish + "\"}]," + "\"usage\":{\"prompt_tokens\":1,\"completion_tokens\":1,\"total_tokens\":2}}"; return new HttpResponseMessage(System.Net.HttpStatusCode.OK) { Content = new StringContent(json, System.Text.Encoding.UTF8, "application/json"), }; } } ``` ## Members [Section titled “Members”](#members) | Member | What it is | | -------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `Guardrail` | `delegate string? Guardrail(LlmClient.BeforeToolEvent ev)` — a POLICY check, “may it?”, never “is it right?”. `"allow"` or `null` permits; any other string denies with that reason as the tool result. | | `Verdict(Ok, Reason)` | What `Completion.Verify` returns. `Reason` feeds back as the next retry’s prompt when `Ok` is false. | | `Completion.Verify` | `Func` — judges the run against the tool calls **accumulated across every attempt**, so an agent cannot escape the gate by declining to re-declare its plan on a retry. | | `Completion.MaxAttempts` | Required, `>= 1`. Exhausting it returns `Status = "incomplete"`, `Limit = "completion"`. | | `LoopRunOptions.Model` | Per-call model override. `"inherit"` and absence are treated identically — both mean “no opinion, use the spec’s/client’s default.” | | `Outcome` | `Text`, `Status` (reuses `AgentStatus`/`RunStatus` — no new strings minted), `StoppedBy` (human-readable, null when nothing stopped it early), `Attempts`, `Turns`, `Result` (the underlying `RunResult`). | | `Loop.Status` | Observed only: `"idle"` → `"running"` → `"idle"`/`"error"`/the run’s own non-done status. Never set by the caller. | | `Loop.Unsupported` | The spec fields this Loop cannot honour, from the closed vocabulary `"tools"` \| `"team"` \| `"waitFor"` \| `"onMetric"` — identical strings in all eight ports. Empty ⇒ the whole spec is honoured. | | `LoopSupport.GuardedHooks` | Compiles `AgentSpec.Guardrails` into one `BeforeTool` hook, first-deny-wins, composed ahead of any hook already set. | | `LoopSupport.AllTodosDone` | The built-in completion verifier: reads the shipped `todowrite` tool’s plan metadata, requires every item checked. No plan declared ⇒ nothing to verify ⇒ passes — an agent that never uses `todowrite` is never punished by this gate. | ## See also [Section titled “See also”](#see-also) * [Harness & loop](/harness/) — The narrative page: the placement law, the four-row table, and why the model is per-call, not per-loop. * [`Agent`](/api/csharp/runtime/agent/) — Define a sub-agent with its own toolkit, prompt and budget, callable as a tool by its parent. * [`AgentRuntime`](/api/csharp/runtime/runtime/) — The six host verbs that drive sub-agents, plus the read-only list and inspect views. * [`Handle`](/api/csharp/runtime/handle/) — The state machine for one spawned agent: pending, running, suspended, done. * [`Budget`](/api/csharp/runtime/budgets/) — Cap tool calls and wall-clock per agent and per team, enforced while the run is in flight. # AgentRuntime > The six host verbs that drive sub-agents, plus the read-only list and inspect views. — C#. **C#** · package `Toolnexus` · [SPEC §7D](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [Agents/AgentRuntime.cs](https://github.com/muthuishere/toolnexus/blob/main/csharp/src/Toolnexus/Agents/AgentRuntime.cs) ```csharp public sealed class AgentRuntime { public AgentRuntime(RuntimeOptions opts); public Handle Root { get; } public IConversationStore ConversationStore { get; } public SpawnResult Spawn(Handle parent, string defName, Budget? budget = null); public PostResult Post(Handle h, InboxItem item); public PostResult Wake(Handle h, string? prompt = null); public Task WaitAsync(Handle h, int? timeoutMs = null); public void Interrupt(Handle h); public Task CloseAsync(Handle h, bool force = false, string? reason = null); public List List(); public HandleView? Inspect(string id); } ``` The full host surface behind [`Agent`](/api/csharp/runtime/agent/): six verbs — `Spawn`/`Post`/`Wake`/`WaitAsync`/`Interrupt`/`CloseAsync` — over a tree of [`Handle`](/api/csharp/runtime/handle/)s rooted at `Root`, plus read-only `List`/`Inspect` views. Everything rides shipped machinery: the `LlmClient` loop, §10 pending/`WaitFor`, `Toolkit` views, one runtime-wide `ConversationStore` (conversation id = handle id), and — via `RuntimeOptions.Clock` — an injectable `TimeProvider` so fixtures can run on a virtual clock. ## When to use it [Section titled “When to use it”](#when-to-use-it) You need more than `Agent.RunAsync`’s one-shot spawn→wake→wait→close: posting to a running agent’s inbox mid-turn, interrupting a stuck run, keeping several handles alive across many turns, or inspecting the whole tree (`List`) for a dashboard or ops tool. One `AgentRuntime` owns the whole tree — spawn every agent you need from `Root` (or from each other) rather than building a runtime per agent. ## Why this and not the alternative [Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative) Agent.RunAsync is this, pre-wired for the common case `Agent.RunAsync` builds exactly this — a fresh `AgentRuntime`, one spawn, one wake, one wait, one close — and throws the runtime away afterward. Reach for `AgentRuntime` directly the moment you need the tree to **outlive** a single prompt: a long-running coordinator, a dashboard polling `List()`, or a caller that wants to `Interrupt` a handle it doesn’t own the whole lifecycle of. ## 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) ```csharp using Toolnexus.Agents; var llm = new MockLlm(_ => Task.FromResult(MockLlm.Text("done"))); var rt = new AgentRuntime(new RuntimeOptions { ApiKey = "test-key", Handler = llm, BaseUrl = "http://runtime.invalid", Style = "openai", Model = "mock", Registry = new Dictionary { ["worker"] = new() { Name = "worker", Does = "does work" } }, }); var spawned = rt.Spawn(rt.Root, "worker"); if (spawned.Error != null) throw new Exception(spawned.Error); var wait = rt.WaitAsync(spawned.Handle!); rt.Wake(spawned.Handle!, "get to work"); var result = await wait; if (result.Status != "done") throw new Exception(result.Status); if (result.Text != "done") throw new Exception(result.Text); await rt.CloseAsync(rt.Root); Console.WriteLine($"ok: {spawned.Handle!.Id} -> {result.Status}"); sealed class MockLlm : System.Net.Http.HttpMessageHandler { readonly Func> _respond; public MockLlm(Func> respond) => _respond = respond; protected override Task SendAsync( System.Net.Http.HttpRequestMessage request, CancellationToken ct) => _respond(request); public static System.Net.Http.HttpResponseMessage Text(string content) => new(System.Net.HttpStatusCode.OK) { Content = new System.Net.Http.StringContent(System.Text.Json.JsonSerializer.Serialize(new { choices = new[] { new { message = new { role = "assistant", content } } }, usage = new { prompt_tokens = 5, completion_tokens = 3, total_tokens = 8 }, }), System.Text.Encoding.UTF8, "application/json"), }; } ``` ### 2. The realistic case — `Post` to the inbox, then `Wake` drains it [Section titled “2. The realistic case — Post to the inbox, then Wake drains it”](#2-the-realistic-case--post-to-the-inbox-then-wake-drains-it) ```csharp using Toolnexus.Agents; var seenInbox = ""; var llm = new MockLlm(async req => { var body = await req.Content!.ReadAsStringAsync(); if (body.Contains("[inbox:")) seenInbox = body; return MockLlm.Text("processed"); }); var rt = new AgentRuntime(new RuntimeOptions { ApiKey = "test-key", Handler = llm, BaseUrl = "http://runtime.invalid", Style = "openai", Model = "mock", Registry = new Dictionary { ["watcher"] = new() { Name = "watcher", Does = "watches events" } }, }); var spawned = rt.Spawn(rt.Root, "watcher"); var h = spawned.Handle!; // Post is unsolicited — it appends to the inbox with NO state transition. var posted = rt.Post(h, new InboxItem("external", "webhook", "order #42 shipped")); if (!posted.Ok) throw new Exception(posted.Error); if (h.InboxCount != 1) throw new Exception($"expected 1 queued item, got {h.InboxCount}"); // Wake drains the whole inbox into ONE turn's input. var wait = rt.WaitAsync(h); rt.Wake(h); var result = await wait; if (result.Status != "done") throw new Exception(result.Status); if (h.InboxCount != 0) throw new Exception("inbox should be drained after a completed turn"); if (!seenInbox.Contains("order #42 shipped")) throw new Exception("the LLM never saw the posted item"); if (!seenInbox.Contains("from=external channel=webhook")) throw new Exception("missing provenance"); await rt.CloseAsync(rt.Root); Console.WriteLine($"ok: {result.Text}, inbox drained with provenance intact"); sealed class MockLlm : System.Net.Http.HttpMessageHandler { readonly Func> _respond; public MockLlm(Func> respond) => _respond = respond; protected override Task SendAsync( System.Net.Http.HttpRequestMessage request, CancellationToken ct) => _respond(request); public static System.Net.Http.HttpResponseMessage Text(string content) => new(System.Net.HttpStatusCode.OK) { Content = new System.Net.Http.StringContent(System.Text.Json.JsonSerializer.Serialize(new { choices = new[] { new { message = new { role = "assistant", content } } }, usage = new { prompt_tokens = 5, completion_tokens = 3, total_tokens = 8 }, }), System.Text.Encoding.UTF8, "application/json"), }; } ``` ### 3. Full surface — `List`/`Inspect`, then a graceful `CloseAsync(Root)` tears down the whole tree [Section titled “3. Full surface — List/Inspect, then a graceful CloseAsync(Root) tears down the whole tree”](#3-full-surface--listinspect-then-a-graceful-closeasyncroot-tears-down-the-whole-tree) ```csharp using Toolnexus.Agents; var llm = new MockLlm(_ => Task.FromResult(MockLlm.Text("ok"))); var rt = new AgentRuntime(new RuntimeOptions { ApiKey = "test-key", Handler = llm, BaseUrl = "http://runtime.invalid", Style = "openai", Model = "mock", Registry = new Dictionary { ["scout-a"] = new() { Name = "scout-a", Does = "scouts region A" }, ["scout-b"] = new() { Name = "scout-b", Does = "scouts region B" }, }, }); var a = rt.Spawn(rt.Root, "scout-a").Handle!; var b = rt.Spawn(rt.Root, "scout-b").Handle!; // Register both waits BEFORE waking (no completion race), then wake both siblings. var waitA = rt.WaitAsync(a); var waitB = rt.WaitAsync(b); rt.Wake(a, "scout region A"); rt.Wake(b, "scout region B"); await Task.WhenAll(waitA, waitB); var views = rt.List(); if (views.Count != 2) throw new Exception($"expected 2 handles, got {views.Count}"); if (views.All(v => v.State != "idle")) throw new Exception("expected both settled to idle"); var inspected = rt.Inspect(a.Id); if (inspected == null || inspected.Id != a.Id) throw new Exception("Inspect should find the spawned handle"); // Stop-all = CloseAsync(Root) — leaf-first, graceful. await rt.CloseAsync(rt.Root); if (a.State != "closed" || b.State != "closed") throw new Exception($"{a.State},{b.State}"); if (!rt.TraceContains($"{a.Id}: spawned")) throw new Exception("expected a spawn trace line"); Console.WriteLine($"ok: {views.Count} handles listed, both closed via CloseAsync(Root)"); sealed class MockLlm : System.Net.Http.HttpMessageHandler { readonly Func> _respond; public MockLlm(Func> respond) => _respond = respond; protected override Task SendAsync( System.Net.Http.HttpRequestMessage request, CancellationToken ct) => _respond(request); public static System.Net.Http.HttpResponseMessage Text(string content) => new(System.Net.HttpStatusCode.OK) { Content = new System.Net.Http.StringContent(System.Text.Json.JsonSerializer.Serialize(new { choices = new[] { new { message = new { role = "assistant", content } } }, usage = new { prompt_tokens = 5, completion_tokens = 3, total_tokens = 8 }, }), System.Text.Encoding.UTF8, "application/json"), }; } ``` Caution `Wake` on a `running` or `suspended` handle is **not an error** — it’s a no-op on `running` (the inbox just drains into the next turn) and buffers on `suspended` (only the matching `Answer` resumes it). Only `Post`’s inbox-full case and `Spawn`’s cap checks are loud, synchronous rejections. ## Parameters (`RuntimeOptions`) [Section titled “Parameters (RuntimeOptions)”](#parameters-runtimeoptions) | Field | Type | What it is | | ---------------------------------------- | ------------------------------ | ---------------------------------------------------------------------------------------------------- | | `Handler` | `HttpMessageHandler?` | The LLM HTTP seam — inject a scripted handler for tests; `null` ⇒ the real HTTP stack. | | `Registry` | `Dictionary` | name → definition; `Agent.Registry()` builds this from a team graph, or hand-roll it as shown above. | | `InboxCap` | `int` | Bounded inbox per handle; default `8`. | | `MaxConcurrentTurns` | `int` | Global cap on simultaneous LLM HTTP calls; default `8`. | | `ShutdownMs` | `int` | Graceful-close bound before `CloseAsync` escalates to interrupt; default `200`. | | `Store` | `IConversationStore?` | The one runtime-wide store; `null` ⇒ a fresh in-memory store. | | `Clock` | `TimeProvider?` | Injectable clock for every timer/timeout; `null` ⇒ the system clock. | | `BaseUrl` / `Style` / `Model` / `ApiKey` | — | LLM endpoint defaults; a def’s `Model == "inherit"` resolves to `Model` here. | ## See also [Section titled “See also”](#see-also) * [`Agent`](/api/csharp/runtime/agent/) — Define a sub-agent with its own toolkit, prompt and budget, callable as a tool by its parent. * [`Handle`](/api/csharp/runtime/handle/) — The state machine for one spawned agent: pending, running, suspended, done. * [`AgentTypes.Budget`](/api/csharp/runtime/budgets/) — Cap tool calls and wall-clock per agent and per team, enforced while the run is in flight. # The task tool — model-facing delegation > The opt-in tool that lets the model itself spawn a teammate. Default OFF. — C#. **C#** · package `Toolnexus` · [SPEC §7D](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [Agents/AgentRuntime.cs](https://github.com/muthuishere/toolnexus/blob/main/csharp/src/Toolnexus/Agents/AgentRuntime.cs) ```csharp task { agent: string, prompt: string } -> string ``` No constructible symbol in C# — it's behavior, not a type Only JS exposes a standalone, constructible `agents.taskTool` value. In every other port — C# included — `task` is a tool the runtime **auto-registers** on a handle’s toolkit the moment that handle’s `AgentDef.Team` (or `AgentSpec.Team`) is non-empty: see `AgentRuntime.RunTurnBodyAsync`’s `if (h.Def.Team is { Count: > 0 }) toolkit.Register(TaskTool(h));` and the private `TaskTool` builder right below it. There is nothing to import or call directly — set `Team`, and the model gains the tool. `task = spawn → wake → wait → close`, fused into one call. The parent’s transcript gains **exactly one** tool message per call, however long the child’s own run takes — the child runs on a fresh transcript, and its usage rolls up into the parent’s ledger. The tool’s description lists **only** the caller’s own `Team` (sorted by name, each entry `": "`); a target outside the team is a loud tool error, never silently ignored. ## When to use it [Section titled “When to use it”](#when-to-use-it) You want the **model itself** to decide when to delegate — not you, wiring a fixed pipeline of `task` tool calls in your own code. Give an `AgentDef`/`AgentSpec` a `Team`, and its model gains a `task` tool scoped to exactly that team; nothing else changes about how you build or run the agent. ## Why this and not the alternative [Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative) Team is opt-in, and recursion doesn't leak `Team = null` (the default) means the agent gets **no** `task` tool at all — recursion is opt-in, never default. A child only gets its own `task` tool if its *own* def declares a `Team`; delegation depth is bounded by each level’s explicit choice, plus [`Budget.MaxDepth`](/api/csharp/runtime/budgets/). If you want to call a sub-agent from your *own* code instead of letting the model decide, drop it in as a plain tool with [`Agent.AsTool`](/api/csharp/runtime/agent/) instead. ## Examples [Section titled “Examples”](#examples) ### 1. The smallest useful call — the model delegates, once [Section titled “1. The smallest useful call — the model delegates, once”](#1-the-smallest-useful-call--the-model-delegates-once) ```csharp using Toolnexus.Agents; var llm = new MockLlm(req => { var body = ReadBody(req); if (body.Contains("\"role\":\"tool\"")) return Task.FromResult(MockLlm.Text($"coordinator says: {ExtractLastToolContent(body)}")); return Task.FromResult(MockLlm.ToolCall("t1", "task", new { agent = "explorer", prompt = "find the treasure" })); }); var rt = new AgentRuntime(new RuntimeOptions { ApiKey = "test-key", Handler = llm, BaseUrl = "http://runtime.invalid", Style = "openai", Model = "mock", Registry = new Dictionary { ["coordinator"] = new() { Name = "coordinator", Does = "delegates exploration", Team = new List { "explorer" }, // non-empty Team ⇒ the runtime registers `task` }, ["explorer"] = new() { Name = "explorer", Does = "explores and reports back" }, }, }); var h = rt.Spawn(rt.Root, "coordinator").Handle!; var wait = rt.WaitAsync(h); rt.Wake(h, "find the treasure via the team"); var result = await wait; if (result.Status != "done") throw new Exception(result.Status); if (!result.Text.Contains("coordinator says:")) throw new Exception(result.Text); await rt.CloseAsync(rt.Root); Console.WriteLine($"ok: {result.Text}"); static string ReadBody(System.Net.Http.HttpRequestMessage req) => req.Content!.ReadAsStringAsync().GetAwaiter().GetResult(); static string ExtractLastToolContent(string body) { using var doc = System.Text.Json.JsonDocument.Parse(body); var msgs = doc.RootElement.GetProperty("messages").EnumerateArray().ToList(); return msgs.Last(m => m.GetProperty("role").GetString() == "tool").GetProperty("content").GetString() ?? ""; } sealed class MockLlm : System.Net.Http.HttpMessageHandler { readonly Func> _respond; public MockLlm(Func> respond) => _respond = respond; protected override Task SendAsync( System.Net.Http.HttpRequestMessage request, CancellationToken ct) => _respond(request); public static System.Net.Http.HttpResponseMessage Text(string content) => Json(System.Text.Json.JsonSerializer.Serialize(new { choices = new[] { new { message = new { role = "assistant", content } } }, usage = new { prompt_tokens = 5, completion_tokens = 3, total_tokens = 8 }, })); public static System.Net.Http.HttpResponseMessage ToolCall(string id, string name, object args) => Json(System.Text.Json.JsonSerializer.Serialize(new { choices = new[] { new { message = new { role = "assistant", content = (string?)null, tool_calls = new[] { new { id, type = "function", function = new { name, arguments = System.Text.Json.JsonSerializer.Serialize(args) } } } } }, }, usage = new { prompt_tokens = 10, completion_tokens = 5, total_tokens = 15 }, })); static System.Net.Http.HttpResponseMessage Json(string body) => new(System.Net.HttpStatusCode.OK) { Content = new System.Net.Http.StringContent(body, System.Text.Encoding.UTF8, "application/json"), }; } ``` ### 2. The realistic case — two parallel calls to the same agent+prompt reattach, not duplicate [Section titled “2. The realistic case — two parallel calls to the same agent+prompt reattach, not duplicate”](#2-the-realistic-case--two-parallel-calls-to-the-same-agentprompt-reattach-not-duplicate) ```csharp using Toolnexus.Agents; var childRuns = 0; var llm = new MockLlm(req => { var body = ReadBody(req); var model = System.Text.Json.JsonDocument.Parse(body).RootElement.GetProperty("model").GetString(); if (model == "researcher-model") { childRuns++; return Task.FromResult(MockLlm.Text("42")); } if (body.Contains("\"role\":\"tool\"")) return Task.FromResult(MockLlm.Text("got two answers back")); // ONE turn issues TWO parallel task calls for the SAME agent+prompt (SPEC §7D: parallel task // calls in one turn run concurrently; identical key ⇒ reattach to one child, never a duplicate spawn). return Task.FromResult(MockLlm.ToolCalls( ("c1", "task", new { agent = "researcher", prompt = "what is the answer?" }), ("c2", "task", new { agent = "researcher", prompt = "what is the answer?" }))); }); var rt = new AgentRuntime(new RuntimeOptions { ApiKey = "test-key", Handler = llm, BaseUrl = "http://runtime.invalid", Style = "openai", Model = "coordinator-model", Registry = new Dictionary { ["coordinator"] = new() { Name = "coordinator", Does = "asks twice, by accident", Team = new List { "researcher" } }, ["researcher"] = new() { Name = "researcher", Does = "researches an answer", Model = "researcher-model" }, }, }); var h = rt.Spawn(rt.Root, "coordinator").Handle!; var wait = rt.WaitAsync(h); rt.Wake(h, "ask the researcher, twice"); var result = await wait; if (result.Status != "done") throw new Exception(result.Status); // Reattachment by task key (agent+prompt) is the ONLY idempotency mechanism — only one child spawned. if (h.Children.Count != 1) throw new Exception($"expected 1 reattached child, got {h.Children.Count}"); if (childRuns != 1) throw new Exception($"expected the researcher model to run exactly once, ran {childRuns} time(s)"); await rt.CloseAsync(rt.Root); Console.WriteLine($"ok: {result.Text} (1 child spawned for 2 identical task calls, ran {childRuns} time)"); static string ReadBody(System.Net.Http.HttpRequestMessage req) => req.Content!.ReadAsStringAsync().GetAwaiter().GetResult(); sealed class MockLlm : System.Net.Http.HttpMessageHandler { readonly Func> _respond; public MockLlm(Func> respond) => _respond = respond; protected override Task SendAsync( System.Net.Http.HttpRequestMessage request, CancellationToken ct) => _respond(request); public static System.Net.Http.HttpResponseMessage Text(string content) => Json(System.Text.Json.JsonSerializer.Serialize(new { choices = new[] { new { message = new { role = "assistant", content } } }, usage = new { prompt_tokens = 5, completion_tokens = 3, total_tokens = 8 }, })); public static System.Net.Http.HttpResponseMessage ToolCalls(params (string id, string name, object args)[] calls) => Json(System.Text.Json.JsonSerializer.Serialize(new { choices = new[] { new { message = new { role = "assistant", content = (string?)null, tool_calls = calls.Select(c => new { id = c.id, type = "function", function = new { name = c.name, arguments = System.Text.Json.JsonSerializer.Serialize(c.args) } }).ToArray() } }, }, usage = new { prompt_tokens = 10, completion_tokens = 5, total_tokens = 15 }, })); static System.Net.Http.HttpResponseMessage Json(string body) => new(System.Net.HttpStatusCode.OK) { Content = new System.Net.Http.StringContent(body, System.Text.Encoding.UTF8, "application/json"), }; } ``` ### 3. Full surface — a target outside the team is a loud, named error [Section titled “3. Full surface — a target outside the team is a loud, named error”](#3-full-surface--a-target-outside-the-team-is-a-loud-named-error) ```csharp using Toolnexus.Agents; var llm = new MockLlm(req => { var body = ReadBody(req); if (body.Contains("\"role\":\"tool\"")) return Task.FromResult(MockLlm.Text($"acknowledged: {ExtractLastToolContent(body)}")); // targets an agent that exists in the REGISTRY but not in THIS agent's team. return Task.FromResult(MockLlm.ToolCall("r1", "task", new { agent = "stranger", prompt = "hi" })); }); var rt = new AgentRuntime(new RuntimeOptions { ApiKey = "test-key", Handler = llm, BaseUrl = "http://runtime.invalid", Style = "openai", Model = "mock", Registry = new Dictionary { ["rogue"] = new() { Name = "rogue", Does = "tries to go off-team", Team = new List { "ally" } }, ["ally"] = new() { Name = "ally", Does = "a legitimate teammate" }, ["stranger"] = new() { Name = "stranger", Does = "not on rogue's team" }, }, }); var h = rt.Spawn(rt.Root, "rogue").Handle!; var wait = rt.WaitAsync(h); rt.Wake(h, "try to reach stranger"); var result = await wait; if (result.Status != "done") throw new Exception(result.Status); if (!result.Text.Contains("not in this agent's team")) throw new Exception(result.Text); if (!result.Text.Contains("ally")) throw new Exception("the error should list the actual team"); if (h.Children.Count != 0) throw new Exception("an out-of-team target must never spawn a child"); await rt.CloseAsync(rt.Root); Console.WriteLine($"ok: {result.Text}"); static string ReadBody(System.Net.Http.HttpRequestMessage req) => req.Content!.ReadAsStringAsync().GetAwaiter().GetResult(); static string ExtractLastToolContent(string body) { using var doc = System.Text.Json.JsonDocument.Parse(body); var msgs = doc.RootElement.GetProperty("messages").EnumerateArray().ToList(); return msgs.Last(m => m.GetProperty("role").GetString() == "tool").GetProperty("content").GetString() ?? ""; } sealed class MockLlm : System.Net.Http.HttpMessageHandler { readonly Func> _respond; public MockLlm(Func> respond) => _respond = respond; protected override Task SendAsync( System.Net.Http.HttpRequestMessage request, CancellationToken ct) => _respond(request); public static System.Net.Http.HttpResponseMessage Text(string content) => Json(System.Text.Json.JsonSerializer.Serialize(new { choices = new[] { new { message = new { role = "assistant", content } } }, usage = new { prompt_tokens = 5, completion_tokens = 3, total_tokens = 8 }, })); public static System.Net.Http.HttpResponseMessage ToolCall(string id, string name, object args) => Json(System.Text.Json.JsonSerializer.Serialize(new { choices = new[] { new { message = new { role = "assistant", content = (string?)null, tool_calls = new[] { new { id, type = "function", function = new { name, arguments = System.Text.Json.JsonSerializer.Serialize(args) } } } } }, }, usage = new { prompt_tokens = 10, completion_tokens = 5, total_tokens = 15 }, })); static System.Net.Http.HttpResponseMessage Json(string body) => new(System.Net.HttpStatusCode.OK) { Content = new System.Net.Http.StringContent(body, System.Text.Encoding.UTF8, "application/json"), }; } ``` Caution A suspending child agent presents to `task`’s caller as an ordinary §10 suspending tool result — no new pending type. If the child’s own run halts on out-of-band input, the `task` call returns a `Pending` result carrying the child’s `Request`, and a re-invoked `task` with the same key **reattaches** to the still-suspended child rather than spawning a second one. ## Shape [Section titled “Shape”](#shape) | Field | What it is | | -------- | ------------------------------------------------------------------------------------------------------------------------------------------ | | `agent` | Must be one of the caller’s `Team` names — anything else ⇒ an `isError` result listing the real team, never a spawn. | | `prompt` | The task text; together with `agent` this is the **reattachment key** — a repeat call with the same pair reuses the existing child. | | Result | `Text` (or `[] ` when the child didn’t finish `"done"`), `IsError` from the child, `Metadata { agent, turns, totalTokens }`. | ## See also [Section titled “See also”](#see-also) * [`Agent`](/api/csharp/runtime/agent/) — Define a sub-agent with its own toolkit, prompt and budget, callable as a tool by its parent. * [`AgentRuntime`](/api/csharp/runtime/runtime/) — The six host verbs that drive sub-agents, plus the read-only list and inspect views. * [`Handle`](/api/csharp/runtime/handle/) — The state machine for one spawned agent: pending, running, suspended, done. * [`AgentTypes.Budget`](/api/csharp/runtime/budgets/) — Cap tool calls and wall-clock per agent and per team, enforced while the run is in flight. # AgentStatus.All / RunStatus.All / StopLimit.All / StopLimit.FromPool > 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. — C#. **C#** · package `Toolnexus` · [SPEC §7D](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) ```csharp // The §7D agent/task status vocabulary — SEVEN values. public static class AgentStatus { public const string Done = "done"; public const string Pending = "pending"; public const string Incomplete = "incomplete"; public const string Interrupted = "interrupted"; public const string Closed = "closed"; public const string Timeout = "timeout"; public const string Error = "error"; public static readonly IReadOnlyList All; // all seven, in SPEC order } // The §8 CLIENT status vocabulary — THREE values. Distinct from AgentStatus despite sharing the // field name "status": a §8 run that exceeds its budget throws RunTimeoutException instead of // ever reporting "timeout" here. public static class RunStatus { public const string Done = "done"; public const string Pending = "pending"; public const string Incomplete = "incomplete"; public static readonly IReadOnlyList All; // all three, in SPEC order } // The values AgentResult.Limit and LlmClient.RunResult.Limit take — NINE values, closed vocabulary. public static class StopLimit { public const string MaxTurns = "maxTurns"; public const string MaxTokens = "maxTokens"; public const string MaxToolCalls = "maxToolCalls"; public const string MaxWallMs = "maxWallMs"; public const string MaxChildren = "maxChildren"; public const string MaxConcurrent = "maxConcurrent"; public const string MaxDepth = "maxDepth"; public const string Completion = "completion"; public const string Timeout = "timeout"; public static readonly IReadOnlyList All; // all nine, in SPEC order internal static string FromPool(string pool); // maps an internal pool name onto the canonical spelling } ``` 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. `AgentStatus`, `RunStatus` and `StopLimit` are all **public**, and every value in each `.All` list is also a public named constant — so a host writes `StopLimit.MaxWallMs` rather than the bare string `"maxWallMs"`, and can still assert against `.All` when it wants the whole closed set. ## When to use it [Section titled “When to use it”](#when-to-use-it) Branch on `AgentResult.Status`/`AgentRuntime` results against `AgentStatus.*` (the seven-value §7D vocabulary — task-level: spawn, run turn, resume, close). Branch on `LlmClient.RunResult.Status` against `RunStatus.*` (the three-value §8 vocabulary — a single client run). Read `AgentResult.Limit`/`RunResult.Limit` against `StopLimit.*` to learn *which* budget field, or which non-budget reason, actually stopped a run — never by parsing the human-readable `Text`. Use `StopLimit.FromPool` only if you are implementing a runtime-level integration that talks in this port’s *internal* pool names (`"tokens"`, `"toolCalls"`, `"wallMs"`, `"children"`, `"concurrent"`, `"depth"`) and needs the canonical spelling a host actually branches on. ## Why this and not the alternative [Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative) AgentStatus and RunStatus share the field name \`status\` but are NOT the same vocabulary This is the exact collision issue #92.1 reported: `AgentStatus` has **seven** values and includes `"timeout"`; `RunStatus` has **three** values (`done`/`pending`/`incomplete`) and never includes `"timeout"` — a §8 client run that exceeds `Options.TimeoutMs` throws [`LlmClient.RunTimeoutException`](/api/csharp/client/resilience/) instead. Every value in `RunStatus.All` also appears in `AgentStatus.All` (the three-value set is a subset of the seven), but the reverse is not true — do not assume a `RunResult.Status` and an `AgentResult.Status` are interchangeable just because both are typed `string`. No public invariant-predicate helper is shipped on purpose There is deliberately no `StopLimit.IsValid(string)` or similar. Shipping a predicate would make the library’s own closed-vocabulary rule a *dependency* a host has to call correctly; instead the invariant — every `.All` value has a matching named constant, and nothing outside `.All` ever reaches `AgentResult.Limit`/`RunResult.Limit` — is enforced inside the library itself and verified by this port’s own test suite, not exposed as API surface. ## Examples [Section titled “Examples”](#examples) ### 1. The smallest useful call — branch on a closed vocabulary, not a string literal [Section titled “1. The smallest useful call — branch on a closed vocabulary, not a string literal”](#1-the-smallest-useful-call--branch-on-a-closed-vocabulary-not-a-string-literal) ```csharp using Toolnexus.Agents; string DescribeStatus(string status) => status switch { var s when s == AgentStatus.Done => "finished", var s when s == AgentStatus.Pending => "waiting on a human", var s when s == AgentStatus.Timeout => "ran out of wall-clock time", _ => $"other: {status}", }; if (DescribeStatus(AgentStatus.Done) != "finished") throw new Exception("Done"); if (DescribeStatus(AgentStatus.Timeout) != "ran out of wall-clock time") throw new Exception("Timeout"); Console.WriteLine($"ok: {AgentStatus.All.Count} agent statuses, {RunStatus.All.Count} run statuses"); ``` ### 2. The realistic case — the two vocabularies are distinct despite the shared field name [Section titled “2. The realistic case — the two vocabularies are distinct despite the shared field name”](#2-the-realistic-case--the-two-vocabularies-are-distinct-despite-the-shared-field-name) ```csharp using Toolnexus.Agents; // Seven agent/task statuses; three client/run statuses; "timeout" is agent-only. if (AgentStatus.All.Count != 7) throw new Exception(AgentStatus.All.Count.ToString()); if (RunStatus.All.Count != 3) throw new Exception(RunStatus.All.Count.ToString()); if (!AgentStatus.All.Contains(AgentStatus.Timeout)) throw new Exception("Timeout must be an agent status"); if (RunStatus.All.Contains("timeout")) throw new Exception("\"timeout\" must NOT be a run status — the #92.1 collision"); // Every RunStatus value is also a valid AgentStatus value — the three-value set is a subset. foreach (var s in RunStatus.All) if (!AgentStatus.All.Contains(s)) throw new Exception($"{s} missing from AgentStatus.All"); Console.WriteLine($"ok: RunStatus {{{string.Join(", ", RunStatus.All)}}} ⊂ AgentStatus {{{string.Join(", ", AgentStatus.All)}}}"); ``` ### 3. Full surface — the nine-value limit vocabulary and `FromPool`’s internal→canonical mapping [Section titled “3. Full surface — the nine-value limit vocabulary and FromPool’s internal→canonical mapping”](#3-full-surface--the-nine-value-limit-vocabulary-and-frompools-internalcanonical-mapping) ```csharp using Toolnexus.Agents; // The CLOSED vocabulary, in SPEC order. var expected = new[] { "maxTurns", "maxTokens", "maxToolCalls", "maxWallMs", "maxChildren", "maxConcurrent", "maxDepth", "completion", "timeout", }; if (!expected.SequenceEqual(StopLimit.All)) throw new Exception(string.Join(",", StopLimit.All)); // Every named constant matches a value in .All — a host can write StopLimit.MaxWallMs safely. if (StopLimit.MaxWallMs != "maxWallMs") throw new Exception(StopLimit.MaxWallMs); if (!StopLimit.All.Contains(StopLimit.MaxWallMs)) throw new Exception("constant/list mismatch"); // FromPool is INTERNAL — this illustrates the mapping it performs, not a call your own code makes. // (tokens -> maxTokens, toolCalls -> maxToolCalls, wallMs -> maxWallMs, children -> maxChildren, // concurrent -> maxConcurrent, depth -> maxDepth). An unrecognised pool name passes through // unchanged rather than throwing, so a caller can still see what stopped the run. var mapping = new Dictionary { ["tokens"] = "maxTokens", ["toolCalls"] = "maxToolCalls", ["wallMs"] = "maxWallMs", ["children"] = "maxChildren", ["concurrent"] = "maxConcurrent", ["depth"] = "maxDepth", }; foreach (var (pool, canonical) in mapping) if (!StopLimit.All.Contains(canonical)) throw new Exception($"{pool} -> {canonical} not in StopLimit.All"); Console.WriteLine($"ok: {StopLimit.All.Count} canonical limit strings, {mapping.Count} internal pool names mapped"); ``` ## Fields [Section titled “Fields”](#fields) | Type | Member | Value | | -------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `AgentStatus` | `Done` / `Pending` / `Incomplete` / `Interrupted` / `Closed` / `Timeout` / `Error` | `"done"` / `"pending"` / `"incomplete"` / `"interrupted"` / `"closed"` / `"timeout"` / `"error"` | | `AgentStatus.All` | — | all seven, in SPEC order | | `RunStatus` | `Done` / `Pending` / `Incomplete` | `"done"` / `"pending"` / `"incomplete"` | | `RunStatus.All` | — | all three, in SPEC order — never `"timeout"` | | `StopLimit` | `MaxTurns` / `MaxTokens` / `MaxToolCalls` / `MaxWallMs` / `MaxChildren` / `MaxConcurrent` / `MaxDepth` / `Completion` / `Timeout` | the seven `Budget` fields plus the two non-budget stops | | `StopLimit.All` | — | all nine, in SPEC order — the only values `AgentResult.Limit`/`RunResult.Limit` may carry | | `StopLimit.FromPool(pool)` | `internal` | maps this runtime’s internal pool name (`"tokens"`, `"toolCalls"`, `"wallMs"`, `"children"`, `"concurrent"`, `"depth"`) onto the canonical spelling; an unrecognised name passes through unchanged | ## See also [Section titled “See also”](#see-also) * [`Agent`](/api/csharp/runtime/agent/) — Define a sub-agent with its own toolkit, prompt and budget, callable as a tool by its parent. * [`AgentRuntime`](/api/csharp/runtime/runtime/) — The six host verbs that drive sub-agents, plus the read-only list and inspect views. * [`Handle`](/api/csharp/runtime/handle/) — The state machine for one spawned agent: pending, running, suspended, done. * [`Budget`](/api/csharp/runtime/budgets/) — Cap tool calls and wall-clock per agent and per team, enforced while the run is in flight. # A2AServer.Start > Publish an Agent Card and answer JSON-RPC over the client loop — your toolkit becomes someone else's remote agent. — C#. **C#** · package `Toolnexus` · [SPEC §7B](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [A2AServer.cs](https://github.com/muthuishere/toolnexus/blob/main/csharp/src/Toolnexus/A2AServer.cs) ```csharp public Task ServeAsync(string addr, Toolkit.ServeOptions opts) ``` `Toolkit.ServeAsync` is the entry point apps call; under the hood it delegates to `A2AServer.StartAsync`, which stands up a minimal HTTP server that — when `opts.A2A` (or a top-level `a2a` config block) is present — mounts `GET /.well-known/agent-card.json` (built from the toolkit’s **skills**, never raw tools) and `POST /` (JSON-RPC 2.0: `SendMessage` submits a Task and fulfils it asynchronously via `opts.Client.RunAsync`/`AskAsync`; `GetTask` polls it). This is the exact inbound counterpart to [`A2A.Agent`](/api/csharp/agents/agent/) / [`A2A.AgentTools`](/api/csharp/agents/agent-tools/) — your toolkit becomes the peer being called. ## When to use it [Section titled “When to use it”](#when-to-use-it) You want your own toolkit reachable by any A2A-speaking caller — another toolnexus instance, a teammate’s agent, or a third-party A2A client — over plain HTTP, with no bespoke wire format to maintain. Each of the toolkit’s skills becomes one advertised A2A skill; the JSON-RPC fulfilment runs the same `client.RunAsync`/`AskAsync` loop a local caller would use. ## Why this and not the alternative [Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative) a2a absent ⇒ no A2A routes, not an error `ServeAsync` is generic — A2A is opt-in via `opts.A2A`. Omit it (and any top-level `a2a` config block) to run a plain HTTP toolkit host that only serves other profiles, e.g. [`McpServe`](/api/csharp/serve/mcp/)’s `POST /mcp`; every A2A-specific route 404s. Pass both `A2A` and `Mcp` together to serve the same toolkit over two protocols on one port. ## Examples [Section titled “Examples”](#examples) ### 1. The smallest useful call — the card, with defaults [Section titled “1. The smallest useful call — the card, with defaults”](#1-the-smallest-useful-call--the-card-with-defaults) ```csharp using Toolnexus; using var peerLlm = new Stub(ctx => Stub.Json(ctx, 200, """ {"choices":[{"message":{"content":"ok"}}],"usage":{"prompt_tokens":1,"completion_tokens":1,"total_tokens":2}} """)); await using var tk = await Toolkit.CreateAsync(new Toolkit.Options { Builtins = false, Skills = new List { new("greet", "says hello", "Say a friendly hello.") }, }); var client = LlmClient.Create(new LlmClient.Options { BaseUrl = peerLlm.BaseUrl, Style = "openai", Model = "mock", ApiKey = "k", }); var handle = await tk.ServeAsync("127.0.0.1:0", new Toolkit.ServeOptions { Client = client, A2A = new A2AConfig(), // all defaults }); using var http = new HttpClient(); var body = await http.GetStringAsync(handle.Url + "/.well-known/agent-card.json"); var card = Json.ParseObjectLoose(body); if (card["name"] as string != "toolnexus-agent") throw new Exception(card["name"] as string); if (card["protocolVersion"] as string != "0.3.0") throw new Exception(card["protocolVersion"] as string); if (card["url"] as string != handle.Url + "/") throw new Exception(card["url"] as string); await handle.StopAsync(); Console.WriteLine($"ok: {card["name"]} at {card["url"]}"); sealed class Stub : IDisposable { readonly System.Net.HttpListener _listener = new(); readonly CancellationTokenSource _cts = new(); public int Port { get; } public string BaseUrl => $"http://127.0.0.1:{Port}"; public Stub(Action handler) { var probe = new System.Net.Sockets.TcpListener(System.Net.IPAddress.Loopback, 0); probe.Start(); Port = ((System.Net.IPEndPoint)probe.LocalEndpoint).Port; probe.Stop(); _listener.Prefixes.Add($"http://127.0.0.1:{Port}/"); _listener.Start(); _ = Task.Run(async () => { while (!_cts.IsCancellationRequested) { System.Net.HttpListenerContext ctx; try { ctx = await _listener.GetContextAsync(); } catch { break; } try { handler(ctx); } catch { } } }); } public static void Json(System.Net.HttpListenerContext ctx, int status, string body) { var bytes = System.Text.Encoding.UTF8.GetBytes(body); ctx.Response.StatusCode = status; ctx.Response.ContentType = "application/json"; ctx.Response.ContentLength64 = bytes.Length; ctx.Response.OutputStream.Write(bytes, 0, bytes.Length); ctx.Response.OutputStream.Close(); } public void Dispose() { _cts.Cancel(); try { _listener.Stop(); } catch { } try { _listener.Close(); } catch { } } } ``` ### 2. The realistic case — a real local round trip through an outbound A2A caller [Section titled “2. The realistic case — a real local round trip through an outbound A2A caller”](#2-the-realistic-case--a-real-local-round-trip-through-an-outbound-a2a-caller) ```csharp using Toolnexus; using var peerLlm = new Stub(ctx => Stub.Json(ctx, 200, """ {"choices":[{"message":{"content":"the weather is sunny"}}],"usage":{"prompt_tokens":1,"completion_tokens":1,"total_tokens":2}} """)); await using var tk = await Toolkit.CreateAsync(new Toolkit.Options { Builtins = false, Skills = new List { new("weather", "reports the weather", "Report the weather.") }, }); var client = LlmClient.Create(new LlmClient.Options { BaseUrl = peerLlm.BaseUrl, Style = "openai", Model = "mock", ApiKey = "k", }); var handle = await tk.ServeAsync("127.0.0.1:0", new Toolkit.ServeOptions { Client = client, A2A = new A2AConfig { Name = "weather-desk" }, }); // Another toolkit, on the caller side, resolves and calls the served toolkit exactly like any // other A2A peer — SendMessage then poll GetTask under the hood. var tools = await A2A.AgentTools(new Agent { Card = handle.Url + "/.well-known/agent-card.json", PollEvery = 25, }); var tool = tools.Single(t => t.Name == "weather-desk_weather"); var result = await tool.ExecuteAsync(new Dictionary { ["task"] = "how's the weather?" }); if (result.IsError || result.Output != "the weather is sunny") throw new Exception(result.Output); await handle.StopAsync(); Console.WriteLine($"ok: {result.Output}"); sealed class Stub : IDisposable { readonly System.Net.HttpListener _listener = new(); readonly CancellationTokenSource _cts = new(); public int Port { get; } public string BaseUrl => $"http://127.0.0.1:{Port}"; public Stub(Action handler) { var probe = new System.Net.Sockets.TcpListener(System.Net.IPAddress.Loopback, 0); probe.Start(); Port = ((System.Net.IPEndPoint)probe.LocalEndpoint).Port; probe.Stop(); _listener.Prefixes.Add($"http://127.0.0.1:{Port}/"); _listener.Start(); _ = Task.Run(async () => { while (!_cts.IsCancellationRequested) { System.Net.HttpListenerContext ctx; try { ctx = await _listener.GetContextAsync(); } catch { break; } try { handler(ctx); } catch { } } }); } public static void Json(System.Net.HttpListenerContext ctx, int status, string body) { var bytes = System.Text.Encoding.UTF8.GetBytes(body); ctx.Response.StatusCode = status; ctx.Response.ContentType = "application/json"; ctx.Response.ContentLength64 = bytes.Length; ctx.Response.OutputStream.Write(bytes, 0, bytes.Length); ctx.Response.OutputStream.Close(); } public void Dispose() { _cts.Cancel(); try { _listener.Stop(); } catch { } try { _listener.Close(); } catch { } } } ``` ### 3. Full surface — `onTask` telemetry, and no `A2A` profile ⇒ 404 [Section titled “3. Full surface — onTask telemetry, and no A2A profile ⇒ 404”](#3-full-surface--ontask-telemetry-and-no-a2a-profile--404) ```csharp using Toolnexus; using var peerLlm = new Stub(ctx => Stub.Json(ctx, 200, """ {"choices":[{"message":{"content":"done"}}],"usage":{"prompt_tokens":3,"completion_tokens":2,"total_tokens":5}} """)); await using var tk = await Toolkit.CreateAsync(new Toolkit.Options { Builtins = false, Skills = new List { new("ping", "says done", "Reply done.") }, }); var client = LlmClient.Create(new LlmClient.Options { BaseUrl = peerLlm.BaseUrl, Style = "openai", Model = "mock", ApiKey = "k", }); var terminalStates = new List(); var handle = await tk.ServeAsync("127.0.0.1:0", new Toolkit.ServeOptions { Client = client, A2A = new A2AConfig { Name = "telemetry-desk" }, OnTask = ev => { terminalStates.Add(ev.State); if (ev.Result is not null && ev.State == "completed" && ev.Result.Usage.TotalTokens != 5) throw new Exception($"expected usage 5, got {ev.Result.Usage.TotalTokens}"); return Task.CompletedTask; }, }); var tools = await A2A.AgentTools(new Agent { Card = handle.Url + "/.well-known/agent-card.json", PollEvery = 25 }); var result = await tools.Single(t => t.Name == "telemetry-desk_ping") .ExecuteAsync(new Dictionary { ["task"] = "ping" }); if (result.IsError || result.Output != "done") throw new Exception(result.Output); if (!terminalStates.Contains("completed")) throw new Exception($"onTask never saw completed: {string.Join(",", terminalStates)}"); await handle.StopAsync(); // No A2A profile at all ⇒ the card route (and every A2A route) 404s. await using var barebones = await Toolkit.CreateAsync(new Toolkit.Options { Builtins = false }); var noA2a = await barebones.ServeAsync("127.0.0.1:0", new Toolkit.ServeOptions { Client = client }); using var http = new HttpClient(); var res = await http.GetAsync(noA2a.Url + "/.well-known/agent-card.json"); if (res.StatusCode != System.Net.HttpStatusCode.NotFound) throw new Exception(res.StatusCode.ToString()); await noA2a.StopAsync(); Console.WriteLine($"ok: onTask saw {string.Join(",", terminalStates)}; no-A2A-profile 404s"); sealed class Stub : IDisposable { readonly System.Net.HttpListener _listener = new(); readonly CancellationTokenSource _cts = new(); public int Port { get; } public string BaseUrl => $"http://127.0.0.1:{Port}"; public Stub(Action handler) { var probe = new System.Net.Sockets.TcpListener(System.Net.IPAddress.Loopback, 0); probe.Start(); Port = ((System.Net.IPEndPoint)probe.LocalEndpoint).Port; probe.Stop(); _listener.Prefixes.Add($"http://127.0.0.1:{Port}/"); _listener.Start(); _ = Task.Run(async () => { while (!_cts.IsCancellationRequested) { System.Net.HttpListenerContext ctx; try { ctx = await _listener.GetContextAsync(); } catch { break; } try { handler(ctx); } catch { } } }); } public static void Json(System.Net.HttpListenerContext ctx, int status, string body) { var bytes = System.Text.Encoding.UTF8.GetBytes(body); ctx.Response.StatusCode = status; ctx.Response.ContentType = "application/json"; ctx.Response.ContentLength64 = bytes.Length; ctx.Response.OutputStream.Write(bytes, 0, bytes.Length); ctx.Response.OutputStream.Close(); } public void Dispose() { _cts.Cancel(); try { _listener.Stop(); } catch { } try { _listener.Close(); } catch { } } } ``` Caution `SendMessage` returns the **submitted** Task immediately — fulfilment (`working` → `completed` | `failed`) runs asynchronously in the background. A caller must poll `GetTask` (or use [`A2A.AgentTools`](/api/csharp/agents/agent-tools/), which does this for you) rather than expect the first response to carry the final answer. ## Parameters [Section titled “Parameters”](#parameters) | Parameter | Type | What it is | | -------------------------- | ------------ | ------------------------------------------------------------------------------------------------------ | | `addr` | `string` | `host:port` to bind. `port` 0 (or omitted) picks a free ephemeral port. | | `opts.Client` | `LlmClient` | Fulfils each Task via `RunAsync`/`AskAsync` (contextId keyed, so a peer’s turns share a conversation). | | `opts.A2A` | `A2AConfig?` | Opt-in A2A profile — `null` (and no top-level `a2a` block) ⇒ no A2A routes mounted. | | `opts.OnTask` | `OnTask?` | Fires on each Task’s terminal state with the `RunResult` telemetry. | | `opts.Mcp` / `opts.OnCall` | — | The independent MCP inbound profile — see [`McpServe.Build`](/api/csharp/serve/mcp/). | ## See also [Section titled “See also”](#see-also) * [`A2AServer.BuildAgentCard`](/api/csharp/serve/agent-card/) — Construct the Agent Card that advertises your name, skills and endpoint. * [`A2AServer.FileTaskStore`](/api/csharp/serve/task-store/) — Persist inbound A2A tasks so a suspended request survives a restart. * [`McpServe.Build`](/api/csharp/serve/mcp/) — The inbound MCP profile: any MCP client can call your tools. * [`A2A.Agent`](/api/csharp/agents/agent/) — The outbound counterpart: call a served toolkit like this one from another agent. # A2AServer.BuildAgentCard > Construct the Agent Card that advertises your name, skills and endpoint. — C#. **C#** · package `Toolnexus` · [SPEC §7B](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [A2AServer.cs](https://github.com/muthuishere/toolnexus/blob/main/csharp/src/Toolnexus/A2AServer.cs) ```csharp public static Dictionary BuildAgentCard( A2AConfig cfg, IReadOnlyList skills, string url) ``` The pure function [`A2AServer.StartAsync`](/api/csharp/serve/a2a/) calls to answer `GET /.well-known/agent-card.json`: it turns an `A2AConfig` plus a toolkit’s skills into the plain `Dictionary` card body — name, description, version, protocol/capabilities, `skills[]` (each `{id, name, description}`, filtered to `cfg.Skills` when set), and `url`, the JSON-RPC endpoint. No I/O, no server — just the shaping. ## When to use it [Section titled “When to use it”](#when-to-use-it) You want the exact card JSON your served toolkit would publish, without actually starting an HTTP listener — for a snapshot test, to hand a card to a caller out-of-band, or to inspect what a given `A2AConfig` + skill set produces before wiring up `ServeAsync`. ## Why this and not the alternative [Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative) ServeAsync calls this for you `ServeAsync` builds the card on every `GET` request using the toolkit’s live skill list, so in an app you rarely call `BuildAgentCard` directly. Reach for it when you want the card **without** standing up a server, or need to unit-test card shaping (filtering, defaults, provider block) in isolation from HTTP. ## Examples [Section titled “Examples”](#examples) ### 1. The smallest useful call — defaults, no skills [Section titled “1. The smallest useful call — defaults, no skills”](#1-the-smallest-useful-call--defaults-no-skills) ```csharp using Toolnexus; var card = A2AServer.BuildAgentCard( new A2AConfig(), Array.Empty(), "http://localhost:9/"); if (card["name"] as string != "toolnexus-agent") throw new Exception(card["name"] as string); if (card["description"] as string != "") throw new Exception(card["description"] as string); if (card["version"] as string != "0.1.0") throw new Exception(card["version"] as string); if (card["protocolVersion"] as string != "0.3.0") throw new Exception(card["protocolVersion"] as string); var caps = (IDictionary)card["capabilities"]!; if (!Equals(caps["streaming"], false)) throw new Exception("streaming"); if (((IEnumerable)card["skills"]!).Any()) throw new Exception("expected no skills"); Console.WriteLine($"ok: {card["name"]} v{card["version"]}"); ``` ### 2. The realistic case — skills mapped, filtered to a subset [Section titled “2. The realistic case — skills mapped, filtered to a subset”](#2-the-realistic-case--skills-mapped-filtered-to-a-subset) ```csharp using Toolnexus; var skills = new List { new("greet", "says hello", "loc1", "body1"), new("farewell", "says goodbye", "loc2", "body2"), new("internal-only", "not for peers", "loc3", "body3"), }; var card = A2AServer.BuildAgentCard( new A2AConfig { Name = "desk", Description = "a friendly desk", Skills = new List { "greet", "farewell" } }, skills, "http://localhost:9/"); var names = ((IEnumerable)card["skills"]!) .Select(s => ((IDictionary)s!)["name"] as string) .OrderBy(n => n, StringComparer.Ordinal) .ToList(); if (!names.SequenceEqual(new[] { "farewell", "greet" })) throw new Exception(string.Join(",", names)); // id == name, never a raw tool name — the card advertises SKILLS. foreach (var s in (IEnumerable)card["skills"]!) { var d = (IDictionary)s!; if (!Equals(d["id"], d["name"])) throw new Exception("id should equal name"); } Console.WriteLine($"ok: {card["name"]} advertises {string.Join(",", names)}"); ``` ### 3. Full surface — a provider block, and the URL you pass through verbatim [Section titled “3. Full surface — a provider block, and the URL you pass through verbatim”](#3-full-surface--a-provider-block-and-the-url-you-pass-through-verbatim) ```csharp using Toolnexus; var skills = new List { new("audit", "runs an audit", "loc", "body") }; var card = A2AServer.BuildAgentCard( new A2AConfig { Name = "audit-desk", Description = "runs compliance audits", Version = "2.3.0", Provider = new A2AProvider { Organization = "Acme Corp", Url = "https://acme.example" }, }, skills, "http://127.0.0.1:5100/"); if (card["version"] as string != "2.3.0") throw new Exception(card["version"] as string); // url is exactly what the caller passed — BuildAgentCard never rewrites it. if (card["url"] as string != "http://127.0.0.1:5100/") throw new Exception(card["url"] as string); var provider = (IDictionary)card["provider"]!; if (provider["organization"] as string != "Acme Corp") throw new Exception(provider["organization"] as string); // no Skills filter set ⇒ every skill is advertised. var names = ((IEnumerable)card["skills"]!) .Select(s => ((IDictionary)s!)["name"] as string).ToList(); if (!names.SequenceEqual(new[] { "audit" })) throw new Exception(string.Join(",", names)); Console.WriteLine($"ok: {card["name"]} by {provider["organization"]}, url={card["url"]}"); ``` Caution `skills` here is the toolkit’s `SkillSource.SkillInfo` list — never raw `ITool`s. A card can never leak builtin/native/MCP tool names; that isolation is why the parameter type is deliberately narrower than “everything the toolkit knows about”. ## Parameters [Section titled “Parameters”](#parameters) | Parameter | Type | What it is | | --------- | -------------------------------------- | -------------------------------------------------------------------------- | | `cfg` | `A2AConfig` | Name/description/version/provider + optional `Skills` allowlist. | | `skills` | `IReadOnlyList` | The toolkit’s skill catalog — filtered to `cfg.Skills` when set, else all. | | `url` | `string` | The JSON-RPC endpoint to publish as `card.url` (peers `POST` here). | ## See also [Section titled “See also”](#see-also) * [`A2AServer.Start`](/api/csharp/serve/a2a/) — Publish an Agent Card and answer JSON-RPC over the client loop — your toolkit becomes someone else’s remote agent. * [`A2AServer.FileTaskStore`](/api/csharp/serve/task-store/) — Persist inbound A2A tasks so a suspended request survives a restart. * [`McpServe.Build`](/api/csharp/serve/mcp/) — The inbound MCP profile: any MCP client can call your tools. # McpServe.Build > The inbound MCP profile: any MCP client can call your tools. — C#. **C#** · package `Toolnexus` · [SPEC §7C](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [McpServe.cs](https://github.com/muthuishere/toolnexus/blob/main/csharp/src/Toolnexus/McpServe.cs) ```csharp public static McpServer BuildMcpServer( ITransport transport, IReadOnlyList tools, MCPServeConfig? cfg = null, OnCall? onCall = null) ``` The inbound mirror of [`A2AServer.Start`](/api/csharp/serve/a2a/) — but where A2A advertises **skills** and fulfils a Task through the whole client loop, this advertises the toolkit’s **unified tools** (every source: mcp · skill · native · http · builtin · a2a) and dispatches each `tools/call` straight to `ITool.ExecuteAsync`. There is no `client`, no Task, no `TaskStore` — the calling MCP client *is* the LLM host. Built on the official `ModelContextProtocol` SDK’s own server types (`ITransport`, `McpServer`); `Toolkit.ServeAsync` mounts the streamable-HTTP profile (`POST /mcp`) on top of it when `opts.Mcp` (or a top-level `mcpServer` config block) is present. ## When to use it [Section titled “When to use it”](#when-to-use-it) Turn your toolkit into a **universal MCP gateway**: aggregate N MCP servers, your skills, and your own native/HTTP tools behind one `Toolkit`, then re-expose the union as a single MCP server any MCP client (Claude Desktop, an IDE, another agent) can call. `BuildMcpServer` is the low-level constructor — call it directly when you have your own `ITransport` (e.g. an in-process pipe for tests, or your own hosting shell); most apps go through `Toolkit.ServeAsync(addr, new Toolkit.ServeOptions { Mcp = ... })`, which wires this to the shared streamable-HTTP endpoint. ## Why this and not the alternative [Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative) This exposes TOOLS; A2A exposes SKILLS through a Task Pick MCP-inbound when the caller wants direct tool calls with no LLM loop of its own on your side — the caller supplies the reasoning. Pick [A2A-inbound](/api/csharp/serve/a2a/) when you want your toolkit to run its **own** client loop per request and hand back only the final answer. The two profiles are independent and can be mounted on the same `ServeAsync` call — `mcp` and `a2a` are both optional and compose. ## Examples [Section titled “Examples”](#examples) ### 1. The smallest useful call — an in-process transport pair [Section titled “1. The smallest useful call — an in-process transport pair”](#1-the-smallest-useful-call--an-in-process-transport-pair) ```csharp using System.IO.Pipelines; using ModelContextProtocol; using ModelContextProtocol.Client; using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; using Toolnexus; var echo = NativeTool.Of("echo", "echo back text", new Dictionary { ["type"] = "object", ["properties"] = new Dictionary { ["text"] = new Dictionary { ["type"] = "string" } }, ["required"] = new List { "text" }, }, (IDictionary a) => a.TryGetValue("text", out var v) ? v?.ToString() ?? "" : ""); var clientToServer = new Pipe(); var serverToClient = new Pipe(); var serverTransport = new StreamServerTransport(clientToServer.Reader.AsStream(), serverToClient.Writer.AsStream()); var server = McpServe.BuildMcpServer(serverTransport, new List { echo }, new MCPServeConfig { Name = "gateway" }); var cts = new CancellationTokenSource(); var run = server.RunAsync(cts.Token); var clientTransport = new StreamClientTransport(clientToServer.Writer.AsStream(), serverToClient.Reader.AsStream()); var client = await McpClient.CreateAsync(clientTransport); if (client.ServerInfo.Name != "gateway") throw new Exception(client.ServerInfo.Name); var tools = await client.ListToolsAsync(); if (!tools.Any(t => t.Name == "echo")) throw new Exception("expected echo in tools/list"); await client.DisposeAsync(); cts.Cancel(); await server.DisposeAsync(); try { await run; } catch { } Console.WriteLine($"ok: {client.ServerInfo.Name} advertises {string.Join(",", tools.Select(t => t.Name))}"); ``` ### 2. The realistic case — `tools/call` dispatches straight to `ExecuteAsync` [Section titled “2. The realistic case — tools/call dispatches straight to ExecuteAsync”](#2-the-realistic-case--toolscall-dispatches-straight-to-executeasync) ```csharp using System.IO.Pipelines; using ModelContextProtocol.Client; using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; using Toolnexus; var echo = NativeTool.Of("echo", "echo back text", new Dictionary { ["type"] = "object", ["properties"] = new Dictionary { ["text"] = new Dictionary { ["type"] = "string" } }, ["required"] = new List { "text" }, }, (IDictionary a) => a.TryGetValue("text", out var v) ? v?.ToString() ?? "" : ""); var boom = NativeTool.Of("boom", "always throws", null, (IDictionary _) => throw new Exception("kaboom")); var calls = new List(); var clientToServer = new Pipe(); var serverToClient = new Pipe(); var serverTransport = new StreamServerTransport(clientToServer.Reader.AsStream(), serverToClient.Writer.AsStream()); var server = McpServe.BuildMcpServer(serverTransport, new List { echo, boom }, null, ev => { calls.Add(ev); return Task.CompletedTask; }); var cts = new CancellationTokenSource(); var run = server.RunAsync(cts.Token); var client = await McpClient.CreateAsync( new StreamClientTransport(clientToServer.Writer.AsStream(), serverToClient.Reader.AsStream())); var ok = await client.CallToolAsync("echo", new Dictionary { ["text"] = "hi" }); if (ok.IsError == true) throw new Exception("expected success"); if (((TextContentBlock)ok.Content[0]).Text != "hi") throw new Exception("wrong echo"); // A throwing tool becomes an isError result — never crashes the server. var bad = await client.CallToolAsync("boom", new Dictionary()); if (bad.IsError != true) throw new Exception("expected isError"); if (!((TextContentBlock)bad.Content[0]).Text.Contains("kaboom")) throw new Exception("expected kaboom in output"); if (calls.Count != 2) throw new Exception($"expected 2 onCall events, got {calls.Count}"); await client.DisposeAsync(); cts.Cancel(); await server.DisposeAsync(); try { await run; } catch { } Console.WriteLine($"ok: echo succeeded, boom isError=true, onCall saw {calls.Count} calls"); ``` ### 3. Full surface — the real streamable-HTTP endpoint via `Toolkit.ServeAsync` [Section titled “3. Full surface — the real streamable-HTTP endpoint via Toolkit.ServeAsync”](#3-full-surface--the-real-streamable-http-endpoint-via-toolkitserveasync) ```csharp using ModelContextProtocol.Client; using ModelContextProtocol.Protocol; using Toolnexus; var echo = NativeTool.Of("echo", "echo back text", new Dictionary { ["type"] = "object", ["properties"] = new Dictionary { ["text"] = new Dictionary { ["type"] = "string" } }, ["required"] = new List { "text" }, }, (IDictionary a) => a.TryGetValue("text", out var v) ? v?.ToString() ?? "" : ""); await using var tk = await Toolkit.CreateAsync(new Toolkit.Options { Builtins = false, ExtraTools = new List { echo }, }); // mcp.Tools narrows the advertised surface — even though the toolkit only has one tool here, // this is the filter a real gateway would use to expose a subset of a much larger toolkit. var srv = await tk.ServeAsync("127.0.0.1:0", new Toolkit.ServeOptions { Mcp = new MCPServeConfig { Name = "http-gateway", Tools = new[] { "echo" } }, }); var client = await McpClient.CreateAsync(new HttpClientTransport(new HttpClientTransportOptions { Endpoint = new Uri(srv.Url + "/mcp"), TransportMode = HttpTransportMode.StreamableHttp, })); if (client.ServerInfo.Name != "http-gateway") throw new Exception(client.ServerInfo.Name); var res = await client.CallToolAsync("echo", new Dictionary { ["text"] = "over http" }); var text = ((TextContentBlock)res.Content[0]).Text; if (text != "over http") throw new Exception(text); await client.DisposeAsync(); await srv.StopAsync(); Console.WriteLine($"ok: {client.ServerInfo.Name} -> {text}"); ``` Caution `mcp` absent (no `opts.Mcp`, no top-level `mcpServer` config block) mounts no `/mcp` route at all — an MCP-only `ServeAsync` call 404s everything else, and an A2A-only call answers `/mcp` with a JSON-RPC method-not-found error rather than an MCP result. The two profiles never shadow each other. ## Parameters [Section titled “Parameters”](#parameters) | Parameter | Type | What it is | | ----------- | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | | `transport` | `ITransport` | The SDK transport to serve over — an in-process stream pair for tests, or the streamable-HTTP transport `Toolkit.ServeAsync` builds per request. | | `tools` | `IReadOnlyList` | The tools to expose. Use `McpServe.ExposedMcpTools(tk.Tools(), cfg)` to apply `cfg.Tools` filtering first. | | `cfg` | `MCPServeConfig?` | `Name`/`Version` for `initialize`; `Tools` narrows `tools/list` (unknown names ignored, never an error). | | `onCall` | `OnCall?` | Fires per inbound `tools/call` with `{name, source, ms, isError}`. Host callback errors are isolated. | ## See also [Section titled “See also”](#see-also) * [`A2AServer.Start`](/api/csharp/serve/a2a/) — Publish an Agent Card and answer JSON-RPC over the client loop — your toolkit becomes someone else’s remote agent. * [`A2AServer.BuildAgentCard`](/api/csharp/serve/agent-card/) — Construct the Agent Card that advertises your name, skills and endpoint. * [`A2AServer.FileTaskStore`](/api/csharp/serve/task-store/) — Persist inbound A2A tasks so a suspended request survives a restart. # Toolkit.McpHandler > An embeddable MCP server you mount on your own HTTP server, with per-request auth, tools and confirmation hooks. — C#. **C#** · package `Toolnexus` · [SPEC §7C.1](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [McpHandler.cs](https://github.com/muthuishere/toolnexus/blob/main/csharp/src/Toolnexus/McpHandler.cs) ```csharp public McpHttpHandler McpHandler(McpHandlerConfig? cfg = null) // McpHttpHandler public Task HandleAsync(HttpListenerContext ctx); public Task HandleAsync(McpHttpExchange exchange); public void NotifyToolsChanged(); ``` A streamable-HTTP MCP server (protocol `2025-06-18`, `2025-11-25` opt-in) that you **mount** rather than run. Where [`McpServe.Build`](/api/csharp/serve/mcp/) behind `ServeAsync` owns a listener and exposes one static tool list, `McpHandler` hands you a handler for your own HTTP server, behind your own TLS, CORS and OAuth. Every policy is a hook; toolnexus has no notion of tenant, user or role. * `Authenticate` runs on every request; a throw ⇒ HTTP 401 with `WwwAuthenticate`’s header. * `ListTools` resolves the tool list **per request** for `tools/list` and the `tools/call` lookup — a name outside it is `-32602 "tool not found: "` and never runs. * `BeforeCall` returns `McpDecision.Allow()`, `Deny(msg, result?)` or `Confirm(msg, result?)`. A confirm asks the client with `elicitation/create` on the same POST’s SSE response when it declared the capability, else returns `result` (or `isError "confirmation required: …"`). * Hooks receive an `McpContext` (`Identity`, `Request`, `SessionId`, `Meta` = `params._meta`); a tool reads the same object from `McpContext.Current`. * `OnCall`, `ServerInfo`, resources/prompts hooks, pluggable `SessionIds`, `Stateless`, `StatelessSessionId`, `ProtocolVersions`, `ConfirmTimeout`, `MaxBodyBytes`. ## When to use it [Section titled “When to use it”](#when-to-use-it) You already run an HTTP service and want MCP clients (Claude Desktop, an IDE, another agent) to call its tools **as the signed-in user**: each caller sees only the tools their identity allows, a destructive tool asks for confirmation, and every call is observable. Mount the handler at `/mcp` next to your existing routes; tools can be built at runtime per request, so they need not be registered in the toolkit at all. ## Why this and not the alternative [Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative) Mount a handler, not a second server `ServeAsync(addr, { Mcp })` is the zero-config path: one listener, one static tool list, no auth. Pick `McpHandler` as soon as tools depend on who is calling, or the endpoint must live behind your own auth and transport. It adds no framework dependency: an `HttpListener` host passes the `HttpListenerContext`; an ASP.NET Core host subclasses `McpHttpExchange` (method, headers, body in; status, headers, body out) over its `HttpContext`. ## Examples [Section titled “Examples”](#examples) ### 1. The smallest useful call — mount on an HttpListener [Section titled “1. The smallest useful call — mount on an HttpListener”](#1-the-smallest-useful-call--mount-on-an-httplistener) ```csharp using System.Net; using Toolnexus; await using var tk = await Toolkit.CreateAsync(new Toolkit.Options { Builtins = false, ExtraTools = new List { NativeTool.Of("hello", "say hello", null, (IDictionary _) => "hello") }, }); var handler = tk.McpHandler(new McpHandlerConfig { Name = "demo", Stateless = true }); var listener = new HttpListener(); listener.Prefixes.Add("http://127.0.0.1:8765/mcp/"); listener.Start(); while (true) { var ctx = await listener.GetContextAsync(); _ = Task.Run(() => handler.HandleAsync(ctx)); // closes the response when done } ``` ### 2. The realistic case — per-identity tools and a confirmation gate [Section titled “2. The realistic case — per-identity tools and a confirmation gate”](#2-the-realistic-case--per-identity-tools-and-a-confirmation-gate) ```csharp using System.Text.Json; using Toolnexus; record User(string Name, bool Admin); var handler = tk.McpHandler(new McpHandlerConfig { Authenticate = ex => ex.Header("Authorization") switch { "Bearer token-alice" => Task.FromResult(new User("alice", true)), "Bearer token-bob" => Task.FromResult(new User("bob", false)), _ => throw new UnauthorizedAccessException("invalid token"), }, WwwAuthenticate = (_, _) => "Bearer resource_metadata=\"https://example.test/.well-known/oauth-protected-resource\"", ListTools = ctx => { var user = (User)ctx.Identity!; var tools = new List { listRecords }; if (user.Admin) tools.Add(deleteRecord); // deleteRecord.Annotations.DestructiveHint = true return Task.FromResult>(tools); }, BeforeCall = (ctx, tool, args) => Task.FromResult( tool.Annotations?.DestructiveHint != true || args.GetRawText().Contains("\"nonce\":\"n-123\"") ? McpDecision.Allow() : McpDecision.Confirm("Really delete this record?", new ToolResult("Confirmation needed: call again with \"nonce\":\"n-123\".", false))), OnCall = ev => Console.WriteLine($"{ev.Name} by {((User)ev.Identity!).Name}: {ev.Decision} {ev.ResultBytes}B"), }); ``` Bob’s `tools/list` has no `delete_record`, and calling it anyway is `tool not found` — it never runs. Alice’s client gets an `elicitation/create` prompt when it supports elicitation; otherwise she gets the shaped “re-call with a nonce” result. ### 3. Full surface — structured results, list\_changed and a custom host [Section titled “3. Full surface — structured results, list\_changed and a custom host”](#3-full-surface--structured-results-list_changed-and-a-custom-host) ```csharp using System.Text.Json; using Toolnexus; var stats = NativeTool.Of("stats", "record counts", null, (IDictionary _) => new ToolResult("", false) { Structured = new Dictionary { ["count"] = 3 }, // structuredContent (+ its JSON as text) Meta = new Dictionary { ["trace"] = McpContext.Current?.Meta?.GetValueOrDefault("trace") }, // _meta }); stats.OutputSchema = new Dictionary { ["type"] = "object" }; var handler = tk.McpHandler(new McpHandlerConfig { ProtocolVersions = new[] { "2025-11-25", "2025-06-18" }, ServerInfo = ctx => new McpServerInfo("records-" + ctx.Request.Header("Host"), "1.0.0", Title: "Records"), ListTools = _ => Task.FromResult>(new List { stats }), ListResources = _ => Task.FromResult>(new[] { new McpResource("docs://api", "api") }), ReadResource = (_, uri) => Task.FromResult>(new[] { new McpResourceContent(uri, Text: "# API") }), }); // Tools changed (e.g. a plan upgrade)? Clients holding a GET stream re-run tools/list. handler.NotifyToolsChanged(); // ASP.NET Core (or any server): adapt its request once, then // app.Map("/mcp", http => handler.HandleAsync(new AspNetExchange(http))); // where AspNetExchange : McpHttpExchange maps Method, Header, RequestBody, SetStatus, SetHeader, ResponseBody. ``` Caution Stateful mode (the default) keeps sessions in memory: behind several replicas, plug `SessionIds` (signed ids or a shared store), or set `Stateless = true` — confirmation then never elicits and always takes the shaped-result path. A confirm’s elicitation answer must reach the replica holding the open POST. ## Parameters [Section titled “Parameters”](#parameters) | Member | Type | What it is | | -------------------------------------------------------------- | -------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | | `Name` / `Title` / `Version` / `Instructions` | `string?` | Static `serverInfo`; defaults `"toolnexus"` / `"0.1.0"`; empty title omitted. | | `ServerInfo` | `Func?` | Per-request `serverInfo`; wins over the static fields. | | `ProtocolVersions` | `IReadOnlyList?` | Accepted versions, newest first; only `2025-11-25` and `2025-06-18` are kept. Default `["2025-06-18"]`. | | `Authenticate` / `WwwAuthenticate` | `Func>?` / `Func?` | Identity per request; a throw ⇒ 401 with the header. | | `ListTools` | `Func>>?` | Per-request tool list; null ⇒ the toolkit’s tools. | | `BeforeCall` | `Func>?` | Allow / deny / confirm each call; a throw ⇒ deny with its message. | | `ConfirmTimeout` | `TimeSpan` | Bounds an elicitation round trip. Default 5 minutes. | | `OnCall` | `Action?` | `{Name, Source, Ms, IsError, Err, ResultBytes, Decision, Identity, SessionId}`; throws isolated. | | `ListResources` / `ReadResource` / `ListPrompts` / `GetPrompt` | hooks | Absent ⇒ capability not advertised, method `-32601`. | | `SessionIds` | `IMcpSessionIds?` | `GenerateAsync` / `ValidateAsync` / `TerminateAsync`; default in-memory. | | `Stateless` / `StatelessSessionId` | `bool` / `string?` | No session issued or checked; optional fixed id echoed on initialize. | | `MaxBodyBytes` | `long` | POST body cap (413 above it). Default 4 MiB. | ## See also [Section titled “See also”](#see-also) * [`McpServe.Build`](/api/csharp/serve/mcp/) — The `ServeAsync` MCP profile: one listener, one static tool list. * [`A2AServer.Start`](/api/csharp/serve/a2a/) — Publish an Agent Card and answer JSON-RPC over the client loop. # A2AServer.FileTaskStore > Persist inbound A2A tasks so a suspended request survives a restart. — C#. **C#** · package `Toolnexus` · [SPEC §7B](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [A2AServer.cs](https://github.com/muthuishere/toolnexus/blob/main/csharp/src/Toolnexus/A2AServer.cs) ```csharp public sealed class FileTaskStore : ITaskStore { public FileTaskStore(string dir); public Task GetAsync(string id); public Task SaveAsync(A2ATask task); } ``` One JSON file per Task id, written atomically (temp file + move) so a concurrent `GetAsync` never reads a half-written file mid-poll. This is the pluggable alternative to the default `InMemoryTaskStore`: when `A2AConfig.Store` is `"file:"`, `A2AServer.StartAsync` resolves to a `FileTaskStore` rooted at `` — a served toolkit’s Tasks then survive a process restart. Not nested under A2AServer Despite the page title (kept for cross-port symbol parity), the real C# type is the top-level class `Toolnexus.FileTaskStore`, declared alongside — not inside — the `A2AServer` static class in `A2AServer.cs`. Construct it as `new FileTaskStore(dir)`, not `A2AServer.FileTaskStore(dir)`. ## When to use it [Section titled “When to use it”](#when-to-use-it) The default in-memory `ITaskStore` loses every submitted/working/completed Task when the process restarts — fine for a short-lived demo, not for a long-poll caller that might catch you mid-restart. Point `A2AConfig.Store` at `"file:"` (or construct `FileTaskStore` yourself for a custom `ITaskStore`) to make Task state durable across restarts. ## Why this and not the alternative [Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative) Reach for a custom ITaskStore for anything bigger than a directory `FileTaskStore` is the built-in, dependency-free option — one small JSON file per Task, atomic writes, no database. For a multi-instance deployment or a real workload, implement `ITaskStore` yourself (Postgres, Redis, NATS/JetStream — the interface is just `GetAsync`/`SaveAsync`) and pass the instance directly as `A2AConfig.Store`, which accepts an `ITaskStore` object as-is. ## Examples [Section titled “Examples”](#examples) ### 1. The smallest useful call — save, then get it back [Section titled “1. The smallest useful call — save, then get it back”](#1-the-smallest-useful-call--save-then-get-it-back) ```csharp using Toolnexus; var dir = Path.Combine(Path.GetTempPath(), "toolnexus-docs-" + Guid.NewGuid().ToString("n")); var store = new FileTaskStore(dir); var task = new A2ATask { Id = "t1", Status = new A2ATaskStatus { State = "completed" } }; await store.SaveAsync(task); var loaded = await store.GetAsync("t1"); if (loaded == null) throw new Exception("expected the task back"); if (loaded.Status?.State != "completed") throw new Exception(loaded.Status?.State); // An unknown id is a plain null, never a throw. if (await store.GetAsync("nope") != null) throw new Exception("expected null for an unknown id"); Directory.Delete(dir, recursive: true); Console.WriteLine($"ok: {loaded.Id} -> {loaded.Status?.State}"); ``` ### 2. The realistic case — wired in via `A2AConfig.Store = "file:"` [Section titled “2. The realistic case — wired in via A2AConfig.Store = "file:\"”](#2-the-realistic-case--wired-in-via-a2aconfigstore--filedir) ```csharp using Toolnexus; var dir = Path.Combine(Path.GetTempPath(), "toolnexus-docs-" + Guid.NewGuid().ToString("n")); // A2AServer.ResolveStore is what StartAsync calls internally to turn the "file:" prefix into a // FileTaskStore rooted at the given directory. var resolved = A2AServer.ResolveStore("file:" + dir); if (resolved is not FileTaskStore) throw new Exception($"expected FileTaskStore, got {resolved.GetType().Name}"); await resolved.SaveAsync(new A2ATask { Id = "t2", Status = new A2ATaskStatus { State = "working" } }); var back = await resolved.GetAsync("t2"); if (back?.Status?.State != "working") throw new Exception(back?.Status?.State); Directory.Delete(dir, recursive: true); Console.WriteLine($"ok: resolved to {resolved.GetType().Name}"); ``` ### 3. Full surface — a real served round trip, task state survives a fresh store instance [Section titled “3. Full surface — a real served round trip, task state survives a fresh store instance”](#3-full-surface--a-real-served-round-trip-task-state-survives-a-fresh-store-instance) ```csharp using Toolnexus; var dir = Path.Combine(Path.GetTempPath(), "toolnexus-docs-" + Guid.NewGuid().ToString("n")); using var peerLlm = new Stub(ctx => Stub.Json(ctx, 200, """ {"choices":[{"message":{"content":"archived"}}],"usage":{"prompt_tokens":1,"completion_tokens":1,"total_tokens":2}} """)); await using var tk = await Toolkit.CreateAsync(new Toolkit.Options { Builtins = false, Skills = new List { new("archive", "archives a request", "Archive it.") }, }); var client = LlmClient.Create(new LlmClient.Options { BaseUrl = peerLlm.BaseUrl, Style = "openai", Model = "mock", ApiKey = "k", }); var handle = await tk.ServeAsync("127.0.0.1:0", new Toolkit.ServeOptions { Client = client, A2A = new A2AConfig { Name = "archivist", Store = "file:" + dir }, }); var tools = await A2A.AgentTools(new Agent { Card = handle.Url + "/.well-known/agent-card.json", PollEvery = 25 }); var result = await tools.Single(t => t.Name == "archivist_archive") .ExecuteAsync(new Dictionary { ["task"] = "archive this" }); if (result.IsError || result.Output != "archived") throw new Exception(result.Output); var taskId = result.Metadata?["taskId"] as string ?? throw new Exception("no taskId in metadata"); await handle.StopAsync(); // simulate a restart: the server (and its in-memory bits) are gone // A brand-new FileTaskStore, same directory, no server running — the completed Task is still there. var reopened = new FileTaskStore(dir); var survived = await reopened.GetAsync(taskId); if (survived?.Status?.State != "completed") throw new Exception(survived?.Status?.State); Directory.Delete(dir, recursive: true); Console.WriteLine($"ok: task {taskId} survived past the server's shutdown"); sealed class Stub : IDisposable { readonly System.Net.HttpListener _listener = new(); readonly CancellationTokenSource _cts = new(); public int Port { get; } public string BaseUrl => $"http://127.0.0.1:{Port}"; public Stub(Action handler) { var probe = new System.Net.Sockets.TcpListener(System.Net.IPAddress.Loopback, 0); probe.Start(); Port = ((System.Net.IPEndPoint)probe.LocalEndpoint).Port; probe.Stop(); _listener.Prefixes.Add($"http://127.0.0.1:{Port}/"); _listener.Start(); _ = Task.Run(async () => { while (!_cts.IsCancellationRequested) { System.Net.HttpListenerContext ctx; try { ctx = await _listener.GetContextAsync(); } catch { break; } try { handler(ctx); } catch { } } }); } public static void Json(System.Net.HttpListenerContext ctx, int status, string body) { var bytes = System.Text.Encoding.UTF8.GetBytes(body); ctx.Response.StatusCode = status; ctx.Response.ContentType = "application/json"; ctx.Response.ContentLength64 = bytes.Length; ctx.Response.OutputStream.Write(bytes, 0, bytes.Length); ctx.Response.OutputStream.Close(); } public void Dispose() { _cts.Cancel(); try { _listener.Stop(); } catch { } try { _listener.Close(); } catch { } } } ``` Caution The file name is `Tools.Sanitize(id) + ".json"` — a Task id is already a GUID from `SendMessage`, so this only matters if you construct/save Tasks with your own ids directly against the store. ## Members [Section titled “Members”](#members) | Member | What it is | | --------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | | `FileTaskStore(string dir)` | Creates `dir` if missing; every Task under this store lives at `/.json`. | | `GetAsync(string id)` | Reads and deserializes; unknown id or unreadable file ⇒ `null`, never a throw. | | `SaveAsync(A2ATask task)` | Atomic write — a temp file, then an overwrite move — so readers never see a partial file. | | `A2AServer.ResolveStore(object? store)` | What `StartAsync` calls: `null`/`"memory"` ⇒ `InMemoryTaskStore`; `"file:"` ⇒ `FileTaskStore`; an `ITaskStore` ⇒ used as-is. | ## See also [Section titled “See also”](#see-also) * [`A2AServer.Start`](/api/csharp/serve/a2a/) — Publish an Agent Card and answer JSON-RPC over the client loop — your toolkit becomes someone else’s remote agent. * [`A2AServer.BuildAgentCard`](/api/csharp/serve/agent-card/) — Construct the Agent Card that advertises your name, skills and endpoint. * [`McpServe.Build`](/api/csharp/serve/mcp/) — The inbound MCP profile: any MCP client can call your tools. # SkillSource.ListSkills > Inventory every discovered skill — and, crucially, the candidates that were skipped and why. C#. **C#** · package `Toolnexus` · [SPEC §3](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [SkillSource.cs](https://github.com/muthuishere/toolnexus/blob/main/csharp/src/Toolnexus/SkillSource.cs) ```csharp public static SkillInventory ListSkills(SkillSource.LoadOptions opts) ``` Runs exactly the same discovery as [`Load`](/api/csharp/skills/load/) — the same directory walk, the same frontmatter parse, the same duplicate handling — but instead of building a `skill` tool it hands back a **report**: the skills it accepted, and a typed list of the candidates it rejected with the reason for each. `Load` throws none of that away quietly-but-visibly; it writes a line to stderr and moves on. `ListSkills` gives you the same information as data you can assert on. ## When to use it [Section titled “When to use it”](#when-to-use-it) * **A skill silently isn’t there.** You wrote a `SKILL.md`, the model never sees it, and nothing obviously failed. `Skipped` tells you it was `missing-name` or `malformed-frontmatter`. * **Validate in CI.** Fail the build when any candidate under `skills/` was skipped. * **Author an allowlist.** The inventory is deliberately **unfiltered**, so it is the right input for building the `Filter` map you then pass to [`LoadWith`](/api/csharp/skills/load-with/). * **Show a picker.** Render the available skills in a UI without wiring a toolkit. ## Why this and not the alternative [Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative) Use Load when you just want the tool [`SkillSource.Load`](/api/csharp/skills/load/) is what you ship — it returns the `skill` tool ready to register. `ListSkills` builds **no tool at all**; it is for diagnosis, validation and UI. Note also that it takes only a `LoadOptions` object — there is no directory-varargs shortcut, so even the simplest call sets `Dirs`. ## Examples [Section titled “Examples”](#examples) ### 1. Inventory the shared fixture [Section titled “1. Inventory the shared fixture”](#1-inventory-the-shared-fixture) ```csharp using Toolnexus; // TOOLNEXUS_REPO is set by the docs test runner; in your own code just use a path. var repo = Environment.GetEnvironmentVariable("TOOLNEXUS_REPO") ?? "."; var inv = SkillSource.ListSkills(new SkillSource.LoadOptions { Dirs = new[] { Path.Combine(repo, "examples", "skills") }, }); if (inv.Skills.Count != 1) throw new Exception($"expected 1 skill, got {inv.Skills.Count}"); if (inv.Skills[0].Name != "hello-world") throw new Exception(inv.Skills[0].Name); if (inv.Skipped.Count != 0) throw new Exception($"clean fixture should skip nothing: {string.Join(",", inv.Skipped.Select(s => s.Reason))}"); Console.WriteLine($"ok: {inv.Skills[0].Name} | skipped {inv.Skipped.Count}"); ``` `Skills` is a **list** of `SkillInfo`, not a dictionary — unlike `SkillSource.Skills` on a loaded source. Order follows discovery order, so sort it yourself if you are printing it. ### 2. The reason a skill went missing [Section titled “2. The reason a skill went missing”](#2-the-reason-a-skill-went-missing) Three candidate files, one of which is fine. ```csharp using Toolnexus; var root = Path.Combine(Path.GetTempPath(), "tn-inv-" + Guid.NewGuid().ToString("N")); void Skill(string folder, string body) { Directory.CreateDirectory(Path.Combine(root, folder)); File.WriteAllText(Path.Combine(root, folder, "SKILL.md"), body); } Skill("good", """ --- name: good description: A perfectly ordinary skill. --- Do the thing. """); // Frontmatter parses, but there is no `name:` — nothing to call the skill by. Skill("nameless", """ --- description: I forgot to name myself. --- Do the thing. """); // Fences are present but the YAML inside them does not parse — and `[` opens a // construct the lenient rescue refuses, so no name can be recovered. Skill("broken", "---\nname: [unterminated\ndescription: nope\n---\n\nDo the thing.\n"); try { var inv = SkillSource.ListSkills(new SkillSource.LoadOptions { Dirs = new[] { root } }); if (inv.Skills.Count != 1 || inv.Skills[0].Name != "good") throw new Exception($"skills: {string.Join(",", inv.Skills.Select(s => s.Name))}"); var reasons = inv.Skipped.Select(s => s.Reason).OrderBy(r => r, StringComparer.Ordinal).ToList(); if (reasons.Count != 2) throw new Exception($"skipped: {string.Join(",", reasons)}"); if (reasons[0] != SkillSource.SkillSkip.Malformed) throw new Exception(reasons[0]); if (reasons[1] != SkillSource.SkillSkip.MissingName) throw new Exception(reasons[1]); // Every skip carries the absolute path of the offending file, so you can print it. if (!inv.Skipped.All(s => s.Location.EndsWith("SKILL.md"))) throw new Exception("location"); Console.WriteLine($"ok: kept {inv.Skills[0].Name} | skipped {string.Join(", ", reasons)}"); } finally { Directory.Delete(root, recursive: true); } ``` A body with no frontmatter at all is not 'malformed' `malformed-frontmatter` means the `---` fences were there, the YAML inside them failed to parse, **and** the lenient line-wise rescue could not recover a `name` either — a near-miss like `name: "oops` is rescued and loads. A `SKILL.md` with no fences parses fine, has no `name`, and is reported as `missing-name`. The distinction is deliberate — it points at the actual mistake. ### 3. Full surface — disk plus data, and authoring an allowlist [Section titled “3. Full surface — disk plus data, and authoring an allowlist”](#3-full-surface--disk-plus-data-and-authoring-an-allowlist) `ListSkills` accepts the same `LoadOptions` as `LoadWith`, including skills supplied as data. Use the inventory to build the `Filter` and hand it straight back to `LoadWith`. ```csharp using Toolnexus; var repo = Environment.GetEnvironmentVariable("TOOLNEXUS_REPO") ?? "."; var opts = new SkillSource.LoadOptions { Dirs = new[] { Path.Combine(repo, "examples", "skills") }, Skills = new[] { new SkillSource.SkillDef("triage", "Triage an inbound bug report.", "1. Reproduce.\n2. Label.", new[] { "rubric.md" }), // Same name as the on-disk fixture — disk wins, this one is reported as a duplicate. new SkillSource.SkillDef("hello-world", "A clashing definition.", "..."), // No name at all — rejected the same way a nameless file is. new SkillSource.SkillDef("", "Anonymous.", "..."), }, }; var inv = SkillSource.ListSkills(opts); var kept = inv.Skills.Select(s => s.Name).OrderBy(n => n, StringComparer.Ordinal).ToList(); if (kept.Count != 2 || kept[0] != "hello-world" || kept[1] != "triage") throw new Exception($"kept: {string.Join(",", kept)}"); var reasons = inv.Skipped.Select(s => s.Reason).OrderBy(r => r, StringComparer.Ordinal).ToList(); if (reasons.Count != 2) throw new Exception($"skipped: {string.Join(",", reasons)}"); if (reasons[0] != SkillSource.SkillSkip.DuplicateName) throw new Exception(reasons[0]); if (reasons[1] != SkillSource.SkillSkip.MissingName) throw new Exception(reasons[1]); // Data-sourced skills get a logical base instead of a file:// path. var triage = inv.Skills.First(s => s.Name == "triage"); if (triage.Origin != "logical") throw new Exception(triage.Origin); if (triage.Location != "skill://triage/") throw new Exception(triage.Location); // The inventory is UNFILTERED, which is what makes it a good allowlist source. opts.Filter = inv.Skills.ToDictionary(s => s.Name, s => s.Name == "triage"); var narrowed = SkillSource.LoadWith(opts); if (narrowed.Skills.Count != 1 || !narrowed.Skills.ContainsKey("triage")) throw new Exception($"filtered: {string.Join(",", narrowed.Skills.Keys)}"); // The inventory itself does not change — re-listing still sees everything. if (SkillSource.ListSkills(opts).Skills.Count != 2) throw new Exception("inventory must ignore Filter"); Console.WriteLine($"ok: kept {string.Join(",", kept)} | skipped {string.Join(",", reasons)} | agent sees triage"); ``` ## What you get back [Section titled “What you get back”](#what-you-get-back) `SkillInventory` has two members: | Member | Type | What it is | | --------- | -------------------------- | ------------------------------------------------ | | `Skills` | `IReadOnlyList` | Accepted skills, unfiltered, in discovery order. | | `Skipped` | `IReadOnlyList` | Rejected candidates — `Location` + `Reason`. | `SkillSkip.Reason` is one of four constants: | Constant | Value | Cause | | ------------------------- | ----------------------- | ------------------------------------------------------ | | `SkillSkip.MissingName` | `missing-name` | Frontmatter parsed but has no `name`. | | `SkillSkip.Malformed` | `malformed-frontmatter` | `---` fences present, YAML inside failed to parse. | | `SkillSkip.DuplicateName` | `duplicate-name` | An earlier candidate already claimed that name. | | `SkillSkip.Unreadable` | `unreadable` | The file could not be read (permissions, broken link). | `LoadOptions` is shared with `LoadWith`: `Dirs`, `Skills`, `Filter`, `SampleLimit`. `Filter` and `SampleLimit` are **ignored** here — the inventory is always the complete picture. ## See also [Section titled “See also”](#see-also) * [`SkillSource.Load`](/api/csharp/skills/load/) — the same discovery, returning the `skill` tool * [`SkillSource.LoadWith`](/api/csharp/skills/load-with/) — takes the same `LoadOptions`, applies `Filter` * [`Toolkit.CreateAsync`](/api/csharp/toolkit/create/) — where the resulting tool ends up # SkillSource.Load > Discover every SKILL.md under one or more directories and expose a single skill tool with progressive disclosure. C#. **C#** · package `Toolnexus` · [SPEC §3](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [SkillSource.cs](https://github.com/muthuishere/toolnexus/blob/main/csharp/src/Toolnexus/SkillSource.cs) ```csharp public static SkillSource Load(params string[] dirs) public static SkillSource Load(IEnumerable dirs) ``` Walks each directory for `**/SKILL.md`, parses the YAML frontmatter of every file it finds, and returns a `SkillSource` carrying two things you care about: `Skills` (the catalog) and `Tool` (one `ITool` named `skill`). That single tool is the whole point. Instead of exposing twenty tools, you expose **one**, plus a short catalog in the system prompt. The model calls `skill` with a name, and only then does the full instruction body enter the conversation — progressive disclosure. ## When to use it [Section titled “When to use it”](#when-to-use-it) When your skills live on disk in the standard `SKILL.md` layout and you want all of them, exactly as written. This is the ordinary path: point it at a folder, hand `source.Tool` to your toolkit, and put `source.Prompt()` in the system prompt. ## Why this and not the alternative [Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative) Use LoadWith when skills are not just 'every file in a folder' `Load` is the thin sugar: it forwards to [`SkillSource.LoadWith`](/api/csharp/skills/load-with/) with only `Dirs` set. Prefer `LoadWith` when you need skills supplied **as data** rather than from disk, a per-agent **allowlist**, or a different **sample limit** for the `` block. And when you want to know what was *rejected* — a malformed header, a missing name — use [`SkillSource.ListSkills`](/api/csharp/skills/list/), which reports skip reasons that `Load` silently swallows. `Load` is **synchronous** — there is no `Async` suffix, because discovery is a directory walk and nothing else. No process is spawned and no network call is made. ## Examples [Section titled “Examples”](#examples) ### 1. Load the shared fixture and look at the catalog [Section titled “1. Load the shared fixture and look at the catalog”](#1-load-the-shared-fixture-and-look-at-the-catalog) This is `examples/skills`, the fixture every port is tested against. ```csharp using Toolnexus; // TOOLNEXUS_REPO is set by the docs test runner; in your own code just use a path. var repo = Environment.GetEnvironmentVariable("TOOLNEXUS_REPO") ?? "."; var dir = Path.Combine(repo, "examples", "skills"); var skills = SkillSource.Load(dir); if (!skills.Skills.ContainsKey("hello-world")) throw new Exception($"expected hello-world, got: {string.Join(",", skills.Skills.Keys)}"); var info = skills.Skills["hello-world"]; if (info.Description == null || !info.Description.StartsWith("A tiny example skill")) throw new Exception($"description: {info.Description}"); if (info.Origin != "fs") throw new Exception($"origin: {info.Origin}"); if (!info.Location.EndsWith("SKILL.md")) throw new Exception($"location: {info.Location}"); // One tool for all skills, regardless of how many were discovered. if (skills.Tool.Name != "skill") throw new Exception($"tool name: {skills.Tool.Name}"); if (skills.Tool.Source != "skill") throw new Exception($"tool source: {skills.Tool.Source}"); Console.WriteLine($"ok: {string.Join(", ", skills.Skills.Keys)} -> tool '{skills.Tool.Name}'"); ``` `Location` is the absolute path of the `SKILL.md` itself, and `Content` is the body **below** the frontmatter. A directory that does not exist is not an error — it logs a warning to stderr and contributes nothing. ### 2. Calling the `skill` tool — what the model actually receives [Section titled “2. Calling the skill tool — what the model actually receives”](#2-calling-the-skill-tool--what-the-model-actually-receives) ```csharp using Toolnexus; var repo = Environment.GetEnvironmentVariable("TOOLNEXUS_REPO") ?? "."; var skills = SkillSource.Load(Path.Combine(repo, "examples", "skills")); var res = await skills.Tool.ExecuteAsync(new Dictionary { ["name"] = "hello-world" }); if (res.IsError) throw new Exception(res.Output); // The output envelope is byte-identical in every port. if (!res.Output.StartsWith("")) throw new Exception("open tag"); if (!res.Output.Contains("# Skill: hello-world")) throw new Exception("heading"); if (!res.Output.Contains("Base directory for this skill: file://")) throw new Exception("base directory"); if (!res.Output.Contains("") || !res.Output.Contains("greet.sh")) throw new Exception("sampled files"); if (!res.Output.EndsWith("")) throw new Exception("close tag"); // Metadata carries the skill name and its directory. if (res.Metadata?["name"] as string != "hello-world") throw new Exception("metadata name"); // An unknown name is a tool ERROR, not an exception — the model reads it and retries. var miss = await skills.Tool.ExecuteAsync(new Dictionary { ["name"] = "nope" }); if (!miss.IsError) throw new Exception("expected IsError"); if (!miss.Output.Contains("Available skills: hello-world")) throw new Exception(miss.Output); Console.WriteLine($"ok: {res.Output.Length} chars | miss: {miss.Output}"); ``` Caution The `` list is **sampled**, not exhaustive — the first 10 sibling files by default, in filesystem walk order. It is a hint so the model knows what is nearby; never treat it as a complete directory listing. Pass `SampleLimit` via [`LoadWith`](/api/csharp/skills/load-with/) to raise the cap, or `-1` to omit the block entirely. ### 3. Several roots, the prompt catalog, and duplicate handling [Section titled “3. Several roots, the prompt catalog, and duplicate handling”](#3-several-roots-the-prompt-catalog-and-duplicate-handling) Roots are scanned in the order given, and the **first** definition of a name wins. ```csharp using Toolnexus; var repo = Environment.GetEnvironmentVariable("TOOLNEXUS_REPO") ?? "."; var shared = Path.Combine(repo, "examples", "skills"); // A second root, created for this example only. var extra = Path.Combine(Path.GetTempPath(), "tn-skills-" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(Path.Combine(extra, "audit-log")); File.WriteAllText(Path.Combine(extra, "audit-log", "SKILL.md"), """ --- name: audit-log description: Read and summarise the deployment audit log. --- # Audit log Read `entries.jsonl` in this skill's base directory and summarise the last 20 lines. """); File.WriteAllText(Path.Combine(extra, "audit-log", "entries.jsonl"), "{\"at\":\"2026-07-31\"}\n"); try { // params overload — one argument per root, scanned left to right. // A missing root just warns on stderr; it is not fatal. var skills = SkillSource.Load(shared, extra, Path.Combine(extra, "does-not-exist")); var names = skills.Skills.Keys.OrderBy(k => k, StringComparer.Ordinal).ToList(); if (names.Count != 2 || names[0] != "audit-log" || names[1] != "hello-world") throw new Exception($"names: {string.Join(",", names)}"); // Prompt() is the catalog you paste into the system prompt: preamble + one line per skill, // ordinal-sorted by name. It carries descriptions only — never the bodies. var prompt = skills.Prompt(); if (!prompt.StartsWith("Skills provide specialized instructions")) throw new Exception("preamble"); if (!prompt.Contains("## Available Skills")) throw new Exception("heading"); if (prompt.IndexOf("**audit-log**", StringComparison.Ordinal) > prompt.IndexOf("**hello-world**", StringComparison.Ordinal)) throw new Exception("expected ordinal ordering"); if (prompt.Contains("Read `entries.jsonl`")) throw new Exception("bodies must stay out of the catalog"); // Both skills answer through the one tool. var res = await skills.Tool.ExecuteAsync(new Dictionary { ["name"] = "audit-log" }); if (res.IsError || !res.Output.Contains("entries.jsonl")) throw new Exception(res.Output); // No skills at all is still a valid source — the catalog says so in words. var none = SkillSource.Load(Array.Empty()); if (none.Prompt() != "No skills are currently available.") throw new Exception(none.Prompt()); Console.WriteLine($"ok: {string.Join(", ", names)} | prompt {prompt.Length} chars"); } finally { Directory.Delete(extra, recursive: true); } ``` ## What you get back [Section titled “What you get back”](#what-you-get-back) | Member | Type | What it is | | ---------- | ---------------------------------------- | ------------------------------------------------------------------- | | `Skills` | `IReadOnlyDictionary` | The catalog, keyed by skill name. | | `Tool` | `ITool` | The single `skill` tool. Add it to your toolkit. | | `Prompt()` | `string` | Markdown catalog for the system prompt — names + descriptions only. | `SkillInfo` fields: `Name`, `Description`, `Location` (absolute path to `SKILL.md`), `Content` (body below the frontmatter), `Origin` (`"fs"` here), `Resources`, `Base`. Discovery rules: `node_modules` and `.git` are skipped, symlinks are followed with cycle detection, a file whose frontmatter has no `name` is dropped, and a duplicate name keeps the first one seen and warns. ## See also [Section titled “See also”](#see-also) * [`SkillSource.LoadWith`](/api/csharp/skills/load-with/) — skills as data, allowlists, sample limits * [`SkillSource.ListSkills`](/api/csharp/skills/list/) — inventory *and* the reasons files were skipped * [`Toolkit.CreateAsync`](/api/csharp/toolkit/create/) — takes skill directories directly * [`ITool`](/api/csharp/types/tool/) — what `Tool` is # SkillSource.LoadWith > Supply skills as data or from a lazy provider, and narrow them per agent with an allowlist. — C#. **C#** · package `Toolnexus` · [SPEC §3](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [SkillSource.cs](https://github.com/muthuishere/toolnexus/blob/main/csharp/src/Toolnexus/SkillSource.cs) ```csharp public static SkillSource LoadWith(SkillSource.LoadOptions opts) public sealed class LoadOptions { public IEnumerable? Dirs { get; set; } public IReadOnlyList? Skills { get; set; } public IReadOnlyDictionary? Filter { get; set; } public int SampleLimit { get; set; } // 0 => default 10, n>0 => cap, -1 => omit } ``` The general-purpose skill loader behind [`SkillSource.Load`](/api/csharp/skills/load/). `Load(dirs)` is sugar for `LoadWith(new LoadOptions { Dirs = dirs })` — `LoadWith` is where the rest of §3 lives: skills supplied **as data** (`Skills`, no filesystem touched), and a per-agent allowlist (`Filter`) narrowing whichever skills were discovered. * `Dirs` — one or more roots, globbed for `**/SKILL.md` exactly like `Load`. * `Skills` — a list of `SkillDef(Name, Description, Content, Resources?, Base?)`. Each becomes a skill with a **logical** `skill://name/` base (or your own `Base`) instead of a `file://` path, and never reads the filesystem. * `Filter` — a name→bool allowlist applied *after* discovery: empty/null keeps everything; ≥1 `true` entry switches to allowlist mode (only `true`-mapped names survive); an all-`false` map is a drop-list over the all-on baseline. * `SampleLimit` — how many sibling/resource files the `skill` tool’s `` block samples; `0` is the default 10, a positive number caps it, `-1` omits the block entirely. `Dirs` and `Skills` are not exclusive — both can be set, and the two sources merge. ## When to use it [Section titled “When to use it”](#when-to-use-it) * You have skills that don’t live on disk — generated from a database, fetched from an API, assembled in memory — and want them to behave exactly like filesystem skills to the model. * You’re serving multiple agents from one process and each should see a different subset of the same discovered skills (`Filter`). * You want to shrink or drop the `` sampling block (`SampleLimit`) for a smaller system prompt. ## Why this and not the alternative [Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative) Load is LoadWith with one field set [`SkillSource.Load`](/api/csharp/skills/load/) is the shortest path when all you have is one or more directories. Reach for `LoadWith` the moment you need `Skills`, `Filter`, or `SampleLimit` — any option beyond a bare list of roots. The 'lazy provider' lives on Toolkit, not here `LoadWith` itself only accepts an eager `Skills` list. A **lazy** provider — `Func>`, resolved at build time and merged with any static `Skills` — is a [`Toolkit.Options.SkillProvider`](/api/csharp/toolkit/create/) concept, not a `LoadOptions` field. If you need lazy resolution outside a toolkit, call your provider function yourself and pass its result as `Skills`. ## Examples [Section titled “Examples”](#examples) ### 1. Skills supplied as data — no filesystem at all [Section titled “1. Skills supplied as data — no filesystem at all”](#1-skills-supplied-as-data--no-filesystem-at-all) ```csharp using Toolnexus; var source = SkillSource.LoadWith(new SkillSource.LoadOptions { Skills = new List { new("weather-briefing", "Summarize today's weather for a city", "# Weather Briefing\nAsk for a city, then summarize."), }, }); if (!source.Skills.ContainsKey("weather-briefing")) throw new Exception("skill not registered"); if (source.Skills["weather-briefing"].Base != "skill://weather-briefing/") throw new Exception($"unexpected base: {source.Skills["weather-briefing"].Base}"); var res = await source.Tool.ExecuteAsync(new Dictionary { ["name"] = "weather-briefing" }); if (res.IsError || !res.Output.Contains("Base directory for this skill: skill://weather-briefing/")) throw new Exception(res.Output); Console.WriteLine("ok: data-sourced skill, no disk touched"); ``` ### 2. Directory + data, narrowed with an allowlist [Section titled “2. Directory + data, narrowed with an allowlist”](#2-directory--data-narrowed-with-an-allowlist) ```csharp using Toolnexus; var repo = Environment.GetEnvironmentVariable("TOOLNEXUS_REPO") ?? "."; var skillsDir = Path.Combine(repo, "examples", "skills"); var source = SkillSource.LoadWith(new SkillSource.LoadOptions { Dirs = new[] { skillsDir }, Skills = new List { new("weather-briefing", "Summarize today's weather for a city", "# Weather Briefing"), }, // Allowlist mode (>=1 true): only "hello-world" survives, even though two skills were discovered. Filter = new Dictionary { ["hello-world"] = true }, }); if (!source.Skills.ContainsKey("hello-world")) throw new Exception("expected hello-world to survive the filter"); if (source.Skills.ContainsKey("weather-briefing")) throw new Exception("expected weather-briefing to be filtered out"); if (source.Skills.Count != 1) throw new Exception($"expected exactly 1 skill, got {source.Skills.Count}"); Console.WriteLine($"ok: {string.Join(", ", source.Skills.Keys)}"); ``` ### 3. Capping the file-sampling block [Section titled “3. Capping the file-sampling block”](#3-capping-the-file-sampling-block) ```csharp using Toolnexus; var withSampling = SkillSource.LoadWith(new SkillSource.LoadOptions { Skills = new List { new("triage", "Route a support ticket", "# Triage", new[] { "playbook.md", "escalation.md", "contacts.md" }), }, SampleLimit = 2, // cap the block at 2 entries }); var capped = await withSampling.Tool.ExecuteAsync(new Dictionary { ["name"] = "triage" }); if (capped.Output.Split("").Length - 1 != 2) throw new Exception(capped.Output); var noSampling = SkillSource.LoadWith(new SkillSource.LoadOptions { Skills = new List { new("triage", "Route a support ticket", "# Triage", new[] { "playbook.md", "escalation.md" }), }, SampleLimit = -1, // omit entirely }); var uncapped = await noSampling.Tool.ExecuteAsync(new Dictionary { ["name"] = "triage" }); if (uncapped.Output.Contains("")) throw new Exception("expected no block"); Console.WriteLine("ok: SampleLimit=2 capped, SampleLimit=-1 omitted"); ``` ## Fields [Section titled “Fields”](#fields) | Field | Type | What it is | | ------------- | ------------------------------------ | ------------------------------------------------------------- | | `Dirs` | `IEnumerable?` | Roots globbed for `**/SKILL.md`, same as `Load`. | | `Skills` | `IReadOnlyList?` | Skills supplied as data — logical base, no disk access. | | `Filter` | `IReadOnlyDictionary?` | Post-discovery allowlist/drop-list over the merged skill set. | | `SampleLimit` | `int` | `0` ⇒ default 10, `n>0` ⇒ cap, `-1` ⇒ omit ``. | ## See also [Section titled “See also”](#see-also) * [`SkillSource.Load`](/api/csharp/skills/load/) — Glob a skills directory and expose one skill tool with progressive disclosure. * [`SkillSource.ListSkills`](/api/csharp/skills/list/) — Enumerate discovered skills and, crucially, the ones that were skipped and why. # Answer.Declined > Wraps a human's refusal into the Answer a suspended run resumes with, carrying a reason string that defaults to "declined". — C#. **C#** · package `Toolnexus` · [SPEC §10](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) ```csharp public static Answer Declined(string id, string reason = "declined"); ``` Wraps a human’s refusal into the `Answer` a suspended run resumes with, carrying a reason string that defaults to `"declined"`. It sets `Ok = false` — the field the loop actually branches on — and `Reason` as an advisory string a host may show in a log or a UI, but never as the resume decision itself. ## When to use it [Section titled “When to use it”](#when-to-use-it) Whenever the out-of-band resolution of a [`Request`](/api/csharp/suspension/pending/) is a refusal rather than an answer: a human clicked “deny,” an approval expired, or a durable queue’s consumer timed out waiting for input. Pass it to `Options.WaitFor`’s return, or to `AgentRuntime.ResumeAsync`, wherever the tool that suspended should be told “no” rather than given a value to work with. ## Why this and not the alternative [Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative) Reason is advisory only — the loop rule branches on Ok, never on the string `declined` | `cancelled` | `expired` are the three reasons the MCP elicitation bridge maps onto (`declined`→`decline`, everything else→`cancel`), but `Answer.Declined` accepts **any** string — nothing validates it against that set. Do not write code that branches on `Reason`’s value; the client loop, the runtime, and every consumer of `Answer` decide purely from `Ok`. Reach for [`Answer.Output`](/api/csharp/suspension/answer-output/) instead when the human actually supplied a result — `Answer.Declined` is never the right shape for “here is the value, but late” or “here is a partial answer.” ## 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) ```csharp using Toolnexus; var d = Answer.Declined("req-1", "declined"); if (d.Ok) throw new Exception("Answer.Declined always sets Ok = false"); if (d.Reason != "declined") throw new Exception(d.Reason); // The parameter default is the same string — Declined(id) alone means the same thing. var withDefault = Answer.Declined("req-2"); if (withDefault.Reason != "declined") throw new Exception(withDefault.Reason); if (withDefault.Ok) throw new Exception("Ok must be false regardless of the reason text"); Console.WriteLine($"ok: {d.Id} declined ({d.Reason}), {withDefault.Id} declined ({withDefault.Reason})"); ``` ### 2. The realistic case — a declined answer feeds back as a tool error, the loop keeps going [Section titled “2. The realistic case — a declined answer feeds back as a tool error, the loop keeps going”](#2-the-realistic-case--a-declined-answer-feeds-back-as-a-tool-error-the-loop-keeps-going) ```csharp using System.Net; using System.Text; using Toolnexus; var httpCalls = 0; using var stub = new Stub(ctx => { httpCalls++; if (httpCalls == 1) { Stub.Json(ctx, 200, """ {"choices":[{"message":{"role":"assistant","content":null,"tool_calls":[{"id":"call_1","type":"function","function":{"name":"approve_purchase","arguments":"{\"item\":\"gpu\"}"}}]},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":10,"completion_tokens":5,"total_tokens":15}} """); } else { Stub.Json(ctx, 200, """ {"choices":[{"message":{"role":"assistant","content":"The purchase was declined."},"finish_reason":"stop"}],"usage":{"prompt_tokens":15,"completion_tokens":4,"total_tokens":19}} """); } }); var approve = NativeTool.Of("approve_purchase", "Requests spend approval before proceeding", new Dictionary { ["type"] = "object", ["properties"] = new Dictionary { ["item"] = new Dictionary { ["type"] = "string" } }, }, (IDictionary a, ToolContext? ctx) => ctx?.Answer != null ? (object)ToolResult.Error("declined") : (object)ToolResult.Pending(new Request { Kind = "approval", Prompt = $"Approve purchase of {a["item"]}?" })); var client = LlmClient.Create(new LlmClient.Options { BaseUrl = stub.BaseUrl, Style = "openai", Model = "gpt-4o-mini", ApiKey = "test-key", // A human said no. WaitFor = req => Task.FromResult(Answer.Declined(req.Id, "declined")), }); await using var tk = await Toolkit.CreateAsync(new Toolkit.Options { ExtraTools = new List { approve } }); var result = await client.RunAsync("buy a gpu", tk); if (result.Status != "done") throw new Exception($"status: {result.Status}"); // NOT "pending" — WaitFor resolved it if (!result.ToolCalls[0].IsError) throw new Exception("the fed-back tool result must carry the decline"); Console.WriteLine($"ok: {result.Text} (tool result: {result.ToolCalls[0].Output})"); sealed class Stub : IDisposable { readonly HttpListener _listener = new(); readonly CancellationTokenSource _cts = new(); public int Port { get; } public string BaseUrl => $"http://127.0.0.1:{Port}"; public Stub(Action handler) { var probe = new System.Net.Sockets.TcpListener(IPAddress.Loopback, 0); probe.Start(); Port = ((IPEndPoint)probe.LocalEndpoint).Port; probe.Stop(); _listener.Prefixes.Add($"http://127.0.0.1:{Port}/"); _listener.Start(); _ = Task.Run(async () => { while (!_cts.IsCancellationRequested) { HttpListenerContext ctx; try { ctx = await _listener.GetContextAsync(); } catch { break; } try { handler(ctx); } catch { } } }); } public static void Json(HttpListenerContext ctx, int status, string body) { var bytes = Encoding.UTF8.GetBytes(body); ctx.Response.StatusCode = status; ctx.Response.ContentType = "application/json"; ctx.Response.ContentLength64 = bytes.Length; ctx.Response.OutputStream.Write(bytes, 0, bytes.Length); ctx.Response.OutputStream.Close(); } public void Dispose() { _cts.Cancel(); try { _listener.Stop(); } catch { } try { _listener.Close(); } catch { } } } ``` ### 3. Full surface — a custom reason string, and why `Reason` is advisory-only [Section titled “3. Full surface — a custom reason string, and why Reason is advisory-only”](#3-full-surface--a-custom-reason-string-and-why-reason-is-advisory-only) ```csharp using Toolnexus; // Any string is accepted — none of "declined"/"cancelled"/"expired" is enforced here. var expired = Answer.Declined("req-3", "expired"); var cancelled = Answer.Declined("req-4", "cancelled"); var custom = Answer.Declined("req-5", "manager rejected: over budget"); foreach (var a in new[] { expired, cancelled, custom }) if (a.Ok) throw new Exception($"{a.Id}: Ok must be false regardless of the reason text"); // Nothing in this port validates Reason against the R1 vocabulary — only Ok drives the loop. // A host that wants the MCP-elicitation-bridge mapping applies it itself: static string ToElicitAction(Answer a) => a.Ok ? "accept" : a.Reason == "declined" ? "decline" : "cancel"; // everything else maps to "cancel" if (ToElicitAction(expired) != "cancel") throw new Exception(ToElicitAction(expired)); if (ToElicitAction(custom) != "cancel") throw new Exception(ToElicitAction(custom)); if (ToElicitAction(Answer.Declined("req-6")) != "decline") throw new Exception("default reason maps to decline"); Console.WriteLine($"ok: {expired.Reason}, {cancelled.Reason}, and \"{custom.Reason}\" are all just Ok=false"); ``` ## Parameters [Section titled “Parameters”](#parameters) | Parameter | Type | What it is | | --------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `id` | `string` | Echoes the [`Request.Id`](/api/csharp/suspension/pending/) being answered. | | `reason` | `string` | Default `"declined"`. Advisory only — stored on `Answer.Reason`; distinguishes an explicit refusal from a dismissal/timeout for logging, but the loop and the runtime branch only on `Ok`. | ## See also [Section titled “See also”](#see-also) * [`Suspension.Pending`](/api/csharp/suspension/pending/) — Return a Pending from a tool to park the run until someone answers. * [`Suspension.AuthRequired`](/api/csharp/suspension/auth-required/) — The auth-shaped suspension: hand back a URL, resume once the user has granted access. * [`LlmClient.WaitFor`](/api/csharp/suspension/wait-for/) — The single hook where the host resolves a suspension — in-process prompt or durable queue, same contract. * [`Suspension.PendingOf`](/api/csharp/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. — C#. **C#** · package `Toolnexus` · [SPEC §10](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) ```csharp public static Answer Output(string id, string output); ``` Wraps a human’s typed string reply into the `Answer` a suspended run resumes with — the success counterpart to [`Answer.Declined`](/api/csharp/suspension/answer-declined/). Building the payload by hand (`new Answer { Id = …, Ok = true, Data = new Dictionary { ["output"] = … } }`) is what this removes: the `"output"` key stops being something a host can spell wrong on the way back out of a durable store, because there is no hand-built map left to spell it wrong in. ## When to use it [Section titled “When to use it”](#when-to-use-it) Anywhere you resolve a [`Request`](/api/csharp/suspension/pending/) with a plain string answer — the most common shape by far: a human typed a value, approved with a note, or a durable queue handed back a single string result. Pass it to `Options.WaitFor`’s return, or to `AgentRuntime.ResumeAsync`, wherever the resolution is “here is the output,” not a refusal. ## Why this and not the alternative [Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative) Reach for Answer.Declined when the human said no `Answer.Output` always sets `Ok = true` — it is not the vehicle for a refusal, a timeout, or an expired request. Use [`Answer.Declined`](/api/csharp/suspension/answer-declined/) for those; the loop rule branches only on `Ok`, so getting this one right is what determines whether the paused tool sees success or failure on the resumed retry. For anything richer than a single string result (structured `Data`, no explicit `output` key), build an `Answer` record literal directly — the typed constructor exists for the single-result shape specifically, not as the only way to build one. ## Examples [Section titled “Examples”](#examples) ### 1. The smallest useful call — build and read the pinned payload [Section titled “1. The smallest useful call — build and read the pinned payload”](#1-the-smallest-useful-call--build-and-read-the-pinned-payload) ```csharp using Toolnexus; var a = Answer.Output("req-1", "the result"); if (a.Id != "req-1") throw new Exception(a.Id); if (!a.Ok) throw new Exception("Answer.Output always sets Ok = true"); if ((string?)a.Data!["output"] != "the result") throw new Exception(a.Data["output"]?.ToString()); // A non-string output cannot silently degrade to "" — the type is the contract. try { Answer.Output("req-1", null!); throw new Exception("expected ArgumentNullException"); } catch (ArgumentNullException) { /* expected */ } Console.WriteLine($"ok: {a.Id} -> {a.Data["output"]}"); ``` ### 2. The realistic case — resolving a paused tool via `WaitFor` [Section titled “2. The realistic case — resolving a paused tool via WaitFor”](#2-the-realistic-case--resolving-a-paused-tool-via-waitfor) ```csharp using System.Net; using System.Text; using Toolnexus; string? seen = null; var tool = NativeTool.Of("ask", "asks a human", new Dictionary { ["type"] = "object", ["properties"] = new Dictionary() }, (IDictionary _, ToolContext? ctx) => { // First call: no resolution yet — park the run. if (ctx?.Answer?.Ok != true) return ToolResult.Pending(new Request { Id = "r1", Kind = "input", Prompt = "what's the value?" }); // Resumed retry: ctx.Answer carries the Answer.Output payload. seen = ctx.Answer.Data!["output"]?.ToString(); return (object)("got " + seen); }); var httpCalls = 0; using var stub = new Stub(ctx => { httpCalls++; var body = httpCalls == 1 ? """{"choices":[{"message":{"role":"assistant","content":null,"tool_calls":[{"id":"c1","type":"function","function":{"name":"ask","arguments":"{}"}}]},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":5,"completion_tokens":2,"total_tokens":7}}""" : """{"choices":[{"message":{"role":"assistant","content":"got the answer"},"finish_reason":"stop"}],"usage":{"prompt_tokens":5,"completion_tokens":2,"total_tokens":7}}"""; Stub.Json(ctx, 200, body); }); var client = LlmClient.Create(new LlmClient.Options { BaseUrl = stub.BaseUrl, Style = "openai", Model = "gpt-4o-mini", ApiKey = "test-key", // The out-of-band resolution: a human typed "42". WaitFor = req => Task.FromResult(Answer.Output(req.Id, "42")), }); await using var tk = await Toolkit.CreateAsync(new Toolkit.Options().WithBuiltins(false)); tk.Register(tool); await client.RunAsync("go", tk); if (seen != "42") throw new Exception($"seen: {seen}"); Console.WriteLine($"ok: the paused tool resumed with \"{seen}\""); sealed class Stub : IDisposable { readonly HttpListener _listener = new(); readonly CancellationTokenSource _cts = new(); public int Port { get; } public string BaseUrl => $"http://127.0.0.1:{Port}"; public Stub(Action handler) { var probe = new System.Net.Sockets.TcpListener(IPAddress.Loopback, 0); probe.Start(); Port = ((IPEndPoint)probe.LocalEndpoint).Port; probe.Stop(); _listener.Prefixes.Add($"http://127.0.0.1:{Port}/"); _listener.Start(); _ = Task.Run(async () => { while (!_cts.IsCancellationRequested) { HttpListenerContext ctx; try { ctx = await _listener.GetContextAsync(); } catch { break; } try { handler(ctx); } catch { } } }); } public static void Json(HttpListenerContext ctx, int status, string body) { var bytes = Encoding.UTF8.GetBytes(body); ctx.Response.StatusCode = status; ctx.Response.ContentType = "application/json"; ctx.Response.ContentLength64 = bytes.Length; ctx.Response.OutputStream.Write(bytes, 0, bytes.Length); ctx.Response.OutputStream.Close(); } public void Dispose() { _cts.Cancel(); try { _listener.Stop(); } catch { } try { _listener.Close(); } catch { } } } ``` ### 3. Full surface — resuming a suspended sub-agent through the §7D runtime [Section titled “3. Full surface — resuming a suspended sub-agent through the §7D runtime”](#3-full-surface--resuming-a-suspended-sub-agent-through-the-7d-runtime) ```csharp using Toolnexus; using Toolnexus.Agents; // (Illustrative shape — see AgentRuntimeTests.cs `ResumeReturnsTheResumedResult` for the full // hermetic fixture with a scripted MockLlm.) A tool inside a spawned agent suspended; the caller // resolves it with a plain string, exactly as it would for the top-level client's WaitFor. Answer BuildResolution(string pendingId) => Answer.Output(pendingId, "approved"); var resolution = BuildResolution("agent-req-9"); if (!resolution.Ok) throw new Exception("Answer.Output must always be Ok = true"); if ((string?)resolution.Data!["output"] != "approved") throw new Exception("output key"); // rt.ResumeAsync(resolution) would route this to the deepest suspended handle waiting on // "agent-req-9" and return the resumed AgentResult once the agent's turn completes. Console.WriteLine($"ok: resolution for {resolution.Id} carries output=\"{resolution.Data["output"]}\""); ``` ## Parameters [Section titled “Parameters”](#parameters) | Parameter | Type | What it is | | --------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `id` | `string` | Echoes the [`Request.Id`](/api/csharp/suspension/pending/) being answered. Throws `ArgumentNullException` if `null`. | | `output` | `string` | The tool’s result, stored at `Data["output"]`. Throws `ArgumentNullException` if `null` — a non-string result is rejected rather than silently degraded to `""`. | ## See also [Section titled “See also”](#see-also) * [`Suspension.Pending`](/api/csharp/suspension/pending/) — Return a Pending from a tool to park the run until someone answers. * [`Suspension.AuthRequired`](/api/csharp/suspension/auth-required/) — The auth-shaped suspension: hand back a URL, resume once the user has granted access. * [`LlmClient.WaitFor`](/api/csharp/suspension/wait-for/) — The single hook where the host resolves a suspension — in-process prompt or durable queue, same contract. * [`Suspension.PendingOf`](/api/csharp/suspension/pending-of/) — Detect that a RunResult is parked rather than finished, and get the Request that parked it. # ToolResult.AuthRequired > The auth-shaped suspension: hand back a URL, resume once the user has granted access. — C#. **C#** · package `Toolnexus` · [SPEC §10](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [ToolResult.cs](https://github.com/muthuishere/toolnexus/blob/main/csharp/src/Toolnexus/ToolResult.cs) ```csharp public sealed record ToolResult(string Output, bool IsError, IDictionary? Metadata = null) { public static ToolResult AuthRequired(string url, string prompt = "Authorization required to continue"); } ``` Not \`Suspension.AuthRequired\` This port puts the §10 producer helpers directly on **`ToolResult`** — there is no separate `Suspension` class in C#. Java does the same; JavaScript, Python and Go expose free functions (`authRequired`/`auth_required`) instead. Sugar over [`ToolResult.Pending`](/api/csharp/suspension/pending/) for the login case: it builds a `Request` with `Kind = "authorization"` and the given `Url`, with a sensible default `Prompt`. There is no auth subsystem in toolnexus — `kind:"authorization"` is a convention, not special handling: it means “the host’s `WaitFor` should perform an OAuth2/OIDC-shaped redirect → consent → callback out-of-band,” but the kernel itself never touches OIDC. ## When to use it [Section titled “When to use it”](#when-to-use-it) A tool needs a valid session before it can do anything useful — an API that requires a signed-in user, a service behind SSO. Return `AuthRequired(loginUrl)` the first time; the host’s `WaitFor` drives the actual login flow, and the tool is retried once login succeeds. ## Why this and not the alternative [Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative) Reach for ToolResult.Pending when it isn't a URL-based login `AuthRequired` only fits the “go to this link and come back” shape. An approval, a free-text question, or any suspension without a URL should use [`ToolResult.Pending`](/api/csharp/suspension/pending/) directly with the `Kind` that fits — `"approval"`, `"input"`, or an open vocabulary of your own choosing. ## Examples [Section titled “Examples”](#examples) ### 1. The smallest useful call — the default prompt [Section titled “1. The smallest useful call — the default prompt”](#1-the-smallest-useful-call--the-default-prompt) ```csharp using Toolnexus; var res = ToolResult.AuthRequired("https://example.com/login"); if (!res.IsError) throw new Exception("a parked call is not a success"); var req = ToolResult.PendingOf(res)!; if (req.Kind != "authorization") throw new Exception(req.Kind); if (req.Url != "https://example.com/login") throw new Exception(req.Url); if (req.Prompt != "Authorization required to continue") throw new Exception(req.Prompt); Console.WriteLine($"ok: {req.Kind} at {req.Url}"); ``` ### 2. A custom prompt, and ignoring the answer’s payload — the session is what changed [Section titled “2. A custom prompt, and ignoring the answer’s payload — the session is what changed”](#2-a-custom-prompt-and-ignoring-the-answers-payload--the-session-is-what-changed) ```csharp using Toolnexus; var res = ToolResult.AuthRequired("https://accounts.example.com/authorize", "Sign in to read your calendar"); var req = ToolResult.PendingOf(res)!; if (req.Prompt != "Sign in to read your calendar") throw new Exception(req.Prompt); // A tool behind AuthRequired typically doesn't need answer.Data — the world changed out-of-band // (the session is now valid), so it just re-runs its normal logic on the retry. var calendar = NativeTool.Of("read_calendar", "reads today's events", null, (IDictionary _, ToolContext? ctx) => ctx?.Answer?.Ok == true ? (object)"09:00 standup, 14:00 1:1" : (object)ToolResult.AuthRequired("https://accounts.example.com/authorize", "Sign in to read your calendar")); var first = await calendar.ExecuteAsync(new Dictionary()); if (ToolResult.PendingOf(first) is null) throw new Exception("expected a suspension on the first call"); var retried = await calendar.ExecuteAsync(new Dictionary(), new ToolContext(answer: new Answer { Id = req.Id, Ok = true })); if (retried.IsError || retried.Output != "09:00 standup, 14:00 1:1") throw new Exception(retried.Output); Console.WriteLine($"ok: suspended, then resolved: {retried.Output}"); ``` ### 3. Full surface — inside a real client run, `WaitFor` performs the “login” [Section titled “3. Full surface — inside a real client run, WaitFor performs the “login””](#3-full-surface--inside-a-real-client-run-waitfor-performs-the-login) ```csharp using System.Net; using System.Text; using Toolnexus; var httpCalls = 0; using var stub = new Stub(ctx => { httpCalls++; if (httpCalls == 1) { Stub.Json(ctx, 200, """ {"id":"c1","choices":[{"message":{"role":"assistant","content":null,"tool_calls":[{"id":"call_1","type":"function","function":{"name":"read_calendar","arguments":"{}"}}]},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":8,"completion_tokens":4,"total_tokens":12}} """); } else { Stub.Json(ctx, 200, """ {"id":"c2","choices":[{"message":{"role":"assistant","content":"You have a 09:00 standup and a 14:00 1:1."},"finish_reason":"stop"}],"usage":{"prompt_tokens":12,"completion_tokens":6,"total_tokens":18}} """); } }); var loginsPerformed = 0; var calendar = NativeTool.Of("read_calendar", "reads today's events", null, (IDictionary _, ToolContext? ctx) => ctx?.Answer?.Ok == true ? (object)"09:00 standup, 14:00 1:1" : (object)ToolResult.AuthRequired("https://accounts.example.com/authorize")); var client = LlmClient.Create(new LlmClient.Options { BaseUrl = stub.BaseUrl, Style = "openai", Model = "gpt-4o-mini", ApiKey = "test-key", WaitFor = request => { if (request.Kind != "authorization") throw new Exception($"unexpected kind: {request.Kind}"); loginsPerformed++; // stand in for driving an actual OAuth redirect/consent/callback return Task.FromResult(new Answer { Id = request.Id, Ok = true }); }, }); await using var tk = await Toolkit.CreateAsync(new Toolkit.Options { ExtraTools = new List { calendar } }); var result = await client.RunAsync("what's on my calendar today?", tk); if (result.Status != "done") throw new Exception($"status: {result.Status}"); if (loginsPerformed != 1) throw new Exception($"expected exactly one login, got {loginsPerformed}"); if (result.Text != "You have a 09:00 standup and a 14:00 1:1.") throw new Exception(result.Text); Console.WriteLine($"ok: {result.Text} (1 login, tool retried once)"); sealed class Stub : IDisposable { readonly HttpListener _listener = new(); readonly CancellationTokenSource _cts = new(); public int Port { get; } public string BaseUrl => $"http://127.0.0.1:{Port}"; public Stub(Action handler) { var probe = new System.Net.Sockets.TcpListener(IPAddress.Loopback, 0); probe.Start(); Port = ((IPEndPoint)probe.LocalEndpoint).Port; probe.Stop(); _listener.Prefixes.Add($"http://127.0.0.1:{Port}/"); _listener.Start(); _ = Task.Run(async () => { while (!_cts.IsCancellationRequested) { HttpListenerContext ctx; try { ctx = await _listener.GetContextAsync(); } catch { break; } try { handler(ctx); } catch { } } }); } public static void Json(HttpListenerContext ctx, int status, string body) { var bytes = Encoding.UTF8.GetBytes(body); ctx.Response.StatusCode = status; ctx.Response.ContentType = "application/json"; ctx.Response.ContentLength64 = bytes.Length; ctx.Response.OutputStream.Write(bytes, 0, bytes.Length); ctx.Response.OutputStream.Close(); } public void Dispose() { _cts.Cancel(); try { _listener.Stop(); } catch { } try { _listener.Close(); } catch { } } } ``` Caution `Kind == "authorization"` is a convention, not a code path with special handling in the kernel — there is no OIDC library inside toolnexus. Everything about *how* login happens lives in your `WaitFor`, at the edge, which is what keeps the mechanism identical whether the “auth” is OAuth2, a magic link, or a Slack approval. ## Parameters [Section titled “Parameters”](#parameters) | Parameter | Type | What it is | | --------- | -------- | -------------------------------------------------------------------------- | | `url` | `string` | The authorize endpoint / login link the host’s `WaitFor` should drive. | | `prompt` | `string` | Human-readable prompt. Defaults to `"Authorization required to continue"`. | ## See also [Section titled “See also”](#see-also) * [`ToolResult.Pending`](/api/csharp/suspension/pending/) — Return a Pending from a tool to park the run until someone answers. * [`LlmClient.WaitFor`](/api/csharp/suspension/wait-for/) — The single hook where the host resolves a suspension — in-process prompt or durable queue, same contract. * [`ToolResult.PendingOf`](/api/csharp/suspension/pending-of/) — Detect that a RunResult is parked rather than finished, and get the Request that parked it. * [`ToolResult`](/api/csharp/types/tool-result/) — The result envelope this rides on. # ToolResult.Pending > Return a Pending from a tool to park the run until someone answers. — C#. **C#** · package `Toolnexus` · [SPEC §10](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [ToolResult.cs](https://github.com/muthuishere/toolnexus/blob/main/csharp/src/Toolnexus/ToolResult.cs) ```csharp public sealed record ToolResult(string Output, bool IsError, IDictionary? Metadata = null) { public static ToolResult Pending(Request request); } ``` Not \`Suspension.Pending\` This port puts the §10 producer helpers directly on **`ToolResult`** — there is no separate `Suspension` class in C#. Java does the same; JavaScript, Python and Go expose free functions named `pending`/`Pending` instead. See [`ToolResult`](/api/csharp/types/tool-result/) for the full type. A tool that cannot finish in one shot returns `ToolResult.Pending(request)` instead of an answer. It builds a `ToolResult` with `IsError = true` and `Metadata["pending"] = request` — a correlation id is generated for you when `request.Id` is empty. The client loop reads that metadata key, calls the configured [`WaitFor`](/api/csharp/suspension/wait-for/), and on an `Ok` answer **re-executes the same tool once** with the resolution attached to `ToolContext.Answer`. ## When to use it [Section titled “When to use it”](#when-to-use-it) Any tool whose real answer depends on something outside the current process — a human’s approval, a login flow, a file that hasn’t arrived yet. Return `Pending` on the first call; check `ctx?.Answer` to detect you’re on the resumed retry and finish the job using the resolution. ## Why this and not the alternative [Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative) Reach for ToolResult.AuthRequired for the login-shaped case `Pending` takes a full `Request` — any `Kind`, any `Prompt`, any `Data`. When the suspension is specifically “the user needs to log in at a URL”, [`ToolResult.AuthRequired`](/api/csharp/suspension/auth-required/) is the same mechanism with the `kind:"authorization"` shape and a default prompt already filled in — less to type for the most common case. ## Examples [Section titled “Examples”](#examples) ### 1. The smallest useful call — build and read a suspension [Section titled “1. The smallest useful call — build and read a suspension”](#1-the-smallest-useful-call--build-and-read-a-suspension) ```csharp using Toolnexus; var res = ToolResult.Pending(new Request { Kind = "approval", Prompt = "Approve refund of $40?" }); if (!res.IsError) throw new Exception("a parked call is not a success"); var req = ToolResult.PendingOf(res)!; if (req.Kind != "approval") throw new Exception(req.Kind); if (req.Prompt != "Approve refund of $40?") throw new Exception(req.Prompt); if (string.IsNullOrEmpty(req.Id)) throw new Exception("an id is generated when none is supplied"); Console.WriteLine($"ok: {req.Kind} #{req.Id}"); ``` ### 2. The realistic case — a full suspend/resolve loop against a stub LLM [Section titled “2. The realistic case — a full suspend/resolve loop against a stub LLM”](#2-the-realistic-case--a-full-suspendresolve-loop-against-a-stub-llm) ```csharp using System.Net; using System.Text; using Toolnexus; var httpCalls = 0; using var stub = new Stub(ctx => { httpCalls++; if (httpCalls == 1) { Stub.Json(ctx, 200, """ {"id":"c1","choices":[{"message":{"role":"assistant","content":null,"tool_calls":[{"id":"call_1","type":"function","function":{"name":"approve_purchase","arguments":"{\"item\":\"gpu\"}"}}]},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":10,"completion_tokens":5,"total_tokens":15}} """); } else { Stub.Json(ctx, 200, """ {"id":"c2","choices":[{"message":{"role":"assistant","content":"Purchase approved."},"finish_reason":"stop"}],"usage":{"prompt_tokens":15,"completion_tokens":4,"total_tokens":19}} """); } }); var approve = NativeTool.Of("approve_purchase", "Requests spend approval before proceeding", new Dictionary { ["type"] = "object", ["properties"] = new Dictionary { ["item"] = new Dictionary { ["type"] = "string" } }, }, (IDictionary a, ToolContext? ctx) => { // On the RESUMED retry, ctx.Answer carries the resolution — this is how the tool tells // "first call" (suspend) from "post-WaitFor retry" (finish) apart. if (ctx?.Answer != null) return (object)(ctx.Answer.Ok ? $"approved: {a["item"]}" : ToolResult.Error("declined")); return (object)ToolResult.Pending(new Request { Kind = "approval", Prompt = $"Approve purchase of {a["item"]}?" }); }); var client = LlmClient.Create(new LlmClient.Options { BaseUrl = stub.BaseUrl, Style = "openai", Model = "gpt-4o-mini", ApiKey = "test-key", WaitFor = async request => { // Stand in for a human clicking "approve" — any out-of-band resolution works the same way. await Task.Delay(1); return new Answer { Id = request.Id, Ok = true }; }, }); await using var tk = await Toolkit.CreateAsync(new Toolkit.Options { ExtraTools = new List { approve } }); var result = await client.RunAsync("buy a gpu", tk); if (result.Status != "done") throw new Exception($"status: {result.Status}"); if (result.Text != "Purchase approved.") throw new Exception(result.Text); if (result.ToolCalls[0].Output != "approved: gpu") throw new Exception(result.ToolCalls[0].Output); Console.WriteLine($"ok: {result.Text} (resolved via {httpCalls} LLM calls, tool retried once)"); sealed class Stub : IDisposable { readonly HttpListener _listener = new(); readonly CancellationTokenSource _cts = new(); public int Port { get; } public string BaseUrl => $"http://127.0.0.1:{Port}"; public Stub(Action handler) { var probe = new System.Net.Sockets.TcpListener(IPAddress.Loopback, 0); probe.Start(); Port = ((IPEndPoint)probe.LocalEndpoint).Port; probe.Stop(); _listener.Prefixes.Add($"http://127.0.0.1:{Port}/"); _listener.Start(); _ = Task.Run(async () => { while (!_cts.IsCancellationRequested) { HttpListenerContext ctx; try { ctx = await _listener.GetContextAsync(); } catch { break; } try { handler(ctx); } catch { } } }); } public static void Json(HttpListenerContext ctx, int status, string body) { var bytes = Encoding.UTF8.GetBytes(body); ctx.Response.StatusCode = status; ctx.Response.ContentType = "application/json"; ctx.Response.ContentLength64 = bytes.Length; ctx.Response.OutputStream.Write(bytes, 0, bytes.Length); ctx.Response.OutputStream.Close(); } public void Dispose() { _cts.Cancel(); try { _listener.Stop(); } catch { } try { _listener.Close(); } catch { } } } ``` ### 3. Full surface — a declined answer feeds back an error, the loop keeps going [Section titled “3. Full surface — a declined answer feeds back an error, the loop keeps going”](#3-full-surface--a-declined-answer-feeds-back-an-error-the-loop-keeps-going) ```csharp using System.Net; using System.Text; using Toolnexus; var httpCalls = 0; using var stub = new Stub(ctx => { httpCalls++; if (httpCalls == 1) { Stub.Json(ctx, 200, """ {"id":"c1","choices":[{"message":{"role":"assistant","content":null,"tool_calls":[{"id":"call_1","type":"function","function":{"name":"approve_purchase","arguments":"{\"item\":\"gpu\"}"}}]},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":10,"completion_tokens":5,"total_tokens":15}} """); } else { Stub.Json(ctx, 200, """ {"id":"c2","choices":[{"message":{"role":"assistant","content":"The purchase was declined."},"finish_reason":"stop"}],"usage":{"prompt_tokens":15,"completion_tokens":4,"total_tokens":19}} """); } }); var approve = NativeTool.Of("approve_purchase", "Requests spend approval before proceeding", new Dictionary { ["type"] = "object", ["properties"] = new Dictionary { ["item"] = new Dictionary { ["type"] = "string" } }, }, (IDictionary a, ToolContext? ctx) => ctx?.Answer != null ? (object)ToolResult.Error("declined") : (object)ToolResult.Pending(new Request { Kind = "approval", Prompt = $"Approve purchase of {a["item"]}?" })); var client = LlmClient.Create(new LlmClient.Options { BaseUrl = stub.BaseUrl, Style = "openai", Model = "gpt-4o-mini", ApiKey = "test-key", // A human said no — WaitFor reports it, the loop never re-executes the tool for a real answer. WaitFor = request => Task.FromResult(new Answer { Id = request.Id, Ok = false, Reason = "declined" }), }); await using var tk = await Toolkit.CreateAsync(new Toolkit.Options { ExtraTools = new List { approve } }); var result = await client.RunAsync("buy a gpu", tk); if (result.Status != "done") throw new Exception($"status: {result.Status}"); // NOT "pending" — WaitFor resolved it if (result.Text != "The purchase was declined.") throw new Exception(result.Text); if (!result.ToolCalls[0].IsError) throw new Exception("the fed-back tool result must carry the decline"); if (!result.ToolCalls[0].Output.Contains("declined/expired")) throw new Exception(result.ToolCalls[0].Output); Console.WriteLine($"ok: {result.Text} (tool result: {result.ToolCalls[0].Output})"); sealed class Stub : IDisposable { readonly HttpListener _listener = new(); readonly CancellationTokenSource _cts = new(); public int Port { get; } public string BaseUrl => $"http://127.0.0.1:{Port}"; public Stub(Action handler) { var probe = new System.Net.Sockets.TcpListener(IPAddress.Loopback, 0); probe.Start(); Port = ((IPEndPoint)probe.LocalEndpoint).Port; probe.Stop(); _listener.Prefixes.Add($"http://127.0.0.1:{Port}/"); _listener.Start(); _ = Task.Run(async () => { while (!_cts.IsCancellationRequested) { HttpListenerContext ctx; try { ctx = await _listener.GetContextAsync(); } catch { break; } try { handler(ctx); } catch { } } }); } public static void Json(HttpListenerContext ctx, int status, string body) { var bytes = Encoding.UTF8.GetBytes(body); ctx.Response.StatusCode = status; ctx.Response.ContentType = "application/json"; ctx.Response.ContentLength64 = bytes.Length; ctx.Response.OutputStream.Write(bytes, 0, bytes.Length); ctx.Response.OutputStream.Close(); } public void Dispose() { _cts.Cancel(); try { _listener.Stop(); } catch { } try { _listener.Close(); } catch { } } } ``` Caution A suspension is never a tool error for observability purposes: the `tool` metric event carries `IsError = false` and a `pending: true` marker, and `Hooks.AfterTool`’s failure path does not run on it. Don’t treat `IsError == true` alone as “the tool broke” — check [`ToolResult.PendingOf`](/api/csharp/suspension/pending-of/) first. ## Parameters [Section titled “Parameters”](#parameters) | Parameter | Type | What it is | | --------- | --------- | ------------------------------------------------------------------------------------------------------------- | | `request` | `Request` | What is being asked. `Id` is filled in for you when empty — the correlation key `WaitFor`/`Answer` echo back. | ## See also [Section titled “See also”](#see-also) * [`ToolResult.AuthRequired`](/api/csharp/suspension/auth-required/) — The auth-shaped suspension: hand back a URL, resume once the user has granted access. * [`LlmClient.WaitFor`](/api/csharp/suspension/wait-for/) — The single hook where the host resolves a suspension — in-process prompt or durable queue, same contract. * [`ToolResult.PendingOf`](/api/csharp/suspension/pending-of/) — Detect that a RunResult is parked rather than finished, and get the Request that parked it. * [`ToolResult`](/api/csharp/types/tool-result/) — The result envelope this rides on. # ToolResult.PendingOf > Detect that a RunResult is parked rather than finished, and get the Request that parked it. — C#. **C#** · package `Toolnexus` · [SPEC §10](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [ToolResult.cs](https://github.com/muthuishere/toolnexus/blob/main/csharp/src/Toolnexus/ToolResult.cs) ```csharp public sealed record ToolResult(string Output, bool IsError, IDictionary? Metadata = null) { public static Request? PendingOf(ToolResult? result); } ``` Not \`Suspension.PendingOf\` This port puts the §10 reader directly on **`ToolResult`** — there is no separate `Suspension` class in C#. Java does the same; JavaScript, Python and Go expose a free function (`pendingOf`/`pending_of`) instead. Reads `Metadata["pending"]` back off a `ToolResult` as a typed `Request`, or returns `null` when the result is an ordinary success/error. It is the one honest way to ask “is this result actually a suspension?” — `IsError` alone can’t tell you, because a suspension also sets `IsError = true`. `PendingOf` accepts a nullable `ToolResult`, so it’s safe to call on a possibly-absent result without a separate null check. ## When to use it [Section titled “When to use it”](#when-to-use-it) Inside a [`Hooks.BeforeTool`/`AfterTool`](/api/csharp/client/hooks/) callback, or any code that inspects a `ToolResult` before the client loop’s own suspension handling runs — logging, metrics, a custom retry policy. The loop itself already uses `PendingOf` internally to decide whether a result should trigger `WaitFor`; reach for it yourself whenever you’re building similar logic on top of a raw `ToolResult`. ## Why this and not the alternative [Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative) Reach for RunResult.Pending once the run has already returned `PendingOf` reads a suspension off a single **tool call’s** `ToolResult` — useful mid-loop, in a hook. Once `RunAsync` has returned, the run-level answer is already sitting on `RunResult.Pending` (non-null exactly when `RunResult.Status == "pending"`) — no need to dig back into `Metadata` yourself at that point. ## Examples [Section titled “Examples”](#examples) ### 1. The smallest useful call — success, error, and suspension side by side [Section titled “1. The smallest useful call — success, error, and suspension side by side”](#1-the-smallest-useful-call--success-error-and-suspension-side-by-side) ```csharp using Toolnexus; var ok = ToolResult.Ok("done"); var err = ToolResult.Error("not found"); var suspended = ToolResult.Pending(new Request { Kind = "input", Prompt = "which environment?" }); if (ToolResult.PendingOf(ok) is not null) throw new Exception("an ordinary success is not a suspension"); if (ToolResult.PendingOf(err) is not null) throw new Exception("an ordinary error is not a suspension"); var req = ToolResult.PendingOf(suspended); if (req is null || req.Kind != "input") throw new Exception("expected the suspension's Request back"); // Safe on null, too — no separate has-value check needed. if (ToolResult.PendingOf(null) is not null) throw new Exception("null in, null out"); Console.WriteLine($"ok: ok={ToolResult.PendingOf(ok)}, err={ToolResult.PendingOf(err)}, suspended={req.Kind}"); ``` ### 2. The realistic case — an `AfterTool` hook that tells suspensions from real failures [Section titled “2. The realistic case — an AfterTool hook that tells suspensions from real failures”](#2-the-realistic-case--an-aftertool-hook-that-tells-suspensions-from-real-failures) ```csharp using System.Net; using System.Text; using Toolnexus; using var stub = new Stub(ctx => { Stub.Json(ctx, 200, """ {"id":"c1","choices":[{"message":{"role":"assistant","content":null,"tool_calls":[{"id":"call_1","type":"function","function":{"name":"approve_purchase","arguments":"{}"}}]},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":10,"completion_tokens":5,"total_tokens":15}} """); }); var approve = NativeTool.Of("approve_purchase", "requests approval", null, (IDictionary _, ToolContext? _) => (object)ToolResult.Pending(new Request { Kind = "approval", Prompt = "approve?" })); var sawSuspension = false; var sawFailure = false; var client = LlmClient.Create(new LlmClient.Options { BaseUrl = stub.BaseUrl, Style = "openai", Model = "gpt-4o-mini", ApiKey = "test-key", Hooks = new LlmClient.Hooks { AfterTool = ev => { // Distinguish "parked, waiting on the world" from "actually broke" — IsError is true // on BOTH, so PendingOf is the only reliable check. if (ToolResult.PendingOf(ev.Result) is not null) sawSuspension = true; else if (ev.Result.IsError) sawFailure = true; return null; }, }, // No WaitFor here — this run is expected to halt as "pending", not resolve inline. }); await using var tk = await Toolkit.CreateAsync(new Toolkit.Options { ExtraTools = new List { approve } }); var result = await client.RunAsync("buy a gpu", tk); if (result.Status != "pending") throw new Exception(result.Status); // AfterTool is NOT called for a suspension (SPEC §10) — only PendingOf inside the loop itself sees it. if (sawSuspension) throw new Exception("AfterTool must not fire on a suspended call"); if (sawFailure) throw new Exception("a suspension must never be classified as a failure"); Console.WriteLine($"ok: status={result.Status}, AfterTool saw neither a suspension nor a failure"); sealed class Stub : IDisposable { readonly HttpListener _listener = new(); readonly CancellationTokenSource _cts = new(); public int Port { get; } public string BaseUrl => $"http://127.0.0.1:{Port}"; public Stub(Action handler) { var probe = new System.Net.Sockets.TcpListener(IPAddress.Loopback, 0); probe.Start(); Port = ((IPEndPoint)probe.LocalEndpoint).Port; probe.Stop(); _listener.Prefixes.Add($"http://127.0.0.1:{Port}/"); _listener.Start(); _ = Task.Run(async () => { while (!_cts.IsCancellationRequested) { HttpListenerContext ctx; try { ctx = await _listener.GetContextAsync(); } catch { break; } try { handler(ctx); } catch { } } }); } public static void Json(HttpListenerContext ctx, int status, string body) { var bytes = Encoding.UTF8.GetBytes(body); ctx.Response.StatusCode = status; ctx.Response.ContentType = "application/json"; ctx.Response.ContentLength64 = bytes.Length; ctx.Response.OutputStream.Write(bytes, 0, bytes.Length); ctx.Response.OutputStream.Close(); } public void Dispose() { _cts.Cancel(); try { _listener.Stop(); } catch { } try { _listener.Close(); } catch { } } } ``` ### 3. Full surface — reading a suspension straight off `RunResult.Pending` [Section titled “3. Full surface — reading a suspension straight off RunResult.Pending”](#3-full-surface--reading-a-suspension-straight-off-runresultpending) ```csharp using System.Net; using System.Text; using Toolnexus; using var stub = new Stub(ctx => { Stub.Json(ctx, 200, """ {"id":"c1","choices":[{"message":{"role":"assistant","content":null,"tool_calls":[{"id":"call_1","type":"function","function":{"name":"read_calendar","arguments":"{}"}}]},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":8,"completion_tokens":4,"total_tokens":12}} """); }); var calendar = NativeTool.Of("read_calendar", "reads events", null, (IDictionary _, ToolContext? _) => (object)ToolResult.AuthRequired("https://accounts.example.com/authorize")); var client = LlmClient.Create(new LlmClient.Options { BaseUrl = stub.BaseUrl, Style = "openai", Model = "gpt-4o-mini", ApiKey = "test-key" }); await using var tk = await Toolkit.CreateAsync(new Toolkit.Options { ExtraTools = new List { calendar } }); var result = await client.RunAsync("what's on my calendar?", tk); // RunResult already surfaces the Request directly — no need to dig back through Metadata here. if (result.Status != "pending") throw new Exception(result.Status); if (result.Pending is null) throw new Exception("expected RunResult.Pending to be set"); if (result.Pending.Kind != "authorization") throw new Exception(result.Pending.Kind); if (result.Pending.Url != "https://accounts.example.com/authorize") throw new Exception(result.Pending.Url); Console.WriteLine($"ok: parked on {result.Pending.Kind} at {result.Pending.Url}"); sealed class Stub : IDisposable { readonly HttpListener _listener = new(); readonly CancellationTokenSource _cts = new(); public int Port { get; } public string BaseUrl => $"http://127.0.0.1:{Port}"; public Stub(Action handler) { var probe = new System.Net.Sockets.TcpListener(IPAddress.Loopback, 0); probe.Start(); Port = ((IPEndPoint)probe.LocalEndpoint).Port; probe.Stop(); _listener.Prefixes.Add($"http://127.0.0.1:{Port}/"); _listener.Start(); _ = Task.Run(async () => { while (!_cts.IsCancellationRequested) { HttpListenerContext ctx; try { ctx = await _listener.GetContextAsync(); } catch { break; } try { handler(ctx); } catch { } } }); } public static void Json(HttpListenerContext ctx, int status, string body) { var bytes = Encoding.UTF8.GetBytes(body); ctx.Response.StatusCode = status; ctx.Response.ContentType = "application/json"; ctx.Response.ContentLength64 = bytes.Length; ctx.Response.OutputStream.Write(bytes, 0, bytes.Length); ctx.Response.OutputStream.Close(); } public void Dispose() { _cts.Cancel(); try { _listener.Stop(); } catch { } try { _listener.Close(); } catch { } } } ``` Caution Don’t need example 2’s stub in your own code just to call `PendingOf` — it’s a pure, synchronous reader. The stub/client machinery above exists only to show it wired into a real hook, where it matters most. ## Parameters [Section titled “Parameters”](#parameters) | Parameter | Type | What it is | | --------- | ------------- | ------------------------------------------------------------- | | `result` | `ToolResult?` | The result to inspect. `null` is accepted and returns `null`. | ## Returns [Section titled “Returns”](#returns) `Request?` — the suspension’s request when `result.Metadata["pending"]` is present, otherwise `null`. ## See also [Section titled “See also”](#see-also) * [`ToolResult.Pending`](/api/csharp/suspension/pending/) — Return a Pending from a tool to park the run until someone answers. * [`ToolResult.AuthRequired`](/api/csharp/suspension/auth-required/) — The auth-shaped suspension: hand back a URL, resume once the user has granted access. * [`LlmClient.WaitFor`](/api/csharp/suspension/wait-for/) — The single hook where the host resolves a suspension — in-process prompt or durable queue, same contract. * [`ToolResult`](/api/csharp/types/tool-result/) — The result envelope this rides on. # Relay tools — declaration-only > Declare a tool the host executes, not the library: the call rides out on the suspension. — C#. **C#** · package `Toolnexus` · [SPEC §10](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) Not available in C# Go-only today (golang/relay.go). SPEC §10 addendum landed as a Go preview; the other five ports do not ship a relay tool yet. 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) Relay exists for exactly one posture: a **proxy** that owns no execution at all — every tool call, even a plain function call, is surfaced to the host and run there, with toolnexus doing nothing but wire-format translation. Two ordinary C# mechanisms get you close to that outcome today, depending on which half of “proxy” you actually need: * **The caller executes tools itself, and owns the conversation.** This is the standard OpenAI function-calling posture relay is a stand-in for. Use [`LlmClient.TranslateAsync`](/api/csharp/translate/translate/) (SPEC §11): declare a `Toolkit` or raw `tools` array, get back the model’s `ToolCalls` completely undeclared-and-unexecuted, and run them however you like on your side. No suspension involved at all. * **toolnexus owns the conversation, but one specific call needs the host to answer it.** Return [`ToolResult.Pending`](/api/csharp/suspension/pending/) from that tool and resolve it via [`LlmClient.WaitFor`](/api/csharp/suspension/wait-for/) — the same primitive relay is built on (SPEC §10 opening: “relay is a use of the suspension primitive, exactly as auth is”), just scoped to one tool instead of every call in a turn. Neither reproduces relay’s specific “all N calls in one turn ride a single suspension, in OpenAI `tool_calls` shape” contract — if you need that exact shape, it’s Go-only for now. ## See also [Section titled “See also”](#see-also) * [`ToolResult.Pending`](/api/csharp/suspension/pending/) — Return a Pending from a tool to park the run until someone answers. * [`ToolResult.AuthRequired`](/api/csharp/suspension/auth-required/) — The auth-shaped suspension: hand back a URL, resume once the user has granted access. * [`LlmClient.WaitFor`](/api/csharp/suspension/wait-for/) — The single hook where the host resolves a suspension — in-process prompt or durable queue, same contract. * [`ToolResult.PendingOf`](/api/csharp/suspension/pending-of/) — Detect that a RunResult is parked rather than finished, and get the Request that parked it. * [`LlmClient.TranslateAsync`](/api/csharp/translate/translate/) — The closest shipped C# mechanism: pure translation, no toolnexus-owned execution at all. # AgentRuntime.ResumeAsync > Route an Answer to the deepest suspended sub-agent handle and resume it in place. — C#. **C#** · package `Toolnexus` · [SPEC §7D](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [Agents/AgentRuntime.cs](https://github.com/muthuishere/toolnexus/blob/main/csharp/src/Toolnexus/Agents/AgentRuntime.cs) Symbol correction: no LlmClient.Resume in this port An earlier draft of this page claimed a `LlmClient.ResumeAsync`/`RunWithAnswer` entry point on the bare client — that symbol does not exist. SPEC §10’s “Durable resume — the answer-carrying entry point” (`runWithAnswer`/`askWithAnswer`) is explicitly **`STATUS: golang only — a preview, NOT part of the §0 conformance contract`** (`golang/relay.go`). What C# ships **today**, unconditional and shipped in all six ports (§7D, not a preview), is `AgentRuntime.ResumeAsync(answer)` — the answer-carrying re-entry point for a suspended sub-agent `Handle`. This page documents that real, tested mechanism. ```csharp public async Task ResumeAsync(Answer answer) ``` Routes an `Answer` to the **deepest suspended** handle in the runtime’s tree, resumes it from its checkpoint (a retry-with-answer of the halted tool — turns and token usage keep accumulating, never reset), then cascades upward: each suspended ancestor replays too, and its re-invoked `task` delegation call **reattaches** to the already-resumed child by task key rather than spawning a duplicate. `ResumeAsync` itself returns nothing — call [`rt.WaitAsync(handle)`](/api/csharp/runtime/runtime/) afterward for the finished `AgentResult`. ## When to use it [Section titled “When to use it”](#when-to-use-it) Reach for `rt.ResumeAsync(answer)` any time a spawned agent’s handle transitions to `"suspended"` (`Handle.State == "suspended"`, `Handle.PendingRequest` set) and you have — or have just obtained — the `Answer` to that suspension: a human approved a payment, a login completed, a form was filled in. There is no separate resume path per handle; one call resolves whichever handle is currently the deepest suspended one in the tree. ## Why this and not the alternative [Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative) Set AgentDef.WaitFor instead when resolution fits inside the call `AgentDef.WaitFor` resolves a suspension **inline** — the same posture as the bare client’s `WaitFor` — so the handle never settles into `"suspended"` at all. Reach for `ResumeAsync` when resolution genuinely can’t happen inside the original turn: the process handling the answer is a webhook handler, a queue consumer, or simply a later turn of the same long-running process, not the code that spawned the handle. This crosses TIME, not necessarily a process boundary The suspended `Handle` tree lives in the `AgentRuntime` instance’s memory. So `ResumeAsync` genuinely lets an answer arrive arbitrarily late within the **same running process** (a different async continuation, an hour later, driven by a webhook) — it does not, by itself, survive a process restart. That cross-restart case is precisely the gap the golang-only `RunWithAnswer`/ `AskWithAnswer` preview covers (see [`suspension/relay`](/api/csharp/suspension/relay/)). ## Examples [Section titled “Examples”](#examples) ### 1. The smallest useful call — suspend, resume, get the final answer [Section titled “1. The smallest useful call — suspend, resume, get the final answer”](#1-the-smallest-useful-call--suspend-resume-get-the-final-answer) ```csharp using Toolnexus; using Toolnexus.Agents; var turn = 0; var llm = new MockLlm(_ => { turn++; return Task.FromResult(turn == 1 ? MockLlm.ToolCall("c1", "charge_card", new { }) : MockLlm.Text("Done — charged.")); }); var chargeCard = NativeTool.Of("charge_card", "charge the card", null, (IDictionary _, ToolContext? ctx) => ctx?.Answer is { } a ? (object)ToolResult.Ok($"charged (ok={a.Ok})") : ToolResult.Pending(new Request { Kind = "approval", Prompt = "Approve $500 charge?" })); var rt = new AgentRuntime(new RuntimeOptions { ApiKey = "test-key", Handler = llm, BaseUrl = "http://runtime.invalid", Style = "openai", Model = "mock", Registry = new Dictionary { ["approve"] = new() { Name = "approve", Does = "approves a payment", Tools = new List { chargeCard } }, }, }); var h = rt.Spawn(rt.Root, "approve").Handle!; var wait = rt.WaitAsync(h); rt.Wake(h, "Charge the card."); await wait; // settles once, on the pending suspension — NOT the resumed result if (h.State != "suspended") throw new Exception($"expected suspended, got {h.State}"); if (h.PendingRequest?.Kind != "approval") throw new Exception($"expected an approval request, got {h.PendingRequest?.Kind}"); // ResumeAsync itself awaits the resumed turn to completion — read h.LastResult afterward, // not a second await on `wait` (that Task already settled on the FIRST suspension). await rt.ResumeAsync(new Answer { Id = h.PendingRequest!.Id, Ok = true }); var result = h.LastResult!; if (h.State != "idle") throw new Exception($"resumed back to idle, got {h.State}"); if (result.Status != "done" || result.Text != "Done — charged.") throw new Exception($"{result.Status} {result.Text}"); await rt.CloseAsync(rt.Root); Console.WriteLine($"ok: suspended -> resumed -> {result.Status}, {result.Text}"); sealed class MockLlm : System.Net.Http.HttpMessageHandler { readonly Func> _respond; public MockLlm(Func> respond) => _respond = respond; protected override Task SendAsync( System.Net.Http.HttpRequestMessage request, CancellationToken ct) => _respond(request); public static System.Net.Http.HttpResponseMessage Text(string content) => Json(System.Text.Json.JsonSerializer.Serialize(new { choices = new[] { new { message = new { role = "assistant", content } } }, usage = new { prompt_tokens = 5, completion_tokens = 3, total_tokens = 8 }, })); public static System.Net.Http.HttpResponseMessage ToolCall(string id, string name, object args) => Json(System.Text.Json.JsonSerializer.Serialize(new { choices = new[] { new { message = new { role = "assistant", content = (string?)null, tool_calls = new[] { new { id, type = "function", function = new { name, arguments = System.Text.Json.JsonSerializer.Serialize(args) } } } } }, }, usage = new { prompt_tokens = 5, completion_tokens = 3, total_tokens = 8 }, })); static System.Net.Http.HttpResponseMessage Json(string body) => new(System.Net.HttpStatusCode.OK) { Content = new System.Net.Http.StringContent(body, System.Text.Encoding.UTF8, "application/json"), }; } ``` ### 2. A declined answer — the run finishes, it just doesn’t do the thing [Section titled “2. A declined answer — the run finishes, it just doesn’t do the thing”](#2-a-declined-answer--the-run-finishes-it-just-doesnt-do-the-thing) `ResumeAsync`’s loop rule branches only on `Answer.Ok` — a decline is data, not a thrown error. ```csharp using Toolnexus; using Toolnexus.Agents; var turn = 0; var llm = new MockLlm(_ => { turn++; return Task.FromResult(turn == 1 ? MockLlm.ToolCall("c1", "charge_card", new { }) : MockLlm.Text("Understood — the charge was cancelled.")); }); var chargeCard = NativeTool.Of("charge_card", "charge the card", null, (IDictionary _, ToolContext? ctx) => ctx?.Answer is { } a ? (object)ToolResult.Ok($"charge outcome ok={a.Ok}") : ToolResult.Pending(new Request { Kind = "approval", Prompt = "Approve $500 charge?" })); var rt = new AgentRuntime(new RuntimeOptions { ApiKey = "test-key", Handler = llm, BaseUrl = "http://runtime.invalid", Style = "openai", Model = "mock", Registry = new Dictionary { ["approve"] = new() { Name = "approve", Does = "approves a payment", Tools = new List { chargeCard } }, }, }); var h = rt.Spawn(rt.Root, "approve").Handle!; var wait = rt.WaitAsync(h); rt.Wake(h, "Charge the card."); await wait; if (h.State != "suspended") throw new Exception($"expected suspended, got {h.State}"); await rt.ResumeAsync(new Answer { Id = h.PendingRequest!.Id, Ok = false, Reason = "declined" }); var result = h.LastResult!; // The RUN still completes — it just knows the charge was declined. if (result.Status != "done") throw new Exception(result.Status); if (!result.Text!.Contains("cancelled")) throw new Exception(result.Text); await rt.CloseAsync(rt.Root); Console.WriteLine($"ok: {result.Text}"); sealed class MockLlm : System.Net.Http.HttpMessageHandler { readonly Func> _respond; public MockLlm(Func> respond) => _respond = respond; protected override Task SendAsync( System.Net.Http.HttpRequestMessage request, CancellationToken ct) => _respond(request); public static System.Net.Http.HttpResponseMessage Text(string content) => Json(System.Text.Json.JsonSerializer.Serialize(new { choices = new[] { new { message = new { role = "assistant", content } } }, usage = new { prompt_tokens = 5, completion_tokens = 3, total_tokens = 8 }, })); public static System.Net.Http.HttpResponseMessage ToolCall(string id, string name, object args) => Json(System.Text.Json.JsonSerializer.Serialize(new { choices = new[] { new { message = new { role = "assistant", content = (string?)null, tool_calls = new[] { new { id, type = "function", function = new { name, arguments = System.Text.Json.JsonSerializer.Serialize(args) } } } } }, }, usage = new { prompt_tokens = 5, completion_tokens = 3, total_tokens = 8 }, })); static System.Net.Http.HttpResponseMessage Json(string body) => new(System.Net.HttpStatusCode.OK) { Content = new System.Net.Http.StringContent(body, System.Text.Encoding.UTF8, "application/json"), }; } ``` ### 3. Inspecting the parked handle before resuming — `Inspect`/`List` [Section titled “3. Inspecting the parked handle before resuming — Inspect/List”](#3-inspecting-the-parked-handle-before-resuming--inspectlist) A host that stores the answer separately from the runtime (a queue, a database row) still needs a live handle to resume — `List()`/`Inspect()` give a read-only view of what’s parked, including the pending request itself, without guessing at `Children` indices. ```csharp using Toolnexus; using Toolnexus.Agents; var turn = 0; var llm = new MockLlm(_ => { turn++; return Task.FromResult(turn == 1 ? MockLlm.ToolCall("c1", "charge_card", new { }) : MockLlm.Text("Charged.")); }); var chargeCard = NativeTool.Of("charge_card", "charge the card", null, (IDictionary _, ToolContext? ctx) => ctx?.Answer is { } a ? (object)ToolResult.Ok("charged") : ToolResult.Pending(new Request { Kind = "approval", Prompt = "Approve $500 charge?" })); var rt = new AgentRuntime(new RuntimeOptions { ApiKey = "test-key", Handler = llm, BaseUrl = "http://runtime.invalid", Style = "openai", Model = "mock", Registry = new Dictionary { ["approve"] = new() { Name = "approve", Does = "approves a payment", Tools = new List { chargeCard } }, }, }); var h = rt.Spawn(rt.Root, "approve").Handle!; var wait = rt.WaitAsync(h); rt.Wake(h, "Charge the card."); await wait; // The read-only view: same Request the Handle carries, reachable from List()/Inspect() alone. var view = rt.Inspect(h.Id); if (view is null || view.State != "suspended") throw new Exception($"{view?.State}"); await rt.ResumeAsync(new Answer { Id = h.PendingRequest!.Id, Ok = true }); var result = h.LastResult!; if (result.Status != "done") throw new Exception(result.Status); await rt.CloseAsync(rt.Root); Console.WriteLine($"ok: {view.State} -> {result.Status}"); sealed class MockLlm : System.Net.Http.HttpMessageHandler { readonly Func> _respond; public MockLlm(Func> respond) => _respond = respond; protected override Task SendAsync( System.Net.Http.HttpRequestMessage request, CancellationToken ct) => _respond(request); public static System.Net.Http.HttpResponseMessage Text(string content) => Json(System.Text.Json.JsonSerializer.Serialize(new { choices = new[] { new { message = new { role = "assistant", content } } }, usage = new { prompt_tokens = 5, completion_tokens = 3, total_tokens = 8 }, })); public static System.Net.Http.HttpResponseMessage ToolCall(string id, string name, object args) => Json(System.Text.Json.JsonSerializer.Serialize(new { choices = new[] { new { message = new { role = "assistant", content = (string?)null, tool_calls = new[] { new { id, type = "function", function = new { name, arguments = System.Text.Json.JsonSerializer.Serialize(args) } } } } }, }, usage = new { prompt_tokens = 5, completion_tokens = 3, total_tokens = 8 }, })); static System.Net.Http.HttpResponseMessage Json(string body) => new(System.Net.HttpStatusCode.OK) { Content = new System.Net.Http.StringContent(body, System.Text.Encoding.UTF8, "application/json"), }; } ``` ## Signature [Section titled “Signature”](#signature) | Parameter | Type | What it is | | --------- | -------- | ------------------------------------------------------------------------------------- | | `answer` | `Answer` | Must echo the `Id` of the pending `Request` — routed to the deepest suspended handle. | | returns | `Task` | Call `rt.WaitAsync(handle)` afterward for the finished `AgentResult`. | ## See also [Section titled “See also”](#see-also) * [`Handle`](/api/csharp/runtime/handle/) — The state machine for one spawned agent: pending, running, suspended, done. * [`AgentRuntime`](/api/csharp/runtime/runtime/) — The six host verbs that drive sub-agents, plus the read-only list and inspect views. * [`ToolResult.Pending`](/api/csharp/suspension/pending/) — Return a Pending from a tool to park the run until someone answers. * [`suspension/relay`](/api/csharp/suspension/relay/) — The golang-only §10 preview that typically resumes via `RunWithAnswer`/`AskWithAnswer`. # LlmClient.WaitFor > The single hook where the host resolves a suspension — in-process prompt or durable queue, same contract. — C#. **C#** · package `Toolnexus` · [SPEC §10](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [LlmClient.cs](https://github.com/muthuishere/toolnexus/blob/main/csharp/src/Toolnexus/LlmClient.cs) ```csharp public sealed class Options { // §10 Suspension resolver — the ONE host slot. public Func>? WaitFor { get; set; } public Options WithWaitFor(Func> v) { WaitFor = v; return this; } } ``` The one host-configurable slot in the whole suspension mechanism. When a tool call returns a [`Pending`](/api/csharp/suspension/pending/) result, the loop calls `WaitFor(request)`. On `answer.Ok == true` it re-executes the same tool once with the resolution attached (`ToolContext.Answer`); on `false` it feeds back an error `ToolResult` and the run continues. Its interior is unconstrained — open a browser and poll, post a link to a channel and wait, forward the request over A2A to another agent. When `WaitFor` is `null`, a run never hangs: it halts and returns a `RunResult` with `Status = "pending"` and the `Request`, so a durable host can resolve it out-of-band and continue later, possibly in a different process. ## When to use it [Section titled “When to use it”](#when-to-use-it) Set `WaitFor` whenever you want suspensions resolved **inline**, in the same process, before `RunAsync` returns — a CLI that prompts interactively, a script that polls an approval queue with a timeout. Leave it unset when you want the **durable** path: persist the returned `RunResult.Pending`, resolve it however long that takes (minutes, days), and resume separately. ## Why this and not the alternative [Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative) Reach for Hooks.BeforeTool when you want to intercept BEFORE a suspension exists `WaitFor` only fires once a tool has already returned `Pending`. If you want to short-circuit a call before it runs at all — deny it outright, or raise your own suspension without touching the tool’s code — use [`LlmClient.Hooks.BeforeTool`](/api/csharp/client/hooks/) instead; a guard-raised suspension there is treated identically to one the tool produced itself. ## Examples [Section titled “Examples”](#examples) ### 1. The smallest useful call — resolve inline, the loop never surfaces `"pending"` [Section titled “1. The smallest useful call — resolve inline, the loop never surfaces "pending"”](#1-the-smallest-useful-call--resolve-inline-the-loop-never-surfaces-pending) ```csharp using System.Net; using System.Text; using Toolnexus; using var stub = new Stub(ctx => { Stub.Json(ctx, 200, """ {"id":"c1","choices":[{"message":{"role":"assistant","content":"hello, no tools needed"},"finish_reason":"stop"}],"usage":{"prompt_tokens":5,"completion_tokens":3,"total_tokens":8}} """); }); var waitForCalls = 0; var client = LlmClient.Create(new LlmClient.Options { BaseUrl = stub.BaseUrl, Style = "openai", Model = "gpt-4o-mini", ApiKey = "test-key", WaitFor = request => { waitForCalls++; return Task.FromResult(new Answer { Id = request.Id, Ok = true }); }, }); await using var tk = await Toolkit.CreateAsync(new Toolkit.Options()); var result = await client.RunAsync("say hello", tk); if (result.Status != "done") throw new Exception(result.Status); if (waitForCalls != 0) throw new Exception("WaitFor must not be called when nothing suspends"); Console.WriteLine($"ok: {result.Text} (WaitFor invoked {waitForCalls} times)"); sealed class Stub : IDisposable { readonly HttpListener _listener = new(); readonly CancellationTokenSource _cts = new(); public int Port { get; } public string BaseUrl => $"http://127.0.0.1:{Port}"; public Stub(Action handler) { var probe = new System.Net.Sockets.TcpListener(IPAddress.Loopback, 0); probe.Start(); Port = ((IPEndPoint)probe.LocalEndpoint).Port; probe.Stop(); _listener.Prefixes.Add($"http://127.0.0.1:{Port}/"); _listener.Start(); _ = Task.Run(async () => { while (!_cts.IsCancellationRequested) { HttpListenerContext ctx; try { ctx = await _listener.GetContextAsync(); } catch { break; } try { handler(ctx); } catch { } } }); } public static void Json(HttpListenerContext ctx, int status, string body) { var bytes = Encoding.UTF8.GetBytes(body); ctx.Response.StatusCode = status; ctx.Response.ContentType = "application/json"; ctx.Response.ContentLength64 = bytes.Length; ctx.Response.OutputStream.Write(bytes, 0, bytes.Length); ctx.Response.OutputStream.Close(); } public void Dispose() { _cts.Cancel(); try { _listener.Stop(); } catch { } try { _listener.Close(); } catch { } } } ``` ### 2. The realistic case — no `WaitFor`, the run halts as `"pending"` instead of hanging [Section titled “2. The realistic case — no WaitFor, the run halts as "pending" instead of hanging”](#2-the-realistic-case--no-waitfor-the-run-halts-as-pending-instead-of-hanging) ```csharp using System.Net; using System.Text; using Toolnexus; using var stub = new Stub(ctx => { Stub.Json(ctx, 200, """ {"id":"c1","choices":[{"message":{"role":"assistant","content":null,"tool_calls":[{"id":"call_1","type":"function","function":{"name":"approve_purchase","arguments":"{\"item\":\"gpu\"}"}}]},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":10,"completion_tokens":5,"total_tokens":15}} """); }); var approve = NativeTool.Of("approve_purchase", "Requests spend approval", null, (IDictionary a, ToolContext? ctx) => ctx?.Answer != null ? (object)$"approved: {a["item"]}" : (object)ToolResult.Pending(new Request { Kind = "approval", Prompt = $"Approve purchase of {a["item"]}?" })); // No WaitFor configured — this client is a "durable" host: it does NOT try to resolve suspensions // itself, it just reports them. var client = LlmClient.Create(new LlmClient.Options { BaseUrl = stub.BaseUrl, Style = "openai", Model = "gpt-4o-mini", ApiKey = "test-key", }); await using var tk = await Toolkit.CreateAsync(new Toolkit.Options { ExtraTools = new List { approve } }); var result = await client.RunAsync("buy a gpu", tk); if (result.Status != "pending") throw new Exception($"status: {result.Status}"); // never hangs if (result.Pending is null) throw new Exception("expected the Request that parked the run"); if (result.Pending.Kind != "approval") throw new Exception(result.Pending.Kind); // The Request is plain data — persist it (a file, a queue, a row) and resolve it whenever, however. Console.WriteLine($"ok: status={result.Status}, waiting on: {result.Pending.Prompt}"); sealed class Stub : IDisposable { readonly HttpListener _listener = new(); readonly CancellationTokenSource _cts = new(); public int Port { get; } public string BaseUrl => $"http://127.0.0.1:{Port}"; public Stub(Action handler) { var probe = new System.Net.Sockets.TcpListener(IPAddress.Loopback, 0); probe.Start(); Port = ((IPEndPoint)probe.LocalEndpoint).Port; probe.Stop(); _listener.Prefixes.Add($"http://127.0.0.1:{Port}/"); _listener.Start(); _ = Task.Run(async () => { while (!_cts.IsCancellationRequested) { HttpListenerContext ctx; try { ctx = await _listener.GetContextAsync(); } catch { break; } try { handler(ctx); } catch { } } }); } public static void Json(HttpListenerContext ctx, int status, string body) { var bytes = Encoding.UTF8.GetBytes(body); ctx.Response.StatusCode = status; ctx.Response.ContentType = "application/json"; ctx.Response.ContentLength64 = bytes.Length; ctx.Response.OutputStream.Write(bytes, 0, bytes.Length); ctx.Response.OutputStream.Close(); } public void Dispose() { _cts.Cancel(); try { _listener.Stop(); } catch { } try { _listener.Close(); } catch { } } } ``` ### 3. Full surface — a `WaitFor` that never resolves the SAME request twice [Section titled “3. Full surface — a WaitFor that never resolves the SAME request twice”](#3-full-surface--a-waitfor-that-never-resolves-the-same-request-twice) ```csharp using System.Net; using System.Text; using Toolnexus; var httpCalls = 0; using var stub = new Stub(ctx => { httpCalls++; Stub.Json(ctx, 200, httpCalls == 1 ? """{"id":"c1","choices":[{"message":{"role":"assistant","content":null,"tool_calls":[{"id":"call_1","type":"function","function":{"name":"flaky_approve","arguments":"{}"}}]},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":10,"completion_tokens":5,"total_tokens":15}}""" : """{"id":"c2","choices":[{"message":{"role":"assistant","content":"gave up after one retry"},"finish_reason":"stop"}],"usage":{"prompt_tokens":15,"completion_tokens":4,"total_tokens":19}}"""); }); // A deliberately BROKEN tool: even on the resumed retry (ctx.Answer set) it suspends again. var flaky = NativeTool.Of("flaky_approve", "always re-suspends, even after resolution", null, (IDictionary _, ToolContext? _) => (object)ToolResult.Pending(new Request { Kind = "approval", Prompt = "approve?" })); var waitForCalls = 0; var client = LlmClient.Create(new LlmClient.Options { BaseUrl = stub.BaseUrl, Style = "openai", Model = "gpt-4o-mini", ApiKey = "test-key", WaitFor = request => { waitForCalls++; return Task.FromResult(new Answer { Id = request.Id, Ok = true }); }, }); await using var tk = await Toolkit.CreateAsync(new Toolkit.Options { ExtraTools = new List { flaky } }); var result = await client.RunAsync("approve this", tk); // The loop rule: WaitFor is called AT MOST ONCE per suspension. A retry that still suspends // becomes an "unresolved: " error fed back to the model — it never loops forever. if (waitForCalls != 1) throw new Exception($"expected exactly 1 WaitFor call, got {waitForCalls}"); if (result.Status != "done") throw new Exception(result.Status); if (!result.ToolCalls[0].Output.Contains("unresolved")) throw new Exception(result.ToolCalls[0].Output); Console.WriteLine($"ok: WaitFor called {waitForCalls}x, tool result: {result.ToolCalls[0].Output}"); sealed class Stub : IDisposable { readonly HttpListener _listener = new(); readonly CancellationTokenSource _cts = new(); public int Port { get; } public string BaseUrl => $"http://127.0.0.1:{Port}"; public Stub(Action handler) { var probe = new System.Net.Sockets.TcpListener(IPAddress.Loopback, 0); probe.Start(); Port = ((IPEndPoint)probe.LocalEndpoint).Port; probe.Stop(); _listener.Prefixes.Add($"http://127.0.0.1:{Port}/"); _listener.Start(); _ = Task.Run(async () => { while (!_cts.IsCancellationRequested) { HttpListenerContext ctx; try { ctx = await _listener.GetContextAsync(); } catch { break; } try { handler(ctx); } catch { } } }); } public static void Json(HttpListenerContext ctx, int status, string body) { var bytes = Encoding.UTF8.GetBytes(body); ctx.Response.StatusCode = status; ctx.Response.ContentType = "application/json"; ctx.Response.ContentLength64 = bytes.Length; ctx.Response.OutputStream.Write(bytes, 0, bytes.Length); ctx.Response.OutputStream.Close(); } public void Dispose() { _cts.Cancel(); try { _listener.Stop(); } catch { } try { _listener.Close(); } catch { } } } ``` Caution Named `WaitFor`, never `Await` — `await` is a reserved word in C#. And it is genuinely `Task`-typed async, not a blocking call: don’t wrap a synchronous prompt in `.Result`/`.Wait()` inside it, or you risk deadlocking the client’s own async call chain. ## `WaitForCtx` — end the ask with the turn [Section titled “WaitForCtx — end the ask with the turn”](#waitforctx--end-the-ask-with-the-turn) ```csharp public Func>? WaitForCtx { get; set; } ``` The same §10 contract as `WaitFor`, plus the turn’s `CancellationToken`, so a question pending on a human ends when the turn is cancelled (throw `OperationCanceledException` or let the token cancel your await). When both are set, `WaitForCtx` wins. ```csharp var client = LlmClient.Create(new LlmClient.Options { BaseUrl = "http://127.0.0.1:8080/v1", Style = "openai", Model = "m", ApiKey = "YOUR_KEY_HERE", WaitForCtx = async (request, ct) => { var reply = await ui.AskAsync(request.Prompt, ct); // cancelled with the turn return new Answer { Id = request.Id, Ok = true, Data = reply }; }, }); ``` ## Fields [Section titled “Fields”](#fields) | Field | Type | What it is | | --------- | ------------------------------ | --------------------------------------------------------------------------------------------------------- | | `WaitFor` | `Func>?` | Called once per suspension. `null` ⇒ the run halts with `Status = "pending"` instead of resolving inline. | ## See also [Section titled “See also”](#see-also) * [`ToolResult.Pending`](/api/csharp/suspension/pending/) — Return a Pending from a tool to park the run until someone answers. * [`ToolResult.AuthRequired`](/api/csharp/suspension/auth-required/) — The auth-shaped suspension: hand back a URL, resume once the user has granted access. * [`ToolResult.PendingOf`](/api/csharp/suspension/pending-of/) — Detect that a RunResult is parked rather than finished, and get the Request that parked it. * [`LlmClient.Hooks`](/api/csharp/client/hooks/) — Intercept before/after model calls and tool calls: audit, redact, veto, or rewrite. # Toolkit.CreateAsync > Collapse MCP servers, skills, built-ins, A2A agents and your own functions into one tool list. — C#. **C#** · package `Toolnexus` · [SPEC §4](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) ```csharp public sealed class Toolkit : IAsyncDisposable { public static Task CreateAsync(Options opts, CancellationToken cancellationToken = default); public sealed class Options { public object? McpConfig { get; set; } // path, raw JSON, or parsed dict public List? SkillsDir { get; set; } // one or more skill roots public IReadOnlyList? Skills { get; set; } // skills as data (§3 S1) public Func>? SkillProvider { get; set; } public IReadOnlyDictionary? SkillsFilter { get; set; } public int SkillSampleLimit { get; set; } // 0=default 10, n>0=cap, -1=omit public IReadOnlyList? DisableTools { get; set; } // drop by final exposed name public IReadOnlyList? DisableSkills { get; set; } // drop by skill name public List? ExtraTools { get; set; } // custom/native/http tools public List? AnnotatedObjects { get; set; } // scanned via Tools.FromObject public object? Builtins { get; set; } // bool | {disabled|enabled|tools:{...}} public List? Agents { get; set; } // remote A2A agents public Func>? WaitFor { get; set; } // §10 MCP elicitation bridge } } ``` Collapse MCP servers, skills, built-ins, A2A agents and your own functions into one tool list. `Toolkit.CreateAsync` is the one entry point that turns every tool source toolnexus knows about — a connected `mcp.json`, a `skills/` folder, the 10 built-in file/shell/web tools, remote A2A agent skills, and plain C# methods — into a single flat `ITool` list, deduped and ready to hand to [`LlmClient.RunAsync`](/api/csharp/client/run/). ## When to use it [Section titled “When to use it”](#when-to-use-it) Call it once per agent, before the first `RunAsync`/`StreamAsync`. Leave every field but the ones you need unset: an empty `new Toolkit.Options()` still gives you all 10 built-ins (SPEC §4A), and each additional field only turns on one more source — an `mcp.json`, a skills directory, your own `ITool`s — composed onto that baseline rather than replacing it. ## Why this and not the alternative [Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative) Wiring a source directly (McpSource.LoadAsync / SkillSource.Load / BuiltinTools.Create) Reach for the lower-level `McpSource`/`SkillSource`/`BuiltinTools` APIs only when you need ONE source in isolation — inspecting what an `mcp.json` resolves to, unit-testing a skill directory, or assembling built-ins for a non-toolkit caller. The moment an agent needs more than one source together (which is almost every real agent), `Toolkit.CreateAsync` is the aggregator: it applies the documented precedence (MCP → skill → builtin → agents → extraTools, first name wins, an `ExtraTools` entry shadows a same-named builtin) and the `DisableTools`/`DisableSkills` drop-lists in one place, so two agents built from the same options always see the same tool set. ## Examples [Section titled “Examples”](#examples) ### 1. The smallest useful call — built-ins only [Section titled “1. The smallest useful call — built-ins only”](#1-the-smallest-useful-call--built-ins-only) ```csharp using Toolnexus; // no fields set: the 10 built-in tools (bash/read/write/edit/grep/glob/webfetch/question/ // apply_patch/todowrite) are on by default (SPEC §4A) — nothing else to configure. await using var tk = await Toolkit.CreateAsync(new Toolkit.Options()); Console.WriteLine(tk.Tools().Count); // 10 ``` ### 2. The realistic case — MCP servers + a skills folder [Section titled “2. The realistic case — MCP servers + a skills folder”](#2-the-realistic-case--mcp-servers--a-skills-folder) ```csharp using Toolnexus; await using var tk = await Toolkit.CreateAsync(new Toolkit.Options { McpConfig = "mcp.json", // connects every server, local stdio + remote HTTP SkillsDir = new[] { "skills" }, // globs **/SKILL.md, exposes one `skill` tool }); var client = LlmClient.Create(new LlmClient.Options { BaseUrl = "https://api.openai.com/v1", Style = "openai", Model = "gpt-4o-mini", ApiKey = Environment.GetEnvironmentVariable("OPENAI_API_KEY"), }); var res = await client.RunAsync("Use the available tools to triage today's alerts.", tk); Console.WriteLine(res.Text); ``` ### 3. The full surface — filters, drop-lists, extras, and the §10 bridge [Section titled “3. The full surface — filters, drop-lists, extras, and the §10 bridge”](#3-the-full-surface--filters-drop-lists-extras-and-the-10-bridge) ```csharp using Toolnexus; await using var mcp = await StartMcp(); // a connected MCP server exposing tools "a", "b", "c" await using var tk = await Toolkit.CreateAsync(new Toolkit.Options { McpConfig = new Dictionary { ["srv"] = new Dictionary { ["url"] = mcp.Url }, }, SkillsDir = new[] { "skills" }, Skills = new List { new("inline-skill", "short desc", "full instructions"), }, SkillsFilter = new Dictionary { ["inline-skill"] = true }, SkillSampleLimit = 5, // drop by final exposed name / skill name, after aggregation DisableTools = new[] { "srv_b" }, DisableSkills = new[] { "some-noisy-skill" }, // your own C# functions, alongside every other source ExtraTools = new List { NativeTool.Of("ping", "health check", null, _ => "pong") }, // turn off the whole built-in source (an ExtraTools entry with the same name always wins anyway) Builtins = false, // remote A2A agents — each advertised skill becomes a tool (source "a2a") Agents = new List { remoteAgent }, // §10: bridge MCP server elicitation onto the same WaitFor the client uses WaitFor = async request => await MyApprovalQueue.ResolveAsync(request), }); Console.WriteLine(tk.Get("srv_b")); // null — dropped by DisableTools Console.WriteLine(tk.Get("srv_a")); // present — other tools from the same server are untouched Console.WriteLine(tk.Get("ping")); // present — extra tool ``` ## See also [Section titled “See also”](#see-also) * [`LlmClient.Create`](/api/csharp/client/create/) — The unified client this toolkit’s tool list is meant to be passed to. * [`McpSource.LoadAsync`](/api/csharp/mcp/load/) — Read an `mcp.json`, connect every local stdio and remote streamable-HTTP server, expose each server tool as a `Tool`. * [`McpSource.ParseConfig`](/api/csharp/mcp/parse-config/) — Parse an `mcp.json` path/string/object into the normalized server config `McpConfig` accepts. * [`SkillSource.Load`](/api/csharp/skills/load/) — Discover every `SKILL.md` under one or more directories and expose a single `skill` tool with progressive disclosure. * [`SkillSource.LoadWith`](/api/csharp/skills/load-with/) — The options-object form behind `SkillsDir`/`Skills`/`SkillProvider`/`SkillsFilter`/`SkillSampleLimit`. * [`SkillSource.ListSkills`](/api/csharp/skills/list/) — Inventory every discovered skill — and the candidates that were skipped and why. * [`BuiltinTools.Create`](/api/csharp/builtins/create/) — Construct every built-in tool directly, outside a toolkit. * [`BuiltinTools.Select`](/api/csharp/builtins/select/) — The selection logic behind the `Builtins` option (bool / disabled / enabled / per-tool map). * [`LlmClient.WaitFor`](/api/csharp/suspension/wait-for/) — The single hook where the host resolves a suspension — the same function this toolkit’s `WaitFor` bridges MCP elicitation onto. # Translate.OpenAIMessagesToAnthropic > Convert an OpenAI-shaped transcript into Anthropic shape, including the tool-result merging a flattening translator gets wrong. — C#. **C#** · package `Toolnexus` · [SPEC §11](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [Translate.cs](https://github.com/muthuishere/toolnexus/blob/main/csharp/src/Toolnexus/Translate.cs) ```csharp public static class Translate { public sealed record Converted(List Messages, string System); public static Converted OpenAIMessagesToAnthropic(IEnumerable? messages); } ``` Converts an OpenAI `messages` array into Anthropic-native messages plus the extracted system prompt — the exact conversion [`LlmClient.TranslateAsync`](/api/csharp/translate/translate/) uses internally when `Style = "anthropic"`. Three rules a naive text-flattening translator gets wrong: an assistant turn’s `tool_calls` become `tool_use` blocks (arguments parsed back from their JSON string into an object); a `tool`-role result becomes a `tool_result` block keyed by `tool_call_id`; and **consecutive tool results merge into one user turn**, because Anthropic expects a single result-bearing turn answering the preceding assistant turn, not one turn per result. `system`/`developer` messages are hoisted out into the returned `System` string. ## When to use it [Section titled “When to use it”](#when-to-use-it) You’re building your own translation path — a custom proxy, a test harness, tooling that inspects what an OpenAI-shaped transcript would look like on Anthropic’s wire — and want exactly this conversion without going through a live provider call. It’s also the reference to check your own work against if you ever need to hand-roll a similar conversion for a different provider shape. ## Why this and not the alternative [Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative) Reach for TranslateAsync when you also need to make the call `OpenAIMessagesToAnthropic` is a pure, synchronous function — no network, no client, no retries. If you actually need to send the converted request to Anthropic and get an OpenAI-shaped result back, skip straight to [`LlmClient.TranslateAsync`](/api/csharp/translate/translate/) (`Style = "anthropic"`), which calls this internally and adds usage accounting, retries, and the `llm` observability event on top. ## Examples [Section titled “Examples”](#examples) ### 1. The smallest useful call — a plain user turn plus a hoisted system message [Section titled “1. The smallest useful call — a plain user turn plus a hoisted system message”](#1-the-smallest-useful-call--a-plain-user-turn-plus-a-hoisted-system-message) ```csharp using Toolnexus; object? Msg(string role, string content) => new Dictionary { ["role"] = role, ["content"] = content }; var messages = new List { Msg("system", "You are terse."), Msg("user", "hello"), }; var converted = Translate.OpenAIMessagesToAnthropic(messages); if (converted.System != "You are terse.") throw new Exception(converted.System); if (converted.Messages.Count != 1) throw new Exception($"expected 1 message, got {converted.Messages.Count}"); var only = (IDictionary)converted.Messages[0]!; if (only["role"] as string != "user" || only["content"] as string != "hello") throw new Exception("user turn mismatch"); Console.WriteLine($"ok: system='{converted.System}', {converted.Messages.Count} message(s)"); ``` ### 2. The realistic case — tool calls become `tool_use`, results MERGE into one user turn [Section titled “2. The realistic case — tool calls become tool\_use, results MERGE into one user turn”](#2-the-realistic-case--tool-calls-become-tool_use-results-merge-into-one-user-turn) ```csharp using System.Linq; using Toolnexus; object? Msg(string role, string content) => new Dictionary { ["role"] = role, ["content"] = content }; object? AssistantToolCalls(params (string id, string name, string args)[] calls) => new Dictionary { ["role"] = "assistant", ["content"] = (string?)null, ["tool_calls"] = calls.Select(c => (object?)new Dictionary { ["id"] = c.id, ["type"] = "function", ["function"] = new Dictionary { ["name"] = c.name, ["arguments"] = c.args }, }).ToList(), }; object? ToolResult(string id, string output) => new Dictionary { ["role"] = "tool", ["tool_call_id"] = id, ["content"] = output }; var messages = new List { Msg("user", "check the weather and the time in Chennai"), // The model called TWO tools in one turn — two OpenAI tool_calls, two tool-role results. AssistantToolCalls(("c1", "get_weather", "{\"city\":\"Chennai\"}"), ("c2", "get_time", "{\"city\":\"Chennai\"}")), ToolResult("c1", "31C, humid"), ToolResult("c2", "14:20 IST"), }; var converted = Translate.OpenAIMessagesToAnthropic(messages); // [user, assistant(tool_use x2), user(tool_result x2)] — the two consecutive tool results MERGE // into ONE user turn, exactly as Anthropic expects. if (converted.Messages.Count != 3) throw new Exception($"expected 3 messages, got {converted.Messages.Count}"); var assistantMsg = (IDictionary)converted.Messages[1]!; var blocks = (List)assistantMsg["content"]!; if (blocks.Count != 2) throw new Exception($"expected 2 tool_use blocks, got {blocks.Count}"); var firstUse = (IDictionary)blocks[0]!; if (firstUse["type"] as string != "tool_use" || firstUse["name"] as string != "get_weather") throw new Exception("tool_use shape"); // arguments parsed BACK from JSON string into an object. var input = (IDictionary)firstUse["input"]!; if (input["city"] as string != "Chennai") throw new Exception("arguments must be parsed, not left as a string"); var mergedResults = (IDictionary)converted.Messages[2]!; var resultBlocks = (List)mergedResults["content"]!; if (mergedResults["role"] as string != "user" || resultBlocks.Count != 2) throw new Exception("both tool results must merge into ONE user turn"); Console.WriteLine($"ok: {converted.Messages.Count} messages, {resultBlocks.Count} tool_result blocks merged into 1 turn"); ``` ### 3. Full surface — feeding the conversion into a real Anthropic-shaped call [Section titled “3. Full surface — feeding the conversion into a real Anthropic-shaped call”](#3-full-surface--feeding-the-conversion-into-a-real-anthropic-shaped-call) ```csharp using System.Net; using System.Text; using Toolnexus; var receivedBody = ""; using var stub = new Stub(ctx => { using var reader = new StreamReader(ctx.Request.InputStream); receivedBody = reader.ReadToEnd(); Stub.Json(ctx, 200, """ {"id":"m1","model":"claude-3-5-sonnet","stop_reason":"end_turn","content":[{"type":"text","text":"It's 31C and 14:20 IST in Chennai."}],"usage":{"input_tokens":30,"output_tokens":10}} """); }); object? Msg(string role, string content) => new Dictionary { ["role"] = role, ["content"] = content }; object? ToolResult(string id, string output) => new Dictionary { ["role"] = "tool", ["tool_call_id"] = id, ["content"] = output }; object? AssistantToolCalls((string id, string name, string args) c) => new Dictionary { ["role"] = "assistant", ["content"] = (string?)null, ["tool_calls"] = new List { new Dictionary { ["id"] = c.id, ["type"] = "function", ["function"] = new Dictionary { ["name"] = c.name, ["arguments"] = c.args } } }, }; // Confirm the pure conversion first, standalone. var converted = Translate.OpenAIMessagesToAnthropic(new List { Msg("system", "Answer in one sentence."), Msg("user", "weather and time in Chennai?"), AssistantToolCalls(("c1", "get_weather_and_time", "{\"city\":\"Chennai\"}")), ToolResult("c1", "31C, humid; 14:20 IST"), }); if (converted.System != "Answer in one sentence.") throw new Exception(converted.System); // Then confirm TranslateAsync uses that SAME conversion end-to-end (Style = "anthropic"). var client = LlmClient.Create(new LlmClient.Options { BaseUrl = stub.BaseUrl, Style = "anthropic", Model = "claude-3-5-sonnet", ApiKey = "test-key" }); var result = await client.TranslateAsync(new Translate.Request { Messages = new List { Msg("system", "Answer in one sentence."), Msg("user", "weather and time in Chennai?"), AssistantToolCalls(("c1", "get_weather_and_time", "{\"city\":\"Chennai\"}")), ToolResult("c1", "31C, humid; 14:20 IST"), }, }); if (result.Text != "It's 31C and 14:20 IST in Chennai.") throw new Exception(result.Text); if (!receivedBody.Contains("\"system\":\"Answer in one sentence.\"")) throw new Exception("hoisted system must reach the wire"); if (!receivedBody.Contains("tool_result")) throw new Exception("the merged tool_result block must reach the wire"); Console.WriteLine($"ok: {result.Text}"); sealed class Stub : IDisposable { readonly HttpListener _listener = new(); readonly CancellationTokenSource _cts = new(); public int Port { get; } public string BaseUrl => $"http://127.0.0.1:{Port}"; public Stub(Action handler) { var probe = new System.Net.Sockets.TcpListener(IPAddress.Loopback, 0); probe.Start(); Port = ((IPEndPoint)probe.LocalEndpoint).Port; probe.Stop(); _listener.Prefixes.Add($"http://127.0.0.1:{Port}/"); _listener.Start(); _ = Task.Run(async () => { while (!_cts.IsCancellationRequested) { HttpListenerContext ctx; try { ctx = await _listener.GetContextAsync(); } catch { break; } try { handler(ctx); } catch { } } }); } public static void Json(HttpListenerContext ctx, int status, string body) { var bytes = Encoding.UTF8.GetBytes(body); ctx.Response.StatusCode = status; ctx.Response.ContentType = "application/json"; ctx.Response.ContentLength64 = bytes.Length; ctx.Response.OutputStream.Write(bytes, 0, bytes.Length); ctx.Response.OutputStream.Close(); } public void Dispose() { _cts.Cancel(); try { _listener.Stop(); } catch { } try { _listener.Close(); } catch { } } } ``` Caution An empty assistant turn (no text, no tool calls after filtering) is dropped rather than emitted — Anthropic rejects a content-less assistant message. If your input transcript has one, it silently disappears from `Converted.Messages` rather than erroring. ## Parameters [Section titled “Parameters”](#parameters) | Parameter | Type | What it is | | ---------- | ----------------------- | ------------------------------------------------------- | | `messages` | `IEnumerable?` | An OpenAI `messages` array. `null` is treated as empty. | ## Returns (`Converted`) [Section titled “Returns (Converted)”](#returns-converted) | Field | Type | What it is | | ---------- | --------------- | -------------------------------------------------------------------------------------------- | | `Messages` | `List` | Anthropic-native messages: `tool_use`/`tool_result` blocks, consecutive tool results merged. | | `System` | `string` | The hoisted `system`/`developer` content, joined with blank lines. `""` if none was present. | ## See also [Section titled “See also”](#see-also) * [`LlmClient.TranslateAsync`](/api/csharp/translate/translate/) — Uses this conversion internally for an Anthropic-style upstream, plus the live provider call. # LlmClient.TranslateAsync > Declare a toolkit to a provider and translate one request/response without executing anything or keeping state. — C#. **C#** · package `Toolnexus` · [SPEC §11](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [LlmClient.cs](https://github.com/muthuishere/toolnexus/blob/main/csharp/src/Toolnexus/LlmClient.cs) ```csharp // On LlmClient: public Task TranslateAsync(Translate.Request request, CancellationToken cancellationToken = default); // On the static Translate class (Translate.cs): public static class Translate { public sealed class Request { public List Messages { get; set; } = new(); // OpenAI `messages`, verbatim public List? Tools { get; set; } // OpenAI `tools`, verbatim — declared, never executed public Toolkit? Toolkit { get; set; } // an ordinary toolkit — declared, never executed public object? ToolChoice { get; set; } // OpenAI `tool_choice`, verbatim public string? System { get; set; } // overrides the system prompt public int MaxTokens { get; set; } // 0 ⇒ per-provider default } public sealed class Result { public string Text { get; set; } = ""; public List ToolCalls { get; set; } = new(); public string FinishReason { get; set; } = "stop"; // "stop" | "tool_calls" | "length" | "content_filter" public LlmClient.Usage Usage { get; set; } = new(); public string Model { get; set; } = ""; public IDictionary? Raw { get; set; } } public sealed record ToolCall(string Id, string Name, string Arguments); // Arguments: JSON STRING, the OpenAI wire form } ``` `TranslateAsync` 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 — every call is self-contained, so it’s safe to run statelessly and concurrently. It’s the **inbound** half of the §5 adapters: `Toolkit.ToOpenAI`/`ToAnthropic` send declarations *out*, `TranslateAsync` reads the provider’s tool calls back *in*. A `Toolkit` (or raw `Tools`) on the request is **declared and never executed** — there is no execution path in this entry point at all, so there’s no `Builtins:false` discipline to remember. ## When to use it [Section titled “When to use it”](#when-to-use-it) The **caller** owns the conversation and executes tools itself — the standard OpenAI function-calling posture, where every request already carries the full history including prior tool results. You want toolnexus for one thing only: translating that request to whichever provider’s native wire format (Anthropic, say) and translating the response straight back to OpenAI shape, without adopting the agent loop’s conversation ownership. ## Why this and not the alternative [Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative) Reach for RunAsync when toolnexus should own the conversation `TranslateAsync` never executes a tool and never keeps history — you resend the whole transcript every call, exactly like calling the provider directly. If you want toolnexus to run the tool-calling loop itself — execute tools, feed results back, repeat until done — use [`LlmClient.RunAsync`](/api/csharp/client/run/) instead. Need the loop AND a caller-owned execution posture at once? That’s the Go-only relay-tool + `RunWithAnswer` combination (SPEC §10 addendum) — see [suspension/relay](/api/csharp/suspension/relay/). ## Examples [Section titled “Examples”](#examples) ### 1. The smallest useful call — OpenAI in, OpenAI out, no tools involved [Section titled “1. The smallest useful call — OpenAI in, OpenAI out, no tools involved”](#1-the-smallest-useful-call--openai-in-openai-out-no-tools-involved) ```csharp using System.Net; using System.Text; using Toolnexus; using var stub = new Stub(ctx => { Stub.Json(ctx, 200, """ {"id":"c1","choices":[{"message":{"role":"assistant","content":"2 + 2 = 4"},"finish_reason":"stop"}],"usage":{"prompt_tokens":6,"completion_tokens":4,"total_tokens":10}} """); }); var client = LlmClient.Create(new LlmClient.Options { BaseUrl = stub.BaseUrl, Style = "openai", Model = "gpt-4o-mini", ApiKey = "test-key" }); var result = await client.TranslateAsync(new Translate.Request { Messages = new List { new Dictionary { ["role"] = "user", ["content"] = "what's 2 + 2?" } }, }); if (result.FinishReason != "stop") throw new Exception(result.FinishReason); if (result.Text != "2 + 2 = 4") throw new Exception(result.Text); if (result.ToolCalls.Count != 0) throw new Exception("no tools declared, none should be called"); Console.WriteLine($"ok: {result.Text} (usage={result.Usage.TotalTokens})"); sealed class Stub : IDisposable { readonly HttpListener _listener = new(); readonly CancellationTokenSource _cts = new(); public int Port { get; } public string BaseUrl => $"http://127.0.0.1:{Port}"; public Stub(Action handler) { var probe = new System.Net.Sockets.TcpListener(IPAddress.Loopback, 0); probe.Start(); Port = ((IPEndPoint)probe.LocalEndpoint).Port; probe.Stop(); _listener.Prefixes.Add($"http://127.0.0.1:{Port}/"); _listener.Start(); _ = Task.Run(async () => { while (!_cts.IsCancellationRequested) { HttpListenerContext ctx; try { ctx = await _listener.GetContextAsync(); } catch { break; } try { handler(ctx); } catch { } } }); } public static void Json(HttpListenerContext ctx, int status, string body) { var bytes = Encoding.UTF8.GetBytes(body); ctx.Response.StatusCode = status; ctx.Response.ContentType = "application/json"; ctx.Response.ContentLength64 = bytes.Length; ctx.Response.OutputStream.Write(bytes, 0, bytes.Length); ctx.Response.OutputStream.Close(); } public void Dispose() { _cts.Cancel(); try { _listener.Stop(); } catch { } try { _listener.Close(); } catch { } } } ``` ### 2. The realistic case — a declared `Toolkit` against an Anthropic-style upstream [Section titled “2. The realistic case — a declared Toolkit against an Anthropic-style upstream”](#2-the-realistic-case--a-declared-toolkit-against-an-anthropic-style-upstream) ```csharp using System.Net; using System.Text; using Toolnexus; var receivedBody = ""; using var stub = new Stub(ctx => { using var reader = new StreamReader(ctx.Request.InputStream); receivedBody = reader.ReadToEnd(); Stub.Json(ctx, 200, """ {"id":"m1","model":"claude-3-5-sonnet","stop_reason":"tool_use","content":[{"type":"tool_use","id":"toolu_1","name":"get_weather","input":{"city":"Chennai"}}],"usage":{"input_tokens":20,"output_tokens":8}} """); }); var weather = NativeTool.Of("get_weather", "current weather for a city", new Dictionary { ["type"] = "object", ["properties"] = new Dictionary { ["city"] = new Dictionary { ["type"] = "string" } }, }, (IDictionary _) => "unused — TranslateAsync never executes this"); var client = LlmClient.Create(new LlmClient.Options { BaseUrl = stub.BaseUrl, Style = "anthropic", Model = "claude-3-5-sonnet", ApiKey = "test-key" }); await using var tk = await Toolkit.CreateAsync(new Toolkit.Options { Builtins = false, ExtraTools = new List { weather } }); var result = await client.TranslateAsync(new Translate.Request { Messages = new List { new Dictionary { ["role"] = "user", ["content"] = "how's the weather in Chennai?" } }, Toolkit = tk, }); // A turn emitting any tool call is ALWAYS "tool_calls" to the OpenAI-shaped caller — the raw // Anthropic stop_reason ("tool_use") never leaks through. if (result.FinishReason != "tool_calls") throw new Exception(result.FinishReason); if (result.ToolCalls.Count != 1 || result.ToolCalls[0].Name != "get_weather") throw new Exception("expected the declared tool call back"); if (result.ToolCalls[0].Arguments != "{\"city\":\"Chennai\"}") throw new Exception(result.ToolCalls[0].Arguments); // JSON STRING, the OpenAI wire form if (!receivedBody.Contains("get_weather")) throw new Exception("toolkit must have been DECLARED to the provider"); Console.WriteLine($"ok: {result.ToolCalls[0].Name}({result.ToolCalls[0].Arguments}) finish={result.FinishReason}"); sealed class Stub : IDisposable { readonly HttpListener _listener = new(); readonly CancellationTokenSource _cts = new(); public int Port { get; } public string BaseUrl => $"http://127.0.0.1:{Port}"; public Stub(Action handler) { var probe = new System.Net.Sockets.TcpListener(IPAddress.Loopback, 0); probe.Start(); Port = ((IPEndPoint)probe.LocalEndpoint).Port; probe.Stop(); _listener.Prefixes.Add($"http://127.0.0.1:{Port}/"); _listener.Start(); _ = Task.Run(async () => { while (!_cts.IsCancellationRequested) { HttpListenerContext ctx; try { ctx = await _listener.GetContextAsync(); } catch { break; } try { handler(ctx); } catch { } } }); } public static void Json(HttpListenerContext ctx, int status, string body) { var bytes = Encoding.UTF8.GetBytes(body); ctx.Response.StatusCode = status; ctx.Response.ContentType = "application/json"; ctx.Response.ContentLength64 = bytes.Length; ctx.Response.OutputStream.Write(bytes, 0, bytes.Length); ctx.Response.OutputStream.Close(); } public void Dispose() { _cts.Cancel(); try { _listener.Stop(); } catch { } try { _listener.Close(); } catch { } } } ``` ### 3. Full surface — `ToolChoice`, a `System` override, and `MaxTokens` [Section titled “3. Full surface — ToolChoice, a System override, and MaxTokens”](#3-full-surface--toolchoice-a-system-override-and-maxtokens) ```csharp using System.Net; using System.Text; using Toolnexus; var receivedBody = ""; using var stub = new Stub(ctx => { using var reader = new StreamReader(ctx.Request.InputStream); receivedBody = reader.ReadToEnd(); Stub.Json(ctx, 200, """ {"id":"c1","choices":[{"message":{"role":"assistant","content":"terse, as instructed"},"finish_reason":"stop"}],"usage":{"prompt_tokens":9,"completion_tokens":3,"total_tokens":12}} """); }); var client = LlmClient.Create(new LlmClient.Options { BaseUrl = stub.BaseUrl, Style = "openai", Model = "gpt-4o-mini", ApiKey = "test-key", SystemPrompt = "the client's own default system prompt — expect this to be OVERRIDDEN below", }); var result = await client.TranslateAsync(new Translate.Request { Messages = new List { new Dictionary { ["role"] = "user", ["content"] = "reply briefly" }, }, System = "You always answer in exactly three words.", // overrides Options.SystemPrompt for this ONE call ToolChoice = "none", MaxTokens = 64, }); if (result.Text != "terse, as instructed") throw new Exception(result.Text); if (result.Model != "gpt-4o-mini") throw new Exception(result.Model); if (!receivedBody.Contains("You always answer in exactly three words.")) throw new Exception("request.System must win"); if (receivedBody.Contains("the client's own default")) throw new Exception("the client-level SystemPrompt must be overridden, not merged"); if (!receivedBody.Contains("\"max_tokens\":64")) throw new Exception(receivedBody); Console.WriteLine($"ok: {result.Text} (model={result.Model})"); sealed class Stub : IDisposable { readonly HttpListener _listener = new(); readonly CancellationTokenSource _cts = new(); public int Port { get; } public string BaseUrl => $"http://127.0.0.1:{Port}"; public Stub(Action handler) { var probe = new System.Net.Sockets.TcpListener(IPAddress.Loopback, 0); probe.Start(); Port = ((IPEndPoint)probe.LocalEndpoint).Port; probe.Stop(); _listener.Prefixes.Add($"http://127.0.0.1:{Port}/"); _listener.Start(); _ = Task.Run(async () => { while (!_cts.IsCancellationRequested) { HttpListenerContext ctx; try { ctx = await _listener.GetContextAsync(); } catch { break; } try { handler(ctx); } catch { } } }); } public static void Json(HttpListenerContext ctx, int status, string body) { var bytes = Encoding.UTF8.GetBytes(body); ctx.Response.StatusCode = status; ctx.Response.ContentType = "application/json"; ctx.Response.ContentLength64 = bytes.Length; ctx.Response.OutputStream.Write(bytes, 0, bytes.Length); ctx.Response.OutputStream.Close(); } public void Dispose() { _cts.Cancel(); try { _listener.Stop(); } catch { } try { _listener.Close(); } catch { } } } ``` Caution `Hooks.BeforeLLM`/`AfterLLM` fire exactly once around a `TranslateAsync` call — but **tool hooks never fire**, because no tool ever runs here. If your `Hooks.BeforeTool`/`AfterTool` do anything load-bearing (auditing, veto), remember they are silently absent on this path. ## Fields (`Translate.Request`) [Section titled “Fields (Translate.Request)”](#fields-translaterequest) | Field | Type | What it is | | ------------ | ---------------- | ---------------------------------------------------------------------------------------------------------------------------------- | | `Messages` | `List` | OpenAI `messages`, verbatim — including assistant `tool_calls` and `tool`-role results. | | `Tools` | `List?` | OpenAI `tools`, verbatim. Declared, never executed. | | `Toolkit` | `Toolkit?` | An ordinary toolkit, declared via the §5 adapters. Composes with `Tools` — toolkit declarations come first. | | `ToolChoice` | `object?` | OpenAI `tool_choice`, verbatim. `null` omits it. | | `System` | `string?` | Overrides the system prompt for this call only. `null` uses any `system` message already in `Messages`, or `Options.SystemPrompt`. | | `MaxTokens` | `int` | Overrides the per-provider default. `0` uses the default. | ## See also [Section titled “See also”](#see-also) * [`Translate.OpenAIMessagesToAnthropic`](/api/csharp/translate/inbound/) — The inbound conversion `TranslateAsync` uses internally for an Anthropic-style upstream. * [`LlmClient.RunAsync`](/api/csharp/client/run/) — The agent loop this bypasses: use it when toolnexus should own the conversation and execute tools itself. * [`suspension/relay`](/api/csharp/suspension/relay/) — The Go-only mechanism for “toolnexus owns the loop, but the host executes every call.” # FromFile > 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. — C#. **C#** · package `Toolnexus` · [SPEC §1B](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) ```csharp public static ContentPart FromFile(string path, string? mimeType = null, long? maxPartBytes = null); public static ContentPart FromFile(FileInfo file, string? mimeType = null, long? maxPartBytes = null); public static Task FromFileAsync(string path, string? mimeType = null, long? maxPartBytes = null, CancellationToken ct = default); public static Task FromFileAsync(FileInfo file, string? mimeType = null, long? maxPartBytes = null, CancellationToken ct = default); public static ContentPart FromStream(Stream stream, string? mimeType = null, string? name = null, long? maxPartBytes = null); // reads forward, eagerly — does NOT dispose public static Task FromStreamAsync(Stream stream, string? mimeType = null, string? name = null, long? maxPartBytes = null, CancellationToken ct = default); public static ContentPart FromBytes(byte[] bytes, string mimeType, string? name = null, long? maxPartBytes = null); public static ContentPart FromBytes(ReadOnlySpan bytes, string mimeType, string? name = null, long? maxPartBytes = null); public static ContentPart FromBytes(ReadOnlyMemory bytes, string mimeType, string? name = null, long? maxPartBytes = null); public static ContentPart FromUrl(string url, string? mimeType = null, string? name = null, long? maxPartBytes = null); public static ContentPart FromText(string text); public static implicit operator ContentPart(string text); // a bare string lifts to a text part public long ByteLength { get; } // decoded byte length; 0 for a url/text part // On LlmClient.Options — governs how an UNREPRESENTABLE part is handled at request assembly: public string? OnUnsupportedPart { get; set; } // null (default) | "error" | "text" ``` The authoring side of multimodal content: constructors that turn a path, bytes, a stream, a data URL, or a remote URL into the [`ContentPart`](/api/csharp/types/content-part/) a prompt or tool result carries — the write half of that read-only shape. Every edge constructor here **reads and base64s at construction time**: a path, a `FileInfo` handle, or a `Stream` never enters the part itself, only `mimeType` + `Data` do, because those are what survive a persisted transcript, a subagent boundary, or an A2A hop — none of which share your filesystem or your open handles. ## When to use it [Section titled “When to use it”](#when-to-use-it) Use `FromFile`/`FromFileAsync` when you have a path or a `FileInfo` on disk; `FromStream`/ `FromStreamAsync` for a `Stream` you already hold open (a `FileStream`, a `MemoryStream`, a network stream — read forward, never seeked, so a non-seekable pipe works); `FromBytes` when you already have `byte[]`/`ReadOnlySpan`/`ReadOnlyMemory` in hand and `mimeType` is not something to infer; `FromUrl` for an `https:` URL to keep by reference, or a `data:` URL to normalize into `mimeType`+`Data` at construction so two spellings of the same bytes never diverge downstream. Reach for `Options.OnUnsupportedPart` — not any of the constructors above — when you need to change what happens to a part the *provider style itself* cannot represent (an audio part sent to a provider whose adapter has no audio block). ## Why this and not the alternative [Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative) OnUnsupportedPart overrides the provenance rule uniformly — it does not change what a constructor produces The full explanation of *why* a part holds bytes and not a path lives on [`ContentPart`](/api/csharp/types/content-part/) — read that page for the constructor-level rationale. What belongs here is the **provenance split** `OnUnsupportedPart` controls, because none of the edge constructors above know or care about it: * **The default (`OnUnsupportedPart` unset, `null`)**: a part **you attached** to a prompt is a typed `ContentPart.InvalidPartException` *before any HTTP call* if the target provider style cannot represent it — you asked for something specific and it is refused loudly, not silently downgraded. A part **derived from a tool or MCP result** instead degrades to a text placeholder (`"[unsupported audio part (audio/mpeg, 3 bytes)]"`) with a warning emitted **at most once** per client — the run is not failed by something a tool volunteered. * **`"error"`**: forces the strict (attached) behavior uniformly, even for tool-derived parts — every unrepresentable part throws. * **`"text"`**: forces the lenient (tool-derived) behavior uniformly, even for parts you attached yourself — nothing ever throws; everything unrepresentable becomes the placeholder text instead. A part is never silently dropped in any of the three settings. ## Examples [Section titled “Examples”](#examples) ### 1. The smallest useful call — a path becomes bytes, never a path [Section titled “1. The smallest useful call — a path becomes bytes, never a path”](#1-the-smallest-useful-call--a-path-becomes-bytes-never-a-path) ```csharp using Toolnexus; var repo = Environment.GetEnvironmentVariable("TOOLNEXUS_REPO") ?? "."; var png = Path.Combine(repo, "examples/media/fixture.png"); var image = ContentPart.FromFile(png); if (image.Type != "image") throw new Exception(image.Type); if (image.MimeType != "image/png") throw new Exception(image.MimeType!); if (image.Url is not null) throw new Exception("a path becomes bytes, never a url"); if (string.IsNullOrEmpty(image.Data)) throw new Exception("Data must be populated at construction"); // A bare string in a prompt list lifts implicitly to a text part — no explicit FromText needed. List prompt = ["What is in this image?", image]; if (prompt[0].Type != "text") throw new Exception("implicit string lift"); Console.WriteLine($"ok: {image.Type} part, {image.ByteLength} decoded bytes"); ``` ### 2. The realistic case — the read side of `OnUnsupportedPart`: attached errors, tool-derived degrades [Section titled “2. The realistic case — the read side of OnUnsupportedPart: attached errors, tool-derived degrades”](#2-the-realistic-case--the-read-side-of-onunsupportedpart-attached-errors-tool-derived-degrades) ```csharp using System.Net; using System.Text; using Toolnexus; // An audio part ATTACHED directly to a prompt, sent to a style whose adapter has no audio block, // is a typed refusal BEFORE any HTTP call — the default (OnUnsupportedPart unset) is strict for // what the caller explicitly attached. var audio = ContentPart.FromBytes(new byte[] { 1, 2, 3 }, "audio/mpeg"); using var up1 = new Upstream(_ => throw new Exception("must not be reached")); try { await Client(up1.BaseUrl, "anthropic").RunAsync(new List { "listen", audio }, await EmptyToolkit()); throw new Exception("expected InvalidPartException for an attached unsupported part"); } catch (ContentPart.InvalidPartException e) { if (!e.Message.Contains("audio")) throw new Exception(e.Message); } // The SAME kind of part, but DERIVED from a tool result, degrades to a placeholder instead — // the run is not failed by something a tool volunteered. using var up2 = new Upstream( """{"content":[{"type":"tool_use","id":"t1","name":"listen","input":{}}],"stop_reason":"tool_use"}""", """{"content":[{"type":"text","text":"heard it"}],"stop_reason":"end_turn"}"""); await using var tk = await ToolkitReturning("listen", "a clip", ContentPart.FromBytes(new byte[] { 1, 2, 3 }, "audio/mpeg")); var r = await Client(up2.BaseUrl, "anthropic").RunAsync("hear it", tk); if (r.Status != "done") throw new Exception($"a volunteered part must not fail the run, got {r.Status}"); Console.WriteLine("ok: an attached unsupported part errors; a tool-derived one degrades to a placeholder"); // --- fixture plumbing shared by this example --- static async Task EmptyToolkit() => await Toolkit.CreateAsync(new Toolkit.Options { Builtins = false }); static LlmClient Client(string baseUrl, string style, Action? tweak = null) { var o = new LlmClient.Options { BaseUrl = baseUrl, Style = style, Model = "m", ApiKey = "k", Retries = 0 }; tweak?.Invoke(o); return LlmClient.Create(o); } static async Task ToolkitReturning(string name, string output, ContentPart part) { var tk = await Toolkit.CreateAsync(new Toolkit.Options { Builtins = false }); tk.Register(NativeTool.Of(name, "returns a part", new Dictionary { ["type"] = "object", ["properties"] = new Dictionary() }, (IDictionary _, ToolContext? __) => (object)ToolResult.OkWithParts(output, new List { part }))); return tk; } sealed class Upstream : IDisposable { readonly HttpListener _listener = new(); readonly CancellationTokenSource _cts = new(); readonly string[] _bodies; int _i; public int Port { get; } public string BaseUrl => $"http://127.0.0.1:{Port}"; public Upstream(params string[] bodies) : this(_ => { }, bodies) { } public Upstream(Action onRequest, params string[] bodies) { _bodies = bodies; var probe = new System.Net.Sockets.TcpListener(IPAddress.Loopback, 0); probe.Start(); Port = ((IPEndPoint)probe.LocalEndpoint).Port; probe.Stop(); _listener.Prefixes.Add($"http://127.0.0.1:{Port}/"); _listener.Start(); _ = Task.Run(async () => { while (!_cts.IsCancellationRequested) { HttpListenerContext ctx; try { ctx = await _listener.GetContextAsync(); } catch { break; } try { onRequest(ctx); var body = _bodies.Length == 0 ? "" : _bodies[Math.Min(_i++, _bodies.Length - 1)]; var bytes = Encoding.UTF8.GetBytes(body); ctx.Response.StatusCode = 200; ctx.Response.ContentType = "application/json"; ctx.Response.ContentLength64 = bytes.Length; ctx.Response.OutputStream.Write(bytes, 0, bytes.Length); ctx.Response.OutputStream.Close(); } catch { } } }); } public void Dispose() { _cts.Cancel(); try { _listener.Stop(); } catch { } try { _listener.Close(); } catch { } } } ``` ### 3. Full surface — forcing uniform strictness with `OnUnsupportedPart = "error"` [Section titled “3. Full surface — forcing uniform strictness with OnUnsupportedPart = "error"”](#3-full-surface--forcing-uniform-strictness-with-onunsupportedpart--error) ```csharp using System.Net; using System.Text; using Toolnexus; // The SAME tool-derived audio part that degraded in example 2 now throws too, because the // override forces uniform strictness across BOTH provenances — attached and tool-derived alike. using var up = new Upstream( """{"content":[{"type":"tool_use","id":"t1","name":"listen","input":{}}],"stop_reason":"tool_use"}""", """{"content":[{"type":"text","text":"n/a"}],"stop_reason":"end_turn"}"""); await using var tk = await ToolkitReturning("listen", "a clip", ContentPart.FromBytes(new byte[] { 1, 2, 3 }, "audio/mpeg")); try { await Client(up.BaseUrl, "anthropic", o => o.OnUnsupportedPart = "error").RunAsync("hear it", tk); throw new Exception("expected InvalidPartException — the override forces strictness on a tool-derived part too"); } catch (ContentPart.InvalidPartException) { /* expected */ } // ByteLength is DECODED bytes — the figure a size cap and the token estimate are both derived // from, never the base64 string's own (33% larger) length. var bytes = new byte[82]; var part = ContentPart.FromBytes(bytes, "image/png"); if (part.ByteLength != 82) throw new Exception($"expected 82 decoded bytes, got {part.ByteLength}"); if (ContentPart.FromUrl("https://example.com/shot.png").ByteLength != 0) throw new Exception("a url part has no local bytes to count"); Console.WriteLine($"ok: OnUnsupportedPart=\"error\" forces strictness uniformly; ByteLength={part.ByteLength}"); static LlmClient Client(string baseUrl, string style, Action? tweak = null) { var o = new LlmClient.Options { BaseUrl = baseUrl, Style = style, Model = "m", ApiKey = "k", Retries = 0 }; tweak?.Invoke(o); return LlmClient.Create(o); } static async Task ToolkitReturning(string name, string output, ContentPart part) { var tk = await Toolkit.CreateAsync(new Toolkit.Options { Builtins = false }); tk.Register(NativeTool.Of(name, "returns a part", new Dictionary { ["type"] = "object", ["properties"] = new Dictionary() }, (IDictionary _, ToolContext? __) => (object)ToolResult.OkWithParts(output, new List { part }))); return tk; } sealed class Upstream : IDisposable { readonly HttpListener _listener = new(); readonly CancellationTokenSource _cts = new(); readonly string[] _bodies; int _i; public int Port { get; } public string BaseUrl => $"http://127.0.0.1:{Port}"; public Upstream(params string[] bodies) { _bodies = bodies; var probe = new System.Net.Sockets.TcpListener(IPAddress.Loopback, 0); probe.Start(); Port = ((IPEndPoint)probe.LocalEndpoint).Port; probe.Stop(); _listener.Prefixes.Add($"http://127.0.0.1:{Port}/"); _listener.Start(); _ = Task.Run(async () => { while (!_cts.IsCancellationRequested) { HttpListenerContext ctx; try { ctx = await _listener.GetContextAsync(); } catch { break; } try { var body = _bodies.Length == 0 ? "" : _bodies[Math.Min(_i++, _bodies.Length - 1)]; var bytes = Encoding.UTF8.GetBytes(body); ctx.Response.StatusCode = 200; ctx.Response.ContentType = "application/json"; ctx.Response.ContentLength64 = bytes.Length; ctx.Response.OutputStream.Write(bytes, 0, bytes.Length); ctx.Response.OutputStream.Close(); } catch { } } }); } public void Dispose() { _cts.Cancel(); try { _listener.Stop(); } catch { } try { _listener.Close(); } catch { } } } ``` ## Constructors [Section titled “Constructors”](#constructors) | Method | What it does | | ------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `FromText(text)` / implicit `string` | A `text` part. A bare `string` in a prompt list lifts to one automatically. | | `FromFile(path \| FileInfo, mimeType?, maxPartBytes?)` | Reads and base64s **now**; mime type from the §6 extension table when omitted. An unknown extension with no explicit `mimeType` is a typed error naming the extension. | | `FromFileAsync(…)` | Async sibling, with a `CancellationToken`. | | `FromStream(stream, mimeType?, name?, maxPartBytes?)` | Reads forward, eagerly, to the end. **Never disposes** the stream — that stays the caller’s responsibility. Works with non-seekable streams. | | `FromStreamAsync(…)` | Async sibling, same contract. | | `FromBytes(byte[] \| ReadOnlySpan \| ReadOnlyMemory, mimeType, name?, maxPartBytes?)` | Base64s native bytes; `mimeType` is required — there is nothing to infer it from. | | `FromUrl(url, mimeType?, name?, maxPartBytes?)` | Keeps an `https:` URL by reference; parses a `data:` URL into `mimeType` + `Data` at construction so the two spellings never diverge downstream. | ## `Options.OnUnsupportedPart` [Section titled “Options.OnUnsupportedPart”](#optionsonunsupportedpart) | Value | Attached part (caller-supplied) | Tool/MCP-derived part | | ---------------- | -------------------------------------------------- | ------------------------------------------------------------------------------------------------- | | `null` (default) | Typed `InvalidPartException`, before any HTTP call | Degrades to `"[unsupported part (, bytes)]"`, warned at most once per client | | `"error"` | Same as default | Also throws — the override forces strictness uniformly | | `"text"` | Degrades to the placeholder instead of throwing | Same as default | ## See also [Section titled “See also”](#see-also) * [`ContentPart`](/api/csharp/types/content-part/) — The full read/write shape, its members, and the fuller rationale for why a part holds bytes and never a path or a caller’s `Stream`. * [`ITool`](/api/csharp/types/tool/) — The uniform shape every tool source collapses to: name, description, JSON-Schema parameters, execute. * [`ToolResult`](/api/csharp/types/tool-result/) — The result envelope: output text, optional error flag, optional non-text parts, and optional metadata that can carry a suspension. * [`ToolContext`](/api/csharp/types/context/) — Optional per-call context handed to execute: cancellation, identity, and host-supplied state. # ContentPart > The non-text half of a message: text | image | file | audio, carrying base64 bytes or a URL plus a mimeType — never a path. — C#. **C#** · package `Toolnexus` · [SPEC §1B](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [ContentPart.cs](https://github.com/muthuishere/toolnexus/blob/main/csharp/src/Toolnexus/ContentPart.cs) ```csharp public sealed record ContentPart { public string Type { get; init; } // "text" | "image" | "file" | "audio" public string? Text { get; init; } // text parts only public string? MimeType { get; init; } // spelled mimeType everywhere, never media_type public string? Data { get; init; } // standard base64, padded, no line breaks public string? Url { get; init; } // an https: URL — exactly one of Data / Url public string? Name { get; init; } // optional filename for a file part // Edge constructors — they read and base64 NOW, so no path or handle enters the part. public static ContentPart FromText(string text); public static implicit operator ContentPart(string text); public static ContentPart FromFile(string path, string? mimeType = null, long? maxPartBytes = null); public static ContentPart FromFile(FileInfo file, string? mimeType = null, long? maxPartBytes = null); public static Task FromFileAsync(string path, string? mimeType = null, long? maxPartBytes = null, CancellationToken ct = default); public static Task FromFileAsync(FileInfo file, string? mimeType = null, long? maxPartBytes = null, CancellationToken ct = default); public static ContentPart FromStream(Stream stream, string? mimeType = null, string? name = null, long? maxPartBytes = null); public static Task FromStreamAsync(Stream stream, string? mimeType = null, string? name = null, long? maxPartBytes = null, CancellationToken ct = default); public static ContentPart FromBytes(byte[] bytes, string mimeType, string? name = null, long? maxPartBytes = null); public static ContentPart FromBytes(ReadOnlySpan bytes, string mimeType, string? name = null, long? maxPartBytes = null); public static ContentPart FromBytes(ReadOnlyMemory bytes,string mimeType, string? name = null, long? maxPartBytes = null); public static ContentPart FromUrl(string url, string? mimeType = null, string? name = null, long? maxPartBytes = null); public ContentPart Validate(); // throws InvalidPartException public long ByteLength { get; } // DECODED bytes; 0 for a url/text part public int EstimatedTokens { get; } // max(85, bytes / 750) — byte-derived public string Describe(); // {type, mimeType, bytes} — data is never logged public string DescribeInText(); // "image (image/png, 82 bytes)" public string UnsupportedPlaceholderText(); // "[unsupported audio part (audio/wav, 41984 bytes)]" public static readonly IReadOnlyDictionary MediaTable; public static (string MimeType, string Type)? MediaFor(string path); public static string TypeForMime(string mimeType); public sealed class InvalidPartException : Exception { } } ``` The non-text half of a message: `text | image | file | audio`, carrying base64 bytes or a URL plus a `mimeType` — never a path. It is a `record`, so it is immutable and has value equality. ## When to use it [Section titled “When to use it”](#when-to-use-it) Two directions, both of them the same type: * **Going in** — attaching an image, a PDF or an audio clip to a run. `RunAsync`, `AskAsync` and `StreamAsync` each take an `IReadOnlyList` in the same first position the `string` prompt occupies, and the implicit `string → ContentPart` lift means a collection expression can mix plain text with attachments. * **Coming out** — returning one from a tool. [`ToolResult.Parts`](/api/csharp/types/tool-result/) carries non-text output (a screenshot, a rendered chart, a generated PDF) alongside the `Output` text that describes it. The [`read`](/api/csharp/builtins/create/) built-in already produces one for a recognised media file, and an MCP server’s image result arrives as one too — so a part you never constructed still behaves exactly like one you did. ## Why this and not the alternative [Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative) A part holds bytes, not a path — and not your Stream either The obvious design is a part that keeps the *path* and reads it later. It does not survive the places a part actually goes. A transcript is persisted by a [`ConversationStore`](/api/csharp/client/store/) and replayed later — possibly on another machine, certainly after the temp file is gone. The same part crosses into a subagent, out through a served toolkit, and on to an A2A peer, none of which share your filesystem. So the edge constructors read and base64 **at construction**: what lands in the part is a `mimeType` and `Data`, which travel. Two .NET-specific choices follow from the same rule: **A caller-supplied `Stream` is read, never disposed.** `FromStream` consumes it eagerly — a part holding a half-read stream is exactly as unportable as one holding a path — but disposal stays the owner’s, because the edge cannot know whether your `Stream` is a throwaway `FileStream` or a long-lived pipe you are still reading. Your `using` keeps meaning what it says. **A non-seekable stream is read forward.** `Seek` and `Length` are touched only when `CanSeek` is true (they are used purely to fail fast on `maxPartBytes` with a better message), so a pipe, a `NetworkStream` or a decompressor works. The `maxPartBytes` *guarantee* is the check at request assembly, not the one at the edge — a part that arrived from an MCP server never passed through a constructor at all. Mime types come from the fixed §6 extension table (`png jpg jpeg gif webp pdf mp3 wav`) and are **never** sniffed from content or resolved through a platform mime database — `/etc/mime.types` varies per machine, which would break cross-port parity. An unknown extension with no explicit `mimeType` is a typed error **naming the extension**. ## 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) ```csharp using Toolnexus; // TOOLNEXUS_REPO is set by the docs test runner; in your own code just use a path. var repo = Environment.GetEnvironmentVariable("TOOLNEXUS_REPO") ?? "."; var png = Path.Combine(repo, "examples/media/fixture.png"); var image = ContentPart.FromFile(png); if (image.Type != "image") throw new Exception(image.Type); if (image.MimeType != "image/png") throw new Exception(image.MimeType!); if (image.Url is not null) throw new Exception("a path becomes bytes, never a url"); if (image.Name != "fixture.png") throw new Exception("the display name survives"); // The committed golden base64 — read from disk, never re-encoded by the example itself. var golden = File.ReadAllText(Path.Combine(repo, "examples/media/fixture.png.base64")).Trim(); if (image.Data != golden) throw new Exception("base64 drifted from the golden"); // A prompt is a list of parts. The implicit string lift keeps plain text plain: // client.RunAsync(prompt, toolkit) takes this list where a string would go. List prompt = ["What is in this image?", image]; if (prompt.Count != 2) throw new Exception("two parts"); if (prompt[0].Type != "text" || prompt[0].Text != "What is in this image?") throw new Exception("string lift"); Console.WriteLine($"ok: {prompt.Count} parts | {image.Describe()}"); ``` ### 2. The native sources a .NET caller already holds [Section titled “2. The native sources a .NET caller already holds”](#2-the-native-sources-a-net-caller-already-holds) `FileInfo`, a `Stream`, `byte[]`, `ReadOnlySpan`, `ReadOnlyMemory` — accept broadly, store narrowly. Every one of them lands as the same `{mimeType, data}` pair. ```csharp using Toolnexus; var repo = Environment.GetEnvironmentVariable("TOOLNEXUS_REPO") ?? "."; var png = Path.Combine(repo, "examples/media/fixture.png"); var golden = File.ReadAllText(Path.Combine(repo, "examples/media/fixture.png.base64")).Trim(); var bytes = File.ReadAllBytes(png); // 1. FileInfo — mime type from .Extension, via the §6 media table. var fromInfo = ContentPart.FromFile(new FileInfo(png)); // 2. A FileStream — read forward, eagerly, at construction. ContentPart fromFileStream; using (var fs = File.OpenRead(png)) fromFileStream = ContentPart.FromStream(fs, name: "fixture.png"); // 3. A MemoryStream the CALLER owns — deliberately no `using`; we close it ourselves below. var ms = new MemoryStream(bytes); var fromMemoryStream = ContentPart.FromStream(ms, "image/png"); // 4 + 5. Native bytes, array and ReadOnlyMemory. mimeType is required — there is nothing to infer from. var fromArray = ContentPart.FromBytes(bytes, "image/png"); var fromMemory = ContentPart.FromBytes(new ReadOnlyMemory(bytes), "image/png"); foreach (var p in new[] { fromInfo, fromFileStream, fromMemoryStream, fromArray, fromMemory }) { if (p.Data != golden) throw new Exception($"a source disagreed: {p.Describe()}"); if (p.MimeType != "image/png" || p.Type != "image") throw new Exception(p.Describe()); } // The edge READ the stream; it did not dispose it. Disposal stays the owner's job. if (!ms.CanRead) throw new Exception("the edge closed a stream it does not own"); ms.Position = 0; if (ms.Read(new byte[82], 0, 82) != 82) throw new Exception("and it is still usable"); ms.Dispose(); // Nothing that cannot cross a process boundary survives into the part. var json = System.Text.Json.JsonSerializer.Serialize(fromFileStream); if (json.Contains(png)) throw new Exception("a path leaked into the part"); if (json.Contains("Stream")) throw new Exception("a handle leaked into the part"); if (!json.Contains("\"mimeType\":\"image/png\"")) throw new Exception(json); Console.WriteLine($"ok: 5 native sources, one shape — {fromInfo.Describe()}"); ``` ### 3. The failure modes, and what a part costs [Section titled “3. The failure modes, and what a part costs”](#3-the-failure-modes-and-what-a-part-costs) ```csharp using Toolnexus; var repo = Environment.GetEnvironmentVariable("TOOLNEXUS_REPO") ?? "."; var bytes = File.ReadAllBytes(Path.Combine(repo, "examples/media/fixture.png")); // (a) EXACTLY ONE of Data / Url. Both is a typed construction error... var both = new ContentPart { Type = "image", MimeType = "image/png", Data = Convert.ToBase64String(bytes), Url = "https://example.com/shot.png", }; try { both.Validate(); throw new Exception("both data and url should be refused"); } catch (ContentPart.InvalidPartException e) { if (!e.Message.Contains("both data and url")) throw; } // ...and so is neither. try { new ContentPart { Type = "image", MimeType = "image/png" }.Validate(); throw new Exception("neither data nor url should be refused"); } catch (ContentPart.InvalidPartException e) { if (!e.Message.Contains("neither data nor url")) throw; } // (b) An unknown extension is refused BY NAME — mime type is never sniffed from content. var odd = Path.Combine(Path.GetTempPath(), $"toolnexus-{Guid.NewGuid():N}.xyz"); File.WriteAllBytes(odd, bytes); try { try { ContentPart.FromFile(odd); throw new Exception("an unknown extension should be refused"); } catch (ContentPart.InvalidPartException e) { if (!e.Message.Contains(".xyz")) throw new Exception(e.Message); } // Say what it is and it is accepted. if (ContentPart.FromFile(odd, "image/png").Type != "image") throw new Exception("explicit mimeType"); } finally { File.Delete(odd); } // (c) maxPartBytes fails fast at the edge, in DECODED bytes. // (The normative check is at request assembly — an MCP-supplied part never passed through here.) try { ContentPart.FromBytes(bytes, "image/png", maxPartBytes: 32); throw new Exception("an oversize part should be refused"); } catch (ContentPart.InvalidPartException e) { if (!e.Message.Contains("32")) throw new Exception(e.Message); } // (d) The token charge is BYTE-derived: max(85, bytes / 750). Never the mimeType string's // length, which would score a 5 MB image at ~3 tokens and make it uncompactable. var image = ContentPart.FromBytes(bytes, "image/png"); if (image.ByteLength != 82) throw new Exception($"bytes: {image.ByteLength}"); if (image.EstimatedTokens != 85) throw new Exception($"tokens: {image.EstimatedTokens}"); if (ContentPart.FromText("four characters!").EstimatedTokens != 4) throw new Exception("text tokens"); // The three user-visible strings are byte-identical in all eight ports. if (image.DescribeInText() != "image (image/png, 82 bytes)") throw new Exception(image.DescribeInText()); if (image.UnsupportedPlaceholderText() != "[unsupported image part (image/png, 82 bytes)]") throw new Exception(image.UnsupportedPlaceholderText()); // A url part renders as 0 — there are no local bytes to count. if (ContentPart.FromUrl("https://example.com/shot.png").DescribeInText() != "image (image/png, 0 bytes)") throw new Exception("url part"); Console.WriteLine($"ok: {image.EstimatedTokens} tokens for {image.ByteLength} bytes | {image.DescribeInText()}"); ``` ## Members [Section titled “Members”](#members) | Member | Type | What it is | | ----------------- | --------- | ------------------------------------------------------------------------ | | `Type` | `string` | `"text"` \| `"image"` \| `"file"` \| `"audio"`. | | `Text` | `string?` | Present only on a `text` part. | | `MimeType` | `string?` | Spelled `mimeType` in every port and on the wire. | | `Data` | `string?` | Standard base64 (RFC 4648 §4), padded, no line breaks. **Never logged.** | | `Url` | `string?` | An `https:` URL. A `data:` URL is normalised into `MimeType` + `Data`. | | `Name` | `string?` | Optional filename for a `file` part. | | `ByteLength` | `long` | Decoded byte count; `0` for a url or text part. | | `EstimatedTokens` | `int` | `max(85, bytes / 750)` for a non-text part; `ceil(chars/4)` for text. | ## Static factories [Section titled “Static factories”](#static-factories) | Method | What it does | | ------------------------------------------------------------------------------------------------- | ------------------------------------------------------- | | `FromText(text)` | A `text` part. A bare `string` lifts to one implicitly. | | `FromFile(path \| FileInfo, mimeType?, maxPartBytes?)` | Reads and base64s now; mime from the §6 table. | | `FromFileAsync(…)` | Async sibling, with a `CancellationToken`. | | `FromStream(stream, mimeType?, name?, maxPartBytes?)` | Reads forward, eagerly. **Does not dispose.** | | `FromStreamAsync(…)` | Async sibling, same contract. | | `FromBytes(byte[] \| ReadOnlySpan \| ReadOnlyMemory, mimeType, name?, maxPartBytes?)` | Base64s native bytes; `mimeType` required. | | `FromUrl(url, mimeType?, name?, maxPartBytes?)` | Keeps an `https:` URL; parses a `data:` URL into bytes. | | `MediaFor(path)` / `TypeForMime(mime)` / `MediaTable` | The fixed §6 extension table and its lookups. | ## See also [Section titled “See also”](#see-also) * [`ITool`](/api/csharp/types/tool/) — The uniform shape every tool source collapses to: name, description, JSON-Schema parameters, execute. * [`ToolResult`](/api/csharp/types/tool-result/) — The result envelope: output text, optional error flag, optional non-text parts, and optional metadata that can carry a suspension. * [`ToolContext`](/api/csharp/types/context/) — Optional per-call context handed to execute: cancellation, identity, and host-supplied state. # ToolContext > The per-call context handed to ExecuteAsync — a CancellationToken, a timeout in milliseconds, and the answer that resolves a suspension. C#. **C#** · package `Toolnexus` · [SPEC §1](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [ToolContext.cs](https://github.com/muthuishere/toolnexus/blob/main/csharp/src/Toolnexus/ToolContext.cs) ```csharp public sealed class ToolContext { public ToolContext(long? timeoutMs = null, CancellationToken cancellationToken = default, Answer? answer = null); public long? TimeoutMs { get; } public CancellationToken CancellationToken { get; } public Answer? Answer { get; } public bool IsCancelled { get; } // => CancellationToken.IsCancellationRequested } ``` The optional second argument to `ExecuteAsync`. It carries the three things a tool needs to be a good citizen: a cancellation token, a deadline, and — on a resumed call — the answer to a question it previously asked. ## When to use it [Section titled “When to use it”](#when-to-use-it) Read `ctx` when your tool does anything **slow or interactive**: * **`IsCancelled` / `CancellationToken`** — long work that should stop when the run is cancelled. * **`TimeoutMs`** — the caller’s deadline for this specific call, in **milliseconds**. * **`Answer`** — you returned a suspension on a previous attempt and the host has now resolved it. A pure, fast, local computation can ignore `ctx` entirely. ## Why a wrapper around CancellationToken [Section titled “Why a wrapper around CancellationToken”](#why-a-wrapper-around-cancellationtoken) ctx is nullable — use ?. everywhere `ExecuteAsync` declares `ToolContext? ctx = null`, so callers may omit it. Inside a tool write `ctx?.IsCancelled == true` rather than `ctx.IsCancelled` — the `== true` handles the `bool?` that `?.` produces. .NET already has `CancellationToken`, so why the wrapper? Because the §1 contract carries two more things — a per-call timeout and the §10 `Answer` — and threading three parameters through every tool source would be worse. The `CancellationToken` is still in there, and you can hand it straight to any async API that accepts one. ## Examples [Section titled “Examples”](#examples) ### 1. Respecting cancellation [Section titled “1. Respecting cancellation”](#1-respecting-cancellation) Check before starting work and between steps. A cancelled tool returns an error *result* promptly rather than throwing. ```csharp using Toolnexus; ToolResult Crunch(int steps, ToolContext? ctx) { var done = 0; for (var i = 0; i < steps; i++) { // ctx is nullable, and ?. on a bool yields bool? — compare against true. if (ctx?.IsCancelled == true) return ToolResult.Error($"cancelled after {done} step(s)"); done++; } return ToolResult.Ok($"completed {done} step(s)"); } // No context at all — the tool still runs. var plain = Crunch(3, null); if (plain.Output != "completed 3 step(s)") throw new Exception(plain.Output); // Cancelled before it starts. using var cts = new CancellationTokenSource(); cts.Cancel(); var stopped = Crunch(3, new ToolContext(cancellationToken: cts.Token)); if (!stopped.IsError || stopped.Output != "cancelled after 0 step(s)") throw new Exception(stopped.Output); // A live token reads as not cancelled. using var live = new CancellationTokenSource(); if (new ToolContext(cancellationToken: live.Token).IsCancelled) throw new Exception("should be live"); Console.WriteLine($"ok: {plain.Output} | {stopped.Output}"); ``` ### 2. Honouring the caller’s timeout [Section titled “2. Honouring the caller’s timeout”](#2-honouring-the-callers-timeout) `TimeoutMs` is a **`long?`** — `null` means “not set”. Fall back to your own default rather than treating it as zero. ```csharp using Toolnexus; ToolResult Fetchish(string url, ToolContext? ctx) { // null means unset — use your own default. var budget = ctx?.TimeoutMs ?? 30_000L; if (budget < 100) return ToolResult.Error($"budget {budget}ms is too small to try"); return ToolResult.Ok($"fetched {url} within {budget}ms"); } var generous = Fetchish("/a", new ToolContext(5000)); if (generous.Output != "fetched /a within 5000ms") throw new Exception(generous.Output); var stingy = Fetchish("/a", new ToolContext(10)); if (!stingy.IsError) throw new Exception("expected the tiny budget to be refused"); var defaulted = Fetchish("/a", new ToolContext()); if (!defaulted.Output.Contains("30000ms")) throw new Exception(defaulted.Output); var noCtx = Fetchish("/a", null); if (!noCtx.Output.Contains("30000ms")) throw new Exception(noCtx.Output); Console.WriteLine($"ok: {generous.Output} | {stingy.Output}"); ``` ### 3. `Answer` — the second half of a suspension [Section titled “3. Answer — the second half of a suspension”](#3-answer--the-second-half-of-a-suspension) This is the property that makes the human-in-the-loop contract work. On the first call the tool returns a `Pending`. The host resolves it, then calls the **same tool again** with `Answer` set. ```csharp using Toolnexus; ToolResult Deploy(ToolContext? ctx) { // Second pass: the host resolved the question and handed the answer back. if (ctx?.Answer is { } answer) { if (!answer.Ok) return ToolResult.Error($"declined: {answer.Reason ?? "no reason"}"); // Data is IDictionary — TryGetValue, not GetValueOrDefault // (that extension is declared on IReadOnlyDictionary and will not infer here). object? env = null; answer.Data?.TryGetValue("env", out env); return ToolResult.Ok($"deployed to {env ?? "unknown"}"); } // First pass: park the run and ask. return ToolResult.Pending(new Request { Kind = "input", Prompt = "Which environment?" }); } // First pass — a suspension, not an answer. var first = Deploy(null); var req = ToolResult.PendingOf(first); if (req is null || req.Kind != "input") throw new Exception("expected a suspension"); // Second pass — the host supplies the resolution, echoing the request id. var resumed = new ToolContext(answer: new Answer { Id = req.Id, Ok = true, Data = new Dictionary { ["env"] = "staging" }, }); var second = Deploy(resumed); if (second.IsError || second.Output != "deployed to staging") throw new Exception(second.Output); // A refusal is a normal outcome, not a crash. var declined = new ToolContext(answer: new Answer { Id = req.Id, Ok = false, Reason = "declined" }); var refused = Deploy(declined); if (!refused.IsError) throw new Exception("expected an error result"); Console.WriteLine($"ok: {second.Output} | {refused.Output}"); ``` Read Answer.Data only when the answer IS the payload For `Kind:"input"` the value you asked for lives in `Answer.Data`. For `Kind:"authorization"` the world changed out-of-band — the session is simply valid now — so you re-run the real work and ignore `Data`. ## `CallId` [Section titled “CallId”](#callid) `ToolContext.CallId` is the model’s id for this tool call when the client loop runs the tool, and `null` outside the loop. One message’s tool calls run concurrently, so this is how a tool tells sibling calls apart (for example, which subagent call is the parent of a UI row). ```csharp var tool = NativeTool.Of("track", "Tracked work", null, (IDictionary a, ToolContext? ctx) => $"call {ctx?.CallId ?? "(outside the loop)"}"); ``` ## Members [Section titled “Members”](#members) | Member | Type | What it is | | ------------------- | ------------------- | ----------------------------------------------------------- | | `TimeoutMs` | `long?` | This call’s budget in **milliseconds**. `null` means unset. | | `CancellationToken` | `CancellationToken` | Hand it straight to any async API that takes one. | | `IsCancelled` | `bool` | Shorthand for `CancellationToken.IsCancellationRequested`. | | `Answer` | `Answer?` | Present **only** on a post-`WaitFor` retry. | ## See also [Section titled “See also”](#see-also) * [`ITool`](/api/csharp/types/tool/) — what receives this * [`ToolResult`](/api/csharp/types/tool-result/) — what `ExecuteAsync` returns * [`Suspension.Pending`](/api/csharp/suspension/pending/) — ask a question mid-call # ITool > The uniform interface every tool source collapses to — name, description, JSON-Schema parameters, ExecuteAsync. C#. **C#** · package `Toolnexus` · [SPEC §1](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [ITool.cs](https://github.com/muthuishere/toolnexus/blob/main/csharp/src/Toolnexus/ITool.cs) ```csharp public interface ITool { string Name { get; } string Description { get; } IDictionary InputSchema { get; } string Source { get; } Task ExecuteAsync(IDictionary args, ToolContext? ctx = null); } ``` The one interface. An MCP server tool, an agent skill, a built-in shell tool, a remote A2A agent, an HTTP endpoint and a plain method of your own are all *the same thing* to an LLM — a named, described, schema’d callable. `ITool` is that thing, and every source produces it. ## When to use it [Section titled “When to use it”](#when-to-use-it) You mostly **receive** tools rather than implement them: `tk.Tools()` hands you a list, and that is what you filter and pass to an adapter. Implement it directly when you are writing a **new tool source** — something producing tools from a shape toolnexus doesn’t already cover. For a single ordinary method, use [`NativeTool.Of`](/api/csharp/native/define-tool/) or the `[ToolMethod]` attribute instead. ## Why an interface, and why `ctx` is optional [Section titled “Why an interface, and why ctx is optional”](#why-an-interface-and-why-ctx-is-optional) Use NativeTool for one-off tools Implementing four properties and a method to wrap one function is a lot of ceremony. [`NativeTool.Of`](/api/csharp/native/define-tool/) does it for you and sets `Source` to `"native"`. Implement `ITool` yourself when you are generating many tools from data. `ExecuteAsync` takes `ToolContext? ctx = null` — the parameter has a default, so callers may omit it entirely. Inside your implementation always treat it as nullable. ## Examples [Section titled “Examples”](#examples) ### 1. The smallest possible tool [Section titled “1. The smallest possible tool”](#1-the-smallest-possible-tool) ```csharp using Toolnexus; var echo = new EchoTool(); var res = await echo.ExecuteAsync(new Dictionary { ["text"] = "hello" }); if (res.Output != "hello" || res.IsError) throw new Exception($"unexpected: {res.Output}"); Console.WriteLine($"ok: {res.Output}"); sealed class EchoTool : ITool { public string Name => "echo"; public string Description => "Return whatever it is given"; public string Source => "custom"; public IDictionary InputSchema => new Dictionary { ["type"] = "object", ["properties"] = new Dictionary { ["text"] = new Dictionary { ["type"] = "string" } }, ["required"] = new[] { "text" }, }; public Task ExecuteAsync(IDictionary args, ToolContext? ctx = null) => Task.FromResult(ToolResult.Ok(args["text"]?.ToString() ?? "")); } ``` `Source` is not free-form — it is one of `mcp`, `skill`, `native`, `http`, `a2a`, `custom`. Use `custom` for tools you implement yourself. ### 2. Reporting failure, and carrying metadata [Section titled “2. Reporting failure, and carrying metadata”](#2-reporting-failure-and-carrying-metadata) A tool that fails does **not** throw — it returns an error result. `ToolResult.Ok` and `ToolResult.Error` are the shorthand, each taking optional metadata. ```csharp using Toolnexus; var divide = new DivideTool(); var ok = await divide.ExecuteAsync(new Dictionary { ["a"] = 10.0, ["b"] = 4.0 }); if (ok.Output != "2.5") throw new Exception($"got {ok.Output}"); if (ok.Metadata?["title"] as string != "divide") throw new Exception("metadata"); var bad = await divide.ExecuteAsync(new Dictionary { ["a"] = 1.0, ["b"] = 0.0 }); if (!bad.IsError) throw new Exception("expected IsError"); Console.WriteLine($"ok: {ok.Output} | error path: {bad.Output}"); sealed class DivideTool : ITool { public string Name => "divide"; public string Description => "Divide two numbers"; public string Source => "custom"; public IDictionary InputSchema => new Dictionary { ["type"] = "object", ["properties"] = new Dictionary { ["a"] = new Dictionary { ["type"] = "number" }, ["b"] = new Dictionary { ["type"] = "number" }, }, ["required"] = new[] { "a", "b" }, }; public Task ExecuteAsync(IDictionary args, ToolContext? ctx = null) { var a = Convert.ToDouble(args["a"]); var b = Convert.ToDouble(args["b"]); if (b == 0) { // The model sees this text and can correct itself on the next turn. return Task.FromResult(ToolResult.Error("Cannot divide by zero")); } return Task.FromResult(ToolResult.Ok( (a / b).ToString(System.Globalization.CultureInfo.InvariantCulture), new Dictionary { ["title"] = "divide", ["operands"] = new[] { a, b } })); } } ``` ### 3. A generated tool source — the real reason this interface is public [Section titled “3. A generated tool source — the real reason this interface is public”](#3-a-generated-tool-source--the-real-reason-this-interface-is-public) Producing many tools from data is where you implement `ITool` directly. `Tools.Sanitize` makes each name schema-safe. ```csharp using Toolnexus; var endpoints = new[] { ("get user", "/users/:id"), ("list orders", "/orders"), }; var tools = endpoints.Select(e => new EndpointTool(e.Item1, e.Item2)).ToList(); if (tools[0].Name != "get_user" || tools[1].Name != "list_orders") throw new Exception($"names: {tools[0].Name},{tools[1].Name}"); var res = await tools[0].ExecuteAsync(new Dictionary { ["id"] = "42" }); if (res.Output != "/users/:id <- 42") throw new Exception(res.Output); Console.WriteLine($"ok: {string.Join(", ", tools.Select(t => t.Name))}"); sealed class EndpointTool : ITool { private readonly string _path; public EndpointTool(string key, string path) { // Names must match [a-zA-Z0-9_-]; Sanitize does exactly that. Name = Tools.Sanitize(key); _path = path; } public string Name { get; } public string Description => $"Call {_path}"; public string Source => "custom"; public IDictionary InputSchema => new Dictionary { ["type"] = "object", ["properties"] = new Dictionary { ["id"] = new Dictionary { ["type"] = "string" } }, }; public Task ExecuteAsync(IDictionary args, ToolContext? ctx = null) { // ctx is nullable — always guard it. if (ctx?.IsCancelled == true) return Task.FromResult(ToolResult.Error("cancelled")); return Task.FromResult(ToolResult.Ok($"{_path} <- {args["id"]}")); } } ``` ## Members [Section titled “Members”](#members) | Member | Type | What it is | | -------------------------- | ------------------------------ | --------------------------------------------------------- | | `Name` | `string` | The name the model calls. Must match `[a-zA-Z0-9_-]`. | | `Description` | `string` | What the model reads to decide whether to call it. | | `InputSchema` | `IDictionary` | A JSON-Schema **object** as a plain dictionary. | | `Source` | `string` | One of `mcp`, `skill`, `native`, `http`, `a2a`, `custom`. | | `ExecuteAsync(args, ctx?)` | `Task` | Runs the tool. `ctx` defaults to `null`. | ## See also [Section titled “See also”](#see-also) * [`ToolResult`](/api/csharp/types/tool-result/) — what `ExecuteAsync` returns * [`ToolContext`](/api/csharp/types/context/) — what `ExecuteAsync` receives * [`NativeTool.Of`](/api/csharp/native/define-tool/) — build a tool from a method * [`Toolkit.CreateAsync`](/api/csharp/toolkit/create/) — aggregate tools from every source # ToolResult > The result envelope every tool returns — output text, an error flag, optional non-text parts, and optional metadata that can carry a suspension. C#. **C#** · package `Toolnexus` · [SPEC §1](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [ToolResult.cs](https://github.com/muthuishere/toolnexus/blob/main/csharp/src/Toolnexus/ToolResult.cs) ```csharp public sealed record ToolResult( string Output, bool IsError, IDictionary? Metadata = null, IReadOnlyList? Parts = null) { public static ToolResult Ok(string output, IDictionary? metadata = null); public static ToolResult Error(string output, IDictionary? metadata = null); public static ToolResult OkWithParts(string output, IReadOnlyList parts, IDictionary? metadata = null); public static ToolResult Pending(Request request); public static ToolResult AuthRequired(string url, string prompt = "Authorization required to continue"); public static Request? PendingOf(ToolResult? result); } ``` What every `ExecuteAsync` returns. Four fields, and the whole tool-calling loop is built on them: `Output` is the text handed back to the model, `IsError` says whether the call failed, `Metadata` is free-form — except for one reserved key that turns a result into a suspension — and [`Parts`](/api/csharp/types/content-part/) carries non-text output: an image, a PDF, an audio clip. It is a `record`, so it is immutable and gives you value equality and `with` expressions for free. ## When to use it [Section titled “When to use it”](#when-to-use-it) Every time you write a tool. It is the return type of [`ITool.ExecuteAsync`](/api/csharp/types/tool/), so you construct one on every code path. ## Why `Parts` is the fourth parameter, and `Output` is still required [Section titled “Why Parts is the fourth parameter, and Output is still required”](#why-parts-is-the-fourth-parameter-and-output-is-still-required) An append, so nothing had to be rewritten `Parts` was appended **after** `Metadata` rather than slotted in beside `Output`. That ordering is the whole point: 19 existing `new ToolResult(output, isError, meta)` call sites across the port kept compiling unchanged, and a tool written before content parts existed is byte-identical afterwards. An absent `Parts` means exactly what it always meant. `Output` stays **required** even when `Parts` is present. It is what the canonical transcript, the compactor, token estimation and any text-only provider see — a tool returning a screenshot sets `Output` to a description (`"screenshot, 1280x720 png"`) and `Parts` to the image. Returning an image with an empty `Output` makes the result invisible to everything except the one provider call that happened to render it. Use `OkWithParts(output, parts)` rather than the positional constructor; it says what it means and cannot get the argument order wrong. ## Why the static factories rather than the constructor [Section titled “Why the static factories rather than the constructor”](#why-the-static-factories-rather-than-the-constructor) Prefer Ok / Error over new ToolResult(…) `ToolResult.Ok(output)` and `ToolResult.Error(output)` say what you mean and cannot get the boolean backwards. Both take optional metadata. Use the positional record constructor only when the error flag is itself computed. A tool that fails does **not** throw — it returns `Error(...)`. The loop feeds that text back to the model as the tool result, so it can read “file not found”, pick another path, and carry on. Throwing escapes the loop and ends the run. `Output` is normalised: a `null` becomes `""`, so it is always safe to read. ## Examples [Section titled “Examples”](#examples) ### 1. Success and failure on the same tool [Section titled “1. Success and failure on the same tool”](#1-success-and-failure-on-the-same-tool) ```csharp using Toolnexus; var config = new Dictionary { ["region"] = "eu-west-1" }; ToolResult ReadConfig(string key) => config.TryGetValue(key, out var v) ? ToolResult.Ok(v) // Recoverable: the model can read this and try another key. : ToolResult.Error($"No such config key: {key}"); var found = ReadConfig("region"); if (found.Output != "eu-west-1" || found.IsError) throw new Exception($"unexpected: {found.Output}"); var missing = ReadConfig("nope"); if (!missing.IsError) throw new Exception("expected IsError"); Console.WriteLine($"ok: {found.Output} | {missing.Output}"); ``` ### 2. Structured output and metadata [Section titled “2. Structured output and metadata”](#2-structured-output-and-metadata) `Output` must be a string, so serialize deliberately. `Metadata` rides alongside for *your* code — the model never sees it, which makes it the right place for bookkeeping. ```csharp using Toolnexus; var hits = new[] { (Id: 1, Title: "Getting started"), (Id: 2, Title: "Advanced usage") }; var res = ToolResult.Ok( // The model reads this. Make it legible, not just valid. string.Join("\n", hits.Select(h => $"#{h.Id} {h.Title}")), // Your code reads this. The model never sees it. new Dictionary { ["title"] = "search: usage", ["count"] = hits.Length, ["ids"] = hits.Select(h => h.Id).ToArray(), }); if (Convert.ToInt32(res.Metadata!["count"]) != 2) throw new Exception("count"); if (!res.Output.Contains("Advanced usage")) throw new Exception("output"); // It is a record: value equality and `with` come for free. var asError = res with { IsError = true }; if (!asError.IsError || asError.Output != res.Output) throw new Exception("with-expression"); Console.WriteLine($"ok: {res.Metadata["title"]}"); ``` ### 3. The reserved key — `Metadata["pending"]` is a suspension [Section titled “3. The reserved key — Metadata\["pending"\] is a suspension”](#3-the-reserved-key--metadatapending-is-a-suspension) `Metadata` is free-form with one exception. A `pending` key holding a `Request` means “this tool cannot finish until something out-of-band happens” — the loop parks the run instead of returning. The producer and reader helpers are **static methods on `ToolResult`**. ```csharp using Toolnexus; // Pending() returns a ToolResult carrying Metadata["pending"] = Request. // An empty Id is filled in for you — it is the correlation key. var res = ToolResult.Pending(new Request { Kind = "input", Prompt = "Which environment?" }); if (!res.IsError) throw new Exception("a parked call is not a success"); var req = ToolResult.PendingOf(res); if (req is null) throw new Exception("PendingOf should read the suspension back"); if (req.Kind != "input") throw new Exception($"kind: {req.Kind}"); if (req.Prompt != "Which environment?") throw new Exception("prompt"); if (string.IsNullOrEmpty(req.Id)) throw new Exception("an id is generated"); // AuthRequired is sugar for the login case; the prompt has a default. var auth = ToolResult.AuthRequired("https://example.com/login"); var authReq = ToolResult.PendingOf(auth)!; if (authReq.Kind != "authorization") throw new Exception("auth kind"); if (authReq.Url != "https://example.com/login") throw new Exception("auth url"); // An ordinary result has no suspension. if (ToolResult.PendingOf(ToolResult.Ok("done")) is not null) throw new Exception("expected none"); Console.WriteLine($"ok: {req.Kind} | {authReq.Kind}"); ``` ### 4. Non-text output — `Parts` [Section titled “4. Non-text output — Parts”](#4-non-text-output--parts) ```csharp using Toolnexus; var repo = Environment.GetEnvironmentVariable("TOOLNEXUS_REPO") ?? "."; var png = Path.Combine(repo, "examples/media/fixture.png"); // Output DESCRIBES; Parts CARRIES. Both, always. var res = ToolResult.OkWithParts( ContentPart.FromFile(png).DescribeInText(), // "image (image/png, 82 bytes)" [ContentPart.FromFile(png)]); if (res.IsError) throw new Exception("a screenshot is a success"); if (res.Parts is null || res.Parts.Count != 1) throw new Exception("one part"); if (res.Parts[0].Type != "image") throw new Exception(res.Parts[0].Type); // The transcript and the compactor read Output, never the base64. if (res.Output != "image (image/png, 82 bytes)") throw new Exception(res.Output); // Absent Parts is the pre-multimodal shape, unchanged. if (ToolResult.Ok("done").Parts is not null) throw new Exception("absent means absent"); Console.WriteLine($"ok: {res.Output} | {res.Parts[0].Describe()}"); ``` Caution A suspension sets `IsError` to true. That is deliberate — a parked call has not produced an answer. Do not treat `IsError` alone as “the tool broke”; check `ToolResult.PendingOf(result)` when suspension is in play. ## Members [Section titled “Members”](#members) | Member | Type | What it is | | ---------- | ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | | `Output` | `string` | The text handed to the model. `null` is normalised to `""`. | | `IsError` | `bool` | Whether the call failed. Fed back to the model, not thrown. | | `Metadata` | `IDictionary?` | Free-form, may be null. **Reserved:** `pending` holds a §10 `Request`. | | `Parts` | `IReadOnlyList?` | Non-text output (§1B). The **fourth** positional parameter, appended after `Metadata`. Absent ⇒ byte-identical to pre-multimodal behaviour. | ## Static factories [Section titled “Static factories”](#static-factories) | Method | What it does | | --------------------------------------- | ------------------------------------------------------------------ | | `Ok(output, metadata?)` | A success result. | | `Error(output, metadata?)` | A failure result the model can recover from. | | `OkWithParts(output, parts, metadata?)` | A success carrying non-text content alongside its describing text. | | `Pending(request)` | A §10 suspension. Generates an `Id` when the request has none. | | `AuthRequired(url, prompt?)` | Sugar for `Kind:"authorization"` at a login URL. | | `PendingOf(result)` | Reads the `Request` back off a result, or `null`. | ## See also [Section titled “See also”](#see-also) * [`ContentPart`](/api/csharp/types/content-part/) — what `Parts` holds * [`ITool`](/api/csharp/types/tool/) — what returns this * [`ToolContext`](/api/csharp/types/context/) — what `ExecuteAsync` receives * [`Suspension.Pending`](/api/csharp/suspension/pending/) — the suspension entry point # API Reference — Elixir > The toolnexus (Hex) public surface — toolkit, client, skills, MCP, adapters — with each symbol's equivalents in the other six ports. Package **`toolnexus`** (Hex), Elixir ≥ 1.16 / OTP 26. See the [combined API Reference](/api/) for every symbol shown in eight-language synced tabs. Why the BEAM port exists Long-running agents want supervision, isolation, and cheap concurrency — and that is exactly what **agent orchestration** needs. The Elixir/OTP port is where sub-agents, teams, hierarchical budgets, and durable suspension feel native (every MCP connection is a supervised process; a crashed stdio server is isolated as `"failed"` without taking the toolkit down; parallel tool calls ride `Task.async_stream`). Proving that shape on the BEAM is the reason toolnexus carries **agentic orchestration across all eight languages** — same contract everywhere, native OTP underneath. The MCP client is in-house on OTP (no third-party SDK), which is also why this port ships the **full** elicitation bridge (form *and* URL mode). ## Toolkit [Section titled “Toolkit”](#toolkit) * `Toolnexus.create_toolkit(opts) :: {:ok, toolkit} | {:error, term}` — the aggregator. `Toolnexus.create_toolkit!/1` returns the toolkit or raises. * `opts` (keyword): `:mcp_config`, `:skills_dir`, `:skills`, `:skill_provider`, `:skills_filter`, `:skill_sample_limit`, `:builtins`, `:agents`, `:extra_tools`, `:disable_tools`, `:disable_skills`, `:wait_for`. * Equivalents: `createToolkit` (js) · `create_toolkit` (py) · `CreateToolkit` (go) · `Toolkit.create` (java) · `Toolkit.CreateAsync` (c#). ## Skills [Section titled “Skills”](#skills) * `Toolnexus.Skill.load(input) :: %Toolnexus.Skill.Source{}` — `input` is a path, a list of paths, or an options keyword. * `Toolnexus.Skill.list(input) :: %{skills: [...], skipped: [...]}` — discover + validate; each skip is `%{location, reason}` with reasons `missing-name` · `malformed-frontmatter` · `duplicate-name` · `unreadable`. * A **data skill** is a plain map: `%{name, description, content, resources, base}` (only `:name` and `:content` required). * Equivalents: `loadSkills` / `listSkills` (js) · `load_skills` / `list_skills` (py) · `LoadSkills` / `ListSkills` (go) · `SkillSource.load` / `listSkills` (java) · `SkillSource.Load` / `ListSkills` (c#). ## MCP · Adapters · Client [Section titled “MCP · Adapters · Client”](#mcp--adapters--client) * `Toolnexus.Mcp.load(config, opts \\ []) :: {:ok, mcp} | {:error, term}` — `mcp.tools`. * `Toolnexus.Adapters.to_openai(tools)` · `to_anthropic(tools)` · `to_gemini(tools)`. * `Toolnexus.Client.create(opts) :: %Toolnexus.Client{}` → `Toolnexus.Client.run(client, prompt, toolkit, opts \\ [])`, `Toolnexus.Client.stream(client, prompt, toolkit, opts \\ [])`. ## 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.define_tool(name:, description:, input_schema?, execute:)` — `execute` is `fn args -> end` or `fn args, ctx -> end`. * `Toolnexus.Http.tool(name:, description:, method:, url:, headers?, query?, body?, result_mode?, timeout?)` — note the function is `tool/1` (not `http_tool`); `:timeout` in ms. * `:builtins` toggle → ten built-ins: `bash · read · write · edit · grep · glob · webfetch · question · apply_patch · todowrite`. (`Toolnexus.Builtin.tools/0` · `load/1`.) * Equivalents: `defineTool` / `httpTool` (js) · `define_tool` / `http_tool` (py) · `NativeTool` / `HTTPTool` (go) · `NativeTool.of` / `HttpTool.of` (java) · `NativeTool.Of` / `HttpTool.Of` (c#). ## Sub-agents & teams [Section titled “Sub-agents & teams”](#sub-agents--teams) * `Toolnexus.Agents.agent(name, does:, uses?, soul?/soul_file?, team?, budget?, model?, wait_for?)`. * `Toolnexus.Agents.run(agent, rt_opts, prompt)` — result map `%{status:, text:, total_tokens:, turns:, is_error:, pending:}`. * `Toolnexus.Agents.as_tool(agent, rt_opts)` · `Toolnexus.Agents.compose_soul(dir) :: {soul, found}`. * Equivalents: `agents.agent` / `.run` / `.asTool` / `composeSoul` (js) · `agent` (py) · `agents.New` (go) · `Agents.agent` (java) · `new Agent` (c#). ## A2A — serve & remote agents [Section titled “A2A — serve & remote agents”](#a2a--serve--remote-agents) * `Toolnexus.Toolkit.serve(tk, addr, client:, a2a:, mcp?, on_task?, on_call?) :: Toolnexus.Serve.Handle.t()` — `handle.url` · `Toolnexus.Serve.stop/1`. * `Toolnexus.A2A.agent(card:, headers?, timeout?, poll_every?)` + `create_toolkit(agents: [...])` / `Toolnexus.Toolkit.add_agent(tk, card_url)`. * Equivalents: `tk.serve` / `tk.addAgent` (js) · `serve` / `add_agent` (py) · `Serve` / `AddAgent` (go) · `serve` / `addAgent` (java) · `ServeAsync` / `AddAgentAsync` (c#). ## Suspension & the human loop [Section titled “Suspension & the human loop”](#suspension--the-human-loop) * `:wait_for` (toolkit option; `fn Request -> Answer`). * `%Toolnexus.Request{id, kind, prompt, url, data, expires_at}` (serializes `expiresAt`) · `%Toolnexus.Answer{id, ok, data, reason}`. * `Toolnexus.ToolResult.pending?/1` detects a suspension; a tool suspends by returning `%ToolResult{is_error: true, metadata: %{pending: %Request{}}}`. * Equivalents: `waitFor` / `pending` (js) · `wait_for` / `pending` (py) · `WaitFor` / `Pending` (go) · `waitFor` / `ToolResult.pending` (java) · `WithWaitFor` / `ToolResult.Pending` (c#). ## Media generation [Section titled “Media generation”](#media-generation) Guide: [Media generation](/toolnexus/media/) · recipe: [Generate images, video and speech](/toolnexus/cookbook/media/) · SPEC §7G. * `Toolnexus.Media.load_style(path) :: {:ok, map()} | {:error, String.t()}` * `Toolnexus.Media.new(styles, opts \\ [])` — one style map or a list; a verb resolves to the first style that declares it. Options: `:on_progress` (`fn %{verb, jobId, status} -> any`, after every poll) · `:transport` (the same injectable seam as `Toolnexus.Client`: `fn request -> {:ok, %{status, headers, body}} | {:error, e}`) · `:http_options` (extra `Req` options, e.g. `plug:` in tests). * Verbs, each `(media, args, opts \\ [])` with `args` a map or keyword list: `image` (`prompt`, `width`, `height`, `aspectRatio`, `seed`) · `video` (`prompt`, `image`, `seconds`, `aspectRatio`) · `speech` (`text`, `voice`) · `transcribe` (`audio`) · `clone_voice` (`name`, `samples`) · `talking` (`image`, `audio`). Snake-case atom keys work (`:aspect_ratio`); `:provider_options` in `args` overwrites the body last. * `Toolnexus.Media.generate(media, verb, args, opts \\ []) :: {:ok, Result.t()} | {:error, String.t()}` — the raw call; `verb` is a string or atom (`"cloneVoice"`, `:clone_voice`). `opts[:signal]` is cooperative cancellation (a pid or zero-arity predicate). * `%Toolnexus.Media.Result{files: [ContentPart.t()], text, job_id, status}` — files carry `type`, `mime_type`, `url`. * `Toolnexus.Media.tools(media) :: [Tool.t()]` (source `"media"`), or `create_toolkit(media: media)`. * `RunResult.files` — maps `%{type, mime_type, url, from, call_id}`, in call order; the same parts are on each `tool_calls` entry’s `parts`. * Errors: the function face returns `{:error, message}` (exact §7G string) rather than raising; a signalled cancel raises `Toolnexus.Media.Cancelled`. Process-idiom cancellation also works: run in a `Task` and `Task.shutdown/1` it. A media tool never raises (`is_error: true`). * Equivalents: `createMedia` (js) · `create_media` (py) · `CreateMedia` (go) · `Media.create` (java) · `Media.Create` (c#) · `toolnexus.media/create-media` (clojure) · `create_media` (rust). # Toolnexus.Adapters.to_anthropic > Render any tool list as Anthropic tool-use schema. Elixir. **Elixir** · package `toolnexus` · [SPEC §4](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [elixir/lib/toolnexus/adapters.ex](https://github.com/muthuishere/toolnexus/blob/main/elixir/lib/toolnexus/adapters.ex) ```elixir @spec to_anthropic([Toolnexus.Tool.t()]) :: [map()] def to_anthropic(tools) ``` Turns a list of tools into the `tools` array the Anthropic Messages API expects. It is the flattest of the three adapters — no wrapper object, and `input_schema` keeps its name. ## When to use it [Section titled “When to use it”](#when-to-use-it) When you are calling `POST /v1/messages` yourself (Anthropic API, Bedrock, Vertex) and need schema for the request body. Reach for it whenever you own the loop and want toolnexus only for tool *aggregation* — MCP servers, skills, HTTP endpoints and native functions arriving as one list. ## 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 Toolnexus.Client [`Toolnexus.Client.create`](/api/elixir/client/create/) picks the adapter from its style option and runs the whole tool-calling loop — schema, dispatch, result feedback, retries. Reach for `to_anthropic` **instead** only when you already have your own loop and want nothing but the schema. `Toolnexus.Toolkit.to_anthropic/1` takes a toolkit and delegates straight here with its tools — use that when you have a toolkit, and this when you have a bare list. ## 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) Keys in the emitted maps are **strings**, not atoms — this is wire data headed for JSON. ```elixir alias Toolnexus.{Adapters, Native} weather = Native.define_tool(%{ name: "get_weather", description: "Current weather for a city", input_schema: %{ "type" => "object", "properties" => %{"city" => %{"type" => "string"}}, "required" => ["city"] }, execute: fn args, _ctx -> "sunny in #{args["city"]}" end }) [entry] = Adapters.to_anthropic([weather]) # Flat — three keys, no "function" wrapper (unlike OpenAI). true = entry |> Map.keys() |> Enum.sort() == ["description", "input_schema", "name"] true = entry["name"] == "get_weather" true = entry["description"] == "Current weather for a city" true = entry["input_schema"]["required"] == ["city"] IO.puts("ok: #{entry["name"]}") ``` `input_schema` is passed through **verbatim** — the very map that is on the tool. Anthropic is the one provider whose key name already matches toolnexus’s own. ### 2. Feeding it straight into a Messages request body [Section titled “2. Feeding it straight into a Messages request body”](#2-feeding-it-straight-into-a-messages-request-body) The output is plain maps and lists, so `Jason.encode!/1` handles it with no custom encoder. ```elixir alias Toolnexus.{Adapters, Native} mk = fn name, desc -> Native.define_tool(%{ name: name, description: desc, input_schema: %{"type" => "object", "properties" => %{}}, execute: fn _args, _ctx -> name end }) end tools = [mk.("search", "Search the docs"), mk.("ping", "Health check")] body = %{ "model" => "claude-sonnet-4-5", "max_tokens" => 1024, "messages" => [%{"role" => "user", "content" => "search for adapters"}], "tools" => Adapters.to_anthropic(tools) } # Order is preserved — the adapter is a plain Enum.map/2. names = Enum.map(body["tools"], & &1["name"]) true = names == ["search", "ping"] json = Jason.encode!(body) true = String.contains?(json, ~s("input_schema")) # The key comes from the environment, never a literal in your source. {"x-api-key", key} = {"x-api-key", System.get_env("ANTHROPIC_API_KEY") || "YOUR_KEY_HERE"} true = is_binary(key) IO.puts("ok: #{Enum.join(names, ", ")}") ``` ### 3. Round-tripping a `tool_use` block back to the tool [Section titled “3. Round-tripping a tool\_use block back to the tool”](#3-round-tripping-a-tool_use-block-back-to-the-tool) Schema out, tool call in. Unlike OpenAI, Anthropic hands you `input` as a **real map** — there is no `Jason.decode!/1` step. ```elixir alias Toolnexus.{Adapters, Context, Native} weather = Native.define_tool(%{ name: "get_weather", description: "Current weather for a city", input_schema: %{ "type" => "object", "properties" => %{"city" => %{"type" => "string"}}, "required" => ["city"] }, execute: fn args, _ctx -> if args["city"] == "", do: raise("city is required"), else: "sunny in #{args["city"]}" end }) tools = [weather] 1 = length(Adapters.to_anthropic(tools)) # A content block exactly as the model returns it. block = %{ "type" => "tool_use", "id" => "toolu_01", "name" => "get_weather", "input" => %{"city" => "Chennai"} } called = Enum.find(tools, &(&1.name == block["name"])) true = called != nil res = called.execute.(block["input"], %Context{}) false = res.is_error true = res.output == "sunny in Chennai" # What you append to the conversation for the next turn. result_block = %{ "type" => "tool_result", "tool_use_id" => block["id"], "content" => res.output, "is_error" => res.is_error } true = result_block["tool_use_id"] == "toolu_01" # Failure is data, not an exception — it maps onto tool_result.is_error. bad = called.execute.(%{"city" => ""}, %Context{}) true = bad.is_error true = bad.output == "city is required" # An empty tool list emits an empty array — no wrapper, unlike Gemini. [] = Adapters.to_anthropic([]) IO.puts("ok: #{block["name"]} -> #{res.output}") ``` Caution A failing tool does **not** raise out of `execute`. It returns `is_error: true`, which you copy onto the `tool_result` block so the model reads the failure text and can correct itself next turn. ## Shape [Section titled “Shape”](#shape) | Path | From | Notes | | -------------------- | ------------------- | ------------------------------------------------------ | | `[]["name"]` | `Tool.name` | What the model calls back with, in `tool_use.name`. | | `[]["description"]` | `Tool.description` | What the model reads to decide whether to call it. | | `[]["input_schema"]` | `Tool.input_schema` | Passed through unchanged — same key name, no renaming. | There is no `"type"` key and no nesting: an Anthropic tool entry has exactly these three keys. ## See also [Section titled “See also”](#see-also) * [`Adapters.to_openai`](/api/elixir/adapters/openai/) · [`Adapters.to_gemini`](/api/elixir/adapters/gemini/) * [`Toolnexus.create_toolkit`](/api/elixir/toolkit/create/) — `Toolkit.to_anthropic/1` delegates here * [`Toolnexus.Client.create`](/api/elixir/client/create/) — calls the adapter for you * [`Toolnexus.Tool`](/api/elixir/types/tool/) — where `name`, `description` and `input_schema` come from # Toolnexus.Adapters.to_gemini > Render any tool list as Gemini function-declaration schema. Elixir. **Elixir** · package `toolnexus` · [SPEC §4](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [elixir/lib/toolnexus/adapters.ex](https://github.com/muthuishere/toolnexus/blob/main/elixir/lib/toolnexus/adapters.ex) ```elixir @spec to_gemini([Toolnexus.Tool.t()]) :: [map()] def to_gemini(tools) ``` Turns a list of tools into the `tools` array Gemini’s `generateContent` expects. Gemini is the odd one out: every declaration lives inside **one** wrapper element keyed `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 calling the Gemini REST API (or Vertex AI) yourself and need the `tools` field of the request body. This is also the shape `google-genai`-style SDKs accept when you hand them raw declarations rather than typed objects. ## 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 Toolnexus.Client [`Toolnexus.Client.create`](/api/elixir/client/create/) selects the adapter from its style option and runs the whole tool-calling loop for you. Use `to_gemini` **instead** only when you already own the request/response loop and want toolnexus purely for tool aggregation. `Toolnexus.Toolkit.to_gemini/1` takes a toolkit and delegates straight here with its tools — use that when you have a toolkit, and this when you have a bare list. ## Examples [Section titled “Examples”](#examples) ### 1. One tool, and the wrapper that surprises everyone [Section titled “1. One tool, and the wrapper that surprises everyone”](#1-one-tool-and-the-wrapper-that-surprises-everyone) The top-level list is a list of *tool groups*, not of tools. Destructure it before you look for names. ```elixir alias Toolnexus.{Adapters, Native} weather = Native.define_tool(%{ name: "get_weather", description: "Current weather for a city", input_schema: %{ "type" => "object", "properties" => %{"city" => %{"type" => "string"}}, "required" => ["city"] }, execute: fn args, _ctx -> "sunny in #{args["city"]}" end }) result = Adapters.to_gemini([weather]) # Always exactly ONE element, whatever the tool count. 1 = length(result) [%{"functionDeclarations" => decls}] = result 1 = length(decls) [decl] = decls true = decl |> Map.keys() |> Enum.sort() == ["description", "name", "parameters"] true = decl["name"] == "get_weather" # input_schema is renamed to `parameters`, as with OpenAI. true = decl["parameters"]["required"] == ["city"] IO.puts("ok: #{decl["name"]}") ``` ### 2. Many tools, one wrapper, straight into a request body [Section titled “2. Many tools, one wrapper, straight into a request body”](#2-many-tools-one-wrapper-straight-into-a-request-body) ```elixir alias Toolnexus.{Adapters, Native} mk = fn name, desc -> Native.define_tool(%{ name: name, description: desc, input_schema: %{"type" => "object", "properties" => %{}}, execute: fn _args, _ctx -> name end }) end tools = [mk.("search", "Search the docs"), mk.("ping", "Health check"), mk.("kv_get", "Read a key")] body = %{ "contents" => [%{"role" => "user", "parts" => [%{"text" => "search for adapters"}]}], "tools" => Adapters.to_gemini(tools) } # Three tools still means ONE wrapper with three declarations. 1 = length(body["tools"]) [%{"functionDeclarations" => decls}] = body["tools"] 3 = length(decls) names = Enum.map(decls, & &1["name"]) true = names == ["search", "ping", "kv_get"] json = Jason.encode!(body) true = String.contains?(json, ~s("functionDeclarations")) # The key comes from the environment, never a literal in your source. api_key = System.get_env("GEMINI_API_KEY") || "YOUR_KEY_HERE" true = is_binary(api_key) IO.puts("ok: #{Enum.join(names, ", ")}") ``` The empty case is not empty With zero tools you still get `[%{"functionDeclarations" => []}]`, not `[]`. That is deliberate JS parity (SPEC §0.7) — every port emits the wrapper. If your provider rejects an empty declaration list, omit the `tools` field yourself rather than expecting the adapter to. ### 3. Round-tripping a `functionCall` part back to the tool [Section titled “3. Round-tripping a functionCall part back to the tool”](#3-round-tripping-a-functioncall-part-back-to-the-tool) Gemini returns `args` as a **real map** (like Anthropic, unlike OpenAI’s JSON string), and expects a `functionResponse` part back. ```elixir alias Toolnexus.{Adapters, Context, Native} weather = Native.define_tool(%{ name: "get_weather", description: "Current weather for a city", input_schema: %{ "type" => "object", "properties" => %{"city" => %{"type" => "string"}}, "required" => ["city"] }, execute: fn args, _ctx -> if args["city"] in [nil, ""], do: raise("city is required"), else: "sunny in #{args["city"]}" end }) tools = [weather] [%{"functionDeclarations" => [_decl]}] = Adapters.to_gemini(tools) # A part exactly as the model returns it. part = %{"functionCall" => %{"name" => "get_weather", "args" => %{"city" => "Chennai"}}} call = part["functionCall"] called = Enum.find(tools, &(&1.name == call["name"])) true = called != nil res = called.execute.(call["args"], %Context{}) false = res.is_error true = res.output == "sunny in Chennai" # What you append to `contents` for the next turn. response_part = %{ "functionResponse" => %{"name" => call["name"], "response" => %{"output" => res.output}} } true = response_part["functionResponse"]["response"]["output"] == "sunny in Chennai" # Failure is data, not an exception — feed the text back the same way. bad = called.execute.(%{}, %Context{}) true = bad.is_error true = bad.output == "city is required" # Zero tools: the wrapper survives, the declarations list is empty. [%{"functionDeclarations" => []}] = Adapters.to_gemini([]) IO.puts("ok: #{call["name"]} -> #{res.output}") ``` ## Shape [Section titled “Shape”](#shape) | Path | From | Notes | | ----------------------------- | ------------------- | -------------------------------------------------------- | | `[0]` | — | The single wrapper map. There is never a second element. | | `[0]["functionDeclarations"]` | the tool list | One declaration per tool, order preserved. | | `…[]["name"]` | `Tool.name` | What comes back in `functionCall.name`. | | `…[]["description"]` | `Tool.description` | | | `…[]["parameters"]` | `Tool.input_schema` | Renamed — `input_schema` → `parameters`, as with OpenAI. | ## See also [Section titled “See also”](#see-also) * [`Adapters.to_openai`](/api/elixir/adapters/openai/) · [`Adapters.to_anthropic`](/api/elixir/adapters/anthropic/) * [`Toolnexus.create_toolkit`](/api/elixir/toolkit/create/) — `Toolkit.to_gemini/1` delegates here * [`Toolnexus.Client.create`](/api/elixir/client/create/) — calls the adapter for you * [`Toolnexus.Tool`](/api/elixir/types/tool/) — where `name`, `description` and `input_schema` come from # Toolnexus.Adapters.to_openai > Render any tool list as OpenAI function-calling schema. Elixir. **Elixir** · package `toolnexus` · [SPEC §4](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [elixir/lib/toolnexus/adapters.ex](https://github.com/muthuishere/toolnexus/blob/main/elixir/lib/toolnexus/adapters.ex) ```elixir @spec to_openai([Toolnexus.Tool.t()]) :: [map()] def to_openai(tools) ``` Turns a list of tools into the `tools` array an OpenAI-shaped chat completion expects. This is the bridge between “toolnexus knows about these tools” and “the model can call them”. ## When to use it [Section titled “When to use it”](#when-to-use-it) When you are driving the LLM call yourself and need schema to put in the request body. Every OpenAI-compatible endpoint takes this shape — OpenAI, OpenRouter, Groq, Together, a local Ollama, or your own gateway. ## Why this and not the client [Section titled “Why this and not the client”](#why-this-and-not-the-client) Skip this entirely if you use Toolnexus.Client [`Toolnexus.Client.create`](/api/elixir/client/create/) calls the right adapter for you based on its style option and runs the whole tool-calling loop. Reach for `to_openai` when you already have your own loop and only want toolnexus for tool *aggregation*. `Toolnexus.Toolkit.to_openai/1` takes a toolkit and delegates here with its tools — use that when you have a toolkit, and this when you have a bare list. ## Examples [Section titled “Examples”](#examples) ### 1. One tool to OpenAI schema [Section titled “1. One tool to OpenAI schema”](#1-one-tool-to-openai-schema) Keys in the emitted maps are **strings**, not atoms — this is wire data headed for JSON. ```elixir alias Toolnexus.{Adapters, Native} weather = Native.define_tool(%{ name: "get_weather", description: "Current weather for a city", input_schema: %{ "type" => "object", "properties" => %{"city" => %{"type" => "string"}}, "required" => ["city"] }, execute: fn args, _ctx -> "sunny in #{args["city"]}" end }) schema = Adapters.to_openai([weather]) 1 = length(schema) entry = hd(schema) true = entry["type"] == "function" or entry[:type] == "function" fn_block = entry["function"] || entry[:function] true = fn_block["name"] == "get_weather" or fn_block[:name] == "get_weather" IO.puts("ok: #{fn_block["name"] || fn_block[:name]}") ``` Note the nesting: OpenAI wraps each tool in a `"function"` block. The `input_schema` on a tool becomes `function.parameters` — the key is renamed. ### 2. Feeding it straight into a request body [Section titled “2. Feeding it straight into a request body”](#2-feeding-it-straight-into-a-request-body) The output is plain maps and lists, so `Jason.encode!/1` handles it with no custom encoder. ```elixir alias Toolnexus.{Adapters, Native} mk = fn name, desc -> Native.define_tool(%{ name: name, description: desc, input_schema: %{"type" => "object", "properties" => %{}}, execute: fn _args, _ctx -> name end }) end tools = [mk.("search", "Search the docs"), mk.("ping", "Health check")] body = %{ "model" => "gpt-4o-mini", "messages" => [%{"role" => "user", "content" => "search for adapters"}], "tools" => Adapters.to_openai(tools) } entries = body["tools"] 2 = length(entries) # Order is preserved. names = Enum.map(entries, fn e -> f = e["function"] || e[:function] f["name"] || f[:name] end) true = names == ["search", "ping"] IO.puts("ok: #{Enum.join(names, ", ")}") ``` ### 3. Round-tripping a call back to the tool [Section titled “3. Round-tripping a call back to the tool”](#3-round-tripping-a-call-back-to-the-tool) Schema out, tool call in. The `name` the model returns is the same `name` you look up. ```elixir alias Toolnexus.{Adapters, Context, Native} weather = Native.define_tool(%{ name: "get_weather", description: "Current weather for a city", input_schema: %{ "type" => "object", "properties" => %{"city" => %{"type" => "string"}}, "required" => ["city"] }, execute: fn args, _ctx -> "sunny in #{args["city"]}" end }) tools = [weather] # What a model would send back. OpenAI encodes arguments as a JSON STRING; # decode it before calling the tool. called_name = "get_weather" args = %{"city" => "Chennai"} called = Enum.find(tools, &(&1.name == called_name)) true = called != nil res = called.execute.(args, %Context{}) true = res.output == "sunny in Chennai" false = res.is_error # An empty tool list is valid — it just means "no tools this turn". [] = Adapters.to_openai([]) IO.puts("ok: #{called_name} -> #{res.output}") ``` Caution OpenAI sends `arguments` as a **JSON string**, not a map. `Jason.decode!/1` it before calling `execute`, which expects a real map with string keys. ## Shape [Section titled “Shape”](#shape) | Path | From | Notes | | ------------------------------- | ------------------- | ---------------------------------------- | | `[]["type"]` | — | Always the literal `"function"`. | | `[]["function"]["name"]` | `Tool.name` | What the model calls back with. | | `[]["function"]["description"]` | `Tool.description` | | | `[]["function"]["parameters"]` | `Tool.input_schema` | Renamed — `input_schema` → `parameters`. | ## See also [Section titled “See also”](#see-also) * [`Adapters.to_anthropic`](/api/elixir/adapters/anthropic/) · [`Adapters.to_gemini`](/api/elixir/adapters/gemini/) * [`Toolnexus.create_toolkit`](/api/elixir/toolkit/create/) — `Toolkit.to_openai/1` delegates here * [`Toolnexus.Client.create`](/api/elixir/client/create/) — calls the adapter for you # Toolnexus.A2a.agent > Point at a remote agent's card and use it exactly like a local tool. — Elixir. **Elixir** · package `toolnexus` · [SPEC §7A](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [elixir/lib/toolnexus/a2a.ex](https://github.com/muthuishere/toolnexus/blob/main/elixir/lib/toolnexus/a2a.ex) ```elixir @spec agent(keyword() | map()) :: map() def agent(opts) # opts: :card (required — the Agent Card URL) # :headers — ${ENV_VAR} values expand at call time, never logged # :timeout — overall poll budget in ms, default 300_000 # :poll_every — interval between GetTask polls in ms, default 1_000 # Toolnexus.agent/1 delegates here — the top-level shortcut. ``` Builds an **Agent descriptor**: a plain map pointing at a remote peer’s Agent Card URL. No I/O happens here — `agent/1` is pure data assembly. Resolve the descriptor into callable tools with [`Toolnexus.A2a.agent_tools/1`](/api/elixir/agents/agent-tools/), which fetches the card and turns each advertised skill into a `Toolnexus.Tool`. ## When to use it [Section titled “When to use it”](#when-to-use-it) * **Calling one remote A2A peer you already know the card URL of** — build the descriptor by hand and hand it to `agent_tools/1`, or register it live with `Toolnexus.Toolkit.add_agent/3`. * **Wiring the descriptor into toolkit construction** — `Toolnexus.create_toolkit!(agents: [...])` takes a list of these descriptors alongside `mcp_config` and `skills_dir`. * **An authenticated peer** — pass `:headers` with `${ENV_VAR}` placeholders; they expand from the process environment at call time and are never logged, matching MCP’s remote-header contract. ## Why this and not the alternative [Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative) Reach for parse\_agents\_config/1 when peers live in config [`Toolnexus.A2a.parse_agents_config/1`](/api/elixir/agents/parse-config/) turns a whole `agents` config block (the way `mcpServers` declares MCP servers) into a list of these same descriptors, skipping disabled entries. Reach for `agent/1` when you have exactly one peer and no config file; reach for `parse_agents_config/1` when peers are data your deployment already owns. `agent/1` never touches the network — that happens in [`agent_tools/1`](/api/elixir/agents/agent-tools/), which is the function that actually resolves a descriptor into tools. ## Examples [Section titled “Examples”](#examples) ### 1. The smallest useful call — a descriptor, no network [Section titled “1. The smallest useful call — a descriptor, no network”](#1-the-smallest-useful-call--a-descriptor-no-network) ```elixir alias Toolnexus.A2a ag = A2a.agent(card: "http://localhost:0/.well-known/agent-card.json") true = ag.card == "http://localhost:0/.well-known/agent-card.json" true = ag.headers == nil true = ag.timeout == nil true = ag.poll_every == nil # The top-level shortcut builds the identical descriptor. true = Toolnexus.agent(card: ag.card) == ag IO.puts("ok: #{ag.card}") ``` ### 2. The full round trip — our own served toolkit as the remote peer [Section titled “2. The full round trip — our own served toolkit as the remote peer”](#2-the-full-round-trip--our-own-served-toolkit-as-the-remote-peer) One toolkit’s [`Toolnexus.Toolkit.serve/3`](/api/elixir/serve/a2a/) stands up a real A2A peer on loopback (its LLM call is stubbed via `Client.create`’s `http_options: [plug: ...]`, so nothing leaves the box); the OTHER side calls it with `agent/1` + `agent_tools/1` over real HTTP. ```elixir alias Toolnexus.{A2a, Client, Context, Serve, Toolkit} # The A2A wire is real loopback HTTP (via Req), unlike the plug-stubbed LLM call below. Application.ensure_all_started(:req) llm_plug = fn conn -> {:ok, _raw, conn} = Plug.Conn.read_body(conn) resp = %{ "choices" => [%{"message" => %{"role" => "assistant", "content" => "The sum is 5."}}], "usage" => %{"prompt_tokens" => 4, "completion_tokens" => 2, "total_tokens" => 6} } conn |> Plug.Conn.put_resp_content_type("application/json") |> Plug.Conn.send_resp(200, Jason.encode!(resp)) end llm_client = Client.create( base_url: "http://localhost", style: "openai", model: "gpt-x", api_key: "test-key", http_options: [plug: llm_plug] ) {:ok, peer_toolkit} = Toolnexus.create_toolkit( skills: [%{name: "calc", description: "Adds numbers", content: "Use the add tool."}], builtins: false ) handle = Toolkit.serve(peer_toolkit, "127.0.0.1:0", client: llm_client, a2a: %{name: "Calc Agent"}) # The OTHER side: point agent/1 at the peer's card and resolve its skills to tools. [calc_tool] = A2a.agent_tools(A2a.agent(card: handle.url <> "/.well-known/agent-card.json", poll_every: 20)) true = calc_tool.name == "Calc_Agent_calc" true = calc_tool.source == "a2a" result = calc_tool.execute.(%{"task" => "add 2 and 3"}, %Context{}) false = result.is_error true = result.output == "The sum is 5." true = result.metadata.agent == "Calc Agent" Serve.stop(handle) IO.puts("ok: #{calc_tool.name} -> #{result.output}") ``` ### 3. The full surface — `${ENV}` header expansion against a bare Plug/Bandit peer [Section titled “3. The full surface — ${ENV} header expansion against a bare Plug/Bandit peer”](#3-the-full-surface--env-header-expansion-against-a-bare-plugbandit-peer) `headers` values are never resolved until the call — the server sees the expanded value, never the placeholder, and the env var is never logged. ```elixir alias Toolnexus.{A2a, Context} Application.ensure_all_started(:req) defmodule DocsAgentStub do @behaviour Plug import Plug.Conn def init(agent), do: agent def call(conn, agent) do {:ok, body, conn} = read_body(conn) case {conn.method, conn.request_path} do {"GET", "/.well-known/agent-card.json"} -> Agent.update(agent, &Map.put(&1, :seen_auth, get_req_header(conn, "authorization"))) card = %{ "name" => "Secure Agent", "url" => Agent.get(agent, & &1.url), "skills" => [%{"id" => "ping", "description" => "pings back"}] } conn |> put_resp_content_type("application/json") |> send_resp(200, Jason.encode!(card)) {"POST", _} -> rpc = Jason.decode!(body) result = %{ "id" => "t1", "status" => %{"state" => "completed"}, "artifacts" => [%{"parts" => [%{"kind" => "text", "text" => "pong"}]}] } payload = %{"jsonrpc" => "2.0", "id" => rpc["id"], "result" => result} conn |> put_resp_content_type("application/json") |> send_resp(200, Jason.encode!(payload)) end end end {:ok, sock} = :gen_tcp.listen(0, []) {:ok, port} = :inet.port(sock) :gen_tcp.close(sock) url = "http://127.0.0.1:#{port}" {:ok, agent_state} = Agent.start_link(fn -> %{url: url <> "/", seen_auth: nil} end) {:ok, _pid} = Bandit.start_link(plug: {DocsAgentStub, agent_state}, scheme: :http, ip: {127, 0, 0, 1}, port: port) System.put_env("TOOLNEXUS_DOCS_A2A_TOKEN", "sekret") ag = A2a.agent( card: url <> "/.well-known/agent-card.json", headers: %{"authorization" => "Bearer ${TOOLNEXUS_DOCS_A2A_TOKEN}"}, timeout: 5_000, poll_every: 10 ) [tool] = A2a.agent_tools(ag) result = tool.execute.(%{"task" => "ping"}, %Context{}) System.delete_env("TOOLNEXUS_DOCS_A2A_TOKEN") false = result.is_error true = result.output == "pong" true = Agent.get(agent_state, & &1.seen_auth) == ["Bearer sekret"] IO.puts("ok: expanded header reached the peer, never the placeholder") ``` ## `Agent` descriptor shape [Section titled “Agent descriptor shape”](#agent-descriptor-shape) | Field | Type | What it is | | ------------ | ---------------------- | ------------------------------------------------------------------------ | | `card` | `String.t()` | The Agent Card URL — required. | | `headers` | `map() \| nil` | `${ENV_VAR}` values, expanded at call time, never logged. | | `timeout` | `pos_integer() \| nil` | Overall poll budget in ms. `nil` ⇒ `agent_tools/1`’s default (300\_000). | | `poll_every` | `pos_integer() \| nil` | Interval between `GetTask` polls in ms. `nil` ⇒ default (1\_000). | ## See also [Section titled “See also”](#see-also) * [`Toolnexus.A2a.agent_tools`](/api/elixir/agents/agent-tools/) — expand a remote agent card into one tool per advertised skill. * [`Toolnexus.A2a.parse_agents_config`](/api/elixir/agents/parse-config/) — declare remote peers in config the way MCP servers are declared, with precedence rules. * [`Toolnexus.Serve.start`](/api/elixir/serve/a2a/) — the inbound mirror: publish an Agent Card and answer JSON-RPC over the client loop. # Toolnexus.A2a.agent_tools > Expand a remote agent card into one tool per advertised skill. — Elixir. **Elixir** · package `toolnexus` · [SPEC §7A](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [elixir/lib/toolnexus/a2a.ex](https://github.com/muthuishere/toolnexus/blob/main/elixir/lib/toolnexus/a2a.ex) ```elixir @spec agent_tools(map()) :: [Toolnexus.Tool.t()] def agent_tools(ag) # ag = an Agent descriptor from Toolnexus.A2a.agent/1 # Tool.name = sanitize(card.name) <> "_" <> sanitize(skill.id || skill.name) # Tool.source = "a2a" # Tool.input_schema = %{"type"=>"object", "properties"=>%{"task"=>%{"type"=>"string", ...}}, # "required"=>["task"], "additionalProperties"=>false} ``` Fetches the descriptor’s Agent Card (`GET `) and turns every entry in `skills[]` into one uniform `Toolnexus.Tool`. Each tool’s `execute` performs the whole §7A wire dance — one `SendMessage` then polling `GetTask` until a terminal state — and maps the finished Task to a `ToolResult`. A card fetch or parse failure **raises**; per-peer isolation is the toolkit’s job (the toolkit catches it and logs, contributing zero tools for that peer, never failing the whole build). ## When to use it [Section titled “When to use it”](#when-to-use-it) * **You have an `Agent` descriptor and want callable tools** — this is the only function that actually performs I/O to resolve one; `agent/1` itself never touches the network. * **Wiring it into a toolkit by hand**, without `mcp_config`’s `agents` block — call `agent_tools/1` and pass the result to `Toolnexus.Toolkit.register/2` or `extra_tools:`. * **Inspecting what a peer advertises** before deciding whether to add it — the returned tools’ `name`/`description` come straight from the card’s `skills[]`. ## 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 create\_toolkit's agents: option `Toolnexus.create_toolkit!(agents: [...])` and `Toolnexus.Toolkit.add_agent/3` both call `agent_tools/1` for you and isolate a failing peer automatically (logged, zero tools, build still succeeds). Reach for `agent_tools/1` directly only when you want the raw tool list, or want the raise to propagate (e.g. a CI check that a peer’s card is reachable). ## Examples [Section titled “Examples”](#examples) ### 1. The smallest useful call — resolve a card, no execution yet [Section titled “1. The smallest useful call — resolve a card, no execution yet”](#1-the-smallest-useful-call--resolve-a-card-no-execution-yet) ```elixir alias Toolnexus.A2a Application.ensure_all_started(:req) defmodule DocsCardOnlyStub do @behaviour Plug import Plug.Conn def call(conn, _opts) do card = %{ "name" => "Weather Agent", "skills" => [ %{"id" => "forecast", "description" => "Gives a forecast"}, %{"name" => "alerts!"} ] } conn |> put_resp_content_type("application/json") |> send_resp(200, Jason.encode!(card)) end def init(opts), do: opts end {:ok, sock} = :gen_tcp.listen(0, []) {:ok, port} = :inet.port(sock) :gen_tcp.close(sock) url = "http://127.0.0.1:#{port}" {:ok, _pid} = Bandit.start_link(plug: DocsCardOnlyStub, scheme: :http, ip: {127, 0, 0, 1}, port: port) tools = A2a.agent_tools(A2a.agent(card: url <> "/.well-known/agent-card.json")) true = Enum.map(tools, & &1.name) == ["Weather_Agent_forecast", "Weather_Agent_alerts_"] [forecast, alerts] = tools true = forecast.source == "a2a" true = forecast.description == "Gives a forecast" # a skill with no id/description falls back to its name true = alerts.description == "alerts!" IO.puts("ok: #{Enum.join(Enum.map(tools, & &1.name), ", ")}") ``` ### 2. The full round trip — execute a resolved tool over real loopback HTTP [Section titled “2. The full round trip — execute a resolved tool over real loopback HTTP”](#2-the-full-round-trip--execute-a-resolved-tool-over-real-loopback-http) ```elixir alias Toolnexus.{A2a, Context} Application.ensure_all_started(:req) defmodule DocsCalcAgentStub do @behaviour Plug import Plug.Conn def init(agent), do: agent def call(conn, agent) do {:ok, body, conn} = read_body(conn) st = Agent.get(agent, & &1) case {conn.method, conn.request_path} do {"GET", "/.well-known/agent-card.json"} -> card = %{ "name" => "Calc Agent", "url" => st.url, "skills" => [%{"id" => "add", "description" => "Adds two numbers"}] } conn |> put_resp_content_type("application/json") |> send_resp(200, Jason.encode!(card)) {"POST", _} -> rpc = Jason.decode!(body) result = case rpc["method"] do "SendMessage" -> %{"id" => "t1", "status" => %{"state" => "submitted"}} "GetTask" -> %{ "id" => "t1", "status" => %{"state" => "completed"}, "artifacts" => [%{"parts" => [%{"kind" => "text", "text" => "5"}]}] } end payload = %{"jsonrpc" => "2.0", "id" => rpc["id"], "result" => result} conn |> put_resp_content_type("application/json") |> send_resp(200, Jason.encode!(payload)) end end end {:ok, sock} = :gen_tcp.listen(0, []) {:ok, port} = :inet.port(sock) :gen_tcp.close(sock) url = "http://127.0.0.1:#{port}" {:ok, agent_state} = Agent.start_link(fn -> %{url: url <> "/"} end) {:ok, _pid} = Bandit.start_link(plug: {DocsCalcAgentStub, agent_state}, scheme: :http, ip: {127, 0, 0, 1}, port: port) [add] = A2a.agent_tools(A2a.agent(card: url <> "/.well-known/agent-card.json", poll_every: 20)) result = add.execute.(%{"task" => "add 2 and 3"}, %Context{}) false = result.is_error true = result.output == "5" true = result.metadata.agent == "Calc Agent" true = result.metadata.task_id == "t1" true = result.metadata.state == "completed" IO.puts("ok: #{add.name} -> #{result.output}") ``` ### 3. The full surface — a failing peer raises; the toolkit isolates it [Section titled “3. The full surface — a failing peer raises; the toolkit isolates it”](#3-the-full-surface--a-failing-peer-raises-the-toolkit-isolates-it) ```elixir alias Toolnexus.{A2a, Toolkit} Application.ensure_all_started(:req) defmodule DocsDeadCardStub do @behaviour Plug import Plug.Conn def init(opts), do: opts def call(conn, _opts), do: send_resp(conn, 500, "card lookup failed") end {:ok, sock} = :gen_tcp.listen(0, []) {:ok, port} = :inet.port(sock) :gen_tcp.close(sock) url = "http://127.0.0.1:#{port}" {:ok, _pid} = Bandit.start_link(plug: DocsDeadCardStub, scheme: :http, ip: {127, 0, 0, 1}, port: port) card_url = url <> "/.well-known/agent-card.json" # Calling agent_tools/1 directly raises — no isolation at this layer. raised? = try do A2a.agent_tools(A2a.agent(card: card_url)) false rescue RuntimeError -> true end true = raised? # create_toolkit!/1 (agents:) DOES isolate it — the peer contributes zero tools. tk = Toolnexus.create_toolkit!(agents: [A2a.agent(card: card_url)], builtins: false) true = Toolkit.tools(tk) == [] IO.puts("ok: agent_tools/1 raises directly, the toolkit isolates it") ``` ## What you get back [Section titled “What you get back”](#what-you-get-back) | Field | Type | What it is | | -------------- | ------------------------------- | ------------------------------------------------------------------- | | `name` | `String.t()` | `sanitize(card.name) <> "_" <> sanitize(skill.id \|\| skill.name)`. | | `description` | `String.t()` | The skill’s `description`, falling back to its `name`/`id`. | | `input_schema` | `map()` | Always the fixed `{task: string}` shape (§7A). | | `source` | `"a2a"` | | | `execute` | `(args, ctx) -> ToolResult.t()` | SendMessage → poll GetTask → map the terminal Task. | ## See also [Section titled “See also”](#see-also) * [`Toolnexus.A2a.agent`](/api/elixir/agents/agent/) — point at a remote agent’s card and use it exactly like a local tool. * [`Toolnexus.A2a.parse_agents_config`](/api/elixir/agents/parse-config/) — declare remote peers in config the way MCP servers are declared, with precedence rules. # Toolnexus.A2a.parse_agents_config > Declare remote peers in config the way MCP servers are declared, with precedence rules. — Elixir. **Elixir** · package `toolnexus` · [SPEC §7A](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [elixir/lib/toolnexus/a2a.ex](https://github.com/muthuishere/toolnexus/blob/main/elixir/lib/toolnexus/a2a.ex) ```elixir @spec parse_agents_config(map() | nil) :: [map()] def parse_agents_config(block) # block = %{"" => %{"card" => ..., "headers" => ..., "timeout" => ..., # "pollEvery" => ..., "enabled"/"disabled" => ...}} # nil ⇒ [] ``` Parses an `agents` config block — the same shape as `mcpServers`, keyed by an arbitrary id — into a list of [`Toolnexus.A2a.agent/1`](/api/elixir/agents/agent/) descriptors. Entries are resolved in **sorted-key order**, disabled entries are skipped (MCP `isEnabled` precedence: `disabled: true` wins over `enabled: false`), and entries missing a `card` string are silently dropped. The config key is **only an identifier** — a tool’s name prefix always comes from the fetched card’s `name`, never the key. ## When to use it [Section titled “When to use it”](#when-to-use-it) * **Peers are declared in `mcp.json`** alongside `mcpServers` — a top-level `agents` block resolves through `Toolnexus.create_toolkit!/1` automatically; you rarely call `parse_agents_config/1` directly unless you are building the toolkit by hand. * **You want the same enable/disable precedence MCP servers get** — `disabled: true` beats `enabled: false` beats “present”, so ops can toggle a peer off without deleting its config. * **Testing config parsing in isolation** — pure function, no network, easy to unit test against a literal map. ## Why this and not the alternative [Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative) Reach for agent/1 when you have exactly one peer, not a config block [`Toolnexus.A2a.agent/1`](/api/elixir/agents/agent/) builds one descriptor by hand. Reach for `parse_agents_config/1` when peers are declared as data — a config file, a database row, anything that already looks like the `mcpServers` shape — and you want them all resolved with the same precedence rules in one pass. ## Examples [Section titled “Examples”](#examples) ### 1. The smallest useful call — `nil` and an empty block [Section titled “1. The smallest useful call — nil and an empty block”](#1-the-smallest-useful-call--nil-and-an-empty-block) ```elixir alias Toolnexus.A2a true = A2a.parse_agents_config(nil) == [] true = A2a.parse_agents_config(%{}) == [] IO.puts("ok: nil and {} both parse to []") ``` ### 2. The realistic case — one enabled peer, two disabled, one malformed [Section titled “2. The realistic case — one enabled peer, two disabled, one malformed”](#2-the-realistic-case--one-enabled-peer-two-disabled-one-malformed) ```elixir alias Toolnexus.A2a block = %{ "calc" => %{"card" => "http://calc.local/card.json", "timeout" => 5_000, "pollEvery" => 250}, "off_via_disabled" => %{"card" => "http://x.local/card.json", "disabled" => true}, "off_via_enabled" => %{"card" => "http://y.local/card.json", "enabled" => false}, "malformed" => %{"nope" => "no card key"} } descriptors = A2a.parse_agents_config(block) true = descriptors == [ %{card: "http://calc.local/card.json", headers: nil, timeout: 5_000, poll_every: 250} ] # pollEvery (config wire key) maps to :poll_every (the descriptor field) [only] = descriptors true = only.poll_every == 250 IO.puts("ok: #{length(descriptors)} of #{map_size(block)} entries survived") ``` ### 3. The full surface — sorted-key order feeds a live toolkit round trip [Section titled “3. The full surface — sorted-key order feeds a live toolkit round trip”](#3-the-full-surface--sorted-key-order-feeds-a-live-toolkit-round-trip) ```elixir alias Toolnexus.{A2a, Client, Toolkit} Application.ensure_all_started(:req) llm_plug = fn conn -> {:ok, _raw, conn} = Plug.Conn.read_body(conn) resp = %{ "choices" => [%{"message" => %{"role" => "assistant", "content" => "sunny"}}], "usage" => %{"prompt_tokens" => 1, "completion_tokens" => 1, "total_tokens" => 2} } conn |> Plug.Conn.put_resp_content_type("application/json") |> Plug.Conn.send_resp(200, Jason.encode!(resp)) end llm_client = Client.create(base_url: "http://localhost", style: "openai", model: "gpt-x", api_key: "test-key", http_options: [plug: llm_plug]) {:ok, peer_toolkit} = Toolnexus.create_toolkit(skills: [%{name: "weather", description: "reports weather", content: "Say sunny."}], builtins: false) handle = Toolkit.serve(peer_toolkit, "127.0.0.1:0", client: llm_client, a2a: %{name: "Weather Agent"}) # a config block, resolved and wired straight into a consuming toolkit config = %{"weather" => %{"card" => handle.url <> "/.well-known/agent-card.json", "pollEvery" => 10}} descriptors = A2a.parse_agents_config(config) tk = Toolnexus.create_toolkit!(agents: descriptors, builtins: false) true = Enum.map(Toolkit.tools(tk), & &1.name) == ["Weather_Agent_weather"] result = Toolkit.execute(tk, "Weather_Agent_weather", %{"task" => "how's the weather"}) false = result.is_error true = result.output == "sunny" Toolnexus.Serve.stop(handle) IO.puts("ok: config block -> #{length(descriptors)} descriptor(s) -> live tool -> \"#{result.output}\"") ``` ## What you get back [Section titled “What you get back”](#what-you-get-back) `parse_agents_config/1` returns a list of the same descriptor maps `Toolnexus.A2a.agent/1` builds (see [its fields table](/api/elixir/agents/agent/#agent-descriptor-shape)) — `%{card, headers, timeout, poll_every}` — ready to pass straight to `agent_tools/1`, `create_toolkit!(agents: ...)`, or `Toolkit.add_agent/3`. ## See also [Section titled “See also”](#see-also) * [`Toolnexus.A2a.agent`](/api/elixir/agents/agent/) — point at a remote agent’s card and use it exactly like a local tool. * [`Toolnexus.A2a.agent_tools`](/api/elixir/agents/agent-tools/) — expand a remote agent card into one tool per advertised skill. # Toolnexus.Builtin.BackgroundShells > 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. — Elixir. **Elixir** · package `toolnexus` · [SPEC §4A](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) ```elixir bg = Toolnexus.Builtin.BackgroundShells.new() Toolnexus.Builtin.tools(%{background: bg, kill_grace_ms: 2000}) # bash gains "run_in_background"; adds bash_output %{id} and kill_shell %{id} Toolnexus.Builtin.BackgroundShells.list(bg) # [%{id, running, killed, exit_code, ...}] Toolnexus.Builtin.BackgroundShells.kill(bg, id) # :ok | {:error, reason} Toolnexus.Builtin.BackgroundShells.close_all(bg) # :ok — call on shutdown ``` Opt-in background shells (`elixir/lib/toolnexus/builtin.ex`). With `background:` set, `bash` accepts `run_in_background: true` and returns at once with a background id in `metadata.backgroundId` (no timeout applies). `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`, SIGKILL). The host owns the registry and calls `close_all/1` on shutdown. A shell killed by a signal it did not get from `kill_shell` reports `exited with code -1`; because the BEAM cannot tell a signal from an exit status, a command that itself exits 129..159 also reads as -1. POSIX only. The same builtins config turns on the other opt-in tools: `web_search: fn query, limit -> {:ok, [%{title, url, snippet}]} | {:error, reason} end` adds `websearch` (the library ships no provider), and `notebook_edit: true` adds `notebook_edit` for `.ipynb` cells. ## When to use it [Section titled “When to use it”](#when-to-use-it) * **Dev servers, watchers, long builds** — start them, keep working, read their output later. * **A coding agent** that must not block a turn on `mix test --cover` taking minutes. ## Why this and not the alternative [Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative) Not bash with a long timeout A foreground `bash` blocks the turn and dies at its timeout. A background shell returns immediately and keeps running until it exits, the model kills it, or the host closes the registry. Use foreground `bash` for anything that finishes in seconds. ## Examples [Section titled “Examples”](#examples) ### 1. Off by default [Section titled “1. Off by default”](#1-off-by-default) ```elixir names = Enum.map(Toolnexus.Builtin.tools(%{}), & &1.name) false = Enum.any?(~w(bash_output kill_shell websearch notebook_edit), &(&1 in names)) IO.puts("ok: no background tools unless configured") ``` ### 2. Start, read, finish [Section titled “2. Start, read, finish”](#2-start-read-finish) ```elixir alias Toolnexus.Builtin.BackgroundShells bg = BackgroundShells.new() tools = Map.new(Toolnexus.Builtin.tools(%{background: bg, kill_grace_ms: 100}), &{&1.name, &1}) r = tools["bash"].execute.(%{"command" => "echo one; sleep 0.2; echo two", "run_in_background" => true}, nil) id = r.metadata.backgroundId out = Enum.reduce_while(1..100, "", fn _, acc -> o = tools["bash_output"].execute.(%{"id" => id}, nil) acc = acc <> o.output if o.metadata.status == "exited", do: {:halt, acc}, else: (Process.sleep(50) && {:cont, acc}) end) true = out =~ "one" and out =~ "two" and out =~ "[status: exited with code 0]" :ok = BackgroundShells.close_all(bg) IO.puts("ok: #{id} exited 0") ``` ### 3. Kill a stubborn job, then websearch over a host provider [Section titled “3. Kill a stubborn job, then websearch over a host provider”](#3-kill-a-stubborn-job-then-websearch-over-a-host-provider) ```elixir alias Toolnexus.Builtin.BackgroundShells bg = BackgroundShells.new() cfg = %{background: bg, kill_grace_ms: 100, web_search: fn q, _n -> {:ok, [%{title: "Result for #{q}", url: "https://a.example", snippet: "s"}]} end} tools = Map.new(Toolnexus.Builtin.tools(cfg), &{&1.name, &1}) r = tools["bash"].execute.(%{"command" => "trap '' TERM; sleep 30 & wait", "run_in_background" => true}, nil) id = r.metadata.backgroundId Process.sleep(150) k = tools["kill_shell"].execute.(%{"id" => id}, nil) true = k.metadata.killed "killed" = tools["bash_output"].execute.(%{"id" => id}, nil).metadata.status :ok = BackgroundShells.close_all(bg) "1. Result for elixir\n https://a.example\n s" = tools["websearch"].execute.(%{"query" => "elixir"}, nil).output IO.puts("ok: killed #{id}; websearch answered") ``` ## See also [Section titled “See also”](#see-also) * [`Toolnexus.Builtin.load`](/api/elixir/builtins/select/) — Pick which built-in file/shell/web tools are in play, by name or by source toggle. * [`Toolnexus.Builtin.tools`](/api/elixir/builtins/create/) — Construct every built-in tool directly, outside a toolkit. # Toolnexus.Builtin.tools > Construct every built-in tool directly, outside a toolkit — no config, no toggles. Elixir. **Elixir** · package `toolnexus` · [SPEC §4A](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [elixir/lib/toolnexus/builtin.ex](https://github.com/muthuishere/toolnexus/blob/main/elixir/lib/toolnexus/builtin.ex) ```elixir @spec tools() :: [Toolnexus.Tool.t()] def tools() ``` The real symbol is Toolnexus.Builtin.tools/0, not .create There is no `Toolnexus.Builtin.tools` function in this port. The zero-arity constructor for “all ten built-ins, unconditionally” is `Toolnexus.Builtin.tools/0` — this page documents that function. Builds all ten built-in tools — `bash`, `read`, `write`, `edit`, `grep`, `glob`, `webfetch`, `question`, `apply_patch`, `todowrite` — in fixed order, every time, with no config and no filtering. This is what [`Toolnexus.Builtin.load/1`](/api/elixir/builtins/select/) calls internally before applying the on/off and per-tool toggles. ## When to use it [Section titled “When to use it”](#when-to-use-it) * **You want the full set, unconditionally** — no `enabled`/`disabled` check, no `tools` allowlist to reason about, just the ten tools. * **Testing or inspecting a single builtin** — `tools/0` is the shortest path to a real `bash` or `read` tool struct you can call `execute.(args, ctx)` on directly. `read` is not text-only: for a recognised media extension (`png jpg jpeg gif webp pdf mp3 wav`) it returns ` (, bytes)` as `output` plus one [`Toolnexus.ContentPart`](/api/elixir/types/content-part/) in `parts`; bytes that are neither valid UTF-8 nor a recognised media file come back as an `is_error: true` result. * **Building your own selection logic** — if `Builtin.load/1`’s toggle shape doesn’t fit your config format, call `tools/0` and filter the list yourself. ## Why this and not the alternative [Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative) Use load/1 when config decides which tools are in play [`Toolnexus.Builtin.load/1`](/api/elixir/builtins/select/) is `tools/0` plus the on/off and per-name toggle logic driven by a config map — reach for it whenever “which builtins” is a decision that comes from outside your code (an agent’s config, a per-tenant setting). Use `tools/0` directly when the answer is always “all of them.” ## Examples [Section titled “Examples”](#examples) ### 1. All ten, in fixed order, every call [Section titled “1. All ten, in fixed order, every call”](#1-all-ten-in-fixed-order-every-call) ```elixir alias Toolnexus.Builtin tools = Builtin.tools() true = Enum.map(tools, & &1.name) == ["bash", "read", "write", "edit", "grep", "glob", "webfetch", "question", "apply_patch", "todowrite"] true = Enum.all?(tools, &(&1.source == "builtin")) # Calling it again returns the same shape — no memoization surprises, no shared state. true = Enum.map(Builtin.tools(), & &1.name) == Enum.map(tools, & &1.name) IO.puts("ok: #{length(tools)} builtins, fixed order") ``` ### 2. Executing one directly — no toolkit, no agent, just the tool [Section titled “2. Executing one directly — no toolkit, no agent, just the tool”](#2-executing-one-directly--no-toolkit-no-agent-just-the-tool) ```elixir alias Toolnexus.{Builtin, Context} tools = Builtin.tools() write = Enum.find(tools, &(&1.name == "write")) read = Enum.find(tools, &(&1.name == "read")) dir = Path.join(System.tmp_dir!(), "toolnexus-docs-builtins-#{System.unique_integer([:positive])}") path = Path.join(dir, "note.txt") wrote = write.execute.(%{"path" => path, "content" => "hello from tools/0"}, %Context{}) false = wrote.is_error true = wrote.metadata.bytes == byte_size("hello from tools/0") got = read.execute.(%{"path" => path}, %Context{}) false = got.is_error true = got.output == "hello from tools/0" File.rm_rf!(dir) IO.puts("ok: wrote #{wrote.metadata.bytes} bytes, read back #{byte_size(got.output)}") ``` ### 3. `tools/0` ignores config entirely — unlike `load/1` [Section titled “3. tools/0 ignores config entirely — unlike load/1”](#3-tools0-ignores-config-entirely--unlike-load1) Same underlying ten tools, but `tools/0` never looks at a config map — passing one to `load/1` still narrows the set; there is no equivalent argument to `tools/0`. ```elixir alias Toolnexus.Builtin full = Builtin.tools() 10 = length(full) # load/1 with a `tools` toggle narrows the set... narrowed = Builtin.load(%{"tools" => %{"bash" => false}}) 9 = length(narrowed) # ...but tools/0 has no config argument at all — always the full ten. true = length(Builtin.tools()) == length(full) true = Enum.map(Builtin.tools(), & &1.name) == Enum.map(full, & &1.name) IO.puts("ok: tools/0 always #{length(full)}, load/1 narrowed to #{length(narrowed)}") ``` ## Search context, truncation, and the opt-in tools [Section titled “Search context, truncation, and the opt-in tools”](#search-context-truncation-and-the-opt-in-tools) * `grep` takes `context`, `before` and `after` line counts: match lines print as `path:N:text`, context lines as `path-N-text`, with `--` between groups that do not touch. Absent ⇒ the output is unchanged. * A `read` window that stops before the end of the file sets `metadata.truncated`, `totalLines` and `nextOffset`; a `grep` cut by its `limit` sets `truncated` and `total`. The text output stays the pinned cross-port bytes. * `edit`, `write` and `apply_patch` results carry `metadata.files` and a unified `metadata.diff`. * Every builtin carries `hints` (see [hooks](/api/elixir/client/hooks/)). * `tools/0` stays the default ten. The opt-in tools come from `Toolnexus.Builtin.tools/1` config: `background:` ([background shells](/api/elixir/builtins/background-shells/)), `web_search: fn query, limit -> ... end` (`websearch`) and `notebook_edit: true` (`notebook_edit`, replace / insert / delete an `.ipynb` cell by id or index). ## See also [Section titled “See also”](#see-also) * [`Toolnexus.Builtin.load`](/api/elixir/builtins/select/) — the config-driven on/off and per-tool toggle * [`Toolnexus.create_toolkit`](/api/elixir/toolkit/create/) — where builtins normally get wired into an agent * [`Toolnexus.Tool`](/api/elixir/types/tool/) — the struct every builtin returns # Toolnexus.Builtin.load > Pick which built-in file/shell/web tools are in play, by name or by source toggle. Elixir. **Elixir** · package `toolnexus` · [SPEC §4A](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [elixir/lib/toolnexus/builtin.ex](https://github.com/muthuishere/toolnexus/blob/main/elixir/lib/toolnexus/builtin.ex) ```elixir @spec load(nil | boolean() | map()) :: [Toolnexus.Tool.t()] def load(cfg \\ nil) ``` Resolves the active built-in tools for a config. The whole source can be switched off (`enabled: false` / `disabled: true` / `cfg == false`), or left on with individual tools dropped by name via a `"tools"` map — the same allowlist/droplist shape used by the MCP `tools` filter and [`Skill.load/1`’s `:filter`](/api/elixir/skills/load-with/). Elixir has no separate select\* function The other five ports call this operation `select` or `selectBuiltins`. In Elixir it is the same `load/1` you would call from [`create_toolkit/1`](/api/elixir/toolkit/create/) internally — there is nothing extra to import. ## When to use it [Section titled “When to use it”](#when-to-use-it) * **You are assembling built-in tools outside a toolkit** and want to honor the same `enabled`/`disabled`/`tools` config shape `create_toolkit/1` accepts for its `:builtins` option, without going through a toolkit. * **You want fewer than the full ten** — drop `bash` and `apply_patch` for a read-only agent, keep everything else, by mapping just those two names to `false`. * **Config-driven on/off** — `cfg` can come straight from a parsed JSON/YAML document (string keys) or a hand-built map (atom or string keys); both work. ## Why this and not the alternative [Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative) Use Builtin.tools/0 when you want everything, no config [`Toolnexus.Builtin.tools/0`](/api/elixir/builtins/create/) constructs all ten built-ins directly and ignores config entirely. Reach for `load/1` instead whenever “on/off” or “which ones” is a decision your caller makes — which is the normal case once builtins are wired into an agent. ## Examples [Section titled “Examples”](#examples) ### 1. The default — no config means everything, in fixed order [Section titled “1. The default — no config means everything, in fixed order”](#1-the-default--no-config-means-everything-in-fixed-order) ```elixir alias Toolnexus.Builtin tools = Builtin.load() true = Enum.map(tools, & &1.name) == ["bash", "read", "write", "edit", "grep", "glob", "webfetch", "question", "apply_patch", "todowrite"] true = Enum.all?(tools, &(&1.source == "builtin")) # `nil` behaves identically to omitting the argument. true = Builtin.load(nil) == tools IO.puts("ok: #{length(tools)} builtins, default on") ``` ### 2. Turning the whole source off — a bare boolean, or `enabled`/`disabled` [Section titled “2. Turning the whole source off — a bare boolean, or enabled/disabled”](#2-turning-the-whole-source-off--a-bare-boolean-or-enableddisabled) ```elixir alias Toolnexus.Builtin [] = Builtin.load(false) [] = Builtin.load(%{"enabled" => false}) [] = Builtin.load(%{"disabled" => true}) # `disabled: true` wins even if `enabled` also says true — same precedence as the MCP source. [] = Builtin.load(%{"disabled" => true, "enabled" => true}) 10 = length(Builtin.load(true)) 10 = length(Builtin.load(%{})) IO.puts("ok: whole-source toggle honoured, disabled wins ties") ``` ### 3. Dropping individual tools by name [Section titled “3. Dropping individual tools by name”](#3-dropping-individual-tools-by-name) The `"tools"` map only ever **drops** — it is not an allowlist here. Any name mapped to `false` is removed; `true`, absent, or an unrecognised name all leave the rest untouched. ```elixir alias Toolnexus.Builtin cfg = %{"tools" => %{"bash" => false, "apply_patch" => false, "made_up_name" => false}} tools = Builtin.load(cfg) names = Enum.map(tools, & &1.name) false = "bash" in names false = "apply_patch" in names # Everything else survives, in the same fixed order. true = names == ["read", "write", "edit", "grep", "glob", "webfetch", "question", "todowrite"] # An explicit `true` (or omission) keeps a tool — it is not an allowlist switch. kept = Builtin.load(%{"tools" => %{"bash" => true}}) true = "bash" in Enum.map(kept, & &1.name) 10 = length(kept) IO.puts("ok: dropped #{10 - length(tools)}, kept #{length(tools)}") ``` ## `cfg` shapes accepted [Section titled “cfg shapes accepted”](#cfg-shapes-accepted) | `cfg` | Effect | | ------------------------------- | -------------------------------------------------------------------------------------------------- | | `nil` (default) | All ten builtins. | | `false` | `[]` — the whole source is off. | | `true` | All ten builtins. | | `%{"enabled" => false}` | `[]`. | | `%{"disabled" => true}` | `[]` — wins over `enabled: true`. | | `%{"tools" => %{name => bool}}` | All ten minus any name mapped to `false`. Atom or string keys both work (`cfg_get/2` checks both). | ## The ten tools, in fixed order [Section titled “The ten tools, in fixed order”](#the-ten-tools-in-fixed-order) `bash`, `read`, `write`, `edit`, `grep`, `glob`, `webfetch`, `question`, `apply_patch`, `todowrite` — see [`Toolnexus.Builtin.tools/0`](/api/elixir/builtins/create/) for what each does. ## See also [Section titled “See also”](#see-also) * [`Toolnexus.Builtin.tools/0`](/api/elixir/builtins/create/) — construct all ten directly, no config * [`Toolnexus.create_toolkit`](/api/elixir/toolkit/create/) — where `:builtins` config normally lives * [`Toolnexus.Tool`](/api/elixir/types/tool/) — the struct every builtin returns # 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. — Elixir. **Elixir** · package `toolnexus` · [SPEC §8](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) Not available in Elixir 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) Elixir ships `Toolnexus.Acp` (`elixir/lib/toolnexus/acp.ex`): `connect/2` spawns the agent over stdio and keeps one warm session, `prompt/3` runs a turn, `close/1` stops it, and the source plugs into the client through the in-process path. What it does **not** have yet is any of the four Go hooks: * **Permissions** — every `session/request_permission` is answered inline with the first `allow`-kind option. There is no host callback, so a plan mode or a human approval prompt cannot sit in front of the agent’s own tool calls. If you need one, configure the agent itself (most ACP agents have their own permission mode) or gate the toolnexus tools the agent is given through `before_tool`. * **Updates** — only `agent_message_chunk` text forms the reply; thought chunks, tool-call narration and plans are dropped, and there is no observer. * **Cancel** — a turn cannot be cancelled with `session/cancel`; stop it with a `prompt/3` timeout, or `close/1` the session (which ends the agent process). * **Streaming** — an ACP-backed client does not stream; use `Client.run/4`. The Go API is documented at [`ACPOptions.OnPermission`](/api/go/client/acp-hooks/). Tracked in `openspec/changes/add-go-host-asks`, task 2.6d. ## See also [Section titled “See also”](#see-also) * [`Toolnexus.Client.create`](/api/elixir/client/create/) — The unified client: system prompt, skills injection, parallel and chained tool calls, retries, memory. * [`Toolnexus.Client.run`](/api/elixir/client/run/) — Send a prompt, let the loop call tools until the model stops, get a RunResult. * [`Toolnexus.Client.stream`](/api/elixir/client/stream/) — The streaming loop: text deltas, tool-call events, and suspension events as they happen. * [`Toolnexus.Client.create`](/api/elixir/client/hooks/) — Intercept before/after model calls and tool calls: audit, redact, veto, or rewrite. # 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. — Elixir. **Elixir** · package `toolnexus` · [SPEC §8](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) ```elixir Toolnexus.Client.create( base_url: String.t(), api_key: String.t() | nil, # used as-is when set api_key_env: String.t() | nil, # the NAME of an env var, read at call time ... ) ``` Which key the client sends, and to whom. In order: `:api_key` when set; else the env var named by `:api_key_env`; else **only** 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` (and its subdomains) → `OPENROUTER_API_KEY`. Any other host, or plain http, with neither option set raises `ArgumentError` (“No API key: …”) before any request is made. The same rule applies to [`Toolnexus.Client.list_models/1`](/api/elixir/client/list-models/), which returns `{:error, message}` instead of raising. BREAKING in 0.22.0 Before 0.22.0 a client with no `:api_key` took the first of `OPENROUTER_API_KEY`, `OPENAI_API_KEY`, `ANTHROPIC_API_KEY` that was set and sent it to whatever `:base_url` was configured — so an OpenRouter key could reach api.openai.com, and any key could reach a local or third-party server. A host on a local server (Ollama, llama.cpp, vLLM) or a proxy must now set `:api_key` or `:api_key_env`. ## When to use it [Section titled “When to use it”](#when-to-use-it) * **A local or self-hosted endpoint** — `http://localhost:11434/v1` has no “own” env var, so pass `api_key: "unused"` (many local servers ignore it) or the key it really wants. * **A proxy or gateway** that fronts a provider — name the env var that holds the gateway key with `api_key_env: "GATEWAY_KEY"`; the value is read at call time and never logged. * **Several provider keys in one environment** — leave both unset on the official hosts; each host gets only its own key. ## 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 for real secrets `:api_key` takes the **value**, so it ends up in whatever built the options (config files, supervisor specs, crash dumps of the client struct). `:api_key_env` takes only the **name**; the value stays in the process environment and is read at the moment of the request. Use `:api_key` for tests and placeholders. ## Examples [Section titled “Examples”](#examples) ### 1. A local server needs an explicit key [Section titled “1. A local server needs an explicit key”](#1-a-local-server-needs-an-explicit-key) ```elixir alias Toolnexus.Client # A custom host with no key raises before any request is made. no_key = Client.create(base_url: "http://127.0.0.1:1/v1", model: "m") raised = try do Client.run(no_key, "hi", []) false rescue e in ArgumentError -> e.message =~ "No API key" end true = raised t = fn _req -> {:ok, %{status: 200, headers: %{}, body: %{"choices" => [%{"message" => %{"role" => "assistant", "content" => "local ok"}}], "usage" => %{"prompt_tokens" => 1, "completion_tokens" => 1, "total_tokens" => 2}}}} end local = Client.create(base_url: "http://127.0.0.1:11434/v1", model: "m", api_key: "YOUR_KEY_HERE", transport: t) true = Client.run(local, "hi", []).text == "local ok" IO.puts("ok: explicit key required for a local host") ``` ### 2. Name the env var with `:api_key_env` [Section titled “2. Name the env var with :api\_key\_env”](#2-name-the-env-var-with-api_key_env) ```elixir alias Toolnexus.Client System.put_env("MY_GATEWAY_KEY", "YOUR_KEY_HERE") me = self() t = fn req -> send(me, {:auth, req.headers["authorization"]}) {:ok, %{status: 200, headers: %{}, body: %{"data" => []}}} end {:ok, []} = Client.list_models(base_url: "https://gateway.example/v1", api_key_env: "MY_GATEWAY_KEY", transport: t) receive do {:auth, "Bearer YOUR_KEY_HERE"} -> IO.puts("ok: the gateway got the key named by api_key_env") end ``` ### 3. Each official host gets only its own env var [Section titled “3. Each official host gets only its own env var”](#3-each-official-host-gets-only-its-own-env-var) ```elixir alias Toolnexus.Client System.put_env("OPENAI_API_KEY", "fake-openai") System.put_env("OPENROUTER_API_KEY", "fake-openrouter") me = self() t = fn req -> send(me, {:auth, req.headers["authorization"]}) {:ok, %{status: 200, headers: %{}, body: %{"data" => []}}} end {:ok, []} = Client.list_models(base_url: "https://api.openai.com/v1", transport: t) receive do: ({:auth, "Bearer fake-openai"} -> :ok) {:ok, []} = Client.list_models(base_url: "https://openrouter.ai/api/v1", transport: t) receive do: ({:auth, "Bearer fake-openrouter"} -> :ok) # No fallback: another host never sees either key, and plain http never qualifies. {:error, msg} = Client.list_models(base_url: "https://example.com/v1", transport: t) true = msg =~ "No API key" and not (msg =~ "fake-") {:error, _} = Client.list_models(base_url: "http://api.openai.com/v1", transport: t) IO.puts("ok: keys go only to their own host") ``` ## See also [Section titled “See also”](#see-also) * [`Toolnexus.Client.create`](/api/elixir/client/create/) — The unified client: system prompt, skills injection, parallel and chained tool calls, retries, memory. * [`Toolnexus.Client.run`](/api/elixir/client/run/) — Send a prompt, let the loop call tools until the model stops, get a RunResult. * [`Toolnexus.Client.stream`](/api/elixir/client/stream/) — The streaming loop: text deltas, tool-call events, and suspension events as they happen. * [`Toolnexus.Client.create`](/api/elixir/client/hooks/) — Intercept before/after model calls and tool calls: audit, redact, veto, or rewrite. # Toolnexus.Client conversation > Keep a transcript across turns so the model remembers what it already did. — Elixir. **Elixir** · package `toolnexus` · [SPEC §8](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [elixir/lib/toolnexus/client.ex](https://github.com/muthuishere/toolnexus/blob/main/elixir/lib/toolnexus/client.ex) ```elixir # low-level, stateless primitive — you carry the transcript @spec run(Toolnexus.Client.t(), String.t(), term(), keyword()) :: Toolnexus.Client.RunResult.t() def run(client, prompt, toolkit, opts \\ []) # opts: history: [map()] # durable, id-keyed primitive — the client carries the transcript via a ConversationStore @spec ask(Toolnexus.Client.t(), String.t(), term(), keyword() | String.t()) :: Toolnexus.Client.RunResult.t() def ask(client, prompt, toolkit, opts \\ []) # opts: id: String.t(), on_text: (String.t() -> any()) ``` There is no `Client.conversation/1` object in the Elixir port — the other five ports wrap multi-turn memory as a stateful `Conversation` value; here it collapses to two functions you already have. `run/4`’s `history:` option is the stateless primitive (you hold the transcript); `ask/4` is the stateful, id-keyed one built on top of it and a [`ConversationStore`](/api/elixir/client/store/) (you hold only an id). ## When to use it [Section titled “When to use it”](#when-to-use-it) * **One turn, then you’re done with the transcript** — plain `run/4`, no `history:` needed. * **You already have `RunResult.messages` from a previous call and want to continue it once** — `run(client, next_prompt, toolkit, history: prior.messages)`. No store, no id. * **A conversation keyed by something durable** — a user id, a chat thread id, an A2A `contextId` — `ask(client, prompt, toolkit, id: id)`. The client loads that id’s transcript, runs, and saves the update back, every time. * **You want text streamed to a callback but still need the final `RunResult`** — `ask/4`’s `:on_text` option; the block-style alternative to consuming `stream/4`’s iterator yourself. ## Why this and not the alternative [Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative) Reach for stream/4 directly when you're building an iterator consumer `ask/4` with `:on_text` internally calls [`stream/4`](/api/elixir/client/stream/) and reduces its events down to a callback + a final `RunResult` — convenient when your caller wants “push me deltas” rather than “give me something to iterate”. If you want the raw event stream (tool-call visibility, `pending` events, `usage`), consume `stream/4` yourself instead. `ask/4` and `run/4` behave identically for `openai` and `anthropic` style — the store never sees provider-specific shape, only whatever `RunResult.messages` already is. ## Examples [Section titled “Examples”](#examples) ### 1. The smallest useful call — `ask/4` with no `:id` is just `run/4` [Section titled “1. The smallest useful call — ask/4 with no :id is just run/4”](#1-the-smallest-useful-call--ask4-with-no-id-is-just-run4) Without an id, `ask/4` is a stateless one-shot — the store is never touched. ```elixir alias Toolnexus.Client plug = fn conn -> {:ok, _raw, conn} = Plug.Conn.read_body(conn) resp = %{"choices" => [%{"message" => %{"role" => "assistant", "content" => "hello"}}], "usage" => %{"prompt_tokens" => 2, "completion_tokens" => 1, "total_tokens" => 3}} conn |> Plug.Conn.put_resp_content_type("application/json") |> Plug.Conn.send_resp(200, Jason.encode!(resp)) end client = Client.create(base_url: "http://localhost", style: "openai", model: "gpt-x", api_key: "test-key", http_options: [plug: plug]) via_ask = Client.ask(client, "hi", []) via_run = Client.run(client, "hi", []) true = via_ask.text == via_run.text true = via_ask.text == "hello" # Nothing was saved — no id was given. store = Client.conversation_store(client) true = Toolnexus.Client.InMemoryConversationStore.get(store, "hi") == nil IO.puts("ok: ask/4 with no :id == run/4 (#{via_ask.text})") ``` ### 2. The realistic case — `ask/4` with `:id`, the durable path [Section titled “2. The realistic case — ask/4 with :id, the durable path”](#2-the-realistic-case--ask4-with-id-the-durable-path) The second `ask/4` for the same id continues the conversation: its request carries turn one’s messages plus the new prompt, with no `history:` passed by hand. ```elixir alias Toolnexus.Client {:ok, bodies} = Agent.start_link(fn -> [] end) {:ok, calls} = Agent.start_link(fn -> 0 end) plug = fn conn -> {:ok, raw, conn} = Plug.Conn.read_body(conn) body = Jason.decode!(raw) Agent.update(bodies, fn b -> b ++ [body] end) n = Agent.get_and_update(calls, fn c -> {c + 1, c + 1} end) text = if n == 1, do: "42, got it", else: "you said 42" resp = %{"choices" => [%{"message" => %{"role" => "assistant", "content" => text}}], "usage" => %{"prompt_tokens" => 3, "completion_tokens" => 2, "total_tokens" => 5}} conn |> Plug.Conn.put_resp_content_type("application/json") |> Plug.Conn.send_resp(200, Jason.encode!(resp)) end client = Client.create(base_url: "http://localhost", style: "openai", model: "gpt-x", api_key: "test-key", http_options: [plug: plug]) first = Client.ask(client, "remember 42", [], id: "thread-1") true = first.text == "42, got it" second = Client.ask(client, "what did I say?", [], "thread-1") true = second.text == "you said 42" [_first_body, second_body] = Agent.get(bodies, & &1) true = length(second_body["messages"]) == 3 true = List.last(second_body["messages"]) == %{"role" => "user", "content" => "what did I say?"} IO.puts("ok: turn 2 saw #{length(second_body["messages"])} messages from turn 1's transcript") ``` A bare string id works too `ask(client, prompt, toolkit, "thread-1")` is shorthand for `id: "thread-1"` — both forms above are used deliberately to show they’re equivalent. ### 3. The full surface — `:on_text` streams deltas but still returns a `RunResult` [Section titled “3. The full surface — :on\_text streams deltas but still returns a RunResult”](#3-the-full-surface--on_text-streams-deltas-but-still-returns-a-runresult) `on_text` makes `ask/4` internally stream, forwarding each text delta to your callback, while the return value stays the ordinary final `RunResult` — the return type never changes based on whether you streamed. ```elixir alias Toolnexus.Client sse = """ data: {"choices":[{"delta":{"content":"The "}}]} data: {"choices":[{"delta":{"content":"answer is 9."}}]} data: {"choices":[{"delta":{}}],"usage":{"prompt_tokens":4,"completion_tokens":3,"total_tokens":7}} data: [DONE] """ plug = fn conn -> {:ok, _raw, conn} = Plug.Conn.read_body(conn) conn |> Plug.Conn.put_resp_content_type("text/event-stream") |> Plug.Conn.send_resp(200, sse) end client = Client.create(base_url: "http://localhost", style: "openai", model: "gpt-x", api_key: "test-key", http_options: [plug: plug]) {:ok, deltas} = Agent.start_link(fn -> [] end) on_text = fn delta -> Agent.update(deltas, fn d -> d ++ [delta] end) end result = Client.ask(client, "what is 4+5?", [], on_text: on_text) true = Agent.get(deltas, & &1) == ["The ", "answer is 9."] true = result.text == "The answer is 9." true = result.status == "done" IO.puts("ok: streamed #{length(Agent.get(deltas, & &1))} delta(s), returned final text #{inspect(result.text)}") ``` ## Options [Section titled “Options”](#options) | Where | Option | What it does | | ------- | ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | | `run/4` | `:history` | A prior `RunResult.messages` to continue. Stateless — you carry it. | | `ask/4` | `:id` | Loads that id’s transcript from the client’s store, runs, saves the update back. Omit ⇒ stateless, identical to `run/4`. | | `ask/4` | `:on_text` | `(delta :: String.t() -> any())`, invoked per text delta while `ask/4` streams internally. Omit ⇒ non-streaming. Return value is still a `RunResult`. | ## See also [Section titled “See also”](#see-also) * [`Toolnexus.Client.run`](/api/elixir/client/run/) — the stateless loop `ask/4` is built on. * [`Toolnexus.Client.stream`](/api/elixir/client/stream/) — the iterator `ask/4`’s `:on_text` consumes internally. * [`Toolnexus.Client.InMemoryConversationStore`](/api/elixir/client/store/) — the default store, and how to swap in your own. * [`Toolnexus.Client.create`](/api/elixir/client/create/) — `:store` is set here. # Toolnexus.Client.create > The unified client: system prompt, skills injection, parallel and chained tool calls, retries, memory. — Elixir. **Elixir** · package `toolnexus` · [SPEC §8](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) ```elixir @spec create(keyword() | map()) :: Toolnexus.Client.t() def create(opts) Toolnexus.Client.create( base_url: "https://openrouter.ai/api/v1", style: "openai", # "openai" | "anthropic" — the provider wire format model: "openai/gpt-4o-mini", api_key: nil, # nil ⇒ api_key_env, else ONLY the base_url host's own env var (https) — see client/api-key api_key_env: nil, # the NAME of an env var to read the key from headers: %{}, system_prompt: nil, # prepended to the toolkit's skills prompt max_turns: 10, # default retries: 2, # default — see client/resilience retry_base_ms: 500, # default retryable_statuses: nil, # default — ADDS to the built-in retryable set timeout_ms: nil, # default — whole-run deadline, nil = none on_error: nil, # default — see client/resilience hooks: %{}, # before_llm / after_llm / before_tool / after_tool — see client/hooks store: nil, # ConversationStore struct, default: in-memory — see client/store on_metric: nil, # (event_map -> any) — see client/metrics wait_for: nil, # §10 suspension resolver — see suspension/wait-for wait_for_ctx: nil, # (ctx, request -> answer), wins over wait_for — see suspension/wait-for prompt_cache: nil, # %{key, long_ttl} — see client/prompt-cache tool_tiers: nil, # %{core, deferred, ...} — see client/tool-tiers request_params: nil, # shallow-merged into every LLM body — §8 Gap 1 body_transform: nil, # run LAST after the request_params merge — §8 Gap 1 http_options: [], # extra Req options for the LLM path — §8 Gap 2 transport: nil, # injectable HTTP transport, replaces the wire call only — §8 Gap 2 max_part_bytes: nil, # cap on a content part's decoded bytes — §1B on_unsupported_part: nil, # "error" | "text" — §8A provenance rule override registry: nil # inject a shared MetricsRegistry, nil = a fresh private one ) ``` The unified client: system prompt, skills injection, parallel and chained tool calls, retries, memory. `Client.create/1` (`elixir/lib/toolnexus/client.ex:345`) is the other half of the “zero to agent” pair — a [`Toolnexus.Toolkit`](/api/elixir/toolkit/create/) gives you tools, `Client.create/1` gives you the loop that calls them. It takes a keyword list or a map, fills every unset field with its documented default (`retries: 2`, `max_turns: 10`, a fresh in-memory conversation store, a fresh private metrics registry, …), and returns a plain `%Toolnexus.Client{}` struct — there is no process, no supervision tree, nothing to start or stop. Pass the resulting client into [`Toolnexus.Client.run/4`](/api/elixir/client/run/) or [`Toolnexus.Client.stream/4`](/api/elixir/client/stream/) together with a toolkit (or `nil` for a tool-less run) to actually drive a conversation. API key by host (BREAKING in 0.22.0) With no `:api_key`, the client reads only `:api_key_env` or the env var that belongs to the `:base_url` host (`api.openai.com`, `api.anthropic.com`, `openrouter.ai`, over https). A local or custom `:base_url` must set `:api_key` or `:api_key_env`, or the run raises before any request. See [API key by host](/api/elixir/client/api-key/). ## When to use it [Section titled “When to use it”](#when-to-use-it) * **You are wiring up “zero to agent”** — one `Toolnexus.create_toolkit/1` for tools, one `Client.create/1` for the model connection, then `Client.run/4` to send a prompt and get a `RunResult`. This is the entry point every README and example starts from. * **You need to point at a different provider or gateway** — `base_url` + `style` is the whole provider surface (`"openai"` covers OpenAI-compatible gateways like OpenRouter; `"anthropic"` covers Claude’s native wire shape). Swapping providers is a one-line change to this call, never a different client type. * **You want retries, a deadline, hooks, metrics, or a custom conversation store** — all of it is an option on this same call. Nothing here requires a second construction step or a different entry point; the struct just carries more fields. * **You need the toolkit’s tools mapped to OpenAI/Anthropic/Gemini schema automatically** — pass the toolkit into `run/4`/`stream/4`, not into `create/1`. `Client.create/1` only knows about the provider connection; the toolkit is supplied per-call so the same client can drive different toolkits across calls. ## Why this and not the alternative [Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative) One options map, not a builder or a family of client types Every option — retries, hooks, store, metrics, resilience, request shaping — is a field on the same `keyword()`/`map()` passed to `create/1`, not a separate `WithRetries`/`WithHooks` builder chain and not a different struct per provider. That is deliberate: the struct is what every other function in this module (`run/4`, `stream/4`, `ask/4`, `metrics/1`) pattern-matches on, so adding an option never changes the type callers hold. If you find yourself reaching for two clients that differ only in one option (say, one with a shorter `timeout_ms` for a background job), that is the right use of two `create/1` calls — the struct is cheap, immutable, and has no attached process. The one thing genuinely absent from this surface is a way to change a client’s options after construction — there is no `Client.update/2`. A different `timeout_ms` or a different `model` is a new `create/1` call, not a mutation, which keeps the struct’s fields honest: every field you read off a `%Toolnexus.Client{}` was set once, at construction, and nothing later in a run loop reaches back to change it underneath a caller holding a reference. ## Examples [Section titled “Examples”](#examples) ### 1. The smallest useful call — construct and run once [Section titled “1. The smallest useful call — construct and run once”](#1-the-smallest-useful-call--construct-and-run-once) ```elixir alias Toolnexus.Client client = Client.create( base_url: "https://openrouter.ai/api/v1", style: "openai", model: "openai/gpt-4o-mini", api_key: "test-key" ) %Client{} = client true = client.retries == 2 true = client.max_turns == 10 IO.puts("ok: client created with defaults (retries=#{client.retries}, max_turns=#{client.max_turns})") ``` ### 2. The realistic case — a toolkit, custom retries, and a deadline [Section titled “2. The realistic case — a toolkit, custom retries, and a deadline”](#2-the-realistic-case--a-toolkit-custom-retries-and-a-deadline) ```elixir {:ok, toolkit} = Toolnexus.create_toolkit(skills_dir: ["../examples/skills"]) client = Toolnexus.Client.create( base_url: "https://openrouter.ai/api/v1", style: "openai", model: "openai/gpt-4o-mini", api_key: System.get_env("OPENROUTER_API_KEY") || "test-key", retries: 3, retry_base_ms: 250, timeout_ms: 30_000 ) # result = Toolnexus.Client.run(client, "What tools do you have?", toolkit) # IO.puts(result.text) IO.puts("ok: client + toolkit wired (#{length(Toolnexus.Toolkit.tools(toolkit))} tools)") Toolnexus.Toolkit.close(toolkit) ``` ### 3. The full surface — hooks, a custom store, metrics, and a resolver [Section titled “3. The full surface — hooks, a custom store, metrics, and a resolver”](#3-the-full-surface--hooks-a-custom-store-metrics-and-a-resolver) ```elixir alias Toolnexus.Client alias Toolnexus.Client.InMemoryConversationStore client = Client.create( base_url: "https://openrouter.ai/api/v1", style: "openai", model: "openai/gpt-4o-mini", api_key: "test-key", system_prompt: "You are a support agent. Be concise.", max_turns: 6, retries: 2, retry_base_ms: 500, retryable_statuses: [520, 521, 522, 523, 524], on_error: fn %{retryable: retryable} -> if retryable, do: :retry, else: :fail end, hooks: %{ before_tool: fn %{name: name} -> IO.puts("-> calling #{name}") && nil end }, store: InMemoryConversationStore.new(), on_metric: fn ev -> if ev.event == "llm", do: IO.puts("metric: llm status=#{ev.status}") end, request_params: %{"temperature" => 0.2}, max_part_bytes: 5_000_000 ) %Client{} = client true = client.max_turns == 6 IO.puts("ok: full-surface client created (max_turns=#{client.max_turns}, retryable_statuses=#{inspect(client.retryable_statuses)})") ``` ## See also [Section titled “See also”](#see-also) * [`Toolnexus.Client.run`](/api/elixir/client/run/) — Send a prompt, let the loop call tools until the model stops, get a RunResult. * [`Toolnexus.Client.stream`](/api/elixir/client/stream/) — The streaming loop: text deltas, tool-call events, and suspension events as they happen. * [`Toolnexus.Client.create` — hooks](/api/elixir/client/hooks/) — Intercept before/after model calls and tool calls: audit, redact, veto, or rewrite. * [`Toolnexus.Client conversation`](/api/elixir/client/conversation/) — Keep a transcript across turns so the model remembers what it already did. * [`Toolnexus.Client.create` — resilience](/api/elixir/client/resilience/) — Classify an LLM error into retry / fail, bound a run with a deadline, and cancel cleanly. * [`Toolnexus.Client.InMemoryConversationStore`](/api/elixir/client/store/) — Swap in-memory history for your own store so a conversation survives a process restart. * [`Toolnexus.Client.MetricsRegistry`](/api/elixir/client/metrics/) — Emit one metric event per model call and per tool call: tokens, latency, outcome. * [`Toolnexus.ProviderError`](/api/elixir/client/provider-error/) — The typed exception a non-2xx provider response raises, carrying status/body/retry\_after. * [`Toolnexus.create_toolkit`](/api/elixir/toolkit/create/) — Collapse MCP servers, skills, built-ins, A2A agents and your own functions into one tool list. # Toolnexus.Client.create > Intercept before/after model calls and tool calls: audit, redact, veto, or rewrite. — Elixir. **Elixir** · package `toolnexus` · [SPEC §8](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [elixir/lib/toolnexus/client.ex](https://github.com/muthuishere/toolnexus/blob/main/elixir/lib/toolnexus/client.ex) ```elixir Toolnexus.Client.create(hooks: %{ before_llm: (%{messages: [map()], tools: [map()], model: String.t(), turn: non_neg_integer()} -> map() | nil), after_llm: (%{response: map(), model: String.t(), turn: non_neg_integer()} -> any()), before_tool: (%{name: String.t(), args: map(), id: String.t(), turn: non_neg_integer()} -> map() | nil), after_tool: (%{name: String.t(), args: map(), result: Toolnexus.ToolResult.t(), id: String.t(), turn: non_neg_integer()} -> map() | nil) }) ``` There is no separate `Hooks` type — `hooks` is a plain map of up to four 1-arity functions passed to [`Toolnexus.Client.create/1`](/api/elixir/client/create/). All four are optional; an absent key behaves as a no-op. Every hook must be **1-arity** — a wrong-arity function raises `ArgumentError` immediately rather than silently never firing. ## When to use it [Section titled “When to use it”](#when-to-use-it) * **Guardrails** — deny a dangerous tool call in `before_tool` before it ever runs. * **Context compaction** — `before_llm` can replace `messages`/`tools` for the rest of the run (trim history, drop tools the model doesn’t need this turn). * **Cost / audit logging** — `after_llm` sees the raw provider response (it carries `usage`) on every turn; log it, don’t rewrite it. * **Redaction** — `after_tool` can replace a tool’s `result` before it enters the transcript, so a secret a tool returned never reaches the model or your logs. ## Why this and not the alternative [Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative) Reach for on\_metric when you only want to observe Hooks can **mutate and short-circuit**; [`:on_metric`](/api/elixir/client/metrics/) is observe-only and pre-shaped into per-call events (`llm`, `tool`, `run`). If all you want is “log every tool call with its duration”, `:on_metric` is less code and cannot accidentally change behavior. Reach for hooks when you need to actually change what happens — deny, rewrite, redact. Hooks and `:on_metric` reach the client identically whether set directly on `create/1` or forwarded by the §7D agent runtime — nothing is renamed, dropped, or reordered on that path. ## Examples [Section titled “Examples”](#examples) ### 1. `before_tool` — deny a tool call before it runs [Section titled “1. before\_tool — deny a tool call before it runs”](#1-before_tool--deny-a-tool-call-before-it-runs) Returning `%{result: %ToolResult{}}` short-circuits the tool: `execute` never runs, and the returned result is what the model sees instead. ```elixir alias Toolnexus.{Client, Context, Tool, ToolResult} deleted = fn -> raise "should never run" end delete_all = %Tool{ name: "delete_all", description: "Deletes everything", input_schema: %{"type" => "object", "properties" => %{}}, source: "native", execute: fn _args, %Context{} -> deleted.() end } plug = fn conn -> {:ok, _raw, conn} = Plug.Conn.read_body(conn) resp = %{ "choices" => [ %{ "message" => %{ "role" => "assistant", "content" => nil, "tool_calls" => [%{"id" => "c1", "type" => "function", "function" => %{"name" => "delete_all", "arguments" => "{}"}}] } } ], "usage" => %{"prompt_tokens" => 3, "completion_tokens" => 2, "total_tokens" => 5} } conn |> Plug.Conn.put_resp_content_type("application/json") |> Plug.Conn.send_resp(200, Jason.encode!(resp)) end client = Client.create( base_url: "http://localhost", style: "openai", model: "gpt-x", api_key: "test-key", http_options: [plug: plug], # a second turn would loop forever asking for the same tool, so cap it low — # the assertion below only needs the FIRST turn's denial. max_turns: 1, hooks: %{ before_tool: fn %{name: "delete_all"} -> %{result: ToolResult.error("denied: destructive tools are disabled")} end } ) result = Client.run(client, "delete everything", [delete_all]) true = result.status == "incomplete" [call] = result.tool_calls true = call.name == "delete_all" true = call.is_error true = call.output == "denied: destructive tools are disabled" IO.puts("ok: #{call.output}") ``` ### 2. `before_llm` — context compaction (rewrite messages and tools mid-run) [Section titled “2. before\_llm — context compaction (rewrite messages and tools mid-run)”](#2-before_llm--context-compaction-rewrite-messages-and-tools-mid-run) Returning `%{messages: ..., tools: ...}` replaces the working transcript/tool list **for the rest of the run** — the canonical use is trimming history or hiding tools the model doesn’t need this turn. The plug below echoes back what it actually received, so we can see the rewrite land on the wire. ```elixir alias Toolnexus.{Client, Context, Tool} noisy_tool = %Tool{ name: "noisy", description: "A tool we want hidden after turn 0", input_schema: %{"type" => "object", "properties" => %{}}, source: "native", execute: fn _args, %Context{} -> "noise" end } keep_tool = %Tool{ name: "keep", description: "A tool that should still reach the model", input_schema: %{"type" => "object", "properties" => %{}}, source: "native", execute: fn _args, %Context{} -> "kept" end } {:ok, seen} = Agent.start_link(fn -> [] end) plug = fn conn -> {:ok, raw, conn} = Plug.Conn.read_body(conn) body = Jason.decode!(raw) Agent.update(seen, fn s -> s ++ [body] end) resp = %{ "choices" => [%{"message" => %{"role" => "assistant", "content" => "done"}}], "usage" => %{"prompt_tokens" => 2, "completion_tokens" => 1, "total_tokens" => 3} } conn |> Plug.Conn.put_resp_content_type("application/json") |> Plug.Conn.send_resp(200, Jason.encode!(resp)) end client = Client.create( base_url: "http://localhost", style: "openai", model: "gpt-x", api_key: "test-key", http_options: [plug: plug], hooks: %{ before_llm: fn %{tools: tools} -> # drop "noisy" from what the model is shown this turn %{tools: Enum.reject(tools, &(&1["function"]["name"] == "noisy"))} end } ) _result = Client.run(client, "hi", [noisy_tool, keep_tool]) [sent] = Agent.get(seen, & &1) true = length(sent["tools"]) == 1 true = hd(sent["tools"])["function"]["name"] == "keep" IO.puts("ok: before_llm dropped 1 of 2 tools before they hit the wire") ``` ### 3. `after_llm` + `after_tool` — audit the raw response, redact a tool’s output [Section titled “3. after\_llm + after\_tool — audit the raw response, redact a tool’s output”](#3-after_llm--after_tool--audit-the-raw-response-redact-a-tools-output) `after_llm` is observe-only (its return value is ignored) but sees the **raw provider payload**, including `usage`. `after_tool` can transform a result before it joins the transcript. ```elixir alias Toolnexus.{Client, Context, Tool, ToolResult} secret_tool = %Tool{ name: "whoami", description: "Returns account info", input_schema: %{"type" => "object", "properties" => %{}}, source: "native", execute: fn _args, %Context{} -> ToolResult.ok("api_key=sk-super-secret-value") end } {:ok, audit} = Agent.start_link(fn -> [] end) {:ok, calls} = Agent.start_link(fn -> 0 end) plug = fn conn -> {:ok, _raw, conn} = Plug.Conn.read_body(conn) n = Agent.get_and_update(calls, fn c -> {c + 1, c + 1} end) resp = if n == 1 do %{ "choices" => [ %{ "message" => %{ "role" => "assistant", "content" => nil, "tool_calls" => [%{"id" => "c1", "type" => "function", "function" => %{"name" => "whoami", "arguments" => "{}"}}] } } ], "usage" => %{"prompt_tokens" => 4, "completion_tokens" => 2, "total_tokens" => 6} } else %{ "choices" => [%{"message" => %{"role" => "assistant", "content" => "ok, redacted"}}], "usage" => %{"prompt_tokens" => 3, "completion_tokens" => 2, "total_tokens" => 5} } end conn |> Plug.Conn.put_resp_content_type("application/json") |> Plug.Conn.send_resp(200, Jason.encode!(resp)) end client = Client.create( base_url: "http://localhost", style: "openai", model: "gpt-x", api_key: "test-key", http_options: [plug: plug], hooks: %{ after_llm: fn %{response: response, turn: turn} -> Agent.update(audit, fn a -> a ++ [{turn, response["usage"]["total_tokens"]}] end) end, after_tool: fn %{name: "whoami", result: %ToolResult{} = r} -> %{result: %ToolResult{r | output: "api_key=[REDACTED]"}} end } ) result = Client.run(client, "who am I?", [secret_tool]) true = result.text == "ok, redacted" [call] = result.tool_calls true = call.output == "api_key=[REDACTED]" audited = Agent.get(audit, & &1) true = audited == [{0, 6}, {1, 5}] IO.puts("ok: redacted output=#{call.output}, audited #{length(audited)} llm call(s)") ``` Wrong arity fails loudly A `hooks.before_llm`/etc that is not a 1-arity function raises `ArgumentError` the first time the loop tries to call it — it never silently falls through to “hook absent” behavior. ## Hook shapes [Section titled “Hook shapes”](#hook-shapes) | Hook | Receives | Return to act on it | Effect | | -------------- | --------------------------------- | ---------------------------------------------- | ------------------------------------------------------------------------ | | `:before_llm` | `%{messages, tools, model, turn}` | `%{messages: ..., tools: ...}` | Replaces the working transcript/tool list for the rest of the run. | | `:after_llm` | `%{response, model, turn}` | (ignored) | Observe the raw provider payload — logging, cost, tracing. | | `:before_tool` | `%{name, args, id, turn, hints}` | `%{result: %ToolResult{}}` or `%{args: map()}` | Short-circuits the tool (deny/cache/dry-run), or rewrites its arguments. | | `:after_tool` | `%{name, args, result, id, turn}` | `%{result: %ToolResult{}}` | Transforms the result (redact, annotate) before it joins the transcript. | ## Permission gates and tool hints [Section titled “Permission gates and tool hints”](#permission-gates-and-tool-hints) `before_tool` events carry the tool’s `hints` — `%Toolnexus.ToolHints{read_only, destructive, open_world}`, or `nil` when unknown (treat as mutating). Every builtin sets them (`read`, `grep`, `glob`, `question`, `todowrite` read-only; `webfetch` read-only and open-world; `write`, `edit`, `apply_patch` destructive; `bash` destructive and open-world), MCP tools map their annotations, and your own `%Tool{hints: ...}` is passed through. Hints are advisory and never sent to the model. A permission gate is a `before_tool` hook, there is no separate option: return `nil` to allow, `%{result: %ToolResult{is_error: true, ...}}` to deny (the model sees the error and the run continues), or a pending result to ask (resolved by `:wait_for` / `:wait_for_ctx`). Under `:tool_tiers`, a `call_tool` call is unwrapped first, so the gate sees the real tool. ```elixir hooks: %{ before_tool: fn ev -> unless ev.hints && ev.hints.read_only, do: %{result: %Toolnexus.ToolResult{output: "denied: plan mode allows read-only tools only", is_error: true}} end } ``` ## See also [Section titled “See also”](#see-also) * [`Toolnexus.Client.create`](/api/elixir/client/create/) — the unified client: system prompt, skills injection, parallel and chained tool calls, retries, memory. * [`Toolnexus.Client.run`](/api/elixir/client/run/) — send a prompt, let the loop call tools until the model stops, get a RunResult. * [`Toolnexus.Client.metrics`](/api/elixir/client/metrics/) — observe-only per-call events, pre-shaped, no mutation. * [`Toolnexus.Client.resilience`](/api/elixir/client/resilience/) — `:on_error` — the other host-classification seam, scoped to LLM failures. # 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. — Elixir. **Elixir** · package `toolnexus` · [SPEC §8](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) ```elixir @spec list_models(keyword() | map()) :: {:ok, [%{id: String.t(), name: String.t() | nil, context_length: integer() | nil}]} | {:error, String.t()} def list_models(opts) # the same options as Client.create/1 ``` One GET, no retries. openai style: `GET {base_url}/models` (OpenRouter’s `context_length` is read when present). anthropic style: `GET {base_url}/v1/models`, following `has_more` / `last_id` pages. Results are sorted by id; `name` and `context_length` are `nil` when the provider does not report them. The key follows the [API-key-by-host](/api/elixir/client/api-key/) rule; a missing key, an HTTP error or a bad body is `{:error, message}`, never a raise. ## When to use it [Section titled “When to use it”](#when-to-use-it) * **A model switcher** in a host UI — list what the configured endpoint really serves. * **Sizing a context budget** — OpenRouter reports `context_length`, which feeds `tool_tiers`’ `context_window` or a compactor threshold. * **A startup health check** — a cheap authenticated call that fails fast on a bad key. ## Why this and not the alternative [Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative) Not a hand-rolled Req.get A direct `Req.get` would have to repeat the key-by-host rule, the anthropic pagination and the response normalisation. `list_models/1` reuses the client’s options, `:transport` and `:http_options`, so a test stub for the client also covers it. ## Examples [Section titled “Examples”](#examples) ### 1. The smallest call [Section titled “1. The smallest call”](#1-the-smallest-call) ```elixir t = fn _req -> {:ok, %{status: 200, headers: %{}, body: %{"data" => [%{"id" => "gpt-b"}, %{"id" => "gpt-a"}]}}} end {:ok, models} = Toolnexus.Client.list_models(base_url: "https://api.example/v1", api_key: "YOUR_KEY_HERE", transport: t) ["gpt-a", "gpt-b"] = Enum.map(models, & &1.id) IO.puts("ok: #{length(models)} models") ``` ### 2. OpenRouter’s context length [Section titled “2. OpenRouter’s context length”](#2-openrouters-context-length) ```elixir t = fn _req -> {:ok, %{status: 200, headers: %{}, body: %{"data" => [%{"id" => "z/model", "name" => "Z", "context_length" => 200_000}]}}} end {:ok, [m]} = Toolnexus.Client.list_models(base_url: "https://router.example/api/v1", api_key: "YOUR_KEY_HERE", transport: t) true = m == %{id: "z/model", name: "Z", context_length: 200_000} IO.puts("ok: #{m.id} has #{m.context_length} tokens") ``` ### 3. Anthropic pagination and errors [Section titled “3. Anthropic pagination and errors”](#3-anthropic-pagination-and-errors) ```elixir me = self() t = fn req -> send(me, {:url, req.url}) body = if String.contains?(req.url, "after_id"), do: %{"data" => [%{"id" => "claude-a", "display_name" => "A"}], "has_more" => false}, else: %{"data" => [%{"id" => "claude-b", "display_name" => "B"}], "has_more" => true, "last_id" => "claude-b"} {:ok, %{status: 200, headers: %{}, body: body}} end {:ok, [a, b]} = Toolnexus.Client.list_models(base_url: "https://api.example/v1", style: "anthropic", api_key: "YOUR_KEY_HERE", transport: t) true = a.id == "claude-a" and b.name == "B" denied = fn _ -> {:ok, %{status: 401, headers: %{}, body: ~s({"error":"bad"})}} end {:error, msg} = Toolnexus.Client.list_models(base_url: "https://api.example", api_key: "YOUR_KEY_HERE", transport: denied) true = msg =~ "HTTP 401" IO.puts("ok: two pages, then #{msg}") ``` ## See also [Section titled “See also”](#see-also) * [`Toolnexus.Client.create`](/api/elixir/client/create/) — The unified client: system prompt, skills injection, parallel and chained tool calls, retries, memory. * [`Toolnexus.Client.run`](/api/elixir/client/run/) — Send a prompt, let the loop call tools until the model stops, get a RunResult. * [`Toolnexus.Client.stream`](/api/elixir/client/stream/) — The streaming loop: text deltas, tool-call events, and suspension events as they happen. * [`Toolnexus.Client.create`](/api/elixir/client/hooks/) — Intercept before/after model calls and tool calls: audit, redact, veto, or rewrite. # Toolnexus.Client.MetricsRegistry > Emit one metric event per model call and per tool call: tokens, latency, outcome. — Elixir. **Elixir** · package `toolnexus` · [SPEC §8](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [elixir/lib/toolnexus/client.ex](https://github.com/muthuishere/toolnexus/blob/main/elixir/lib/toolnexus/client.ex) ```elixir # Client.create/1 options: # on_metric: (event_map() -> any()) # forward anywhere — statsd, logs, OTel # registry: pid() | nil # share ONE registry across many clients @spec metrics(Toolnexus.Client.t()) :: String.t() def metrics(client) # Prometheus text exposition — byte-identical across all six ports ``` Two outputs from one internal instrumentation, both opt-in and zero-cost when unused. `:on_metric` receives a readable event map at every LLM call, tool call, and run — forward it anywhere. `Client.metrics/1` accumulates those same events into a tiny in-memory registry and renders them as **Prometheus text exposition format** — no third-party dependency, and its *text* is byte-identical across every toolnexus port. ## When to use it [Section titled “When to use it”](#when-to-use-it) * **You already scrape Prometheus** — mount `Client.metrics(client)` at `GET /metrics` and you’re done; no exporter library, no cardinality surprises (labels are bounded — no per-request `id`). * **You want events, not counters** — `:on_metric` gets a semantic record (`event: "llm"` / `"tool"` / `"run"`) per significant point; pipe it into statsd, structured logs, or your own OTel span. * **Several clients should share one scrape endpoint** — pass a `MetricsRegistry` pid you created yourself as `:registry` and every client using it accumulates into the same counters. ## Why this and not the alternative [Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative) Reach for hooks when you need to change behavior, not just observe it `:on_metric` and `Client.metrics/1` are strictly observational — nothing you do with an event can change what the client does next. [`hooks`](/api/elixir/client/hooks/) is the seam for that (deny a tool, rewrite a request, redact a result). Use metrics for “what happened”, hooks for “what should happen”. ## Examples [Section titled “Examples”](#examples) ### 1. The smallest useful call — `metrics/1` before any activity [Section titled “1. The smallest useful call — metrics/1 before any activity”](#1-the-smallest-useful-call--metrics1-before-any-activity) Valid Prometheus text renders even with zero traffic: just the `# HELP`/`# TYPE` header lines, in a fixed order, ending with a trailing newline. ```elixir alias Toolnexus.Client client = Client.create(base_url: "http://localhost", style: "openai", model: "gpt-x", api_key: "k") text = Client.metrics(client) true = String.starts_with?(text, "# HELP toolnexus_llm_requests_total Total LLM requests.\n") true = String.contains?(text, "# TYPE toolnexus_tool_calls_total counter") true = String.ends_with?(text, "\n") # No series lines yet — only the metric headers. false = String.contains?(text, "toolnexus_llm_requests_total{") IO.puts("ok: #{text |> String.split("\n") |> length()} header lines before any traffic") ``` ### 2. A realistic case — scrape after a run with a tool call [Section titled “2. A realistic case — scrape after a run with a tool call”](#2-a-realistic-case--scrape-after-a-run-with-a-tool-call) ```elixir alias Toolnexus.{Client, Context, Tool, ToolResult} add_tool = %Tool{ name: "add", description: "Add two numbers", input_schema: %{"type" => "object", "properties" => %{}}, source: "native", execute: fn args, %Context{} -> ToolResult.ok(to_string(trunc(args["a"] + args["b"]))) end } {:ok, calls} = Agent.start_link(fn -> 0 end) plug = fn conn -> {:ok, _raw, conn} = Plug.Conn.read_body(conn) n = Agent.get_and_update(calls, fn c -> {c + 1, c + 1} end) resp = if n == 1 do %{ "choices" => [ %{ "message" => %{ "role" => "assistant", "content" => nil, "tool_calls" => [%{"id" => "c1", "type" => "function", "function" => %{"name" => "add", "arguments" => ~s({"a":2,"b":3})}}] } } ], "usage" => %{"prompt_tokens" => 6, "completion_tokens" => 4, "total_tokens" => 10} } else %{"choices" => [%{"message" => %{"role" => "assistant", "content" => "5"}}], "usage" => %{"prompt_tokens" => 4, "completion_tokens" => 1, "total_tokens" => 5}} end conn |> Plug.Conn.put_resp_content_type("application/json") |> Plug.Conn.send_resp(200, Jason.encode!(resp)) end client = Client.create(base_url: "http://localhost", style: "openai", model: "gpt-x", api_key: "test-key", http_options: [plug: plug]) _result = Client.run(client, "add 2 and 3", [add_tool]) text = Client.metrics(client) true = String.contains?(text, ~s(toolnexus_llm_requests_total{model="gpt-x",status="ok"} 2)) true = String.contains?(text, ~s(toolnexus_tool_calls_total{tool="add",source="native",is_error="false",pending="false"} 1)) true = String.contains?(text, "toolnexus_llm_tokens_total{type=\"prompt\"}") IO.puts("ok: scraped after 2 LLM calls + 1 tool call") ``` ### 3. The full surface — `:on_metric` forwarding, and a shared `:registry` [Section titled “3. The full surface — :on\_metric forwarding, and a shared :registry”](#3-the-full-surface--on_metric-forwarding-and-a-shared-registry) `:registry` lets several clients accumulate into one `MetricsRegistry` — pass it explicitly and `Client.metrics/1` on either client renders the combined totals. ```elixir alias Toolnexus.Client alias Toolnexus.Client.MetricsRegistry {:ok, forwarded} = Agent.start_link(fn -> [] end) shared_registry = MetricsRegistry.new() plug = fn conn -> {:ok, _raw, conn} = Plug.Conn.read_body(conn) resp = %{"choices" => [%{"message" => %{"role" => "assistant", "content" => "hi"}}], "usage" => %{"prompt_tokens" => 1, "completion_tokens" => 1, "total_tokens" => 2}} conn |> Plug.Conn.put_resp_content_type("application/json") |> Plug.Conn.send_resp(200, Jason.encode!(resp)) end on_metric = fn ev -> Agent.update(forwarded, fn f -> f ++ [ev.event] end) end client_a = Client.create(base_url: "http://localhost", style: "openai", model: "a", api_key: "k", http_options: [plug: plug], on_metric: on_metric, registry: shared_registry) client_b = Client.create(base_url: "http://localhost", style: "openai", model: "b", api_key: "k", http_options: [plug: plug], on_metric: on_metric, registry: shared_registry) _ = Client.run(client_a, "hi", []) _ = Client.run(client_b, "hi", []) # Both clients' calls landed in the SAME registry. combined = Client.metrics(client_a) true = combined == Client.metrics(client_b) true = String.contains?(combined, ~s(toolnexus_llm_requests_total{model="a",status="ok"} 1)) true = String.contains?(combined, ~s(toolnexus_llm_requests_total{model="b",status="ok"} 1)) # on_metric saw every "llm" and "run" event from both clients, in order. true = Agent.get(forwarded, & &1) == ["llm", "run", "llm", "run"] IO.puts("ok: 2 clients, 1 registry, #{length(Agent.get(forwarded, & &1))} forwarded events") ``` ## `on_metric` event shapes [Section titled “on\_metric event shapes”](#on_metric-event-shapes) | `event` | Fields | Fires | | -------- | -------------------------------------------------------------------------------- | ---------------------------------------------------------------- | | `"llm"` | `model`, `status` (`"ok"`/`"error"`), `ms`, `prompt_tokens`, `completion_tokens` | Once per LLM call. | | `"tool"` | `tool`, `source`, `is_error`, `ms`, `pending?` | Once per tool call (including a suspended one, `pending: true`). | | `"run"` | `model`, `turns`, `tool_calls`, `total_tokens`, `ms`, `error?` | Once per `run`/`ask`/`stream`. | ## Prometheus series (`Client.metrics/1`) [Section titled “Prometheus series (Client.metrics/1)”](#prometheus-series-clientmetrics1) | Metric | Labels | Type | | ---------------------------------------- | --------------------------------------- | --------- | | `toolnexus_llm_requests_total` | `model`, `status` | counter | | `toolnexus_llm_tokens_total` | `type` (`prompt`/`completion`) | counter | | `toolnexus_llm_request_duration_seconds` | `model` | histogram | | `toolnexus_tool_calls_total` | `tool`, `source`, `is_error`, `pending` | counter | | `toolnexus_tool_duration_seconds` | `tool` | histogram | | `toolnexus_run_errors_total` | `model` | counter | Histogram buckets (seconds): `[0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10, 30, 60, +Inf]`. Series within a metric are sorted lexicographically by rendered label string — this ordering, the bucket set, and label escaping are all part of the byte-identical contract. ## See also [Section titled “See also”](#see-also) * [`Toolnexus.Client.create`](/api/elixir/client/create/) — `:on_metric` and `:registry` are options here. * [`Toolnexus.Client.create`](/api/elixir/client/hooks/) — the seam for changing behavior, not just observing it. * [`Toolnexus.Client.run`](/api/elixir/client/run/) · [`Toolnexus.Client.stream`](/api/elixir/client/stream/) — both emit the same events. # prompt_cache > Mark the stable prompt prefix for the provider (Anthropic cache breakpoints, OpenAI prompt_cache_key) and read cacheRead/cacheWrite tokens from usage. — Elixir. **Elixir** · package `toolnexus` · [SPEC §8](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) ```elixir Toolnexus.Client.create( ..., prompt_cache: %{key: String.t() | nil, long_ttl: boolean()} | nil ) # usage gains, only when the provider reports a non-zero count: %{prompt_tokens: _, completion_tokens: _, total_tokens: _, cache_read_tokens: non_neg_integer(), cache_write_tokens: non_neg_integer()} ``` Marks the stable request prefix for the provider’s prompt cache (`elixir/lib/toolnexus/prompt_cache.ex`). Under **anthropic** style the request gets `cache_control: %{"type" => "ephemeral"}` (plus `"ttl" => "1h"` with `long_ttl: true`) 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 **openai** style it sets `prompt_cache_key` to `:key`. Gemini: nothing yet (explicit `cachedContent` is deferred). It is applied before `:request_params` and `:body_transform`, so both can still override it; the transcript itself is never changed. Absent ⇒ the request body is byte-identical to before. Usage reports `cache_read_tokens` (anthropic `cache_read_input_tokens`, openai `prompt_tokens_details.cached_tokens`) and `cache_write_tokens` (anthropic `cache_creation_input_tokens`). The keys are **absent** when the provider reports no cache, so uncached usage maps are unchanged. Context size under anthropic Anthropic’s `input_tokens` excludes cached tokens, so the real context size is `prompt_tokens + cache_read_tokens + cache_write_tokens`. Under openai, `prompt_tokens` already includes `cache_read_tokens`. A UI that shows context use from `prompt_tokens` alone under-reports a cached anthropic session. ## When to use it [Section titled “When to use it”](#when-to-use-it) * **Long sessions with a big, fixed prefix** — many tools, a long system prompt, a skills catalog: every turn re-sends the same bytes, and the cache bills them at a fraction. * **You want to see whether the cache works** — `cache_read_tokens` per run makes a drop in hits visible. * **Pair it with [`tool_tiers`](/api/elixir/client/tool-tiers/)** when tools are added mid-session: a changed tools array breaks the cached prefix, tool tiers keep it fixed. ## Why this and not the alternative [Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative) Not :request\_params You could inject `cache_control` yourself through `:body_transform`, but you would have to find the last tool, convert a string system prompt into a block and re-mark the latest message on every turn. `:prompt_cache` does exactly that and nothing else. Keep `:body_transform` for the provider fields toolnexus does not model. ## Examples [Section titled “Examples”](#examples) ### 1. OpenAI: a cache key, and the cached-token count [Section titled “1. OpenAI: a cache key, and the cached-token count”](#1-openai-a-cache-key-and-the-cached-token-count) ```elixir alias Toolnexus.Client me = self() t = fn req -> send(me, {:key, req.body["prompt_cache_key"]}) {:ok, %{status: 200, headers: %{}, body: %{"choices" => [%{"message" => %{"role" => "assistant", "content" => "hi"}}], "usage" => %{"prompt_tokens" => 100, "completion_tokens" => 5, "prompt_tokens_details" => %{"cached_tokens" => 80}}}}} end c = Client.create(base_url: "https://api.example/v1", model: "m", api_key: "YOUR_KEY_HERE", transport: t, prompt_cache: %{key: "session-1"}) r = Client.run(c, "x", []) receive do: ({:key, "session-1"} -> :ok) true = r.usage.cache_read_tokens == 80 and r.usage.prompt_tokens == 100 false = Map.has_key?(r.usage, :cache_write_tokens) IO.puts("ok: openai cached #{r.usage.cache_read_tokens} of #{r.usage.prompt_tokens}") ``` ### 2. Anthropic: three breakpoints and the 1-hour lifetime [Section titled “2. Anthropic: three breakpoints and the 1-hour lifetime”](#2-anthropic-three-breakpoints-and-the-1-hour-lifetime) ```elixir alias Toolnexus.Client me = self() t = fn req -> send(me, {:body, req.body}) {:ok, %{status: 200, headers: %{}, body: %{"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" => 50}}}} end tool = Toolnexus.define_tool(name: "lookup", description: "Look a thing up.", execute: fn _ -> "x" end) c = Client.create(base_url: "https://api.example", style: "anthropic", model: "m", api_key: "YOUR_KEY_HERE", system_prompt: "You are terse.", transport: t, prompt_cache: %{long_ttl: true}) r = Client.run(c, "hi", [tool]) body = receive do: ({:body, b} -> b) mark = %{"type" => "ephemeral", "ttl" => "1h"} true = List.last(body["tools"])["cache_control"] == mark [%{"type" => "text", "text" => _, "cache_control" => ^mark}] = body["system"] true = List.last(List.last(body["messages"])["content"])["cache_control"] == mark # Real context size under anthropic: input + cache read + cache write. true = r.usage.prompt_tokens + r.usage.cache_read_tokens + r.usage.cache_write_tokens == 960 IO.puts("ok: 3 breakpoints, context #{960} tokens") ``` ### 3. Off is byte-identical [Section titled “3. Off is byte-identical”](#3-off-is-byte-identical) ```elixir alias Toolnexus.Client me = self() t = fn req -> send(me, {:body, req.body}) {:ok, %{status: 200, headers: %{}, body: %{"content" => [%{"type" => "text", "text" => "ok"}], "stop_reason" => "end_turn", "usage" => %{"input_tokens" => 3, "output_tokens" => 1}}}} end c = Client.create(base_url: "https://api.example", style: "anthropic", model: "m", api_key: "YOUR_KEY_HERE", system_prompt: "S", transport: t) r = Client.run(c, "hi", []) body = receive do: ({:body, b} -> b) true = is_binary(body["system"]) false = Jason.encode!(body) =~ "cache_control" false = Map.has_key?(r.usage, :cache_read_tokens) IO.puts("ok: no prompt_cache, no cache_control") ``` ## See also [Section titled “See also”](#see-also) * [`Toolnexus.Client.create`](/api/elixir/client/create/) — The unified client: system prompt, skills injection, parallel and chained tool calls, retries, memory. * [`Toolnexus.Client.run`](/api/elixir/client/run/) — Send a prompt, let the loop call tools until the model stops, get a RunResult. * [`Toolnexus.Client.stream`](/api/elixir/client/stream/) — The streaming loop: text deltas, tool-call events, and suspension events as they happen. * [`Toolnexus.Client.create`](/api/elixir/client/hooks/) — Intercept before/after model calls and tool calls: audit, redact, veto, or rewrite. # Toolnexus.ProviderError > 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. — Elixir. **Elixir** · package `toolnexus` · [SPEC §8](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) ```elixir defexception [:status, :body, :retry_after, :message] @type t :: %Toolnexus.ProviderError{ status: non_neg_integer() | nil, body: term(), retry_after: String.t() | nil, message: String.t() } # raised as: raise Toolnexus.ProviderError, status: status, body: body, retry_after: retry_after ``` 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. `Toolnexus.ProviderError` is a `defexception` (`elixir/lib/toolnexus/errors.ex:20-58`), so a caller `rescue`s it like any other Elixir exception. Its four fields are set through the standard `exception/1` callback (`errors.ex:58`), which is what every raise site in `elixir/lib/toolnexus/client.ex` (e.g. `client.ex:1273`) actually calls — construction is never done with `%ProviderError{...}` struct literals directly, because the callback is where redaction happens. ## When to use it [Section titled “When to use it”](#when-to-use-it) * **You need to branch on the HTTP status a provider returned** — `429` to back off harder than the built-in retry policy, `401`/`403` to prompt for a new key, anything else to surface as a hard failure. `status` is the typed field; nothing here requires parsing a message string. * **You want the `Retry-After` signal without digging through headers** — `retry_after` carries the header **verbatim**, exactly as in every other port (see the note below; the client’s own retry loop parses it separately and consumes it before a caller ever sees the exception — see [`Toolnexus.Client.create`](/api/elixir/client/resilience/)). * **You want to log or display the body safely** — `body` is already redacted (never raw), so a caller can log it, show it in an error banner, or ship it to an error tracker without a second redaction pass. ## 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**, or `nil` 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 null out 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 parsed seconds here — see the changelog. A 200-char cap that is inlined, not a named constant — a real, minor drift Every other port that carries this exact error names its 200-character message cap as a module constant — `ERROR_BODY_CAP` (js/java/csharp), `MAX_ERROR_BODY` (python), `providerBodyCap` (go). Elixir’s cap is the bare integer `200` inlined inside `cause/2` (`errors.ex:80-90`) — there is no `@error_body_cap` attribute to point at. This is a genuine, small cross-port inconsistency, not a documentation gap: the *behavior* (redact, then cap the **message** at 200 bytes, `body` itself stays uncapped) is identical to every other port, only the Elixir source spells the number inline rather than through a named attribute. Redaction (ADR 0027, `docs/adr/0027-*.md`) runs before the cap, never after: `@account_keys` (`user_id`, `account_id`, `org_id`, `organization`, `errors.ex:47`) are replaced with the constant `@redacted` token `«redacted»` (`errors.ex:46`) inside `redact/1`, and only *then* does `cause/2` truncate the rendered message to 200 bytes with an ellipsis. A `401`/`403` body is never echoed at all — a gateway in front of the real provider often reflects the `Authorization` header back verbatim, so those two statuses render no cause text regardless of what the body contains. Not a suspension A `ProviderError` is raised, never returned as a §10 `Pending` — an LLM outage is not something a human resolves by answering a question. If you want a failed run to pause-and-resume instead of crashing the caller, catch the raise above this layer; see [`Toolnexus.Client.create`](/api/elixir/client/resilience/) for the retry/backoff policy that runs *before* this exception is ever raised. ## Examples [Section titled “Examples”](#examples) ### 1. The smallest useful call — rescue and read the typed fields [Section titled “1. The smallest useful call — rescue and read the typed fields”](#1-the-smallest-useful-call--rescue-and-read-the-typed-fields) ```elixir alias Toolnexus.{Client, ProviderError} plug = fn conn -> {:ok, _raw, conn} = Plug.Conn.read_body(conn) conn |> Plug.Conn.put_resp_content_type("application/json") |> Plug.Conn.send_resp(400, ~s({"error":"bad request","user_id":"u_123"})) end client = Client.create( base_url: "http://localhost", style: "openai", model: "gpt-x", api_key: "test-key", retries: 0, http_options: [plug: plug] ) result = try do Client.run(client, "hi", []) :not_raised rescue e in ProviderError -> {:raised, e.status, e.body} end {:raised, 400, body} = result # The response was auto-decoded to a map — redaction walks it by key, replacing the # VALUE at the known account-identifier key, not scanning every string for the pattern. "«redacted»" = body["user_id"] false = inspect(body) =~ "u_123" IO.puts("ok: ProviderError status=400 body=#{inspect(body)}") ``` ### 2. `retry_after` — the raw header, verbatim [Section titled “2. retry\_after — the raw header, verbatim”](#2-retry_after--the-raw-header-verbatim) ```elixir alias Toolnexus.{Client, ProviderError} plug = fn conn -> {:ok, _raw, conn} = Plug.Conn.read_body(conn) conn |> Plug.Conn.put_resp_header("retry-after", "30") |> Plug.Conn.put_resp_content_type("application/json") |> Plug.Conn.send_resp(429, ~s({"error":"rate limited"})) end client = Client.create( base_url: "http://localhost", style: "openai", model: "gpt-x", api_key: "test-key", retries: 0, http_options: [plug: plug] ) {:raised, "30"} = try do Client.run(client, "hi", []) :not_raised rescue e in ProviderError -> {:raised, e.retry_after} end IO.puts("ok: ProviderError retry_after=\"30\"") ``` ### 3. `401`/`403` never echo the body, no matter what it contains [Section titled “3. 401/403 never echo the body, no matter what it contains”](#3-401403-never-echo-the-body-no-matter-what-it-contains) ```elixir alias Toolnexus.{Client, ProviderError} plug = fn conn -> {:ok, _raw, conn} = Plug.Conn.read_body(conn) conn |> Plug.Conn.put_resp_content_type("application/json") |> Plug.Conn.send_resp(401, ~s({"error":"Authorization: Bearer sk-super-secret-key"})) end client = Client.create( base_url: "http://localhost", style: "openai", model: "gpt-x", api_key: "test-key", retries: 0, http_options: [plug: plug] ) message = try do Client.run(client, "hi", []) "not raised" rescue e in ProviderError -> e.message end true = message == "LLM 401" false = message =~ "sk-super-secret-key" IO.puts("ok: #{message}") ``` ## See also [Section titled “See also”](#see-also) * [`Toolnexus.Client.create`](/api/elixir/client/create/) — The unified client: system prompt, skills injection, parallel and chained tool calls, retries, memory. * [`Toolnexus.Client.run`](/api/elixir/client/run/) — Send a prompt, let the loop call tools until the model stops, get a RunResult. * [`Toolnexus.Client.stream`](/api/elixir/client/stream/) — The streaming loop: text deltas, tool-call events, and suspension events as they happen. * [`Toolnexus.Client.create`](/api/elixir/client/hooks/) — Intercept before/after model calls and tool calls: audit, redact, veto, or rewrite. * [`Toolnexus.Client.create` — resilience](/api/elixir/client/resilience/) — Classify an LLM error into retry / fail, bound a run with a deadline; the retry policy that runs before a `ProviderError` ever reaches the caller. # Toolnexus.Client.create > Classify an LLM error into retry / fail, bound a run with a deadline, and cancel cleanly. — Elixir. **Elixir** · package `toolnexus` · [SPEC §8](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [elixir/lib/toolnexus/client.ex](https://github.com/muthuishere/toolnexus/blob/main/elixir/lib/toolnexus/client.ex) ```elixir Toolnexus.Client.create( retries: 2, # default. Bounds every retry-vs-fail decision. retry_base_ms: 500, # default. Exponential backoff base (+ jitter), honors Retry-After. timeout_ms: nil, # default. Whole-run deadline; nil = no deadline. retryable_statuses: nil, # default. Extra retryable statuses, ADDED to the default set. on_error: (%{error: Exception.t() | nil, status: integer() | nil, attempt: non_neg_integer(), retryable: boolean()} -> :retry | :fail) ) ``` There is no separate resilience type — retry/timeout behavior lives on five [`Toolnexus.Client.create/1`](/api/elixir/client/create/) options. `on_error` is the one host-classification seam: it decides, per failed LLM attempt, whether the client retries or gives up immediately. ## When to use it [Section titled “When to use it”](#when-to-use-it) * **The defaults are usually right** — `429`/`500`/`502`/`503`/`504`/`529` and network errors retry with exponential backoff + jitter, honoring a `Retry-After` header; anything else fails immediately. That list is exhaustive, not “429 plus any 5xx” — a `501`, a `507` or a Cloudflare `520` is terminal by default. Most callers never touch `on_error`. * **Your upstream returns a transient status outside that set** — `retryable_statuses` lists them, the classic case being a Cloudflare-fronted origin answering `520`–`527`: ```elixir {:ok, client} = Toolnexus.Client.create( model: model, api_key: api_key, retryable_statuses: [520, 521, 522, 523, 524, 525, 526, 527] ) ``` It is **additive to the defaults, never a replacement** — it can only make more statuses retryable, never fewer, so setting it cannot accidentally drop `429` and lose `Retry-After` handling with it. It sets the *default classification* only: `on_error` still runs on every failed attempt and has the final say, so an `on_error` returning `:fail` overrides a status you listed yourself. `Retry-After` handling is unaffected. * **You need a different retry policy** — e.g. treat `429` (rate limit) as an immediate fail because your caller already backs off at a higher layer, or extend retryability to a provider-specific status your upstream returns. * **You need a hard ceiling on total run time** — `timeout_ms` bounds the whole `run`/`stream`, not just one HTTP call; a run past its deadline raises before the next attempt is even made. ## Why this and not the alternative [Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative) Not a suspension A retry-exhausted failure **raises** — it never becomes a §10 `Pending`. Suspension (`wait_for`) is for a tool that needs a *user action* to continue; an LLM outage is not a user action, so there is deliberately no `:suspend` tier here. If you want a failed run to pause-and-resume instead of raising, that has to happen above this layer (catch the raise, then re-`run/4` with `history:` once whatever was wrong is fixed). `on_error` only ever sees LLM-call failures — tool failures are a `%ToolResult{is_error: true}`, handled entirely differently (they flow into the transcript, they never retry the wire call). ## Examples [Section titled “Examples”](#examples) Every example stubs the wire call with `http_options: [plug: fn conn -> ... end]` and sets `retry_base_ms: 1` so backoff doesn’t slow the page down. ### 1. The default classifier — retry on 500, then succeed [Section titled “1. The default classifier — retry on 500, then succeed”](#1-the-default-classifier--retry-on-500-then-succeed) ```elixir alias Toolnexus.Client {:ok, attempts} = Agent.start_link(fn -> 0 end) plug = fn conn -> {:ok, _raw, conn} = Plug.Conn.read_body(conn) n = Agent.get_and_update(attempts, fn c -> {c + 1, c + 1} end) if n == 1 do conn |> Plug.Conn.put_resp_content_type("application/json") |> Plug.Conn.send_resp(500, ~s({"error":"upstream hiccup"})) else resp = %{"choices" => [%{"message" => %{"role" => "assistant", "content" => "ok now"}}], "usage" => %{"prompt_tokens" => 3, "completion_tokens" => 2, "total_tokens" => 5}} conn |> Plug.Conn.put_resp_content_type("application/json") |> Plug.Conn.send_resp(200, Jason.encode!(resp)) end end client = Client.create( base_url: "http://localhost", style: "openai", model: "gpt-x", api_key: "test-key", retry_base_ms: 1, http_options: [plug: plug] ) result = Client.run(client, "hi", []) true = result.text == "ok now" true = Agent.get(attempts, & &1) == 2 IO.puts("ok: succeeded after #{Agent.get(attempts, & &1)} attempts (1 retry)") ``` ### 2. Custom `on_error` — treat 429 as an immediate fail [Section titled “2. Custom on\_error — treat 429 as an immediate fail”](#2-custom-on_error--treat-429-as-an-immediate-fail) The default classifier retries `429`. A caller that already rate-limits upstream may want the opposite: fail fast and let its own backoff handle it. ```elixir alias Toolnexus.Client {:ok, attempts} = Agent.start_link(fn -> 0 end) plug = fn conn -> {:ok, _raw, conn} = Plug.Conn.read_body(conn) Agent.update(attempts, &(&1 + 1)) conn |> Plug.Conn.put_resp_content_type("application/json") |> Plug.Conn.send_resp(429, ~s({"error":"rate limited"})) end client = Client.create( base_url: "http://localhost", style: "openai", model: "gpt-x", api_key: "test-key", retry_base_ms: 1, http_options: [plug: plug], on_error: fn %{status: 429} -> :fail end ) raised? = try do Client.run(client, "hi", []) false rescue e in Toolnexus.ProviderError -> String.contains?(Exception.message(e), "LLM 429") end true = raised? true = Agent.get(attempts, & &1) == 1 IO.puts("ok: on_error fail-fast made exactly 1 attempt") ``` ### 3. `:retries` bounds even a `:retry` decision [Section titled “3. :retries bounds even a :retry decision”](#3-retries-bounds-even-a-retry-decision) Whatever `on_error` decides, `:retry` can never loop unbounded — `retries` (default 2) caps the total attempts. Left at the default classifier, a server that always fails still stops. ```elixir alias Toolnexus.Client {:ok, attempts} = Agent.start_link(fn -> 0 end) plug = fn conn -> {:ok, _raw, conn} = Plug.Conn.read_body(conn) Agent.update(attempts, &(&1 + 1)) conn |> Plug.Conn.put_resp_content_type("application/json") |> Plug.Conn.send_resp(500, ~s({"error":"down"})) end client = Client.create( base_url: "http://localhost", style: "openai", model: "gpt-x", api_key: "test-key", retries: 1, retry_base_ms: 1, http_options: [plug: plug] ) raised? = try do Client.run(client, "hi", []) false rescue e in Toolnexus.ProviderError -> String.contains?(Exception.message(e), "LLM 500") end true = raised? # retries: 1 -> the initial attempt PLUS 1 retry, never more. true = Agent.get(attempts, & &1) == 2 IO.puts("ok: retries: 1 bounded the loop to #{Agent.get(attempts, & &1)} total attempts") ``` ## Options [Section titled “Options”](#options) | Option | Default | What it does | | --------------------- | -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `:retries` | `2` | Hard ceiling on retry attempts — bounds `:retry` no matter what `on_error` decides. | | `:retry_base_ms` | `500` | Exponential backoff base (`base * 2^attempt` + jitter), overridden by a `Retry-After` response header when present. | | `:timeout_ms` | `nil` | Whole-run deadline in ms. `nil` = no deadline. Checked before every LLM attempt; past it, the run raises rather than making another call. | | `:retryable_statuses` | `nil` | Extra HTTP statuses to classify as retryable, **added** to the default `429`/`500`/`502`/`503`/`504`/`529` — widens the set only, never shrinks it, and `on_error` still has the final say. | | `:on_error` | `nil` (default classifier) | `(info -> :retry \| :fail)`. `info`: `%{status:, error:, attempt:, retryable:}` — `status` on a non-2xx response, `error` on a transport exception, `attempt` zero-based, `retryable` = whether the default rule (`429`/`500`/`502`/`503`/`504`/`529`/network, plus any `:retryable_statuses`) would already retry it. Absent ⇒ `retryable? :retry : :fail`, byte-identical to the hardcoded rule this option replaced. | ### 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: `:retry_base_ms` (default `500`, `base * 2^attempt`, no jitter, `Retry-After` still wins). See [`Toolnexus.Classifier`](/api/elixir/judge/classifier/). ## See also [Section titled “See also”](#see-also) * [`Toolnexus.Client.create`](/api/elixir/client/create/) — the unified client: system prompt, skills injection, parallel and chained tool calls, retries, memory. * [`Toolnexus.Client.run`](/api/elixir/client/run/) — where the deadline and retry loop actually run. * [`Toolnexus.Client.create`](/api/elixir/client/hooks/) — `before_llm`/`after_llm` fire once per successful attempt, not per retry. * [SPEC §10](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) — the suspension primitive `on_error` deliberately does not participate in. # Toolnexus.Client.run > Send a prompt, let the loop call tools until the model stops, get a RunResult. — Elixir. **Elixir** · package `toolnexus` · [SPEC §8](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [elixir/lib/toolnexus/client.ex](https://github.com/muthuishere/toolnexus/blob/main/elixir/lib/toolnexus/client.ex) ```elixir @spec run(Toolnexus.Client.t(), String.t(), term(), keyword()) :: Toolnexus.Client.RunResult.t() def run(client, prompt, toolkit, opts \\ []) # opts: :history — a prior transcript (RunResult.messages) to continue ``` Runs the whole tool-calling agent loop synchronously: build the system prompt from `system_prompt` + the toolkit’s skills prompt, call the endpoint, execute any tool calls the model asked for, feed the results back, repeat — until the model stops calling tools or `max_turns` is hit. Returns a `%Toolnexus.Client.RunResult{}` with the final text, the full transcript, every tool call made, and cumulative usage. ## When to use it [Section titled “When to use it”](#when-to-use-it) * **You just want an answer** — give it a prompt and a toolkit (a plain list of `%Tool{}`, or anything with `:tools`/`:prompt` such as `Toolnexus.create_toolkit/1`’s return) and block until the loop finishes. * **The toolkit came from MCP, skills, HTTP, or `define_tool`** — `run/4` does not care where the tools came from, only that they satisfy the toolkit protocol. * **You need the whole outcome, not just text** — `RunResult` carries `tool_calls`, `usage`, `turns`, and `status`, so you can log or bill on it. ## Why this and not the alternative [Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative) Reach for stream/4 when you want incremental output [`Toolnexus.Client.stream/4`](/api/elixir/client/stream/) runs the identical loop but hands back an `Enumerable` of events (`text` deltas, `tool_call`, `tool_result`, `done`) as they happen — the right choice for a chat UI or a CLI that prints tokens as they arrive. `run/4` is the right choice when you only care about the final `RunResult` and are fine blocking for it. For a stateful conversation identified by an id, see [`Toolnexus.Client.ask/4`](/api/elixir/client/conversation/) — it wraps `run/4` with load/save against a `ConversationStore`. `run/4` itself is the stateless primitive: pass `opts[:history]` yourself if you want to continue a transcript without a store. ## Examples [Section titled “Examples”](#examples) Every example below stubs the wire call with `http_options: [plug: fn conn -> ... end]` — an in-process `Req` plug, not a real socket — so the page runs with no network and no real API key. ### 1. The smallest useful call — single turn, no tools [Section titled “1. The smallest useful call — single turn, no tools”](#1-the-smallest-useful-call--single-turn-no-tools) ```elixir alias Toolnexus.Client plug = fn conn -> {:ok, _raw, conn} = Plug.Conn.read_body(conn) resp = %{ "choices" => [%{"message" => %{"role" => "assistant", "content" => "hello there"}}], "usage" => %{"prompt_tokens" => 5, "completion_tokens" => 3, "total_tokens" => 8} } conn |> Plug.Conn.put_resp_content_type("application/json") |> Plug.Conn.send_resp(200, Jason.encode!(resp)) end client = Client.create( base_url: "http://localhost", style: "openai", model: "gpt-x", api_key: "test-key", http_options: [plug: plug] ) result = Client.run(client, "say hi", []) true = result.text == "hello there" true = result.status == "done" true = result.turns == 1 true = result.tool_calls == [] true = result.usage == %{prompt_tokens: 5, completion_tokens: 3, total_tokens: 8} IO.puts("ok: #{result.text} (#{result.turns} turn)") ``` ### 2. A tool-call turn, then the final answer [Section titled “2. A tool-call turn, then the final answer”](#2-a-tool-call-turn-then-the-final-answer) The model asks for `add`, the loop runs it and feeds `"5"` back as a `role: "tool"` message, then calls the endpoint again for the real answer — two turns, one tool call. ```elixir alias Toolnexus.{Client, Context, Tool, ToolResult} {:ok, calls} = Agent.start_link(fn -> 0 end) add_tool = %Tool{ name: "add", description: "Add two numbers", input_schema: %{"type" => "object", "properties" => %{}}, source: "native", execute: fn args, %Context{} -> ToolResult.ok(to_string(trunc(args["a"] + args["b"]))) end } plug = fn conn -> {:ok, _raw, conn} = Plug.Conn.read_body(conn) n = Agent.get_and_update(calls, fn c -> {c + 1, c + 1} end) resp = if n == 1 do %{ "choices" => [ %{ "message" => %{ "role" => "assistant", "content" => nil, "tool_calls" => [ %{"id" => "c1", "type" => "function", "function" => %{"name" => "add", "arguments" => ~s({"a":2,"b":3})}} ] } } ], "usage" => %{"prompt_tokens" => 6, "completion_tokens" => 4, "total_tokens" => 10} } else %{ "choices" => [%{"message" => %{"role" => "assistant", "content" => "the sum is 5"}}], "usage" => %{"prompt_tokens" => 5, "completion_tokens" => 3, "total_tokens" => 8} } end conn |> Plug.Conn.put_resp_content_type("application/json") |> Plug.Conn.send_resp(200, Jason.encode!(resp)) end client = Client.create( base_url: "http://localhost", style: "openai", model: "gpt-x", api_key: "test-key", http_options: [plug: plug] ) result = Client.run(client, "add 2 and 3", [add_tool]) true = result.text == "the sum is 5" true = result.turns == 2 true = result.tool_call_count == 1 [%{name: "add", output: "5", is_error: false}] = result.tool_calls true = result.usage.total_tokens == 18 IO.puts("ok: #{result.text} after #{result.tool_call_count} tool call(s)") ``` ### 3. The full surface — Anthropic style, and continuing a transcript with `:history` [Section titled “3. The full surface — Anthropic style, and continuing a transcript with :history”](#3-the-full-surface--anthropic-style-and-continuing-a-transcript-with-history) `opts[:history]` is the low-level continuation primitive: pass back a prior `RunResult.messages` and the loop appends the new prompt to it instead of starting fresh. ```elixir alias Toolnexus.Client {:ok, calls} = Agent.start_link(fn -> [] end) plug = fn conn -> {:ok, raw, conn} = Plug.Conn.read_body(conn) body = Jason.decode!(raw) Agent.update(calls, fn c -> c ++ [body] end) resp = %{ "content" => [%{"type" => "text", "text" => "got it"}], "usage" => %{"input_tokens" => 4, "output_tokens" => 2} } conn |> Plug.Conn.put_resp_content_type("application/json") |> Plug.Conn.send_resp(200, Jason.encode!(resp)) end client = Client.create( base_url: "http://localhost", style: "anthropic", model: "claude-x", api_key: "test-key", system_prompt: "be brief", http_options: [plug: plug] ) first = Client.run(client, "remember the number 42", []) true = first.status == "done" # Continue the SAME transcript — no store, no id, just the messages from the first run. second = Client.run(client, "what number did I say?", [], history: first.messages) true = second.turns == 1 bodies = Agent.get(calls, & &1) [_first_body, second_body] = bodies # The continued call's `messages` carries BOTH turns — the history plus the new prompt. true = length(second_body["messages"]) == 3 true = List.last(second_body["messages"]) == %{"role" => "user", "content" => "what number did I say?"} IO.puts("ok: continued a transcript across two run/4 calls (#{length(second_body["messages"])} messages)") ``` ## `RunResult` shape [Section titled “RunResult shape”](#runresult-shape) | Field | Type | What it is | | ----------------- | --------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | | `text` | `String.t()` | The final assistant text. | | `messages` | `[map()]` | Full transcript in provider wire shape — pass to `run/4` as `history:` to continue. | | `tool_calls` | `[map()]` | `%{name, args, output, is_error, metadata}` per call, in call order. | | `tool_call_count` | `non_neg_integer()` | `length(tool_calls)`. | | `turns` | `non_neg_integer()` | LLM calls made this run. | | `usage` | `%{prompt_tokens, completion_tokens, total_tokens}` | Summed across turns. | | `model` | `String.t()` | Echoes `client.model`. | | `status` | `"done"` \| `"pending"` \| `"incomplete"` | `"pending"` — a tool suspended (§10) and no `wait_for`; `"incomplete"` — `max_turns` hit with the model still calling tools. | | `limit` | `String.t()` \| `nil` | `"maxTurns"` when `status == "incomplete"`, else `nil`. | | `pending` | `Toolnexus.Request.t()` \| `nil` | Set only when `status == "pending"` (§10). | ## See also [Section titled “See also”](#see-also) * [`Toolnexus.Client.create`](/api/elixir/client/create/) — the unified client: system prompt, skills injection, parallel and chained tool calls, retries, memory. * [`Toolnexus.Client.stream`](/api/elixir/client/stream/) — the streaming loop: text deltas, tool-call events, and suspension events as they happen. * [`Toolnexus.Client conversation`](/api/elixir/client/conversation/) — `ask/4`: a stateful id-keyed conversation built on `run/4`. * [`Toolnexus.Client.create`](/api/elixir/client/hooks/) — intercept before/after model calls and tool calls. # Toolnexus.Client.InMemoryConversationStore > Swap in-memory history for your own store so a conversation survives a process restart. — Elixir. **Elixir** · package `toolnexus` · [SPEC §8](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [elixir/lib/toolnexus/client.ex](https://github.com/muthuishere/toolnexus/blob/main/elixir/lib/toolnexus/client.ex) ```elixir defmodule Toolnexus.Client.ConversationStore do @callback get(store :: struct(), id :: String.t()) :: [map()] | nil @callback save(store :: struct(), id :: String.t(), messages :: [map()]) :: any() end defmodule Toolnexus.Client.InMemoryConversationStore do @behaviour Toolnexus.Client.ConversationStore defstruct [:pid] @spec new() :: t() def new() end ``` A `ConversationStore` is any struct whose module implements two callbacks — `get/2`, `save/3`. The client dispatches on the struct’s module, so implementing this behaviour for a file, a database table, or Redis is all it takes to make `ask/4` conversations outlive the process. `InMemoryConversationStore` is the default: an `Agent`-backed map, gone when the client’s process tree ends. ## When to use it [Section titled “When to use it”](#when-to-use-it) * **You want conversations to survive a restart** — implement `ConversationStore` against whatever you already persist to (Postgres, Redis, a file), and pass it as `:store` on [`Toolnexus.Client.create/1`](/api/elixir/client/create/). * **You just need per-process memory for now** — do nothing; the default in-memory store is created for you and lives as long as the client’s process. * **You need to read or rewind a transcript from outside `ask/4`** — [`Toolnexus.Client.conversation_store/1`](#3-the-full-surface--sharing-the-store-outside-ask4) returns the exact instance the client is using, so a host can read (or, with your own store, mutate) it directly. ## Why this and not the alternative [Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative) Reach for run/4's :history when you don't want a store at all [`Toolnexus.Client.run/4`](/api/elixir/client/run/) accepts `history:` directly — the lowest-level continuation primitive, no store, no id, just “here are the prior messages, append the new prompt”. A `ConversationStore` is for when you want the client to own the load/save cycle keyed by an id, which is what [`ask/4`](/api/elixir/client/conversation/) uses it for. ## Examples [Section titled “Examples”](#examples) ### 1. The smallest useful call — the default store, used directly [Section titled “1. The smallest useful call — the default store, used directly”](#1-the-smallest-useful-call--the-default-store-used-directly) `Client.create/1` always gives you a store, even if you never mention `:store` — read/write it through the two behaviour callbacks, dispatched via the struct’s module. ```elixir alias Toolnexus.Client alias Toolnexus.Client.{ConversationStore, InMemoryConversationStore} client = Client.create(base_url: "http://localhost", style: "openai", model: "gpt-x", api_key: "k") store = Client.conversation_store(client) true = match?(%InMemoryConversationStore{}, store) # Nothing saved yet. true = InMemoryConversationStore.get(store, "conv-1") == nil InMemoryConversationStore.save(store, "conv-1", [%{"role" => "user", "content" => "hi"}]) true = InMemoryConversationStore.get(store, "conv-1") == [%{"role" => "user", "content" => "hi"}] # The behaviour also dispatches generically, by module — this is what the client itself does. %mod{} = store true = mod.get(store, "conv-1") == [%{"role" => "user", "content" => "hi"}] true = ConversationStore in InMemoryConversationStore.__info__(:attributes)[:behaviour] IO.puts("ok: default store round-tripped a transcript for conv-1") ``` ### 2. A realistic case — a custom store plugged in at `create/1` [Section titled “2. A realistic case — a custom store plugged in at create/1”](#2-a-realistic-case--a-custom-store-plugged-in-at-create1) Any struct implementing the two callbacks works. This one is still in-memory (kept hermetic for the docs), but the shape is exactly what a Postgres- or file-backed store would look like: `get` reads, `save` writes, and `ask/4` never knows the difference. ```elixir alias Toolnexus.Client alias Toolnexus.Client.ConversationStore defmodule DocsFileLikeStore do @behaviour ConversationStore defstruct [:pid] def new do {:ok, pid} = Agent.start_link(fn -> %{} end) %__MODULE__{pid: pid} end @impl true def get(%__MODULE__{pid: pid}, id), do: Agent.get(pid, &Map.get(&1, id)) @impl true def save(%__MODULE__{pid: pid}, id, messages), do: Agent.update(pid, &Map.put(&1, id, messages)) end plug = fn conn -> {:ok, _raw, conn} = Plug.Conn.read_body(conn) resp = %{"choices" => [%{"message" => %{"role" => "assistant", "content" => "noted"}}], "usage" => %{"prompt_tokens" => 2, "completion_tokens" => 1, "total_tokens" => 3}} conn |> Plug.Conn.put_resp_content_type("application/json") |> Plug.Conn.send_resp(200, Jason.encode!(resp)) end client = Client.create( base_url: "http://localhost", style: "openai", model: "gpt-x", api_key: "test-key", http_options: [plug: plug], store: DocsFileLikeStore.new() ) _ = Client.ask(client, "remember this", [], id: "conv-2") true = Client.conversation_store(client).__struct__ == DocsFileLikeStore history = DocsFileLikeStore.get(Client.conversation_store(client), "conv-2") true = length(history) == 2 IO.puts("ok: ask/4 persisted #{length(history)} messages into the custom store") ``` ### 3. The full surface — sharing the store outside `ask/4` [Section titled “3. The full surface — sharing the store outside ask/4”](#3-the-full-surface--sharing-the-store-outside-ask4) `conversation_store/1` returns the **exact instance** the client uses (Gap 4 in SPEC §8) — you can write to it directly and `ask/4` will pick that up as history on its next call, with no shadow copy anywhere. ```elixir alias Toolnexus.Client alias Toolnexus.Client.InMemoryConversationStore {:ok, seen} = Agent.start_link(fn -> [] end) plug = fn conn -> {:ok, raw, conn} = Plug.Conn.read_body(conn) Agent.update(seen, fn s -> s ++ [Jason.decode!(raw)] end) resp = %{"choices" => [%{"message" => %{"role" => "assistant", "content" => "ok"}}], "usage" => %{"prompt_tokens" => 1, "completion_tokens" => 1, "total_tokens" => 2}} conn |> Plug.Conn.put_resp_content_type("application/json") |> Plug.Conn.send_resp(200, Jason.encode!(resp)) end client = Client.create( base_url: "http://localhost", style: "openai", model: "gpt-x", api_key: "test-key", http_options: [plug: plug] ) # Seed history directly on the store the client already holds — no prior ask/4 call at all. store = Client.conversation_store(client) InMemoryConversationStore.save(store, "conv-3", [ %{"role" => "user", "content" => "my favorite color is teal"}, %{"role" => "assistant", "content" => "got it, teal"} ]) _ = Client.ask(client, "what's my favorite color?", [], id: "conv-3") [sent] = Agent.get(seen, & &1) # The seeded history was there BEFORE ask/4 ever ran for this id. true = length(sent["messages"]) == 3 true = hd(sent["messages"]) == %{"role" => "user", "content" => "my favorite color is teal"} IO.puts("ok: seeded #{length(sent["messages"]) - 1} prior message(s) were visible to ask/4") ``` ## `ConversationStore` behaviour [Section titled “ConversationStore behaviour”](#conversationstore-behaviour) | Callback | Signature | What it does | | -------- | -------------------------------- | ------------------------------------------------------------ | | `get/2` | `(store, id) -> [map()] \| nil` | Load a transcript by id, or `nil` if none exists yet. | | `save/3` | `(store, id, messages) -> any()` | Persist the transcript under an id. Return value is ignored. | ## See also [Section titled “See also”](#see-also) * [`Toolnexus.Client.create`](/api/elixir/client/create/) — pass a custom store via `:store`. * [`Toolnexus.Client conversation`](/api/elixir/client/conversation/) — `ask/4`, the load/save cycle this store backs. * [`Toolnexus.Client.run`](/api/elixir/client/run/) — the store-free continuation primitive (`history:`). # Toolnexus.Client.stream > The streaming loop: text deltas, tool-call events, and suspension events as they happen. — Elixir. **Elixir** · package `toolnexus` · [SPEC §8](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [elixir/lib/toolnexus/client.ex](https://github.com/muthuishere/toolnexus/blob/main/elixir/lib/toolnexus/client.ex) ```elixir @spec stream(Toolnexus.Client.t(), String.t(), term(), keyword()) :: Enumerable.t() def stream(client, prompt, toolkit, opts \\ []) # opts: :id — makes the stream stateful, like ask/4 # # yields event maps: # %{type: "text", delta: String.t()} # %{type: "tool_call", id: String.t(), name: String.t(), args: map()} # %{type: "tool_result", id: String.t(), name: String.t(), output: String.t(), is_error: boolean()} # %{type: "usage", usage: map()} # %{type: "pending", request: Toolnexus.Request.t()} # §10 # %{type: "done", result: Toolnexus.Client.RunResult.t()} ``` The same agent loop as [`run/4`](/api/elixir/client/run/) — same hooks, same tools, same telemetry — but instead of blocking until it’s finished, `stream/4` returns an `Enumerable.t()` of events as they happen: text token deltas, a `tool_call` right before a tool runs, a `tool_result` right after, and a terminal `done` event carrying the exact same `RunResult` that `run/4` would have returned. ## When to use it [Section titled “When to use it”](#when-to-use-it) * **A chat UI or CLI that prints tokens as they arrive** instead of waiting for the whole answer. * **You want tool-call visibility mid-run** — `tool_call`/`tool_result` events fire as each tool executes, useful for a “thinking…” indicator or a live audit trail. * **A suspended tool (§10)** — the `pending` event fires *before* `wait_for` is even consulted, so a channel can push an approval link to a user the moment a tool asks for one. ## Why this and not the alternative [Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative) Reach for run/4 when you only need the final answer [`Toolnexus.Client.run/4`](/api/elixir/client/run/) runs the identical loop and blocks for one `RunResult`. Use `stream/4` only when you need the incremental events — every event’s payload is also available, in aggregate, on the `done` event’s `RunResult`, so `stream/4` never trades away information `run/4` has. For streaming with a callback instead of an `Enumerable`, see [`ask/4`’s `:on_text` option](/api/elixir/client/conversation/) — it consumes `stream/4` internally and hands back the final `RunResult`, for callers who want deltas pushed to them rather than an iterator to pull from. ## Examples [Section titled “Examples”](#examples) ### 1. The smallest useful call — text deltas, then `done` [Section titled “1. The smallest useful call — text deltas, then done”](#1-the-smallest-useful-call--text-deltas-then-done) The underlying wire format is OpenAI SSE (`stream:true` + `stream_options.include_usage`) — `data: {...}` lines terminated by `data: [DONE]`. The plug below serves that shape directly, in process, with no network and no real key. ```elixir alias Toolnexus.Client sse = """ data: {"choices":[{"delta":{"content":"Hel"}}]} data: {"choices":[{"delta":{"content":"lo"}}]} data: {"choices":[{"delta":{}}],"usage":{"prompt_tokens":5,"completion_tokens":2,"total_tokens":7}} data: [DONE] """ plug = fn conn -> {:ok, _raw, conn} = Plug.Conn.read_body(conn) conn |> Plug.Conn.put_resp_content_type("text/event-stream") |> Plug.Conn.send_resp(200, sse) end client = Client.create( base_url: "http://localhost", style: "openai", model: "gpt-x", api_key: "test-key", http_options: [plug: plug] ) events = client |> Client.stream("hi", []) |> Enum.to_list() true = length(events) == 4 [e1, e2, e3, e4] = events true = e1 == %{type: "text", delta: "Hel"} true = e2 == %{type: "text", delta: "lo"} true = e3 == %{type: "usage", usage: %{prompt_tokens: 5, completion_tokens: 2, total_tokens: 7}} true = e4.type == "done" true = e4.result.text == "Hello" true = e4.result.status == "done" IO.puts("ok: streamed #{e4.result.text |> String.length()} chars over #{length(events)} events") ``` ### 2. Tool-call events, then the final turn’s text [Section titled “2. Tool-call events, then the final turn’s text”](#2-tool-call-events-then-the-final-turns-text) `tool_call`/`tool_result` fire around the tool’s execution; the assistant’s `content` deltas from the model’s *next* turn (after seeing the tool result) stream in after that. ```elixir alias Toolnexus.{Client, Context, Tool, ToolResult} add_tool = %Tool{ name: "add", description: "Add two numbers", input_schema: %{"type" => "object", "properties" => %{}}, source: "native", execute: fn args, %Context{} -> ToolResult.ok(to_string(trunc(args["a"] + args["b"]))) end } tool_call_sse = """ data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"c1","function":{"name":"add","arguments":""}}]}}]} data: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\\"a\\":2,\\"b\\":3}"}}]}}]} data: {"choices":[{"delta":{}}],"usage":{"prompt_tokens":6,"completion_tokens":4,"total_tokens":10}} data: [DONE] """ final_sse = """ data: {"choices":[{"delta":{"content":"the sum is 5"}}]} data: {"choices":[{"delta":{}}],"usage":{"prompt_tokens":5,"completion_tokens":3,"total_tokens":8}} data: [DONE] """ {:ok, calls} = Agent.start_link(fn -> 0 end) plug = fn conn -> {:ok, _raw, conn} = Plug.Conn.read_body(conn) n = Agent.get_and_update(calls, fn c -> {c + 1, c + 1} end) body = if n == 1, do: tool_call_sse, else: final_sse conn |> Plug.Conn.put_resp_content_type("text/event-stream") |> Plug.Conn.send_resp(200, body) end client = Client.create( base_url: "http://localhost", style: "openai", model: "gpt-x", api_key: "test-key", http_options: [plug: plug] ) events = client |> Client.stream("add 2 and 3", [add_tool]) |> Enum.to_list() [call_ev, result_ev, text_ev, usage_ev, done_ev] = events true = call_ev == %{type: "tool_call", id: "c1", name: "add", args: %{"a" => 2, "b" => 3}} true = result_ev == %{type: "tool_result", id: "c1", name: "add", output: "5", is_error: false, metadata: nil} true = text_ev == %{type: "text", delta: "the sum is 5"} true = usage_ev.type == "usage" true = done_ev.result.text == "the sum is 5" true = done_ev.result.tool_call_count == 1 IO.puts("ok: #{call_ev.name}(#{inspect(call_ev.args)}) -> #{result_ev.output} -> #{done_ev.result.text}") ``` ### 3. The full surface — stateful with `:id` [Section titled “3. The full surface — stateful with :id”](#3-the-full-surface--stateful-with-id) Pass `:id` and `stream/4` becomes stateful exactly like `ask/4`: it loads that id’s transcript from the client’s `ConversationStore` before streaming, and saves the updated transcript back when the `done` event fires — so the *next* call with the same `:id` continues the conversation. ```elixir alias Toolnexus.Client alias Toolnexus.Client.InMemoryConversationStore ack_sse = """ data: {"choices":[{"delta":{"content":"ack"}}]} data: {"choices":[{"delta":{}}],"usage":{"prompt_tokens":2,"completion_tokens":1,"total_tokens":3}} data: [DONE] """ {:ok, bodies} = Agent.start_link(fn -> [] end) plug = fn conn -> {:ok, raw, conn} = Plug.Conn.read_body(conn) Agent.update(bodies, fn b -> b ++ [Jason.decode!(raw)] end) conn |> Plug.Conn.put_resp_content_type("text/event-stream") |> Plug.Conn.send_resp(200, ack_sse) end client = Client.create( base_url: "http://localhost", style: "openai", model: "gpt-x", api_key: "test-key", http_options: [plug: plug] ) _ = client |> Client.stream("remember 42", [], id: "conv-1") |> Enum.to_list() store = Client.conversation_store(client) history = InMemoryConversationStore.get(store, "conv-1") true = length(history) == 2 _ = client |> Client.stream("what did I say?", [], id: "conv-1") |> Enum.to_list() [_first_body, second_body] = Agent.get(bodies, & &1) # The second stream's request carried the FIRST turn's history plus the new prompt. true = length(second_body["messages"]) == 3 true = List.last(second_body["messages"]) == %{"role" => "user", "content" => "what did I say?"} IO.puts("ok: stream/4 with :id persisted #{length(history)} messages, continued to #{length(second_body["messages"])}") ``` ## Event shapes [Section titled “Event shapes”](#event-shapes) | Event | Fields | When | | -------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | | `%{type: "text", delta}` | `delta :: String.t()` | Each assistant text token as it arrives. | | `%{type: "tool_call", id, name, args, via?}` | `args :: map()` | Right before a tool call executes. Under `:tool_tiers` a `call_tool` call names the real tool and inner args, with `via: "call_tool"`. | | `%{type: "tool_result", id, name, output, is_error, metadata, via?}` | | Right after it ran — skipped for a suspending call (see `pending` below). | | `%{type: "usage", usage}` | `%{prompt_tokens, completion_tokens, total_tokens}` plus `cache_read_tokens` / `cache_write_tokens` when the provider reports them | Once per completed turn with no further tool calls, before `done`. | | `%{type: "pending", request}` | `request :: Toolnexus.Request.t()` | §10 — a tool suspended; fires before `wait_for` runs. | | `%{type: "done", result}` | `result :: Toolnexus.Client.RunResult.t()` | Terminal — same shape `run/4` returns. | ## See also [Section titled “See also”](#see-also) * [`Toolnexus.Client.run`](/api/elixir/client/run/) — the same loop, blocking for one `RunResult`. * [`Toolnexus.Client conversation`](/api/elixir/client/conversation/) — `ask/4` with `:on_text`, the callback-style streaming path built on this. * [`Toolnexus.Client.create`](/api/elixir/client/hooks/) — hooks fire identically whether you `run/4` or `stream/4`. * [`Toolnexus.Mcp.Protocol.elicitation_to_request`](/api/elixir/mcp/elicitation/) — what a `pending` event’s `request` looks like when it comes from an MCP server. # 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. — Elixir. **Elixir** · package `toolnexus` · [SPEC §8](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) ```elixir Toolnexus.Client.create( ..., tool_tiers: %{ core: [String.t()], # always sent with the full schema deferred: [String.t()], # listed by name only; every other tool is "listed" budget_tokens: pos_integer(), # optional cap on the up-front schema tokens context_window: pos_integer(), # default budget = 5% of this usage_count: (String.t() -> integer()), count_tokens: (term() -> integer()) } ) @spec tool_tier_plan(Client.t(), toolkit) :: %{String.t() => :core | :listed | :deferred} | nil @spec reset_tool_tiers(Client.t(), toolkit) :: :ok ``` Send only the **core** tool schemas. Every other tool is named inside the description of a `tool_search` tool — *listed* tools as `- name — first line` (cut to 120 bytes), *deferred* tools by name only. The model calls `tool_search %{query}` (`"select:a,b"` for exact names, else keywords) and gets the full definitions back **as a tool result**, so the tools array — and the cached prefix — never changes mid-session. A loaded tool is then called by name, or through `call_tool %{name, arguments}`, which the loop unwraps **before** hooks: `before_tool` and its `hints` see the real tool. Stream `tool_call` / `tool_result` events name the real tool and carry `via: "call_tool"`. `tool_search` itself is engine-internal and never reaches `before_tool`. The plan is built on a toolkit’s first request and then **frozen**, keyed by the toolkit’s id (which survives `Toolkit.register`). A tool registered later is absent from the description but still found by search. `Toolnexus.Client.reset_tool_tiers/2` drops the plan so the next request rebuilds it — call it at a session start. With a budget, the least-used core tool moves down to listed (ties: larger schema, then name), then the least-used listed tool to deferred. ## When to use it [Section titled “When to use it”](#when-to-use-it) * **Many tools, few used per session** — dozens of MCP tools cost thousands of prompt tokens on every turn; tiers send a handful plus two small meta tools. * **Tools arrive mid-session** (an MCP server connects late, a plugin loads) — without tiers the tools array changes and the [prompt cache](/api/elixir/client/prompt-cache/) misses. * **A UI that shows what the model can see** — `tool_tier_plan/2` returns the frozen plan. ## Why this and not the alternative [Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative) Not disable\_tools `disable_tools` removes a tool outright: the model cannot reach it at all. Tiers keep every tool reachable and only defer its schema. Use `disable_tools` for what must never run, tiers for what is merely rarely needed. ## Examples [Section titled “Examples”](#examples) ### 1. Inspect the plan [Section titled “1. Inspect the plan”](#1-inspect-the-plan) ```elixir alias Toolnexus.{Client, Toolkit} mk = fn name, desc -> Toolnexus.define_tool(name: name, description: desc, execute: fn _ -> name end) end tk = Toolkit.register(Toolnexus.create_toolkit!(builtins: false), [mk.("read_file", "Read a file."), mk.("lint", "Lint the code."), mk.("deploy", "Deploy the service.")]) c = Client.create(base_url: "https://api.example/v1", model: "m", api_key: "YOUR_KEY_HERE", tool_tiers: %{core: ["read_file"], deferred: ["deploy"]}) plan = Client.tool_tier_plan(c, tk) true = plan == %{"read_file" => :core, "lint" => :listed, "deploy" => :deferred} IO.puts("ok: #{inspect(plan)}") ``` ### 2. The loop: tool\_search, then call\_tool, with the gate seeing the real tool [Section titled “2. The loop: tool\_search, then call\_tool, with the gate seeing the real tool”](#2-the-loop-tool_search-then-call_tool-with-the-gate-seeing-the-real-tool) ```elixir alias Toolnexus.{Client, Toolkit} me = self() deploy = Toolnexus.define_tool(name: "deploy", description: "Deploy the service.", execute: fn args -> send(me, {:ran, args["env"]}); "deployed" end) read = Toolnexus.define_tool(name: "read_file", description: "Read a file.", execute: fn _ -> "x" end) tk = Toolkit.register(Toolnexus.create_toolkit!(builtins: false), [read, deploy]) {:ok, step} = Agent.start_link(fn -> 0 end) msg = fn calls -> %{"choices" => [%{"message" => %{"role" => "assistant", "content" => nil, "tool_calls" => for({id, n, a} <- calls, do: %{"id" => id, "type" => "function", "function" => %{"name" => n, "arguments" => Jason.encode!(a)}})}}], "usage" => %{"prompt_tokens" => 1, "completion_tokens" => 1, "total_tokens" => 2}} end t = fn req -> send(me, {:tools, Enum.map(req.body["tools"], & &1["function"]["name"])}) body = case Agent.get_and_update(step, &{&1 + 1, &1 + 1}) do 1 -> msg.([{"s1", "tool_search", %{"query" => "select:deploy"}}]) 2 -> msg.([{"c1", "call_tool", %{"name" => "deploy", "arguments" => %{"env" => "prod"}}}]) _ -> %{"choices" => [%{"message" => %{"role" => "assistant", "content" => "shipped"}}], "usage" => %{"prompt_tokens" => 1, "completion_tokens" => 1, "total_tokens" => 2}} end {:ok, %{status: 200, headers: %{}, body: body}} end c = Client.create(base_url: "https://api.example/v1", model: "m", api_key: "YOUR_KEY_HERE", transport: t, tool_tiers: %{core: ["read_file"]}, hooks: %{before_tool: fn ev -> send(me, {:gate, ev.name}); nil end}) r = Client.run(c, "ship it", tk) true = r.text == "shipped" receive do: ({:ran, "prod"} -> :ok) receive do: ({:gate, "deploy"} -> :ok) arrays = for _ <- 1..3, do: (receive do {:tools, a} -> a end) [["read_file", "tool_search", "call_tool"]] = Enum.uniq(arrays) IO.puts("ok: tools array stable across #{length(arrays)} requests") ``` ### 3. Frozen across register, rebuilt by reset\_tool\_tiers [Section titled “3. Frozen across register, rebuilt by reset\_tool\_tiers”](#3-frozen-across-register-rebuilt-by-reset_tool_tiers) ```elixir alias Toolnexus.{Client, Toolkit} mk = fn name -> Toolnexus.define_tool(name: name, description: "Tool #{name}.", execute: fn _ -> name end) end tk = Toolkit.register(Toolnexus.create_toolkit!(builtins: false), [mk.("a"), mk.("b")]) c = Client.create(base_url: "https://api.example/v1", model: "m", api_key: "YOUR_KEY_HERE", tool_tiers: %{core: ["a"]}) plan = Client.tool_tier_plan(c, tk) tk2 = Toolkit.register(tk, mk.("late")) # Same toolkit id ⇒ the frozen plan; "late" is not in it (found by search instead). true = Client.tool_tier_plan(c, tk2) == plan :ok = Client.reset_tool_tiers(c, tk2) true = Client.tool_tier_plan(c, tk2)["late"] == :listed IO.puts("ok: late tool listed after reset") ``` ## See also [Section titled “See also”](#see-also) * [`Toolnexus.Client.create`](/api/elixir/client/create/) — The unified client: system prompt, skills injection, parallel and chained tool calls, retries, memory. * [`Toolnexus.Client.run`](/api/elixir/client/run/) — Send a prompt, let the loop call tools until the model stops, get a RunResult. * [`Toolnexus.Client.stream`](/api/elixir/client/stream/) — The streaming loop: text deltas, tool-call events, and suspension events as they happen. * [`Toolnexus.Client.create`](/api/elixir/client/hooks/) — Intercept before/after model calls and tool calls: audit, redact, veto, or rewrite. # Toolnexus.Agents.Compaction.compactor > Summarise older turns when the transcript grows, so a long-lived agent stays inside its context window. — Elixir. **Elixir** · package `toolnexus` · [SPEC §7F](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [elixir/lib/toolnexus/agents/compaction.ex](https://github.com/muthuishere/toolnexus/blob/main/elixir/lib/toolnexus/agents/compaction.ex) ```elixir @spec compactor(keyword()) :: (map() -> %{messages: [map()]} | nil) def compactor(opts) # opts: # :max_tokens — required; compact only when the estimate exceeds this (at/below ⇒ no-op) # :summarize — required; (older :: [map] -> String.t()) — MAY call an LLM, your choice # :keep_tail — default div(max_tokens, 2) # :count_tokens — default estimate_tokens/1 (ceil(byte_size(json)/4) summed over messages) # :flush_to_memory — default false; inject a pre-compact reminder to save via the memory tool ``` `compactor/1` builds a `before_llm` hook (§8) — the same seam `Toolnexus.Client.create/1` takes under `:hooks`. Below `max_tokens` it returns `nil` and the loop’s transcript is untouched — byte-identical to a run with no compactor. Above it, it replaces the working transcript with `[leading system message (verbatim), summary system message, (flush reminder?), …tail]`, where `tail` is the largest **user-boundary** slice that fits `keep_tail` (falling back to the most recent user turn if none fits) — 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** — a persona with a heartbeat, a coding agent working through many turns — and you need it to stay inside the model’s context window without losing the thread. * **You want summarization on your own terms** — `:summarize` is a plain function; call an LLM from it, or don’t. `compactor/1` never makes a model call on your behalf. * **You want compaction integrated into the existing loop**, not a second code path — it rides `before_llm`, the same hook `RunResult.messages` and the `ConversationStore` already see. ## 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 loop change There is no separate “compacting client” — `compactor/1` returns an ordinary `before_llm` function. Wire it into `Toolnexus.Client.create(hooks: %{before_llm: Compaction.compactor(...)})` (or an agent’s own `:hooks`, which replaces the runtime-wide value for that agent) and the loop behaves exactly as it always did, except the transcript it sends can now be a summary + tail. Skip the compactor entirely and nothing here changes shape — the hook is opt-in. ## Examples [Section titled “Examples”](#examples) ### 1. The smallest useful call — under budget is a no-op [Section titled “1. The smallest useful call — under budget is a no-op”](#1-the-smallest-useful-call--under-budget-is-a-no-op) ```elixir alias Toolnexus.Agents.Compaction hook = Compaction.compactor(max_tokens: 10_000, summarize: fn _older -> "should never run" end) messages = [ %{"role" => "system", "content" => "You are terse."}, %{"role" => "user", "content" => "hi"}, %{"role" => "assistant", "content" => "hello"} ] result = hook.(%{messages: messages, tools: [], model: "gpt-x", turn: 1}) true = result == nil IO.puts("ok: #{length(messages)} messages, well under budget, hook is a no-op") ``` ### 2. The realistic case — over budget: summarize the head, keep a user-boundary tail [Section titled “2. The realistic case — over budget: summarize the head, keep a user-boundary tail”](#2-the-realistic-case--over-budget-summarize-the-head-keep-a-user-boundary-tail) ```elixir alias Toolnexus.Agents.Compaction # A count_tokens override keeps the example deterministic: 1 "token" per message. count = fn msgs -> length(msgs) end hook = Compaction.compactor( max_tokens: 4, keep_tail: 2, count_tokens: count, summarize: fn older -> "#{length(older)} earlier turn(s) summarized" end ) messages = [ %{"role" => "system", "content" => "You are terse."}, %{"role" => "user", "content" => "turn 1"}, %{"role" => "assistant", "content" => "reply 1"}, %{"role" => "user", "content" => "turn 2"}, %{"role" => "assistant", "content" => "reply 2"}, %{"role" => "user", "content" => "turn 3"}, %{"role" => "assistant", "content" => "reply 3"} ] %{messages: compacted} = hook.(%{messages: messages, tools: [], model: "gpt-x", turn: 5}) # leading system message survives verbatim [%{"role" => "system", "content" => "You are terse."} | rest] = compacted [summary | tail] = rest true = String.starts_with?(summary["content"], "[Summary of earlier conversation]\n") # the tail starts at a "user" boundary — no tool result is ever orphaned from its call true = hd(tail)["role"] == "user" true = List.last(tail) == %{"role" => "assistant", "content" => "reply 3"} IO.puts("ok: compacted #{length(messages)} messages -> #{length(compacted)} (system + summary + #{length(tail)}-message tail)") ``` ### 3. The full surface — `flush_to_memory`, wired into a real `Client.run` [Section titled “3. The full surface — flush\_to\_memory, wired into a real Client.run”](#3-the-full-surface--flush_to_memory-wired-into-a-real-clientrun) `http_options: [plug: ...]` stubs the wire call in-process — no network, no real API key — same pattern as [`Client.run`](/api/elixir/client/run/). The compactor rides `hooks.before_llm`, so the second LLM call in this run receives the already-compacted transcript. ```elixir alias Toolnexus.Client alias Toolnexus.Agents.Compaction count = fn msgs -> length(msgs) end hook = Compaction.compactor( max_tokens: 3, keep_tail: 1, count_tokens: count, flush_to_memory: true, summarize: fn older -> "#{length(older)} turn(s), summarized" end ) {:ok, seen} = Agent.start_link(fn -> [] end) plug = fn conn -> {:ok, raw, conn} = Plug.Conn.read_body(conn) body = Jason.decode!(raw) Agent.update(seen, fn s -> s ++ [body["messages"]] end) resp = %{ "choices" => [%{"message" => %{"role" => "assistant", "content" => "got it"}}], "usage" => %{"prompt_tokens" => 2, "completion_tokens" => 1, "total_tokens" => 3} } conn |> Plug.Conn.put_resp_content_type("application/json") |> Plug.Conn.send_resp(200, Jason.encode!(resp)) end client = Client.create( base_url: "http://localhost", style: "openai", model: "gpt-x", api_key: "test-key", hooks: %{before_llm: hook}, http_options: [plug: plug] ) # Prime a long-ish history so the SECOND run/4 call is already over budget. history = [ %{"role" => "system", "content" => "be brief"}, %{"role" => "user", "content" => "turn 1"}, %{"role" => "assistant", "content" => "reply 1"}, %{"role" => "user", "content" => "turn 2"}, %{"role" => "assistant", "content" => "reply 2"} ] result = Client.run(client, "turn 3", [], history: history) true = result.status == "done" [sent] = Agent.get(seen, & &1) # the summary + flush reminder replaced the older turns before this call went out true = Enum.any?(sent, &String.starts_with?(&1["content"] || "", "[Summary of earlier conversation]")) true = Enum.any?(sent, &(&1["content"] == "Before continuing: if anything from earlier is worth keeping, save it with the memory tool now — the earlier transcript is about to be summarized.")) IO.puts("ok: compactor ran inside a real Client.run — #{length(sent)} messages sent on the compacted turn") ``` ## Options [Section titled “Options”](#options) | Option | Type | Default | What it is | | ------------------ | ------------------------------ | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `:max_tokens` | `pos_integer()` | — (required) | Compact only when the estimate exceeds this. | | `:summarize` | `([map] -> String.t())` | — (required) | Produces the summary text. May call an LLM; the library never does on its own. | | `:keep_tail` | `pos_integer()` | `div(max_tokens, 2)` | Minimum tokens of recent tail to keep. | | `:count_tokens` | `([map] -> non_neg_integer())` | `estimate_tokens/1` | Token estimator (`ceil(byte_size(json)/4)` summed), overridable for exactness. | | `:flush_to_memory` | `boolean()` | `false` | Inject a system reminder to persist facts via the memory tool before summarizing. | | `:on_compact` | `(info -> any)` | `nil` | 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 keep them. | Since 0.22.0 the compactor never summarizes away the sole user prompt of a turn (one prompt followed only by tool traffic compacts to `[system?, prompt, summary]`), and it does nothing when the result would not be smaller than the input. ## See also [Section titled “See also”](#see-also) * [`Toolnexus.Client.create`](/api/elixir/client/hooks/) — the `:hooks` option this rides (`before_llm`). * [`Toolnexus.Agents.Home.memory_tool`](/api/elixir/persona/memory-tool/) — the builtin `flush_to_memory` asks the model to use before its context is summarized. * [`Toolnexus.Client.run`](/api/elixir/client/run/) — the loop this hook plugs into. # Toolnexus.Http.tool > Turn an HTTP endpoint into a tool — URL placeholders, querystring, body encoding, and headers with environment expansion. Elixir. **Elixir** · package `toolnexus` · [SPEC §7](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [elixir/lib/toolnexus/http.ex](https://github.com/muthuishere/toolnexus/blob/main/elixir/lib/toolnexus/http.ex) ```elixir @spec tool(keyword() | map()) :: Toolnexus.Tool.t() def tool(opts) ``` Declares a remote HTTP endpoint as a uniform [`Toolnexus.Tool`](/api/elixir/types/tool/). You give it a method, a URL and a schema; it turns the model’s arguments into a real request — substituting URL placeholders, building the querystring, encoding the body — and turns the response back into tool output. ## When to use it [Section titled “When to use it”](#when-to-use-it) * **A REST API you do not control** and that has no MCP server — a payments API, an internal microservice, a status endpoint. * **Turning an existing service into agent capability without writing a client**, when the whole integration is “call this URL with these arguments”. * **Keeping credentials out of your code**: header values expand `${ENV_VAR}` from the environment at call time and are never logged. ## Why this and not the alternative [Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative) Use define\_tool when the call needs real logic [`Toolnexus.Native.define_tool/1`](/api/elixir/native/define-tool/) is the right choice as soon as you need pagination, retries with judgement, response reshaping, or two calls chained together — write the `Req` call yourself inside `execute`. Reach for `Http.tool/1` **instead** when the mapping from arguments to one request is declarative, because then this is the whole integration. If the service already speaks MCP, prefer [`Toolnexus.Mcp.load/1`](/api/elixir/mcp/load/) — you get its own tool names, descriptions and schemas rather than hand-writing them. ## Examples [Section titled “Examples”](#examples) ### 1. Declaring an endpoint — no request is made until the model calls it [Section titled “1. Declaring an endpoint — no request is made until the model calls it”](#1-declaring-an-endpoint--no-request-is-made-until-the-model-calls-it) `tool/1` is pure construction: it builds a `%Tool{}` and touches the network only inside `execute`. That makes the declaration itself trivially testable. ```elixir alias Toolnexus.Http status = Http.tool(%{ name: "service_status", description: "Current status of the platform", method: "GET", url: "https://status.example.invalid/api/v2/status.json" }) true = status.name == "service_status" true = status.description == "Current status of the platform" # The source tag is always "http" — it is not configurable. true = status.source == "http" # No :input_schema given ⇒ the empty object schema, right for a no-argument tool. true = status.input_schema == %{ "type" => "object", "properties" => %{}, "additionalProperties" => false } # It is an ordinary Tool, so the adapters accept it unchanged. [entry] = Toolnexus.Adapters.to_anthropic([status]) true = entry["name"] == "service_status" IO.puts("ok: declared #{status.name} (#{status.source}), no request made") ``` Method is normalised, and accepts an atom `:method` is run through `to_string/1` and upcased, so `"get"`, `"GET"` and `:get` are the same thing. `:url` and `:method` are required — a missing one raises `KeyError` at declaration time, not at call time. ### 2. Path placeholders and the querystring [Section titled “2. Path placeholders and the querystring”](#2-path-placeholders-and-the-querystring) `{placeholder}` segments in the URL are filled from the arguments and **consumed**. On a `GET`, every remaining argument becomes a querystring parameter, sorted by name so the URL is deterministic. The `:req_options` below is an internal escape hatch used here to keep this page hermetic — it serves the request from an in-process plug instead of the network. Leave it out in real code. ```elixir alias Toolnexus.{Context, Http} issues = Http.tool(%{ name: "list_issues", description: "List issues on a repository", method: :get, url: "https://api.example.invalid/repos/{owner}/{repo}/issues", input_schema: %{ "type" => "object", "properties" => %{ "owner" => %{"type" => "string"}, "repo" => %{"type" => "string"}, "state" => %{"type" => "string", "enum" => ["open", "closed"]} }, "required" => ["owner", "repo"] }, # Test-only: answer in-process rather than over the wire. req_options: [ plug: fn conn -> Plug.Conn.send_resp(conn, 200, Jason.encode!(%{path: conn.request_path, qs: conn.query_string})) end ] }) res = issues.execute.( %{"owner" => "muthuishere", "repo" => "toolnexus", "state" => "open", "labels" => "bug"}, %Context{} ) false = res.is_error echo = Jason.decode!(res.output) # owner/repo were consumed by the URL. true = echo["path"] == "/repos/muthuishere/toolnexus/issues" # Everything left over became query params — sorted, so this is stable. true = echo["qs"] == "labels=bug&state=open" # The HTTP status rides along in metadata. true = res.metadata.status == 200 IO.puts("ok: #{echo["path"]}?#{echo["qs"]}") ``` Caution Arguments the model invents that are not in your schema are still sent — as extra query params on a `GET`, or extra body keys otherwise. Set `"additionalProperties" => false` in `:input_schema` if the upstream API is strict about unknown fields. ### 3. The full surface — a POST body, a secret header, failure, and `:result_mode` [Section titled “3. The full surface — a POST body, a secret header, failure, and :result\_mode”](#3-the-full-surface--a-post-body-a-secret-header-failure-and-result_mode) Header values expand `${ENV_VAR}` at **call** time, so rotating a token needs no redeploy and the value never appears in your source or in a log line. ```elixir alias Toolnexus.{Context, Http} # An obvious fake — a real deployment would already have this in the environment. System.put_env("TOOLNEXUS_DOCS_TOKEN", "YOUR_KEY_HERE") echo_plug = fn status -> fn conn -> {:ok, body, conn} = Plug.Conn.read_body(conn) auth = conn |> Plug.Conn.get_req_header("authorization") |> List.first() ctype = conn |> Plug.Conn.get_req_header("content-type") |> List.first() Plug.Conn.send_resp( conn, status, Jason.encode!(%{method: conn.method, body: body, auth: auth, ctype: ctype}) ) end end send_message = Http.tool(%{ name: "send_message", description: "Post a message to a channel", method: "POST", url: "https://api.example.invalid/channels/{channel}/messages", headers: %{"Authorization" => "Bearer ${TOOLNEXUS_DOCS_TOKEN}"}, query: ["dry_run"], body: "json", result_mode: "status+text", timeout: 5_000, input_schema: %{ "type" => "object", "properties" => %{ "channel" => %{"type" => "string"}, "text" => %{"type" => "string"}, "dry_run" => %{"type" => "string"} }, "required" => ["channel", "text"], "additionalProperties" => false }, req_options: [plug: echo_plug.(200)] }) res = send_message.execute.( %{"channel" => "general", "text" => "shipped", "dry_run" => "1"}, %Context{} ) false = res.is_error # result_mode "status+text" prefixes the status and a newline. ["200", payload] = String.split(res.output, "\n", parts: 2) echo = Jason.decode!(payload) true = echo["method"] == "POST" # channel went into the path, dry_run into the query, so only `text` is left for the body. true = Jason.decode!(echo["body"]) == %{"text" => "shipped"} # Content-Type is filled in for you when you do not set it. true = echo["ctype"] == "application/json" # The ${ENV_VAR} was expanded at call time. true = echo["auth"] == "Bearer YOUR_KEY_HERE" # A non-2xx response is a tool ERROR, not a raise — the model reads the text and can react. failing = Http.tool(%{ name: "send_message", description: "Post a message to a channel", method: "POST", url: "https://api.example.invalid/channels/{channel}/messages", req_options: [plug: echo_plug.(422)] }) bad = failing.execute.(%{"channel" => "general", "text" => "shipped"}, %Context{}) true = bad.is_error true = String.starts_with?(bad.output, "HTTP 422: ") true = bad.metadata.status == 422 System.delete_env("TOOLNEXUS_DOCS_TOKEN") IO.puts("ok: 200 status+text, header expanded, 422 -> is_error") ``` ## Options [Section titled “Options”](#options) Accepts a **map or a keyword list**. | Option | Required | Default | What it does | | --------------- | -------- | ------------------- | ----------------------------------------------------------------------------------------------------------------- | | `:name` | yes | — | The name the model calls. | | `:description` | yes | — | What the model reads to decide whether to call it. | | `:method` | yes | — | Upcased string or atom: `:get`, `"post"`, `"PATCH"`… | | `:url` | yes | — | May contain `{placeholder}` segments, filled from args and URL-encoded. | | `:headers` | no | `%{}` | Name → value. Values expand `${ENV_VAR}` at call time; expanded values are never logged. | | `:query` | no | `[]` | Argument names to send as querystring instead of body. On `GET`, *all* remaining args go to the query regardless. | | `:body` | no | `"json"` | `"json"`, `"form"`, or `"raw"` (sends the `body` argument verbatim). Ignored on `GET` / `HEAD`. | | `:input_schema` | no | empty object schema | JSON-Schema object, string-keyed. | | `:timeout` | no | `30_000` | Receive timeout in milliseconds. | | `:result_mode` | no | `"text"` | `"text"` (body), `"json"` (re-encoded JSON, falling back to the raw text), or `"status+text"`. | | `:req_options` | no | `[]` | Internal — extra `Req.new/1` options, e.g. `plug:` in tests. Not part of the cross-port contract. | ### How arguments are routed [Section titled “How arguments are routed”](#how-arguments-are-routed) 1. `{placeholder}` names in the URL, URL-encoded and **removed** from the argument map. 2. Names listed in `:query` — or, on a `GET`, everything remaining — become querystring pairs, sorted by name. 3. Whatever is still left becomes the body on non-`GET`/`HEAD` requests, per `:body`. ### What comes back [Section titled “What comes back”](#what-comes-back) | Case | `output` | `is_error` | `metadata` | | ----------------- | ------------------------- | ---------- | ------------------- | | 2xx | Per `:result_mode` | `false` | `%{status: status}` | | non-2xx | `"HTTP : "` | `true` | `%{status: status}` | | transport failure | The exception message | `true` | `nil` | ## See also [Section titled “See also”](#see-also) * [`Toolnexus.Native.define_tool`](/api/elixir/native/define-tool/) — when one declarative request is not enough * [`Toolnexus.Mcp.load`](/api/elixir/mcp/load/) — prefer this when the service already speaks MCP * [`Toolnexus.create_toolkit`](/api/elixir/toolkit/create/) — pass these in as `:tools` * [`Toolnexus.Tool`](/api/elixir/types/tool/) · [`Toolnexus.ToolResult`](/api/elixir/types/tool-result/) # Toolnexus.Classifier.create > A sibling of the client: pre-declared typed questions in, calibrated answers out — no messages, no tool calling, no loop. — Elixir. **Elixir** · package `toolnexus` · [SPEC §8B](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [elixir/lib/toolnexus/classifier.ex](https://github.com/muthuishere/toolnexus/blob/main/elixir/lib/toolnexus/classifier.ex) ```elixir Toolnexus.Classifier.create(keyword() | map()) :: {:ok, Classifier.t()} | {:error, String.t()} Toolnexus.Classifier.evaluate(Classifier.t(), state :: term(), %{String.t() => question()}) :: {:ok, Toolnexus.Classifier.Decision.t()} | {:error, String.t()} Toolnexus.Classifier.canonical_request(model :: String.t(), questions :: map()) :: binary() Toolnexus.Classifier.near_uniform?(probabilities :: map()) :: boolean() Toolnexus.Classifier.choice_over(instructions :: String.t(), items :: map()) :: Choice.t() ``` A sibling of the client: pre-declared typed questions in, calibrated answers out — no messages, no tool calling, no loop. `Toolnexus.Tool` is the contract for an **action**; `Toolnexus.Classifier` is the contract for a **judgment**. It is never selected as a model for `Client.run/4` or `Client.ask/3`, and constructing one alters no request the client loop makes. Every function returns `{:ok, _}` or `{:error, reason}` — the Elixir port never raises where the JS, Python, Java and C# ports throw, and the reason is a plain sentence naming what went wrong. A classifier interprets; it never authorises A decision is a reading of intent inside a boundary, never the boundary. Numeric limits, permission checks and allowlists stay in your `if` statements. Schema validity is not correctness: a decision can be confidently wrong, and “cannot hallucinate” means only that the returned value is inside the declared schema. Nothing here is a security control. ## When to use it [Section titled “When to use it”](#when-to-use-it) * **A judgment that steers an action, not the action itself** — is this shell command risky, does this turn need the billing skill, which desk owns this ticket, how urgent is it. See [Typed decisions](/judge/) for why this is a third tier next to an `if` and a frontier turn. * **You want a number you can threshold**, not prose you have to parse. There is no parsing step afterwards, because there is nothing to parse. * **You are already holding the state** — a string, a map or a list. It is transmitted verbatim. ## Why this and not the alternative [Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative) Use Client.run/4 when the decision must also DO something A classifier has no messages, no tool calling and no streaming. If the thing you want is an action — call a tool, write a file, answer a user — that is [`Toolnexus.Client.run/4`](/api/elixir/client/run/). Reach for a classifier only when the output is a judgment that your own code then acts on. Running both is normal: a cheap classifier routes, the client loop executes. ## Options [Section titled “Options”](#options) Every option below is a keyword on `create/1` (a map works too). Names are the port-local snake\_case spelling of `ClassifierOptions` (SPEC §8B), which mirrors [`ClientOptions`](/api/elixir/client/create/) field-for-field wherever a field makes sense. | option | default | what it does | | --------------------- | ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `:style` | `"systemone"` | `"systemone"` \| `"llm"` \| `"custom"` \| `"static"`. An unknown style is rejected by `create/1`, not at call time | | `:base_url` | `"https://api.typesafe.ai/v1"` | the System One endpoint base. OpenRouter serves the same wire at `"https://openrouter.ai/api/v1"` | | `:model` | `"jev-latest"` | pin it (e.g. `"jev-1.13.0"`) once thresholds are tuned. `Decision.model` echoes what actually answered, which may be more specific | | `:api_key_env` | `"TYPESAFE_API_KEY"` | the **name** of an env var, never the value. Read at call time, never logged. §8’s `:api_key` takes a value; this option deliberately does not | | `:headers` | `%{}` | extra headers; values expand `${ENV_VAR}` from the environment **at call time** and are never logged, identically to remote-MCP headers | | `:timeout` | `10_000` | milliseconds **per request**, not per run — a classifier has no loop to bound | | `:http_options` | `[]` | extra `Req` options for the classifier path only (`plug:`, `retry:`, proxying) | | `:transport` | — | the injectable HTTP transport: a 1-arity function over a request map returning `{:ok, %{status:, headers:, body:}}` or `{:error, exception}`. `body` is the canonical request **binary**. This is the port’s spelling of `httpClient` | | `:retries` | `2` | retry budget per evaluate. The backoff is `retry_base_ms * 2^attempt`, no jitter, and a `Retry-After` header still wins | | `:retry_base_ms` | `500` | base of that backoff, in ms. The classifier’s half of the §8 `:retry_base_ms` it mirrors — set it to `1` to take a retry test off the clock | | `:on_error` | — | the §8 classifier verbatim: a map with `:attempt`, `:retryable` and one of `:status` / `:error` in, `:retry` or `:fail` out. There is no `:suspend` tier here | | `:retryable_statuses` | — | extra HTTP statuses to treat as retryable. **Adds only, never removes** — a host cannot drop `429` and lose `Retry-After` with it — and `:on_error` still decides each attempt, so `:fail` overrides a status listed here | | `:request_params` | — | merged into the base body before marshalling; a caller key wins | | `:body_transform` | — | a 1-arity function run **after** the `:request_params` merge and before marshalling. Order is base body → `:request_params` → `:body_transform` → marshal | | `:on_metric` | — | receives `"classifier.evaluate"` events into the **same** sink as the client, and the degenerate-criteria advisory as `"classifier.warning"` | | `:client` | — | `style: "llm"` only — the `Toolnexus.Client` to emulate over | | `:evaluate` | — | `style: "custom"` only — your own `(state, questions) -> {:ok, Decision.t()} \| {:error, term}`. Every wire option is ignored | | `:decisions` | `[]` | the `static` corpus: a list of `%{state: …, questions: …, response: map_or_binary}`. `style: "static"` only. Gated at **core** tier by the options manifest | One port-local note against the options manifest: the injectable transport is split in two here — `:http_options` (pass-through to `Req`) and `:transport` (replace the call entirely) — where other ports carry one `httpClient`. ### Retries [Section titled “Retries”](#retries) The default retryable set is `408`, `429`, `500`, `502`, `503`, `504`, `529`, plus network errors. `408` is the classifier’s addition over the §8 client set; `529` is there because TypeSafe documents it as retry-with-backoff. It is an enumeration on purpose — “any 5xx” would sweep in `501` and `505`, which never become healthy. `Retry-After` is honoured with the §8 `delay-seconds` rule, and a `401`/`403` body is never echoed into the error, because gateways reflect the credential they were sent. ### Backends [Section titled “Backends”](#backends) * **`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. * **`llm`** — the three question types rendered as one structured-output call on any §8 client, so a host with no System One credential runs the same questions on a cheap chat model. It reports `calibrated: false`. * **`custom`** — your own `:evaluate`: a fine-tuned encoder, a rules engine, or a cache. * **`static`** — recorded decisions keyed by the canonical request **and** the state. This is what CI runs: no network, no credential, and the only backend a test may assert a number against. Full comparison, including what `llm` costs you, in [Backends](/judge/backends/). ## Examples [Section titled “Examples”](#examples) ### 1. The smallest useful call — one `noul`, recorded, no network [Section titled “1. The smallest useful call — one noul, recorded, no network”](#1-the-smallest-useful-call--one-noul-recorded-no-network) ```elixir alias Toolnexus.Classifier alias Toolnexus.Classifier.{Decision, Noul} state = "Order 4021 arrived smashed and I would like my money back." questions = %{"wants_refund" => %Noul{instructions: "Is the customer asking for a refund?"}} {:ok, judge} = Classifier.create( style: "static", model: "jev-1.13.0", decisions: [ %{ state: state, questions: questions, response: %{ "model" => "typesafe/jev-1.13-20260917", "answers" => %{"wants_refund" => %{"type" => "noul", "noul" => 0.97}}, "usage" => %{"input_tokens" => 112, "output_tokens" => 9} } } ] ) {:ok, decision} = Classifier.evaluate(judge, state, questions) {:ok, answer} = Decision.noul(decision, "wants_refund") true = answer.noul > 0.9 true = decision.calibrated true = decision.model == "typesafe/jev-1.13-20260917" IO.puts("ok: wants_refund = #{answer.noul} answered by #{decision.model}") ``` ### 2. The realistic case — a `custom` backend, with the metric sink wired [Section titled “2. The realistic case — a custom backend, with the metric sink wired”](#2-the-realistic-case--a-custom-backend-with-the-metric-sink-wired) `style: "custom"` hands the whole evaluation to your function, so a cache, a rules engine or a local model drops straight in. The `:on_metric` sink is the same one the client uses. ```elixir alias Toolnexus.Classifier alias Toolnexus.Classifier.{ChoiceAnswer, Decision, Usage} {:ok, events} = Agent.start_link(fn -> [] end) rules = fn state, _questions -> desk = if String.contains?(state, "charged"), do: "billing", else: "technical" {:ok, %Decision{ model: "rules-v1", answers: %{ "department" => %ChoiceAnswer{ choice: desk, probabilities: %{"billing" => 1.0, "technical" => 0.0}, confidence: 1.0, near_uniform: false } }, usage: %Usage{input_tokens: 0, output_tokens: 0}, # A rules engine derives nothing from token probabilities, so it says so. calibrated: false }} end {:ok, judge} = Classifier.create( style: "custom", evaluate: rules, on_metric: fn ev -> Agent.update(events, &[ev | &1]) end ) {:ok, decision} = Classifier.evaluate( judge, "I was charged twice for the annual plan", %{ "department" => Classifier.choice_over("Which desk should own this ticket?", %{ "billing" => "own it here when the problem is money that moved: a duplicate charge, a refund owed", "technical" => "own it here when the problem is the product itself: a login that fails" }) } ) {:ok, choice} = Decision.choice(decision, "department") true = choice.choice == "billing" false = decision.calibrated [ev] = Agent.get(events, & &1) true = ev.event == "classifier.evaluate" true = ev.status == "ok" IO.puts("ok: routed to #{choice.choice}; one #{ev.event} event, calibrated=#{decision.calibrated}") ``` ### 3. The full surface — defaults, the additive retry set, and a rejected style [Section titled “3. The full surface — defaults, the additive retry set, and a rejected style”](#3-the-full-surface--defaults-the-additive-retry-set-and-a-rejected-style) `:retryable_statuses` widens the default set and can never narrow it, and `create/1` rejects a style whose required option is missing **before** any call is made. ```elixir alias Toolnexus.Classifier {:ok, default} = Classifier.create() true = default.style == "systemone" true = default.base_url == "https://api.typesafe.ai/v1" true = default.model == "jev-latest" true = default.api_key_env == "TYPESAFE_API_KEY" true = default.timeout == 10_000 true = default.retries == 2 # Same values, read off the module rather than a literal. true = Classifier.default_base_url() == default.base_url true = Classifier.default_model() == default.model true = Classifier.default_api_key_env() == default.api_key_env true = Classifier.default_timeout() == default.timeout {:ok, tuned} = Classifier.create( base_url: "https://openrouter.ai/api/v1", model: "typesafe/jev-1.13", api_key_env: "OPENROUTER_API_KEY", headers: %{"x-tenant" => "acme", "x-token" => "${SOME_TENANT_TOKEN}"}, timeout: 3_000, retries: 4, # Cloudflare-fronted origins answer 520..527. ADDED to 408/429/500/502/503/504/529. retryable_statuses: [520, 521], request_params: %{"metadata" => %{"job" => "ticket-routing"}}, body_transform: fn body -> Map.put(body, "trace_id", "t-1") end ) true = tuned.retryable_statuses == [520, 521] true = tuned.api_key_env == "OPENROUTER_API_KEY" # A missing required option is an error from create/1, not a surprise at call time. {:error, why} = Classifier.create(style: "llm") true = String.contains?(why, ~s(style "llm" requires :client)) {:error, _} = Classifier.create(style: "custom") {:error, _} = Classifier.create(style: "telepathy") IO.puts("ok: defaults intact, retryable widened to #{inspect(tuned.retryable_statuses)}, #{why}") ``` ## See also [Section titled “See also”](#see-also) * [`Toolnexus.Classifier.Noul`](/api/elixir/judge/questions/) — The three question types, the criteria each one needs, and the limits enforced client-side before the request. * [`Toolnexus.Classifier.Decision`](/api/elixir/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 its own contract, and where it sits next to code and a frontier turn. * [Backends](/judge/backends/) — `systemone` / `llm` / `custom` / `static`, with the measured cost of each. * [The encoding obligation](/judge/encoding/) — the one thing that decides whether your answers mean anything. * [Cookbook: a typed decision](/cookbook/judge/) — the runnable end-to-end version of this page. # Toolnexus.Classifier.Decision > One answer per question under the caller's own keys, read through typed accessors that fail loudly rather than hand back a zero. — Elixir. **Elixir** · package `toolnexus` · [SPEC §8B](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [elixir/lib/toolnexus/classifier.ex](https://github.com/muthuishere/toolnexus/blob/main/elixir/lib/toolnexus/classifier.ex) ```elixir %Toolnexus.Classifier.Decision{ model: String.t() | nil, answers: %{String.t() => NoulAnswer.t() | ChoiceAnswer.t() | ScoreAnswer.t()}, usage: Toolnexus.Classifier.Usage.t(), calibrated: boolean() } Decision.noul(Decision.t(), key) :: {:ok, NoulAnswer.t()} | {:error, String.t()} Decision.choice(Decision.t(), key) :: {:ok, ChoiceAnswer.t()} | {:error, String.t()} Decision.score(Decision.t(), key) :: {:ok, ScoreAnswer.t()} | {:error, String.t()} %NoulAnswer{noul: number()} %ChoiceAnswer{choice: String.t() | nil, probabilities: map(), confidence: number(), near_uniform: boolean()} %ScoreAnswer{score: number(), legend: map(), probabilities: map(), confidence: number()} %Usage{input_tokens: integer(), output_tokens: integer(), cost: number() | nil} Toolnexus.Classifier.ScoreAnswer.levels(ScoreAnswer.t()) :: [String.t()] Toolnexus.Classifier.near_uniform?(probabilities :: map()) :: boolean() ``` One answer per question under the caller’s own keys, read through typed accessors that fail loudly rather than hand back a zero. The keys are yours: they are addressing, not content, and are never transmitted, so a key may be a tool, skill or agent name verbatim. ## When to use it [Section titled “When to use it”](#when-to-use-it) * **Read one answer by key and type** — `Decision.noul/2`, `Decision.choice/2`, `Decision.score/2`. A missing key or a wrong type comes back as `{:error, reason}` naming the key and what it actually is; in the JS, Python, Java and C# ports the same mistake throws, and in Go it is the second return value. It is never a silent `0.0` and never a `MatchError` from inside the library. * **Read the two health flags** — `:calibrated` on the decision, `:near_uniform` on every choice answer. Both are advisory; see below. * **Read `:usage`** — `:cost` is a gateway field. TypeSafe’s own API does not return one, and **absent is not zero**: print “not reported” rather than a `$0.00` that reads as a free call. The three answer shapes differ on purpose: | answer | what it carries | | ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `%NoulAnswer{}` | `:noul`, one number in `0..1`, and **no confidence** — the number *is* the answer | | `%ChoiceAnswer{}` | the selected `:choice` (always one you offered), a `:probabilities` entry for **every** offered option, a `:confidence`, and the derived `:near_uniform` | | `%ScoreAnswer{}` | a `:score` that **may fall between levels** (`1.21` is a real answer) within the rubric’s bounds, a `:probabilities` entry per level index, and the rubric echoed back as `:legend` | `:legend` and `:probabilities` are keyed by **string** level indices (`"0"`, `"1"`, …) and stay strings throughout: atomising keys a remote backend controls is unbounded atom growth, and map order would lose the numbering anyway. `ScoreAnswer.levels/1` gives the legend back in level order, which sorting the map’s keys as strings would not (`"2"` before `"10"`). ## Why this and not the alternative [Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative) Pattern-match the struct only once you know the type `decision.answers["urgency"]` gives you the struct directly, which is fine when the question type is obvious at the call site. The accessors exist for the case where it is not: a renamed key, a question type changed in a config, a recorded corpus that drifted. Then a bare `%ScoreAnswer{score: s} = decision.answers["urgency"]` raises a `MatchError` that says nothing, while `Decision.score(decision, "urgency")` returns `classifier: answer "urgency" is a choice answer, not score`. ## `calibrated` and `near_uniform?` — advisory, and that word is load-bearing [Section titled “calibrated and near\_uniform? — advisory, and that word is load-bearing”](#calibrated-and-near_uniform--advisory-and-that-word-is-load-bearing) `:calibrated` travels with every decision. The `systemone` style reports **true**; the `llm` style reports **false** unless it derived its probabilities from provider token probabilities. **A response that omits `calibrated`, or sends `null`, decodes as `true`** — only the literal `false` is false, because the systemone wire reports calibration by being itself and a backend that is not calibrated says so explicitly. It is the flag that tells you whether a threshold you tuned is meaningful — and **a threshold tuned against one backend does not transfer to another**. `:near_uniform` is on every choice answer, **derived from the response and never read from the wire**. With `n` the number of entries in the `:probabilities` map and `p_i` their values **as returned**: ```plaintext near_uniform ⇔ max over i of |p_i − 1/n| ≤ 0.05 ``` * the tolerance is **0.05 absolute** and the comparison is **inclusive** — a maximum deviation of exactly 0.05 *is* near-uniform; * the probabilities are never sorted, renormalised or rounded first, and an offered option absent from the map counts as `0` by not being an entry; * `n == 1` is trivially uniform, so `true`; an **empty** map has no distribution at all and is `false`. `Toolnexus.Classifier.near_uniform?/1` is public, so you can run the same rule over a probability map you got from somewhere else. Why 0.05 and not something else — wire rounding, backend non-determinism, and the separation it has to make — is in [The encoding obligation](/judge/encoding/). What neither flag detects `near_uniform` detects an encoding that gave the model **nothing to rank on** — usually options that were never described. That is all it detects. It cannot distinguish a good encoding from a subtly wrong one: a wrong-but-answerable question still reads as answerable, and confidence reports on the **question**, not on the answer (the worst working encoding measured carried the *highest* median confidence). `calibrated` carries the same caveat: calibration is not correctness. Neither flag is a correctness signal, and neither is a security control — a decision interprets, it never authorises. ## Examples [Section titled “Examples”](#examples) ### 1. The smallest useful call — reading all three answer shapes [Section titled “1. The smallest useful call — reading all three answer shapes”](#1-the-smallest-useful-call--reading-all-three-answer-shapes) ```elixir alias Toolnexus.Classifier alias Toolnexus.Classifier.{Choice, Decision, Noul, Score, ScoreAnswer} state = "Ticket 4021: charged twice; not blocked, but I want the money back this week." questions = %{ "wants_money_back" => %Noul{instructions: "Is the customer asking for money to be returned?"}, "department" => %Choice{ instructions: "Which desk should own this ticket?", criteria: %{ "billing" => "own it here when the problem is money that moved: a duplicate charge, a refund owed", "technical" => "own it here when the problem is the product itself: a login that fails" } }, "urgency" => %Score{ instructions: "How fast does this ticket need a human?", criteria: [ "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" ] } } response = %{ "model" => "typesafe/jev-1.13-20260917", "answers" => %{ "wants_money_back" => %{"type" => "noul", "noul" => 0.99}, "department" => %{ "type" => "choice", "choice" => "billing", "probabilities" => %{"billing" => 0.94, "technical" => 0.06}, "confidence" => 0.94 }, "urgency" => %{ "type" => "score", "score" => 0.49, "legend" => %{ "0" => "the customer is working normally and is waiting on an answer", "1" => "the customer is inconvenienced and will chase if nobody replies today", "2" => "the customer is blocked from working right now and every hour costs them" }, "probabilities" => %{"0" => 0.52, "1" => 0.48, "2" => 0}, "confidence" => 0.27 } }, "usage" => %{"input_tokens" => 516, "output_tokens" => 72} } {:ok, judge} = Classifier.create( style: "static", model: "jev-1.13.0", decisions: [%{state: state, questions: questions, response: response}] ) {:ok, decision} = Classifier.evaluate(judge, state, questions) {:ok, refund} = Decision.noul(decision, "wants_money_back") {:ok, desk} = Decision.choice(decision, "department") {:ok, urgency} = Decision.score(decision, "urgency") # A noul carries NO confidence — the number IS the answer. true = refund.noul == 0.99 true = desk.choice == "billing" false = desk.near_uniform # A score may land BETWEEN levels: 0.49 is a genuine split between level 0 and level 1. true = urgency.score == 0.49 level = round(urgency.score) true = Map.fetch!(urgency.legend, to_string(level)) == Enum.at(ScoreAnswer.levels(urgency), level) # :cost is a gateway field. Absent is NOT zero. true = decision.usage.cost == nil true = decision.usage.input_tokens == 516 IO.puts("ok: refund=#{refund.noul} desk=#{desk.choice} urgency=#{urgency.score} cost=not reported") ``` ### 2. The realistic case — the accessors fail, they do not lie [Section titled “2. The realistic case — the accessors fail, they do not lie”](#2-the-realistic-case--the-accessors-fail-they-do-not-lie) ```elixir alias Toolnexus.Classifier.{ChoiceAnswer, Decision, NoulAnswer, Usage} decision = %Decision{ model: "typesafe/jev-1.13-20260917", answers: %{ "is_injection" => %NoulAnswer{noul: 0.04}, "route" => %ChoiceAnswer{ choice: "billing", probabilities: %{"billing" => 0.88, "technical" => 0.12}, confidence: 0.88, near_uniform: false } }, usage: %Usage{input_tokens: 90, output_tokens: 11}, calibrated: true } {:ok, injection} = Decision.noul(decision, "is_injection") true = injection.noul < 0.1 # Wrong TYPE: named, not coerced, and not a MatchError from inside the library. {:error, wrong_type} = Decision.score(decision, "route") true = String.contains?(wrong_type, ~s("route")) true = String.contains?(wrong_type, "is a choice answer, not score") # Missing KEY: named too. A typo does not read back as 0.0. {:error, missing} = Decision.choice(decision, "departement") true = String.contains?(missing, ~s("departement")) true = String.contains?(missing, "no answer") # Thresholding is YOUR if statement. The decision advises; the code decides. {:ok, route} = Decision.choice(decision, "route") desk = if route.confidence >= 0.7 and not route.near_uniform, do: route.choice, else: "human_triage" true = desk == "billing" IO.puts("ok: #{wrong_type} / #{missing}") ``` ### 3. The full surface — the `near_uniform?` rule, both sides of the boundary [Section titled “3. The full surface — the near\_uniform? rule, both sides of the boundary”](#3-the-full-surface--the-near_uniform-rule-both-sides-of-the-boundary) ```elixir alias Toolnexus.Classifier alias Toolnexus.Classifier.Decision # Four options, 1/n = 0.25. The rule is |p - 1/n| <= 0.05, ABSOLUTE and INCLUSIVE. true = Classifier.near_uniform?(%{"a" => 0.25, "b" => 0.25, "c" => 0.25, "d" => 0.25}) # Deviation exactly 0.05 on the extremes: inclusive, so still near-uniform. true = Classifier.near_uniform?(%{"a" => 0.30, "b" => 0.20, "c" => 0.25, "d" => 0.25}) # One point past it. false = Classifier.near_uniform?(%{"a" => 0.31, "b" => 0.19, "c" => 0.25, "d" => 0.25}) # A decided answer is nowhere near. false = Classifier.near_uniform?(%{"a" => 0.80, "b" => 0.10, "c" => 0.05, "d" => 0.05}) # n == 1 is trivially uniform; an empty map has no distribution at all. true = Classifier.near_uniform?(%{"only" => 1.0}) false = Classifier.near_uniform?(%{}) # The flag is DERIVED on decode, never read from the wire — no "near_uniform" field is sent. flat = %{ "model" => "chat/gpt-x", "answers" => %{ "route" => %{ "type" => "choice", "choice" => "billing", "probabilities" => %{"billing" => 0.26, "shipping" => 0.25, "technical" => 0.25, "other" => 0.24}, "confidence" => 0.26 } }, "usage" => %{"input_tokens" => 40, "output_tokens" => 8}, # An llm-style backend that derived nothing from token probabilities says so. "calibrated" => false } {:ok, decision} = Classifier.decode_decision(flat) {:ok, route} = Decision.choice(decision, "route") true = route.near_uniform false = decision.calibrated # Advisory, not correctness: this decision is well-formed, schema-valid and picked an option. # What it tells you is that the model had nothing to rank on — go describe the options. true = route.choice == "billing" IO.puts("ok: near_uniform=#{route.near_uniform} calibrated=#{decision.calibrated} — both advisory") ``` ## See also [Section titled “See also”](#see-also) * [`Toolnexus.Classifier.create`](/api/elixir/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`](/api/elixir/judge/questions/) — The three question types, the criteria each one needs, and the limits enforced client-side before the request. * [The encoding obligation](/judge/encoding/) — where the 0.05 comes from, and what neither flag detects. * [Backends](/judge/backends/) — which backends report `calibrated: true`, and what `llm` costs. * [Cookbook: a typed decision](/cookbook/judge/) — the same decision, printed end to end. # Toolnexus.Classifier.Noul > The three question types, the criteria each one needs, and the limits enforced client-side before the request. — Elixir. **Elixir** · package `toolnexus` · [SPEC §8B](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [elixir/lib/toolnexus/classifier.ex](https://github.com/muthuishere/toolnexus/blob/main/elixir/lib/toolnexus/classifier.ex) ```elixir %Toolnexus.Classifier.Noul{instructions: String.t(), criteria: map() | nil} # criteria: %{"true" => description, "false" => description} — or nil %Toolnexus.Classifier.Choice{instructions: String.t(), criteria: %{String.t() => String.t()}} # criteria: one description per option id, 1..255 options %Toolnexus.Classifier.Score{instructions: String.t(), criteria: [String.t()]} # criteria: 2..10 ordered levels — the LIST ORDER is the level numbering Toolnexus.Classifier.choice_over(instructions :: String.t(), items :: map()) :: Choice.t() ``` The three question types, the criteria each one needs, and the limits enforced client-side before the request. You pass them as a map from **caller-chosen keys** to question structs. The keys are addressing, not content: they are never transmitted, 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 — which is also why many questions in one call cost one state ingest instead of several. ## When to use it [Section titled “When to use it”](#when-to-use-it) | type | you get back | reach for it when | | ----------- | --------------------------------------------------------------------------------------------------------- | --------------------------------------------------- | | `%Noul{}` | one number in `0..1` — **no confidence; the number is the answer** | is this true, is this text from an untrusted source | | `%Choice{}` | one of **your** named options, a probability for **every** option, and a confidence | routing to a skill, a tool, an agent, a move | | `%Score{}` | a number against an ordered rubric of 2–10 levels (`1.21` is a real answer), with a probability per level | risk, urgency, severity | * **`%Noul{}`** — `:criteria` labels the true and the false case, and is optional. `nil` and `%{"true" => "", "false" => ""}` are **different values**: `nil` omits the field from the wire entirely, an empty map sends empty strings. Both survive intact. * **`%Choice{}`** — the answer is always one of the options you offered, and the probability map names exactly those options. `choice_over/2` builds one from any (name, description) pairs — an atom key travels as its plain name, so `:billing` sends `billing`, never `":billing"`, and an atom roster and its string equivalent are the same request. * **`%Score{}`** — the list order **is** the numbering; level 0 is the first string you passed. Nothing sorts it, and no canonicaliser in the library reorders an array for exactly this reason. ## Why this and not the alternative [Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative) criteria\[id] is the whole ball game on a choice For a `%Choice{}`, the description is the **only** thing that differentiates one option from another to the model. The instructions describe the question and the state describes the situation; neither tells the model what picking `billing` rather than `technical` would mean. Passing the id itself, an empty string, or one value repeated is schema-valid, returns HTTP 200 and a well-formed distribution — and ranks at chance. Measured: options described by consequence scored **17, 17, 17** apples; the same state with each description replaced by its own id scored **0, 1, 0**, on the floor of a shuffle control. The full argument, with the three rules that follow from it, is in [The encoding obligation](/judge/encoding/). ## Limits, enforced before the request [Section titled “Limits, enforced before the request”](#limits-enforced-before-the-request) `evaluate/3` validates every question **client-side**, walking the keys in sorted order so the same malformed set always names the same key first. On a violation you get `{:error, reason}`, the reason **names the offending question key and the limit**, and **no request is sent** — faster and more legible than the backend’s own `400 "Too many choices. Must have at most 255 choices."`, which is still surfaced intact if it ever arrives. | rule | limit | failure | | ----------------------- | --------- | --------------------------------------------------------------------- | | a `%Choice{}`’s options | 1..255 | `classifier: question "k": a choice needs 1..255 options, got N` | | a `%Score{}`’s levels | 2..10 | `classifier: question "k": a score needs 2..10 ordered levels, got N` | | a `%Noul{}`’s criteria | none | absent (`nil`) and empty are both valid, and different | | the questions map | non-empty | `classifier: no questions to evaluate` | Separately, degenerate `%Choice{}` criteria — every description empty, or every description equal to its own id, or every description identical — emit **one** `"classifier.warning"` event per question key per classifier, carried in the event’s `:warning` field and never `:error`. The request still goes out **byte-unchanged**: this is detection, not repair, because repairing would invent descriptions you did not write. A single-option choice is never reported. ## Examples [Section titled “Examples”](#examples) ### 1. The smallest useful call — all three types in one evaluation [Section titled “1. The smallest useful call — all three types in one evaluation”](#1-the-smallest-useful-call--all-three-types-in-one-evaluation) ```elixir alias Toolnexus.Classifier alias Toolnexus.Classifier.{Choice, Decision, Noul, Score} state = "Ticket 4021: charged twice for the annual plan; not blocked, but I want the money back." questions = %{ "wants_money_back" => %Noul{instructions: "Is the customer asking for money to be returned?"}, "department" => %Choice{ instructions: "Which desk should own this ticket?", criteria: %{ "billing" => "own it here when the problem is money that moved: a duplicate charge, a refund owed", "shipping" => "own it here when the problem is a physical parcel: a late or damaged delivery", "technical" => "own it here when the problem is the product itself: a login that fails" } }, "urgency" => %Score{ instructions: "How fast does this ticket need a human?", criteria: [ "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" ] } } recorded = %{ "model" => "typesafe/jev-1.13-20260917", "answers" => %{ "wants_money_back" => %{"type" => "noul", "noul" => 0.99}, "department" => %{ "type" => "choice", "choice" => "billing", "probabilities" => %{"billing" => 1, "shipping" => 0, "technical" => 0}, "confidence" => 1 }, "urgency" => %{ "type" => "score", "score" => 0.49, "legend" => %{"0" => "waiting", "1" => "inconvenienced", "2" => "blocked"}, "probabilities" => %{"0" => 0.52, "1" => 0.48, "2" => 0}, "confidence" => 0.27 } }, "usage" => %{"input_tokens" => 516, "output_tokens" => 72} } {:ok, judge} = Classifier.create( style: "static", model: "jev-1.13.0", decisions: [%{state: state, questions: questions, response: recorded}] ) {:ok, decision} = Classifier.evaluate(judge, state, questions) {:ok, noul} = Decision.noul(decision, "wants_money_back") {:ok, choice} = Decision.choice(decision, "department") {:ok, score} = Decision.score(decision, "urgency") true = noul.noul == 0.99 true = choice.choice == "billing" # A score MAY fall between levels — 0.49 is a genuine split between level 0 and level 1. true = score.score == 0.49 IO.puts("ok: refund=#{noul.noul} desk=#{choice.choice} urgency=#{score.score}") ``` ### 2. The realistic case — the limits fire before anything leaves the process [Section titled “2. The realistic case — the limits fire before anything leaves the process”](#2-the-realistic-case--the-limits-fire-before-anything-leaves-the-process) No credential is configured here and none is needed: validation runs first, so an over-long rubric or an empty option set never reaches the wire. ```elixir alias Toolnexus.Classifier alias Toolnexus.Classifier.{Choice, Noul, Score} # style: "systemone" with NO network reachable — the point is that we never get there. {:ok, judge} = Classifier.create(base_url: "http://127.0.0.1:1/never-called") {:error, too_many_levels} = Classifier.evaluate(judge, "some state", %{ "urgency" => %Score{ instructions: "How urgent?", criteria: Enum.map(1..11, fn i -> "level #{i}" end) } }) true = String.contains?(too_many_levels, ~s("urgency")) true = String.contains?(too_many_levels, "2..10") {:error, too_many_options} = Classifier.evaluate(judge, "some state", %{ "route" => %Choice{ instructions: "Where to?", criteria: Map.new(1..256, fn i -> {"opt#{i}", "description #{i}"} end) } }) true = String.contains?(too_many_options, ~s("route")) true = String.contains?(too_many_options, "1..255") {:error, empty} = Classifier.evaluate(judge, "some state", %{}) true = String.contains?(empty, "no questions") # A noul needs no criteria at all: nil is valid, and is NOT the same value as an empty map. absent = Classifier.canonical_request("m", %{"q" => %Noul{instructions: "true?"}}) empty_crit = Classifier.canonical_request("m", %{"q" => %Noul{instructions: "true?", criteria: %{}}}) true = absent != empty_crit false = String.contains?(absent, "criteria") true = String.contains?(empty_crit, "criteria") IO.puts("ok: limits named the key before any request — #{too_many_levels}") ``` ### 3. The full surface — degenerate criteria are reported, once, and change nothing [Section titled “3. The full surface — degenerate criteria are reported, once, and change nothing”](#3-the-full-surface--degenerate-criteria-are-reported-once-and-change-nothing) ```elixir alias Toolnexus.Classifier {:ok, warnings} = Agent.start_link(fn -> [] end) state = "the user typed: open the billing page" # Every description is just its own option id — schema-valid, HTTP 200, ranks at chance. bad = %{ "route" => Classifier.choice_over("Where should this go?", %{ "billing" => "billing", "shipping" => "shipping" }) } recorded = %{ "model" => "typesafe/jev-1.13-20260917", "answers" => %{ "route" => %{ "type" => "choice", "choice" => "billing", "probabilities" => %{"billing" => 0.51, "shipping" => 0.49}, "confidence" => 0.51 } }, "usage" => %{"input_tokens" => 40, "output_tokens" => 8} } {:ok, judge} = Classifier.create( style: "static", model: "jev-1.13.0", decisions: [%{state: state, questions: bad, response: recorded}], on_metric: fn %{event: "classifier.warning"} = ev -> Agent.update(warnings, &[ev | &1]) _ -> :ok end ) {:ok, _} = Classifier.evaluate(judge, state, bad) {:ok, _} = Classifier.evaluate(judge, state, bad) # ONE warning for the key, however many times it is asked — a per-turn judge must not flood. [w] = Agent.get(warnings, & &1) true = w.question == "route" true = String.contains?(w.warning, "degenerate criteria") # It travels in :warning, never :error. A consumer filtering the sink on "has an error" # must not count this as a failure. false = Map.has_key?(w, :error) IO.puts("ok: one warning for #{inspect(w.question)}, request unchanged") ``` ## See also [Section titled “See also”](#see-also) * [`Toolnexus.Classifier.create`](/api/elixir/judge/classifier/) — A sibling of the client: pre-declared typed questions in, calibrated answers out — no messages, no tool calling, no loop. * [`Toolnexus.Classifier.Decision`](/api/elixir/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 obligation](/judge/encoding/) — the measurements behind the caution above, and the three rules that follow. * [Typed decisions](/judge/) — where a judgment sits next to an `if` and a frontier-model turn. * [Cookbook: a typed decision](/cookbook/judge/) — the same three questions, end to end. # Toolnexus.Mcp.Protocol.elicitation_to_request > Map an MCP server's elicitation request onto the §10 suspension contract, and map the answer back. Elixir. **Elixir** · package `toolnexus` · [SPEC §2](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [elixir/lib/toolnexus/mcp/protocol.ex](https://github.com/muthuishere/toolnexus/blob/main/elixir/lib/toolnexus/mcp/protocol.ex) ```elixir @spec elicitation_to_request(map()) :: Toolnexus.Request.t() def elicitation_to_request(params) @spec answer_to_elicit_result(Toolnexus.Answer.t()) :: map() def answer_to_elicit_result(answer) ``` Not on Toolnexus.Mcp — it's on Toolnexus.Mcp.Protocol This function lives on `Toolnexus.Mcp.Protocol`, the pure JSON-RPC codec module, not on `Toolnexus.Mcp` itself. You will not call it directly in ordinary use — pass `:wait_for` to [`Toolnexus.Mcp.load/2`](/api/elixir/mcp/load/) and the connection calls both halves of this bridge for you. This page documents the mapping itself, which is what you need if you are implementing your own `:wait_for` handler or want to understand exactly what a server’s elicitation turns into. The MCP spec lets a server pause a `tools/call` and ask the connecting client something — fill in a form, or go complete an OAuth flow at a URL. toolnexus doesn’t invent a second suspension mechanism for that: `elicitation_to_request/1` maps the server’s `elicitation/create` request onto the exact same §10 `Toolnexus.Request` your own tools use to suspend (e.g. the `question` builtin), and `answer_to_elicit_result/1` maps the resolved `Toolnexus.Answer` back onto what the MCP server expects. ## When to use it [Section titled “When to use it”](#when-to-use-it) * **Writing your own `:wait_for` for `Mcp.load/2`** — your function receives a `Request` built by this mapping; understanding the two shapes (`"input"` vs `"authorization"`) tells you what to branch on. * **Building a host that surfaces MCP elicitation the same way it surfaces your own tool suspensions** — a form UI, a Telegram/Slack prompt, a CLI question — one code path handles both, because both arrive as the same `Request` struct. * **Testing** — construct a fake `elicitation/create` payload and assert what `Request` it becomes, without a real server or network connection. ## Why this and not the alternative [Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative) Most code never touches this function If you already have a `:wait_for` you use for your own tools’ suspensions (the `question` builtin, a custom pending tool), pass the *same function* to `Mcp.load/2`’s `:wait_for`. It will receive `Request`s built by this exact mapping alongside your own — you rarely need to call `elicitation_to_request/1` yourself. ## Examples [Section titled “Examples”](#examples) ### 1. Form-mode elicitation becomes a `kind: "input"` request [Section titled “1. Form-mode elicitation becomes a kind: "input" request”](#1-form-mode-elicitation-becomes-a-kind-input-request) ```elixir alias Toolnexus.Mcp.Protocol params = %{ "message" => "What is your deployment target?", "requestedSchema" => %{ "type" => "object", "properties" => %{"target" => %{"type" => "string", "enum" => ["staging", "prod"]}} } } req = Protocol.elicitation_to_request(params) true = req.kind == "input" true = req.prompt == "What is your deployment target?" true = req.url == nil true = req.data["schema"]["properties"]["target"]["enum"] == ["staging", "prod"] true = String.starts_with?(req.id, "elc-") IO.puts("ok: #{req.kind} — #{req.prompt}") ``` ### 2. URL-mode elicitation becomes a `kind: "authorization"` request [Section titled “2. URL-mode elicitation becomes a kind: "authorization" request”](#2-url-mode-elicitation-becomes-a-kind-authorization-request) ```elixir alias Toolnexus.Mcp.Protocol params = %{ "mode" => "url", "message" => "Authorize toolnexus to access your calendar", "url" => "https://example.com/oauth/authorize?state=abc123" } req = Protocol.elicitation_to_request(params) true = req.kind == "authorization" true = req.prompt == "Authorize toolnexus to access your calendar" true = req.url == "https://example.com/oauth/authorize?state=abc123" true = req.data == nil IO.puts("ok: #{req.kind} — #{req.url}") ``` ### 3. Round-tripping an `Answer` back to the MCP result shape [Section titled “3. Round-tripping an Answer back to the MCP result shape”](#3-round-tripping-an-answer-back-to-the-mcp-result-shape) Three outcomes: accepted (with data), declined, or anything else treated as a cancel. ```elixir alias Toolnexus.{Answer, Mcp.Protocol} accepted = Protocol.answer_to_elicit_result(%Answer{id: "a1", ok: true, data: %{"target" => "prod"}}) true = accepted == %{"action" => "accept", "content" => %{"target" => "prod"}} # ok: true with no data still accepts, with empty content. accepted_empty = Protocol.answer_to_elicit_result(%Answer{id: "a2", ok: true}) true = accepted_empty == %{"action" => "accept", "content" => %{}} declined = Protocol.answer_to_elicit_result(%Answer{id: "a3", ok: false, reason: "declined"}) true = declined == %{"action" => "decline"} # Anything that isn't an explicit decline is a cancel — e.g. a timeout upstream. cancelled = Protocol.answer_to_elicit_result(%Answer{id: "a4", ok: false, reason: "timeout"}) true = cancelled == %{"action" => "cancel"} IO.puts("ok: accept/decline/cancel all mapped") ``` ## Mapping [Section titled “Mapping”](#mapping) | MCP `elicitation/create` params | §10 `Request` | | ------------------------------- | ------------------------------------------------------------------------------------- | | `message` | `prompt` | | `mode == "url"` | `kind: "authorization"`, and `url` carries `params["url"]` | | `mode != "url"` (form) | `kind: "input"`, and `data.schema` carries `params["requestedSchema"]` (when present) | | — | `id` is freshly generated (`"elc--"`) | | §10 `Answer` | MCP elicit result | | ------------------------------- | ----------------------------------------------------- | | `ok: true` | `%{"action" => "accept", "content" => data \|\| %{}}` | | `ok: false, reason: "declined"` | `%{"action" => "decline"}` | | `ok: false`, any other reason | `%{"action" => "cancel"}` | ## See also [Section titled “See also”](#see-also) * [`Toolnexus.Mcp.load` (ctx-aware form)](/api/elixir/mcp/load-with-context/) — where `:wait_for` receives these requests * [`Toolnexus.Mcp.load`](/api/elixir/mcp/load/) — the base connect call * `Toolnexus.Request` / `Toolnexus.Answer` — the §10 suspension contract this bridges onto # Toolnexus.Mcp.list_tools > Connect, list every server's original tool definitions, and disconnect — an inventory that leaves nothing running. Elixir. **Elixir** · package `toolnexus` · [SPEC §2](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [elixir/lib/toolnexus/mcp.ex](https://github.com/muthuishere/toolnexus/blob/main/elixir/lib/toolnexus/mcp.ex) ```elixir @spec list_tools(String.t() | map(), keyword()) :: Toolnexus.Mcp.Inventory.t() def list_tools(input, opts \\ []) # %Toolnexus.Mcp.Inventory{ # tools: %{String.t() => [%{"name" => String.t(), "description" => String.t(), "inputSchema" => map()}]}, # status: %{String.t() => "connected" | "failed" | "disabled"} # } ``` Connects to every enabled server, calls `tools/list`, and disconnects everything **before returning** — no supervisor to close, no connection left running. Unlike [`Toolnexus.Mcp.load/2`](/api/elixir/mcp/load/), the tool names in the result are the servers’ **original, unprefixed** names, and any per-server `"tools"` allowlist in the config is **ignored** — this is a raw inventory of what a server *would* expose, not what an agent would actually see wired up. ## When to use it [Section titled “When to use it”](#when-to-use-it) * **Inspect a config before committing to it** — see what each server offers, without holding a process open or filtering by an allowlist you have not written yet. * **Author a `tools` allowlist** — call `list_tools/2` first to see every tool’s real name, then write the per-server `"tools"` map that [`load/2`](/api/elixir/mcp/load/) will apply. * **A doctor / CI check** — assert a server’s tool surface hasn’t silently changed, with nothing left connected afterward. ## Why this and not the alternative [Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative) Use load when you actually want to call the tools [`Toolnexus.Mcp.load/2`](/api/elixir/mcp/load/) keeps the connections open, prefixes tool names with the server name to avoid collisions, applies any per-server `tools` allowlist, and returns callable [`Toolnexus.Tool`](/api/elixir/types/tool/) structs. Reach for `list_tools/2` **instead** when you want the inventory as data and do not intend to call anything right now. ## Examples [Section titled “Examples”](#examples) ### 1. A disabled server contributes nothing, and nothing is left running [Section titled “1. A disabled server contributes nothing, and nothing is left running”](#1-a-disabled-server-contributes-nothing-and-nothing-is-left-running) ```elixir alias Toolnexus.Mcp config = %{ "mcpServers" => %{ "off" => %{"type" => "local", "command" => ["true"], "enabled" => false} } } inventory = Mcp.list_tools(config) true = inventory.status == %{"off" => "disabled"} true = inventory.tools == %{} IO.puts("ok: #{inspect(inventory.status)}") ``` ### 2. Per-server isolation — a failing server doesn’t block the others’ entries [Section titled “2. Per-server isolation — a failing server doesn’t block the others’ entries”](#2-per-server-isolation--a-failing-server-doesnt-block-the-others-entries) ```elixir alias Toolnexus.Mcp config = %{ "mcpServers" => %{ "off" => %{"type" => "local", "command" => ["true"], "enabled" => false}, "broken" => %{ "type" => "local", "command" => ["/nonexistent-toolnexus-docs-binary"], "timeout" => 500 } } } inventory = Mcp.list_tools(config) true = inventory.status["off"] == "disabled" true = inventory.status["broken"] == "failed" # A failed server has no entry in `tools` at all — not even an empty list. false = Map.has_key?(inventory.tools, "broken") IO.puts("ok: #{inspect(inventory.status)}") ``` ### 3. Malformed config raises, and a deadline bounds a hung server [Section titled “3. Malformed config raises, and a deadline bounds a hung server”](#3-malformed-config-raises-and-a-deadline-bounds-a-hung-server) `list_tools/2` parses config the same way `load/2` does — a JSON error raises before anything connects. It also accepts the same `:deadline`/`:timeout` option, since listing has the identical “one server can hang” problem as loading does. ```elixir alias Toolnexus.Mcp raised_on_bad_json? = try do Mcp.list_tools(~s({not valid json)) false rescue _ -> true end true = raised_on_bad_json? config = %{ "mcpServers" => %{ "slow" => %{"type" => "local", "command" => ["sleep", "5"], "timeout" => 30_000} } } {message, raised_on_deadline?} = try do Mcp.list_tools(config, deadline: 100) {nil, false} rescue e -> {Exception.message(e), true} end true = raised_on_deadline? true = String.contains?(message, "deadline") IO.puts("ok: malformed config raised, deadline exceeded raised (#{message})") ``` ## Options [Section titled “Options”](#options) | Option | Aliases | Default | Effect | | ----------- | ---------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------- | | `:deadline` | `:timeout` | `:infinity` | Overall bound in ms across all servers; on exceed, raises `"toolnexus: MCP list deadline exceeded"`. Same semantics as `load/2`’s deadline. | ## What you get back [Section titled “What you get back”](#what-you-get-back) | Field | Type | What it is | | -------- | -------------------------------------------------------- | --------------------------------------------------------------------- | | `tools` | `%{server => [%{"name", "description", "inputSchema"}]}` | Original (unprefixed) tool definitions per **connected** server only. | | `status` | `%{server => status}` | `"connected"`, `"failed"`, or `"disabled"` per configured server. | ## See also [Section titled “See also”](#see-also) * [`Toolnexus.Mcp.load`](/api/elixir/mcp/load/) — connect and get callable, prefixed, filtered tools * [`Toolnexus.Mcp.load` (ctx-aware form)](/api/elixir/mcp/load-with-context/) — `:deadline` and `:wait_for` in depth * [`Toolnexus.Mcp.parse_config`](/api/elixir/mcp/parse-config/) — parse and validate config without connecting * [`Toolnexus.Mcp.Protocol.elicitation_to_request`](/api/elixir/mcp/elicitation/) — the §10 elicitation bridge # Toolnexus.Mcp.load > Read an mcp.json, connect every enabled server, and expose each server tool as a Tool — with per-server failure isolation. Elixir. **Elixir** · package `toolnexus` · [SPEC §2](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [elixir/lib/toolnexus/mcp.ex](https://github.com/muthuishere/toolnexus/blob/main/elixir/lib/toolnexus/mcp.ex) ```elixir @spec load(String.t() | map(), keyword()) :: Toolnexus.Mcp.Source.t() def load(input, opts \\ []) # %Toolnexus.Mcp.Source{ # tools: [%Toolnexus.Tool{}], # status: %{String.t() => "connected" | "failed" | "disabled"}, # sup: pid(), # connections: %{String.t() => pid()} # } ``` Parses an `mcp.json` (path, raw JSON, or a map — see [`Toolnexus.Mcp.parse_config/1`](/api/elixir/mcp/parse-config/)), then connects to every **enabled** server concurrently — local stdio and remote streamable-HTTP alike — and converts each server’s tools into uniform `Toolnexus.Tool` structs named `sanitize(server)_sanitize(tool)`. One server failing to connect never takes the others down: it is isolated to a `"failed"` status entry, and `load/2` still returns. ## When to use it [Section titled “When to use it”](#when-to-use-it) * **Wiring MCP servers into an agent** — this is the whole point of the MCP source: config in, a flat list of callable `Tool` structs out. * **You want per-server outcome, not just a tool list** — `status` tells you which servers connected, which were skipped as disabled, and which failed, so you can log or surface it. * **You need the supervisor** — `sup` is a `DynamicSupervisor` holding every connection; pass the whole `Source` to [`close/1`](#see-also) when you are done to terminate stdio children cleanly. ## Why this and not the alternative [Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative) Reach for load-with-context when a server might hang This page covers the base call. When you need to **bound how long the overall connect phase can take** — so one hung stdio child doesn’t stall your whole agent boot — or you want to bridge server-initiated elicitation onto your own prompt handler, see [`Toolnexus.Mcp.load` (the ctx-aware form)](/api/elixir/mcp/load-with-context/): same function, scoped to the `:deadline`/`:timeout` and `:wait_for` options. If you only want the tool **definitions** without leaving anything connected — for an inventory, a doctor command, a CI check — use [`Toolnexus.Mcp.list_tools/2`](/api/elixir/mcp/list-tools/) instead; it connects, lists, and disconnects everything before returning. ## Examples [Section titled “Examples”](#examples) ### 1. The smallest useful call — a disabled server [Section titled “1. The smallest useful call — a disabled server”](#1-the-smallest-useful-call--a-disabled-server) `enabled: false` (or `disabled: true`) servers are skipped entirely — no connection attempt, no process spawned — and show up in `status` as `"disabled"`. ```elixir alias Toolnexus.Mcp config = %{ "mcpServers" => %{ "off" => %{"type" => "local", "command" => ["true"], "enabled" => false} } } source = Mcp.load(config) [] = source.tools true = source.status == %{"off" => "disabled"} true = is_pid(source.sup) Mcp.close(source) IO.puts("ok: #{inspect(source.status)}") ``` ### 2. Per-server isolation — one disabled, one that fails to connect [Section titled “2. Per-server isolation — one disabled, one that fails to connect”](#2-per-server-isolation--one-disabled-one-that-fails-to-connect) A server whose command cannot even start (or whose handshake fails) does not stop the others — it is recorded as `"failed"` and simply contributes no tools. This is what makes `load/2` safe to call against config you do not fully trust. ```elixir alias Toolnexus.Mcp config = %{ "mcpServers" => %{ "off" => %{"type" => "local", "command" => ["true"], "enabled" => false}, "broken" => %{ "type" => "local", "command" => ["/nonexistent-toolnexus-docs-binary"], "timeout" => 500 } } } source = Mcp.load(config) true = source.status["off"] == "disabled" true = source.status["broken"] == "failed" # Neither server contributed a tool. [] = source.tools # Only isolation matters here — a failed server still leaves the supervisor alive # for the servers that DID connect. true = Process.alive?(source.sup) Mcp.close(source) false = Process.alive?(source.sup) IO.puts("ok: #{inspect(source.status)}") ``` ### 3. Malformed config raises before anything connects [Section titled “3. Malformed config raises before anything connects”](#3-malformed-config-raises-before-anything-connects) `load/2` calls `parse_config/1` first — a JSON syntax error surfaces immediately, as a raise, not as a per-server `"failed"` status. No process is spawned. ```elixir alias Toolnexus.Mcp raised? = try do Mcp.load(~s({not valid json)) false rescue _ -> true end true = raised? IO.puts("ok: malformed config raised before connecting") ``` ## Options [Section titled “Options”](#options) | Option | Default | What it does | | ------------------------------ | ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `:wait_for` | `nil` | `(Request.t() -> Answer.t())`. When set, the client advertises the MCP `elicitation` capability and bridges an inbound `elicitation/create` onto it (§10). See [`Toolnexus.Mcp.Protocol.elicitation_to_request`](/api/elixir/mcp/elicitation/). | | `:on_server` | `nil` | `(%{name, status, tools, error} -> any)`, called once per server the moment it is `"connected"`, `"failed"` or `"disabled"`, so a host can register a fast server’s `tools` without waiting for a slow one. With it set, servers connect concurrently; the call still returns only after every server finishes, and the returned source is unchanged. | | `:deadline` (alias `:timeout`) | `:infinity` | Overall load deadline in ms. See [the ctx-aware form](/api/elixir/mcp/load-with-context/). | ## What you get back [Section titled “What you get back”](#what-you-get-back) | Field | Type | What it is | | ------------- | --------------------- | -------------------------------------------------------------------- | | `tools` | `[%Toolnexus.Tool{}]` | Every tool from every connected server, concatenated. | | `status` | `%{name => status}` | `"connected"`, `"failed"`, or `"disabled"` per configured server. | | `sup` | `pid()` | The `DynamicSupervisor` holding all connections — pass to `close/1`. | | `connections` | `%{name => pid()}` | The connection process per connected server. | For MCP **resources** (not tools), register [`Toolnexus.Mcp.resource_tools/1`](/api/elixir/mcp/resource-tools/) on the returned source. MCP tool annotations (`readOnlyHint`, `destructiveHint`, `openWorldHint`) become each tool’s `%Toolnexus.ToolHints{}` with MCP’s defaults; no annotations ⇒ `hints: nil`. ## See also [Section titled “See also”](#see-also) * [`Toolnexus.Mcp.load` (ctx-aware form)](/api/elixir/mcp/load-with-context/) — the `:deadline` and `:wait_for` options * [`Toolnexus.Mcp.list_tools`](/api/elixir/mcp/list-tools/) — inventory without leaving anything connected * [`Toolnexus.Mcp.parse_config`](/api/elixir/mcp/parse-config/) — parse and validate config without connecting * [`Toolnexus.Mcp.Protocol.elicitation_to_request`](/api/elixir/mcp/elicitation/) — what `:wait_for` receives * `Toolnexus.Mcp.close/1` — disconnect every server and terminate the supervisor # Toolnexus.Mcp.load (ctx-aware) > Bound how long the overall MCP connect phase may take, and bridge server-initiated elicitation onto your own prompt handler. Elixir. **Elixir** · package `toolnexus` · [SPEC §2](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [elixir/lib/toolnexus/mcp.ex](https://github.com/muthuishere/toolnexus/blob/main/elixir/lib/toolnexus/mcp.ex) ```elixir @spec load(String.t() | map(), keyword()) :: Toolnexus.Mcp.Source.t() def load(input, opts \\ []) # opts: # :deadline (alias :timeout) — ms, default :infinity # :wait_for — (Toolnexus.Request.t() -> Toolnexus.Answer.t()) ``` There is no separate function here — Elixir’s `load/2` is *always* the ctx-aware form; the plain call on [the load page](/api/elixir/mcp/load/) is this same function with the options omitted. This page is scoped to the two options that make it cancellation-aware: `:deadline` (bound the whole connect phase) and `:wait_for` (bridge inbound elicitation). ## When to use it [Section titled “When to use it”](#when-to-use-it) * **A hung or slow stdio server must not stall your agent’s boot.** Without `:deadline`, a server that starts but never completes its `initialize` handshake blocks `load/2` forever. With it, the whole load aborts at the deadline — in-flight connects are torn down (`brutal_kill`), no child process is left running. * **A server wants to ask the human something mid-connect** (a form, an OAuth URL) via MCP elicitation. Pass `:wait_for` and the client advertises the `elicitation` capability and routes the server’s `elicitation/create` through your handler as a §10 `Toolnexus.Request`/`Toolnexus.Answer` — see [`Toolnexus.Mcp.Protocol.elicitation_to_request`](/api/elixir/mcp/elicitation/) for the exact mapping. * **Both together** — a `:wait_for` that itself might hang waiting on a human is exactly the case `:deadline` exists to bound. ## Why this and not the alternative [Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative) Skip the options when a fixed process boundary is enough If your servers are trusted and fast, and nothing ever elicits, the plain [`Toolnexus.Mcp.load/1`](/api/elixir/mcp/load/) call (no opts) is all you need — a hung server is still an edge case worth defending against in anything long-running, but it costs nothing extra to add `:deadline` later. Per-server timeout vs overall deadline Each server config can carry its own `"timeout"` (default 30s) — that bounds *that server’s* handshake. `:deadline` is a *second*, outer bound across the whole `load/2` call, covering every server concurrently. Set both: a generous per-server timeout for slow-but-fine servers, and a tighter `:deadline` as the hard stop for the agent boot as a whole. ## Examples [Section titled “Examples”](#examples) ### 1. Deadline exceeded aborts the whole load [Section titled “1. Deadline exceeded aborts the whole load”](#1-deadline-exceeded-aborts-the-whole-load) `["sleep", "5"]` is a local process that never speaks MCP — it stands in for a hung server without touching the network. A `:deadline` shorter than that raises, and no server ends up connected. ```elixir alias Toolnexus.Mcp config = %{ "mcpServers" => %{ "slow" => %{"type" => "local", "command" => ["sleep", "5"], "timeout" => 30_000} } } {message, raised?} = try do Mcp.load(config, deadline: 100) {nil, false} rescue e -> {Exception.message(e), true} end true = raised? true = String.contains?(message, "deadline") IO.puts("ok: #{message}") ``` ### 2. `:timeout` is accepted as the same option [Section titled “2. :timeout is accepted as the same option”](#2-timeout-is-accepted-as-the-same-option) ```elixir alias Toolnexus.Mcp config = %{ "mcpServers" => %{ "slow" => %{"type" => "local", "command" => ["sleep", "5"], "timeout" => 30_000} } } raised? = try do # :timeout is read as a fallback for :deadline — same effect, same message. Mcp.load(config, timeout: 100) false rescue _ -> true end true = raised? IO.puts("ok: :timeout aborted the load") ``` ### 3. `:wait_for` alongside a bounded deadline, with server isolation intact [Section titled “3. :wait\_for alongside a bounded deadline, with server isolation intact”](#3-wait_for-alongside-a-bounded-deadline-with-server-isolation-intact) Passing `:wait_for` never changes how disabled or failing servers are handled — it only affects servers that actually elicit. Here nothing elicits, so this demonstrates the option is safe to always pass, combined with a real deadline. ```elixir alias Toolnexus.{Answer, Mcp, Request} # A wait_for you would actually wire up: auto-decline every elicitation. wait_for = fn %Request{id: id} -> %Answer{id: id, ok: false, reason: "declined"} end config = %{ "mcpServers" => %{ "off" => %{"type" => "local", "command" => ["true"], "enabled" => false} } } source = Mcp.load(config, wait_for: wait_for, deadline: 5_000) true = source.status == %{"off" => "disabled"} [] = source.tools Mcp.close(source) IO.puts("ok: wait_for + deadline together, disabled server unaffected") ``` ## Options [Section titled “Options”](#options) | Option | Aliases | Default | Effect | | ----------- | ---------- | ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `:deadline` | `:timeout` | `:infinity` | Overall connect-phase bound in ms. On exceed: every in-flight connect task is killed (`Task.shutdown/2` with `:brutal_kill`), the supervisor is stopped, and `load/2` raises `"toolnexus: MCP load deadline exceeded"`. | | `:wait_for` | — | `nil` | `(Request.t() -> Answer.t())`. Advertises the `elicitation` MCP capability; an inbound `elicitation/create` is mapped through [`elicitation_to_request/1`](/api/elixir/mcp/elicitation/), your function is called, and the answer is mapped back. Without it, the capability is not advertised and a compliant server will not elicit. | ## See also [Section titled “See also”](#see-also) * [`Toolnexus.Mcp.load`](/api/elixir/mcp/load/) — the base call this page’s options extend * [`Toolnexus.Mcp.list_tools`](/api/elixir/mcp/list-tools/) — inventory without leaving anything connected * [`Toolnexus.Mcp.parse_config`](/api/elixir/mcp/parse-config/) — parse and validate config without connecting * [`Toolnexus.Mcp.Protocol.elicitation_to_request`](/api/elixir/mcp/elicitation/) — what `:wait_for` receives and returns # Toolnexus.Mcp.parse_config > Parse and validate an mcp.json without connecting to anything — the fast fail for a malformed or misspelled server block. Elixir. **Elixir** · package `toolnexus` · [SPEC §2](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [elixir/lib/toolnexus/mcp.ex](https://github.com/muthuishere/toolnexus/blob/main/elixir/lib/toolnexus/mcp.ex) ```elixir def parse_config(input) when is_map(input) def parse_config(input) when is_binary(input) ``` Reads MCP server configuration and normalises it into a flat map of server name → config. It **connects to nothing** — no ports, no HTTP. That is the whole point: it is the cheap check you can run before paying for [`Toolnexus.Mcp.load/1`](/api/elixir/mcp/load/). ## When to use it [Section titled “When to use it”](#when-to-use-it) * **Validate at startup or in a test**, so a typo in `mcp.json` fails immediately rather than halfway through connecting to five servers. * **Inspect or modify config before loading** — filter servers by environment, inject a header, disable one in CI. * **Accept config from somewhere other than a file** — a database, an env var, an API response. ## Why this and not just calling load [Section titled “Why this and not just calling load”](#why-this-and-not-just-calling-load) load already parses — use this when you need the config itself [`Toolnexus.Mcp.load/1`](/api/elixir/mcp/load/) parses internally, so you do not need to call this first in the ordinary path. Reach for it when you want the *config as data*. A binary is a path OR raw JSON The binary clause checks the first non-whitespace character: a leading `{` is treated as raw JSON, anything else as a file path. That means you can pass either without saying which. The map clause round-trips through `Jason.encode!/1` and `Jason.decode!/1`, which normalises atom keys to the JSON string shape — so `%{mcpServers: %{...}}` and `%{"mcpServers" => %{...}}` both work and both give you string keys back. ## Examples [Section titled “Examples”](#examples) ### 1. Parse the shared fixture from disk [Section titled “1. Parse the shared fixture from disk”](#1-parse-the-shared-fixture-from-disk) This is `examples/mcp.json`, the fixture every port is tested against. ```elixir alias Toolnexus.Mcp # TOOLNEXUS_REPO is set by the docs test runner; in your own code just use a path. repo = System.get_env("TOOLNEXUS_REPO") || "." fixture = Path.join([repo, "examples", "mcp.json"]) config = Mcp.parse_config(fixture) # The `mcpServers` wrapper is unwrapped — you get the servers directly. names = config |> Map.keys() |> Enum.sort() true = names == ["everything", "example-remote"] local = config["everything"] true = local["type"] == "local" true = local["command"] == ["npx", "-y", "@modelcontextprotocol/server-everything"] true = local["timeout"] == 30_000 remote = config["example-remote"] true = remote["url"] == "https://example.com/mcp" # Disabled servers are still returned — parsing does not filter. true = remote["enabled"] == false IO.puts("ok: #{Enum.join(names, ", ")}") ``` Caution Parsing does **not** drop disabled servers. `enabled: false` is preserved in the returned config and is honoured later, by `load/1`. If you are counting servers, count the enabled ones yourself. ### 2. Wrapper spellings, raw JSON, and atom keys [Section titled “2. Wrapper spellings, raw JSON, and atom keys”](#2-wrapper-spellings-raw-json-and-atom-keys) `mcpServers`, `servers` and `mcp` all mean the same thing. ```elixir alias Toolnexus.Mcp server = %{"type" => "local", "command" => ["echo", "hi"]} for wrapper <- ["mcpServers", "servers", "mcp"] do config = Mcp.parse_config(%{wrapper => %{"a" => server}}) true = Map.keys(config) == ["a"] true = config["a"]["command"] == ["echo", "hi"] end # Raw JSON in a binary is detected by the leading brace. from_json = Mcp.parse_config(~s({"mcpServers":{"a":{"type":"local","command":["echo","hi"]}}})) true = Map.keys(from_json) == ["a"] # Atom keys are normalised to strings on the way through. from_atoms = Mcp.parse_config(%{mcpServers: %{a: %{type: "local", command: ["echo", "hi"]}}}) true = Map.keys(from_atoms) == ["a"] true = from_atoms["a"]["type"] == "local" IO.puts("ok: 3 spellings + raw json + atom keys -> a") ``` That last case is the one to remember: you get **string keys back regardless of what you put in**. ### 3. Validate before loading, and fail loudly [Section titled “3. Validate before loading, and fail loudly”](#3-validate-before-loading-and-fail-loudly) The pattern this function exists for — check the config, then decide whether to connect. ```elixir alias Toolnexus.Mcp validate = fn raw -> config = Mcp.parse_config(raw) Enum.reduce(config, {[], []}, fn {name, cfg}, {enabled, problems} -> # Disabled either way round: `enabled: false` or `disabled: true`. if cfg["disabled"] == true or cfg["enabled"] == false do {enabled, problems} else problem = cond do cfg["type"] == "remote" and (cfg["url"] in [nil, ""]) -> "#{name}: remote server without a url" cfg["type"] != "remote" and (cfg["command"] in [nil, []]) -> "#{name}: local server without a command" true -> nil end {[name | enabled], if(problem, do: [problem | problems], else: problems)} end end) |> then(fn {e, p} -> {Enum.sort(e), Enum.sort(p)} end) end {good_enabled, good_problems} = validate.(~s({"mcpServers":{ "ok_local":{"type":"local","command":["npx","server"]}, "ok_remote":{"type":"remote","url":"https://example.com/mcp"}, "off":{"type":"local","command":["x"],"enabled":false}}})) true = good_enabled == ["ok_local", "ok_remote"] true = good_problems == [] {_, bad_problems} = validate.(~s({"mcpServers":{ "broken_remote":{"type":"remote"}}})) true = length(bad_problems) == 1 # An empty local command fails the parse itself, naming the server (SPEC §2). true = try do Mcp.parse_config(~s({"mcpServers":{"broken_local":{"type":"local","command":[]}}})) false rescue e in ArgumentError -> String.contains?(Exception.message(e), "broken_local") end # Malformed JSON raises rather than returning a half-config. true = try do Mcp.parse_config(~s({not json)) false rescue _ -> true end IO.puts("ok: #{Enum.join(good_enabled, ",")} | problems: #{length(bad_problems)}") ``` ## Accepted input [Section titled “Accepted input”](#accepted-input) | Input | Behaviour | | -------------- | --------------------------------------------------------------------- | | `"./mcp.json"` | A binary not starting with `{` — read from disk with `File.read!/1`. | | `~s({...})` | A binary starting with `{` — decoded directly. | | `%{...}` | A map — round-tripped through JSON, normalising atom keys to strings. | Wrapped under `mcpServers`, `servers` or `mcp` — all three are unwrapped. ## See also [Section titled “See also”](#see-also) * [`Toolnexus.Mcp.load`](/api/elixir/mcp/load/) — parse *and* connect * [`Toolnexus.Mcp.list_tools`](/api/elixir/mcp/list-tools/) — what each server would expose * [`Toolnexus.create_toolkit`](/api/elixir/toolkit/create/) — takes `:mcp_config` in these forms # Toolnexus.Mcp.resource_tools > Opt-in list_mcp_resources / read_mcp_resource tools over every connected MCP server. Read-only. — Elixir. **Elixir** · package `toolnexus` · [SPEC §2](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) ```elixir @spec resource_tools(Toolnexus.Mcp.Source.t() | nil) :: [Toolnexus.Tool.t()] def resource_tools(source) # => [list_mcp_resources, read_mcp_resource] ``` Two tools bound to the source’s connected servers: `list_mcp_resources %{server?}` prints one line per resource (`server uri — name (mimeType): description`) and `read_mcp_resource %{server, uri}` returns its text. Both carry `%Toolnexus.ToolHints{read_only: true}`. They are **opt-in** — `Mcp.load/2` does not add them; register them yourself — and they stop working when the source is closed (a closed server reports an error line, not a crash). `nil`, or a source with no connected server, still returns both tools; listing then says `No resources.` ## When to use it [Section titled “When to use it”](#when-to-use-it) * **MCP servers that expose data as resources** (files, schemas, docs) rather than tools. * **A read-only agent mode** — both tools are read-only, so a plan-mode `before_tool` gate lets them through. ## Why this and not the alternative [Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative) Not a custom tool per resource You could wrap each resource in its own `define_tool`, but the set of resources changes with the server. Two generic tools let the model discover and read whatever is there now. ## Examples [Section titled “Examples”](#examples) ### 1. With no source [Section titled “1. With no source”](#1-with-no-source) ```elixir alias Toolnexus.{Context, Mcp, ToolHints} [list, read] = Mcp.resource_tools(nil) true = list.name == "list_mcp_resources" and read.name == "read_mcp_resource" true = list.hints == %ToolHints{read_only: true} %{output: "No resources.", metadata: %{count: 0}} = list.execute.(%{}, %Context{}) IO.puts("ok: #{list.name}, #{read.name}") ``` ### 2. Register them on a toolkit [Section titled “2. Register them on a toolkit”](#2-register-them-on-a-toolkit) ```elixir alias Toolnexus.{Mcp, Toolkit} source = Mcp.load(%{}) tk = Toolkit.register(Toolnexus.create_toolkit!(builtins: false), Mcp.resource_tools(source)) r = Toolkit.execute(tk, "list_mcp_resources", %{}) true = r.output == "No resources." Mcp.close(source) IO.puts("ok: resource tools on the toolkit") ``` ### 3. Against a real stdio server [Section titled “3. Against a real stdio server”](#3-against-a-real-stdio-server) The shared fixture `examples/mcp-progress-server.mjs` serves one resource, `fixture://hello`. Skipped when `node` is not installed. ```elixir alias Toolnexus.{Context, Mcp} repo = System.get_env("TOOLNEXUS_REPO") || File.cwd!() fixture = Path.join(repo, "examples/mcp-progress-server.mjs") if System.find_executable("node") && File.exists?(fixture) do src = Mcp.load(%{"progress" => %{"command" => ["node", fixture], "timeout" => 5000}}) [list, read] = Mcp.resource_tools(src) "progress fixture://hello — hello (text/plain): A fixed text resource." = list.execute.(%{}, %Context{}).output %{output: "hello resource", is_error: false} = read.execute.(%{"server" => "progress", "uri" => "fixture://hello"}, %Context{}) Mcp.close(src) IO.puts("ok: read fixture://hello") else IO.puts("ok: skipped (no node)") end ``` ## See also [Section titled “See also”](#see-also) * [`Toolnexus.Mcp.load`](/api/elixir/mcp/load/) — Read an mcp.json, connect every local stdio and remote streamable-HTTP server, expose each server tool as a Tool. * [`Toolnexus.Mcp.load`](/api/elixir/mcp/load-with-context/) — The ctx-aware load: bound connection time and cancel a slow or hung server without leaking a child process. * [`Toolnexus.Mcp.list_tools`](/api/elixir/mcp/list-tools/) — List what each configured server would expose, plus per-server status, without wiring it into a toolkit. * [`Toolnexus.Mcp.parse_config`](/api/elixir/mcp/parse-config/) — Parse and validate config without connecting — the fast fail for a malformed or misspelled server block. # collectTools — gather decorated functions > Sweep a module or class and collect every function marked as a tool. — Elixir. **Elixir** · package `toolnexus` · [SPEC §6](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) Not available in Elixir JS/Java/C# expose an explicit collector; Python, Go and Elixir have no equivalent public symbol. 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) There is no module-sweeping collector in this port — nothing scans a module for functions marked with an attribute or convention and turns them into tools automatically. Every native tool in Elixir is built one at a time with [`Toolnexus.Native.define_tool/1`](/api/elixir/native/define-tool/); “collecting” a set of them is just building a plain Elixir list: ```elixir alias Toolnexus.Native defmodule MyTools do def weather, do: Native.define_tool(%{ name: "get_weather", description: "Current weather for a city", input_schema: %{"type" => "object", "properties" => %{"city" => %{"type" => "string"}}}, execute: fn args, _ctx -> "sunny in #{args["city"]}" end }) def ping, do: Native.define_tool(%{ name: "ping", description: "Health check", execute: fn _args, _ctx -> "pong" end }) # The "collector" is just a list of the tools you defined above. def tools, do: [weather(), ping()] end ``` If you are generating many tools **from data** rather than from hand-written functions — a table of endpoints, a set of database-backed jobs — build `%Toolnexus.Tool{}` structs directly in an `Enum.map/2` instead; see the third example on the [`Toolnexus.Tool`](/api/elixir/types/tool/) reference page for that pattern. ## See also [Section titled “See also”](#see-also) * [`Toolnexus.Native.define_tool`](/api/elixir/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. # Toolnexus.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. Elixir. **Elixir** · package `toolnexus` · [SPEC §6](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [elixir/lib/toolnexus/native.ex](https://github.com/muthuishere/toolnexus/blob/main/elixir/lib/toolnexus/native.ex) ```elixir @spec define_tool(keyword() | map()) :: Toolnexus.Tool.t() def define_tool(opts) ``` Wraps a plain Elixir function as a uniform [`Toolnexus.Tool`](/api/elixir/types/tool/). You supply `:name`, `:description` and `:execute`; the returned struct is indistinguishable from a tool that came off an MCP server, a skill directory or an HTTP endpoint. ## When to use it [Section titled “When to use it”](#when-to-use-it) * **Your own business logic as a tool** — a database lookup, a pricing calculation, an internal API wrapper. Anything already written in Elixir. * **Stubs and fakes in tests** — a deterministic tool to drive the client loop without a network. * **Adapting a third-party library** — one closure over the library call is the whole integration. ## Why this and not the alternative [Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative) Build the struct by hand when you are generating many tools `define_tool/1` gives you return-value coercion and rescue-to-`is_error` for free, which is what you want for a handful of hand-written tools. When you are producing tools **from data** — a table of endpoints, a database of jobs — construct [`%Toolnexus.Tool{}`](/api/elixir/types/tool/) directly instead: you already control the return shape and the extra layer buys nothing. For a remote HTTP endpoint, [`Toolnexus.Http.tool/1`](/api/elixir/http/tool/) is the better fit — it handles URL placeholders, querystrings, body encoding and `${ENV_VAR}` headers, so you do not hand-roll a request inside `execute`. No reflection in this port Some ports derive the schema from the function signature or an annotation ([`Native.reflect`](/api/elixir/native/reflect/)). Elixir has no such helper — write the JSON Schema map yourself, or omit `:input_schema` for a no-argument tool. ## Examples [Section titled “Examples”](#examples) ### 1. The smallest possible definition [Section titled “1. The smallest possible definition”](#1-the-smallest-possible-definition) Only `:name`, `:description` and `:execute` are required. Omit `:input_schema` and you get the empty object schema — the right shape for a tool that takes no arguments. ```elixir alias Toolnexus.{Context, Native} now = Native.define_tool(%{ name: "server_time", description: "The current server time as an ISO-8601 string", execute: fn _args -> "2026-01-01T00:00:00Z" end }) true = now.name == "server_time" # Source defaults to "native". true = now.source == "native" # Default schema: an object with no properties, closed. true = now.input_schema == %{"type" => "object", "properties" => %{}, "additionalProperties" => false} res = now.execute.(%{}, %Context{}) false = res.is_error true = res.output == "2026-01-01T00:00:00Z" IO.puts("ok: #{now.name} -> #{res.output}") ``` Note the **arity-1** `:execute`. `define_tool/1` inspects the function’s arity and calls `run.(args)` or `run.(args, ctx)` accordingly, so you only take a context when you want one. ### 2. Arguments, a schema, and returning something other than a binary [Section titled “2. Arguments, a schema, and returning something other than a binary”](#2-arguments-a-schema-and-returning-something-other-than-a-binary) A binary return becomes the output verbatim; anything else is JSON-encoded (`Jason.encode!/1`), which matches `JSON.stringify` in the JS port. ```elixir alias Toolnexus.{Context, Native} inventory = %{"widget" => 12, "sprocket" => 0} stock = Native.define_tool(%{ name: "check_stock", description: "How many units of a SKU are on hand", input_schema: %{ "type" => "object", "properties" => %{"sku" => %{"type" => "string", "description" => "The SKU to look up"}}, "required" => ["sku"], "additionalProperties" => false }, execute: fn args -> sku = args["sku"] # A map return is JSON-encoded for you. %{sku: sku, on_hand: Map.get(inventory, sku, 0), in_stock: Map.get(inventory, sku, 0) > 0} end }) res = stock.execute.(%{"sku" => "widget"}, %Context{}) false = res.is_error true = Jason.decode!(res.output) == %{"sku" => "widget", "on_hand" => 12, "in_stock" => true} # A binary return is passed through untouched — no quoting, no encoding. plain = Native.define_tool(%{ name: "shout", description: "Uppercase the input", execute: fn args -> String.upcase(args["text"] || "") end }) true = plain.execute.(%{"text" => "hello"}, %Context{}).output == "HELLO" IO.puts("ok: #{res.output}") ``` Caution `args` arrives with **string keys** — that is what came off the wire from the model. Pattern-matching `%{sku: sku}` will not match; use `args["sku"]`. ### 3. The full surface — context, custom source, explicit results, and raising [Section titled “3. The full surface — context, custom source, explicit results, and raising”](#3-the-full-surface--context-custom-source-explicit-results-and-raising) `:source` overrides the `"native"` default. Returning a `%ToolResult{}` gives you full control (including `metadata`), and a raise inside `execute` is rescued into an error result rather than crashing the loop. ```elixir alias Toolnexus.{Context, Native, ToolResult} transfer = Native.define_tool(%{ name: "transfer_funds", description: "Move money between two accounts", input_schema: %{ "type" => "object", "properties" => %{ "from" => %{"type" => "string"}, "to" => %{"type" => "string"}, "amount" => %{"type" => "number"} }, "required" => ["from", "to", "amount"], "additionalProperties" => false }, source: "custom", # Arity-2: the loop always passes a %Context{}, so no nil-guard is needed. execute: fn args, ctx -> amount = args["amount"] cond do amount <= 0 -> raise ArgumentError, "amount must be positive" amount > 1000 -> # Explicit failure, no exception — the model reads this and can retry smaller. ToolResult.error("Amount #{amount} exceeds the per-call limit of 1000") true -> %ToolResult{ output: "moved #{amount} from #{args["from"]} to #{args["to"]}", is_error: false, metadata: %{session_id: ctx.session_id, amount: amount} } end end }) true = transfer.source == "custom" ctx = %Context{session_id: "sess-1"} ok = transfer.execute.(%{"from" => "a", "to" => "b", "amount" => 250}, ctx) false = ok.is_error true = ok.output == "moved 250 from a to b" true = ok.metadata.session_id == "sess-1" over = transfer.execute.(%{"from" => "a", "to" => "b", "amount" => 5000}, ctx) true = over.is_error true = String.contains?(over.output, "exceeds the per-call limit") # A raise is rescued: is_error with the exception message as the output. raised = transfer.execute.(%{"from" => "a", "to" => "b", "amount" => 0}, ctx) true = raised.is_error true = raised.output == "amount must be positive" # A keyword list works exactly like a map. kw = Native.define_tool(name: "ping", description: "Health check", execute: fn _ -> "pong" end) true = kw.execute.(%{}, %Context{}).output == "pong" IO.puts("ok: #{ok.output} | limit: #{over.is_error} | raised: #{raised.is_error}") ``` ## Options [Section titled “Options”](#options) Accepts a **map or a keyword list**. | Option | Required | Default | What it does | | --------------- | -------- | ----------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- | | `:name` | yes | — | The name the model calls. Must match `[a-zA-Z0-9_-]`; `Toolnexus.Tool.sanitize/1` will fix one up. | | `:description` | yes | — | What the model reads to decide whether to call it. | | `:execute` | yes | — | `fn args -> ... end` **or** `fn args, ctx -> ... end`. Arity is detected at build time. | | `:input_schema` | no | `%{"type" => "object", "properties" => %{}, "additionalProperties" => false}` | JSON-Schema object, string-keyed. | | `:source` | no | `"native"` | The `source` tag on the resulting tool. | ### Return-value handling [Section titled “Return-value handling”](#return-value-handling) | `execute` returns | Becomes | | ------------------------- | ----------------------------------------------------------- | | `%Toolnexus.ToolResult{}` | Passed through unchanged — the only way to set `metadata`. | | a binary | `%ToolResult{output: it, is_error: false}` | | anything else | `%ToolResult{output: Jason.encode!(it), is_error: false}` | | a raise | `%ToolResult{output: Exception.message(e), is_error: true}` | ## See also [Section titled “See also”](#see-also) * [`Toolnexus.Tool`](/api/elixir/types/tool/) — the struct this produces * [`Toolnexus.ToolResult`](/api/elixir/types/tool-result/) — the richer return value * [`Toolnexus.Http.tool`](/api/elixir/http/tool/) — the same idea for a remote endpoint * [`Toolnexus.create_toolkit`](/api/elixir/toolkit/create/) — pass these in as `:tools` # Annotation / reflection tools > Derive the schema from the function signature or annotation instead of writing it by hand. — Elixir. **Elixir** · package `toolnexus` · [SPEC §6](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) Not available in Elixir Elixir has no reflection-based tool builder; use Toolnexus.Native.define\_tool/1 with an explicit input\_schema. 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) Elixir has no macro or attribute that inspects a function’s typespec or argument list and derives a JSON Schema from it — there is no `@tool` decorator, no runtime reflection over a `def`’s signature the way JavaScript’s `tool()` wrapper or Python’s `tool()` decorator can read type hints. [`Toolnexus.Native.define_tool/1`](/api/elixir/native/define-tool/) is the whole native-tool surface in this port, and it always takes an **explicit** `:input_schema` map — you write the JSON Schema once, by hand, next to the function: ```elixir alias Toolnexus.Native Native.define_tool(%{ name: "get_weather", description: "Current weather for a city", input_schema: %{ "type" => "object", "properties" => %{"city" => %{"type" => "string"}}, "required" => ["city"] }, execute: fn args, _ctx -> "sunny in #{args["city"]}" end }) ``` This costs a few extra lines per tool compared to a reflected signature, in exchange for a schema that is never a guess about what a dynamically-typed function accepts — Elixir function clauses and guards don’t carry the kind of static type information reflection would need anyway. If you are hand-writing more than a handful of these, factor the schema into a module attribute or a shared helper rather than repeating it inline. ## See also [Section titled “See also”](#see-also) * [`Toolnexus.Native.define_tool`](/api/elixir/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. # Toolnexus.Agents.Home.compose_soul > Build a persona's system prompt from its home directory: identity, memory, skills. — Elixir. **Elixir** · package `toolnexus` · [SPEC §7E](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [elixir/lib/toolnexus/agents/home.ex](https://github.com/muthuishere/toolnexus/blob/main/elixir/lib/toolnexus/agents/home.ex) ```elixir @spec compose_soul(String.t()) :: {String.t(), [String.t()]} def compose_soul(dir) # bootstrap order: AGENTS.md, SOUL.md, IDENTITY.md, USER.md, TOOLS.md, HEARTBEAT.md, MEMORY.md # (Toolnexus.Agents.Home.bootstrap_order/0) ``` Composes whichever bootstrap files exist under `dir`, in the canonical order, into one soul string — the persona’s system prompt. Each present file becomes a `## ` section; absent files are silently skipped; a file over 2 MB is truncated with a notice (the file on disk is untouched). Returns `{soul, found}`, where `found` lists the discovered filenames in the order they were composed. ## When to use it [Section titled “When to use it”](#when-to-use-it) * **The directory IS the agent** — a persona’s identity lives in files (`SOUL.md`, `IDENTITY.md`, …) that a human or another process edits directly, and you want that editable state to become a system prompt without hand-rolling the file walk and ordering. * **You need the composition step in isolation** — to preview a soul before running the agent, to snapshot it for a log, or to build something other than an `AgentDef` from the same files. * **You’re auditing what a persona will see** — `found` tells you exactly which files were present at composition time. ## Why this and not the alternative [Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative) Reach for from\_dir/2 for the everyday path `compose_soul/1` is the composition primitive — a pure function from directory to string. [`Toolnexus.Agents.Home.from_dir/2`](/api/elixir/persona/from-dir/) is what you actually call in application code: it runs `compose_soul/1` for you, wires the `memory` tool over the same directory, and hands back a runnable `AgentDef`. Reach for `compose_soul/1` directly only when you want the soul string itself, decoupled from building an agent. Composition happens **once**, at session start — a frozen snapshot. A later `memory` tool write changes the files on disk, but not the live prompt already in context; the next session’s `compose_soul/1` call is what picks it up. This is deliberate (cache-stable prompts), not a bug. ## Examples [Section titled “Examples”](#examples) ### 1. The smallest useful call — one bootstrap file [Section titled “1. The smallest useful call — one bootstrap file”](#1-the-smallest-useful-call--one-bootstrap-file) ```elixir alias Toolnexus.Agents.Home dir = Path.join(System.tmp_dir!(), "toolnexus_doc_compose_soul_1_#{System.unique_integer([:positive])}") File.rm_rf!(dir) File.mkdir_p!(dir) File.write!(Path.join(dir, "SOUL.md"), "You are Kestrel, a calm and precise research agent.") {soul, found} = Home.compose_soul(dir) true = found == ["SOUL.md"] true = soul == "## SOUL.md\n\nYou are Kestrel, a calm and precise research agent." IO.puts("ok: composed #{byte_size(soul)} bytes from #{Enum.join(found, ", ")}") ``` ### 2. The realistic case — several files, canonical order, missing files skipped [Section titled “2. The realistic case — several files, canonical order, missing files skipped”](#2-the-realistic-case--several-files-canonical-order-missing-files-skipped) Files are composed in the **bootstrap order**, not directory-listing order, and a file that isn’t present is simply absent from `found` — no error, no placeholder section. ```elixir alias Toolnexus.Agents.Home dir = Path.join(System.tmp_dir!(), "toolnexus_doc_compose_soul_2_#{System.unique_integer([:positive])}") File.rm_rf!(dir) File.mkdir_p!(dir) # Written out of canonical order on purpose — composition order must not depend on write order. File.write!(Path.join(dir, "MEMORY.md"), "- prefers concise answers") File.write!(Path.join(dir, "AGENTS.md"), "Follow the repo's CLAUDE.md conventions.") File.write!(Path.join(dir, "IDENTITY.md"), "Name: Kestrel. Role: research agent.") # TOOLS.md, USER.md, HEARTBEAT.md, SOUL.md are all absent. {soul, found} = Home.compose_soul(dir) true = found == ["AGENTS.md", "IDENTITY.md", "MEMORY.md"] true = String.starts_with?(soul, "## AGENTS.md") true = String.contains?(soul, "## IDENTITY.md\n\nName: Kestrel. Role: research agent.") true = String.ends_with?(soul, "## MEMORY.md\n\n- prefers concise answers") IO.puts("ok: #{length(found)} bootstrap file(s) composed in canonical order") ``` ### 3. The full surface — the 2 MB truncation cap [Section titled “3. The full surface — the 2 MB truncation cap”](#3-the-full-surface--the-2-mb-truncation-cap) A file that exceeds the byte cap is truncated in the composed soul, with a notice appended; the file on disk is left exactly as written. ```elixir alias Toolnexus.Agents.Home dir = Path.join(System.tmp_dir!(), "toolnexus_doc_compose_soul_3_#{System.unique_integer([:positive])}") File.rm_rf!(dir) File.mkdir_p!(dir) huge = String.duplicate("x", 2 * 1024 * 1024 + 100) File.write!(Path.join(dir, "MEMORY.md"), huge) {soul, found} = Home.compose_soul(dir) true = found == ["MEMORY.md"] true = String.contains?(soul, "[truncated: exceeds 2 MB bootstrap cap]") # the composed section is capped, but the on-disk file is untouched true = byte_size(File.read!(Path.join(dir, "MEMORY.md"))) == byte_size(huge) true = byte_size(soul) < byte_size(huge) IO.puts("ok: oversized MEMORY.md truncated to #{byte_size(soul)} bytes (disk file untouched)") ``` ## Fields [Section titled “Fields”](#fields) | Field | Type | What it is | | ---------------- | -------------- | --------------------------------------------------------------------------- | | `dir` (arg) | `String.t()` | The persona’s home directory. | | `soul` (return) | `String.t()` | The composed system prompt — `## ` sections, joined with blank lines. | | `found` (return) | `[String.t()]` | Discovered bootstrap filenames, in canonical order. | ## See also [Section titled “See also”](#see-also) * [`Toolnexus.Agents.Home.from_dir`](/api/elixir/persona/from-dir/) — Point at an agent home directory and get a configured agent back. * [`Toolnexus.Agents.Home.memory_tool`](/api/elixir/persona/memory-tool/) — The opt-in built-in that lets a persona write durable notes to its own home. # Toolnexus.Agents.Home.from_dir > Point at an agent home directory and get a configured agent back. — Elixir. **Elixir** · package `toolnexus` · [SPEC §7E](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [elixir/lib/toolnexus/agents/home.ex](https://github.com/muthuishere/toolnexus/blob/main/elixir/lib/toolnexus/agents/home.ex) ```elixir @spec from_dir(String.t(), keyword() | map()) :: Toolnexus.Agents.AgentDef.t() def from_dir(dir, opts \\ []) # opts: :does (routing description, default "persona agent from "), # :name (default: dir's basename), :model (default "inherit"), # :tools (extra tools alongside the memory builtin), # :memory (set false to omit the memory tool — a read-only persona) ``` The directory IS the agent. `from_dir/2` runs [`compose_soul/1`](/api/elixir/persona/compose-soul/) over `dir` for the frozen system-prompt snapshot, wires the `memory` tool over the same directory (unless `:memory` is `false`), and returns a `Toolnexus.Agents.AgentDef` — runnable with `Toolnexus.Agents.run/3` or droppable into a parent’s `:team` like any other agent definition. ## When to use it [Section titled “When to use it”](#when-to-use-it) * **You have a persona’s files on disk** — `AGENTS.md`/`SOUL.md`/`IDENTITY.md`/… under one directory — and want a runnable agent without hand-assembling the soul string and the memory tool yourself. * **You want the persona to remember things across sessions** — the memory tool comes wired by default; pass `memory: false` for a read-only persona (a fixed identity that never writes). * **You’re building several personas the same way** — one directory convention, one call, per persona; naming and routing description both have sane defaults. ## Why this and not the alternative [Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative) Reach for compose\_soul/1 when you don't want an AgentDef `from_dir/2` is the everyday path — a directory in, a runnable `AgentDef` out. Reach for [`compose_soul/1`](/api/elixir/persona/compose-soul/) directly only when you want the soul string itself (previewing it, logging it, feeding it into something that isn’t a `Toolnexus.Agents` definition) without also getting the memory tool and the `AgentDef` wrapper. ## Examples [Section titled “Examples”](#examples) ### 1. The smallest useful call — a directory with one file becomes a runnable agent [Section titled “1. The smallest useful call — a directory with one file becomes a runnable agent”](#1-the-smallest-useful-call--a-directory-with-one-file-becomes-a-runnable-agent) ```elixir alias Toolnexus.Agents.Home dir = Path.join(System.tmp_dir!(), "toolnexus_doc_from_dir_1_#{System.unique_integer([:positive])}") File.rm_rf!(dir) File.mkdir_p!(dir) File.write!(Path.join(dir, "SOUL.md"), "You are Kestrel, a calm research agent.") agent = Home.from_dir(dir) true = agent.name == Path.basename(dir) true = agent.spec[:does] == "persona agent from #{dir}" true = agent.spec[:model] == "inherit" true = agent.spec[:soul] == "## SOUL.md\n\nYou are Kestrel, a calm research agent." # the memory tool rides along by default true = Enum.any?(agent.spec[:uses][:tools], &(&1.name == "memory")) IO.puts("ok: #{agent.name} composed with #{length(agent.spec[:uses][:tools])} tool(s)") ``` ### 2. The realistic case — explicit options, and a read-only persona with `memory: false` [Section titled “2. The realistic case — explicit options, and a read-only persona with memory: false”](#2-the-realistic-case--explicit-options-and-a-read-only-persona-with-memory-false) ```elixir alias Toolnexus.Agents.Home dir = Path.join(System.tmp_dir!(), "toolnexus_doc_from_dir_2_#{System.unique_integer([:positive])}") File.rm_rf!(dir) File.mkdir_p!(dir) File.write!(Path.join(dir, "AGENTS.md"), "Answer tersely.") named = Home.from_dir(dir, does: "terse Q&A", name: "terse-bot", model: "gpt-4o-mini") true = named.name == "terse-bot" true = named.spec[:does] == "terse Q&A" true = named.spec[:model] == "gpt-4o-mini" read_only = Home.from_dir(dir, memory: false) false = Enum.any?(read_only.spec[:uses][:tools], &(&1.name == "memory")) IO.puts("ok: #{named.name} (writable) vs #{read_only.name} (#{length(read_only.spec[:uses][:tools])} tools, read-only)") ``` ### 3. The full surface — running the persona end to end, memory tool included [Section titled “3. The full surface — running the persona end to end, memory tool included”](#3-the-full-surface--running-the-persona-end-to-end-memory-tool-included) ```elixir alias Toolnexus.{Agents} alias Toolnexus.Agents.Home dir = Path.join(System.tmp_dir!(), "toolnexus_doc_from_dir_3_#{System.unique_integer([:positive])}") File.rm_rf!(dir) File.mkdir_p!(dir) File.write!(Path.join(dir, "IDENTITY.md"), "You are Kestrel.") extra_tool = Toolnexus.define_tool(%{ name: "ping", description: "health check", execute: fn _args -> "pong" end }) agent = Home.from_dir(dir, does: "demo persona", tools: [extra_tool]) true = Enum.map(agent.spec[:uses][:tools], & &1.name) |> Enum.sort() == ["memory", "ping"] mock_transport = fn %{body: body} -> msgs = body["messages"] || [] tool_msgs = Enum.filter(msgs, &(&1["role"] == "tool")) resp = if tool_msgs == [] do %{ "choices" => [%{"message" => %{"role" => "assistant", "content" => nil, "tool_calls" => [ %{"id" => "c1", "type" => "function", "function" => %{"name" => "memory", "arguments" => ~s({"action":"add","text":"user likes concise replies"})}} ]}}], "usage" => %{"prompt_tokens" => 4, "completion_tokens" => 2, "total_tokens" => 6} } else %{"choices" => [%{"message" => %{"role" => "assistant", "content" => hd(tool_msgs)["content"]}}], "usage" => %{"prompt_tokens" => 2, "completion_tokens" => 1, "total_tokens" => 3}} end {:ok, %{status: 200, headers: %{}, body: resp}} end r = Agents.run(agent, %{transport: mock_transport}, "remember that I like concise replies") true = r.status == "done" true = String.contains?(r.text, "MEMORY.md") true = File.read!(Path.join(dir, "MEMORY.md")) =~ "user likes concise replies" IO.puts("ok: persona from #{Path.basename(dir)} wrote to its own MEMORY.md via the wired memory tool") ``` ## `opts` fields [Section titled “opts fields”](#opts-fields) | Field | Type | Default | What it is | | --------- | ------------ | ---------------------------- | -------------------------------------------------------- | | `:does` | `String.t()` | `"persona agent from "` | Routing description. | | `:name` | `String.t()` | the directory’s basename | Agent name. | | `:model` | `String.t()` | `"inherit"` | LLM model. | | `:tools` | `[Tool.t()]` | `[]` | Extra tools alongside the memory builtin. | | `:memory` | `boolean()` | `true` | Set `false` to omit the memory tool (read-only persona). | ## See also [Section titled “See also”](#see-also) * [`Toolnexus.Agents.Home.compose_soul`](/api/elixir/persona/compose-soul/) — Build a persona’s system prompt from its home directory: identity, memory, skills. * [`Toolnexus.Agents.Home.memory_tool`](/api/elixir/persona/memory-tool/) — The opt-in built-in that lets a persona write durable notes to its own home. # Toolnexus.Agents.Home.memory_tool > The opt-in built-in that lets a persona write durable notes to its own home. — Elixir. **Elixir** · package `toolnexus` · [SPEC §7E](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [elixir/lib/toolnexus/agents/home.ex](https://github.com/muthuishere/toolnexus/blob/main/elixir/lib/toolnexus/agents/home.ex) ```elixir @spec memory_tool(String.t()) :: Toolnexus.Tool.t() def memory_tool(dir) # input_schema: {action: "add" | "replace" | "remove", target?: "self" | "user", text, with?} # action=add → appends "- " to the target file # action=replace → swaps an existing `text` substring for `with` (isError if absent) # action=remove → deletes an existing `text` substring (isError if absent) # target=self (default) → MEMORY.md; target=user → USER.md ``` One tool, three actions, over two files under `dir`: `MEMORY.md` (the agent’s own durable notes) and `USER.md` (its model of the user). Every write lands on **disk** immediately; the current session’s prompt is not touched — the frozen soul snapshot picks it up at the start of the **next** session. A `replace`/`remove` whose target substring is not found in the file is a loud `is_error: true`, never a silent no-op. ## When to use it [Section titled “When to use it”](#when-to-use-it) * **You’re building a persona that should remember things** — this is the builtin [`Toolnexus.Agents.Home.from_dir/2`](/api/elixir/persona/from-dir/) wires in by default; call `memory_tool/1` directly only when you want it standalone (a custom agent definition, a test, a tool list you’re assembling by hand). * **You want the model’s mental model of the user tracked separately from its own notes** — `target: "user"` writes `USER.md` instead of `MEMORY.md`. ## Why this and not the alternative [Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative) Reach for from\_dir/2 for the everyday path `memory_tool/1` is the file-backed builtin itself. In application code you almost always want [`Toolnexus.Agents.Home.from_dir/2`](/api/elixir/persona/from-dir/), which wires this tool over the same directory it composed the soul from — pass `memory: false` there to opt back out. Call `memory_tool/1` directly when you’re assembling a toolkit by hand, or writing a test against the tool in isolation. ## Examples [Section titled “Examples”](#examples) ### 1. The smallest useful call — `add`, executed directly [Section titled “1. The smallest useful call — add, executed directly”](#1-the-smallest-useful-call--add-executed-directly) ```elixir alias Toolnexus.{Context, ToolResult} alias Toolnexus.Agents.Home dir = Path.join(System.tmp_dir!(), "toolnexus_doc_memory_tool_1_#{System.unique_integer([:positive])}") File.rm_rf!(dir) File.mkdir_p!(dir) tool = Home.memory_tool(dir) true = tool.name == "memory" result = tool.execute.(%{"action" => "add", "text" => "prefers dark mode"}, %Context{}) %ToolResult{is_error: false} = result true = String.contains?(result.output, "MEMORY.md") true = File.read!(Path.join(dir, "MEMORY.md")) == "- prefers dark mode\n" IO.puts("ok: #{result.output}") ``` ### 2. The realistic case — `replace` and `remove`, and the loud miss [Section titled “2. The realistic case — replace and remove, and the loud miss”](#2-the-realistic-case--replace-and-remove-and-the-loud-miss) ```elixir alias Toolnexus.{Context, ToolResult} alias Toolnexus.Agents.Home dir = Path.join(System.tmp_dir!(), "toolnexus_doc_memory_tool_2_#{System.unique_integer([:positive])}") File.rm_rf!(dir) File.mkdir_p!(dir) File.write!(Path.join(dir, "MEMORY.md"), "- prefers dark mode\n- likes verbose logs\n") tool = Home.memory_tool(dir) replaced = tool.execute.(%{"action" => "replace", "text" => "verbose logs", "with" => "terse logs"}, %Context{}) false = replaced.is_error true = File.read!(Path.join(dir, "MEMORY.md")) =~ "terse logs" removed = tool.execute.(%{"action" => "remove", "text" => "- prefers dark mode\n"}, %Context{}) false = removed.is_error false = File.read!(Path.join(dir, "MEMORY.md")) =~ "dark mode" # a replace/remove target that isn't present is a loud error, not a silent no-op miss = tool.execute.(%{"action" => "replace", "text" => "nonexistent", "with" => "x"}, %Context{}) true = miss.is_error true = String.contains?(miss.output, "not found") IO.puts("ok: replace + remove applied; miss reported as isError") ``` ### 3. The full surface — `target: "user"` writes `USER.md` instead [Section titled “3. The full surface — target: "user" writes USER.md instead”](#3-the-full-surface--target-user-writes-usermd-instead) ```elixir alias Toolnexus.{Context, ToolResult} alias Toolnexus.Agents.Home dir = Path.join(System.tmp_dir!(), "toolnexus_doc_memory_tool_3_#{System.unique_integer([:positive])}") File.rm_rf!(dir) File.mkdir_p!(dir) tool = Home.memory_tool(dir) self_note = tool.execute.(%{"action" => "add", "text" => "checked the invoice twice"}, %Context{}) user_note = tool.execute.(%{"action" => "add", "target" => "user", "text" => "works in UTC+5:30"}, %Context{}) false = self_note.is_error false = user_note.is_error true = String.contains?(self_note.output, "MEMORY.md") true = String.contains?(user_note.output, "USER.md") true = File.read!(Path.join(dir, "MEMORY.md")) =~ "checked the invoice twice" true = File.read!(Path.join(dir, "USER.md")) =~ "works in UTC+5:30" # writes are on disk only — they say so, so a caller never assumes the live prompt changed true = String.contains?(user_note.output, "loads next session") IO.puts("ok: self note → MEMORY.md, user note → USER.md") ``` ## Input schema [Section titled “Input schema”](#input-schema) | Field | Type | Required | What it is | | -------- | -------------------------------- | --------------------- | --------------------------------------------------------------------- | | `action` | `"add" \| "replace" \| "remove"` | yes | The operation. | | `target` | `"self" \| "user"` | no (default `"self"`) | `self` → `MEMORY.md`, `user` → `USER.md`. | | `text` | `String.t()` | yes | For `add`: the entry. For `replace`/`remove`: the existing substring. | | `with` | `String.t()` | for `replace` | The replacement text. | ## See also [Section titled “See also”](#see-also) * [`Toolnexus.Agents.Home.compose_soul`](/api/elixir/persona/compose-soul/) — Build a persona’s system prompt from its home directory: identity, memory, skills. * [`Toolnexus.Agents.Home.from_dir`](/api/elixir/persona/from-dir/) — Point at an agent home directory and get a configured agent back. # Toolnexus.Agents.agent > Define a sub-agent with its own toolkit, prompt and budget, callable as a tool by its parent. — Elixir. **Elixir** · package `toolnexus` · [SPEC §7D](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [elixir/lib/toolnexus/agents.ex](https://github.com/muthuishere/toolnexus/blob/main/elixir/lib/toolnexus/agents.ex) ```elixir @spec agent(String.t(), keyword() | map()) :: Toolnexus.Agents.AgentDef.t() def agent(name, spec) # spec: :does (required — routing description), :uses (%{tools: [...]}), # :soul | :soul_file (system prompt inline or from a file), # :team ([agent/2] this agent may delegate to — recursion is opt-in), # :budget (%{max_turns, max_tokens, max_tool_calls, max_wall_ms, # max_children, max_concurrent, max_depth}), # :model (default "inherit" — the runtime's llm.model), # :wait_for (§10 interpreter — (Request.t() -> Answer.t())), # :on_spawn / :on_close, :hooks / :on_metric (§8 seams, per-agent) @spec run(AgentDef.t(), keyword() | map(), String.t()) :: map() def run(a, rt_opts, prompt) # one-shot: build a runtime, run to completion, tear down. # rt_opts are Toolnexus.Agents.Runtime options (:transport, :llm, ...). @spec as_tool(AgentDef.t(), keyword() | map()) :: Toolnexus.Tool.t() def as_tool(a, rt_opts) # the bridge: an Agent IS a Tool — drop it into the classic client/toolkit API. ``` The Level-1 surface over the agent runtime: one axiom, **an Agent is a Tool** — (system prompt × a filtered toolkit view × the §8 client loop), invocable uniformly. `agent/2` is the one new noun; `run/3` (one-shot, tear itself down) and `as_tool/2` (the bridge into the classic API) are how you actually use a definition. Everything compiles down to the six [`Runtime`](/api/elixir/runtime/runtime/) verbs — `agent/2` never spawns a process by itself. ## When to use it [Section titled “When to use it”](#when-to-use-it) * **You want a sub-agent with its own system prompt, tools, and budget**, callable by a parent agent’s `task` tool, or run directly to completion. * **Delegation without hand-rolling the runtime** — declare a `:team` and the parent gets a `task` tool scoped to exactly that team; children never inherit delegation unless their own definition also declares a team (recursion is opt-in). * **Dropping an agent into the classic toolkit/client API** — `as_tool/2` turns any `AgentDef` into a plain `Toolnexus.Tool`, so `Toolnexus.Client.run/4` can call it like any other tool. ## Why this and not the alternative [Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative) Reach for Runtime directly when you need the six verbs `run/3` is intentionally one-shot: spawn, wake, wait, and (unless it durably suspends) tear the runtime down. When you need long-lived handles — posting to an idle agent later, listing every spawned handle, interrupting one mid-turn — build a [`Toolnexus.Agents.Runtime`](/api/elixir/runtime/runtime/) yourself and drive the six verbs directly; `Agents.registry/2` turns an `AgentDef` + its team graph into the `:registry` option `Runtime.new/1` expects. ## Examples [Section titled “Examples”](#examples) ### 1. The smallest useful call — `agent/2` builds a definition, no I/O [Section titled “1. The smallest useful call — agent/2 builds a definition, no I/O”](#1-the-smallest-useful-call--agent2-builds-a-definition-no-io) ```elixir alias Toolnexus.Agents explore = Agents.agent("explore", does: "read-only research") coder = Agents.agent("coder", does: "implements changes", team: [explore], budget: %{max_tokens: 10_000}) true = explore.name == "explore" true = explore.spec[:does] == "read-only research" true = coder.spec[:team] == [explore] # registry/2 is the transitive closure of the team graph — pure data, no process spawned reg = Agents.registry(coder) true = reg |> Map.keys() |> Enum.sort() == ["coder", "explore"] true = reg["coder"][:task_targets] == ["explore"] IO.puts("ok: #{map_size(reg)} agent(s) in the registry — #{Enum.join(Map.keys(reg), ", ")}") ``` ### 2. The realistic case — `run/3` one-shot to completion, an in-memory transport [Section titled “2. The realistic case — run/3 one-shot to completion, an in-memory transport”](#2-the-realistic-case--run3-one-shot-to-completion-an-in-memory-transport) The `:transport` option (the same §8 seam `Toolnexus.Client.create` accepts) replaces the LLM HTTP call with an in-process function — zero network, zero cost, fully deterministic. ```elixir alias Toolnexus.Agents mock_transport = fn %{body: body} -> msgs = body["messages"] || [] tool_msgs = Enum.filter(msgs, &(&1["role"] == "tool")) resp = if tool_msgs == [] do %{ "choices" => [%{"message" => %{"role" => "assistant", "content" => nil, "tool_calls" => [ %{"id" => "c1", "type" => "function", "function" => %{"name" => "lookup", "arguments" => ~s({"q":"bug"})}} ]}}], "usage" => %{"prompt_tokens" => 5, "completion_tokens" => 3, "total_tokens" => 8} } else %{ "choices" => [%{"message" => %{"role" => "assistant", "content" => "bug at line 42"}}], "usage" => %{"prompt_tokens" => 4, "completion_tokens" => 2, "total_tokens" => 6} } end {:ok, %{status: 200, headers: %{}, body: resp}} end lookup = Toolnexus.define_tool(%{ name: "lookup", description: "look something up", input_schema: %{"type" => "object", "properties" => %{"q" => %{"type" => "string"}}}, execute: fn args -> "data(#{args["q"]})" end }) explore = Agents.agent("explore", does: "read-only research", uses: %{tools: [lookup]}, model: "m-explore") r = Agents.run(explore, %{transport: mock_transport}, "find the bug") true = r.status == "done" true = r.text == "bug at line 42" false = r.is_error true = r.turns == 2 IO.puts("ok: #{r.text} in #{r.turns} turns") ``` ### 3. The full surface — `as_tool/2` bridges into the classic client/toolkit API [Section titled “3. The full surface — as\_tool/2 bridges into the classic client/toolkit API”](#3-the-full-surface--as_tool2-bridges-into-the-classic-clienttoolkit-api) ```elixir alias Toolnexus.{Agents, Client} mock_transport = fn %{body: body} -> msgs = body["messages"] || [] tool_msgs = Enum.filter(msgs, &(&1["role"] == "tool")) resp = case body["model"] do "m-explore" -> %{"choices" => [%{"message" => %{"role" => "assistant", "content" => "bug at line 42"}}], "usage" => %{"prompt_tokens" => 3, "completion_tokens" => 2, "total_tokens" => 5}} "m-old-api" -> if tool_msgs == [] do %{ "choices" => [%{"message" => %{"role" => "assistant", "content" => nil, "tool_calls" => [ %{"id" => "e1", "type" => "function", "function" => %{"name" => "explore", "arguments" => ~s({"prompt":"scan the repo"})}} ]}}], "usage" => %{"prompt_tokens" => 4, "completion_tokens" => 2, "total_tokens" => 6} } else %{ "choices" => [%{"message" => %{"role" => "assistant", "content" => "summary: #{hd(tool_msgs)["content"]}"}}], "usage" => %{"prompt_tokens" => 3, "completion_tokens" => 2, "total_tokens" => 5} } end end {:ok, %{status: 200, headers: %{}, body: resp}} end explore = Agents.agent("explore", does: "read-only research", model: "m-explore") toolkit = [Agents.as_tool(explore, %{transport: mock_transport})] client = Client.create(base_url: "http://mock.local", style: "openai", model: "m-old-api", api_key: "test", transport: mock_transport) r = Client.run(client, "scan the repo for bugs", toolkit) true = r.text =~ "summary:" and r.text =~ "bug at line 42" true = hd(r.tool_calls).metadata.agent == "explore" IO.puts("ok: an Agent, called through the classic client API like any other tool") ``` ## `AgentDef.spec` fields (Level-1 surface) [Section titled “AgentDef.spec fields (Level-1 surface)”](#agentdefspec-fields-level-1-surface) | Field | Type | What it is | | ------------------------- | ----------------------------- | ------------------------------------------------------------------------------- | | `:does` | `String.t()` | Required routing description — advertised to a delegating parent’s `task` tool. | | `:uses` | `%{tools: [Tool.t()]}` | The toolkit view this agent’s turns see. | | `:soul` / `:soul_file` | `String.t()` | Inline system prompt, or a path to read it from. | | `:team` | `[AgentDef.t()]` | Delegation targets — never inherited by children. | | `:budget` | `map()` | See [Budgets](/api/elixir/runtime/budgets/). | | `:model` | `String.t()` | Default `"inherit"` — resolves to the runtime’s `llm.model`. | | `:wait_for` | `(Request.t() -> Answer.t())` | §10 interpreter authority for this agent. | | `:on_spawn` / `:on_close` | functions | Lifecycle callbacks. | | `:hooks` / `:on_metric` | §8 seams | Per-agent — replaces the runtime-wide value, never merged. | ## `as_tool_with/2` — a child under the parent’s gates [Section titled “as\_tool\_with/2 — a child under the parent’s gates”](#as_tool_with2--a-child-under-the-parents-gates) `Toolnexus.Agents.as_tool/2` stops the child run when the calling turn is cancelled. `Toolnexus.Agents.as_tool_with(agent, fn %Toolnexus.Context{} = ctx -> rt_opts end)` builds the runtime options **per call**, so each call can carry the parent turn’s `:hooks` (the same permission gate) and the new runtime `:wait_for`, which answers a child’s suspension when no agent on its chain has its own `:wait_for`. A child’s `bash` therefore cannot skip the parent’s gate. The child’s tool calls reach those hooks; there is no token stream of the child’s text. ```elixir Toolnexus.Agents.as_tool_with(explore, fn ctx -> %{transport: transport, hooks: parent_hooks, wait_for: parent_wait_for} end) ``` ## See also [Section titled “See also”](#see-also) * [`Toolnexus.Agents.Runtime`](/api/elixir/runtime/runtime/) — the six host verbs that drive sub-agents, plus the read-only list and inspect views. * [`Toolnexus.Agents.Handle`](/api/elixir/runtime/handle/) — the state machine for one spawned agent: pending, running, suspended, done. * [`Toolnexus.Agents budget`](/api/elixir/runtime/budgets/) — cap tool calls and wall-clock per agent and per team, enforced while the run is in flight. # Toolnexus.Agents budget > Cap tool calls and wall-clock per agent and per team, enforced while the run is in flight. — Elixir. **Elixir** · package `toolnexus` · [SPEC §7D](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [elixir/lib/toolnexus/agents/handle.ex](https://github.com/muthuishere/toolnexus/blob/main/elixir/lib/toolnexus/agents/handle.ex) ```elixir # A budget is a PLAIN MAP — no dedicated struct in this port — passed as # :budget in an agent/2 spec, or as the 4th arg to spawn_agent/4: %{ max_turns: 6, # LLM calls per Handle turn (client-loop cap; default 6) max_tokens: nil, # carved at spawn: effective = min(own request, parent remaining) max_tool_calls: nil, # cumulative across every wake this handle has done max_wall_ms: nil, # wall-clock since spawn max_children: :infinity, max_concurrent: 8, # running children per parent, admitted atomically max_depth: 3 # checked at spawn, on the PARENT } @spec spawn_agent(pid(), pid(), String.t(), map() | nil) :: {:ok, pid()} | {:error, String.t()} def spawn_agent(rt, parent, def_name, budget \\ nil) # budget here OVERRIDES/merges onto the definition's own :budget for this one spawn ``` Budgets are **hierarchical and live-enforced**, not just carved once at spawn: `pool_tokens` carves `min(own request, parent's remaining)` when a handle is created, and every ancestor’s remaining pool is walked again **before each turn and each spawn** — carving alone would miss sibling spend. Any limit stop is **loud**: the handle settles with `status: "incomplete"` and the limit named in `limit` (`"maxTokens"`, `"maxToolCalls"`, `"maxWallMs"`, `"maxChildren"`, `"maxDepth"`) — never a silent `"done"`, never a crash. Partial work and the transcript are preserved. ## When to use it [Section titled “When to use it”](#when-to-use-it) * **Bounding a delegated sub-agent’s spend** — `budget: %{max_tokens: 20_000}` on `agent/2`, or a one-off override on a specific `spawn_agent/4` call. * **Capping fan-out** — `max_children` and `max_depth` stop a runaway delegation tree at spawn time, before any tokens are spent on it. * **A slow tool or a stuck loop** — `max_tool_calls` and `max_wall_ms` bound a handle even when the model keeps asking for more turns. ## Why this and not the alternative [Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative) This is a plain map here, a named type in most other ports Elixir represents a budget as an ordinary map, not a dedicated struct — it composes the same way every other keyword-ish option in this port does (`agent/2`’s `spec`, `Client.create`’s `opts`). Reach for the `:budget` key on `agent/2` when the cap belongs to the definition; pass a budget map as `spawn_agent/4`’s 4th argument when you want a one-off override for a single spawn (e.g. a tighter cap for one particular delegation) without touching the shared definition. ## Examples [Section titled “Examples”](#examples) ### 1. The smallest useful call — a budget map on `agent/2` [Section titled “1. The smallest useful call — a budget map on agent/2”](#1-the-smallest-useful-call--a-budget-map-on-agent2) ```elixir alias Toolnexus.Agents coder = Agents.agent("coder", does: "implements changes", budget: %{max_turns: 4, max_tokens: 20_000, max_tool_calls: 10}) reg = Agents.registry(coder) true = reg["coder"][:budget] == %{max_turns: 4, max_tokens: 20_000, max_tool_calls: 10} IO.puts("ok: #{inspect(reg["coder"][:budget])}") ``` ### 2. The realistic case — carve at spawn, and `max_children` refused at spawn [Section titled “2. The realistic case — carve at spawn, and max\_children refused at spawn”](#2-the-realistic-case--carve-at-spawn-and-max_children-refused-at-spawn) ```elixir alias Toolnexus.{Agents, Agents.Runtime} transport = fn _req -> {:ok, %{status: 200, headers: %{}, body: %{"choices" => [%{"message" => %{"role" => "assistant", "content" => "ok"}}], "usage" => %{"prompt_tokens" => 1, "completion_tokens" => 1, "total_tokens" => 2}}}} end explore = Agents.agent("explore", does: "read-only research", model: "m-explore") coordinator = Agents.agent("coordinator", does: "delegates", team: [explore], model: "m-coordinator") rt = Runtime.new(%{transport: transport, registry: Agents.registry(coordinator)}) # coordinator carves 100 tokens off the (unlimited) root; its child ASKS for 500, but the # effective pool is min(own request, parent remaining) = 100. {:ok, c} = Runtime.spawn_agent(rt, Runtime.root(rt), "coordinator", %{max_tokens: 100, max_children: 2}) {:ok, kid} = Runtime.spawn_agent(rt, c, "explore", %{max_tokens: 500}) true = Runtime.snapshot(kid).pool_tokens == 100 {:ok, _kid2} = Runtime.spawn_agent(rt, c, "explore") kid3 = Runtime.spawn_agent(rt, c, "explore") # maxChildren is checked at spawn time, on the PARENT — never mid-run true = match?({:error, _}, kid3) {:error, msg} = kid3 true = msg =~ "maxChildren" carved = Runtime.snapshot(kid).pool_tokens Runtime.shutdown(rt) IO.puts("ok: carved #{carved} token(s); 3rd child refused: #{msg}") ``` ### 3. The full surface — `max_tool_calls` exhausted: loud `incomplete`, never a crash [Section titled “3. The full surface — max\_tool\_calls exhausted: loud incomplete, never a crash”](#3-the-full-surface--max_tool_calls-exhausted-loud-incomplete-never-a-crash) The ancestor-chain walk runs **before every turn**, so a second `wake` is refused synchronously — the handle never even starts a Run it cannot afford. ```elixir alias Toolnexus.{Agents, Agents.Runtime} usage = %{"prompt_tokens" => 5, "completion_tokens" => 3, "total_tokens" => 8} transport = fn %{body: body} -> tool_msgs = Enum.filter(body["messages"] || [], &(&1["role"] == "tool")) resp = if tool_msgs == [] do %{"choices" => [%{"message" => %{"role" => "assistant", "content" => nil, "tool_calls" => [ %{"id" => "l1", "type" => "function", "function" => %{"name" => "lookup", "arguments" => ~s({"q":"x"})}} ]}}], "usage" => usage} else %{"choices" => [%{"message" => %{"role" => "assistant", "content" => "looked it up"}}], "usage" => usage} end {:ok, %{status: 200, headers: %{}, body: resp}} end lookup = Toolnexus.define_tool(%{ name: "lookup", description: "look something up", input_schema: %{"type" => "object", "properties" => %{"q" => %{"type" => "string"}}}, execute: fn args -> "data(#{args["q"]})" end }) worker = Agents.agent("worker", does: "answers, one tool call at a time", uses: %{tools: [lookup]}, model: "m-worker", budget: %{max_tool_calls: 1}) rt = Runtime.new(%{transport: transport, registry: Agents.registry(worker)}) {:ok, h} = Runtime.spawn_agent(rt, Runtime.root(rt), "worker") Runtime.wake(rt, h, "look something up") r1 = Runtime.wait(rt, h) true = r1.status == "done" true = Runtime.snapshot(h).tool_calls_total == 1 # a SECOND wake is refused synchronously — cumulative tool_calls_total (1) >= max_tool_calls (1) refusal = Runtime.wake(rt, h, "look up something else") true = refusal == %{ok: false, is_error: "budget exhausted (maxToolCalls 1); partial work preserved"} r2 = Runtime.wait(rt, h) true = r2.status == "incomplete" true = r2.limit == "maxToolCalls" true = r2.is_error Runtime.shutdown(rt) IO.puts("ok: 1st wake done, 2nd wake refused loudly — limit=#{r2.limit}") ``` ## Budget map fields [Section titled “Budget map fields”](#budget-map-fields) | Field | Default | Enforced | | ---------------- | ----------- | ---------------------------------------------------------------------------------------------------- | | `max_turns` | `6` | The client loop’s own cap (§8) — per Handle turn, not cumulative. | | `max_tokens` | `:infinity` | Carved at spawn (`min(own, parent remaining)`); walked live before each turn/spawn. | | `max_tool_calls` | `:infinity` | Cumulative across every wake this handle has done. | | `max_wall_ms` | `:infinity` | Wall-clock since spawn (via the runtime’s injectable clock). | | `max_children` | `:infinity` | Checked at spawn, on the parent. | | `max_concurrent` | `8` | Running children per parent — an atomic admission gate, not a hard reject (excess wakes queue FIFO). | | `max_depth` | `3` | Checked at spawn, on the parent. | A limit stop is always `status: "incomplete"` with `limit` naming which one tripped — money is excluded from the budget dimensions (vendor-specific; hosts convert their own cost tracking in an optional `onBudget` callback). ## See also [Section titled “See also”](#see-also) * [`Toolnexus.Agents.agent`](/api/elixir/runtime/agent/) — define a sub-agent with its own toolkit, prompt and budget, callable as a tool by its parent. * [`Toolnexus.Agents.Runtime`](/api/elixir/runtime/runtime/) — the six host verbs that drive sub-agents, plus the read-only list and inspect views. * [`Toolnexus.Agents.Handle`](/api/elixir/runtime/handle/) — the state machine for one spawned agent: pending, running, suspended, done. # Toolnexus.Agents.Handle > The state machine for one spawned agent: pending, running, suspended, done. — Elixir. **Elixir** · package `toolnexus` · [SPEC §7D](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [elixir/lib/toolnexus/agents/handle.ex](https://github.com/muthuishere/toolnexus/blob/main/elixir/lib/toolnexus/agents/handle.ex) ```elixir # states: idle → running → (idle | suspended | closed) # suspended → running ONLY via the Answer to its pending Request @spec snapshot(pid()) :: map() def snapshot(pid) # -> %{id, state, def, parent, depth, inbox, pool_tokens, usage_total, turns_total, # tool_calls_total, children, pending_req, last_result, task_key, ...} # The six verbs (Toolnexus.Agents.Runtime wraps these; see that page): def post(pid, item) # -> %{ok, is_error?} def wake(pid, prompt \\ nil) # -> %{ok, is_error?} def wait(pid, timeout_ms \\ nil, opts \\ []) # -> next-or-last result def interrupt(pid) # -> :ok, running→idle (never a kill) ``` **One GenServer per handle.** The handle’s inbox is GenServer STATE — bounded, loud-reject, transactionally drained, checkpointable — never the BEAM mailbox; the BEAM mailbox carries only the six verbs. A Run (one client-loop invocation) is a separate **monitored** process: its crash or kill crosses this boundary as an `is_error` **result**, never a GenServer exit — only the root may throw to the host. Handle→handle *blocking* calls flow strictly **rootward** (child→ancestor); the only parent→child interaction is a cast (the concurrency-slot transfer on dequeue). Downward traversal — close cascade, `list`, `resume` — runs from **outside** the tree. ## When to use it [Section titled “When to use it”](#when-to-use-it) * **Reading one handle’s full state** — `snapshot/1` is the only way to see `inbox`, `children`, `pending_req`, and the rolled-up `usage_total`/`turns_total` for a single handle; `Runtime.list/1` only gives the flat summary. * **You already hold a `pid`** from `Runtime.spawn_agent/4` and want to drive it directly — every verb is also exposed on `Runtime`, which is the everyday way to call them; `Handle` is the module they actually dispatch to. * **Understanding the state machine itself** — `idle → running → (idle | suspended | closed)`, and `suspended → running` **only** via the Answer to its own pending Request; there is no other exit from `suspended`. ## Why this and not the alternative [Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative) Call the verbs through Runtime, not Handle, in application code [`Toolnexus.Agents.Runtime`](/api/elixir/runtime/runtime/) wraps every `Handle` verb (`spawn_agent/4`, `post/3`, `wake/3`, `wait/3`, `interrupt/2`, `close/2`) and additionally owns the cross-cutting infrastructure (turn gate, ConversationStore, clock). Reach for `Handle` functions directly only when you already have a raw `pid` and want the primitive without going through the runtime wrapper — `snapshot/1` in particular has no `Runtime` equivalent for a single handle. ## Examples [Section titled “Examples”](#examples) ### 1. The smallest useful call — spawn, then read the idle snapshot [Section titled “1. The smallest useful call — spawn, then read the idle snapshot”](#1-the-smallest-useful-call--spawn-then-read-the-idle-snapshot) ```elixir alias Toolnexus.{Agents, Agents.Runtime, Agents.Handle} transport = fn _req -> {:ok, %{status: 200, headers: %{}, body: %{"choices" => [%{"message" => %{"role" => "assistant", "content" => "ok"}}], "usage" => %{"prompt_tokens" => 1, "completion_tokens" => 1, "total_tokens" => 2}}}} end peer = Agents.agent("peer", does: "does nothing yet", model: "m-peer") rt = Runtime.new(%{transport: transport, registry: Agents.registry(peer)}) {:ok, h} = Runtime.spawn_agent(rt, Runtime.root(rt), "peer") snap = Handle.snapshot(h) true = snap.id == "root/peer.1" true = snap.state == :idle true = snap.depth == 1 true = snap.inbox == [] true = snap.children == [] Runtime.shutdown(rt) IO.puts("ok: #{snap.id} spawned #{snap.state} at depth #{snap.depth}") ``` ### 2. The realistic case — `idle→running→idle`, observed via `snapshot/1` [Section titled “2. The realistic case — idle→running→idle, observed via snapshot/1”](#2-the-realistic-case--idlerunningidle-observed-via-snapshot1) ```elixir alias Toolnexus.{Agents, Agents.Runtime, Agents.Handle} transport = fn _req -> {:ok, %{status: 200, headers: %{}, body: %{"choices" => [%{"message" => %{"role" => "assistant", "content" => "done"}}], "usage" => %{"prompt_tokens" => 1, "completion_tokens" => 1, "total_tokens" => 2}}}} end peer = Agents.agent("peer", does: "settles quickly", model: "m-peer") rt = Runtime.new(%{transport: transport, registry: Agents.registry(peer)}) {:ok, h} = Runtime.spawn_agent(rt, Runtime.root(rt), "peer") true = Handle.snapshot(h).state == :idle Runtime.wake(rt, h, "go") r = Runtime.wait(rt, h) true = r.status == "done" after_snap = Handle.snapshot(h) true = after_snap.state == :idle true = after_snap.turns_total == 1 true = after_snap.last_result.text == "done" true = Handle.snapshot(h).id == "root/peer.1" trace = Runtime.trace(rt) true = Enum.any?(trace, &(&1 =~ "root/peer.1: idle→running")) Runtime.shutdown(rt) IO.puts("ok: settled back to #{after_snap.state} after #{after_snap.turns_total} turn") ``` ### 3. The full surface — `suspended`, then `Runtime.resume/2` back to `done` [Section titled “3. The full surface — suspended, then Runtime.resume/2 back to done”](#3-the-full-surface--suspended-then-runtimeresume2-back-to-done) The Answer is the **only** exit from `suspended` — never a plain `wake`. A suspending tool result (`metadata.pending`) surfaces as `status: "pending"` (§10); the handle itself flips to `:suspended`. ```elixir alias Toolnexus.{Agents, Agents.Runtime, Agents.Handle, Answer, Request, ToolResult} transport = fn %{body: body} -> tool_msgs = Enum.filter(body["messages"] || [], &(&1["role"] == "tool")) resp = if Enum.any?(tool_msgs, &String.contains?(to_string(&1["content"]), "secret-token")) do %{"choices" => [%{"message" => %{"role" => "assistant", "content" => "asker-done: secret-token"}}], "usage" => %{"prompt_tokens" => 2, "completion_tokens" => 1, "total_tokens" => 3}} else %{ "choices" => [%{"message" => %{"role" => "assistant", "content" => nil, "tool_calls" => [ %{"id" => "a1", "type" => "function", "function" => %{"name" => "check_secret", "arguments" => "{}"}} ]}}], "usage" => %{"prompt_tokens" => 2, "completion_tokens" => 1, "total_tokens" => 3} } end {:ok, %{status: 200, headers: %{}, body: resp}} end check_secret = Toolnexus.define_tool(%{ name: "check_secret", description: "needs human approval", input_schema: %{"type" => "object", "properties" => %{}}, execute: fn _args, ctx -> if ctx.answer && ctx.answer.ok do "secret-token" else %ToolResult{ output: "approve secret access?", is_error: false, metadata: %{pending: %Request{id: "req-1", kind: "approval", prompt: "approve secret access?"}} } end end }) asker = Agents.agent("asker", does: "needs approvals", uses: %{tools: [check_secret]}, model: "m-asker") rt = Runtime.new(%{transport: transport, registry: Agents.registry(asker)}) {:ok, h} = Runtime.spawn_agent(rt, Runtime.root(rt), "asker") Runtime.wake(rt, h, "get the secret") r = Runtime.wait(rt, h) true = r.status == "pending" true = r.pending.kind == "approval" true = Handle.snapshot(h).state == :suspended # resume/2 routes the Answer to this (deepest) suspended handle, blocks until it # resettles, and returns the result of the topmost handle the cascade re-ran. final = Runtime.resume(rt, %Answer{id: r.pending.id, ok: true}) true = final.status == "done" true = final.text == "asker-done: secret-token" true = Handle.snapshot(h).state == :idle Runtime.shutdown(rt) IO.puts("ok: suspended -> resume -> \"#{final.text}\"") ``` ## `snapshot/1` shape [Section titled “snapshot/1 shape”](#snapshot1-shape) | Field | Type | What it is | | -------------------------------------------------- | -------------------------------------------- | ----------------------------------------------------------------------------- | | `id` | `String.t()` | Deterministic, parent-scoped (`root/coordinator.1/explore.2`) — never random. | | `state` | `:idle \| :running \| :suspended \| :closed` | | | `depth` | `non_neg_integer()` | Distance from root. | | `inbox` | `[map()]` | Items posted but not yet drained into a turn. | | `pool_tokens` | `non_neg_integer() \| :infinity` | Remaining token budget (carved from the parent). | | `usage_total` / `turns_total` / `tool_calls_total` | integers | Cumulative across every turn this handle has run. | | `children` | `[%{pid, id, task_key}]` | Direct children only. | | `pending_req` | `Request.t() \| nil` | Set only while `state == :suspended`. | | `last_result` | `map() \| nil` | The most recent settled result (idle or suspended). | ## See also [Section titled “See also”](#see-also) * [`Toolnexus.Agents.agent`](/api/elixir/runtime/agent/) — define a sub-agent with its own toolkit, prompt and budget, callable as a tool by its parent. * [`Toolnexus.Agents.Runtime`](/api/elixir/runtime/runtime/) — the six host verbs that drive sub-agents, plus the read-only list and inspect views. * [`Toolnexus.Agents budget`](/api/elixir/runtime/budgets/) — cap tool calls and wall-clock per agent and per team, enforced while the run is in flight. # 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. — Elixir. **Elixir** · package `toolnexus` · [SPEC §7D](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) ```elixir def harness(spec), do: spec # a NAME, not a type — the agent spec already IS it %Toolnexus.Agents.Loop{agent: agent, options: options, toolkit: toolkit, status: "idle", turns: 0, history: []} @type guardrail :: (map() -> String.t() | nil) @type verdict :: %{ok: boolean(), reason: String.t()} @type completion :: %{required(:verify) => (Client.RunResult.t() -> verdict()), required(:max_attempts) => pos_integer()} Toolnexus.Agents.loop(agent_def, client_options, toolkit) # build a Loop Toolnexus.Agents.Loop.run(loop, prompt, opts \\ []) # {outcome, loop} Toolnexus.Agents.Loop.all_todos_done(run_result) # built-in completion verifier Toolnexus.Agents.Loop.loop_unsupported(spec) # ["tools", "team", "waitFor", "onMetric"] ``` 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. `Toolnexus.Agents.Loop` is a plain struct, not a process — it threads `turns` and `history` through `run/3`’s return value (`{outcome, loop}`), which is what lets the same completion-gate machinery serve both a caller driving a `Loop` directly and a sub-agent turn running inside [`Toolnexus.Agents.Runtime`](/api/elixir/runtime/runtime/)’s own process (`elixir/lib/toolnexus/agents/loop.ex:1-20`). The placement law the module doc states directly: the **agent spec** (the harness) answers “MAY it?” — capability, ceilings, per problem; **run opts** answer “with WHAT?” — model for this call, per call; **Loop** answers “DID it?” — status, turns, observed. None of them answers “is it RIGHT?” — that’s the job of a tool, a skill, or an agent itself. `harness/1` (`loop.ex:58`) is a NAME, not a type: `agent("x", harness(does: "..."))` and `agent("x", does: "...")` are indistinguishable, because an agent spec already IS the harness — tools, soul, team, budget, model, policy, ceilings all live there already. See the narrative overview at [`/harness/`](/harness/) for how harness/loop/guardrail/completion fit together conceptually; this page documents only the Elixir call shapes. ## When to use it [Section titled “When to use it”](#when-to-use-it) * **You need a policy gate over tool calls, not just the plain client loop** — a `guardrail` (`@type guardrail :: (map() -> String.t() | nil)`, `loop.ex:27`) inspects every proposed tool call and can deny it by returning a non-empty, non-`"allow"` string; returning `nil` or `""` (or `"allow"`) permits it. Compile a list of guardrails into hooks with `Loop.guarded_hooks/2` — first deny wins, and a later guardrail can never re-allow what an earlier one denied. * **“Done” needs to mean something stronger than “the model stopped calling tools”** — a `completion` map (`@type completion`, `loop.ex:36`) pairs a `:verify` function (judging the **accumulated** tool calls across every attempt, not just the latest one) with a required `:max_attempts` ceiling. `Loop.all_todos_done/1` (`loop.ex:242`) is the shipped built-in verify: it reads the `todowrite` builtin’s result metadata and requires every declared item checked, passing automatically when no plan was ever declared (so it never punishes an agent that doesn’t use the builtin). * **A driver needs to know, in advance, which spec fields it can honour** — `Loop.loop_unsupported/1` (`loop.ex:172`) takes a spec and returns, in a fixed order, the names of the fields a `Loop` cannot drive: `tools`, `team`, `waitFor`, `onMetric` — because a `Loop` drives one client over one conversation, so it cannot own a tool wiring, a team, a §10 interpreter, or a metric sink; those belong to [`Toolnexus.Agents.Runtime`](/api/elixir/runtime/runtime/) instead. An empty list means the spec is fully honoured by a bare `Loop`. ## Why this and not the alternative [Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative) Two doors on one agent (SPEC §7D) `Toolnexus.Agents.Runtime.spawn/2` runs an agent unconditionally — no guardrail, no completion gate, and it owns tools/team/waitFor/onMetric. `Agents.loop(agent, opts, toolkit)` + `Loop.run/3` is the OTHER door: it adds the guardrail + completion gate this page documents, at the cost of not being able to drive those four runtime-owned fields at all — `loop_unsupported/1` exists specifically so a caller finds out which fields it lost, rather than having them silently ignored. Reach for the `Runtime` door when you need sub-agents, a team, or durable §10 suspension; reach for `Loop` when you need a policy gate over a single conversation and don’t need any of those four. ## Examples [Section titled “Examples”](#examples) ### 1. The smallest useful call — no completion, no guardrails, unchanged behavior [Section titled “1. The smallest useful call — no completion, no guardrails, unchanged behavior”](#1-the-smallest-useful-call--no-completion-no-guardrails-unchanged-behavior) ```elixir alias Toolnexus.Agents alias Toolnexus.Agents.Loop {:ok, tk} = Toolnexus.create_toolkit(builtins: false) plug = fn conn -> {:ok, _raw, conn} = Plug.Conn.read_body(conn) resp = %{"choices" => [%{"message" => %{"role" => "assistant", "content" => "hello"}}], "usage" => %{"prompt_tokens" => 1, "completion_tokens" => 1, "total_tokens" => 2}} conn |> Plug.Conn.put_resp_content_type("application/json") |> Plug.Conn.send_resp(200, Jason.encode!(resp)) end opts = %{base_url: "http://localhost", style: "openai", model: "gpt-x", api_key: "test-key", http_options: [plug: plug]} agent = Agents.agent("plain", does: "answers") loop = Agents.loop(agent, opts, tk) {out, _loop} = Loop.run(loop, "hi") true = out.status == "done" true = out.text == "hello" true = out.attempts == 1 true = out.stopped_by == nil IO.puts("ok: #{out.status} in #{out.attempts} attempt(s)") ``` ### 2. The realistic case — a completion gate that blocks on an open todo, then passes [Section titled “2. The realistic case — a completion gate that blocks on an open todo, then passes”](#2-the-realistic-case--a-completion-gate-that-blocks-on-an-open-todo-then-passes) ```elixir alias Toolnexus.Agents alias Toolnexus.Agents.Loop {:ok, tk} = Toolnexus.create_toolkit( builtins: %{"tools" => %{"todowrite" => true, "bash" => false, "read" => false, "write" => false, "edit" => false, "glob" => false, "grep" => false, "webfetch" => false, "apply_patch" => false, "question" => false}} ) {:ok, turn} = Agent.start_link(fn -> 0 end) todo = fn id, text, done -> %{"id" => id, "text" => text, "completed" => done} end plug = fn conn -> {:ok, _raw, conn} = Plug.Conn.read_body(conn) n = Agent.get_and_update(turn, fn c -> {c, c + 1} end) message = case n do 0 -> %{"role" => "assistant", "tool_calls" => [%{"id" => "t1", "type" => "function", "function" => %{"name" => "todowrite", "arguments" => Jason.encode!(%{"todos" => [todo.("1", "draft", true), todo.("2", "proofread", false)]})}}]} 1 -> %{"role" => "assistant", "content" => "I think I am finished"} 2 -> %{"role" => "assistant", "tool_calls" => [%{"id" => "t2", "type" => "function", "function" => %{"name" => "todowrite", "arguments" => Jason.encode!(%{"todos" => [todo.("1", "draft", true), todo.("2", "proofread", true)]})}}]} _ -> %{"role" => "assistant", "content" => "all done"} end finish = if Map.has_key?(message, "tool_calls"), do: "tool_calls", else: "stop" resp = %{"choices" => [%{"message" => message, "finish_reason" => finish}], "usage" => %{"prompt_tokens" => 1, "completion_tokens" => 1, "total_tokens" => 2}} conn |> Plug.Conn.put_resp_content_type("application/json") |> Plug.Conn.send_resp(200, Jason.encode!(resp)) end opts = %{base_url: "http://localhost", style: "openai", model: "gpt-x", api_key: "test-key", http_options: [plug: plug]} agent = Agents.agent("gated", does: "plans", completion: %{verify: &Loop.all_todos_done/1, max_attempts: 3}) {out, _loop} = Loop.run(Agents.loop(agent, opts, tk), "do the thing") true = out.status == "done" true = out.attempts >= 2 IO.puts("ok: #{out.status} after #{out.attempts} attempt(s) (gate forced a retry)") ``` ### 3. The full surface — guardrails deny-first, and unsupported fields are named explicitly [Section titled “3. The full surface — guardrails deny-first, and unsupported fields are named explicitly”](#3-the-full-surface--guardrails-deny-first-and-unsupported-fields-are-named-explicitly) ```elixir alias Toolnexus.Agents.Loop {:ok, seen} = Agent.start_link(fn -> 0 end) hooks = Loop.guarded_hooks( [ fn ev -> if ev[:name] == "danger", do: "policy: no", else: "allow" end, fn _ -> Agent.update(seen, &(&1 + 1)); "allow" end ], nil ) denied = hooks[:before_tool].(%{name: "danger", args: %{}, turn: 1}) true = denied.result.is_error true = denied.result.output =~ "policy: no" unless Agent.get(seen, & &1) == 0 do raise "a later guardrail never runs after a denial" end nil = hooks[:before_tool].(%{name: "safe", args: %{}, turn: 1}) true = Agent.get(seen, & &1) == 1 # a Loop cannot drive team / waitFor — the driver names them rather than dropping them silently unsupported = Loop.loop_unsupported(%{team: [%{}], wait_for: fn _ -> nil end}) true = unsupported == ["team", "waitFor"] true = Loop.loop_unsupported(%{does: "x"}) == [] IO.puts("ok: guardrail denied=#{denied.result.is_error}, unsupported=#{inspect(unsupported)}") ``` ## See also [Section titled “See also”](#see-also) * [`Toolnexus.Agents`](/api/elixir/runtime/agent/) — Define a sub-agent with its own toolkit, prompt and budget, callable as a tool by its parent. * [`Toolnexus.Agents.Runtime`](/api/elixir/runtime/runtime/) — The six host verbs that drive sub-agents, plus the read-only list and inspect views. * [`Toolnexus.Agents.Handle`](/api/elixir/runtime/handle/) — The state machine for one spawned agent: pending, running, suspended, done. * [`Toolnexus.Agents budget`](/api/elixir/runtime/budgets/) — Cap tool calls and wall-clock per agent and per team, enforced while the run is in flight. * [Harness & Loop](/harness/) — The narrative overview: what harness/loop/guardrail/completion mean conceptually, across all eight ports. # Toolnexus.Agents.Runtime > The six host verbs that drive sub-agents, plus the read-only list and inspect views. — Elixir. **Elixir** · package `toolnexus` · [SPEC §7D](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [elixir/lib/toolnexus/agents/runtime.ex](https://github.com/muthuishere/toolnexus/blob/main/elixir/lib/toolnexus/agents/runtime.ex) ```elixir @spec new(keyword() | map()) :: pid() def new(opts) # opts: :registry (agent name -> definition, see Agents.registry/2), :transport (§8 seam), # :llm (%{base_url, style, model, api_key, retries}), :max_concurrent_turns (default 8), # :inbox_cap (default 8), :shutdown_ms (default 200), :clock, :store # the six verbs (all take the runtime pid + a Handle pid): def spawn_agent(rt, parent, def_name, budget \\ nil) # -> {:ok, pid} | {:error, msg} def post(rt, pid, item) # -> %{ok, is_error?} def wake(rt, pid, prompt \\ nil) # -> %{ok, is_error?} def wait(rt, pid, timeout_ms \\ nil) # -> %{text, is_error, status, pending, turns, total_tokens} def interrupt(rt, pid) # -> :ok def close(rt, opts \\ []) # -> :ok (close(root) = stop-all) # read-only views + lifecycle: def root(rt) # the root handle def list(rt) # [%{id, state, tokens, inbox}] — every non-root handle def snapshot(pid) # full detail on ONE handle def resume(rt, answer) # route a §10 Answer to the deepest suspended handle def shutdown(rt) # close(root) then tear the runtime down ``` An OTP runtime for SPEC §7D: handles are `GenServer`s under a runtime-owned `DynamicSupervisor`; the six verbs are the GenServer protocol; a Run (one client-loop invocation) is its own monitored process, so its crash or kill crosses the handle boundary as an `is_error` **result**, never a GenServer exit. The runtime owns everything cross-cutting — one `ConversationStore` for every handle (conversation id = the handle’s deterministic id, so resume reads real history), one `MetricsRegistry`, an injectable clock, and the global turn gate that wraps **only** the LLM HTTP call (never a whole Run — holding it across a Run would deadlock parent against child). ## When to use it [Section titled “When to use it”](#when-to-use-it) * **You need long-lived handles**, not one-shot completion — post to an idle agent later, list everything spawned so far, interrupt one mid-turn. `Toolnexus.Agents.run/3` tears its runtime down as soon as the top-level agent settles; `Runtime` does not. * **Driving the unsolicited rail directly** — `post/3` + `wake/3` is how a heartbeat, a timer tick, or a sibling’s result reaches an idle agent as a coalesced turn. * **Inspecting live state** — `list/1` for a flat overview, `snapshot/1` for one handle’s full detail (state, inbox depth, rolled-up tokens, children). ## Why this and not the alternative [Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative) Reach for Agents.run/3 when one-shot to completion is all you need [`Toolnexus.Agents.run/3`](/api/elixir/runtime/agent/) builds a `Runtime`, spawns the top-level agent, wakes it, waits, and tears the runtime down — exactly what most callers want. `Toolnexus.Agents.registry/2` turns an `AgentDef` (and its whole team graph) into the `:registry` map `Runtime.new/1` expects, so you rarely hand-build one. ## 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) ```elixir alias Toolnexus.{Agents, Agents.Runtime} transport = fn %{body: _body} -> resp = %{ "choices" => [%{"message" => %{"role" => "assistant", "content" => "pong"}}], "usage" => %{"prompt_tokens" => 1, "completion_tokens" => 1, "total_tokens" => 2} } {:ok, %{status: 200, headers: %{}, body: resp}} end peer = Agents.agent("peer", does: "says pong", model: "m-peer") rt = Runtime.new(%{transport: transport, registry: Agents.registry(peer)}) {:ok, h} = Runtime.spawn_agent(rt, Runtime.root(rt), "peer") %{ok: true} = Runtime.wake(rt, h, "ping") r = Runtime.wait(rt, h) true = r.status == "done" true = r.text == "pong" true = r.turns == 1 Runtime.shutdown(rt) IO.puts("ok: #{r.text} in #{r.turns} turn") ``` ### 2. The realistic case — the unsolicited rail: `post/3` coalesces into one turn [Section titled “2. The realistic case — the unsolicited rail: post/3 coalesces into one turn”](#2-the-realistic-case--the-unsolicited-rail-post3-coalesces-into-one-turn) ```elixir alias Toolnexus.{Agents, Agents.Runtime} transport = fn %{body: body} -> last = to_string(List.last(body["messages"] || [])["content"] || "") n = length(Regex.scan(~r/^\d+\./m, last)) resp = %{ "choices" => [%{"message" => %{"role" => "assistant", "content" => "processed #{n} items"}}], "usage" => %{"prompt_tokens" => 3, "completion_tokens" => 2, "total_tokens" => 5} } {:ok, %{status: 200, headers: %{}, body: resp}} end peer = Agents.agent("peer", does: "coalesces inbox posts", model: "m-peer") rt = Runtime.new(%{transport: transport, registry: Agents.registry(peer)}) {:ok, h} = Runtime.spawn_agent(rt, Runtime.root(rt), "peer") Runtime.post(rt, h, %{from: "root", channel: "peer", text: "update 1"}) Runtime.post(rt, h, %{from: "external", channel: "external", text: "webhook payload"}) Runtime.wake(rt, h) r = Runtime.wait(rt, h) # TWO posts, ONE wake -> ONE turn for both items (coalesced, never two turns) true = r.turns == 1 true = r.text == "processed 2 items" Runtime.shutdown(rt) IO.puts("ok: #{r.text} (#{r.turns} turn for 2 posts)") ``` ### 3. The full surface — delegation, `list/1`/`snapshot/1`, then a leaf-first `close/2` [Section titled “3. The full surface — delegation, list/1/snapshot/1, then a leaf-first close/2”](#3-the-full-surface--delegation-list1snapshot1-then-a-leaf-first-close2) ```elixir alias Toolnexus.{Agents, Agents.Runtime} transport = fn %{body: body} -> tool_msgs = Enum.filter(body["messages"] || [], &(&1["role"] == "tool")) resp = case body["model"] do "m-coordinator" -> if tool_msgs == [] do %{ "choices" => [%{"message" => %{"role" => "assistant", "content" => nil, "tool_calls" => [ %{"id" => "c1", "type" => "function", "function" => %{"name" => "task", "arguments" => Jason.encode!(%{"agent" => "explore", "prompt" => "find it"})}} ]}}], "usage" => %{"prompt_tokens" => 4, "completion_tokens" => 2, "total_tokens" => 6} } else %{ "choices" => [%{"message" => %{"role" => "assistant", "content" => "done: #{hd(tool_msgs)["content"]}"}}], "usage" => %{"prompt_tokens" => 3, "completion_tokens" => 2, "total_tokens" => 5} } end "m-explore" -> %{ "choices" => [%{"message" => %{"role" => "assistant", "content" => "found it"}}], "usage" => %{"prompt_tokens" => 2, "completion_tokens" => 1, "total_tokens" => 3} } end {:ok, %{status: 200, headers: %{}, body: resp}} end explore = Agents.agent("explore", does: "read-only research", model: "m-explore") coordinator = Agents.agent("coordinator", does: "delegates to its team", team: [explore], model: "m-coordinator") rt = Runtime.new(%{transport: transport, registry: Agents.registry(coordinator)}) {:ok, c} = Runtime.spawn_agent(rt, Runtime.root(rt), "coordinator") Runtime.wake(rt, c, "find the thing") r = Runtime.wait(rt, c) true = r.status == "done" true = r.text == "done: found it" # task = spawn -> wake -> wait -> close, FUSED: the child is already closed, # but its final state stays queryable (close != loss). listing = Runtime.list(rt) true = Enum.map(listing, & &1.id) |> Enum.sort() == ["root/coordinator.1", "root/coordinator.1/explore.1"] true = Enum.map(listing, & &1.state) |> Enum.sort() == [:closed, :idle] snap = Runtime.snapshot(c) true = snap.turns_total == 2 # close(root) cascades leaf-first; the settled coordinator flips straight to :closed. Runtime.close(rt) true = Runtime.snapshot(c).state == :closed Runtime.shutdown(rt) IO.puts("ok: #{length(listing)} handle(s) — #{r.text}") ``` ## `wait/3` result shape [Section titled “wait/3 result shape”](#wait3-result-shape) | Field | Type | What it is | | -------------- | ------------------------------------------------------------------------------------------ | ------------------------------------------------------- | | `text` | `String.t()` | The final text, or an error/limit message. | | `is_error` | `boolean()` | | | `status` | `"done" \| "pending" \| "incomplete" \| "interrupted" \| "closed" \| "timeout" \| "error"` | Closed vocabulary — identical strings in all six ports. | | `pending` | `Request.t() \| nil` | Set only when `status == "pending"` (§10). | | `turns` | `non_neg_integer()` | LLM calls this run made. | | `total_tokens` | `non_neg_integer()` | Cumulative for this handle. | ## See also [Section titled “See also”](#see-also) * [`Toolnexus.Agents.agent`](/api/elixir/runtime/agent/) — define a sub-agent with its own toolkit, prompt and budget, callable as a tool by its parent. * [`Toolnexus.Agents.Handle`](/api/elixir/runtime/handle/) — the state machine for one spawned agent: pending, running, suspended, done. * [`Toolnexus.Agents budget`](/api/elixir/runtime/budgets/) — cap tool calls and wall-clock per agent and per team, enforced while the run is in flight. # The task tool — model-facing delegation > The opt-in tool that lets the model itself spawn a teammate. Default OFF. — Elixir. **Elixir** · package `toolnexus` · [SPEC §7D](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) Not available in Elixir Only JS exposes a constructible task tool symbol; elsewhere it is the `task` builtin, toggled on the runtime. The opt-in tool that lets the model itself spawn a teammate. Default OFF. ## What to use instead [Section titled “What to use instead”](#what-to-use-instead) There is no constructible `task` tool symbol to import in this port — the `task` tool is a **runtime builtin**, assembled automatically by [`Toolnexus.Agents.Runtime`](/api/elixir/runtime/runtime/) for any handle whose [`AgentDef`](/api/elixir/runtime/agent/) declares a `:team`. Declaring `team: [explore]` on `Toolnexus.Agents.agent/2` is the whole opt-in — the parent gets a `task` tool scoped to exactly that team (sorted by name, description composed from each teammate’s `does`); a child gets no `task` tool unless its own definition also declares a team (recursion is opt-in, never inherited). There is nothing to construct or toggle beyond that `:team` field — see the delegation example on [`Toolnexus.Agents.Runtime`](/api/elixir/runtime/runtime/#examples). ## See also [Section titled “See also”](#see-also) * [`Toolnexus.Agents`](/api/elixir/runtime/agent/) — Define a sub-agent with its own toolkit, prompt and budget, callable as a tool by its parent. * [`Toolnexus.Agents.Runtime`](/api/elixir/runtime/runtime/) — The six host verbs that drive sub-agents, plus the read-only list and inspect views. * [`Toolnexus.Agents.Handle`](/api/elixir/runtime/handle/) — The state machine for one spawned agent: pending, running, suspended, done. * [`Toolnexus.Agents budget`](/api/elixir/runtime/budgets/) — Cap tool calls and wall-clock per agent and per team, enforced while the run is in flight. # Toolnexus.Status.task/0 / run/0 / limits/0 / canonical_limit/1 > 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. — Elixir. **Elixir** · package `toolnexus` · [SPEC §7D](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) ```elixir Toolnexus.Status.run() #=> ["done", "pending", "incomplete"] Toolnexus.Status.task() #=> ["done", "pending", "incomplete", "interrupted", "closed", "timeout", "error"] Toolnexus.Status.limits() #=> ["maxTurns", "maxTokens", "maxToolCalls", "maxWallMs", # "maxChildren", "maxConcurrent", "maxDepth", "completion", "timeout"] Toolnexus.Status.run?("pending") #=> true Toolnexus.Status.task?("timeout") #=> true Toolnexus.Status.limit?("maxTurns") #=> true Toolnexus.Status.Limit.max_turns() #=> "maxTurns" Toolnexus.Status.Limit.completion() #=> "completion" Toolnexus.Status.Limit.timeout() #=> "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, plus the helper that canonicalizes a limit name. `Toolnexus.Status` (`elixir/lib/toolnexus/errors.ex:129-212`) exists because the two vocabularies share a field name — `status` — but are **not the same closed set**, and reading the §7D task set while holding a §8 `RunResult` was a real defect (issue #92). `run/0` is [`Toolnexus.Client.run/4`](/api/elixir/client/run/)’s `RunResult.status` (3 values); `task/0` is [`Toolnexus.Agents.Runtime`](/api/elixir/runtime/runtime/)’s `TaskResult.status` (7 values, the task set is a strict superset). `limits/0` is a third, separate vocabulary: the 9 canonical strings that name *which* budget field (or non-budget stop) ended a task whose status is `"incomplete"`. ## When to use it [Section titled “When to use it”](#when-to-use-it) * **You’re branching on a `RunResult.status`** from the plain [`Client.run/4`](/api/elixir/client/run/) loop — compare against `Toolnexus.Status.run/0`’s three members (`"done"`, `"pending"`, `"incomplete"`). `"timeout"` is never one of them: a whole-run `:timeout_ms` deadline raises `Toolnexus.TimeoutError` instead of settling as a status. * **You’re branching on a `TaskResult.status`** from [`Toolnexus.Agents.Runtime`](/api/elixir/runtime/runtime/) or [`Toolnexus.Agents.Handle`](/api/elixir/runtime/handle/) — use `Toolnexus.Status.task/0`’s seven members instead. Here `"timeout"` DOES appear, but it means something narrower than it sounds: a `wait/2` deadline expired while **the child keeps running** — it is not a terminal failure the way a §8 run’s deadline is. * **A task’s status is `"incomplete"`** and you want to know *why* — read `TaskResult.limit` and compare it against `Toolnexus.Status.limits/0` (or the named constants under `Toolnexus.Status.Limit`, e.g. `Limit.max_turns/0`) rather than a string literal, so a typo in your own code can’t silently fail to match. * **You’re constructing or comparing a limit value in your own code** — reach for `Toolnexus.Status.Limit.max_turns/0` etc. instead of writing `"maxTurns"` by hand; the module exists specifically so a spelling mistake becomes a compile-time typo (`UndefinedFunctionError`) rather than a silent mismatch at runtime. ## Why this and not the alternative [Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative) Don't reuse the §7D task vocabulary for a §8 RunResult, or vice versa The two sets overlap on `"done"`, `"pending"` and `"incomplete"`, which is exactly what makes the mistake easy: a host that always treats `status` as the 7-member task set will silently accept `"interrupted"`, `"closed"`, `"timeout"` or `"error"` as valid readings of a plain `Client.run/4` result, even though those four can never appear there. Use `Toolnexus.Status.run?/1` / `Toolnexus.Status.task?/1` to check membership explicitly rather than assuming which set you’re looking at. `canonical_limit/1` itself is private (`elixir/lib/toolnexus/agents/handle.ex:714-718`) — it is the internal normalizer the runtime calls on every `TaskResult.limit` before it is ever handed back to a caller (`handle.ex:529`, `:870`), so by the time you read `limit` off a `TaskResult` it is already one of the nine canonical strings. A host never calls it directly; the guarantee is that the *field* is always canonical, not that you need to canonicalize it yourself. ## Examples [Section titled “Examples”](#examples) ### 1. The smallest useful call — read the three vocabularies [Section titled “1. The smallest useful call — read the three vocabularies”](#1-the-smallest-useful-call--read-the-three-vocabularies) ```elixir alias Toolnexus.Status true = Status.run() == ["done", "pending", "incomplete"] true = Status.task() == ["done", "pending", "incomplete", "interrupted", "closed", "timeout", "error"] true = Status.limits() == ["maxTurns", "maxTokens", "maxToolCalls", "maxWallMs", "maxChildren", "maxConcurrent", "maxDepth", "completion", "timeout"] IO.puts("ok: run=#{length(Status.run())} task=#{length(Status.task())} limits=#{length(Status.limits())}") ``` ### 2. The realistic case — branch on which set a status belongs to [Section titled “2. The realistic case — branch on which set a status belongs to”](#2-the-realistic-case--branch-on-which-set-a-status-belongs-to) ```elixir alias Toolnexus.Status defmodule VocabDemo do def describe(status) do cond do Status.run?(status) and Status.task?(status) -> "shared: #{status}" Status.task?(status) -> "task-only: #{status}" Status.run?(status) -> "run-only: #{status}" true -> "unknown: #{status}" end end end true = VocabDemo.describe("pending") == "shared: pending" true = VocabDemo.describe("timeout") == "task-only: timeout" true = VocabDemo.describe("nonsense") == "unknown: nonsense" IO.puts("ok: #{VocabDemo.describe("pending")} / #{VocabDemo.describe("timeout")}") ``` ### 3. The full surface — named limit constants instead of string literals [Section titled “3. The full surface — named limit constants instead of string literals”](#3-the-full-surface--named-limit-constants-instead-of-string-literals) ```elixir alias Toolnexus.Status.Limit named = [ Limit.max_turns(), Limit.max_tokens(), Limit.max_tool_calls(), Limit.max_wall_ms(), Limit.max_children(), Limit.max_concurrent(), Limit.max_depth(), Limit.completion(), Limit.timeout() ] true = named == Toolnexus.Status.limits() true = Toolnexus.Status.limit?(Limit.max_turns()) # a task that hit its turn ceiling reports the SAME spelling `Limit.max_turns/0` returns task_result = %{status: "incomplete", limit: "maxTurns"} true = task_result.limit == Limit.max_turns() IO.puts("ok: #{inspect(named)}") ``` ## See also [Section titled “See also”](#see-also) * [`Toolnexus.Agents`](/api/elixir/runtime/agent/) — Define a sub-agent with its own toolkit, prompt and budget, callable as a tool by its parent. * [`Toolnexus.Agents.Runtime`](/api/elixir/runtime/runtime/) — The six host verbs that drive sub-agents, plus the read-only list and inspect views. * [`Toolnexus.Agents.Handle`](/api/elixir/runtime/handle/) — The state machine for one spawned agent: pending, running, suspended, done. * [`Toolnexus.Agents budget`](/api/elixir/runtime/budgets/) — Cap tool calls and wall-clock per agent and per team, enforced while the run is in flight. # Toolnexus.Serve.start > Publish an Agent Card and answer JSON-RPC over the client loop — your toolkit becomes someone else's remote agent. — Elixir. **Elixir** · package `toolnexus` · [SPEC §7B](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [elixir/lib/toolnexus/serve.ex](https://github.com/muthuishere/toolnexus/blob/main/elixir/lib/toolnexus/serve.ex) ```elixir @spec start(String.t(), keyword()) :: Toolnexus.Serve.Handle.t() def start(addr, opts) # addr = "host:port" ("127.0.0.1:0" for an ephemeral port); opts[:a2a] mounts the A2A routes, # opts[:mcp] co-mounts /mcp (SPEC §7C). Neither present ⇒ every request 404s. # The everyday entry point wraps this for you: @spec serve(Toolnexus.Toolkit.t(), String.t(), keyword()) :: Toolnexus.Serve.Handle.t() def Toolnexus.Toolkit.serve(toolkit, addr, opts \\ []) # opts: :client (the Client that fulfils tasks), :a2a (profile, or from # toolkit's mcp_config "a2a" block), :mcp (profile, §7C), :on_task, :on_call ``` Stands up a Bandit HTTP server. With the `a2a` profile present it mounts `GET /.well-known/agent-card.json` (built from the toolkit’s **skills**, never raw tools — see [`build_agent_card/3`](/api/elixir/serve/agent-card/)) and `POST /` for JSON-RPC 2.0: `SendMessage` creates a Task, persists it via the configured [`TaskStore`](/api/elixir/serve/task-store/), returns it immediately in `"submitted"` state, and fulfils it **asynchronously** through `client.run(task_text, toolkit)`; `GetTask` polls it. A §10 suspension surfaces as the protocol’s `input-required` state carrying the request prompt — never a false `completed`. A fulfilment error becomes a `"failed"` Task; it never crashes the server. ## When to use it [Section titled “When to use it”](#when-to-use-it) * **Expose a toolkit’s skills to real A2A peers** — any client speaking the SendMessage/GetTask subset (verified against the `a2a-python` SDK) can call in. * **Bridge your own toolnexus toolkit to another toolnexus toolkit** — the hermetic examples below are exactly this: one served toolkit is another port’s remote peer. * **Co-mount MCP alongside A2A** on the same address — pass both `a2a:` and `mcp:`; they share one Bandit listener, routed by path (`/.well-known/agent-card.json` + `POST /` vs `POST /mcp`). ## Why this and not the alternative [Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative) Call Toolkit.serve/3, not Serve.start/2, in application code `Toolnexus.Toolkit.serve/3` is the entry point real code should use — it resolves the `a2a`/`mcp` profile from either an inline option or the toolkit’s own `mcp_config` (`"a2a"`/`"mcpServer"` top-level blocks), wires `client.run/3` as the fulfilment function, and threads a message’s `contextId` through `Client.ask/4` so a peer’s turns are remembered across Tasks. `Serve.start/2` is the lower-level primitive `Toolkit.serve/3` delegates to — reach for it directly only if you are not starting from a `Toolnexus.Toolkit.t()`. For the inbound **MCP** profile instead of (or alongside) A2A, see [`Toolnexus.McpServe`](/api/elixir/serve/mcp/) — same `serve/3` call, a different `opts` key. ## Examples [Section titled “Examples”](#examples) ### 1. The smallest useful call — no profile at all, everything 404s [Section titled “1. The smallest useful call — no profile at all, everything 404s”](#1-the-smallest-useful-call--no-profile-at-all-everything-404s) ```elixir alias Toolnexus.{Serve, Toolkit} Application.ensure_all_started(:req) {:ok, tk} = Toolnexus.create_toolkit(builtins: false) handle = Toolkit.serve(tk, "127.0.0.1:0") true = handle.port > 0 true = handle.url == "http://127.0.0.1:#{handle.port}" resp = Req.get!(url: handle.url <> "/.well-known/agent-card.json", retry: false, decode_body: false) true = resp.status == 404 Serve.stop(handle) IO.puts("ok: no profile -> 404 (server was still listening on #{handle.url})") ``` ### 2. The realistic case — SendMessage runs a tool, GetTask observes it complete [Section titled “2. The realistic case — SendMessage runs a tool, GetTask observes it complete”](#2-the-realistic-case--sendmessage-runs-a-tool-gettask-observes-it-complete) The LLM call is stubbed via `Client.create`’s `http_options: [plug: ...]` (in-process, no network); the A2A wire itself is real loopback HTTP. ```elixir alias Toolnexus.{Client, Native, Serve, Toolkit} Application.ensure_all_started(:req) {:ok, calls} = Agent.start_link(fn -> 0 end) llm_plug = fn conn -> {:ok, raw, conn} = Plug.Conn.read_body(conn) body = Jason.decode!(raw) n = Agent.get_and_update(calls, fn c -> {c + 1, c + 1} end) resp = if n == 1 do %{ "choices" => [ %{ "message" => %{ "role" => "assistant", "content" => nil, "tool_calls" => [ %{"id" => "c1", "type" => "function", "function" => %{"name" => "echo", "arguments" => ~s({"text":"hi"})}} ] } } ], "usage" => %{"prompt_tokens" => 3, "completion_tokens" => 2, "total_tokens" => 5} } else %{ "choices" => [%{"message" => %{"role" => "assistant", "content" => "echoed: hi"}}], "usage" => %{"prompt_tokens" => 2, "completion_tokens" => 1, "total_tokens" => 3} } end conn |> Plug.Conn.put_resp_content_type("application/json") |> Plug.Conn.send_resp(200, Jason.encode!(resp)) end llm_client = Client.create(base_url: "http://localhost", style: "openai", model: "gpt-x", api_key: "test-key", http_options: [plug: llm_plug]) {:ok, tk} = Toolnexus.create_toolkit( skills: [%{name: "echoer", description: "Echoes text", content: "Call echo."}], extra_tools: [Native.define_tool(name: "echo", description: "Echo text back", execute: fn args -> "echoed: #{args["text"]}" end)], builtins: false ) handle = Toolkit.serve(tk, "127.0.0.1:0", client: llm_client, a2a: %{name: "Echo Agent"}) send_body = %{ "jsonrpc" => "2.0", "id" => "1", "method" => "SendMessage", "params" => %{"message" => %{"role" => "user", "messageId" => "m1", "parts" => [%{"kind" => "text", "text" => "please echo hi"}]}} } submit_resp = Req.post!(url: handle.url <> "/", json: send_body, retry: false, decode_body: false) %{"result" => %{"id" => task_id, "status" => %{"state" => "submitted"}}} = Jason.decode!(submit_resp.body) final = Stream.repeatedly(fn -> get_body = %{"jsonrpc" => "2.0", "id" => "2", "method" => "GetTask", "params" => %{"id" => task_id}} resp = Req.post!(url: handle.url <> "/", json: get_body, retry: false, decode_body: false) %{"result" => task} = Jason.decode!(resp.body) if task["status"]["state"] in ["submitted", "working"], do: (Process.sleep(10) && nil), else: task end) |> Enum.find(&(&1 != nil)) true = final["status"]["state"] == "completed" [%{"parts" => [%{"text" => text}]}] = final["artifacts"] true = text == "echoed: hi" Serve.stop(handle) IO.puts("ok: Task #{task_id} completed with \"#{text}\"") ``` ### 3. The full surface — co-mounted A2A + MCP, and a top-level config block [Section titled “3. The full surface — co-mounted A2A + MCP, and a top-level config block”](#3-the-full-surface--co-mounted-a2a--mcp-and-a-top-level-config-block) ```elixir alias Toolnexus.{Client, Serve, Toolkit} Application.ensure_all_started(:req) llm_plug = fn conn -> {:ok, _raw, conn} = Plug.Conn.read_body(conn) resp = %{ "choices" => [%{"message" => %{"role" => "assistant", "content" => "ok"}}], "usage" => %{"prompt_tokens" => 1, "completion_tokens" => 1, "total_tokens" => 2} } conn |> Plug.Conn.put_resp_content_type("application/json") |> Plug.Conn.send_resp(200, Jason.encode!(resp)) end llm_client = Client.create(base_url: "http://localhost", style: "openai", model: "gpt-x", api_key: "test-key", http_options: [plug: llm_plug]) # A top-level `a2a` config block (a reserved key, like `mcpServers`) mounts the profile # without an inline `a2a:` option — Toolkit.serve/3 resolves it from the toolkit itself. tk = Toolnexus.create_toolkit!( mcp_config: %{"mcpServers" => %{}, "a2a" => %{"name" => "cfg-served"}}, skills: [%{name: "s1", description: "a skill", content: "c"}], builtins: false ) handle = Toolkit.serve(tk, "127.0.0.1:0", client: llm_client, mcp: %{name: "gw"}) card = Req.get!(url: handle.url <> "/.well-known/agent-card.json", retry: false).body true = card["name"] == "cfg-served" true = Enum.map(card["skills"], & &1["id"]) == ["s1"] # MCP is co-mounted at /mcp alongside the A2A routes on the SAME listener. init_body = %{"jsonrpc" => "2.0", "id" => 1, "method" => "initialize", "params" => %{}} mcp_resp = Req.post!( url: handle.url <> "/mcp", json: init_body, headers: [{"accept", "application/json, text/event-stream"}], retry: false, decode_body: false ) %{"result" => %{"serverInfo" => %{"name" => "gw"}}} = Jason.decode!(mcp_resp.body) Serve.stop(handle) IO.puts("ok: one listener, two profiles — A2A card \"#{card["name"]}\" + MCP serverInfo \"gw\"") ``` ## `ServeHandle` shape [Section titled “ServeHandle shape”](#servehandle-shape) | Field | Type | What it is | | ------ | --------------------- | ---------------------------------------------------------------------------------------- | | `url` | `String.t()` | Base URL, e.g. `"http://127.0.0.1:52341"` — the actual bound port when `addr` used `:0`. | | `port` | `:inet.port_number()` | The bound port. | | `pid` | `pid()` | The Bandit listener process — `Serve.stop/1` (alias `close/1`) shuts it down. | ## See also [Section titled “See also”](#see-also) * [`Toolnexus.Serve.build_agent_card`](/api/elixir/serve/agent-card/) — construct the Agent Card that advertises your name, skills and endpoint. * [`Toolnexus.Serve task_store`](/api/elixir/serve/task-store/) — persist inbound A2A tasks so a suspended request survives a restart. * [`Toolnexus.McpServe`](/api/elixir/serve/mcp/) — the inbound MCP profile: any MCP client can call your tools. * [`Toolnexus.A2a.agent`](/api/elixir/agents/agent/) — the outbound mirror: call a served toolkit from the other side. # Toolnexus.Serve.build_agent_card > Construct the Agent Card that advertises your name, skills and endpoint. — Elixir. **Elixir** · package `toolnexus` · [SPEC §7B](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [elixir/lib/toolnexus/serve.ex](https://github.com/muthuishere/toolnexus/blob/main/elixir/lib/toolnexus/serve.ex) ```elixir @spec build_agent_card(map(), [Toolnexus.Skill.Info.t()], String.t()) :: map() def build_agent_card(cfg, skills, url) # cfg = the a2a profile map: %{name?, description?, version?, provider?, skills?: [String.t()]} # skills = the toolkit's Skill.Info list (never raw Tools) # url = the JSON-RPC POST endpoint peers will call (base <> "/") ``` Builds the exact map `GET /.well-known/agent-card.json` serves. Card `skills[]` always come from the toolkit’s **skills** (`SKILL.md` name + description), filtered to `cfg[:skills]` when given — **never raw tools**, so a peer only ever sees the capabilities you meant to advertise. Defaults apply when a field is omitted: `name: "toolnexus-agent"`, `description: ""`, `version: "0.1.0"`, `protocolVersion` is always `"0.3.0"`, `capabilities: {streaming: false, pushNotifications: false}`. `provider` is present only when `cfg[:provider]` was configured. ## When to use it [Section titled “When to use it”](#when-to-use-it) * **You want the card map without standing up a server** — snapshot-test it, log it, or hand it to a discovery layer that doesn’t hit the HTTP endpoint. * **Understanding exactly what a peer will see** before you `serve/3` — this is a pure function of your skills + config, so you can print it locally first. * **Filtering which skills are advertised** — pass `cfg[:skills]` (a list of skill names) to expose a subset of what the toolkit actually has, without changing the toolkit itself. ## Why this and not the alternative [Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative) Serve.start/2 calls this for you on every GET [`Toolnexus.Serve.start/2`](/api/elixir/serve/a2a/) (via `Toolkit.serve/3`) calls `build_agent_card/3` internally on every `GET /.well-known/agent-card.json` — you never need to call it yourself to run a server. Reach for it directly only when you want the card as data, not as an HTTP response. ## Examples [Section titled “Examples”](#examples) ### 1. The smallest useful call — all defaults, no config [Section titled “1. The smallest useful call — all defaults, no config”](#1-the-smallest-useful-call--all-defaults-no-config) ```elixir alias Toolnexus.Serve card = Serve.build_agent_card(%{}, [], "http://localhost:4000/") true = card["name"] == "toolnexus-agent" true = card["description"] == "" true = card["version"] == "0.1.0" true = card["protocolVersion"] == "0.3.0" true = card["capabilities"] == %{"streaming" => false, "pushNotifications" => false} true = card["defaultInputModes"] == ["text"] true = card["skills"] == [] false = Map.has_key?(card, "provider") true = card["url"] == "http://localhost:4000/" IO.puts("ok: #{card["name"]} v#{card["version"]}") ``` ### 2. The realistic case — every skill advertised, plus a provider block [Section titled “2. The realistic case — every skill advertised, plus a provider block”](#2-the-realistic-case--every-skill-advertised-plus-a-provider-block) ```elixir alias Toolnexus.Serve skills = [ %Toolnexus.Skill.Info{name: "greeter", description: "Says hello nicely", location: "greeter/SKILL.md", content: "Wave."}, %Toolnexus.Skill.Info{name: "internal", description: "Not meant for peers", location: "internal/SKILL.md", content: "Hidden."} ] cfg = %{ name: "docs-agent", description: "an agent from the docs", version: "2.0.0", provider: %{organization: "deemwar", url: "https://deemwar.com"} } card = Serve.build_agent_card(cfg, skills, "http://127.0.0.1:9000/") true = card["name"] == "docs-agent" true = card["provider"] == %{"organization" => "deemwar", "url" => "https://deemwar.com"} true = Enum.map(card["skills"], & &1["id"]) == ["greeter", "internal"] true = hd(card["skills"]) == %{"id" => "greeter", "name" => "greeter", "description" => "Says hello nicely"} IO.puts("ok: #{length(card["skills"])} skill(s) advertised, provider #{card["provider"]["organization"]}") ``` ### 3. The full surface — filtered to a subset, then round-tripped through a real server [Section titled “3. The full surface — filtered to a subset, then round-tripped through a real server”](#3-the-full-surface--filtered-to-a-subset-then-round-tripped-through-a-real-server) ```elixir alias Toolnexus.{Client, Serve, Toolkit} Application.ensure_all_started(:req) skills = [ %Toolnexus.Skill.Info{name: "greeter", description: "Says hello nicely", location: "greeter/SKILL.md", content: "Wave."}, %Toolnexus.Skill.Info{name: "internal", description: "Not meant for peers", location: "internal/SKILL.md", content: "Hidden."} ] # built directly: only "greeter" survives the filter filtered = Serve.build_agent_card(%{skills: ["greeter"]}, skills, "http://x/") true = Enum.map(filtered["skills"], & &1["id"]) == ["greeter"] # the SAME filter, live over HTTP through Toolkit.serve/3 llm_client = Client.create(base_url: "http://127.0.0.1:1", style: "openai", model: "m", api_key: "k") {:ok, tk} = Toolnexus.create_toolkit( skills: [ %{name: "greeter", description: "Says hello nicely", content: "Wave."}, %{name: "internal", description: "Not meant for peers", content: "Hidden."} ], builtins: false ) handle = Toolkit.serve(tk, "127.0.0.1:0", client: llm_client, a2a: %{skills: ["greeter"]}) served = Req.get!(url: handle.url <> "/.well-known/agent-card.json", retry: false).body true = Enum.map(served["skills"], & &1["id"]) == ["greeter"] Serve.stop(handle) IO.puts("ok: filtered to #{length(served["skills"])} of 2 skills, matching build_agent_card/3 directly") ``` ## `cfg` (a2a profile) fields [Section titled “cfg (a2a profile) fields”](#cfg-a2a-profile-fields) | Field | Default | What it does | | -------------- | ------------------- | --------------------------------------------------------- | | `:name` | `"toolnexus-agent"` | Card `name`. | | `:description` | `""` | Card `description`. | | `:version` | `"0.1.0"` | Card `version`. | | `:provider` | omitted | `%{organization, url}` — included only when set. | | `:skills` | all | A list of skill names to filter the card’s `skills[]` to. | ## See also [Section titled “See also”](#see-also) * [`Toolnexus.Serve.start`](/api/elixir/serve/a2a/) — publish an Agent Card and answer JSON-RPC over the client loop — your toolkit becomes someone else’s remote agent. * [`Toolnexus.Serve task_store`](/api/elixir/serve/task-store/) — persist inbound A2A tasks so a suspended request survives a restart. * [`Toolnexus.McpServe`](/api/elixir/serve/mcp/) — the inbound MCP profile: any MCP client can call your tools. # Toolnexus.McpServe > The inbound MCP profile: any MCP client can call your tools. — Elixir. **Elixir** · package `toolnexus` · [SPEC §7C](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [elixir/lib/toolnexus/mcp\_serve.ex](https://github.com/muthuishere/toolnexus/blob/main/elixir/lib/toolnexus/mcp_serve.ex) ```elixir @spec exposed_tools([Toolnexus.Tool.t()], map() | nil) :: [Toolnexus.Tool.t()] def exposed_tools(tools, cfg) # cfg[:tools] (a list of names) filters the set; absent/nil ⇒ every toolkit tool; # unknown names in the filter are ignored, never an error. # The everyday entry point — same serve/3 call as A2A, a different opts key: @spec serve(Toolnexus.Toolkit.t(), String.t(), keyword()) :: Toolnexus.Serve.Handle.t() def Toolnexus.Toolkit.serve(toolkit, addr, opts \\ []) # opts[:mcp] = %{name?, version?, tools?: [String.t()]} — mounts POST /mcp ``` There is no `McpServe.build/1` — the inbound MCP profile is `Toolnexus.McpServe.exposed_tools/2` (the toolkit → exposed-tool-list filter) plus `Toolnexus.McpServe.handle_http/4` (the streamable-HTTP JSON-RPC dispatcher), both driven by `Toolnexus.Serve.start/2` when `opts[:mcp]` is present — mounted at `POST /mcp`, co-located with the A2A routes on the same listener. Where A2A advertises **skills** and fulfils a Task through the whole client loop, MCP advertises the toolkit’s **unified tools** (every source — mcp · skill · native · http · builtin · a2a) and dispatches each `tools/call` straight to `Tool.execute` — there is no `client`, no Task, no TaskStore; the calling MCP client *is* the LLM host. This turns a toolkit into a **universal MCP gateway**: aggregate N servers + skills + your own tools behind one toolkit, then re-expose the union as one MCP server. ## When to use it [Section titled “When to use it”](#when-to-use-it) * **Re-expose an aggregated toolkit to Claude Desktop, an IDE, or another agent** — connect any MCP client to `POST /mcp` and it sees `tools/list` + can `tools/call` every tool the toolkit knows, regardless of source. * **Gateway N MCP servers behind one endpoint** — build a toolkit from several `mcpServers`, serve it, and downstream clients only need to know about the one gateway. * **Narrow what’s exposed** — `mcp: %{tools: ["safe_tool"]}` filters the surface without touching the underlying toolkit. ## Why this and not the alternative [Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative) Reach for the A2A profile when the caller isn't an MCP client The MCP profile is for MCP clients specifically — `tools/list` + `tools/call`, no LLM loop on this side. When the caller is a peer agent that wants to hand you a natural-language task (not a typed tool call), see [`Toolnexus.Serve.start`](/api/elixir/serve/a2a/) (the A2A profile) instead — both can be co-mounted on the same `serve/3` call. Deferred per §7C: a stdio transport, MCP resources/prompts/sampling/completion (skills already reach clients via the `skill` tool), and auth in core. ## Examples [Section titled “Examples”](#examples) ### 1. The smallest useful call — `exposed_tools/2` filtering, no server [Section titled “1. The smallest useful call — exposed\_tools/2 filtering, no server”](#1-the-smallest-useful-call--exposed_tools2-filtering-no-server) ```elixir alias Toolnexus.{McpServe, Native} mk = fn name -> Native.define_tool(name: name, description: name, execute: fn _ -> name end) end tools = [mk.("add"), mk.("explode")] true = McpServe.exposed_tools(tools, nil) == tools true = McpServe.exposed_tools(tools, %{}) == tools true = Enum.map(McpServe.exposed_tools(tools, %{tools: ["explode"]}), & &1.name) == ["explode"] # unknown names in the filter are ignored, never an error true = McpServe.exposed_tools(tools, %{"tools" => ["nope"]}) == [] IO.puts("ok: nil/[]->all, a named subset, unknown names silently dropped") ``` ### 2. The realistic case — self-hosting: our own MCP client against our own served toolkit [Section titled “2. The realistic case — self-hosting: our own MCP client against our own served toolkit”](#2-the-realistic-case--self-hosting-our-own-mcp-client-against-our-own-served-toolkit) ```elixir alias Toolnexus.{Context, Mcp, Native, Serve, Toolkit} Application.ensure_all_started(:req) add_tool = Native.define_tool( name: "add", description: "Add two numbers", input_schema: %{"type" => "object", "properties" => %{"a" => %{"type" => "number"}, "b" => %{"type" => "number"}}, "required" => ["a", "b"]}, execute: fn args -> to_string(trunc(args["a"] + args["b"])) end ) fail_tool = Native.define_tool(name: "explode", description: "Always errors", execute: fn _ -> raise "kaboom" end) {:ok, tk} = Toolnexus.create_toolkit(extra_tools: [add_tool, fail_tool], builtins: false) handle = Toolkit.serve(tk, "127.0.0.1:0", mcp: %{name: "gateway", version: "9.9.9"}) source = Mcp.load(%{"mcpServers" => %{"gw" => %{"url" => handle.url <> "/mcp", "timeout" => 10_000}}}) true = source.status == %{"gw" => "connected"} true = Enum.map(source.tools, & &1.name) == ["gw_add", "gw_explode"] add = Enum.find(source.tools, &(&1.name == "gw_add")) true = add.source == "mcp" result = add.execute.(%{"a" => 2, "b" => 3}, %Context{}) false = result.is_error true = result.output == "5" # isError propagates end to end (execute raise -> MCP isError -> client error) explode = Enum.find(source.tools, &(&1.name == "gw_explode")) er = explode.execute.(%{}, %Context{}) true = er.is_error true = er.output == "kaboom" Mcp.close(source) Serve.stop(handle) IO.puts("ok: gw_add -> #{result.output}, gw_explode isError=#{er.is_error}") ``` ### 3. The full surface — `mcp.tools` filters the exposed subset over the real wire [Section titled “3. The full surface — mcp.tools filters the exposed subset over the real wire”](#3-the-full-surface--mcptools-filters-the-exposed-subset-over-the-real-wire) ```elixir alias Toolnexus.{Mcp, Native, Serve, Toolkit} Application.ensure_all_started(:req) mk = fn name -> Native.define_tool(name: name, description: name, execute: fn _ -> name end) end {:ok, tk} = Toolnexus.create_toolkit(extra_tools: [mk.("add"), mk.("explode")], builtins: false) handle = Toolkit.serve(tk, "127.0.0.1:0", mcp: %{tools: ["add", "does-not-exist"]}) source = Mcp.load(%{"mcpServers" => %{"gw" => %{"url" => handle.url <> "/mcp", "timeout" => 10_000}}}) # only "add" survives — "explode" is filtered out, "does-not-exist" is silently ignored true = Enum.map(source.tools, & &1.name) == ["gw_add"] Mcp.close(source) Serve.stop(handle) IO.puts("ok: filtered to #{length(source.tools)} tool(s) over real streamable-HTTP") ``` ## `mcp` profile fields [Section titled “mcp profile fields”](#mcp-profile-fields) | Field | Default | What it does | | ---------- | ----------------- | ------------------------------------------------------- | | `:name` | `"toolnexus"` | `initialize`’s `serverInfo.name`. | | `:version` | `"0.1.0"` | `initialize`’s `serverInfo.version`. | | `:tools` | all toolkit tools | Filters the exposed set by name; unknown names ignored. | ## See also [Section titled “See also”](#see-also) * [`Toolnexus.Serve.start`](/api/elixir/serve/a2a/) — publish an Agent Card and answer JSON-RPC over the client loop — your toolkit becomes someone else’s remote agent. * [`Toolnexus.Serve.build_agent_card`](/api/elixir/serve/agent-card/) — construct the Agent Card that advertises your name, skills and endpoint. * [`Toolnexus.Mcp.load`](/api/elixir/mcp/load/) — the outbound mirror: connect to a served MCP endpoint as a client. # Toolnexus.McpHandler > An embeddable MCP server: a Plug you mount on your own listener, with per-request hooks. — Elixir. **Elixir** · package `toolnexus` · [SPEC §7C.1](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [elixir/lib/toolnexus/mcp\_handler.ex](https://github.com/muthuishere/toolnexus/blob/main/elixir/lib/toolnexus/mcp_handler.ex) ```elixir @spec Toolnexus.mcp_handler(Toolnexus.Toolkit.t() | nil, keyword() | map()) :: Toolnexus.McpHandler.t() def mcp_handler(toolkit, cfg \\ []) # A Plug: mount it on Bandit, Plug.Router or Phoenix. Bandit.start_link(plug: {Toolnexus.McpHandler, handler}, port: 4000) @spec Toolnexus.McpHandler.notify_tools_changed(Toolnexus.McpHandler.t()) :: :ok # Request accessors — work on the hook ctx AND the tool's %Toolnexus.Context{}: Toolnexus.McpHandler.identity(ctx) # what :authenticate returned Toolnexus.McpHandler.request(ctx) # the Plug.Conn Toolnexus.McpHandler.session_id(ctx) # Mcp-Session-Id (nil when stateless) Toolnexus.McpHandler.request_meta(ctx) # the JSON-RPC params._meta ``` `Toolkit.serve(tk, addr, mcp: …)` owns a listener and exposes one static tool list. `Toolnexus.mcp_handler/2` is what a host mounts on **its own** router, behind its own TLS, CORS and OAuth. Every policy is a hook; toolnexus has no notion of tenant, user, role or storage — the host decides those inside the hooks. The transport is streamable HTTP (protocol `2025-06-18`, opt-in `2025-11-25`), implemented by hand over JSON-RPC; the public API uses toolnexus’s own types only. ## When to use it [Section titled “When to use it”](#when-to-use-it) * **Multi-tenant MCP gateway** — `authenticate` turns a bearer token into an identity, and `list_tools` resolves the tool list per request, so each tenant sees (and can call) only its tools. A name outside that list answers `-32602 "tool not found: "` and is never executed. * **Gate dangerous tools** — `before_call` returns `:allow`, `{:deny, msg}` or `{:confirm, msg}`. Confirm asks the client over `elicitation/create` on the same POST’s SSE stream when it declared elicitation; otherwise the caller gets your shaped `Decision.result` (e.g. “re-call with a nonce”). * **Tools that change at runtime** — call `notify_tools_changed/1`; clients holding a GET stream receive `notifications/tools/list_changed` and re-list. ## Why this and not the alternative [Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative) Reach for serve/3 when you just want a port [`Toolnexus.McpServe`](/api/elixir/serve/mcp/) (`serve(tk, addr, mcp: %{})`) is one call and one static tool list, with no auth. Use the handler when the MCP endpoint is part of an app you already run, or when who-sees-what is decided per request. ## Examples [Section titled “Examples”](#examples) ### 1. The smallest useful call — mount and list [Section titled “1. The smallest useful call — mount and list”](#1-the-smallest-useful-call--mount-and-list) ```elixir alias Toolnexus.McpHandler Application.ensure_all_started(:req) echo = Toolnexus.define_tool(name: "echo", description: "Echo", execute: fn a -> a["text"] end) handler = Toolnexus.mcp_handler(nil, stateless: true, list_tools: fn _ctx -> [echo] end) {:ok, srv} = Bandit.start_link(plug: {McpHandler, handler}, ip: {127, 0, 0, 1}, port: 0, startup_log: false) {:ok, {_, port}} = ThousandIsland.listener_info(srv) body = Req.post!("http://127.0.0.1:#{port}/", json: %{"jsonrpc" => "2.0", "id" => 1, "method" => "tools/call", "params" => %{"name" => "echo", "arguments" => %{"text" => "hi"}}}, retry: false ).body "hi" = hd(body["result"]["content"])["text"] IO.puts("ok: echo -> hi") ``` ### 2. The realistic case — per-identity tools behind your auth [Section titled “2. The realistic case — per-identity tools behind your auth”](#2-the-realistic-case--per-identity-tools-behind-your-auth) ```elixir alias Toolnexus.McpHandler Application.ensure_all_started(:req) mk = fn name -> Toolnexus.define_tool(name: name, description: name, execute: fn _a, ctx -> "#{name}:#{McpHandler.identity(ctx)}" end) end handler = Toolnexus.mcp_handler(nil, stateless: true, authenticate: fn conn -> case Plug.Conn.get_req_header(conn, "authorization") do ["Bearer " <> u] when u in ["alice", "admin"] -> {:ok, u} _ -> {:error, :unauthorized} end end, www_authenticate: fn _conn, _err -> ~s(Bearer resource_metadata="https://example.test/.well-known/oauth-protected-resource") end, list_tools: fn ctx -> if McpHandler.identity(ctx) == "admin", do: [mk.("search"), mk.("wipe")], else: [mk.("search")] end ) {:ok, srv} = Bandit.start_link(plug: {McpHandler, handler}, ip: {127, 0, 0, 1}, port: 0, startup_log: false) {:ok, {_, port}} = ThousandIsland.listener_info(srv) url = "http://127.0.0.1:#{port}/" call = fn who, name -> Req.post!(url, json: %{"jsonrpc" => "2.0", "id" => 1, "method" => "tools/call", "params" => %{"name" => name}}, headers: [{"authorization", "Bearer #{who}"}], retry: false ).body end -32602 = call.("alice", "wipe")["error"]["code"] # never executed "wipe:admin" = hd(call.("admin", "wipe")["result"]["content"])["text"] 401 = Req.post!(url, json: %{}, retry: false).status IO.puts("ok: alice cannot see wipe, admin can, no token -> 401") ``` ### 3. The full surface — gate, structured results, observability [Section titled “3. The full surface — gate, structured results, observability”](#3-the-full-surface--gate-structured-results-observability) ```elixir alias Toolnexus.{McpHandler, ToolAnnotations, ToolResult} alias Toolnexus.McpHandler.Decision stats = Toolnexus.define_tool( name: "stats", description: "Counts", output_schema: %{"type" => "object", "properties" => %{"count" => %{"type" => "integer"}}}, execute: fn _ -> %ToolResult{structured: %{"count" => 3}, meta: %{"usage" => %{"units" => 2}}} end ) drop = Toolnexus.define_tool( name: "drop", description: "Drop a table", annotations: %ToolAnnotations{title: "Drop", destructive_hint: true}, execute: fn _ -> "dropped" end ) handler = Toolnexus.mcp_handler(nil, name: "records", title: "Records API", version: "1.2.3", protocol_versions: ["2025-11-25", "2025-06-18"], list_tools: fn _ctx -> [stats, drop] end, before_call: fn _ctx, tool, args -> if tool.annotations && tool.annotations.destructive_hint && args["nonce"] != "ok", do: %Decision{kind: :confirm, message: "Drop it?", result: ToolResult.ok("re-call with nonce ok")}, else: :allow end, on_call: fn ev -> IO.puts("#{ev.name} #{ev.decision} #{ev.ms}ms #{ev.result_bytes}B") end, confirm_timeout: 60_000, max_body_bytes: 1_048_576 ) # tools/list carries outputSchema + annotations; tools/call on stats returns # structuredContent {"count":3}, _meta {"usage":{"units":2}} and the JSON as its text block. true = McpHandler.wire_tool(drop)["annotations"] == %{"title" => "Drop", "destructiveHint" => true} McpHandler.stop(handler) IO.puts("ok") ``` ## Options [Section titled “Options”](#options) | Option | Default | What it does | | ---------------------------------------------------------------------- | --------------------------------------------- | ---------------------------------------------------------------------------------------------------- | | `:name` / `:title` / `:version` / `:instructions` | `"toolnexus"` / omitted / `"0.1.0"` / omitted | Static `serverInfo`. | | `:server_info` | — | `fn ctx -> %{name:, title:, version:, instructions:} end`, per request; wins. | | `:protocol_versions` | `["2025-06-18"]` | Accepted versions; anything outside `["2025-11-25", "2025-06-18"]` is dropped. | | `:authenticate` / `:www_authenticate` | — | `{:ok, identity}` or 401 with the given `WWW-Authenticate`. | | `:list_tools` | the toolkit’s tools | Per-request tool list for `tools/list` and the `tools/call` lookup. | | `:before_call` | allow | `:allow` · `{:deny, msg}` · `{:confirm, msg}` · `{:error, r}` (deny) · `%Decision{}`. | | `:confirm_timeout` | `300_000` ms | Bounds an elicitation round trip. | | `:on_call` | — | `%{name, source, ms, is_error, err, result_bytes, decision, identity, session_id}`; raises isolated. | | `:list_resources` / `:read_resource` / `:list_prompts` / `:get_prompt` | — | Absent ⇒ `-32601`. | | `:session_ids` | in memory | A `Toolnexus.McpHandler.SessionIds` module or `%{generate:, validate:, terminate:}`. | | `:stateless` / `:stateless_session_id` | `false` / — | No session issued or checked; confirm never elicits. | | `:max_body_bytes` | 4 MiB | Larger POST ⇒ 413. | ## See also [Section titled “See also”](#see-also) * [`Toolnexus.McpServe`](/api/elixir/serve/mcp/) — the one-call, listener-owning MCP profile. * [`Toolnexus.Mcp.load`](/api/elixir/mcp/load/) — the outbound mirror: connect to an MCP server as a client. # Toolnexus.Serve task_store > Persist inbound A2A tasks so a suspended request survives a restart. — Elixir. **Elixir** · package `toolnexus` · [SPEC §7B](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [elixir/lib/toolnexus/serve.ex](https://github.com/muthuishere/toolnexus/blob/main/elixir/lib/toolnexus/serve.ex) ```elixir # The pluggable-persistence behaviour every Task read/write goes through: defmodule Toolnexus.Serve.TaskStore do @callback get(store :: struct(), id :: String.t()) :: map() | nil @callback save(store :: struct(), task :: map()) :: any() end # Two built-in implementations: Toolnexus.Serve.InMemoryTaskStore.new() # default — lives only for the process lifetime Toolnexus.Serve.FileTaskStore.new(dir) # one .json file per Task, atomic write # Selector, used by Serve.start/2 opts[:a2a][:store]: @spec resolve_store(nil | String.t() | struct()) :: struct() def resolve_store(store) # nil | "memory" -> InMemoryTaskStore.new() # "file:" -> FileTaskStore.new(dir) # a struct -> used as-is (your own TaskStore) ``` There is no single `task_store/1` function — a **TaskStore** is a behaviour (`Toolnexus.Serve.TaskStore`) plus `resolve_store/1`, the selector `Toolnexus.Serve.start/2` calls on the `a2a.store` option. Every Task read/write inside `serve/3` — the `SendMessage` create, the `working`/`completed`/`failed`/`input-required` transitions, every `GetTask` — goes through whatever store you configured. The default is in-memory (Tasks vanish on restart); `"file:"` persists one JSON file per Task id with an atomic write (write to `.tmp`, then rename), so a suspended (`input-required`) Task genuinely survives a process restart. ## When to use it [Section titled “When to use it”](#when-to-use-it) * **You need a suspended A2A Task to survive a restart** — pass `a2a: %{store: "file:/var/lib/toolnexus/tasks"}` to `Toolkit.serve/3`; every Task, including one parked in `input-required`, is on disk. * **Tests, demos, or anything ephemeral** — the default (`nil`/`"memory"`) needs no config and cleans itself up when the process exits. * **Your own persistence** (Postgres, Redis, S3) — implement the two-callback `Toolnexus.Serve.TaskStore` behaviour and pass the struct directly as `a2a: %{store: my_store}}`; `resolve_store/1` uses a struct as-is. ## Why this and not the alternative [Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative) You rarely call resolve\_store/1 yourself `Toolnexus.Serve.start/2` calls `resolve_store/1` on `opts[:a2a][:store]` (or `Toolkit.serve/3`’s `a2a:` profile) for you. Call it directly only when you want to hand-build a store ahead of time — e.g. to seed it, or to hold a reference for out-of-band inspection during a test. ## Examples [Section titled “Examples”](#examples) ### 1. The smallest useful call — resolving each selector [Section titled “1. The smallest useful call — resolving each selector”](#1-the-smallest-useful-call--resolving-each-selector) ```elixir alias Toolnexus.Serve alias Toolnexus.Serve.{FileTaskStore, InMemoryTaskStore} true = match?(%InMemoryTaskStore{}, Serve.resolve_store(nil)) true = match?(%InMemoryTaskStore{}, Serve.resolve_store("memory")) dir = Path.join(System.tmp_dir!(), "toolnexus-docs-tasks-#{System.unique_integer([:positive])}") true = match?(%FileTaskStore{dir: ^dir}, Serve.resolve_store("file:" <> dir)) custom = InMemoryTaskStore.new() true = Serve.resolve_store(custom) == custom raised? = try do Serve.resolve_store("redis:somewhere") false rescue ArgumentError -> true end true = raised? File.rm_rf!(dir) IO.puts("ok: memory, file:, struct pass-through, unknown raises") ``` ### 2. The realistic case — a served toolkit’s suspended Task survives a “restart” [Section titled “2. The realistic case — a served toolkit’s suspended Task survives a “restart””](#2-the-realistic-case--a-served-toolkits-suspended-task-survives-a-restart) ```elixir alias Toolnexus.{Client, Native, Request, Serve, Toolkit, Tool, ToolResult} Application.ensure_all_started(:req) dir = Path.join(System.tmp_dir!(), "toolnexus-docs-tasks-#{System.unique_integer([:positive])}") on_exit = fn -> File.rm_rf!(dir) end llm_plug = fn conn -> {:ok, _raw, conn} = Plug.Conn.read_body(conn) resp = %{ "choices" => [ %{"message" => %{"role" => "assistant", "content" => nil, "tool_calls" => [ %{"id" => "c1", "type" => "function", "function" => %{"name" => "authorize", "arguments" => "{}"}} ]}} ], "usage" => %{"prompt_tokens" => 1, "completion_tokens" => 1, "total_tokens" => 2} } conn |> Plug.Conn.put_resp_content_type("application/json") |> Plug.Conn.send_resp(200, Jason.encode!(resp)) end llm_client = Client.create(base_url: "http://localhost", style: "openai", model: "m", api_key: "k", http_options: [plug: llm_plug]) authorize_tool = %Tool{ name: "authorize", description: "Always suspends for approval", input_schema: %{"type" => "object", "properties" => %{}}, source: "native", execute: fn _args, _ctx -> %ToolResult{ output: "Authorize me", is_error: true, metadata: %{pending: %Request{id: "r1", kind: "authorization", prompt: "Authorize me"}} } end } {:ok, tk} = Toolnexus.create_toolkit(extra_tools: [authorize_tool], builtins: false) handle = Toolkit.serve(tk, "127.0.0.1:0", client: llm_client, a2a: %{store: "file:" <> dir}) send_body = %{ "jsonrpc" => "2.0", "id" => "1", "method" => "SendMessage", "params" => %{"message" => %{"role" => "user", "messageId" => "m1", "parts" => [%{"kind" => "text", "text" => "go"}]}} } submit = Req.post!(url: handle.url <> "/", json: send_body, retry: false, decode_body: false) %{"result" => %{"id" => task_id}} = Jason.decode!(submit.body) wait_for_state = fn -> Stream.repeatedly(fn -> get_body = %{"jsonrpc" => "2.0", "id" => "2", "method" => "GetTask", "params" => %{"id" => task_id}} resp = Req.post!(url: handle.url <> "/", json: get_body, retry: false, decode_body: false) %{"result" => task} = Jason.decode!(resp.body) if task["status"]["state"] in ["submitted", "working"], do: (Process.sleep(10) && nil), else: task end) |> Enum.find(&(&1 != nil)) end final = wait_for_state.() true = final["status"]["state"] == "input-required" Serve.stop(handle) # "restart": read the persisted file directly, with no live server at all. file = Path.join(dir, Toolnexus.Tool.sanitize(task_id) <> ".json") true = File.exists?(file) reloaded = Jason.decode!(File.read!(file)) true = reloaded["status"]["state"] == "input-required" on_exit.() IO.puts("ok: Task #{task_id} survived the server stopping, still input-required on disk") ``` ### 3. The full surface — custom TaskStore struct, passed through as-is [Section titled “3. The full surface — custom TaskStore struct, passed through as-is”](#3-the-full-surface--custom-taskstore-struct-passed-through-as-is) ```elixir alias Toolnexus.Serve alias Toolnexus.Serve.TaskStore defmodule DocsCountingTaskStore do @behaviour TaskStore defstruct [:agent] def new do {:ok, pid} = Agent.start_link(fn -> %{tasks: %{}, saves: 0} end) %__MODULE__{agent: pid} end @impl true def get(%__MODULE__{agent: pid}, id), do: Agent.get(pid, & &1.tasks[id]) @impl true def save(%__MODULE__{agent: pid}, task) do Agent.update(pid, fn st -> %{st | tasks: Map.put(st.tasks, task["id"], task), saves: st.saves + 1} end) end def save_count(%__MODULE__{agent: pid}), do: Agent.get(pid, & &1.saves) end store = DocsCountingTaskStore.new() # resolve_store/1 hands a struct back unchanged — it IS the store. true = Serve.resolve_store(store) == store DocsCountingTaskStore.save(store, %{"id" => "t1", "status" => %{"state" => "submitted"}}) DocsCountingTaskStore.save(store, %{"id" => "t1", "status" => %{"state" => "completed"}}) true = DocsCountingTaskStore.get(store, "t1")["status"]["state"] == "completed" true = DocsCountingTaskStore.save_count(store) == 2 true = DocsCountingTaskStore.get(store, "missing") == nil IO.puts("ok: custom TaskStore behaviour — #{DocsCountingTaskStore.save_count(store)} save(s) recorded") ``` ## `resolve_store/1` selectors [Section titled “resolve\_store/1 selectors”](#resolve_store1-selectors) | Selector | Store | Notes | | --------------------------------- | ------------------- | ------------------------------------------------------------------------------ | | `nil` | `InMemoryTaskStore` | Default. Tasks live only for the process lifetime. | | `"memory"` | `InMemoryTaskStore` | Explicit spelling of the default. | | `"file:"` | `FileTaskStore` | One `.json` per Task, atomic write. `dir` is created if missing. | | a struct implementing `TaskStore` | that struct | Used as-is — your own persistence. | | any other string | — | Raises `ArgumentError`. | ## See also [Section titled “See also”](#see-also) * [`Toolnexus.Serve.start`](/api/elixir/serve/a2a/) — publish an Agent Card and answer JSON-RPC over the client loop — your toolkit becomes someone else’s remote agent. * [`Toolnexus.Serve.build_agent_card`](/api/elixir/serve/agent-card/) — construct the Agent Card that advertises your name, skills and endpoint. * [`Toolnexus.McpServe`](/api/elixir/serve/mcp/) — the inbound MCP profile: any MCP client can call your tools. # Toolnexus.Skill.list > Enumerate discovered skills and, crucially, the ones that were skipped and why. Elixir. **Elixir** · package `toolnexus` · [SPEC §3](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [elixir/lib/toolnexus/skill.ex](https://github.com/muthuishere/toolnexus/blob/main/elixir/lib/toolnexus/skill.ex) ```elixir @spec list(String.t() | [String.t()] | keyword() | map()) :: %{skills: [Toolnexus.Skill.Info.t()], skipped: [%{location: String.t(), reason: String.t()}]} def list(input) ``` Runs the same discovery and parsing as [`Toolnexus.Skill.load/1`](/api/elixir/skills/load/) and hands back the result **as data** — the skills that parsed, and the candidates that were rejected together with a typed reason. No `skill` tool is built and no prompt is rendered. The `skipped` list is the reason this function exists. `load/1` silently drops a malformed `SKILL.md`; `list/1` tells you the path and the reason, so a broken skill fails your CI instead of quietly vanishing from the model’s catalog. ## When to use it [Section titled “When to use it”](#when-to-use-it) * **A startup or CI check** — assert every `SKILL.md` in the repo parsed, so a typo in the frontmatter is a build failure rather than a mysteriously absent capability. * **Authoring an allowlist** — enumerate what exists, then feed names to `load/1`’s `:filter`. The inventory is deliberately **unfiltered** for exactly this. * **Tooling and dashboards** — a `skills list` CLI command, an admin page, a doctor command. ## Why this and not the alternative [Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative) Use load when you want a tool, not a report [`Toolnexus.Skill.load/1`](/api/elixir/skills/load/) is the runtime path: it returns the `skill` tool and the prompt catalog, and it ignores anything that failed to parse. Use `list/1` **instead** when you want to *inspect* — the failures, the full unfiltered set, or the parsed metadata — and are not about to call an LLM. `list/1` accepts every input shape `load/1` does, including `:skills` and `:provider`, so the same configuration can be validated and then loaded. ## Examples [Section titled “Examples”](#examples) ### 1. Inventory the shared fixture [Section titled “1. Inventory the shared fixture”](#1-inventory-the-shared-fixture) ```elixir alias Toolnexus.Skill # TOOLNEXUS_REPO is set by the docs test runner; in your own code just use a path. repo = System.get_env("TOOLNEXUS_REPO") || "." dir = Path.join([repo, "examples", "skills"]) %{skills: skills, skipped: skipped} = Skill.list(dir) # Nothing was rejected — this is the healthy shape. [] = skipped [info] = skills true = info.name == "hello-world" true = String.starts_with?(info.description, "A tiny example skill.") true = String.ends_with?(info.location, "hello-world/SKILL.md") true = info.origin == :fs # The instruction body is already parsed and available. true = String.contains?(info.content, "# Hello World Skill") IO.puts("ok: #{length(skills)} skill(s), #{length(skipped)} skipped") ``` ### 2. The skip reasons, seen on a deliberately broken tree [Section titled “2. The skip reasons, seen on a deliberately broken tree”](#2-the-skip-reasons-seen-on-a-deliberately-broken-tree) Four things can go wrong. Three of them are visible here; `"unreadable"` is the fourth (a `SKILL.md` whose bytes cannot be read — permissions, a dangling symlink). ```elixir alias Toolnexus.Skill root = Path.join(System.tmp_dir!(), "toolnexus-docs-skills-#{System.unique_integer([:positive])}") write = fn sub, text -> path = Path.join([root, sub, "SKILL.md"]) File.mkdir_p!(Path.dirname(path)) File.write!(path, text) end # Fine. write.("greet", "---\nname: greet\ndescription: Say hello\n---\n\nBody.\n") # Frontmatter fences present but the YAML does not parse. write.("broken", "---\nname: [unclosed\n---\n\nBody.\n") # Parses, but there is no `name` key. write.("nameless", "---\ndescription: No name here\n---\n\nBody.\n") # A second skill claiming a name already taken — first one wins. write.("clash", "---\nname: greet\ndescription: Another greet\n---\n\nBody.\n") %{skills: skills, skipped: skipped} = Skill.list(root) # Exactly one survivor, and it is unambiguous which name won. 1 = length(skills) true = Enum.map(skills, & &1.name) == ["greet"] reasons = skipped |> Enum.map(& &1.reason) |> Enum.sort() true = reasons == ["duplicate-name", "malformed-frontmatter", "missing-name"] # Every skip carries the offending path, so an error message can name the file. true = Enum.all?(skipped, &String.ends_with?(&1.location, "SKILL.md")) File.rm_rf!(root) IO.puts("ok: 1 kept, skipped: #{Enum.join(reasons, ", ")}") ``` Caution `duplicate-name` is decided by traversal order, and the **first** candidate wins. If two skills share a name, which one survives depends on where they sit on disk — so treat a `duplicate-name` skip as a build failure rather than a preference. ### 3. The full surface — validate a whole configuration, then load it [Section titled “3. The full surface — validate a whole configuration, then load it”](#3-the-full-surface--validate-a-whole-configuration-then-load-it) `list/1` sees data-supplied and provider-supplied skills too, and it never applies `:filter`. That combination is what makes it the right tool for authoring an allowlist. ```elixir alias Toolnexus.Skill repo = System.get_env("TOOLNEXUS_REPO") || "." dir = Path.join([repo, "examples", "skills"]) config = [ dirs: [dir], 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 — this becomes a skip, not a crash. %{description: "Anonymous", content: "..."} ] ] %{skills: skills, skipped: skipped} = Skill.list(config) names = skills |> Enum.map(& &1.name) |> Enum.sort() true = names == ["hello-world", "release", "triage"] true = Enum.map(skipped, & &1.reason) == ["missing-name"] # Data-sourced skills get a logical base and never touch disk. release = Enum.find(skills, &(&1.name == "release")) true = release.origin == :logical true = release.base == "skill://release/" # Directory-sourced ones keep their real path. hello = Enum.find(skills, &(&1.name == "hello-world")) true = hello.origin == :fs # A CI gate: nothing may be silently dropped. gate = fn %{skipped: s} -> if s == [], do: :ok, else: {:error, Enum.map(s, & &1.reason)} end {:error, ["missing-name"]} = gate.(%{skipped: skipped}) # Now author the allowlist from the inventory and load only what this agent gets. allow = Map.new(["hello-world", "triage"], &{&1, true}) source = Skill.load(Keyword.put(config, :filter, allow)) true = source.skills |> Enum.map(& &1.name) |> Enum.sort() == ["hello-world", "triage"] # list/1 stays UNFILTERED — that is the point. true = length(Skill.list(Keyword.put(config, :filter, allow)).skills) == 3 IO.puts("ok: inventory #{Enum.join(names, ", ")} -> loaded #{length(source.skills)}") ``` ## Options [Section titled “Options”](#options) Identical to [`Skill.load/1`](/api/elixir/skills/load/) — a binary, a list of binaries, or a map / keyword list: | Option | Effect on `list/1` | | --------------- | --------------------------------------------------------------------------------------- | | `:dirs` | Roots walked for `**/SKILL.md`. A missing root warns on stderr and contributes nothing. | | `:skills` | Data-supplied skills; validated and included, `origin: :logical`. | | `:provider` | Resolved once. A failing provider is isolated with a warning, not raised. | | `:filter` | **Ignored** — the inventory is intentionally unfiltered. | | `:sample_limit` | Ignored — no `skill` tool is built, so nothing is sampled. | ### What you get back [Section titled “What you get back”](#what-you-get-back) | Key | Type | What it is | | ---------- | ------------------------- | ---------------------------------------------------------------- | | `:skills` | `[%Skill.Info{}]` | Parsed skills, in discovery order, deduped by name (first wins). | | `:skipped` | `[%{location:, reason:}]` | One entry per rejected candidate. | ### Skip reasons [Section titled “Skip reasons”](#skip-reasons) | Reason | Cause | | ------------------------- | ---------------------------------------------------------------------- | | `"missing-name"` | Frontmatter parsed but `name` is absent or empty. | | `"malformed-frontmatter"` | The `---` fences are present but the YAML between them fails to parse. | | `"duplicate-name"` | An earlier candidate already claimed that name. | | `"unreadable"` | The `SKILL.md` could not be read off disk. | ## See also [Section titled “See also”](#see-also) * [`Toolnexus.Skill.load`](/api/elixir/skills/load/) — the runtime path that builds the `skill` tool * [`Toolnexus.Skill.load` with data and filters](/api/elixir/skills/load-with/) — `:skills`, `:provider`, `:filter` * [`Toolnexus.create_toolkit`](/api/elixir/toolkit/create/) — where the loaded skills end up # Toolnexus.Skill.load > Glob a skills directory and expose one skill tool with progressive disclosure. Elixir. **Elixir** · package `toolnexus` · [SPEC §3](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [elixir/lib/toolnexus/skill.ex](https://github.com/muthuishere/toolnexus/blob/main/elixir/lib/toolnexus/skill.ex) ```elixir @spec load(String.t() | [String.t()] | keyword() | map()) :: Toolnexus.Skill.Source.t() def load(input) # %Toolnexus.Skill.Source{skills: [%Skill.Info{}], tool: %Toolnexus.Tool{}, prompt: String.t()} ``` Walks a skills root for every `**/SKILL.md`, parses each file’s YAML frontmatter, and returns three things: the discovered `skills`, a **single** `tool` named `skill`, and a `prompt` catalog you paste into your system prompt. That single-tool design is the whole point. The model sees a short list of skill *names and descriptions* in the system prompt and one `skill` tool; the full instruction body and the file listing are only loaded when it actually calls `skill(name: "…")`. That is progressive disclosure — twenty skills cost you twenty lines of context, not twenty tool schemas. ## When to use it [Section titled “When to use it”](#when-to-use-it) * **You keep prompt-shaped expertise on disk** — a `skills/` folder of Markdown playbooks, each with scripts and reference files beside it — and want the model to pull one in on demand. * **You want to add capability without redeploying** — drop a new `SKILL.md` in the folder and call `load/1` again. * **You are following the open SKILL.md convention** used by Claude’s agent skills, so the same folder works with other tooling. ## Why this and not the alternative [Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative) Use create\_toolkit when you also have MCP servers or native tools [`Toolnexus.create_toolkit`](/api/elixir/toolkit/create/) takes a `:skills_dir` and does this internally, merging the `skill` tool into one list with MCP, HTTP and native tools — and it hands the catalog to the client loop for you. Call `Skill.load/1` directly **instead** when skills are your only tool source, or when you want the `skills` list and `prompt` as data (to inspect, to render, to inject into a prompt you build yourself). If you only need to *inspect* what is on disk — including the files that were rejected and why — use [`Toolnexus.Skill.list/1`](/api/elixir/skills/list/); it does no tool wiring. ## Examples [Section titled “Examples”](#examples) ### 1. Load the shared fixture and read the catalog [Section titled “1. Load the shared fixture and read the catalog”](#1-load-the-shared-fixture-and-read-the-catalog) This is `examples/skills`, the fixture every port is tested against. ```elixir alias Toolnexus.Skill # TOOLNEXUS_REPO is set by the docs test runner; in your own code just use a path. repo = System.get_env("TOOLNEXUS_REPO") || "." dir = Path.join([repo, "examples", "skills"]) source = Skill.load(dir) # One skill discovered, parsed from its YAML frontmatter. [info] = source.skills true = info.name == "hello-world" true = String.starts_with?(info.description, "A tiny example skill.") true = String.ends_with?(info.location, "hello-world/SKILL.md") true = info.origin == :fs # Exactly ONE tool, whatever the skill count. true = source.tool.name == "skill" true = source.tool.source == "skill" true = source.tool.input_schema["required"] == ["name"] # The catalog goes in your system prompt — names + descriptions only. true = String.contains?(source.prompt, "## Available Skills") true = String.contains?(source.prompt, "- **hello-world**:") # The instruction body is NOT in the prompt. That is the disclosure boundary. false = String.contains?(source.prompt, "Hello World Skill") IO.puts("ok: #{info.name} | 1 tool: #{source.tool.name}") ``` Caution A skill with no `description` in its frontmatter is still loadable, but it is **omitted from the prompt catalog** — so the model never learns it exists. Always write a description. ### 2. Calling the `skill` tool — what the model actually gets back [Section titled “2. Calling the skill tool — what the model actually gets back”](#2-calling-the-skill-tool--what-the-model-actually-gets-back) This is the second half of progressive disclosure: the body, a base directory, and a sampled file listing so the model can go read the resources next to the skill. ```elixir alias Toolnexus.{Context, Skill} repo = System.get_env("TOOLNEXUS_REPO") || "." source = Skill.load(Path.join([repo, "examples", "skills"])) res = source.tool.execute.(%{"name" => "hello-world"}, %Context{}) false = res.is_error out = res.output true = String.starts_with?(out, ~s()) true = String.ends_with?(out, "") # The instruction body, verbatim from below the frontmatter. true = String.contains?(out, "# Skill: hello-world") true = String.contains?(out, "1. Read `scripts/greet.sh` in this skill's base directory.") # An absolute file:// base so relative paths in the skill resolve. true = String.contains?(out, "Base directory for this skill: file:///") # A SAMPLED listing of sibling files — SKILL.md itself is excluded. true = String.contains?(out, "") true = String.contains?(out, "greet.sh") false = String.contains?(out, "SKILL.md") # Metadata carries the on-disk directory for hosts that need a real path. true = res.metadata.name == "hello-world" true = String.ends_with?(res.metadata.dir, "skills/hello-world") IO.puts("ok: #{byte_size(out)} bytes of skill_content") ``` ### 3. The full surface — several roots, the sample cap, and the miss path [Section titled “3. The full surface — several roots, the sample cap, and the miss path”](#3-the-full-surface--several-roots-the-sample-cap-and-the-miss-path) `:dirs` accepts a list; missing roots warn on stderr rather than raising, so one bad path never takes down the others. `:sample_limit` controls the `skill_files` listing. ```elixir alias Toolnexus.{Context, Skill} repo = System.get_env("TOOLNEXUS_REPO") || "." dir = Path.join([repo, "examples", "skills"]) # Several roots at once; a nonexistent one warns and contributes nothing. merged = Skill.load(dirs: [dir, Path.join([repo, "does", "not", "exist"])]) true = Enum.map(merged.skills, & &1.name) == ["hello-world"] # A bare binary and a bare list are shorthand for `dirs:`. true = Enum.map(Skill.load(dir).skills, & &1.name) == ["hello-world"] true = Enum.map(Skill.load([dir]).skills, & &1.name) == ["hello-world"] # sample_limit: -1 omits the block entirely (smallest context). lean = Skill.load(dirs: dir, sample_limit: -1) lean_out = lean.tool.execute.(%{"name" => "hello-world"}, %Context{}).output false = String.contains?(lean_out, "") true = String.contains?(lean_out, "Base directory for this skill:") # sample_limit: n > 0 caps the listing; 0 (the default) means 10. capped = Skill.load(dirs: dir, sample_limit: 1) capped_out = capped.tool.execute.(%{"name" => "hello-world"}, %Context{}).output true = capped_out |> String.split("") |> length() == 2 # An unknown name is a tool ERROR, not a raise — the model sees what is available. miss = merged.tool.execute.(%{"name" => "nope"}, %Context{}) true = miss.is_error true = miss.output == ~s(Skill "nope" not found. Available skills: hello-world) # With no skills at all the catalog says so, and the tool still exists. empty = Skill.load(dirs: []) [] = empty.skills true = empty.prompt == "No skills are currently available." true = empty.tool.name == "skill" IO.puts("ok: #{length(merged.skills)} skill(s), miss handled, sample_limit honoured") ``` ## Options [Section titled “Options”](#options) Accepts a **binary** (one root), a **list of binaries** (several roots), or a **map / keyword list**: | Option | Default | What it does | | --------------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------- | | `:dirs` | `[]` | A root, or list of roots, walked for `**/SKILL.md`. `node_modules` and `.git` are skipped; symlinks are followed with cycle protection. | | `:skills` | `[]` | Skills supplied as data maps instead of files — see [`load` with data](/api/elixir/skills/load-with/). | | `:provider` | `nil` | A 0-arity function returning skill-data maps, resolved once. A failure is isolated with a warning. | | `:filter` | `nil` | `name => boolean` allowlist / droplist. `nil` or `%{}` means all. | | `:sample_limit` | `0` | Sibling-file cap in `skill_files`: `0` ⇒ 10, `n > 0` ⇒ `n`, `-1` ⇒ omit the block. | ### What you get back [Section titled “What you get back”](#what-you-get-back) | Field | Type | What it is | | -------- | ------------------- | ------------------------------------------------------------------------------------- | | `skills` | `[%Skill.Info{}]` | `name`, `description`, `location`, `content`, `origin`, `resources`, `base`. | | `tool` | `%Toolnexus.Tool{}` | The single `skill` tool, `source: "skill"`, one required `name` argument. | | `prompt` | `String.t()` | The catalog to paste into your system prompt — described skills only, sorted by name. | ## See also [Section titled “See also”](#see-also) * [`Toolnexus.Skill.load` with data and filters](/api/elixir/skills/load-with/) — `:skills`, `:provider`, `:filter` * [`Toolnexus.Skill.list`](/api/elixir/skills/list/) — discovery without wiring, plus skip reasons * [`Toolnexus.create_toolkit`](/api/elixir/toolkit/create/) — takes `:skills_dir` and merges this in * [`Toolnexus.Tool`](/api/elixir/types/tool/) — what `source.tool` is # Toolnexus.Skill.load (data, providers, filters) > Supply skills as data or from a lazy provider, and narrow them per agent with an allowlist. Elixir. **Elixir** · package `toolnexus` · [SPEC §3](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [elixir/lib/toolnexus/skill.ex](https://github.com/muthuishere/toolnexus/blob/main/elixir/lib/toolnexus/skill.ex) ```elixir @spec load(String.t() | [String.t()] | keyword() | map()) :: Toolnexus.Skill.Source.t() def load(input) # options relevant here: # :skills — [%{name:, description:, content:, resources:, base:}] # :provider — (-> [skill-data]) resolved once # :filter — %{name => boolean} ``` There is no separate `load_with` function in this port — `Toolnexus.Skill.load/1` is one function that accepts `:dirs`, `:skills`, `:provider` and `:filter` together, and merges whatever you give it into a single skill source. This page is scoped to the three options [`Skill.load` (directories)](/api/elixir/skills/load/) doesn’t cover: supplying skills as data, supplying them from a lazy provider, and narrowing the result with an allowlist. ## When to use it [Section titled “When to use it”](#when-to-use-it) * **Skills that don’t live on disk** — generated at runtime, pulled from a database, assembled from a config file. `:skills` takes the same shape `load/1` would otherwise parse out of a `SKILL.md`, minus the parsing. * **Skills that are expensive to enumerate** — `:provider` is a 0-arity function resolved exactly once, so you can defer a database call or an API fetch until `load/1` actually runs, without eagerly building the list every time your module loads. * **The same catalog, narrowed per agent** — `:filter` is a `name => boolean` allowlist/droplist applied after directories, `:skills` and `:provider` are all merged, so one skill inventory can back several agents with different subsets. ## Why this and not the alternative [Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative) Directories are still the common case If every skill is a `SKILL.md` on disk, [`Skill.load/1` with `:dirs`](/api/elixir/skills/load/) (or a bare path, its shorthand) is all you need — you never touch `:skills` or `:provider`. Reach for this page’s options when at least one skill’s source of truth is *not* a file. Directories and data skills can mix in one call `:dirs` and `:skills`/`:provider` are not mutually exclusive — pass both, and `load/1` merges the candidates from every source before deduping by name (first source wins, same rule as duplicate `SKILL.md` files). ## Examples [Section titled “Examples”](#examples) ### 1. Skills supplied as data — no disk, no `SKILL.md` [Section titled “1. Skills supplied as data — no disk, no SKILL.md”](#1-skills-supplied-as-data--no-disk-no-skillmd) A data-sourced skill gets `origin: :logical` and a `skill://name/` base instead of a `file://` directory — nothing here ever touches the filesystem. ```elixir alias Toolnexus.{Context, Skill} source = Skill.load( skills: [ %{ name: "changelog", description: "Draft a changelog entry from a diff", content: "1. Summarize the diff.\n2. Pick a Conventional Commits type.", resources: ["templates/entry.md", "templates/breaking.md"] } ] ) [info] = source.skills true = info.name == "changelog" true = info.origin == :logical true = info.base == "skill://changelog/" res = source.tool.execute.(%{"name" => "changelog"}, %Context{}) false = res.is_error true = String.contains?(res.output, "Base directory for this skill: skill://changelog/") true = String.contains?(res.output, "templates/entry.md") IO.puts("ok: #{info.name} (#{info.origin}) -> #{info.base}") ``` ### 2. A lazy `:provider`, resolved once, isolated on failure [Section titled “2. A lazy :provider, resolved once, isolated on failure”](#2-a-lazy-provider-resolved-once-isolated-on-failure) `:provider` is called exactly once when `load/1` runs. If it raises or returns something that isn’t a list, the failure is isolated with a warning — exactly like one MCP server failing to connect never takes the others down. ```elixir alias Toolnexus.Skill calls = :counters.new(1, []) provider = fn -> :counters.add(calls, 1, 1) [%{name: "triage", description: "Triage an incoming bug report", content: "1. Reproduce it."}] end source = Skill.load(provider: provider) true = Enum.map(source.skills, & &1.name) == ["triage"] true = :counters.get(calls, 1) == 1 # A failing provider does not crash load/1 — it just contributes nothing. failing = fn -> raise "upstream unavailable" end empty_source = Skill.load(provider: failing) [] = empty_source.skills true = empty_source.tool.name == "skill" IO.puts("ok: provider called #{:counters.get(calls, 1)}x, failure isolated") ``` ### 3. `:filter` narrows a merged directory + data catalog [Section titled “3. :filter narrows a merged directory + data catalog”](#3-filter-narrows-a-merged-directory--data-catalog) Directories and data skills merge first; `:filter` is applied to the merged result, so it can drop names from either source alike. `has_true` semantics: if the filter contains at least one `true`, it becomes an allowlist (only those survive); otherwise it’s a droplist (only `false` entries are removed). ```elixir alias Toolnexus.Skill repo = System.get_env("TOOLNEXUS_REPO") || "." dir = Path.join([repo, "examples", "skills"]) config = [ dirs: [dir], skills: [ %{name: "triage", description: "Triage a bug report", content: "1. Reproduce it."}, %{name: "release", description: "Cut a release", content: "1. Bump the version."} ] ] # No filter: everything from every source. all = Skill.load(config) true = all.skills |> Enum.map(& &1.name) |> Enum.sort() == ["hello-world", "release", "triage"] # Allowlist mode: at least one `true` present, so only those survive. allow = Skill.load(Keyword.put(config, :filter, %{"triage" => true})) true = Enum.map(allow.skills, & &1.name) == ["triage"] # Droplist mode: no `true` present, so only `false` entries are removed. drop = Skill.load(Keyword.put(config, :filter, %{"release" => false})) true = drop.skills |> Enum.map(& &1.name) |> Enum.sort() == ["hello-world", "triage"] IO.puts("ok: all=#{length(all.skills)} allow=#{length(allow.skills)} drop=#{length(drop.skills)}") ``` ## Options (this page’s scope) [Section titled “Options (this page’s scope)”](#options-this-pages-scope) | Option | Default | What it does | | ----------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `:skills` | `[]` | Skills as data: `%{name:, description:, content:, resources:, base:}`. `resources` is the sibling-file list the `skill` tool samples from (no disk walk); `base` defaults to `skill:///`. | | `:provider` | `nil` | A 0-arity function returning a list in the same shape as `:skills`, resolved exactly once. A raise, or a non-list return, is isolated with a stderr warning — the rest of the source still loads. | | `:filter` | `nil` | `%{name => boolean}`. `nil`/`%{}` means all. If any value is `true`, the filter is an allowlist (only `true` names survive); otherwise it’s a droplist (only `false` names are dropped). A name that matches no skill logs a warning. Identical semantics to the MCP `tools` filter and the builtins `tools` toggle map. | ## See also [Section titled “See also”](#see-also) * [`Toolnexus.Skill.load`](/api/elixir/skills/load/) — the directory-sourced form and the `skill` tool shape * [`Toolnexus.Skill.list`](/api/elixir/skills/list/) — discovery and skip reasons, unfiltered, for authoring the allowlist * [`Toolnexus.Tool`](/api/elixir/types/tool/) — what `source.tool` is # Toolnexus.Answer.answer_declined/2 > Wraps a human's refusal into the Answer a suspended run resumes with, carrying a reason string that defaults to "declined". — Elixir. **Elixir** · package `toolnexus` · [SPEC §10](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) ```elixir @spec answer_declined(String.t(), String.t() | nil) :: Toolnexus.Answer.t() def answer_declined(id, reason \\ "declined") # %Toolnexus.Answer{id: id, ok: false, reason: reason} ``` Wraps a human’s refusal into the Answer a suspended run resumes with, carrying a reason string that defaults to “declined”. ## When to use it [Section titled “When to use it”](#when-to-use-it) * **A human (or a policy) refused a [`pending`](/api/elixir/suspension/pending/) suspension** — they declined an authorization request, cancelled an input prompt, or rejected a proposed action. `answer_declined/2` builds the `ok: false` half of the §10 answer contract, the counterpart to [`answer_output/2`](/api/elixir/suspension/answer-output/) for when there is no reply to resume with, only a refusal. * **You want the default spelling `"declined"`** without hand-writing it — the second argument defaults to `"declined"` (`elixir/lib/toolnexus/types.ex:105`), which is also the one string every port’s MCP elicitation bridge maps back to a `decline` action; any other string maps to `cancel` instead, so pass an explicit reason when you mean something more specific (“policy: amount over threshold”) but still want it to read as a decline rather than a cancel upstream. * **`reason` may be `nil` or a string** — anything else (a map, a number) raises an `ArgumentError` rather than silently coercing. ## Why this and not the alternative [Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative) Use answer\_output/2 when the human actually answered `answer_declined/2` is for a suspension the human REFUSED — `ok: false`. If they instead typed a reply to resume the run with, reach for [`Toolnexus.Answer.answer_output/2`](/api/elixir/suspension/answer-output/), which builds `ok: true` with the reply as `data`. There is no third, partial shape between the two. ## 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) ```elixir alias Toolnexus.Answer answer = Answer.answer_declined("req-1") true = answer.id == "req-1" true = answer.ok == false true = answer.reason == "declined" IO.puts("ok: #{inspect(answer)}") ``` ### 2. The realistic case — an explicit reason, and a non-string reason raises [Section titled “2. The realistic case — an explicit reason, and a non-string reason raises”](#2-the-realistic-case--an-explicit-reason-and-a-non-string-reason-raises) ```elixir alias Toolnexus.Answer with_reason = Answer.answer_declined("req-1", "budget exceeded") true = with_reason.ok == false true = with_reason.reason == "budget exceeded" raised? = try do Answer.answer_declined("req-1", %{}) false rescue e in ArgumentError -> Exception.message(e) =~ "must be a string" end true = raised? IO.puts("ok: explicit reason kept, non-string reason raised") ``` ### 3. The full surface — a decline resolves to an error result, never re-invoking the tool [Section titled “3. The full surface — a decline resolves to an error result, never re-invoking the tool”](#3-the-full-surface--a-decline-resolves-to-an-error-result-never-re-invoking-the-tool) Unlike `answer_output/2`, an `Answer` where `ok: false` never reaches the tool’s `execute` a second time — `Toolnexus.Client` resolves it straight to `%ToolResult{output: "declined/expired: ", is_error: true}` and feeds that back to the model, which is free to say something sensible about the refusal. ```elixir alias Toolnexus.{Client, Context, Tool, ToolResult, Request, Answer} {:ok, invocations} = Agent.start_link(fn -> 0 end) delete_account = %Tool{ name: "delete_account", description: "delete the user's account", input_schema: %{"type" => "object", "properties" => %{}}, source: "native", execute: fn _args, %Context{} -> Agent.update(invocations, &(&1 + 1)) %ToolResult{ output: "approval required", is_error: true, metadata: %{pending: %Request{id: "req-1", kind: "authorization", prompt: "Delete this account?"}} } end } {:ok, turn} = Agent.start_link(fn -> 0 end) plug = fn conn -> {:ok, _raw, conn} = Plug.Conn.read_body(conn) n = Agent.get_and_update(turn, fn c -> {c, c + 1} end) message = if n == 0 do %{"role" => "assistant", "content" => nil, "tool_calls" => [ %{"id" => "c1", "type" => "function", "function" => %{"name" => "delete_account", "arguments" => "{}"}} ]} else %{"role" => "assistant", "content" => "understood, cancelled"} end resp = %{"choices" => [%{"message" => message}], "usage" => %{"prompt_tokens" => 4, "completion_tokens" => 2, "total_tokens" => 6}} conn |> Plug.Conn.put_resp_content_type("application/json") |> Plug.Conn.send_resp(200, Jason.encode!(resp)) end client = Client.create( base_url: "http://localhost", style: "openai", model: "gpt-x", api_key: "test-key", http_options: [plug: plug], wait_for: fn _req -> Answer.answer_declined("req-1", "not authorized") end ) result = Client.run(client, "delete my account", [delete_account]) true = result.status == "done" true = result.text == "understood, cancelled" unless Agent.get(invocations, & &1) == 1 do raise "execute never re-runs on a decline" end IO.puts("ok: #{result.text}") ``` ## See also [Section titled “See also”](#see-also) * [`Toolnexus.Client.create`](/api/elixir/suspension/wait-for/) — The single hook where the host resolves a suspension — in-process prompt or durable queue, same contract. * [`Toolnexus.Agents.Runtime.resume`](/api/elixir/suspension/resume/) — Route an Answer to the deepest suspended sub-agent handle and resume it in place. * [`Toolnexus.Answer.answer_output/2`](/api/elixir/suspension/answer-output/) — Wraps a human’s typed string reply into the Answer a suspended run resumes with — the success counterpart to answerDeclined. # Toolnexus.Answer.answer_output/2 > Wraps a human's typed string reply into the Answer a suspended run resumes with — the success counterpart to answerDeclined. — Elixir. **Elixir** · package `toolnexus` · [SPEC §10](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) ```elixir @spec answer_output(String.t(), String.t()) :: Toolnexus.Answer.t() def answer_output(id, output) when is_binary(output) # %Toolnexus.Answer{id: id, ok: true, data: %{"output" => output}} ``` Wraps a human’s typed string reply into the Answer a suspended run resumes with — the success counterpart to answerDeclined. ## When to use it [Section titled “When to use it”](#when-to-use-it) * **A [`pending`](/api/elixir/suspension/pending/) suspension asked for free-form input** — a name, a confirmation string, an approved value — and a human (or an upstream service standing in for one) typed a reply. `answer_output/2` is the one place that builds the string-keyed `data` payload every port’s suspension contract expects, so a caller never has to hand-write `%{"output" => value}` and risk an atom-keyed miss after a JSON round-trip. * **You’re about to call [`Toolnexus.Agents.Runtime.resume/2`](/api/elixir/suspension/resume/) or answer a [`:wait_for`](/api/elixir/suspension/wait-for/) callback** — both take an `Answer` (or an equivalent plain map), and `answer_output/2` is the constructor that gets the shape right on the first try. * **`output` must be a string** — passing anything else (a map, a number, `nil`) raises an `ArgumentError` immediately rather than silently coercing or degrading to `""`; the suspension contract is a string in, a string out, on every port. ## Why this and not the alternative [Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative) Use answer\_declined/2 when the human refuses, not when they answer `answer_output/2` is for a suspension the human RESOLVED — `ok: true`, with the typed reply as data. If the human refused or cancelled instead, reach for [`Toolnexus.Answer.answer_declined/2`](/api/elixir/suspension/answer-declined/), which builds `ok: false` with a `reason` string. The two are the full pair the §10 contract expects; there is no third “partial” shape. ## Examples [Section titled “Examples”](#examples) ### 1. The smallest useful call — build the Answer [Section titled “1. The smallest useful call — build the Answer”](#1-the-smallest-useful-call--build-the-answer) ```elixir alias Toolnexus.Answer answer = Answer.answer_output("req-1", "42") true = answer.id == "req-1" true = answer.ok == true true = answer.data == %{"output" => "42"} IO.puts("ok: #{inspect(answer)}") ``` ### 2. The realistic case — a non-string output raises rather than degrading [Section titled “2. The realistic case — a non-string output raises rather than degrading”](#2-the-realistic-case--a-non-string-output-raises-rather-than-degrading) ```elixir alias Toolnexus.Answer raised? = try do Answer.answer_output("req-1", %{a: 1}) false rescue e in ArgumentError -> Exception.message(e) =~ "must be a string" end true = raised? IO.puts("ok: non-string output raised ArgumentError") ``` ### 3. The full surface — surviving a JSON round-trip and resolving a suspension [Section titled “3. The full surface — surviving a JSON round-trip and resolving a suspension”](#3-the-full-surface--surviving-a-json-round-trip-and-resolving-a-suspension) An `Answer` built with `answer_output/2` uses STRING keys throughout, so it is unchanged by a JSON encode/decode round-trip — the shape a host resuming out of a database column or a queue message actually holds. ```elixir alias Toolnexus.{Client, Context, Tool, ToolResult, Request, Answer} ask_human = %Tool{ name: "ask_human", description: "asks the human a question", input_schema: %{"type" => "object", "properties" => %{}}, source: "custom", execute: fn _args, %Context{} = ctx -> case ctx.answer do nil -> %ToolResult{ output: "", metadata: %{pending: %Request{id: "q1", kind: "input", prompt: "your name?"}} } answer -> ToolResult.ok("got: " <> Answer.get(answer, :data)["output"]) end end } {:ok, calls} = Agent.start_link(fn -> 0 end) plug = fn conn -> {:ok, _raw, conn} = Plug.Conn.read_body(conn) n = Agent.get_and_update(calls, fn c -> {c, c + 1} end) message = if n == 0 do %{"role" => "assistant", "content" => nil, "tool_calls" => [ %{"id" => "c1", "type" => "function", "function" => %{"name" => "ask_human", "arguments" => "{}"}} ]} else %{"role" => "assistant", "content" => "thanks"} end resp = %{"choices" => [%{"message" => message}], "usage" => %{"prompt_tokens" => 1, "completion_tokens" => 1, "total_tokens" => 2}} conn |> Plug.Conn.put_resp_content_type("application/json") |> Plug.Conn.send_resp(200, Jason.encode!(resp)) end # The answer, built with answer_output/2, surviving a JSON round-trip like a host would store it. round_tripped = Answer.answer_output("q1", "Ada") |> Jason.encode!() |> Jason.decode!() client = Client.create( base_url: "http://localhost", style: "openai", model: "gpt-x", api_key: "test-key", http_options: [plug: plug], wait_for: fn _req -> round_tripped end ) result = Client.run(client, "ask my name", [ask_human]) true = result.status == "done" true = result.text == "thanks" IO.puts("ok: resumed with a round-tripped answer_output payload") ``` ## See also [Section titled “See also”](#see-also) * [`Toolnexus.Client.create`](/api/elixir/suspension/wait-for/) — The single hook where the host resolves a suspension — in-process prompt or durable queue, same contract. * [`Toolnexus.Agents.Runtime.resume`](/api/elixir/suspension/resume/) — Route an Answer to the deepest suspended sub-agent handle and resume it in place. * [`Toolnexus.Answer.answer_declined/2`](/api/elixir/suspension/answer-declined/) — Wraps a human’s refusal into the Answer a suspended run resumes with, carrying a reason string that defaults to “declined”. # authRequired — suspend for authorization > The auth-shaped suspension: hand back a URL, resume once the user has granted access. — Elixir. **Elixir** · package `toolnexus` · [SPEC §10](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) Not available in Elixir Elixir ships no auth\_required/pending producer helpers — build %ToolResult{metadata: %{pending: %Request{}}} by hand. Only the reader, ToolResult.pending?/1, exists. The auth-shaped suspension: hand back a URL, resume once the user has granted access. ## What to use instead [Section titled “What to use instead”](#what-to-use-instead) Build the auth-shaped suspension by hand: a `%Toolnexus.ToolResult{}` whose `metadata.pending` carries a `%Toolnexus.Request{kind: "authorization"}` with a `:url` instead of expecting typed input: ```elixir %Toolnexus.ToolResult{ output: "Log in", is_error: true, metadata: %{ pending: %Toolnexus.Request{ id: "pnd-2", kind: "authorization", prompt: "Log in", url: "https://example.com/login" } } } ``` Same wire shape as every other port’s `authRequired/1`, same `id` correlation discipline as [`pending`](/api/elixir/suspension/pending/) — only the constructor is missing, not the behavior. `Toolnexus.ToolResult.pending?/1` reads either shape back identically. ## See also [Section titled “See also”](#see-also) * [`Toolnexus.Client.create`](/api/elixir/suspension/wait-for/) — The single hook where the host resolves a suspension — in-process prompt or durable queue, same contract. * [`Toolnexus.Agents.Runtime.resume`](/api/elixir/suspension/resume/) — The answer-carrying entry point: resume a parked run in a different process. # pending — suspend and ask a human > Return a Pending from a tool to park the run until someone answers. — Elixir. **Elixir** · package `toolnexus` · [SPEC §10](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) Not available in Elixir Elixir ships no pending/1 producer — construct %ToolResult{metadata: %{pending: %Request{}}} directly. Only the reader, ToolResult.pending?/1, exists. Return a Pending from a tool to park the run until someone answers. ## What to use instead [Section titled “What to use instead”](#what-to-use-instead) Build the suspension shape by hand: return a `%Toolnexus.ToolResult{}` whose `metadata` map carries a `:pending` key holding a `%Toolnexus.Request{id:, kind: "input", prompt:}`. The `id` is the correlation key a resolver (`:wait_for` or `Toolnexus.Agents.Runtime.resume/2`) echoes back in its `Answer`, so generate one you can look up later (a UUID, a database primary key). The wire shape is byte-identical to what every other port’s `pending/1` producer builds — only the convenience constructor is missing: ```elixir %Toolnexus.ToolResult{ output: "Which environment?", is_error: true, metadata: %{pending: %Toolnexus.Request{id: "pnd-1", kind: "input", prompt: "Which environment?"}} } ``` `Toolnexus.ToolResult.pending?/1` reads it back — see [`Toolnexus.ToolResult`](/api/elixir/types/tool-result/#3-the-reserved-key--metadatapending-is-a-suspension) for a full worked example, and [`:wait_for`](/api/elixir/suspension/wait-for/) for the whole suspend/resolve/retry cycle. ## See also [Section titled “See also”](#see-also) * [`Toolnexus.Client.create`](/api/elixir/suspension/wait-for/) — The single hook where the host resolves a suspension — in-process prompt or durable queue, same contract. * [`Toolnexus.Agents.Runtime.resume`](/api/elixir/suspension/resume/) — The answer-carrying entry point: resume a parked run in a different process. # pendingOf — read a suspension off a result > Detect that a RunResult is parked rather than finished, and get the Request that parked it. — Elixir. **Elixir** · package `toolnexus` · [SPEC §10](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) Not available in Elixir Elixir has no pending\_of/1; match on result.metadata or use ToolResult.pending?/1. Detect that a RunResult is parked rather than finished, and get the Request that parked it. ## What to use instead [Section titled “What to use instead”](#what-to-use-instead) Two calls cover it. `Toolnexus.ToolResult.pending?/1` tells you a **result** is a suspension; matching `metadata.pending` directly gets you the `Request`: ```elixir alias Toolnexus.{ToolResult, Request} case result do %ToolResult{metadata: %{pending: %Request{} = req}} -> req _ -> nil end ``` At the `RunResult` level, `Toolnexus.Client.run/4` already surfaces this without a helper: `result.status == "pending"` and `result.pending` is the `Request` directly (no reader call needed — see [`Client.run`](/api/elixir/client/run/#runresult-shape)). ## See also [Section titled “See also”](#see-also) * [`Toolnexus.Client.create`](/api/elixir/suspension/wait-for/) — The single hook where the host resolves a suspension — in-process prompt or durable queue, same contract. * [`Toolnexus.Agents.Runtime.resume`](/api/elixir/suspension/resume/) — The answer-carrying entry point: resume a parked run in a different process. # Relay tools — declaration-only > Declare a tool the host executes, not the library: the call rides out on the suspension. — Elixir. **Elixir** · package `toolnexus` · [SPEC §10](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) Not available in Elixir Go-only today (golang/relay.go). SPEC §10 addendum landed as a Go preview; the other five ports do not ship a relay tool yet. 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) There is no declaration-only relay tool in Elixir today — every tool the loop knows about is executable in-process. The closest available shape is an ordinary tool whose `execute` itself returns a §10 suspension (`metadata.pending`, see [`pending`](/api/elixir/suspension/pending/)): the model still calls it like a normal tool, but the call “rides out” as a `Request` for the host to answer, exactly as a relay declaration would — the difference is Elixir’s version always runs *some* code locally first (even if that code’s only job is to raise the suspension), where a true relay tool is declared but never invoked by the library at all. ## See also [Section titled “See also”](#see-also) * [`Toolnexus.Client.create`](/api/elixir/suspension/wait-for/) — The single hook where the host resolves a suspension — in-process prompt or durable queue, same contract. * [`Toolnexus.Agents.Runtime.resume`](/api/elixir/suspension/resume/) — The answer-carrying entry point: resume a parked run in a different process. # Toolnexus.Agents.Runtime.resume > The answer-carrying entry point: resume a parked run in a different process. — Elixir. **Elixir** · package `toolnexus` · [SPEC §10](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [elixir/lib/toolnexus/agents/runtime.ex](https://github.com/muthuishere/toolnexus/blob/main/elixir/lib/toolnexus/agents/runtime.ex) ```elixir @spec resume(pid(), map()) :: :ok def resume(rt, answer) # answer :: %{id: term, ok: boolean, data: map | nil, reason: String.t() | nil} ``` Elixir's resume entry point lives on the §7D agent runtime, not Client `Toolnexus.Client` has no `resume` — it only offers [`:wait_for`](/api/elixir/suspension/wait-for/), which resolves a suspension **synchronously, in-process**. The real answer-carrying re-entry point — resuming a suspended sub-agent handle, possibly from a different callback or long after the original call returned — is `Toolnexus.Agents.Runtime.resume/2` on the [§7D agent runtime](/api/elixir/runtime/runtime/), the same mechanism every other port ships under an `AgentRuntime.resume`-shaped name. `resume/2` routes an `Answer` to the **deepest suspended handle** in the runtime’s tree. That handle resumes from its checkpoint — turns and usage grow, they never reset — and then the upward cascade re-runs each suspended parent, whose re-invoked `task` call **reattaches** to the already-finished child by task key rather than re-executing it. A suspended turn commits nothing to the `ConversationStore`; `resume/2` replays the whole turn, and idempotency comes from that reattachment, never from a halted placeholder left in history. ## When to use it [Section titled “When to use it”](#when-to-use-it) * **The suspension will outlive the current process** — a human approves hours later, an OAuth redirect lands after a redeploy, an answer arrives over a queue. Persist the `Request` (its `id`, and for nested agents `data["path"]` to the leaf), tear nothing down, and call `resume/2` whenever the `Answer` shows up — even in a fresh runtime rebuilt from the same registry and a durable `ConversationStore`. * **You’re driving `Toolnexus.Agents`, not the classic `Client` loop directly** — `resume/2` is a runtime verb; it operates on a `Toolnexus.Agents.Runtime` pid, not a `Toolnexus.Client.t()`. ## Why this and not the alternative [Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative) Reach for :wait\_for when resolution is cheap and synchronous [`:wait_for`](/api/elixir/suspension/wait-for/) resolves a suspension right where it happened, inside the same `Client.run/4` call — nothing to persist, nothing to look up later. Reach for `Runtime.resume/2` when that isn’t true: the answer needs a real out-of-band round trip, and the run must survive until it arrives, possibly in a different process entirely. ## Examples [Section titled “Examples”](#examples) ### 1. The smallest useful call — suspend with no resolver anywhere, then resume [Section titled “1. The smallest useful call — suspend with no resolver anywhere, then resume”](#1-the-smallest-useful-call--suspend-with-no-resolver-anywhere-then-resume) No `:wait_for` is configured on the agent or the runtime, so the tool’s suspension surfaces all the way out as a durable pending run. ```elixir alias Toolnexus.{Agents, Context, Tool, ToolResult, Request} alias Toolnexus.Agents.Runtime approve_tool = %Tool{ name: "secret_action", description: "does something that needs approval", input_schema: %{"type" => "object", "properties" => %{}}, source: "native", execute: fn _args, %Context{} = ctx -> case ctx.answer do nil -> %ToolResult{output: "approval required", is_error: true, metadata: %{pending: %Request{id: "req-1", kind: "authorization", prompt: "approve the secret action?"}}} %{ok: true} -> ToolResult.ok("action performed") end end } approver = Agents.agent("approver", does: "does the secret thing", uses: %{tools: [approve_tool]}, model: "m-1") mock_transport = fn %{body: body} -> msgs = body["messages"] || [] tool_msgs = Enum.filter(msgs, &(&1["role"] == "tool")) resp = if tool_msgs == [] do %{ "choices" => [%{"message" => %{"role" => "assistant", "content" => nil, "tool_calls" => [ %{"id" => "c1", "type" => "function", "function" => %{"name" => "secret_action", "arguments" => "{}"}} ]}}], "usage" => %{"prompt_tokens" => 3, "completion_tokens" => 2, "total_tokens" => 5} } else %{"choices" => [%{"message" => %{"role" => "assistant", "content" => hd(tool_msgs)["content"]}}], "usage" => %{"prompt_tokens" => 2, "completion_tokens" => 1, "total_tokens" => 3}} end {:ok, %{status: 200, headers: %{}, body: resp}} end rt = Runtime.new(%{transport: mock_transport, registry: Agents.registry(approver)}) {:ok, h} = Runtime.spawn_agent(rt, Runtime.root(rt), "approver") Runtime.wake(rt, h, "do the secret thing") r1 = Runtime.wait(rt, h) true = r1.status == "pending" true = r1.pending.kind == "authorization" true = Runtime.snapshot(h).state == :suspended Runtime.resume(rt, %{id: r1.pending.id, ok: true}) snap = Runtime.snapshot(h) true = snap.state == :idle true = snap.last_result.text == "action performed" Runtime.shutdown(rt) IO.puts("ok: resumed a durably-suspended handle — final text: #{snap.last_result.text}") ``` ### 2. The realistic case — the runtime survives the wait; usage grows, not resets [Section titled “2. The realistic case — the runtime survives the wait; usage grows, not resets”](#2-the-realistic-case--the-runtime-survives-the-wait-usage-grows-not-resets) ```elixir alias Toolnexus.{Agents, Context, Tool, ToolResult, Request} alias Toolnexus.Agents.Runtime approve_tool = %Tool{ name: "secret_action", description: "does something that needs approval", input_schema: %{"type" => "object", "properties" => %{}}, source: "native", execute: fn _args, %Context{} = ctx -> case ctx.answer do nil -> %ToolResult{output: "approval required", is_error: true, metadata: %{pending: %Request{id: "req-2", kind: "authorization", prompt: "approve?"}}} %{ok: true} -> ToolResult.ok("action performed") end end } approver = Agents.agent("approver", does: "does the secret thing", uses: %{tools: [approve_tool]}, model: "m-1") mock_transport = fn %{body: body} -> msgs = body["messages"] || [] tool_msgs = Enum.filter(msgs, &(&1["role"] == "tool")) resp = if tool_msgs == [] do %{"choices" => [%{"message" => %{"role" => "assistant", "content" => nil, "tool_calls" => [ %{"id" => "c1", "type" => "function", "function" => %{"name" => "secret_action", "arguments" => "{}"}} ]}}], "usage" => %{"prompt_tokens" => 3, "completion_tokens" => 2, "total_tokens" => 5}} else %{"choices" => [%{"message" => %{"role" => "assistant", "content" => hd(tool_msgs)["content"]}}], "usage" => %{"prompt_tokens" => 2, "completion_tokens" => 1, "total_tokens" => 3}} end {:ok, %{status: 200, headers: %{}, body: resp}} end rt = Runtime.new(%{transport: mock_transport, registry: Agents.registry(approver)}) {:ok, h} = Runtime.spawn_agent(rt, Runtime.root(rt), "approver") Runtime.wake(rt, h, "do it") r1 = Runtime.wait(rt, h) true = r1.status == "pending" before_tokens = Runtime.snapshot(h).usage_total # The "different process" is simulated here by simply waiting before resuming — the # handle is genuinely parked (state: :suspended) the whole time, burning no tokens. Runtime.resume(rt, %{id: r1.pending.id, ok: true}) after_snap = Runtime.snapshot(h) true = after_snap.state == :idle true = after_snap.usage_total > before_tokens true = after_snap.turns_total >= 2 Runtime.shutdown(rt) IO.puts("ok: usage grew from #{before_tokens} to #{after_snap.usage_total} tokens across the suspend/resume boundary") ``` ### 3. The full surface — a denial resolves the tool call as an error, not a crash [Section titled “3. The full surface — a denial resolves the tool call as an error, not a crash”](#3-the-full-surface--a-denial-resolves-the-tool-call-as-an-error-not-a-crash) A resume replays the suspended turn from its checkpoint — the tool is invoked fresh, raises the same suspension again, and `ok: false` resolves it to a generic `"declined/expired: "` tool error (the same fallback [`:wait_for`](/api/elixir/suspension/wait-for/) uses for a declined answer) rather than re-invoking the tool with `ctx.answer` set — that re-invocation is reserved for `ok: true`. ```elixir alias Toolnexus.{Agents, Context, Tool, ToolResult, Request} alias Toolnexus.Agents.Runtime approve_tool = %Tool{ name: "secret_action", description: "does something that needs approval", input_schema: %{"type" => "object", "properties" => %{}}, source: "native", execute: fn _args, %Context{} = ctx -> case ctx.answer do nil -> %ToolResult{output: "approval required", is_error: true, metadata: %{pending: %Request{id: "req-3", kind: "authorization", prompt: "approve?"}}} %{ok: true} -> ToolResult.ok("action performed") end end } approver = Agents.agent("approver", does: "does the secret thing", uses: %{tools: [approve_tool]}, model: "m-1") mock_transport = fn %{body: body} -> msgs = body["messages"] || [] tool_msgs = Enum.filter(msgs, &(&1["role"] == "tool")) resp = if tool_msgs == [] do %{"choices" => [%{"message" => %{"role" => "assistant", "content" => nil, "tool_calls" => [ %{"id" => "c1", "type" => "function", "function" => %{"name" => "secret_action", "arguments" => "{}"}} ]}}], "usage" => %{"prompt_tokens" => 3, "completion_tokens" => 2, "total_tokens" => 5}} else %{"choices" => [%{"message" => %{"role" => "assistant", "content" => hd(tool_msgs)["content"]}}], "usage" => %{"prompt_tokens" => 2, "completion_tokens" => 1, "total_tokens" => 3}} end {:ok, %{status: 200, headers: %{}, body: resp}} end rt = Runtime.new(%{transport: mock_transport, registry: Agents.registry(approver)}) {:ok, h} = Runtime.spawn_agent(rt, Runtime.root(rt), "approver") Runtime.wake(rt, h, "do it") r1 = Runtime.wait(rt, h) true = r1.status == "pending" Runtime.resume(rt, %{id: r1.pending.id, ok: false, reason: "not authorized"}) snap = Runtime.snapshot(h) true = snap.state == :idle # a "done" handle never surfaces is_error — a decline resolves to ordinary assistant text, # not a crashed run false = snap.last_result.is_error true = snap.last_result.text == "declined/expired: approve?" Runtime.shutdown(rt) IO.puts("ok: a declined Answer resolved to a normal turn — final text: #{snap.last_result.text}") ``` ## `answer` fields [Section titled “answer fields”](#answer-fields) | Field | Type | What it is | | -------- | ------------------- | ------------------------------------------------------------------------------------------------------- | | `id` | `term()` | Correlates to the `Request.id` the suspended tool raised. | | `ok` | `boolean()` | `true` re-invokes with `ctx.answer` set; `false` is up to the tool to interpret (deny, fall back, etc). | | `data` | `map() \| nil` | Payload the tool reads back via `ctx.answer.data`. | | `reason` | `String.t() \| nil` | Optional human-readable reason, most useful on `ok: false`. | ## See also [Section titled “See also”](#see-also) * [`Toolnexus.Client.create`](/api/elixir/suspension/wait-for/) — the synchronous, in-process resolver (`:wait_for`); prefer it when resolution doesn’t need to survive a restart. * [`Toolnexus.Agents.Runtime`](/api/elixir/runtime/runtime/) — the six host verbs `resume/2` is one of. * [`Toolnexus.ToolResult`](/api/elixir/types/tool-result/) — how a tool raises the suspension `resume/2` answers. # Toolnexus.Client.create > The single hook where the host resolves a suspension — in-process prompt or durable queue, same contract. — Elixir. **Elixir** · package `toolnexus` · [SPEC §10](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [elixir/lib/toolnexus/client.ex](https://github.com/muthuishere/toolnexus/blob/main/elixir/lib/toolnexus/client.ex) ```elixir Toolnexus.Client.create(base_url: ..., style: ..., model: ..., wait_for: wait_for, ...) # wait_for :: (Toolnexus.Request.t() -> Toolnexus.Answer.t() | %{ok: boolean, id: term, data: map, reason: String.t()}) ``` `:wait_for` is not a separate type in Elixir — it is one option on [`Toolnexus.Client.create/1`](/api/elixir/client/create/): a 1-arity function from a §10 `Request` to an `Answer`. When a tool’s `ToolResult` carries `metadata.pending` (a suspension — see [`Toolnexus.ToolResult`](/api/elixir/types/tool-result/)), the loop calls `wait_for.(request)` **in-process, synchronously**, right where the suspension happened. If the answer is `ok: true`, the tool is re-invoked with `ctx.answer` set and the run continues as if nothing paused; if `ok: false`, the suspension resolves to an error result fed back to the model. With no `:wait_for` configured, the run halts instead — `RunResult.status == "pending"` and `RunResult.pending` holds the `Request` for the host to resolve out-of-band. ## When to use it [Section titled “When to use it”](#when-to-use-it) * **You can resolve the suspension right here** — a CLI prompt, an in-memory approval queue, a synchronous call to another service — anything that can answer before the current process returns. * **You want the loop to “just work” through a suspension** without the caller having to notice `status == "pending"` and drive a separate resume path. ## Why this and not the alternative [Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative) No wait\_for -> the run halts and YOU resume it later Omit `:wait_for` (or the request outlives your process) and `Client.run/4` returns with `status: "pending"` and `pending: %Request{}` instead of blocking. That is the durable path — see [`Toolnexus.Agents.Runtime.resume/2`](/api/elixir/suspension/resume/) for resuming an agent run from a **different** process, potentially after a restart. Use `:wait_for` when resolution is cheap and synchronous; use durable resume when it isn’t (a human replies hours later, a webhook lands after a redeploy). ## Examples [Section titled “Examples”](#examples) ### 1. The smallest useful call — no `:wait_for`, the run halts [Section titled “1. The smallest useful call — no :wait\_for, the run halts”](#1-the-smallest-useful-call--no-wait_for-the-run-halts) ```elixir alias Toolnexus.{Client, Context, Tool, ToolResult, Request} approve = %Tool{ name: "delete_account", description: "delete the user's account", input_schema: %{"type" => "object", "properties" => %{}}, source: "native", execute: fn _args, %Context{} -> %ToolResult{ output: "approval required", is_error: true, metadata: %{pending: %Request{id: "req-1", kind: "authorization", prompt: "Delete this account?"}} } end } plug = fn conn -> {:ok, _raw, conn} = Plug.Conn.read_body(conn) resp = %{ "choices" => [%{"message" => %{"role" => "assistant", "content" => nil, "tool_calls" => [ %{"id" => "c1", "type" => "function", "function" => %{"name" => "delete_account", "arguments" => "{}"}} ]}}], "usage" => %{"prompt_tokens" => 4, "completion_tokens" => 2, "total_tokens" => 6} } conn |> Plug.Conn.put_resp_content_type("application/json") |> Plug.Conn.send_resp(200, Jason.encode!(resp)) end client = Client.create(base_url: "http://localhost", style: "openai", model: "gpt-x", api_key: "test-key", http_options: [plug: plug]) result = Client.run(client, "delete my account", [approve]) true = result.status == "pending" true = result.pending.id == "req-1" true = result.pending.kind == "authorization" true = result.pending.prompt == "Delete this account?" IO.puts("ok: run halted — pending #{result.pending.kind} request #{result.pending.id}") ``` ### 2. The realistic case — `:wait_for` resolves it in-process, the run completes [Section titled “2. The realistic case — :wait\_for resolves it in-process, the run completes”](#2-the-realistic-case--wait_for-resolves-it-in-process-the-run-completes) ```elixir alias Toolnexus.{Client, Context, Tool, ToolResult, Request, Answer} approve = %Tool{ name: "delete_account", description: "delete the user's account", input_schema: %{"type" => "object", "properties" => %{}}, source: "native", execute: fn _args, %Context{} = ctx -> case ctx.answer do nil -> %ToolResult{ output: "approval required", is_error: true, metadata: %{pending: %Request{id: "req-2", kind: "authorization", prompt: "Delete this account?"}} } %Answer{ok: true} -> ToolResult.ok("account deleted") end end } {:ok, calls} = Agent.start_link(fn -> 0 end) plug = fn conn -> {:ok, _raw, conn} = Plug.Conn.read_body(conn) n = Agent.get_and_update(calls, fn c -> {c + 1, c + 1} end) resp = if n == 1 do %{ "choices" => [%{"message" => %{"role" => "assistant", "content" => nil, "tool_calls" => [ %{"id" => "c1", "type" => "function", "function" => %{"name" => "delete_account", "arguments" => "{}"}} ]}}], "usage" => %{"prompt_tokens" => 4, "completion_tokens" => 2, "total_tokens" => 6} } else %{"choices" => [%{"message" => %{"role" => "assistant", "content" => "done — account deleted"}}], "usage" => %{"prompt_tokens" => 3, "completion_tokens" => 2, "total_tokens" => 5}} end conn |> Plug.Conn.put_resp_content_type("application/json") |> Plug.Conn.send_resp(200, Jason.encode!(resp)) end wait_for = fn %Request{id: id} -> %Answer{id: id, ok: true} end client = Client.create( base_url: "http://localhost", style: "openai", model: "gpt-x", api_key: "test-key", wait_for: wait_for, http_options: [plug: plug] ) result = Client.run(client, "delete my account", [approve]) true = result.status == "done" true = result.text == "done — account deleted" [%{name: "delete_account", output: "account deleted", is_error: false}] = result.tool_calls IO.puts("ok: #{result.text} (suspension resolved in-process, no caller-visible pending)") ``` ### 3. The full surface — a denial feeds back as a tool error, the model keeps going [Section titled “3. The full surface — a denial feeds back as a tool error, the model keeps going”](#3-the-full-surface--a-denial-feeds-back-as-a-tool-error-the-model-keeps-going) ```elixir alias Toolnexus.{Client, Context, Tool, ToolResult, Request, Answer} approve = %Tool{ name: "delete_account", description: "delete the user's account", input_schema: %{"type" => "object", "properties" => %{}}, source: "native", execute: fn _args, %Context{} = ctx -> case ctx.answer do nil -> %ToolResult{output: "approval required", is_error: true, metadata: %{pending: %Request{id: "req-3", kind: "authorization", prompt: "Delete this account?"}}} %Answer{ok: true} -> ToolResult.ok("account deleted") end end } {:ok, calls} = Agent.start_link(fn -> 0 end) plug = fn conn -> {:ok, _raw, conn} = Plug.Conn.read_body(conn) n = Agent.get_and_update(calls, fn c -> {c + 1, c + 1} end) resp = if n == 1 do %{ "choices" => [%{"message" => %{"role" => "assistant", "content" => nil, "tool_calls" => [ %{"id" => "c1", "type" => "function", "function" => %{"name" => "delete_account", "arguments" => "{}"}} ]}}], "usage" => %{"prompt_tokens" => 4, "completion_tokens" => 2, "total_tokens" => 6} } else %{"choices" => [%{"message" => %{"role" => "assistant", "content" => "understood, I won't delete it"}}], "usage" => %{"prompt_tokens" => 3, "completion_tokens" => 2, "total_tokens" => 5}} end conn |> Plug.Conn.put_resp_content_type("application/json") |> Plug.Conn.send_resp(200, Jason.encode!(resp)) end # The host declines — ok: false. The loop never re-invokes the tool with this answer. wait_for = fn %Request{id: id} -> %Answer{id: id, ok: false, reason: "user declined"} end client = Client.create( base_url: "http://localhost", style: "openai", model: "gpt-x", api_key: "test-key", wait_for: wait_for, http_options: [plug: plug] ) result = Client.run(client, "delete my account", [approve]) true = result.status == "done" true = result.text == "understood, I won't delete it" [%{name: "delete_account", is_error: true, output: output}] = result.tool_calls true = String.starts_with?(output, "declined/expired:") IO.puts("ok: denial fed back as a tool error — model replied: #{result.text}") ``` ## `wait_for` contract [Section titled “wait\_for contract”](#wait_for-contract) | | Type | What it is | | --- | ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | | in | `Toolnexus.Request.t()` | The suspension the tool raised (`id`, `kind`, `prompt`, `url?`, `data?`). | | out | `Toolnexus.Answer.t()` | `%{id, ok, data?, reason?}`. `ok: true` re-invokes the tool with `ctx.answer` set; `ok: false` resolves to a `"declined/expired: "` error result. | ## `wait_for_ctx` — the turn’s context [Section titled “wait\_for\_ctx — the turn’s context”](#wait_for_ctx--the-turns-context) `Client.create(wait_for_ctx: fn %Toolnexus.Context{} = ctx, request -> answer end)` is `:wait_for` plus the turn’s context, and wins over `:wait_for` when both are set. `ctx.signal` is the pid of the process running the turn and `ctx.call_id` is the suspended call’s id. A resolver that waits in another process (a UI, a chat bridge) monitors `ctx.signal`, so a pending question ends when the turn is cancelled (its process stopped) instead of hanging: ```elixir wait_for_ctx: fn ctx, request -> ref = Process.monitor(ctx.signal) send(ui, {:ask, self(), request}) receive do {:answer, answer} -> answer {:DOWN, ^ref, _, _, _} -> exit(:turn_gone) end end ``` ## See also [Section titled “See also”](#see-also) * [`Toolnexus.ToolResult`](/api/elixir/types/tool-result/) — how a tool raises a suspension (`metadata.pending`). * [`Toolnexus.Agents.Runtime.resume`](/api/elixir/suspension/resume/) — the durable path, for resuming after `status == "pending"` from a different process. * [`Toolnexus.Client.run`](/api/elixir/client/run/) — the loop this option configures. # Toolnexus.create_toolkit > Collapse MCP servers, skills, built-ins, A2A agents and your own functions into one tool list. — Elixir. **Elixir** · package `toolnexus` · [SPEC §4](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) ```elixir @spec create_toolkit(keyword() | map()) :: {:ok, Toolnexus.Toolkit.t()} | {:error, Exception.t()} def create_toolkit(opts \\ []) @spec create_toolkit!(keyword() | map()) :: Toolnexus.Toolkit.t() def create_toolkit!(opts \\ []) Toolnexus.create_toolkit( mcp_config: "mcp.json", # path, raw JSON, or a parsed map skills_dir: ["skills"], # one or more skill roots skills: nil, # skills supplied as data, bypassing the filesystem (§3, S1) skill_provider: nil, # 0-arity fun lazily supplying data skills (§3, S1) skills_filter: nil, # per-agent skill allowlist keyed on name (§3, S2) skill_sample_limit: nil, # sibling-file cap: 0 = default 10, n>0 = cap, -1 = omit disable_tools: [], # drop tools by final exposed name, across every source disable_skills: [], # drop skills by name from the skill catalog extra_tools: [], # your own tools, always added to the toolkit builtins: true, # default ON; also readable from a parsed config's top-level `builtins` key agents: [], # remote A2A agent descriptors (§7A) wait_for: nil, # §10 host resolver; bridges MCP elicitation deadline: nil # ms — bounds the MCP load (§2 Gap 3) ) ``` Collapse MCP servers, skills, built-ins, A2A agents and your own functions into one tool list. `Toolnexus.create_toolkit/1` (`elixir/lib/toolnexus.ex:28`) is a thin `{:ok, _} | {:error, _}` wrapper over `Toolnexus.Toolkit.build/1` — the toolkit’s actual assembly logic (`elixir/lib/toolnexus/toolkit.ex:62`). `create_toolkit!/1` calls `Toolkit.build/1` directly and raises instead of returning a tuple. Either way you get back a `%Toolnexus.Toolkit{}` struct that exposes `:tools` (the assembled `Toolnexus.Tool` list) and `:prompt` (the skill catalog for your system prompt) directly, so it plugs straight into [`Toolnexus.Client.run/4`](/api/elixir/client/run/). Assembly is deterministic (SPEC §4): first, any builtin whose name a host `:extra_tools` entry also provides is dropped — a host can shadow a builtin. Then every remaining source is concatenated in the order **MCP → skill → builtin → agents → extra\_tools**, deduped by name with **first wins** (a warning is logged for every dropped duplicate). `:disable_tools` runs last, dropping tools by their *final* exposed name regardless of which source produced them. ## When to use it [Section titled “When to use it”](#when-to-use-it) * **You are wiring up “zero to agent”** — one call here for every tool source (MCP servers, a skills folder, the built-in shell/file tools, remote A2A agents, your own functions), one [`Toolnexus.Client.create/1`](/api/elixir/client/create/) for the model connection, then `Client.run/4` with both. * **You want to mix tool sources without hand-merging lists** — an MCP server that happens to expose a tool named `bash` and the built-in `bash` don’t collide silently; the dedupe order and the warning log make the collision visible and its resolution deterministic. * **You need to turn a tool or a skill off without touching its source config** — `:disable_tools` and `:disable_skills` are host-side subtraction, so a shared `mcp.json` or a shared skills folder can still be trimmed per-deployment (e.g. hide a destructive MCP tool in a read-only environment) without forking the config file. * **A host is going to resolve MCP elicitation or a suspended tool** — pass `:wait_for` here, not to the client; it is the toolkit that owns the MCP connection an elicitation request comes from. See [`Toolnexus.Client.create` — wait\_for](/api/elixir/suspension/wait-for/). ## Why this and not the alternative [Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative) One options map, not five separate builders MCP, skills, built-ins, A2A agents and your own functions are five genuinely different tool *sources*, but to an LLM they are the same thing — a named, described, schema’d callable (see the repo-level `CLAUDE.md`). `create_toolkit/1` reflects that: one options map, one assembled `Tool` list, one `execute/4` router. The alternative — five separate “toolkit” types the caller merges by hand — would make every consumer (the client loop, the adapters, `to_openai/1`) branch on tool source, which is exactly the drift this design exists to prevent. `create_toolkit/1` versus `create_toolkit!/1` is the one real choice here: use the tupled version when a bad `mcp_config` path or a load-deadline timeout is a recoverable condition you want to handle (log it, retry, fall back to a smaller toolkit); use the bang version — the common case in scripts and examples — when a build failure should just crash loudly, because there is nothing sensible to do with a half-built toolkit anyway. ## Examples [Section titled “Examples”](#examples) ### 1. The smallest useful call — skills only, builtins on by default [Section titled “1. The smallest useful call — skills only, builtins on by default”](#1-the-smallest-useful-call--skills-only-builtins-on-by-default) ```elixir {:ok, toolkit} = Toolnexus.create_toolkit(skills_dir: ["../examples/skills"]) IO.puts("Tools: " <> inspect(Enum.map(Toolnexus.Toolkit.tools(toolkit), & &1.name))) IO.puts(Toolnexus.Toolkit.skills_prompt(toolkit)) Toolnexus.Toolkit.close(toolkit) ``` ### 2. The realistic case — MCP + skills + your own function, wired to the client [Section titled “2. The realistic case — MCP + skills + your own function, wired to the client”](#2-the-realistic-case--mcp--skills--your-own-function-wired-to-the-client) ```elixir add = Toolnexus.define_tool( name: "add", description: "Add two numbers and return the sum.", input_schema: %{ "type" => "object", "properties" => %{"a" => %{"type" => "number"}, "b" => %{"type" => "number"}}, "required" => ["a", "b"] }, execute: fn %{"a" => a, "b" => b}, _ctx -> to_string(a + b) end ) ## TOOLNEXUS_REPO is set by the docs test runner; in your own code just use a path. repo = System.get_env("TOOLNEXUS_REPO") || "." {:ok, toolkit} = Toolnexus.create_toolkit( mcp_config: Path.join(repo, "examples/mcp.json"), skills_dir: [Path.join(repo, "examples/skills")], extra_tools: [add] ) client = Toolnexus.Client.create( base_url: "https://openrouter.ai/api/v1", style: "openai", model: "openai/gpt-4o-mini", api_key: System.get_env("OPENROUTER_API_KEY") || "test-key" ) # result = Toolnexus.Client.run(client, "Use the add tool to add 21 and 21.", toolkit) # IO.puts(result.text) IO.puts("ok: toolkit assembled (#{length(Toolnexus.Toolkit.tools(toolkit))} tools), MCP status #{inspect(Toolnexus.Toolkit.mcp_status(toolkit))}") Toolnexus.Toolkit.close(toolkit) ``` ### 3. The full surface — disable\_tools/disable\_skills, a trimmed builtins map, an A2A agent [Section titled “3. The full surface — disable\_tools/disable\_skills, a trimmed builtins map, an A2A agent”](#3-the-full-surface--disable_toolsdisable_skills-a-trimmed-builtins-map-an-a2a-agent) ```elixir {:ok, toolkit} = Toolnexus.create_toolkit( skills_dir: ["../examples/skills"], disable_skills: ["hello-world"], builtins: %{"tools" => %{"bash" => false}}, disable_tools: ["write"], agents: [Toolnexus.agent(card: "https://example.com/.well-known/agent-card.json")], skill_sample_limit: 5, deadline: 5_000 ) names = Enum.map(Toolnexus.Toolkit.tools(toolkit), & &1.name) false = "bash" in names false = "write" in names false = Toolnexus.Toolkit.skills_prompt(toolkit) =~ "hello-world" IO.puts("ok: trimmed toolkit -> " <> inspect(names)) Toolnexus.Toolkit.close(toolkit) ``` ## See also [Section titled “See also”](#see-also) * [`Toolnexus.Client.create`](/api/elixir/client/create/) — The unified client: system prompt, skills injection, parallel and chained tool calls, retries, memory. * [`Toolnexus.Mcp.load`](/api/elixir/mcp/load/) — Read an mcp.json, connect every enabled server, and expose each server tool as a Tool. * [`Toolnexus.Skill.load`](/api/elixir/skills/load/) — Glob a skills directory and expose one skill tool with progressive disclosure. * [`Toolnexus.Skill.list`](/api/elixir/skills/list/) — Enumerate discovered skills and, crucially, the ones that were skipped and why. * [`Toolnexus.Builtin.tools`](/api/elixir/builtins/create/) — Construct every built-in tool directly, outside a toolkit — no config, no toggles. * [`Toolnexus.Client.create` — wait\_for](/api/elixir/suspension/wait-for/) — The single hook where the host resolves a suspension — in-process prompt or durable queue, same contract. # Toolnexus.Translate.openai_messages_to_anthropic > Convert an OpenAI-shaped transcript into Anthropic shape, including the tool-result merging a flattening translator gets wrong. — Elixir. **Elixir** · package `toolnexus` · [SPEC §11](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [elixir/lib/toolnexus/translate.ex](https://github.com/muthuishere/toolnexus/blob/main/elixir/lib/toolnexus/translate.ex) ```elixir @spec openai_messages_to_anthropic([map()]) :: {[map()], String.t()} def openai_messages_to_anthropic(messages) ``` Converts an OpenAI `messages` list into Anthropic-native `messages` plus the extracted system prompt, returned as `{messages, system}`. This is the piece a naive text-flattening translator gets wrong: an assistant turn’s `tool_calls` become `tool_use` blocks (with `arguments` parsed back from its JSON string into an object), a `tool`-role result becomes a `tool_result` block keyed by `tool_call_id`, and **consecutive tool results merge into a single user turn** — because Anthropic expects one result-bearing turn answering the preceding assistant turn, not one turn per result. `system`/`developer` messages are hoisted out into the returned `system` string, since Anthropic takes system separately from the transcript. `Toolnexus.Client.translate/3` calls this internally when `style: "anthropic"` — it’s exposed directly for callers who need the conversion without also making a provider call. ## When to use it [Section titled “When to use it”](#when-to-use-it) * **You’re proxying OpenAI-shaped requests to an Anthropic-style upstream** and need the transcript itself, not just [`Client.translate/3`](/api/elixir/translate/translate/)’s single-call result — e.g. building the request body for a different transport, or inspecting the converted shape before sending it. * **You’re storing transcripts in OpenAI shape** (the common wire format) but need to hand one to an Anthropic-native client or SDK. * **You’re testing tool-call round-tripping** across the two shapes without touching the network. ## Why this and not the alternative [Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative) Reach for Client.translate/3 for the everyday path `openai_messages_to_anthropic/1` is the pure conversion — no HTTP, no client, just data in, data out. [`Toolnexus.Client.translate/3`](/api/elixir/translate/translate/) is what application code usually wants: it calls this function for you when `style: "anthropic"`, then actually makes the call and hands back an OpenAI-shaped `Toolnexus.Translate.Result`. Reach for this function directly when you need the converted transcript itself, decoupled from any network call. ## Examples [Section titled “Examples”](#examples) ### 1. The smallest useful call — a plain user/assistant exchange, system hoisted out [Section titled “1. The smallest useful call — a plain user/assistant exchange, system hoisted out”](#1-the-smallest-useful-call--a-plain-userassistant-exchange-system-hoisted-out) ```elixir alias Toolnexus.Translate messages = [ %{"role" => "system", "content" => "You are terse."}, %{"role" => "user", "content" => "capital of France?"}, %{"role" => "assistant", "content" => "Paris."} ] {converted, system} = Translate.openai_messages_to_anthropic(messages) true = system == "You are terse." true = converted == [ %{"role" => "user", "content" => "capital of France?"}, %{"role" => "assistant", "content" => [%{"type" => "text", "text" => "Paris."}]} ] IO.puts("ok: system hoisted (#{system}), #{length(converted)} message(s) converted") ``` ### 2. The realistic case — a tool call and its result, correctly re-shaped [Section titled “2. The realistic case — a tool call and its result, correctly re-shaped”](#2-the-realistic-case--a-tool-call-and-its-result-correctly-re-shaped) The assistant’s `tool_calls` become a `tool_use` block with `input` parsed back into a map; the matching `tool`-role result becomes a `tool_result` block keyed by `tool_call_id`. ```elixir alias Toolnexus.Translate messages = [ %{"role" => "user", "content" => "weather in Chennai?"}, %{ "role" => "assistant", "content" => nil, "tool_calls" => [ %{"id" => "c1", "type" => "function", "function" => %{"name" => "lookup_weather", "arguments" => ~s({"city":"Chennai"})}} ] }, %{"role" => "tool", "tool_call_id" => "c1", "content" => "28C, humid"} ] {converted, system} = Translate.openai_messages_to_anthropic(messages) true = system == "" [user1, assistant, user2] = converted true = user1 == %{"role" => "user", "content" => "weather in Chennai?"} true = assistant == %{ "role" => "assistant", "content" => [%{"type" => "tool_use", "id" => "c1", "name" => "lookup_weather", "input" => %{"city" => "Chennai"}}] } true = user2 == %{"role" => "user", "content" => [%{"type" => "tool_result", "tool_use_id" => "c1", "content" => "28C, humid"}]} IO.puts("ok: tool_calls -> tool_use, tool result -> tool_result, #{length(converted)} turn(s)") ``` ### 3. The full surface — multiple parallel tool results merge into ONE user turn [Section titled “3. The full surface — multiple parallel tool results merge into ONE user turn”](#3-the-full-surface--multiple-parallel-tool-results-merge-into-one-user-turn) This is the case a flattening translator gets wrong: two consecutive `tool`-role messages (parallel tool calls answered in the same turn) merge into a **single** Anthropic user turn carrying both `tool_result` blocks, in order — not two separate user turns. ```elixir alias Toolnexus.Translate messages = [ %{"role" => "developer", "content" => "Be exact."}, %{"role" => "user", "content" => "compare weather in Chennai and Paris"}, %{ "role" => "assistant", "content" => nil, "tool_calls" => [ %{"id" => "c1", "type" => "function", "function" => %{"name" => "weather", "arguments" => ~s({"city":"Chennai"})}}, %{"id" => "c2", "type" => "function", "function" => %{"name" => "weather", "arguments" => ~s({"city":"Paris"})}} ] }, %{"role" => "tool", "tool_call_id" => "c1", "content" => "28C"}, %{"role" => "tool", "tool_call_id" => "c2", "content" => "12C"}, %{"role" => "assistant", "content" => "Chennai is warmer."} ] {converted, system} = Translate.openai_messages_to_anthropic(messages) # "developer" is hoisted the same as "system" true = system == "Be exact." true = length(converted) == 4 [_user, assistant_calls, merged_results, final] = converted true = length(assistant_calls["content"]) == 2 true = merged_results == %{ "role" => "user", "content" => [ %{"type" => "tool_result", "tool_use_id" => "c1", "content" => "28C"}, %{"type" => "tool_result", "tool_use_id" => "c2", "content" => "12C"} ] } true = final == %{"role" => "assistant", "content" => [%{"type" => "text", "text" => "Chennai is warmer."}]} IO.puts("ok: 2 consecutive tool results merged into #{length(merged_results["content"])}-block user turn (not 2 turns)") ``` ## Fields [Section titled “Fields”](#fields) | | Type | What it is | | ------------------- | ------------ | ---------------------------------------------------------------------------------------- | | `messages` (arg) | `[map()]` | OpenAI-shaped `messages`, string-keyed. | | `messages` (return) | `[map()]` | Anthropic-native `messages` — `tool_use`/`tool_result` blocks, merged where consecutive. | | `system` (return) | `String.t()` | Every `system`/`developer` message’s text, joined with `"\n\n"`. | ## See also [Section titled “See also”](#see-also) * [`Toolnexus.Client.translate`](/api/elixir/translate/translate/) — the entry point that calls this internally, then makes the single provider call. # Toolnexus.Client.translate > Declare a toolkit to a provider and translate one request/response without executing anything or keeping state. — Elixir. **Elixir** · package `toolnexus` · [SPEC §11](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [elixir/lib/toolnexus/client.ex](https://github.com/muthuishere/toolnexus/blob/main/elixir/lib/toolnexus/client.ex) ```elixir @spec translate(Toolnexus.Client.t(), [map()], keyword()) :: Toolnexus.Translate.Result.t() def translate(client, messages, opts \\ []) # messages: OpenAI `messages`, taken verbatim # opts: :tools (OpenAI `tools`, verbatim), :toolkit (declared, never executed, composes with :tools), # :tool_choice, :system (overrides the system prompt), :max_tokens ``` The entry point is Client.translate, not Toolnexus.Translate.translate `Toolnexus.Translate` (`elixir/lib/toolnexus/translate.ex`) holds the pure translation helpers — `openai_messages_to_anthropic/1`, `tool_calls_of/1`, and friends — but there is no `Toolnexus.Translate.translate/2`. The entry point that makes exactly one provider call lives on `Toolnexus.Client`, the same struct `run/4` and `stream/4` use. Single-turn translation (§11): exactly **one** provider call, always returned in **OpenAI shape** regardless of the client’s `:style`. No agent loop, no tool execution, no conversation state — every call is self-contained, so `translate/3` may be called statelessly and concurrently. A `:toolkit` in `opts` is **declared** to the provider and never executed — this is the inbound half of the §5 adapters: `to_anthropic/1`/`to_gemini/1` send declarations out, `translate/3` reads the provider’s tool calls back in, for a caller that owns the conversation and executes tools itself (the standard OpenAI function-calling posture). ## When to use it [Section titled “When to use it”](#when-to-use-it) * **You already have your own agent loop** (or you’re behind a proxy that expects OpenAI-shaped responses) and just want toolnexus to make one provider call and hand back a normalized result — you drive tool execution and the next turn yourself. * **You’re building an OpenAI-compatible endpoint over an Anthropic (or other) upstream** — send OpenAI `messages`/`tools` in, translate one turn, get OpenAI `tool_calls`/`finish_reason` out, no matter which `:style` the client is configured for. * **You want tool declarations validated/echoed without ever running them** — `:toolkit` composes with `:tools` for this. ## Why this and not the alternative [Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative) Reach for run/4 when toolnexus should own the conversation `translate/3` makes exactly one call and never executes a tool — the caller is the loop. [`Toolnexus.Client.run/4`](/api/elixir/client/run/) is the opposite: toolnexus owns the whole loop (system prompt, tool execution, multi-turn continuation, suspension via §10). Reach for `run/4` when you want an answer; reach for `translate/3` when you want ONE normalized request/response and full control of what happens next. ## Examples [Section titled “Examples”](#examples) ### 1. The smallest useful call — no tools, an OpenAI-style upstream [Section titled “1. The smallest useful call — no tools, an OpenAI-style upstream”](#1-the-smallest-useful-call--no-tools-an-openai-style-upstream) `http_options: [plug: ...]` stubs the wire call in-process — the same pattern [`Client.run`](/api/elixir/client/run/) uses — no network, no real API key. ```elixir alias Toolnexus.Client plug = fn conn -> {:ok, _raw, conn} = Plug.Conn.read_body(conn) resp = %{ "choices" => [%{"message" => %{"role" => "assistant", "content" => "Paris"}, "finish_reason" => "stop"}], "usage" => %{"prompt_tokens" => 6, "completion_tokens" => 1, "total_tokens" => 7} } conn |> Plug.Conn.put_resp_content_type("application/json") |> Plug.Conn.send_resp(200, Jason.encode!(resp)) end client = Client.create(base_url: "http://localhost", style: "openai", model: "gpt-x", api_key: "test-key", http_options: [plug: plug]) result = Client.translate(client, [%{"role" => "user", "content" => "capital of France?"}]) true = result.text == "Paris" true = result.tool_calls == [] true = result.finish_reason == "stop" true = result.model == "gpt-x" true = result.usage.total_tokens == 7 IO.puts("ok: #{result.text} (#{result.finish_reason}, #{result.usage.total_tokens} tokens)") ``` ### 2. The realistic case — a declared toolkit, tool calls come back untouched [Section titled “2. The realistic case — a declared toolkit, tool calls come back untouched”](#2-the-realistic-case--a-declared-toolkit-tool-calls-come-back-untouched) The model asks for a tool; `translate/3` never runs it — it hands the call back for the caller to execute. ```elixir alias Toolnexus.{Client, Tool} lookup = %Tool{ name: "lookup_weather", description: "look up the weather", input_schema: %{"type" => "object", "properties" => %{"city" => %{"type" => "string"}}}, source: "native", execute: fn _args, _ctx -> raise "never called by translate/3" end } plug = fn conn -> {:ok, raw, conn} = Plug.Conn.read_body(conn) body = Jason.decode!(raw) # The tool WAS declared to the provider — never executed locally. true = Enum.any?(body["tools"], &(get_in(&1, ["function", "name"]) == "lookup_weather")) resp = %{ "choices" => [%{ "message" => %{"role" => "assistant", "content" => nil, "tool_calls" => [ %{"id" => "c1", "type" => "function", "function" => %{"name" => "lookup_weather", "arguments" => ~s({"city":"Chennai"})}} ]}, "finish_reason" => "tool_calls" }], "usage" => %{"prompt_tokens" => 8, "completion_tokens" => 5, "total_tokens" => 13} } conn |> Plug.Conn.put_resp_content_type("application/json") |> Plug.Conn.send_resp(200, Jason.encode!(resp)) end client = Client.create(base_url: "http://localhost", style: "openai", model: "gpt-x", api_key: "test-key", http_options: [plug: plug]) result = Client.translate(client, [%{"role" => "user", "content" => "weather in Chennai?"}], toolkit: [lookup]) true = result.text == "" true = result.finish_reason == "tool_calls" [call] = result.tool_calls true = call.name == "lookup_weather" true = call.arguments == ~s({"city":"Chennai"}) IO.puts("ok: declared #{length([lookup])} tool(s), got back #{length(result.tool_calls)} call(s) — none executed") ``` ### 3. The full surface — an Anthropic-style upstream, same OpenAI-shaped result [Section titled “3. The full surface — an Anthropic-style upstream, same OpenAI-shaped result”](#3-the-full-surface--an-anthropic-style-upstream-same-openai-shaped-result) Set `style: "anthropic"` on the client and `translate/3` still returns the same `Toolnexus.Translate.Result` shape — the translation, not just the call, is the point. ```elixir alias Toolnexus.Client plug = fn conn -> {:ok, raw, conn} = Plug.Conn.read_body(conn) body = Jason.decode!(raw) true = body["system"] == "be concise" resp = %{ "content" => [ %{"type" => "text", "text" => "checking the weather"}, %{"type" => "tool_use", "id" => "tu_1", "name" => "lookup_weather", "input" => %{"city" => "Chennai"}} ], "stop_reason" => "tool_use", "usage" => %{"input_tokens" => 10, "output_tokens" => 6} } conn |> Plug.Conn.put_resp_content_type("application/json") |> Plug.Conn.send_resp(200, Jason.encode!(resp)) end client = Client.create( base_url: "http://localhost", style: "anthropic", model: "claude-x", api_key: "test-key", http_options: [plug: plug] ) result = Client.translate(client, [%{"role" => "user", "content" => "weather in Chennai?"}], system: "be concise", max_tokens: 512) true = result.text == "checking the weather" true = result.finish_reason == "tool_calls" [call] = result.tool_calls true = call.id == "tu_1" true = call.name == "lookup_weather" true = call.arguments == Jason.encode!(%{"city" => "Chennai"}) true = result.usage == %{prompt_tokens: 10, completion_tokens: 6, total_tokens: 16} IO.puts("ok: anthropic upstream, OpenAI-shaped result — #{result.text}, #{length(result.tool_calls)} call(s)") ``` ## `opts` fields [Section titled “opts fields”](#opts-fields) | Field | Type | What it is | | -------------- | --------------- | ----------------------------------------------------------------------------------------- | | `:tools` | `[map()]` | OpenAI `tools` array, sent verbatim (declaration-only). | | `:toolkit` | toolkit | An ordinary toolkit, declared to the provider and never executed; composes with `:tools`. | | `:tool_choice` | `term()` | OpenAI `tool_choice`, translated per `:style`. | | `:system` | `String.t()` | Overrides the client’s `system_prompt` for this call. | | `:max_tokens` | `pos_integer()` | Overrides the per-provider default (Anthropic requires one; default `4096`). | ## `Toolnexus.Translate.Result` shape [Section titled “Toolnexus.Translate.Result shape”](#toolnexustranslateresult-shape) | Field | Type | What it is | | --------------- | --------------------------------------------------- | ---------------------------------------------------------------------- | | `text` | `String.t()` | Assistant text (`""` when the model only called tools). | | `tool_calls` | `[Toolnexus.Translate.ToolCall.t()]` | `%{id, name, arguments}`, in provider order — none ever dropped. | | `finish_reason` | `String.t()` | `"stop" \| "tool_calls" \| "length" \| "content_filter"`. | | `usage` | `%{prompt_tokens, completion_tokens, total_tokens}` | This single call’s usage. | | `model` | `String.t()` | The model that answered. | | `raw` | `term()` | The provider’s decoded response, for fields this struct doesn’t model. | ## See also [Section titled “See also”](#see-also) * [`Toolnexus.Translate.openai_messages_to_anthropic`](/api/elixir/translate/inbound/) — Convert an OpenAI-shaped transcript into Anthropic shape, including the tool-result merging a flattening translator gets wrong. * [`Toolnexus.Client.run`](/api/elixir/client/run/) — the multi-turn loop, for when toolnexus should own the conversation and execute tools itself. # from_path > 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. — Elixir. **Elixir** · package `toolnexus` · [SPEC §1B](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) ```elixir # The generic constructor every typed helper below dispatches through: @spec new(String.t(), term(), keyword()) :: {:ok, t()} | {:error, Exception.t()} def new(type, source, opts \\ []) # type: "image" | "file" | "audio" @spec new!(String.t(), term(), keyword()) :: t() def new!(type, source, opts \\ []) # raises Toolnexus.ContentPart.Error on failure # Per-type sugar over new/new! — this is what callers actually reach for: ContentPart.image(source, opts \\ []) / ContentPart.image!(source, opts \\ []) ContentPart.file(source, opts \\ []) / ContentPart.file!(source, opts \\ []) ContentPart.audio(source, opts \\ []) / ContentPart.audio!(source, opts \\ []) ContentPart.text(binary) # always succeeds # opts: :mime_type (required for bytes/iodata/enumerables/URL, overrides the extension # table for a path or a File.Stream), :name, :max_part_bytes (cap on DECODED bytes) ``` 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. Elixir does not expose separate `from_path/1`, `from_bytes/2`, `from_data_url/1` and `from_url/1` functions as public API — those are private clauses of `build/3` (`elixir/lib/toolnexus/content_part.ex:169-290`) that `new/3` dispatches to by inspecting the shape of `source`: a `data:` prefix, an `http(s)://` prefix, `{:bytes, binary}`, iodata (a proper or improper list), a `File.Stream`, any other `Enumerable`, or — falling through all of those — a bare binary treated as a filesystem path. The public surface a caller actually reaches for is the generic `new/3` / `new!/3` (`content_part.ex:169,183`) plus the three typed conveniences that wrap them one per part type: `image/2` / `image!/2`, `file/2` / `file!/2`, `audio/2` / `audio!/2` (`content_part.ex:124-148`), and the always-succeeding `text/1` (`content_part.ex:124`) for a plain string part. `byte_size_of/1` (`content_part.ex:390`) reads back the decoded byte size of whatever a non-text part is carrying, for a caller that wants to price or cap it after the fact. A bare binary is **always** read as a path (or a `data:`/`https:` URL) — never as raw bytes — so raw content must be passed explicitly tagged as `{:bytes, binary}`; see the `Toolnexus.ContentPart` module doc for why (`"abc"` is both a plausible filename and plausible bytes, and guessing would silently turn a caller’s file into its own name). ## When to use it [Section titled “When to use it”](#when-to-use-it) * **Attaching an image, PDF or audio clip to a prompt** — `ContentPart.image!(path)` (or `file!`/`audio!`) reads the bytes, base64-encodes them, and fills `mime_type` from the fixed extension table (SPEC §6) at construction time; pass a list of parts as the prompt instead of a plain string. * **You already hold the bytes, an iolist, or a stream, not a path** — pass `{:bytes, bin}` (with a required `:mime_type`), any iodata, or a `File.Stream`/`Enumerable`; every shape is consumed **eagerly**, so what lands in the struct is always already-decoded bytes plus a mime type, never an unread handle. * **The source is a remote URL** — `https://...` is kept as `url` (never downloaded), and `data:;base64,` is decoded into `{mime_type, data}` immediately — both dispatch through the same `new/3`/`image!` call, no separate function to remember. * **You need failure to be a value, not a crash** — use the non-`!` form (`new/3`, `image/2`, …), which returns `{:ok, part} | {:error, exception}`; reach for the `!` form only where a construction failure should stop the caller outright. ## Why this and not the alternative [Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative) A ContentPart never holds a path — even though the constructors accept one The constructors accept a path as input, but the struct that comes out never carries one: the bytes are read and base64-encoded **at construction time**, then the path is discarded. A part holding `%{path: "shot.png"}` would resolve to nothing (or the wrong file) once the transcript is persisted, replayed, handed to a sub-agent, or served over `toolkit.serve/2` — the path only ever made sense inside the process that first saw it. “Accept broadly, store narrowly” is the rule: whatever shape the caller hands in, what lands in the part is always bytes (or a `url`) plus a `mime_type`. `:on_unsupported_part` is a separate, client-level concern from these constructors — it decides what happens at request-assembly time to a part whose type the target provider style cannot represent, not at construction time. It is a [`Toolnexus.Client.create/1`](/api/elixir/client/create/) option (`elixir/lib/toolnexus/client.ex:290`, documented at `:337`, consumed at `:1623`), taking `"error"` or `"text"`. Absent, the default follows the part’s *provenance* (SPEC §8A): a part the caller attached directly to a prompt errors before any HTTP call is made (a caller-authored mistake should fail loudly), while a part a **tool result** produced degrades to a text placeholder with a warn-once (a model-driven tool call should not crash the whole run over one unsupported attachment). Setting `on_unsupported_part: "text"` forces the text-placeholder behavior everywhere, overriding that provenance-based default for BOTH origins. ## Examples [Section titled “Examples”](#examples) ### 1. The smallest useful call — attach an image built from raw bytes [Section titled “1. The smallest useful call — attach an image built from raw bytes”](#1-the-smallest-useful-call--attach-an-image-built-from-raw-bytes) ```elixir alias Toolnexus.ContentPart part = ContentPart.image!({:bytes, "\x89PNG\r\n"}, mime_type: "image/png") true = part.type == "image" true = part.mime_type == "image/png" true = is_binary(part.data) true = is_nil(part.url) IO.puts("ok: built a #{part.type} part, #{ContentPart.byte_size_of(part)} decoded bytes") ``` ### 2. The realistic case — a prompt mixing text and an attached file, sent to the model [Section titled “2. The realistic case — a prompt mixing text and an attached file, sent to the model”](#2-the-realistic-case--a-prompt-mixing-text-and-an-attached-file-sent-to-the-model) ```elixir alias Toolnexus.{Client, ContentPart} pdf = ContentPart.file!({:bytes, "%PDF-1.4 fake"}, mime_type: "application/pdf", name: "report.pdf") plug = fn conn -> {:ok, raw, conn} = Plug.Conn.read_body(conn) body = Jason.decode!(raw) user_msg = Enum.find(body["messages"], &(&1["role"] == "user")) # the file part reached the wire as a file-content block, not a bare string true = is_list(user_msg["content"]) true = Enum.any?(user_msg["content"], &(&1["type"] == "file")) resp = %{"choices" => [%{"message" => %{"role" => "assistant", "content" => "got the file"}}], "usage" => %{"prompt_tokens" => 3, "completion_tokens" => 2, "total_tokens" => 5}} conn |> Plug.Conn.put_resp_content_type("application/json") |> Plug.Conn.send_resp(200, Jason.encode!(resp)) end client = Client.create(base_url: "http://localhost", style: "openai", model: "gpt-x", api_key: "test-key", http_options: [plug: plug]) result = Client.run(client, [ContentPart.text("summarize this"), pdf], []) true = result.text == "got the file" IO.puts("ok: #{result.text}") ``` ### 3. The full surface — a data: URL, a remote URL, and an unsupported-part fallback from a tool [Section titled “3. The full surface — a data: URL, a remote URL, and an unsupported-part fallback from a tool”](#3-the-full-surface--a-data-url-a-remote-url-and-an-unsupported-part-fallback-from-a-tool) ```elixir alias Toolnexus.{Client, Context, Tool, ToolResult, ContentPart} golden = Base.encode64("\x89PNG\r\n") data_url_part = ContentPart.image!("data:image/png;base64," <> golden) true = data_url_part.data == golden true = is_nil(data_url_part.url) remote_part = ContentPart.image!("https://example.com/a.png", mime_type: "image/png") true = remote_part.url == "https://example.com/a.png" true = is_nil(remote_part.data) # A tool that returns audio via anthropic (which has no audio block) degrades to text # instead of erroring, because the part's PROVENANCE is a tool result, not a direct attach. clip_tool = %Tool{ name: "record_clip", description: "returns a recorded audio clip", input_schema: %{"type" => "object", "properties" => %{}}, source: "native", execute: fn _args, %Context{} -> %ToolResult{output: "recorded", parts: [ContentPart.audio!({:bytes, "ID3"}, mime_type: "audio/mpeg")]} end } plug = fn conn -> {:ok, _raw, conn} = Plug.Conn.read_body(conn) resp = %{"content" => [%{"type" => "tool_use", "id" => "c1", "name" => "record_clip", "input" => %{}}], "usage" => %{"input_tokens" => 1, "output_tokens" => 1}} conn |> Plug.Conn.put_resp_content_type("application/json") |> Plug.Conn.send_resp(200, Jason.encode!(resp)) end client = Client.create( base_url: "http://localhost", style: "anthropic", model: "claude-x", api_key: "test-key", max_turns: 1, http_options: [plug: plug] ) result = Client.run(client, "record a clip", [clip_tool]) # max_turns: 1 stops right after the tool call, so we only need the run not to raise — # an unsupported audio part on anthropic degrades to text rather than erroring by default. true = result.status in ["done", "incomplete"] IO.puts("ok: data-url=#{data_url_part.mime_type}, remote=#{remote_part.url}, run status=#{result.status}") ``` ## See also [Section titled “See also”](#see-also) * [`Toolnexus.Tool`](/api/elixir/types/tool/) — The uniform shape every tool source collapses to: name, description, JSON-Schema parameters, execute. * [`Toolnexus.ToolResult`](/api/elixir/types/tool-result/) — The result envelope: output text, optional error flag, optional non-text parts, and optional metadata that can carry a suspension. * [`Toolnexus.ContentPart`](/api/elixir/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. * [`Toolnexus.Context`](/api/elixir/types/context/) — Optional per-call context handed to execute: cancellation, identity, and host-supplied state. # Toolnexus.ContentPart > The non-text half of a message: text | image | file | audio, carrying base64 bytes or a URL plus a mimeType — never a path. — Elixir. **Elixir** · package `toolnexus` · [SPEC §1B](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [elixir/lib/toolnexus/content\_part.ex](https://github.com/muthuishere/toolnexus/blob/main/elixir/lib/toolnexus/content_part.ex) ```elixir defmodule Toolnexus.ContentPart do defstruct [:type, :text, :mime_type, :data, :url, :name] end # Edge constructors — each has a {:ok, part} | {:error, exception} form and a raising ! form. ContentPart.text("what is in this image?") ContentPart.image!("shot.png") # path → bytes, base64d now ContentPart.image!({:bytes, bin}, mime_type: "image/png") # bytes → base64d now ContentPart.image!(["chunk", [?a | "bc"]], mime_type: "image/png") # iodata ContentPart.image!(File.stream!("shot.png", 2048)) # stream → consumed eagerly ContentPart.image!("data:image/png;base64,iVBOR…") # data: → {mime_type, data} ContentPart.image!("https://example.com/a.png") # https → kept as a url ContentPart.file!("report.pdf") ContentPart.audio!("clip.mp3") ``` The non-text half of a message: `text | image | file | audio`. A non-text part carries a `mime_type` (spelled `mimeType` on the wire) plus **exactly one** of `data` — standard base64, padded, no line breaks — or `url`. Both, or neither, is a typed construction error. You attach parts to a run (`Toolnexus.Client.run/3` takes a list of parts in the prompt position), and tools return them on [`Toolnexus.ToolResult`](/api/elixir/types/tool-result/)`.parts`. ## When to use it [Section titled “When to use it”](#when-to-use-it) * **Sending an image, a PDF or an audio clip to the model** — build the part, pass a list of parts instead of a prompt string. `Toolnexus.ContentPart.encode/3` maps each part onto the provider’s block shape (`openai` or `anthropic`) under a positive allowlist, so a part that has no shape on that provider never silently vanishes into an HTTP 200. * **Returning one from a tool** — a screenshot tool sets `output` to a description and `parts` to the image. The built-in `read` already does exactly this for a recognised media file. * **Reading one back** — `describe/1` and `summary/1` render a part as `{type, mimeType, bytes}` without ever touching `data`, and `estimated_tokens/1` prices it for the compactor. ## 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 — that is the whole design The obvious shape is `%{path: "shot.png"}`, and it works right up until the transcript leaves the process. A transcript is persisted by a `ConversationStore`, replayed hours later, handed to a subagent, served over `toolkit.serve/2`, or forwarded to an A2A peer — and on the far side of any of those, `"shot.png"` resolves to nothing, or worse, to a different file. So the edge constructors read and base64-encode **at construction**: the path is used, then discarded. A `File.Stream` is consumed eagerly for the same reason — a part holding a half-read stream survives the transcript boundary no better than a path does. Two deliberately Elixir-shaped decisions **`x/1` and `x!/1` come as a pair**, mirroring `Toolnexus.create_toolkit/1` and `create_toolkit!/1`. The bang form is what you write inline — `ContentPart.image!("shot.png")` in the middle of a list literal, where an unreadable file is a bug and should crash. The non-bang form is what a *tool implementation* uses: it is inside `execute`, where the error is an outcome the model can recover from, so `{:error, e}` becomes `ToolResult.error(Exception.message(e))` rather than a raise that ends the run. **Raw bytes stay explicitly tagged as `{:bytes, bin}`.** In Elixir a bare binary is genuinely ambiguous — `"abc"` is both a plausible filename and plausible file content — and there is no type to tell them apart. A bare binary is therefore *always* a path (or a `data:`/`https:` URL), never content: guessing wrong would silently turn a caller’s file into its own filename, and send the model the string `"shot.png"` where it expected a picture. An iolist has no such ambiguity (a path is never a list), so iodata is accepted unwrapped. ## 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) The smallest useful pair: what you would hand a vision model. The image’s `data` is asserted against the committed golden in `examples/media/` — the same fixture every port checks against. ```elixir alias Toolnexus.ContentPart # TOOLNEXUS_REPO is set by the docs test runner; in your own code just use a path. repo = System.get_env("TOOLNEXUS_REPO") || "." fixture = Path.join(repo, "examples/media/fixture.png") golden = repo |> Path.join("examples/media/fixture.png.base64") |> File.read!() |> String.trim() question = ContentPart.text("What is in this image?") image = ContentPart.image!(fixture) true = question.type == "text" true = image.type == "image" # The extension decided the mime type, from a fixed table — never sniffed from content. true = image.mime_type == "image/png" # The bytes were read and base64d at construction, matching the committed golden. true = image.data == golden true = image.url == nil # And the path is gone: nothing in the wire shape points back at the filesystem. wire = ContentPart.to_map(image) true = Enum.sort(Map.keys(wire)) == ["data", "mimeType", "type"] # The data-free rendering — this exact string is byte-identical in all eight ports. true = ContentPart.summary(image) == "image (image/png, 82 bytes)" IO.puts("ok: #{ContentPart.summary(image)}") ``` ### 2. Every source Elixir users already hold [Section titled “2. Every source Elixir users already hold”](#2-every-source-elixir-users-already-hold) *Accept broadly, store narrowly.* A path, tagged bytes, an iolist (nested or improper), a `File.Stream`, a `data:` URL — six ways in, one shape out. ```elixir alias Toolnexus.ContentPart repo = System.get_env("TOOLNEXUS_REPO") || "." fixture = Path.join(repo, "examples/media/fixture.png") golden = repo |> Path.join("examples/media/fixture.png.base64") |> File.read!() |> String.trim() bin = File.read!(fixture) <> = bin <> = bin parts = [ # a filesystem path — mime from the fixed extension table ContentPart.image!(fixture), # raw bytes, explicitly tagged so they can never be mistaken for a filename ContentPart.image!({:bytes, bin}, mime_type: "image/png"), # a nested iolist, exactly as it fell out of whatever produced it ContentPart.image!([head, [tail]], mime_type: "image/png"), # an improper iolist — Enum cannot even walk this one; IO.iodata_to_binary/1 can ContentPart.image!([first_byte | rest], mime_type: "image/png"), # a File.Stream: consumed eagerly, mime taken from the stream's own path ContentPart.image!(File.stream!(fixture, 16)), # a data: URL normalises into {mime_type, data} — it is never kept as a url ContentPart.image!("data:image/png;base64," <> golden) ] true = Enum.all?(parts, &(&1.data == golden)) true = Enum.all?(parts, &(&1.mime_type == "image/png")) true = Enum.all?(parts, &(&1.url == nil)) # No part carries a stream, a path, or anything else that would not survive being # persisted by a ConversationStore and replayed in another process. true = Enum.all?(parts, fn p -> Enum.sort(Map.keys(ContentPart.to_map(p))) == ["data", "mimeType", "type"] end) # A remote asset is the one case that stays a reference rather than becoming bytes. remote = ContentPart.image!("https://example.com/a.png", mime_type: "image/png") true = remote.url == "https://example.com/a.png" true = remote.data == nil IO.puts("ok: #{length(parts)} sources → one shape (#{ContentPart.summary(hd(parts))})") ``` ### 3. The failure modes, and both call forms [Section titled “3. The failure modes, and both call forms”](#3-the-failure-modes-and-both-call-forms) Everything that is refused, and why each refusal is better than the guess it replaces. ```elixir alias Toolnexus.ContentPart alias Toolnexus.ContentPart.Error repo = System.get_env("TOOLNEXUS_REPO") || "." fixture = Path.join(repo, "examples/media/fixture.png") bin = File.read!(fixture) # 1. Exactly one of :data / :url. Both — or neither — is a typed error, not a coin flip. both = %ContentPart{type: "image", mime_type: "image/png", data: "x", url: "https://example.com/a.png"} {:error, %Error{message: both_msg}} = ContentPart.validate(both) true = String.contains?(both_msg, "both") {:error, %Error{message: neither_msg}} = ContentPart.validate(%ContentPart{type: "image"}) true = String.contains?(neither_msg, "neither") # 2. An unknown extension is refused BY NAME. Mime is never sniffed and never read from a # platform mime database — /etc/mime.types varies per machine and would break parity. odd = Path.join(System.tmp_dir!(), "note-#{System.unique_integer([:positive])}.xyz") File.write!(odd, "hello") {:error, %Error{message: ext_msg}} = ContentPart.file(odd) true = String.contains?(ext_msg, ".xyz") # ...and saying the type out loud is the way through. named = ContentPart.file!(odd, mime_type: "text/plain") true = named.mime_type == "text/plain" true = named.name == Path.basename(odd) File.rm!(odd) # 3. Bytes carry no extension, so they always need an explicit :mime_type. {:error, %Error{message: mime_msg}} = ContentPart.image({:bytes, bin}) true = String.contains?(mime_msg, ":mime_type") # 4. max_part_bytes measures DECODED bytes and fast-fails here at the edge. The same cap is # enforced again at request assembly, which is where the guarantee actually lives — a part # that arrived from an MCP server never passed through a constructor. {:error, %Error{}} = ContentPart.image(fixture, max_part_bytes: 10) {:ok, image} = ContentPart.image(fixture, max_part_bytes: 1024) # 5. The bang form is the same call, raising instead of returning. true = ContentPart.image!(fixture) == image raised = try do ContentPart.image!(fixture, max_part_bytes: 10) nil rescue e in Error -> Exception.message(e) end true = String.contains?(raised, "max_part_bytes") # 6. Token cost is byte-derived: max(85, decoded_bytes / 750). Never the mimeType string's # length, which would price a 5 MB image at ~3 tokens and make it uncompactable. true = ContentPart.estimated_tokens(image) == 85 true = ContentPart.estimated_tokens(ContentPart.text("hello world")) == 3 # 7. Logs and §9 events see {type, mimeType, bytes}. `data` is never rendered. true = ContentPart.describe(image) == %{"type" => "image", "mimeType" => "image/png", "bytes" => 82} IO.puts("ok: every refusal named, #{ContentPart.estimated_tokens(image)} estimated tokens") ``` ## Fields [Section titled “Fields”](#fields) | Field | Type | What it is | | ----------- | ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `type` | `String.t()` | `"text"` \| `"image"` \| `"file"` \| `"audio"`. A **string**, never an atom, so `from_map/1` round-trips an unknown wire type without `String.to_atom/1` on untrusted input. | | `text` | `String.t() \| nil` | The text of a `text` part. | | `mime_type` | `String.t() \| nil` | Spelled `mimeType` on the wire. Required alongside `data`. | | `data` | `String.t() \| nil` | Standard base64 (RFC 4648 §4), padded, no line breaks. Exactly one of `data` / `url`. | | `url` | `String.t() \| nil` | An `http:`/`https:` reference. A `data:` URL is normalised into `{mime_type, data}` instead. | | `name` | `String.t() \| nil` | Filename, mostly for `file` parts; defaulted from a path’s basename. | ## Functions [Section titled “Functions”](#functions) | Function | What it does | | ------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------ | | `ContentPart.text/1` | A text part. | | `ContentPart.image/2` · `image!/2` | An `image` part from any accepted source. | | `ContentPart.file/2` · `file!/2` | A `file` part from any accepted source. | | `ContentPart.audio/2` · `audio!/2` | An `audio` part from any accepted source. | | `ContentPart.new/3` · `new!/3` | The generic constructor the four above delegate to. | | `ContentPart.validate/1` | Check a hand-built part: exactly one of `data`/`url`, plus a `mime_type`. | | `ContentPart.from_map/1` · `to_map/1` | Wire map (string keys, `mimeType`) in and out. | | `ContentPart.part?/1` | True for a part or its wire map — false for a provider block. | | `ContentPart.byte_size_of/1` | Decoded byte length; `0` for a URL-backed part. | | `ContentPart.describe/1` | `{type, mimeType, bytes}` for logs and §9 events — never `data`. | | `ContentPart.summary/1` | `image (image/png, 82 bytes)` — the byte-identical one-liner used as `output` when a result has parts but no text. | | `ContentPart.estimated_tokens/1` | `max(85, decoded_bytes / 750)`, identical in every port. | | `ContentPart.to_block/2` | One part → one provider block, or `{:unsupported, reason}`. | | `ContentPart.encode/3` | A list of parts → provider blocks, applying the §8A provenance rule and the allowlist. | | `ContentPart.allowlist/1` | The positive block-type allowlist for a client style. | | `ContentPart.media_table/0` · `media_for_path/1` | The fixed extension → `{mimeType, type}` table (§6). | Options accepted by the constructors: `:mime_type` (required for bytes / iodata / enumerables, overrides the table for a path or a `File.Stream`), `:name`, and `:max_part_bytes` (a fast-fail cap on **decoded** bytes). ## See also [Section titled “See also”](#see-also) * [`Toolnexus.ToolResult`](/api/elixir/types/tool-result/) — where a tool’s parts ride back * [`Toolnexus.Tool`](/api/elixir/types/tool/) — The uniform shape every tool source collapses to: name, description, JSON-Schema parameters, execute. * [`Toolnexus.Context`](/api/elixir/types/context/) — Optional per-call context handed to execute: cancellation, identity, and host-supplied state. * [`Toolnexus.Builtin.tools`](/api/elixir/builtins/create/) — `read` returns a content part for a recognised media file # Toolnexus.Context > The per-call context handed to every tool — identity, cancellation, a timeout, and the answer that resolves a suspension. Elixir. **Elixir** · package `toolnexus` · [SPEC §1](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [elixir/lib/toolnexus/types.ex](https://github.com/muthuishere/toolnexus/blob/main/elixir/lib/toolnexus/types.ex) ```elixir defmodule Toolnexus.Context do defstruct [:session_id, :message_id, :agent, :call_id, :extra, :answer, :timeout, :signal] end ``` The second argument to every tool’s `execute`. Elixir’s context is **wider than the other ports’** — alongside the shared §1 trio (`signal`, `timeout`, `answer`) it carries call identity: `session_id`, `message_id`, `agent` and `call_id`. ## When to use it [Section titled “When to use it”](#when-to-use-it) * **`answer`** — you returned a suspension on a previous attempt and the host has now resolved it. * **`timeout`** — the caller’s deadline for this specific call. * **`signal`** — a `reference()` or `pid()` for cancellation. * **`session_id` / `message_id` / `agent` / `call_id`** — correlate a tool call with the conversation and agent that produced it. This is what makes per-call logging and tracing possible without threading your own state through every tool. * **`extra`** — host-supplied state you put there yourself. ## Why it is always passed [Section titled “Why it is always passed”](#why-it-is-always-passed) No nil-guards needed Unlike JavaScript and Go, where `ctx` is optional and may be absent entirely, the Elixir loop **always** passes a `%Context{}`. You can pattern-match fields directly. Individual fields still default to `nil`, so match on those — but the struct itself is always there. That difference matters when porting: code moved from Elixir to Go needs nil-guards added; code moved the other way can drop them. ## Examples [Section titled “Examples”](#examples) ### 1. Reading call identity [Section titled “1. Reading call identity”](#1-reading-call-identity) The identity fields are the reason to look at `ctx` even in a tool that does no I/O. ```elixir alias Toolnexus.{Tool, ToolResult, Context} whoami = %Tool{ name: "whoami", description: "Report the call's identity", input_schema: %{"type" => "object"}, source: "custom", execute: fn _args, ctx -> # Every field defaults to nil — match defensively even though ctx itself is always present. agent = ctx.agent || "anonymous" session = ctx.session_id || "no-session" ToolResult.ok("#{agent}@#{session}") end } # A bare context is valid. plain = whoami.execute.(%{}, %Context{}) true = plain.output == "anonymous@no-session" # The loop fills these in for real calls. identified = whoami.execute.(%{}, %Context{agent: "researcher", session_id: "s-42", call_id: "c-1"}) true = identified.output == "researcher@s-42" IO.puts("ok: #{plain.output} | #{identified.output}") ``` ### 2. Honouring cancellation and the caller’s timeout [Section titled “2. Honouring cancellation and the caller’s timeout”](#2-honouring-cancellation-and-the-callers-timeout) `timeout` is a `non_neg_integer()`; `nil` means “not set”, so fall back to your own default rather than treating it as zero. ```elixir alias Toolnexus.{Tool, ToolResult, Context} fetchish = %Tool{ name: "fetchish", description: "Pretend to fetch, bounded by the caller's timeout", input_schema: %{"type" => "object"}, source: "custom", execute: fn args, ctx -> # nil means unset — use your own default. budget = ctx.timeout || 30_000 cond do # signal is a reference() or pid(); treat a dead pid as cancelled. is_pid(ctx.signal) and not Process.alive?(ctx.signal) -> ToolResult.error("cancelled") budget < 100 -> ToolResult.error("budget #{budget}ms is too small to try") true -> ToolResult.ok("fetched #{args["url"]} within #{budget}ms") end end } generous = fetchish.execute.(%{"url" => "/a"}, %Context{timeout: 5000}) true = generous.output == "fetched /a within 5000ms" stingy = fetchish.execute.(%{"url" => "/a"}, %Context{timeout: 10}) true = stingy.is_error defaulted = fetchish.execute.(%{"url" => "/a"}, %Context{}) true = String.contains?(defaulted.output, "30000ms") # A pid that has already exited reads as cancelled. dead = spawn(fn -> :ok end) Process.sleep(10) cancelled = fetchish.execute.(%{"url" => "/a"}, %Context{signal: dead}) true = cancelled.is_error IO.puts("ok: #{generous.output} | #{stingy.output}") ``` ### 3. `answer` — the second half of a suspension [Section titled “3. answer — the second half of a suspension”](#3-answer--the-second-half-of-a-suspension) This is the field that makes the human-in-the-loop contract work. On the first call the tool returns a suspension. The host resolves it, then calls the **same tool again** with `ctx.answer` set. The tool branches on whether the answer is there. ```elixir alias Toolnexus.{Tool, ToolResult, Context, Request, Answer} deploy = %Tool{ name: "deploy", description: "Deploy, asking which environment first", input_schema: %{"type" => "object"}, source: "custom", execute: fn _args, ctx -> case ctx.answer do # First pass: park the run and ask. nil -> req = %Request{id: "pnd-deploy-1", kind: "input", prompt: "Which environment?"} %ToolResult{output: req.prompt, is_error: true, metadata: %{pending: req}} # Second pass: the host resolved the question and handed the answer back. %Answer{ok: false} = a -> ToolResult.error("declined: #{a.reason || "no reason"}") %Answer{ok: true} = a -> env = (a.data || %{})["env"] || "unknown" ToolResult.ok("deployed to #{env}") end end } # First pass — a suspension, not an answer. first = deploy.execute.(%{}, %Context{}) true = ToolResult.pending?(first) %{pending: req} = first.metadata true = req.kind == "input" # Second pass — the host supplies the resolution, echoing the request id. second = deploy.execute.(%{}, %Context{answer: %Answer{id: req.id, ok: true, data: %{"env" => "staging"}}}) false = second.is_error true = second.output == "deployed to staging" # A refusal is a normal outcome, not a crash. refused = deploy.execute.(%{}, %Context{answer: %Answer{id: req.id, ok: false, reason: "declined"}}) true = refused.is_error IO.puts("ok: #{second.output} | #{refused.output}") ``` Read answer.data only when the answer IS the payload For `kind: "input"` the value you asked for lives in `answer.data`. For `kind: "authorization"` the world changed out-of-band — the session is simply valid now — so you re-run the real work and ignore `data`. ## Fields [Section titled “Fields”](#fields) | Field | Type | What it is | | ------------ | ----------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `session_id` | `String.t() \| nil` | The conversation this call belongs to. | | `message_id` | `String.t() \| nil` | The message that triggered it. | | `agent` | `String.t() \| nil` | Which agent is calling — useful with sub-agents. | | `call_id` | `String.t() \| nil` | The model’s id for this tool call, set by the client loop. One message’s calls run concurrently, so this is how a tool tells its siblings apart. Also passed to `:wait_for_ctx`. | | `extra` | `map() \| nil` | Host-supplied state. | | `answer` | `Answer.t() \| nil` | Present **only** on a post-`wait_for` re-execution. | | `timeout` | `non_neg_integer() \| nil` | This call’s budget in ms. `nil` means unset. | | `signal` | `reference() \| pid() \| nil` | Cancellation. In `:wait_for_ctx` it is the pid running the turn — monitor it to end a pending ask with the turn. | ## See also [Section titled “See also”](#see-also) * [`Toolnexus.Tool`](/api/elixir/types/tool/) — what receives this * [`Toolnexus.ToolResult`](/api/elixir/types/tool-result/) — what `execute` returns * [`Toolnexus.Client`](/api/elixir/client/create/) — the loop that fills these fields in # Toolnexus.Tool > The uniform shape every tool source collapses to — name, description, JSON-Schema parameters, execute. Elixir. **Elixir** · package `toolnexus` · [SPEC §1](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [elixir/lib/toolnexus/types.ex](https://github.com/muthuishere/toolnexus/blob/main/elixir/lib/toolnexus/types.ex) ```elixir defmodule Toolnexus.Tool do @enforce_keys [:name, :description, :input_schema, :source, :execute] defstruct [:name, :description, :input_schema, :source, :execute] # execute is (args :: map, ctx :: Toolnexus.Context.t()) -> Toolnexus.ToolResult.t() end ``` The one struct. An MCP server tool, an agent skill, a built-in shell tool, a remote A2A agent, an HTTP endpoint and a plain function of your own are all *the same thing* to an LLM — a named, described, schema’d callable. `Toolnexus.Tool` is that thing, and every source produces it. ## When to use it [Section titled “When to use it”](#when-to-use-it) You mostly **receive** tools rather than construct them: `Toolnexus.Toolkit.tools/1` hands you a list, and that is what you `Enum.map` and pass to an adapter. Construct one directly when you are writing a **new tool source** — something producing tools from a shape toolnexus doesn’t already cover. For a single ordinary function, use [`Toolnexus.Native.define_tool/1`](/api/elixir/native/define-tool/) instead. ## Why this and not the alternative [Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative) All five keys are enforced `@enforce_keys` covers every field, so a struct missing `:source` or `:execute` raises at construction rather than failing later inside the loop. That is deliberate — there is no partially valid tool. `execute` is an arity-2 function, `(args, ctx)`. Unlike JavaScript and Go, **`ctx` is not optional** — the loop always passes a `%Toolnexus.Context{}`, so you can pattern-match on it directly without nil-guards. ## Examples [Section titled “Examples”](#examples) ### 1. The smallest possible tool [Section titled “1. The smallest possible tool”](#1-the-smallest-possible-tool) ```elixir alias Toolnexus.{Tool, ToolResult, Context} echo = %Tool{ name: "echo", description: "Return whatever it is given", input_schema: %{ "type" => "object", "properties" => %{"text" => %{"type" => "string"}}, "required" => ["text"] }, source: "custom", execute: fn args, _ctx -> %ToolResult{output: to_string(args["text"]), is_error: false} end } res = echo.execute.(%{"text" => "hello"}, %Context{}) ^res = %ToolResult{output: "hello", is_error: false, metadata: nil} true = res.output == "hello" false = res.is_error IO.puts("ok: #{res.output}") ``` `source` is not free-form — it is one of `"mcp"`, `"skill"`, `"native"`, `"http"`, `"builtin"`, `"a2a"`, `"custom"`. Use `"custom"` for tools you construct yourself. ### 2. Reporting failure, and carrying metadata [Section titled “2. Reporting failure, and carrying metadata”](#2-reporting-failure-and-carrying-metadata) A tool that fails does **not** raise — it returns `is_error: true`. The loop feeds that text back to the model, so it can react. `ToolResult.ok/1` and `ToolResult.error/1` are the shorthand. ```elixir alias Toolnexus.{Tool, ToolResult, Context} divide = %Tool{ name: "divide", description: "Divide two numbers", input_schema: %{ "type" => "object", "properties" => %{"a" => %{"type" => "number"}, "b" => %{"type" => "number"}}, "required" => ["a", "b"] }, source: "custom", execute: fn args, _ctx -> a = args["a"] / 1 b = args["b"] / 1 if b == 0 do # The model sees this text and can correct itself on the next turn. ToolResult.error("Cannot divide by zero") else %ToolResult{ output: to_string(a / b), is_error: false, metadata: %{title: "divide", operands: [a, b]} } end end } ok = divide.execute.(%{"a" => 10, "b" => 4}, %Context{}) true = ok.output == "2.5" true = ok.metadata.operands == [10.0, 4.0] bad = divide.execute.(%{"a" => 1, "b" => 0}, %Context{}) true = bad.is_error IO.puts("ok: #{ok.output} | error path: #{bad.output}") ``` ### 3. A generated tool source — the real reason this struct is public [Section titled “3. A generated tool source — the real reason this struct is public”](#3-a-generated-tool-source--the-real-reason-this-struct-is-public) Producing many tools from data is where you build the struct directly. `Tool.sanitize/1` makes each name schema-safe. ```elixir alias Toolnexus.{Tool, ToolResult, Context} endpoints = [ %{key: "get user", path: "/users/:id"}, %{key: "list orders", path: "/orders"} ] tools = Enum.map(endpoints, fn e -> %Tool{ # Names must match [a-zA-Z0-9_-]; sanitize/1 does exactly that. name: Tool.sanitize(e.key), description: "Call #{e.path}", input_schema: %{"type" => "object", "properties" => %{"id" => %{"type" => "string"}}}, source: "custom", execute: fn args, _ctx -> ToolResult.ok("#{e.path} <- #{args["id"]}") end } end) true = Enum.map(tools, & &1.name) == ["get_user", "list_orders"] res = hd(tools).execute.(%{"id" => "42"}, %Context{}) true = res.output == "/users/:id <- 42" IO.puts("ok: #{Enum.map_join(tools, ", ", & &1.name)}") ``` ## Fields [Section titled “Fields”](#fields) | Field | Type | What it is | | -------------- | ---------------------------------------- | ---------------------------------------------------------------------------------------- | | `name` | `String.t()` | The name the model calls. Must match `[a-zA-Z0-9_-]` — run it through `Tool.sanitize/1`. | | `description` | `String.t()` | What the model reads to decide whether to call it. | | `input_schema` | `map()` | A JSON-Schema **object**, string-keyed. | | `source` | `String.t()` | One of `mcp`, `skill`, `native`, `http`, `builtin`, `a2a`, `custom`. | | `execute` | `(map(), Context.t() -> ToolResult.t())` | Arity-2 function. `ctx` is **always** passed. | ## Functions [Section titled “Functions”](#functions) | Function | What it does | | --------------------------- | ---------------------------------------------------------------------- | | `Toolnexus.Tool.sanitize/1` | Replaces every character outside `[a-zA-Z0-9_-]` with `_` (SPEC §0.2). | ## See also [Section titled “See also”](#see-also) * [`Toolnexus.ToolResult`](/api/elixir/types/tool-result/) — what `execute` returns * [`Toolnexus.Context`](/api/elixir/types/context/) — what `execute` receives * [`Toolnexus.Native.define_tool`](/api/elixir/native/define-tool/) — build a tool from a function * [`Toolnexus.create_toolkit`](/api/elixir/toolkit/create/) — aggregate tools from every source # Toolnexus.ToolResult > The result envelope every tool returns — output text, an error flag, optional non-text parts, and optional metadata that can carry a suspension. Elixir. **Elixir** · package `toolnexus` · [SPEC §1](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [elixir/lib/toolnexus/types.ex](https://github.com/muthuishere/toolnexus/blob/main/elixir/lib/toolnexus/types.ex) ```elixir defmodule Toolnexus.ToolResult do defstruct output: "", is_error: false, parts: nil, metadata: nil end ``` What every `execute` returns. Four fields, and the whole tool-calling loop is built on them: `output` is the text handed back to the model, `is_error` says whether the call failed, `parts` carries non-text output (an image, a PDF, an audio clip), and `metadata` is free-form — except for one reserved key that turns a result into a suspension. **`output` stays required even when `parts` is present.** It is what the transcript, compaction, token estimation and any text-only provider see, so a tool returning an image sets `output` to a description (`"screenshot, 1280x720 png"`) and `parts` to the image. A result that sets no `parts` is byte-identical to one from before parts existed. Unlike `Toolnexus.Tool`, nothing here is enforced: every field has a default, so `%ToolResult{}` is valid and means “empty success”. ## When to use it [Section titled “When to use it”](#when-to-use-it) Every time you write a tool. It is the return type of [`Toolnexus.Tool`](/api/elixir/types/tool/)’s `execute`, so you construct one on every code path. ## Why an error flag and not a raise [Section titled “Why an error flag and not a raise”](#why-an-error-flag-and-not-a-raise) Raising is for bugs, is\_error is for outcomes A raised exception escapes the loop and ends the run. `is_error: true` is *fed back to the model as the tool’s result*, so it can read “file not found”, pick a different path, and carry on. Use the flag for anything the model could plausibly recover from; let genuine programming errors raise. `output` is always a `String.t()` — it is what the model reads. Serialize structured data yourself (`Jason.encode!/1`) rather than expecting the loop to do it. ## Examples [Section titled “Examples”](#examples) ### 1. Success and failure, with the shorthand constructors [Section titled “1. Success and failure, with the shorthand constructors”](#1-success-and-failure-with-the-shorthand-constructors) `ok/1` and `error/1` cover the two ordinary cases without writing the struct out. ```elixir alias Toolnexus.ToolResult config = %{"region" => "eu-west-1"} read_config = fn key -> case Map.fetch(config, key) do # Recoverable: the model can read this and try another key. :error -> ToolResult.error("No such config key: #{key}") {:ok, v} -> ToolResult.ok(v) end end found = read_config.("region") true = found.output == "eu-west-1" false = found.is_error missing = read_config.("nope") true = missing.is_error IO.puts("ok: #{found.output} | #{missing.output}") ``` ### 2. Structured output and metadata [Section titled “2. Structured output and metadata”](#2-structured-output-and-metadata) `output` must be a string, so serialize deliberately. `metadata` rides alongside for *your* code — the model never sees it, which makes it the right place for bookkeeping. ```elixir alias Toolnexus.ToolResult search = fn q -> hits = [%{id: 1, title: "Getting started"}, %{id: 2, title: "Advanced usage"}] %ToolResult{ # The model reads this. Make it legible, not just valid. output: Enum.map_join(hits, "\n", fn h -> "##{h.id} #{h.title}" end), is_error: false, # Your code reads this. The model never sees it. metadata: %{title: "search: #{q}", count: length(hits), ids: Enum.map(hits, & &1.id)} } end res = search.("usage") true = res.metadata.count == 2 true = res.metadata.ids == [1, 2] true = String.contains?(res.output, "Advanced usage") IO.puts("ok: #{res.metadata.title}") ``` ### 3. The reserved key — `metadata.pending` is a suspension [Section titled “3. The reserved key — metadata.pending is a suspension”](#3-the-reserved-key--metadatapending-is-a-suspension) `metadata` is free-form with one exception. A `:pending` key holding a `%Toolnexus.Request{}` means “this tool cannot finish until something out-of-band happens” — the loop parks the run instead of returning. `ToolResult.pending?/1` is how you detect it. ```elixir alias Toolnexus.{ToolResult, Request} # A suspension is a ToolResult whose metadata carries a Request. req = %Request{id: "pnd-1", kind: "input", prompt: "Which environment?"} res = %ToolResult{output: req.prompt, is_error: true, metadata: %{pending: req}} # A parked call is not a success. true = res.is_error true = ToolResult.pending?(res) # Read the request back off the result by matching the metadata. %{pending: %Request{} = got} = res.metadata true = got.kind == "input" true = got.prompt == "Which environment?" true = got.id == "pnd-1" # The authorization shape carries a URL instead of expecting typed input. auth_req = %Request{id: "pnd-2", kind: "authorization", prompt: "Log in", url: "https://example.com/login"} auth = %ToolResult{output: auth_req.prompt, is_error: true, metadata: %{pending: auth_req}} true = ToolResult.pending?(auth) true = auth.metadata.pending.url == "https://example.com/login" # An ordinary result has no suspension. false = ToolResult.pending?(ToolResult.ok("done")) IO.puts("ok: #{got.kind} | #{auth_req.kind}") ``` Elixir builds suspensions by hand The other ports ship producer helpers (`pending()`, `authRequired()`) and a reader (`pendingOf()`). Elixir currently exposes only the **reader** side, `ToolResult.pending?/1` — you construct the `%ToolResult{metadata: %{pending: %Request{}}}` shape yourself, including the `id`, which is the correlation key the host echoes back in the `Answer`. The wire shape is identical to every other port; only the convenience constructors are missing. ### 4. Non-text output — `parts` alongside `output` [Section titled “4. Non-text output — parts alongside output”](#4-non-text-output--parts-alongside-output) A tool that produces an image still owes the model a sentence. `output` is what the transcript and the compactor read; `parts` is what a vision-capable provider actually gets. ```elixir alias Toolnexus.{ContentPart, ToolResult} repo = System.get_env("TOOLNEXUS_REPO") || "." fixture = Path.join(repo, "examples/media/fixture.png") screenshot = fn -> case ContentPart.image(fixture) do # The non-bang form inside execute: an unreadable file is an outcome the model can # recover from, not a crash that ends the run. {:error, e} -> ToolResult.error("screenshot: #{Exception.message(e)}") {:ok, part} -> %ToolResult{output: ContentPart.summary(part), is_error: false, parts: [part]} end end res = screenshot.() false = res.is_error # output is never empty just because the payload is binary. true = res.output == "image (image/png, 82 bytes)" true = length(res.parts) == 1 true = hd(res.parts).mime_type == "image/png" # parts cannot collide with a §10 suspension: pending?/1 matches on metadata only, # so a result carrying an image is still an ordinary, finished result. false = ToolResult.pending?(res) true = res.metadata == nil IO.puts("ok: #{res.output}") ``` ## Fields [Section titled “Fields”](#fields) | Field | Type | Default | What it is | | ---------- | -------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------- | | `output` | `String.t()` | `""` | The text handed to the model. Serialize structured data yourself. | | `is_error` | `boolean()` | `false` | Whether the call failed. Fed back to the model, not raised. | | `parts` | `[ContentPart.t()] \| nil` | `nil` | Non-text output — see [`Toolnexus.ContentPart`](/api/elixir/types/content-part/). `nil` ⇒ byte-identical to a pre-parts result. | | `metadata` | `map() \| nil` | `nil` | Free-form, for your code. **Reserved:** `:pending` holds a §10 `Request`. | ## Functions [Section titled “Functions”](#functions) | Function | What it does | | ----------------------- | ---------------------------------------------------------------------------------- | | `ToolResult.ok/1` | A success result with the given text. | | `ToolResult.error/1` | An error result with the given text. | | `ToolResult.pending?/1` | True when `metadata.pending` holds a `%Request{}` — i.e. this is a §10 suspension. | ## See also [Section titled “See also”](#see-also) * [`Toolnexus.Tool`](/api/elixir/types/tool/) — what returns this * [`Toolnexus.Context`](/api/elixir/types/context/) — what `execute` receives * [`Toolnexus.Request`](/api/elixir/suspension/pending/) — the suspension request shape * [`Toolnexus.ContentPart`](/api/elixir/types/content-part/) — what `parts` holds # API Reference — Go > The github.com/muthuishere/toolnexus/golang public surface — toolkit, client, skills, MCP, adapters — with each symbol's equivalents in the other five ports. Module **`github.com/muthuishere/toolnexus/golang`** (Go 1.23). See the [combined API Reference](/api/) for every symbol shown in eight-language synced tabs. ## Toolkit [Section titled “Toolkit”](#toolkit) * `CreateToolkit(ctx context.Context, opts Options) (*Toolkit, error)` * `Options` fields: `McpConfig`, `SkillsDir`, `Skills`, `SkillProvider`, `SkillsFilter`, `SkillSampleLimit`, `Builtins`, `Agents`, `ExtraTools`, `WaitFor`. * Equivalents: `createToolkit` (js) · `create_toolkit` (py) · `Toolkit.create` (java) · `Toolkit.CreateAsync` (c#) · `Toolnexus.create_toolkit` (elixir). ## Skills [Section titled “Skills”](#skills) * `LoadSkills(dirs ...string) *SkillSource` — back-compat variadic. * `LoadSkillsWith(opts LoadSkillsOptions) *SkillSource` * `ListSkills(opts LoadSkillsOptions) *SkillInventory` — `.Skills`, `.Skipped` (`SkillSkip.Reason`; constants `SkipMissingName`, `SkipMalformed`, `SkipDuplicateName`, `SkipUnreadable`). * `SkillDef{ Name, Description, Content, Resources, Base }` * `LoadSkillsOptions{ Dirs, Skills, Filter, SampleLimit }` * `SkillProvider func(context.Context) ([]SkillDef, error)` (on `Options`). * Equivalents: `loadSkills` / `listSkills` (js) · `load_skills` / `list_skills` (py) · `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) * `LoadMcp(config any) (*McpSource, error)` * `tk.ToOpenAI()` · `tk.ToAnthropic()` · `tk.ToGemini()` * `CreateClient(opts ClientOptions) *Client` → `client.Ask(ctx, prompt, tk, id)`. ## Tools you build — native, HTTP, built-ins [Section titled “Tools you build — native, HTTP, built-ins”](#tools-you-build--native-http-built-ins) * `NativeTool(name, description, schema, fn)` · `NativeToolReflect[T](name, description, fn)` — schema from struct json tags. * `HTTPTool(opts HTTPToolOptions)` — `{ Name, Description, Method, URL, Headers, Query, Body, ResultMode, Timeout }`; `Timeout` ms (0 ⇒ 30000). * `Builtins` toggle → ten built-ins: `bash · read · write · edit · grep · glob · webfetch · question · apply_patch · todowrite`. * Equivalents: `defineTool` / `httpTool` (js) · `define_tool` / `http_tool` (py) · `NativeTool.of` / `HttpTool.of` (java) · `NativeTool.Of` / `HttpTool.Of` (c#) · `Toolnexus.Native.define_tool` / `Toolnexus.Http.tool` (elixir). ## Sub-agents & teams [Section titled “Sub-agents & teams”](#sub-agents--teams) * `agents.New(name, agents.Spec{Does, Tools, Soul, SoulFile, Team, Budget, Model, WaitFor}) *Agent`. * `(a *Agent) Run(agents.Options{LLM: ...}, prompt) (TaskResult, *Runtime)` — `.Status`, `.Text`, `.TotalTokens`. * `(a *Agent) AsTool(agents.Options) tn.Tool` · `agents.ComposeSoul(dir) (soul, found)`. * Equivalents: `agents.agent` / `.run` / `.asTool` / `composeSoul` (js) · `agent` (py) · `Agents.agent` (java/elixir) · `new Agent` (c#). ## A2A — serve & remote agents [Section titled “A2A — serve & remote agents”](#a2a--serve--remote-agents) * `(tk *Toolkit) Serve(addr, ServeOptions{Client, A2A: *A2AConfig, OnTask, MCP, OnCall}) (*ServeHandle, error)`. * `Agent{Card, Headers, Timeout, PollEvery}` + `Options{Agents: []Agent}` / `tk.AddAgent(ctx, cardURL, opts)`. * Equivalents: `tk.serve` / `tk.addAgent` (js) · `serve` / `add_agent` (py) · `serve` / `addAgent` (java) · `ServeAsync` / `AddAgentAsync` (c#) · `Toolkit.serve` / `Toolkit.add_agent` (elixir). ## Suspension & the human loop [Section titled “Suspension & the human loop”](#suspension--the-human-loop) * `WaitFor` field on `Options` — `func(Request) (Answer, error)`. * `Request{ID, Kind, Prompt, URL, Data, ExpiresAt}` · `Answer{ID, Ok, Data, Reason}`. * `Pending(req Request) ToolResult` · `AuthRequired(url, prompt) ToolResult` · `PendingOf(result) *Request`. * Equivalents: `waitFor` / `pending` / `authRequired` / `pendingOf` (js) · `wait_for` / `pending` (py) · `waitFor` / `ToolResult.pending` (java) · `WithWaitFor` / `ToolResult.Pending` (c#) · `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. * `LoadMediaStyle(path string) (MediaStyle, error)` * `CreateMedia(styles []MediaStyle, opts MediaOptions) *Media` — a verb resolves to the first style that declares it. * `MediaOptions{ OnProgress func(MediaProgress), HTTPClient *http.Client }` — `MediaProgress{ Verb, JobID, Status }` after every poll; `HTTPClient` nil ⇒ `http.DefaultClient`. * Verbs, each `(ctx context.Context, in XInput) (MediaResult, error)`: `Image(ImageInput{ Prompt, Width, Height, AspectRatio, Seed, ProviderOptions })` · `Video(VideoInput{ Prompt, Image, Seconds, AspectRatio, ProviderOptions })` · `Speech(SpeechInput{ Text, Voice, ProviderOptions })` · `Transcribe(TranscribeInput{ Audio, ProviderOptions })` · `CloneVoice(CloneVoiceInput{ Name, Samples, ProviderOptions })` · `Talking(TalkingInput{ Image, Audio, ProviderOptions })`. * **Zero value = unset**: a `0` or `""` field is not sent. To send a literal zero (e.g. `seed: 0`), use `Generate` with an explicit args map. * `(m *Media) Generate(ctx, verb string, args map[string]any, providerOptions map[string]any) (MediaResult, error)` — the raw call. `(m *Media) Supports(verb string) bool`. * `MediaResult{ Files []MediaFile, Text, JobID, Status }` · `MediaFile{ Type, MimeType, URL }`. * `(m *Media) Tools() []Tool` (source `"media"`), or `Options.Media`. * `RunResult.Files []RunFile` — `{ Type, MimeType, URL, Data, Name, From, CallID }`, in call order; the same parts are on `RunResult.ToolCalls[i].Parts`. * Errors: the function face returns an `error` whose `Error()` is the exact §7G message; cancelling `ctx` returns the context error. A media tool never errors (`IsError: true`). * Equivalents: `createMedia` (js) · `create_media` (py) · `Media.create` (java) · `Media.Create` (c#) · `Toolnexus.Media.new` (elixir) · `toolnexus.media/create-media` (clojure) · `create_media` (rust). # ToAnthropic > Render any tool list as Anthropic tool-use schema. Go. **Go** · module `github.com/muthuishere/toolnexus/golang` · [SPEC §4](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [golang/adapters.go](https://github.com/muthuishere/toolnexus/blob/main/golang/adapters.go) ```go func ToAnthropic(tools []Tool) []any ``` Turns a `[]Tool` into the `tools` array an Anthropic Messages request expects. Anthropic’s shape is the flattest of the three: no `type` wrapper and no `function` nesting — each entry is just a name, a description and an `input_schema`. ## When to use it [Section titled “When to use it”](#when-to-use-it) When you drive the Anthropic Messages API yourself — the official SDK, a raw `POST /v1/messages`, Bedrock or Vertex — and need the schema block for the request body. Reach for it whenever you have tools from toolnexus and a Claude-shaped endpoint on the other end. ## Why this and not the alternative [Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative) Use CreateClient instead if you want the loop too [`CreateClient`](/api/go/client/create/) picks the adapter from its style option and runs the whole tool-calling loop — request, tool dispatch, result feedback, next turn. Prefer it *instead* of this unless you already own a loop and want toolnexus purely for tool aggregation; `ToAnthropic` is the schema half only. `tk.ToAnthropic()` on a [`Toolkit`](/api/go/toolkit/create/) is this same function applied to that toolkit’s tools. Use the method when you hold a toolkit, the free function when you hold a slice. ## 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) Note the key: `input_schema`, snake-cased, at the top level of each entry. ```go package main import ( "context" "encoding/json" "fmt" "log" toolnexus "github.com/muthuishere/toolnexus/golang" ) func main() { weather := toolnexus.NativeTool( "get_weather", "Current weather for a city", toolnexus.JSONSchema{ "type": "object", "properties": map[string]any{"city": map[string]any{"type": "string"}}, "required": []string{"city"}, }, func(ctx context.Context, args map[string]any) (string, error) { return fmt.Sprintf("sunny in %v", args["city"]), nil }, ) schema := toolnexus.ToAnthropic([]toolnexus.Tool{weather}) if len(schema) != 1 { log.Fatalf("expected 1 entry, got %d", len(schema)) } b, _ := json.Marshal(schema[0]) var got map[string]any _ = json.Unmarshal(b, &got) // Flat: no {"type":"function","function":{...}} wrapper. if _, wrapped := got["function"]; wrapped { log.Fatalf("anthropic entries are flat, got %v", got) } if got["name"] != "get_weather" || got["description"] != "Current weather for a city" { log.Fatalf("unexpected entry: %v", got) } if _, ok := got["input_schema"].(map[string]any); !ok { log.Fatalf("expected an input_schema object, got %v", got["input_schema"]) } fmt.Println("ok:", got["name"]) } ``` ### 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) ```go package main import ( "context" "encoding/json" "fmt" "log" toolnexus "github.com/muthuishere/toolnexus/golang" ) func main() { mk := func(name, desc string) toolnexus.Tool { return toolnexus.NativeTool(name, desc, toolnexus.JSONSchema{"type": "object", "properties": map[string]any{}}, func(ctx context.Context, args map[string]any) (string, error) { return name, nil }, ) } tools := []toolnexus.Tool{mk("search", "Search the docs"), mk("ping", "Health check")} body := map[string]any{ "model": "claude-sonnet-4-5", "max_tokens": 1024, "messages": []any{map[string]any{"role": "user", "content": "search for adapters"}}, "tools": toolnexus.ToAnthropic(tools), } // The whole body marshals with encoding/json — no custom marshaller needed. b, err := json.Marshal(body) if err != nil { log.Fatal(err) } var got map[string]any _ = json.Unmarshal(b, &got) entries := got["tools"].([]any) if len(entries) != 2 { log.Fatalf("expected 2 tools, got %d", len(entries)) } // Order is preserved. var names []string for _, e := range entries { names = append(names, e.(map[string]any)["name"].(string)) } if names[0] != "search" || names[1] != "ping" { log.Fatalf("order not preserved: %v", names) } fmt.Println("ok:", names) } ``` ### 3. Round-tripping a `tool_use` block back to the tool [Section titled “3. Round-tripping a tool\_use block back to the tool”](#3-round-tripping-a-tool_use-block-back-to-the-tool) Schema out, `tool_use` in. Anthropic hands `input` back as a JSON **object**, not a string — the one real difference from OpenAI at the call site. ```go package main import ( "context" "encoding/json" "fmt" "log" toolnexus "github.com/muthuishere/toolnexus/golang" ) func main() { weather := toolnexus.NativeTool( "get_weather", "Current weather for a city", toolnexus.JSONSchema{ "type": "object", "properties": map[string]any{"city": map[string]any{"type": "string"}}, "required": []string{"city"}, }, func(ctx context.Context, args map[string]any) (string, error) { return fmt.Sprintf("sunny in %v", args["city"]), nil }, ) tools := []toolnexus.Tool{weather} // A content block as Claude would return it. raw := `{"type":"tool_use","id":"toolu_01","name":"get_weather","input":{"city":"Chennai"}}` var block struct { ID string `json:"id"` Name string `json:"name"` Input map[string]any `json:"input"` } if err := json.Unmarshal([]byte(raw), &block); err != nil { log.Fatal(err) } var called *toolnexus.Tool for i := range tools { if tools[i].Name == block.Name { called = &tools[i] break } } if called == nil { log.Fatal("the advertised name should resolve back to the tool") } res, err := called.Execute(block.Input, nil) if err != nil || res.Output != "sunny in Chennai" || res.IsError { log.Fatalf("unexpected: %+v %v", res, err) } // The result goes back as a tool_result block keyed by tool_use_id. reply := map[string]any{ "type": "tool_result", "tool_use_id": block.ID, "content": res.Output, "is_error": res.IsError, } if reply["tool_use_id"] != "toolu_01" { log.Fatalf("unexpected reply: %v", reply) } // An empty tool list is valid — it just means "no tools this turn". if len(toolnexus.ToAnthropic(nil)) != 0 { log.Fatal("expected an empty slice for no tools") } fmt.Println("ok:", block.Name, "->", res.Output) } ``` Caution Anthropic’s `input` is already an object. Do **not** `json.Unmarshal` a string the way you would for OpenAI’s `arguments` — hand `block.Input` to `Execute` directly. ## Shape [Section titled “Shape”](#shape) | Path | From | Notes | | ----------------- | ------------------ | ------------------------------------------ | | `[].name` | `Tool.Name` | What the `tool_use` block calls back with. | | `[].description` | `Tool.Description` | | | `[].input_schema` | `Tool.InputSchema` | Renamed — `inputSchema` → `input_schema`. | Each entry is the exported struct `AnthropicTool`, so you can type-assert it if you prefer that to marshalling. There is no `type` field and no nesting. ## See also [Section titled “See also”](#see-also) * [`ToOpenAI`](/api/go/adapters/openai/) · [`ToGemini`](/api/go/adapters/gemini/) * [`CreateToolkit`](/api/go/toolkit/create/) — `tk.ToAnthropic()` is this, applied to the toolkit * [`CreateClient`](/api/go/client/create/) — calls the adapter for you * [`Tool`](/api/go/types/tool/) — the input type # ToGemini > Render any tool list as Gemini function-declaration schema. Go. **Go** · module `github.com/muthuishere/toolnexus/golang` · [SPEC §4](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [golang/adapters.go](https://github.com/muthuishere/toolnexus/blob/main/golang/adapters.go) ```go func ToGemini(tools []Tool) []any ``` Turns a `[]Tool` into the `tools` array a Gemini `generateContent` request expects. Gemini is the odd one out: **every** tool goes inside a *single* element, under `functionDeclarations`. The returned slice therefore has length 1 no matter how many tools you pass. ## When to use it [Section titled “When to use it”](#when-to-use-it) When you call the Gemini API yourself — `generativelanguage.googleapis.com`, Vertex AI, or a Gemini SDK — and need the `tools` block for the request body. ## Why this and not the alternative [Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative) Use CreateClient instead if you want the loop too [`CreateClient`](/api/go/client/create/) selects the adapter from its style option and runs the tool-calling loop for you — including unwrapping Gemini’s `functionCall` parts and posting `functionResponse` back. Prefer it *instead* of this unless you already have your own loop and want toolnexus only for tool aggregation. `tk.ToGemini()` on a [`Toolkit`](/api/go/toolkit/create/) is this function applied to that toolkit’s tools — the method when you hold a toolkit, the free function when you hold a slice. ## Examples [Section titled “Examples”](#examples) ### 1. One tool, and the wrapper that surprises everyone [Section titled “1. One tool, and the wrapper that surprises everyone”](#1-one-tool-and-the-wrapper-that-surprises-everyone) ```go package main import ( "context" "encoding/json" "fmt" "log" toolnexus "github.com/muthuishere/toolnexus/golang" ) func main() { weather := toolnexus.NativeTool( "get_weather", "Current weather for a city", toolnexus.JSONSchema{ "type": "object", "properties": map[string]any{"city": map[string]any{"type": "string"}}, "required": []string{"city"}, }, func(ctx context.Context, args map[string]any) (string, error) { return fmt.Sprintf("sunny in %v", args["city"]), nil }, ) schema := toolnexus.ToGemini([]toolnexus.Tool{weather}) // ALWAYS exactly one element — the wrapper, not the tool. if len(schema) != 1 { log.Fatalf("expected 1 wrapper element, got %d", len(schema)) } b, _ := json.Marshal(schema[0]) var got map[string]any _ = json.Unmarshal(b, &got) decls := got["functionDeclarations"].([]any) if len(decls) != 1 { log.Fatalf("expected 1 declaration, got %d", len(decls)) } d := decls[0].(map[string]any) if d["name"] != "get_weather" || d["description"] != "Current weather for a city" { log.Fatalf("unexpected declaration: %v", d) } if _, ok := d["parameters"].(map[string]any); !ok { log.Fatalf("expected a parameters object, got %v", d["parameters"]) } fmt.Println("ok:", d["name"]) } ``` Caution `len(ToGemini(tools))` is **not** the number of tools — it is always 1. Count `functionDeclarations` instead. Even with no tools at all you get one element holding an empty declaration list. ### 2. Feeding it straight into a generateContent body [Section titled “2. Feeding it straight into a generateContent body”](#2-feeding-it-straight-into-a-generatecontent-body) ```go package main import ( "context" "encoding/json" "fmt" "log" toolnexus "github.com/muthuishere/toolnexus/golang" ) func main() { mk := func(name, desc string) toolnexus.Tool { return toolnexus.NativeTool(name, desc, toolnexus.JSONSchema{"type": "object", "properties": map[string]any{}}, func(ctx context.Context, args map[string]any) (string, error) { return name, nil }, ) } tools := []toolnexus.Tool{mk("search", "Search the docs"), mk("ping", "Health check")} body := map[string]any{ "contents": []any{map[string]any{ "role": "user", "parts": []any{map[string]any{"text": "search for adapters"}}, }}, "tools": toolnexus.ToGemini(tools), } b, err := json.Marshal(body) if err != nil { log.Fatal(err) } var got map[string]any _ = json.Unmarshal(b, &got) wrappers := got["tools"].([]any) if len(wrappers) != 1 { log.Fatalf("expected 1 wrapper, got %d", len(wrappers)) } decls := wrappers[0].(map[string]any)["functionDeclarations"].([]any) var names []string for _, d := range decls { names = append(names, d.(map[string]any)["name"].(string)) } // Two tools, one wrapper, order preserved. if len(names) != 2 || names[0] != "search" || names[1] != "ping" { log.Fatalf("unexpected declarations: %v", names) } fmt.Println("ok:", names) } ``` ### 3. Round-tripping a `functionCall` part back to the tool [Section titled “3. Round-tripping a functionCall part back to the tool”](#3-round-tripping-a-functioncall-part-back-to-the-tool) Gemini returns a `functionCall` part whose `args` is an object, and expects a `functionResponse` part back — keyed by tool **name**, not by a call id. ```go package main import ( "context" "encoding/json" "fmt" "log" toolnexus "github.com/muthuishere/toolnexus/golang" ) func main() { weather := toolnexus.NativeTool( "get_weather", "Current weather for a city", toolnexus.JSONSchema{ "type": "object", "properties": map[string]any{"city": map[string]any{"type": "string"}}, "required": []string{"city"}, }, func(ctx context.Context, args map[string]any) (string, error) { return fmt.Sprintf("sunny in %v", args["city"]), nil }, ) tools := []toolnexus.Tool{weather} raw := `{"functionCall":{"name":"get_weather","args":{"city":"Chennai"}}}` var part struct { FunctionCall struct { Name string `json:"name"` Args map[string]any `json:"args"` } `json:"functionCall"` } if err := json.Unmarshal([]byte(raw), &part); err != nil { log.Fatal(err) } var called *toolnexus.Tool for i := range tools { if tools[i].Name == part.FunctionCall.Name { called = &tools[i] break } } if called == nil { log.Fatal("the advertised name should resolve back to the tool") } res, err := called.Execute(part.FunctionCall.Args, nil) if err != nil || res.Output != "sunny in Chennai" || res.IsError { log.Fatalf("unexpected: %+v %v", res, err) } response := map[string]any{"functionResponse": map[string]any{ "name": called.Name, "response": map[string]any{"result": res.Output}, }} if _, ok := response["functionResponse"]; !ok { log.Fatal("expected a functionResponse part") } // No tools at all still yields the wrapper — with an empty declaration list. empty := toolnexus.ToGemini(nil) if len(empty) != 1 { log.Fatalf("expected the wrapper even for no tools, got %d", len(empty)) } eb, _ := json.Marshal(empty[0]) var emptyGot map[string]any _ = json.Unmarshal(eb, &emptyGot) if n := len(emptyGot["functionDeclarations"].([]any)); n != 0 { log.Fatalf("expected 0 declarations, got %d", n) } fmt.Println("ok:", called.Name, "->", res.Output) } ``` ## Shape [Section titled “Shape”](#shape) | Path | From | Notes | | ---------------------------------------- | ------------------ | -------------------------------------------------------------- | | `[0]` | — | The single wrapper element (`GeminiTool`). Always exactly one. | | `[0].functionDeclarations` | `tools` | One entry per tool, in input order. | | `[0].functionDeclarations[].name` | `Tool.Name` | What the `functionCall` part names. | | `[0].functionDeclarations[].description` | `Tool.Description` | | | `[0].functionDeclarations[].parameters` | `Tool.InputSchema` | Renamed — `inputSchema` → `parameters`. | ## See also [Section titled “See also”](#see-also) * [`ToOpenAI`](/api/go/adapters/openai/) · [`ToAnthropic`](/api/go/adapters/anthropic/) * [`CreateToolkit`](/api/go/toolkit/create/) — `tk.ToGemini()` is this, applied to the toolkit * [`CreateClient`](/api/go/client/create/) — calls the adapter for you * [`Tool`](/api/go/types/tool/) — the input type # ToOpenAI > Render any tool list as OpenAI function-calling schema. Go. **Go** · module `github.com/muthuishere/toolnexus/golang` · [SPEC §4](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [golang/adapters.go](https://github.com/muthuishere/toolnexus/blob/main/golang/adapters.go) ```go func ToOpenAI(tools []Tool) []any ``` Turns a `[]Tool` into the `tools` array an OpenAI-shaped chat completion expects. This is the bridge between “toolnexus knows about these tools” and “the model can call them”. ## When to use it [Section titled “When to use it”](#when-to-use-it) When you are driving the LLM call yourself and need schema to put in the request body. Every OpenAI-compatible endpoint takes this shape — OpenAI, OpenRouter, Groq, Together, a local Ollama, or your own gateway. ## Why this and not the client [Section titled “Why this and not the client”](#why-this-and-not-the-client) Skip this entirely if you use CreateClient [`CreateClient`](/api/go/client/create/) calls the right adapter for you based on its style option and runs the whole tool-calling loop. Reach for `ToOpenAI` when you already have your own loop and only want toolnexus for tool *aggregation*. `tk.ToOpenAI()` on a [`Toolkit`](/api/go/toolkit/create/) is the same function applied to that toolkit’s tools — use it when you have a toolkit, and the free function when you have a bare slice. ## Examples [Section titled “Examples”](#examples) ### 1. One tool to OpenAI schema [Section titled “1. One tool to OpenAI schema”](#1-one-tool-to-openai-schema) The return type is `[]any` so it marshals directly; assert against the marshalled JSON rather than type-asserting the interior. ```go package main import ( "context" "encoding/json" "fmt" "log" toolnexus "github.com/muthuishere/toolnexus/golang" ) func main() { weather := toolnexus.NativeTool( "get_weather", "Current weather for a city", toolnexus.JSONSchema{ "type": "object", "properties": map[string]any{"city": map[string]any{"type": "string"}}, "required": []string{"city"}, }, func(ctx context.Context, args map[string]any) (string, error) { return fmt.Sprintf("sunny in %v", args["city"]), nil }, ) schema := toolnexus.ToOpenAI([]toolnexus.Tool{weather}) if len(schema) != 1 { log.Fatalf("expected 1 entry, got %d", len(schema)) } b, _ := json.Marshal(schema[0]) var got map[string]any _ = json.Unmarshal(b, &got) if got["type"] != "function" { log.Fatalf("expected type=function, got %v", got["type"]) } fn := got["function"].(map[string]any) if fn["name"] != "get_weather" || fn["description"] != "Current weather for a city" { log.Fatalf("unexpected function block: %v", fn) } fmt.Println("ok:", fn["name"]) } ``` Note the nesting: OpenAI wraps each tool in `{"type":"function","function":{...}}`. The `InputSchema` on a `Tool` becomes `function.parameters` — the key is renamed. ### 2. Feeding it straight into a request body [Section titled “2. Feeding it straight into a request body”](#2-feeding-it-straight-into-a-request-body) ```go package main import ( "context" "encoding/json" "fmt" "log" toolnexus "github.com/muthuishere/toolnexus/golang" ) func main() { mk := func(name, desc string) toolnexus.Tool { return toolnexus.NativeTool(name, desc, toolnexus.JSONSchema{"type": "object", "properties": map[string]any{}}, func(ctx context.Context, args map[string]any) (string, error) { return name, nil }, ) } tools := []toolnexus.Tool{mk("search", "Search the docs"), mk("ping", "Health check")} body := map[string]any{ "model": "gpt-4o-mini", "messages": []any{map[string]any{"role": "user", "content": "search for adapters"}}, "tools": toolnexus.ToOpenAI(tools), } // The whole body marshals with encoding/json — no custom marshaller needed. b, err := json.Marshal(body) if err != nil { log.Fatal(err) } var got map[string]any _ = json.Unmarshal(b, &got) entries := got["tools"].([]any) if len(entries) != 2 { log.Fatalf("expected 2 tools, got %d", len(entries)) } // Order is preserved. var names []string for _, e := range entries { names = append(names, e.(map[string]any)["function"].(map[string]any)["name"].(string)) } if names[0] != "search" || names[1] != "ping" { log.Fatalf("order not preserved: %v", names) } fmt.Println("ok:", names) } ``` ### 3. Round-tripping a call back to the tool [Section titled “3. Round-tripping a call back to the tool”](#3-round-tripping-a-call-back-to-the-tool) Schema out, tool call in. The `Name` the model returns is the same `Name` you look up. ```go package main import ( "context" "encoding/json" "fmt" "log" toolnexus "github.com/muthuishere/toolnexus/golang" ) func main() { weather := toolnexus.NativeTool( "get_weather", "Current weather for a city", toolnexus.JSONSchema{ "type": "object", "properties": map[string]any{"city": map[string]any{"type": "string"}}, "required": []string{"city"}, }, func(ctx context.Context, args map[string]any) (string, error) { return fmt.Sprintf("sunny in %v", args["city"]), nil }, ) tools := []toolnexus.Tool{weather} // What a model would send back. OpenAI encodes arguments as a JSON STRING. rawArgs := `{"city":"Chennai"}` calledName := "get_weather" var args map[string]any if err := json.Unmarshal([]byte(rawArgs), &args); err != nil { log.Fatal(err) } var called *toolnexus.Tool for i := range tools { if tools[i].Name == calledName { called = &tools[i] break } } if called == nil { log.Fatal("the advertised name should resolve back to the tool") } res, err := called.Execute(args, nil) if err != nil || res.Output != "sunny in Chennai" || res.IsError { log.Fatalf("unexpected: %+v %v", res, err) } // An empty tool list is valid — it just means "no tools this turn". if len(toolnexus.ToOpenAI(nil)) != 0 { log.Fatal("expected an empty slice for no tools") } fmt.Println("ok:", calledName, "->", res.Output) } ``` Caution OpenAI sends `arguments` as a **JSON string**, not an object. `json.Unmarshal` it into a `map[string]any` before handing it to `Execute`. ## Shape [Section titled “Shape”](#shape) | Path | From | Notes | | ------------------------- | ------------------ | --------------------------------------- | | `[].type` | — | Always the literal `"function"`. | | `[].function.name` | `Tool.Name` | What the model calls back with. | | `[].function.description` | `Tool.Description` | | | `[].function.parameters` | `Tool.InputSchema` | Renamed — `inputSchema` → `parameters`. | ## See also [Section titled “See also”](#see-also) * [`ToAnthropic`](/api/go/adapters/anthropic/) · [`ToGemini`](/api/go/adapters/gemini/) * [`CreateToolkit`](/api/go/toolkit/create/) — `tk.ToOpenAI()` is this, applied to the toolkit * [`CreateClient`](/api/go/client/create/) — calls the adapter for you # Agent — call a remote A2A peer > Point at a remote agent's card and use it exactly like a local tool. — Go. **Go** · package `github.com/muthuishere/toolnexus/golang` · [SPEC §7A](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [golang/a2a.go](https://github.com/muthuishere/toolnexus/blob/main/golang/a2a.go) ```go type Agent struct { Card string // URL of the peer's Agent Card Headers map[string]string // static headers; ${ENV_VAR} values expand at call time Timeout int // overall poll budget in ms (default 300000) PollEvery int // interval between GetTask polls in ms (default 1000) } // wire an Agent into a toolkit — every advertised skill becomes a Tool toolnexus.CreateToolkit(ctx, toolnexus.Options{Agents: []toolnexus.Agent{ag}}) ``` `Agent` is a plain descriptor for a remote peer — a URL plus optional auth headers and timing. There is no constructor to call: build the struct literal and hand it to [`CreateToolkit`](/api/go/toolkit/create/)’s `Options.Agents` (or [`Toolkit.AddAgent`](#the-runtime-form) at runtime). The toolkit resolves the card, and every skill the peer advertises shows up as an ordinary `Tool` — the model that ends up driving the toolkit cannot tell a remote A2A skill from a native function. ## When to use it [Section titled “When to use it”](#when-to-use-it) Reach for `Agent` when another toolnexus toolkit (or any real A2A agent) already does something useful, and you want your own toolkit to delegate to it as if it were a local tool — no bespoke HTTP client, no bespoke polling loop, just a card URL and optional headers. ## Why this and not the alternative [Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative) Want the raw \[]Tool slice instead of a toolkit? `Agent` wired through `Options.Agents` merges the peer’s tools into a toolkit alongside MCP tools, skills, and your own `ExtraTools`. Reach for [`AgentTools`](/api/go/agents/agent-tools/) directly when you want to resolve a card into a `[]Tool` yourself — to filter, inspect, or register only some of them — without going through `CreateToolkit` at all. ## Examples [Section titled “Examples”](#examples) All three examples stand up a tiny local HTTP server playing a fake A2A peer (`httptest.Server`, speaking the JSON-RPC subset `a2a.go` expects) — no external network, no real agent. ### 1. The smallest useful call — one peer, one tool call [Section titled “1. The smallest useful call — one peer, one tool call”](#1-the-smallest-useful-call--one-peer-one-tool-call) ```go package main import ( "context" "encoding/json" "fmt" "io" "log" "net/http" "net/http/httptest" toolnexus "github.com/muthuishere/toolnexus/golang" ) // startPeer stands up a minimal fake A2A agent: one skill ("greet"), every // task completes immediately with a canned answer. func startPeer() *httptest.Server { var srv *httptest.Server srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("content-type", "application/json") if r.Method == http.MethodGet && r.URL.Path == "/.well-known/agent-card.json" { _ = json.NewEncoder(w).Encode(map[string]any{ "name": "greeter", "url": srv.URL + "/", "skills": []any{ map[string]any{"id": "greet", "name": "Greet", "description": "Say hello"}, }, }) return } body, _ := io.ReadAll(r.Body) var rpc struct { ID any `json:"id"` Method string `json:"method"` } _ = json.Unmarshal(body, &rpc) switch rpc.Method { case "SendMessage": _ = json.NewEncoder(w).Encode(map[string]any{ "jsonrpc": "2.0", "id": rpc.ID, "result": map[string]any{"id": "t1", "status": map[string]any{"state": "submitted"}}, }) case "GetTask": _ = json.NewEncoder(w).Encode(map[string]any{ "jsonrpc": "2.0", "id": rpc.ID, "result": map[string]any{ "id": "t1", "status": map[string]any{"state": "completed"}, "artifacts": []any{map[string]any{"parts": []any{map[string]any{"kind": "text", "text": "hello!"}}}}, }, }) } })) return srv } func main() { peer := startPeer() defer peer.Close() tk, err := toolnexus.CreateToolkit(context.Background(), toolnexus.Options{ Builtins: false, Agents: []toolnexus.Agent{ {Card: peer.URL + "/.well-known/agent-card.json", PollEvery: 5}, }, }) if err != nil { log.Fatal(err) } defer tk.Close() // The peer's "greet" skill is now an ordinary tool: greeter_greet. if _, ok := tk.Get("greeter_greet"); !ok { log.Fatal("expected greeter_greet in the toolkit") } res, err := tk.Execute(context.Background(), "greeter_greet", map[string]any{"task": "say hi"}) if err != nil || res.IsError { log.Fatalf("unexpected: %+v %v", res, err) } fmt.Println("ok:", res.Output) } ``` ### 2. The realistic case — auth headers with `${ENV}` expansion [Section titled “2. The realistic case — auth headers with ${ENV} expansion”](#2-the-realistic-case--auth-headers-with-env-expansion) `Headers` values containing `${ENV_VAR}` expand from the process environment at call time and are never logged — the same rule `HTTPTool` uses for outbound headers. ```go package main import ( "context" "encoding/json" "fmt" "io" "log" "net/http" "net/http/httptest" "os" toolnexus "github.com/muthuishere/toolnexus/golang" ) func startAuthedPeer(seenAuth *string) *httptest.Server { var srv *httptest.Server srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("content-type", "application/json") if r.Method == http.MethodGet && r.URL.Path == "/.well-known/agent-card.json" { _ = json.NewEncoder(w).Encode(map[string]any{ "name": "vault", "url": srv.URL + "/", "skills": []any{map[string]any{"id": "read", "name": "Read", "description": "Read a secret"}}, }) return } *seenAuth = r.Header.Get("Authorization") body, _ := io.ReadAll(r.Body) var rpc struct { ID any `json:"id"` Method string `json:"method"` } _ = json.Unmarshal(body, &rpc) if rpc.Method == "SendMessage" { _ = json.NewEncoder(w).Encode(map[string]any{"jsonrpc": "2.0", "id": rpc.ID, "result": map[string]any{"id": "t1", "status": map[string]any{"state": "submitted"}}}) return } _ = json.NewEncoder(w).Encode(map[string]any{"jsonrpc": "2.0", "id": rpc.ID, "result": map[string]any{"id": "t1", "status": map[string]any{"state": "completed"}, "artifacts": []any{map[string]any{"parts": []any{map[string]any{"kind": "text", "text": "secret-value"}}}}}}) })) return srv } func main() { var seenAuth string peer := startAuthedPeer(&seenAuth) defer peer.Close() // The real key lives only in the environment — never in code. if err := os.Setenv("VAULT_TOKEN", "sk-demo-123"); err != nil { log.Fatal(err) } tk, err := toolnexus.CreateToolkit(context.Background(), toolnexus.Options{ Builtins: false, Agents: []toolnexus.Agent{{ Card: peer.URL + "/.well-known/agent-card.json", Headers: map[string]string{"Authorization": "Bearer ${VAULT_TOKEN}"}, PollEvery: 5, }}, }) if err != nil { log.Fatal(err) } defer tk.Close() res, err := tk.Execute(context.Background(), "vault_read", map[string]any{"task": "read it"}) if err != nil || res.IsError || res.Output != "secret-value" { log.Fatalf("unexpected: %+v %v", res, err) } if seenAuth != "Bearer sk-demo-123" { log.Fatalf("expected the header to expand from the env, got %q", seenAuth) } fmt.Println("ok: peer saw", seenAuth) } ``` ### 3. The full surface — a failed task, and the metadata every call carries [Section titled “3. The full surface — a failed task, and the metadata every call carries”](#3-the-full-surface--a-failed-task-and-the-metadata-every-call-carries) ```go package main import ( "context" "encoding/json" "fmt" "io" "log" "net/http" "net/http/httptest" toolnexus "github.com/muthuishere/toolnexus/golang" ) func startFlakyPeer() *httptest.Server { var srv *httptest.Server srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("content-type", "application/json") if r.Method == http.MethodGet && r.URL.Path == "/.well-known/agent-card.json" { _ = json.NewEncoder(w).Encode(map[string]any{ "name": "flaky", "url": srv.URL + "/", "skills": []any{map[string]any{"id": "risky", "name": "Risky", "description": "Sometimes fails"}}, }) return } body, _ := io.ReadAll(r.Body) var rpc struct { ID any `json:"id"` Method string `json:"method"` } _ = json.Unmarshal(body, &rpc) if rpc.Method == "SendMessage" { _ = json.NewEncoder(w).Encode(map[string]any{"jsonrpc": "2.0", "id": rpc.ID, "result": map[string]any{"id": "t1", "status": map[string]any{"state": "submitted"}}}) return } _ = json.NewEncoder(w).Encode(map[string]any{"jsonrpc": "2.0", "id": rpc.ID, "result": map[string]any{ "id": "t1", "status": map[string]any{"state": "failed", "message": map[string]any{"role": "agent", "parts": []any{map[string]any{"kind": "text", "text": "disk full"}}}}, }}) })) return srv } func main() { peer := startFlakyPeer() defer peer.Close() tk, err := toolnexus.CreateToolkit(context.Background(), toolnexus.Options{ Builtins: false, Agents: []toolnexus.Agent{{ Card: peer.URL + "/.well-known/agent-card.json", Timeout: 5000, PollEvery: 5, }}, }) if err != nil { log.Fatal(err) } defer tk.Close() res, err := tk.Execute(context.Background(), "flaky_risky", map[string]any{"task": "try it"}) if err != nil { log.Fatal(err) } if !res.IsError { log.Fatal("expected the failed task to surface as an error result") } // Every call — success or failure — carries {agent, taskId, state, polls, ms}. if res.Metadata["agent"] != "flaky" || res.Metadata["state"] != "failed" { log.Fatalf("unexpected metadata: %+v", res.Metadata) } if _, ok := res.Metadata["taskId"].(string); !ok { log.Fatal("expected a taskId in metadata") } fmt.Println("ok:", res.Output, "metadata.state =", res.Metadata["state"]) } ``` ## Fields [Section titled “Fields”](#fields) | Field | Type | What it does | | ----------- | ------------------- | ------------------------------------------------------------------------------ | | `Card` | `string` | URL of the peer’s `/.well-known/agent-card.json`. | | `Headers` | `map[string]string` | Static request headers; `${ENV_VAR}` values expand at call time, never logged. | | `Timeout` | `int` | Overall poll budget in ms; default `300000`. | | `PollEvery` | `int` | Interval between `GetTask` polls in ms; default `1000`. | ## The runtime form [Section titled “The runtime form”](#the-runtime-form) `tk.AddAgent(ctx, cardURLOrAgent, opts)` adds a peer to an already-built toolkit, resolving and registering its tools immediately — the async counterpart to putting the `Agent` in `Options.Agents` at construction time. ## See also [Section titled “See also”](#see-also) * [`AgentTools`](/api/go/agents/agent-tools/) — Expand a remote agent card into one tool per advertised skill. * [`ParseAgentsConfig`](/api/go/agents/parse-config/) — Declare remote peers in config the way MCP servers are declared, with precedence rules. * [`agents.New`](/api/go/runtime/agent/) — the LOCAL, in-process counterpart: a sub-agent composed from a toolkit view and a soul, not a remote HTTP peer. # AgentTools > Expand a remote agent card into one tool per advertised skill. — Go. **Go** · package `github.com/muthuishere/toolnexus/golang` · [SPEC §7A](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [golang/a2a.go](https://github.com/muthuishere/toolnexus/blob/main/golang/a2a.go) ```go func AgentTools(ctx context.Context, ag Agent) ([]Tool, error) ``` `AgentTools` does the resolve step by itself: `GET` the peer’s Agent Card, and turn every advertised skill into one `Tool` (`source:"a2a"`) named `sanitize(card.name) + "_" + sanitize(skill.id)`. This is exactly what [`CreateToolkit`](/api/go/toolkit/create/) calls internally for each entry in `Options.Agents` — `AgentTools` is that step, exposed directly. ## When to use it [Section titled “When to use it”](#when-to-use-it) Reach for `AgentTools` when you want the raw `[]Tool` slice instead of a whole toolkit — to inspect what a peer offers before committing to it, to register only some of its skills, or to call `tool.Execute` directly without building a `Toolkit` at all. ## Why this and not the alternative [Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative) Building a toolkit? Skip straight to Options.Agents [`Agent`](/api/go/agents/agent/) wired through `CreateToolkit`’s `Options.Agents` calls `AgentTools` for you and merges the result with your other tool sources (MCP, skills, `ExtraTools`) under one uniform registry. Reach for `AgentTools` directly only when you need the tools themselves, before or instead of a toolkit. ## Examples [Section titled “Examples”](#examples) All three examples resolve tools from a local fake A2A peer (`httptest.Server`) — no external network, no real agent. ### 1. Resolve one card into its tools [Section titled “1. Resolve one card into its tools”](#1-resolve-one-card-into-its-tools) ```go package main import ( "context" "encoding/json" "fmt" "log" "net/http" "net/http/httptest" toolnexus "github.com/muthuishere/toolnexus/golang" ) func startPeer() *httptest.Server { return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("content-type", "application/json") _ = json.NewEncoder(w).Encode(map[string]any{ "name": "librarian", "url": "http://unused/", // this example never calls SendMessage/GetTask "skills": []any{ map[string]any{"id": "search", "name": "Search", "description": "Search the catalog"}, }, }) })) } func main() { peer := startPeer() defer peer.Close() tools, err := toolnexus.AgentTools(context.Background(), toolnexus.Agent{ Card: peer.URL, }) if err != nil { log.Fatal(err) } if len(tools) != 1 { log.Fatalf("expected 1 tool, got %d", len(tools)) } if tools[0].Name != "librarian_search" || tools[0].Source != toolnexus.SourceA2A { log.Fatalf("unexpected tool: %+v", tools[0]) } fmt.Println("ok:", tools[0].Name, tools[0].Source) } ``` ### 2. Register only some of a peer’s tools [Section titled “2. Register only some of a peer’s tools”](#2-register-only-some-of-a-peers-tools) ```go package main import ( "context" "encoding/json" "fmt" "log" "net/http" "net/http/httptest" toolnexus "github.com/muthuishere/toolnexus/golang" ) func startPeer() *httptest.Server { return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("content-type", "application/json") _ = json.NewEncoder(w).Encode(map[string]any{ "name": "ops", "url": "http://unused/", "skills": []any{ map[string]any{"id": "read", "name": "Read", "description": "Read-only, safe"}, map[string]any{"id": "delete", "name": "Delete", "description": "Destructive"}, }, }) })) } func main() { peer := startPeer() defer peer.Close() tools, err := toolnexus.AgentTools(context.Background(), toolnexus.Agent{Card: peer.URL}) if err != nil { log.Fatal(err) } // Hand-pick a safe subset into your own toolkit — never blanket-import a // peer's whole surface when you don't trust all of it equally. var safe []toolnexus.Tool for _, t := range tools { if t.Name == "ops_read" { safe = append(safe, t) } } tk, err := toolnexus.CreateToolkit(context.Background(), toolnexus.Options{Builtins: false}) if err != nil { log.Fatal(err) } defer tk.Close() tk.Register(safe...) if _, ok := tk.Get("ops_read"); !ok { log.Fatal("expected ops_read registered") } if _, ok := tk.Get("ops_delete"); ok { log.Fatal("ops_delete should have been left out") } fmt.Println("ok: registered", len(safe), "of", len(tools), "peer tools") } ``` ### 3. The full surface — a bad card fails the resolve, calling a tool needs no toolkit [Section titled “3. The full surface — a bad card fails the resolve, calling a tool needs no toolkit”](#3-the-full-surface--a-bad-card-fails-the-resolve-calling-a-tool-needs-no-toolkit) ```go package main import ( "context" "encoding/json" "fmt" "io" "log" "net/http" "net/http/httptest" toolnexus "github.com/muthuishere/toolnexus/golang" ) func startPeer() *httptest.Server { var srv *httptest.Server srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("content-type", "application/json") if r.Method == http.MethodGet && r.URL.Path == "/.well-known/agent-card.json" { _ = json.NewEncoder(w).Encode(map[string]any{ "name": "calc", "url": srv.URL + "/", "skills": []any{map[string]any{"id": "double", "name": "Double", "description": "Doubles a number"}}, }) return } body, _ := io.ReadAll(r.Body) var rpc struct { ID any `json:"id"` Method string `json:"method"` } _ = json.Unmarshal(body, &rpc) if rpc.Method == "SendMessage" { _ = json.NewEncoder(w).Encode(map[string]any{"jsonrpc": "2.0", "id": rpc.ID, "result": map[string]any{"id": "t1", "status": map[string]any{"state": "submitted"}}}) return } _ = json.NewEncoder(w).Encode(map[string]any{"jsonrpc": "2.0", "id": rpc.ID, "result": map[string]any{"id": "t1", "status": map[string]any{"state": "completed"}, "artifacts": []any{map[string]any{"parts": []any{map[string]any{"kind": "text", "text": "42"}}}}}}) })) return srv } func main() { // A bad card URL fails the resolve up front — never a partial tool list. if _, err := toolnexus.AgentTools(context.Background(), toolnexus.Agent{Card: "http://127.0.0.1:0/nope"}); err == nil { log.Fatal("expected an error for an unreachable card") } peer := startPeer() defer peer.Close() tools, err := toolnexus.AgentTools(context.Background(), toolnexus.Agent{ Card: peer.URL + "/.well-known/agent-card.json", PollEvery: 5, }) if err != nil { log.Fatal(err) } // A resolved Tool is a complete, self-contained callable — Execute it // directly, no CreateToolkit required. res, err := tools[0].Execute(map[string]any{"task": "double 21"}, &toolnexus.ToolContext{Ctx: context.Background()}) if err != nil || res.IsError || res.Output != "42" { log.Fatalf("unexpected: %+v %v", res, err) } fmt.Println("ok:", tools[0].Name, "->", res.Output) } ``` ## Parameters and return value [Section titled “Parameters and return value”](#parameters-and-return-value) | | Type | What it holds | | ------- | ----------------- | ------------------------------------------------------------------------------------- | | `ctx` | `context.Context` | Bounds the card fetch. | | `ag` | `Agent` | The peer descriptor — `Card`, `Headers`, `Timeout`, `PollEvery`. | | returns | `([]Tool, error)` | One `Tool` per advertised skill, or an error if the card could not be fetched/parsed. | ## See also [Section titled “See also”](#see-also) * [`agents.New`](/api/go/agents/agent/) — Point at a remote agent’s card and use it exactly like a local tool. * [`ParseAgentsConfig`](/api/go/agents/parse-config/) — Declare remote peers in config the way MCP servers are declared, with precedence rules. # ParseAgentsConfig > Declare remote peers in config the way MCP servers are declared, with precedence rules. — Go. **Go** · package `github.com/muthuishere/toolnexus/golang` · [SPEC §7A](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [golang/a2a.go](https://github.com/muthuishere/toolnexus/blob/main/golang/a2a.go) ```go type AgentConfig struct { Card string Headers map[string]string Timeout int PollEvery int Enabled *bool Disabled *bool } type AgentsConfig map[string]AgentConfig func ParseAgentsConfig(block AgentsConfig) []Agent ``` `ParseAgentsConfig` turns an `agents` config block into `[]Agent` descriptors, mirroring how `mcpServers` blocks are declared (§2) — an identifier key, a body, and the same `enabled`/`disabled` precedence. The config key is only an identifier; the tool name prefix always comes from the fetched card’s `name`, never the key. ## When to use it [Section titled “When to use it”](#when-to-use-it) Reach for `ParseAgentsConfig` when peers are declared in a config file (JSON, or a parsed `map[string]any`) alongside MCP servers, rather than built as `Agent` struct literals in code. It’s what `CreateToolkit` calls internally for a top-level `agents` block on a parsed `McpConfig` map. ## Why this and not the alternative [Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative) Building Agent values directly in code? Skip config parsing entirely `ParseAgentsConfig` exists for the config-file path. If you’re constructing `Agent{Card: ...}` literals in Go already, there’s nothing to parse — hand them straight to [`Options.Agents`](/api/go/agents/agent/). ## Examples [Section titled “Examples”](#examples) ### 1. One enabled entry [Section titled “1. One enabled entry”](#1-one-enabled-entry) ```go package main import ( "fmt" "log" toolnexus "github.com/muthuishere/toolnexus/golang" ) func main() { parsed := toolnexus.ParseAgentsConfig(toolnexus.AgentsConfig{ "reviewer": {Card: "https://peer.example/.well-known/agent-card.json", PollEvery: 500}, }) if len(parsed) != 1 { log.Fatalf("expected 1 agent, got %d", len(parsed)) } if parsed[0].Card != "https://peer.example/.well-known/agent-card.json" || parsed[0].PollEvery != 500 { log.Fatalf("unexpected: %+v", parsed[0]) } fmt.Println("ok:", parsed[0].Card) } ``` ### 2. `enabled`/`disabled` precedence — mirrors the MCP rule [Section titled “2. enabled/disabled precedence — mirrors the MCP rule”](#2-enableddisabled-precedence--mirrors-the-mcp-rule) `disabled:true` always wins; else `enabled:false` skips the entry; both left unset ⇒ enabled. ```go package main import ( "fmt" "log" toolnexus "github.com/muthuishere/toolnexus/golang" ) func boolPtr(b bool) *bool { return &b } func main() { parsed := toolnexus.ParseAgentsConfig(toolnexus.AgentsConfig{ "a": {Card: "http://x/1"}, // default: enabled "b": {Card: "http://x/2", Disabled: boolPtr(true)}, // disabled wins "c": {Card: "http://x/3", Enabled: boolPtr(false)}, // enabled:false skips "d": {Card: "http://x/4", Enabled: boolPtr(true), Disabled: boolPtr(true)}, // disabled still wins }) if len(parsed) != 1 { log.Fatalf("expected only 1 survivor, got %d", len(parsed)) } if parsed[0].Card != "http://x/1" { log.Fatalf("unexpected survivor: %+v", parsed[0]) } fmt.Println("ok: only", parsed[0].Card, "survived") } ``` ### 3. The full surface — driven straight from `CreateToolkit`’s config map [Section titled “3. The full surface — driven straight from CreateToolkit’s config map”](#3-the-full-surface--driven-straight-from-createtoolkits-config-map) The same precedence applies when the `agents` block arrives as part of a parsed `McpConfig`-shaped `map[string]any` — the path `CreateToolkit` itself uses. ```go package main import ( "context" "encoding/json" "fmt" "io" "log" "net/http" "net/http/httptest" toolnexus "github.com/muthuishere/toolnexus/golang" ) func startPeer() *httptest.Server { var srv *httptest.Server srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("content-type", "application/json") if r.Method == http.MethodGet && r.URL.Path == "/.well-known/agent-card.json" { _ = json.NewEncoder(w).Encode(map[string]any{ "name": "rev", "url": srv.URL + "/", "skills": []any{map[string]any{"id": "review", "name": "Review", "description": "Review code"}}, }) return } body, _ := io.ReadAll(r.Body) var rpc struct { ID any `json:"id"` Method string `json:"method"` } _ = json.Unmarshal(body, &rpc) if rpc.Method == "SendMessage" { _ = json.NewEncoder(w).Encode(map[string]any{"jsonrpc": "2.0", "id": rpc.ID, "result": map[string]any{"id": "t1", "status": map[string]any{"state": "submitted"}}}) return } _ = json.NewEncoder(w).Encode(map[string]any{"jsonrpc": "2.0", "id": rpc.ID, "result": map[string]any{"id": "t1", "status": map[string]any{"state": "completed"}, "artifacts": []any{map[string]any{"parts": []any{map[string]any{"kind": "text", "text": "LGTM"}}}}}}) })) return srv } func main() { peer := startPeer() defer peer.Close() cardURL := peer.URL + "/.well-known/agent-card.json" off, err := toolnexus.CreateToolkit(context.Background(), toolnexus.Options{ Builtins: false, McpConfig: map[string]any{"agents": map[string]any{"rev": map[string]any{"card": cardURL, "disabled": true}}}, }) if err != nil { log.Fatal(err) } if _, ok := off.Get("rev_review"); ok { log.Fatal("disabled agent should be skipped") } off.Close() on, err := toolnexus.CreateToolkit(context.Background(), toolnexus.Options{ Builtins: false, McpConfig: map[string]any{"agents": map[string]any{"rev": map[string]any{"card": cardURL, "pollEvery": 5}}}, }) if err != nil { log.Fatal(err) } defer on.Close() if _, ok := on.Get("rev_review"); !ok { log.Fatal("enabled agent should resolve from config") } fmt.Println("ok: config-driven agent resolved to rev_review") } ``` ## Precedence [Section titled “Precedence”](#precedence) | `disabled` | `enabled` | Result | | ------------- | ------------ | -------- | | `true` | any | skipped | | unset/`false` | `false` | skipped | | unset/`false` | unset/`true` | included | `Card == ""` is also skipped — an entry with no card has nothing to resolve. ## See also [Section titled “See also”](#see-also) * [`agents.New`](/api/go/agents/agent/) — Point at a remote agent’s card and use it exactly like a local tool. * [`AgentTools`](/api/go/agents/agent-tools/) — Expand a remote agent card into one tool per advertised skill. # BackgroundShells > 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. — Go. **Go** · package `github.com/muthuishere/toolnexus/golang` · [SPEC §4A](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [golang/builtin\_extras.go](https://github.com/muthuishere/toolnexus/blob/main/golang/builtin_extras.go) ```go func NewBackgroundShells() *BackgroundShells func (b *BackgroundShells) List() []BackgroundShellInfo func (b *BackgroundShells) Kill(id string) error func (b *BackgroundShells) CloseAll() type BackgroundShellInfo struct { ID, Command string Started time.Time Running bool ExitCode int // valid when !Running; -1 if killed by a signal Killed bool } // BuiltinsConfig.Background *BackgroundShells — nil ⇒ none of this exists. ``` Pass a registry as `BuiltinsConfig.Background` and the builtins change in three ways: * `bash` gains `run_in_background: true`: it returns at once with `Started background shell . …` (`Metadata["backgroundId"]`). No timeout applies. * `bash_output {id}` returns the output produced **since the last read**, then `[status: running|killed|exited with code N]`. N is `-1` when the shell was killed by a signal it did not get from `kill_shell`. * `kill_shell {id}` stops the whole process group (SIGTERM, `KillGraceMs`, then SIGKILL). A background command is **not** tied to the turn that started it. The host owns the registry: call `CloseAll` on shutdown, or the commands outlive the process that started them. The other opt-in builtins `BuiltinsConfig.WebSearch` (a `WebSearchFunc` the host supplies — the library ships none) adds `websearch`, and `BuiltinsConfig.NotebookEdit: true` adds `notebook_edit` for `.ipynb` cells. See [`CreateBuiltinTools`](/api/go/builtins/create/). ## When to use it [Section titled “When to use it”](#when-to-use-it) A coding agent that starts a dev server, a watcher or a long test run and keeps working while it runs — then checks the output, or stops it. ## Why this and not the alternative [Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative) Short commands? Plain bash is enough A foreground `bash` call waits for the command (with a timeout) and returns everything in one result. Use background shells only for commands that must outlive one tool call; each one is a process the host must remember to stop. ## Examples [Section titled “Examples”](#examples) ### 1. The smallest useful call — start, wait, read [Section titled “1. The smallest useful call — start, wait, read”](#1-the-smallest-useful-call--start-wait-read) ```go package main import ( "context" "fmt" "log" "strings" "time" toolnexus "github.com/muthuishere/toolnexus/golang" ) func main() { bg := toolnexus.NewBackgroundShells() defer bg.CloseAll() tk, err := toolnexus.CreateToolkit(context.Background(), toolnexus.Options{ Builtins: toolnexus.BuiltinsConfig{Background: bg}, }) if err != nil { log.Fatal(err) } defer tk.Close() ctx := context.Background() started, err := tk.Execute(ctx, "bash", map[string]any{"command": "echo hello", "run_in_background": true}) if err != nil { log.Fatal(err) } id := started.Metadata["backgroundId"].(string) for i := 0; i < 50 && bg.List()[0].Running; i++ { time.Sleep(20 * time.Millisecond) } out, err := tk.Execute(ctx, "bash_output", map[string]any{"id": id}) if err != nil || !strings.Contains(out.Output, "hello") || !strings.Contains(out.Output, "[status: exited with code 0]") { log.Fatalf("%v %q", err, out.Output) } fmt.Printf("ok: %s -> %q\n", id, out.Output) } ``` ### 2. The realistic case — a long-running command, stopped with `kill_shell` [Section titled “2. The realistic case — a long-running command, stopped with kill\_shell”](#2-the-realistic-case--a-long-running-command-stopped-with-kill_shell) ```go package main import ( "context" "fmt" "log" "strings" toolnexus "github.com/muthuishere/toolnexus/golang" ) func main() { bg := toolnexus.NewBackgroundShells() defer bg.CloseAll() tk, err := toolnexus.CreateToolkit(context.Background(), toolnexus.Options{ Builtins: toolnexus.BuiltinsConfig{Background: bg, KillGraceMs: 100}, }) if err != nil { log.Fatal(err) } defer tk.Close() ctx := context.Background() started, _ := tk.Execute(ctx, "bash", map[string]any{"command": "sleep 30", "run_in_background": true}) id := started.Metadata["backgroundId"].(string) running, _ := tk.Execute(ctx, "bash_output", map[string]any{"id": id}) if !strings.Contains(running.Output, "[status: running]") { log.Fatalf("expected running, got %q", running.Output) } killed, _ := tk.Execute(ctx, "kill_shell", map[string]any{"id": id}) if killed.Output != "Killed background shell "+id { log.Fatalf("unexpected kill output %q", killed.Output) } fmt.Println("ok:", killed.Output) } ``` ### 3. The full surface — the host’s view with `List`, `Kill` and `CloseAll` [Section titled “3. The full surface — the host’s view with List, Kill and CloseAll”](#3-the-full-surface--the-hosts-view-with-list-kill-and-closeall) ```go package main import ( "fmt" "log" toolnexus "github.com/muthuishere/toolnexus/golang" ) func main() { bg := toolnexus.NewBackgroundShells() tools := toolnexus.CreateBuiltinToolsWith(toolnexus.BuiltinsConfig{Background: bg, KillGraceMs: 100}) names := map[string]bool{} var bash toolnexus.Tool for _, t := range tools { names[t.Name] = true if t.Name == "bash" { bash = t } } if !names["bash_output"] || !names["kill_shell"] { log.Fatal("bash_output and kill_shell should be added") } for _, cmd := range []string{"sleep 30", "sleep 31"} { if _, err := bash.Execute(map[string]any{"command": cmd, "run_in_background": true}, nil); err != nil { log.Fatal(err) } } list := bg.List() if len(list) != 2 || !list[0].Running { log.Fatalf("expected two running shells: %+v", list) } if err := bg.Kill(list[0].ID); err != nil { // the host's own stop button log.Fatal(err) } bg.CloseAll() // on shutdown: stops everything still running for _, s := range bg.List() { if s.Running { log.Fatalf("%s still running after CloseAll", s.ID) } } fmt.Println("ok:", len(list), "shells started, all stopped") } ``` ## See also [Section titled “See also”](#see-also) * [`CreateBuiltinTools`](/api/go/builtins/create/) — the ten default builtins and the other opt-ins. * [`SelectBuiltins`](/api/go/builtins/select/) — choose which builtins a toolkit gets. * [`Hooks`](/api/go/client/hooks/) — `bash` is hinted destructive and open-world; gate it in `BeforeTool`. # CreateBuiltinTools > Construct every built-in tool directly, outside a toolkit. — Go. **Go** · module `github.com/muthuishere/toolnexus/golang` · [SPEC §4A](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [golang/builtin.go](https://github.com/muthuishere/toolnexus/blob/main/golang/builtin.go) ```go func CreateBuiltinTools() []Tool ``` Constructs all ten built-in tools — the opencode-ported file/shell/web toolset toolnexus ships by default — with no toolkit and no filtering. The order is fixed for parity across ports: `bash`, `read`, `write`, `edit`, `grep`, `glob`, `webfetch`, `question`, `apply_patch`, `todowrite`. No arguments, no config Unlike [`SelectBuiltins`](/api/go/builtins/select/), `CreateBuiltinTools` takes no config and applies no on/off logic — it always returns the full set of ten, every time. It is the thing `SelectBuiltins` filters. ## When to use it [Section titled “When to use it”](#when-to-use-it) Reach for `CreateBuiltinTools` when you want the file/shell/web tools **without** going through `Options.Builtins` on a toolkit — inspecting the full set, wiring your own selection logic on top, or hand-assembling a `[]Tool` for a use case that doesn’t fit `CreateToolkit`. ## Why this and not the alternative [Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative) Use SelectBuiltins for the on/off/per-tool config [`SelectBuiltins`](/api/go/builtins/select/) is `CreateBuiltinTools` plus the same on/off and per-tool logic `Options.Builtins` uses on [`CreateToolkit`](/api/go/toolkit/create/). Reach for it instead of `CreateBuiltinTools` the moment you need to drop some tools or turn the whole set off — `CreateBuiltinTools` itself has no such switch. ## Examples [Section titled “Examples”](#examples) ### 1. The full set, in the fixed order [Section titled “1. The full set, in the fixed order”](#1-the-full-set-in-the-fixed-order) ```go package main import ( "fmt" "log" toolnexus "github.com/muthuishere/toolnexus/golang" ) func main() { tools := toolnexus.CreateBuiltinTools() if len(tools) != 10 { log.Fatalf("expected 10 built-ins, got %d", len(tools)) } wantOrder := []string{ "bash", "read", "write", "edit", "grep", "glob", "webfetch", "question", "apply_patch", "todowrite", } for i, name := range wantOrder { if tools[i].Name != name { log.Fatalf("position %d: expected %q, got %q", i, name, tools[i].Name) } if tools[i].Source != toolnexus.SourceBuiltin { log.Fatalf("expected Source builtin for %q, got %v", name, tools[i].Source) } } fmt.Println("ok:", len(tools), "built-ins in fixed order") } ``` ### 2. Calling one directly, no toolkit involved [Section titled “2. Calling one directly, no toolkit involved”](#2-calling-one-directly-no-toolkit-involved) ```go package main import ( "fmt" "log" "os" "path/filepath" toolnexus "github.com/muthuishere/toolnexus/golang" ) func main() { tools := toolnexus.CreateBuiltinTools() var write, read *toolnexus.Tool for i := range tools { switch tools[i].Name { case "write": write = &tools[i] case "read": read = &tools[i] } } if write == nil || read == nil { log.Fatal("expected write and read among the built-ins") } path := filepath.Join(os.TempDir(), "toolnexus-docs-createbuiltintools.txt") defer os.Remove(path) wRes, err := write.Execute(map[string]any{"path": path, "content": "hello"}, nil) if err != nil || wRes.IsError { log.Fatalf("unexpected write result: %+v %v", wRes, err) } rRes, err := read.Execute(map[string]any{"path": path}, nil) if err != nil || rRes.IsError || rRes.Output != "hello" { log.Fatalf("unexpected read result: %+v %v", rRes, err) } fmt.Println("ok:", rRes.Output) } ``` `read` is not text-only. When the path’s extension is in the fixed media table — `png` `jpg` `jpeg` `gif` `webp` `pdf` `mp3` `wav` — it returns `Output` as a one-line description (`shot.png (image/png, 82 bytes)`) and one [`ContentPart`](/api/go/types/content-part/) in [`ToolResult.Parts`](/api/go/types/tool-result/) carrying the base64 bytes. Any other extension is read as UTF-8 text, and bytes that are not valid UTF-8 come back as an `IsError` result naming the file — never a panic escaping into the loop. Nothing consumes the token estimate yet in Go `ContentPart.EstimatedTokens()` implements the SPEC §1B formula and is tested, but the Go compactor does not yet read it: a part still costs whatever the compactor’s text estimate makes of it. That is a gap, not a design decision. ### 3. Hand-assembling a custom set on top of the full list [Section titled “3. Hand-assembling a custom set on top of the full list”](#3-hand-assembling-a-custom-set-on-top-of-the-full-list) `CreateBuiltinTools` always returns all ten — filtering to a custom subset is your own slice logic, which is exactly what [`SelectBuiltins`](/api/go/builtins/select/) does for you when the source is a `BuiltinsConfig`. ```go package main import ( "context" "fmt" "log" toolnexus "github.com/muthuishere/toolnexus/golang" ) func main() { only := map[string]bool{"read": true, "grep": true, "glob": true} var readOnly []toolnexus.Tool for _, t := range toolnexus.CreateBuiltinTools() { if only[t.Name] { readOnly = append(readOnly, t) } } if len(readOnly) != 3 { log.Fatalf("expected 3 tools, got %d", len(readOnly)) } for _, t := range readOnly { if !only[t.Name] { log.Fatalf("unexpected tool in custom set: %s", t.Name) } } // Builtins:false turns the default ten OFF; only our hand-picked three go in // via ExtraTools — CreateBuiltinTools never wires itself into a toolkit on its own. tk, err := toolnexus.CreateToolkit(context.Background(), toolnexus.Options{ ExtraTools: readOnly, Builtins: false, }) if err != nil { log.Fatal(err) } defer tk.Close() if len(tk.Tools()) != 3 { log.Fatalf("expected exactly the 3 hand-picked tools, got %d", len(tk.Tools())) } fmt.Println("ok:", len(readOnly), "hand-picked read-only tools") } ``` ## Return value [Section titled “Return value”](#return-value) `[]Tool` — always length 10, always the same order, each with `Source: SourceBuiltin`. ## Opt-in builtins, grep context and truncation metadata [Section titled “Opt-in builtins, grep context and truncation metadata”](#opt-in-builtins-grep-context-and-truncation-metadata) `CreateBuiltinToolsWith(BuiltinsConfig{…})` (or `Options.Builtins: BuiltinsConfig{…}`) adds tools that are **off** unless configured; the default ten are unchanged: | Field | Adds | | ------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------- | | `Background *BackgroundShells` | `run_in_background` on `bash`, plus `bash_output` and `kill_shell` — see [`BackgroundShells`](/api/go/builtins/background-shells/) | | `WebSearch WebSearchFunc` | `websearch` over a provider the host supplies (`func(ctx, query, limit) ([]WebSearchResult, error)`; the library ships none) | | `NotebookEdit bool` | `notebook_edit` — replace / insert / delete a `.ipynb` cell by id or index (replacing a code cell clears its outputs) | `grep` also takes `context`, `before` and `after`: match lines print as `path:N:text`, context lines as `path-N-text`, with `--` between groups that do not touch. Absent ⇒ output unchanged. And `read` and `grep` now say in **metadata** when output was cut — `read`: `truncated`, `totalLines`, `nextOffset`; `grep`: `truncated`, `total` — while the text stays the pinned cross-port bytes. ```go package main import ( "context" "fmt" "log" "os" "path/filepath" "strings" toolnexus "github.com/muthuishere/toolnexus/golang" ) func main() { dir, _ := os.MkdirTemp("", "grep") defer os.RemoveAll(dir) f := filepath.Join(dir, "a.txt") _ = os.WriteFile(f, []byte("one\ntwo\nNEEDLE\nfour\nfive\n"), 0o644) search := func(ctx context.Context, q string, limit int) ([]toolnexus.WebSearchResult, error) { return []toolnexus.WebSearchResult{{Title: "Go", URL: "https://go.dev", Snippet: "The Go language"}}, nil } tools := map[string]toolnexus.Tool{} for _, t := range toolnexus.CreateBuiltinToolsWith(toolnexus.BuiltinsConfig{WebSearch: search, NotebookEdit: true}) { tools[t.Name] = t } if _, ok := tools["notebook_edit"]; !ok { log.Fatal("notebook_edit should be added") } g, err := tools["grep"].Execute(map[string]any{"pattern": "NEEDLE", "path": dir, "context": 1}, nil) if err != nil || !strings.Contains(g.Output, ":3:NEEDLE") || !strings.Contains(g.Output, "-2-two") || !strings.Contains(g.Output, "-4-four") { log.Fatalf("grep with context: %v %q", err, g.Output) } r, _ := tools["read"].Execute(map[string]any{"path": f, "limit": 2}, nil) if r.Metadata["truncated"] != true { log.Fatalf("read window should report truncation: %v", r.Metadata) } ws, _ := tools["websearch"].Execute(map[string]any{"query": "go"}, nil) if !strings.Contains(ws.Output, "https://go.dev") { log.Fatalf("websearch: %q", ws.Output) } fmt.Println("ok: grep context, read truncated, nextOffset =", r.Metadata["nextOffset"]) } ``` ## See also [Section titled “See also”](#see-also) * [`SelectBuiltins`](/api/go/builtins/select/) — pick which built-in file/shell/web tools are in play, by name or by source toggle. * [`CreateToolkit`](/api/go/toolkit/create/) — the aggregator; its `Builtins` option calls `SelectBuiltins` internally. # SelectBuiltins > Pick which built-in file/shell/web tools are in play, by name or by source toggle. — Go. **Go** · module `github.com/muthuishere/toolnexus/golang` · [SPEC §4A](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [golang/builtin.go](https://github.com/muthuishere/toolnexus/blob/main/golang/builtin.go) ```go func SelectBuiltins(cfg any) []Tool ``` Resolves the active built-in tools for a config value. Whole-source-off wins and returns `nil`. Otherwise all ten built-ins ([`CreateBuiltinTools`](/api/go/builtins/create/)) are on by default; a per-tool map drops any tool explicitly mapped to `false`. This is exactly the logic [`CreateToolkit`](/api/go/toolkit/create/) applies to its `Options.Builtins` field — `SelectBuiltins` is that logic, callable on its own. cfg accepts four shapes `nil` ⇒ on (the default); a `bool` ⇒ taken as-is; a `BuiltinsConfig` (or `*BuiltinsConfig`) ⇒ `Enabled`/`Disabled`/`Tools` precedence, same as MCP’s `enabled`/`disabled`; a `map[string]any` ⇒ the same precedence read off `"enabled"`, `"disabled"`, `"tools"` keys — the shape a parsed JSON `builtins` block naturally decodes to. ## When to use it [Section titled “When to use it”](#when-to-use-it) Reach for `SelectBuiltins` when you want the on/off and per-tool filtering `CreateToolkit` applies to its default toolset, but **without** building a whole toolkit — inspecting what a given config would enable, or wiring your own aggregation logic around the built-ins. ## Why this and not the alternative [Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative) Use CreateBuiltinTools when you don't need the filtering [`CreateBuiltinTools`](/api/go/builtins/create/) always returns the full set of ten, with no on/off logic at all — reach for it instead of `SelectBuiltins` when you always want everything (or intend to apply your own filter). Reach for [`CreateToolkit`](/api/go/toolkit/create/)’s `Options.Builtins` field instead of calling `SelectBuiltins` directly when built-ins are just one part of a larger toolkit — it calls this for you. ## Examples [Section titled “Examples”](#examples) ### 1. `nil` is on, `false` is off [Section titled “1. nil is on, false is off”](#1-nil-is-on-false-is-off) ```go package main import ( "fmt" "log" toolnexus "github.com/muthuishere/toolnexus/golang" ) func main() { def := toolnexus.SelectBuiltins(nil) if len(def) != 10 { log.Fatalf("expected 10 built-ins by default, got %d", len(def)) } off := toolnexus.SelectBuiltins(false) if off != nil { log.Fatalf("expected nil for a whole-source off, got %d tools", len(off)) } on := toolnexus.SelectBuiltins(true) if len(on) != 10 { log.Fatalf("expected 10 built-ins for true, got %d", len(on)) } fmt.Println("ok: nil=", len(def), "false=", len(off), "true=", len(on)) } ``` ### 2. A `BuiltinsConfig` drops individual tools [Section titled “2. A BuiltinsConfig drops individual tools”](#2-a-builtinsconfig-drops-individual-tools) ```go package main import ( "fmt" "log" toolnexus "github.com/muthuishere/toolnexus/golang" ) func main() { filtered := toolnexus.SelectBuiltins(toolnexus.BuiltinsConfig{ Tools: map[string]bool{"bash": false, "apply_patch": false}, }) if len(filtered) != 8 { log.Fatalf("expected 8 built-ins (10 minus 2), got %d", len(filtered)) } for _, t := range filtered { if t.Name == "bash" || t.Name == "apply_patch" { log.Fatalf("expected %q to be dropped", t.Name) } } // disabled:true wins over an unset enabled, same precedence as MCP. disabledTrue := true whole := toolnexus.SelectBuiltins(toolnexus.BuiltinsConfig{Disabled: &disabledTrue}) if whole != nil { log.Fatalf("expected nil, got %d tools", len(whole)) } fmt.Println("ok:", len(filtered), "built-ins after dropping 2") } ``` ### 3. A parsed `map[string]any` — the shape JSON decodes to [Section titled “3. A parsed map\[string\]any — the shape JSON decodes to”](#3-a-parsed-mapstringany--the-shape-json-decodes-to) The same config a top-level `builtins` key in a parsed `mcp.json`-style document would produce. ```go package main import ( "fmt" "log" toolnexus "github.com/muthuishere/toolnexus/golang" ) func main() { cfg := map[string]any{ "enabled": true, "tools": map[string]any{ "webfetch": false, }, } got := toolnexus.SelectBuiltins(cfg) if len(got) != 9 { log.Fatalf("expected 9 built-ins (10 minus webfetch), got %d", len(got)) } for _, t := range got { if t.Name == "webfetch" { log.Fatal("expected webfetch to be dropped") } } // enabled:false disables the whole source, same as a bare `false`. off := toolnexus.SelectBuiltins(map[string]any{"enabled": false}) if off != nil { log.Fatalf("expected nil, got %d tools", len(off)) } fmt.Println("ok:", len(got), "built-ins from a parsed map config") } ``` ## Parameters [Section titled “Parameters”](#parameters) | Parameter | Type | What it does | | --------- | ----- | ---------------------------------------------------------------------------------------------------------- | | `cfg` | `any` | `nil`, `bool`, `BuiltinsConfig`/`*BuiltinsConfig`, or `map[string]any`. Any other type is treated as “on.” | ## `BuiltinsConfig` [Section titled “BuiltinsConfig”](#builtinsconfig) | Field | Type | What it does | | ---------- | ----------------- | ---------------------------------------------------------------------------------------------------------- | | `Enabled` | `*bool` | `nil` ⇒ on; `false` ⇒ off (unless overridden by `Disabled`). | | `Disabled` | `*bool` | `true` ⇒ off, and wins over `Enabled`. | | `Tools` | `map[string]bool` | Per-tool override. A name mapped to `false` is dropped; `true`/absent stays on; unknown names are ignored. | ## See also [Section titled “See also”](#see-also) * [`CreateBuiltinTools`](/api/go/builtins/create/) — construct every built-in tool directly, outside a toolkit. * [`CreateToolkit`](/api/go/toolkit/create/) — the aggregator; its `Builtins` option calls `SelectBuiltins` internally. # ACPOptions.OnPermission > 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. — Go. **Go** · package `github.com/muthuishere/toolnexus/golang` · [SPEC §8](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [golang/acp.go](https://github.com/muthuishere/toolnexus/blob/main/golang/acp.go) ```go type ACPOptions struct { Command string Args []string // ... OnPermission func(ctx context.Context, params json.RawMessage) (optionID string, err error) OnUpdate func(json.RawMessage) } func (c *ACPClient) Generate(req InProcessRequest) (InProcessResponse, error) func (c *ACPClient) GenerateCtx(ctx context.Context, req InProcessRequest) (InProcessResponse, error) func (c *ACPClient) GenerateStream(ctx context.Context, req InProcessRequest, emit func(StreamEvent)) (InProcessResponse, error) // InProcessOptions.GenerateStream — pass ACPClient.GenerateStream to make Client.Stream work. ``` Four opt-in hooks on an ACP model source (`LoadACP`). With none set, behaviour is byte-identical to before: a permission request gets the first allow-kind option and only `agent_message_chunk` text forms the reply. * **`OnPermission`** — called for every `session/request_permission` with its raw params (`sessionId`, `toolCall`, `options`); toolnexus answers with the returned `optionId`. An error or an empty id answers the first reject-kind option, else `cancelled`. It runs off the read loop, so it may block on a human — but `PermissionTimeout` still bounds the whole turn. * **`OnUpdate`** — the raw params of **every** `session/update` (message and thought chunks, tool calls and their updates, plans), before filtering. It runs on the read loop: it must not block. * **`GenerateCtx`** — cancelling its context sends the `session/cancel` notification, waits for the turn’s reply and returns the cancellation error. The process and session stay warm. * **`GenerateStream`** — passed as `InProcessOptions.GenerateStream`, `Client.Stream` streams instead of refusing: message chunks become text deltas, and the agent’s own tool calls arrive as `tool_call` / `tool_result` events with `Via: "acp"`. The loop **never executes** them. Go only today The other six ports ship an ACP client without these hooks (tracked in `openspec/changes/add-go-host-asks`, task 2.6d). A slow human approval in `OnPermission` can still hit `PermissionTimeout` (default 30s); raise it for interactive approval. ## When to use it [Section titled “When to use it”](#when-to-use-it) Building a UI over a coding agent (devin, opencode, …) driven through ACP: a plan mode that rejects every write, a human approval prompt, a live transcript of the agent’s thoughts and tool calls, or a stop button that cancels the turn without killing the warm session. ## Why this and not the alternative [Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative) Want your own gates on every tool? Use an HTTP model instead With ACP the agent runs its own tools; the loop only sees them as `Via: "acp"` events and cannot gate them with `BeforeTool`. `OnPermission` is the only gate, and only for what the agent asks about. If every tool call must pass your hooks, drive an HTTP model with [`CreateClient`](/api/go/client/create/) and give it your toolkit. ## Examples [Section titled “Examples”](#examples) Each example runs a tiny fake ACP agent — the snippet re-runs its own binary with `FAKE_ACP_AGENT=1` — so no real agent is spawned and nothing touches the network. ### 1. The smallest useful call — a plan mode with `OnPermission` [Section titled “1. The smallest useful call — a plan mode with OnPermission”](#1-the-smallest-useful-call--a-plan-mode-with-onpermission) ```go package main import ( "bufio" "context" "encoding/json" "fmt" "log" "os" toolnexus "github.com/muthuishere/toolnexus/golang" ) // fakeAgent is a tiny ACP agent: this same binary, re-run with FAKE_ACP_AGENT=1. // Each turn it narrates (thought + tool_call), asks one permission, then reports // the tool's result and replies with the option the host picked. func fakeAgent() { in := bufio.NewScanner(os.Stdin) in.Buffer(make([]byte, 0, 1<<20), 1<<24) send := func(v map[string]any) { b, _ := json.Marshal(v); os.Stdout.Write(append(b, '\n')) } update := func(u map[string]any) { send(map[string]any{"jsonrpc": "2.0", "method": "session/update", "params": map[string]any{"sessionId": "s1", "update": u}}) } for in.Scan() { var m struct { ID json.RawMessage `json:"id"` Method string `json:"method"` } if json.Unmarshal(in.Bytes(), &m) != nil { continue } switch m.Method { case "initialize": send(map[string]any{"jsonrpc": "2.0", "id": m.ID, "result": map[string]any{"protocolVersion": 1}}) case "session/new": send(map[string]any{"jsonrpc": "2.0", "id": m.ID, "result": map[string]any{"sessionId": "s1"}}) case "session/prompt": update(map[string]any{"sessionUpdate": "agent_thought_chunk", "content": map[string]any{"type": "text", "text": "thinking"}}) update(map[string]any{"sessionUpdate": "tool_call", "toolCallId": "t1", "title": "ls", "rawInput": map[string]any{"path": "."}}) send(map[string]any{"jsonrpc": "2.0", "id": "p1", "method": "session/request_permission", "params": map[string]any{ "sessionId": "s1", "toolCall": map[string]any{"toolCallId": "t1", "title": "ls"}, "options": []any{ map[string]any{"optionId": "allow", "kind": "allow_once", "name": "Allow"}, map[string]any{"optionId": "reject", "kind": "reject_once", "name": "Reject"}, }, }}) in.Scan() // the host's answer var ans struct { Result struct { Outcome struct { OptionID string `json:"optionId"` } `json:"outcome"` } `json:"result"` } _ = json.Unmarshal(in.Bytes(), &ans) update(map[string]any{"sessionUpdate": "tool_call_update", "toolCallId": "t1", "status": "completed", "content": []any{map[string]any{"type": "content", "content": map[string]any{"type": "text", "text": "a.txt"}}}}) update(map[string]any{"sessionUpdate": "agent_message_chunk", "content": map[string]any{"type": "text", "text": "picked " + ans.Result.Outcome.OptionID}}) send(map[string]any{"jsonrpc": "2.0", "id": m.ID, "result": map[string]any{"stopReason": "end_turn"}}) } } } func agentOptions() toolnexus.ACPOptions { self, err := os.Executable() if err != nil { log.Fatal(err) } return toolnexus.ACPOptions{Command: self, Env: []string{"FAKE_ACP_AGENT=1"}} } func main() { if os.Getenv("FAKE_ACP_AGENT") == "1" { fakeAgent() return } opts := agentOptions() var asked string opts.OnPermission = func(_ context.Context, params json.RawMessage) (string, error) { var p struct { ToolCall struct { Title string `json:"title"` } `json:"toolCall"` } _ = json.Unmarshal(params, &p) asked = p.ToolCall.Title return "reject", nil // a plan mode: never let the agent act } c, err := toolnexus.LoadACP(context.Background(), opts) if err != nil { log.Fatal(err) } defer c.Close() resp, err := c.Generate(toolnexus.InProcessRequest{Messages: []any{map[string]any{"role": "user", "content": "list files"}}}) if err != nil { log.Fatal(err) } if asked != "ls" || resp.Content != "picked reject" { log.Fatalf("asked=%q content=%q", asked, resp.Content) } fmt.Println("ok:", resp.Content) } ``` ### 2. The realistic case — a live transcript with `OnUpdate` [Section titled “2. The realistic case — a live transcript with OnUpdate”](#2-the-realistic-case--a-live-transcript-with-onupdate) ```go package main import ( "bufio" "context" "encoding/json" "fmt" "log" "os" "strings" "sync" toolnexus "github.com/muthuishere/toolnexus/golang" ) // fakeAgent is a tiny ACP agent: this same binary, re-run with FAKE_ACP_AGENT=1. // Each turn it narrates (thought + tool_call), asks one permission, then reports // the tool's result and replies with the option the host picked. func fakeAgent() { in := bufio.NewScanner(os.Stdin) in.Buffer(make([]byte, 0, 1<<20), 1<<24) send := func(v map[string]any) { b, _ := json.Marshal(v); os.Stdout.Write(append(b, '\n')) } update := func(u map[string]any) { send(map[string]any{"jsonrpc": "2.0", "method": "session/update", "params": map[string]any{"sessionId": "s1", "update": u}}) } for in.Scan() { var m struct { ID json.RawMessage `json:"id"` Method string `json:"method"` } if json.Unmarshal(in.Bytes(), &m) != nil { continue } switch m.Method { case "initialize": send(map[string]any{"jsonrpc": "2.0", "id": m.ID, "result": map[string]any{"protocolVersion": 1}}) case "session/new": send(map[string]any{"jsonrpc": "2.0", "id": m.ID, "result": map[string]any{"sessionId": "s1"}}) case "session/prompt": update(map[string]any{"sessionUpdate": "agent_thought_chunk", "content": map[string]any{"type": "text", "text": "thinking"}}) update(map[string]any{"sessionUpdate": "tool_call", "toolCallId": "t1", "title": "ls", "rawInput": map[string]any{"path": "."}}) send(map[string]any{"jsonrpc": "2.0", "id": "p1", "method": "session/request_permission", "params": map[string]any{ "sessionId": "s1", "toolCall": map[string]any{"toolCallId": "t1", "title": "ls"}, "options": []any{ map[string]any{"optionId": "allow", "kind": "allow_once", "name": "Allow"}, map[string]any{"optionId": "reject", "kind": "reject_once", "name": "Reject"}, }, }}) in.Scan() // the host's answer var ans struct { Result struct { Outcome struct { OptionID string `json:"optionId"` } `json:"outcome"` } `json:"result"` } _ = json.Unmarshal(in.Bytes(), &ans) update(map[string]any{"sessionUpdate": "tool_call_update", "toolCallId": "t1", "status": "completed", "content": []any{map[string]any{"type": "content", "content": map[string]any{"type": "text", "text": "a.txt"}}}}) update(map[string]any{"sessionUpdate": "agent_message_chunk", "content": map[string]any{"type": "text", "text": "picked " + ans.Result.Outcome.OptionID}}) send(map[string]any{"jsonrpc": "2.0", "id": m.ID, "result": map[string]any{"stopReason": "end_turn"}}) } } } func agentOptions() toolnexus.ACPOptions { self, err := os.Executable() if err != nil { log.Fatal(err) } return toolnexus.ACPOptions{Command: self, Env: []string{"FAKE_ACP_AGENT=1"}} } func main() { if os.Getenv("FAKE_ACP_AGENT") == "1" { fakeAgent() return } opts := agentOptions() var mu sync.Mutex var kinds []string opts.OnUpdate = func(raw json.RawMessage) { // runs on the read loop: never block here var u struct { Update struct { SessionUpdate string `json:"sessionUpdate"` } `json:"update"` } _ = json.Unmarshal(raw, &u) mu.Lock() kinds = append(kinds, u.Update.SessionUpdate) mu.Unlock() } c, err := toolnexus.LoadACP(context.Background(), opts) if err != nil { log.Fatal(err) } defer c.Close() // GenerateCtx is Generate with a context: cancelling it sends session/cancel. resp, err := c.GenerateCtx(context.Background(), toolnexus.InProcessRequest{Messages: []any{map[string]any{"role": "user", "content": "list files"}}}) if err != nil { log.Fatal(err) } mu.Lock() got := strings.Join(kinds, ",") mu.Unlock() if got != "agent_thought_chunk,tool_call,tool_call_update,agent_message_chunk" { log.Fatalf("updates seen: %s", got) } // Without OnPermission the first allow option is picked, as before. if resp.Content != "picked allow" { log.Fatalf("content = %q (thoughts must never reach it)", resp.Content) } fmt.Println("ok:", got) } ``` ### 3. The full surface — `Client.Stream` over ACP with `GenerateStream` [Section titled “3. The full surface — Client.Stream over ACP with GenerateStream”](#3-the-full-surface--clientstream-over-acp-with-generatestream) ```go package main import ( "bufio" "context" "encoding/json" "fmt" "log" "os" "strings" toolnexus "github.com/muthuishere/toolnexus/golang" ) // fakeAgent is a tiny ACP agent: this same binary, re-run with FAKE_ACP_AGENT=1. // Each turn it narrates (thought + tool_call), asks one permission, then reports // the tool's result and replies with the option the host picked. func fakeAgent() { in := bufio.NewScanner(os.Stdin) in.Buffer(make([]byte, 0, 1<<20), 1<<24) send := func(v map[string]any) { b, _ := json.Marshal(v); os.Stdout.Write(append(b, '\n')) } update := func(u map[string]any) { send(map[string]any{"jsonrpc": "2.0", "method": "session/update", "params": map[string]any{"sessionId": "s1", "update": u}}) } for in.Scan() { var m struct { ID json.RawMessage `json:"id"` Method string `json:"method"` } if json.Unmarshal(in.Bytes(), &m) != nil { continue } switch m.Method { case "initialize": send(map[string]any{"jsonrpc": "2.0", "id": m.ID, "result": map[string]any{"protocolVersion": 1}}) case "session/new": send(map[string]any{"jsonrpc": "2.0", "id": m.ID, "result": map[string]any{"sessionId": "s1"}}) case "session/prompt": update(map[string]any{"sessionUpdate": "agent_thought_chunk", "content": map[string]any{"type": "text", "text": "thinking"}}) update(map[string]any{"sessionUpdate": "tool_call", "toolCallId": "t1", "title": "ls", "rawInput": map[string]any{"path": "."}}) send(map[string]any{"jsonrpc": "2.0", "id": "p1", "method": "session/request_permission", "params": map[string]any{ "sessionId": "s1", "toolCall": map[string]any{"toolCallId": "t1", "title": "ls"}, "options": []any{ map[string]any{"optionId": "allow", "kind": "allow_once", "name": "Allow"}, map[string]any{"optionId": "reject", "kind": "reject_once", "name": "Reject"}, }, }}) in.Scan() // the host's answer var ans struct { Result struct { Outcome struct { OptionID string `json:"optionId"` } `json:"outcome"` } `json:"result"` } _ = json.Unmarshal(in.Bytes(), &ans) update(map[string]any{"sessionUpdate": "tool_call_update", "toolCallId": "t1", "status": "completed", "content": []any{map[string]any{"type": "content", "content": map[string]any{"type": "text", "text": "a.txt"}}}}) update(map[string]any{"sessionUpdate": "agent_message_chunk", "content": map[string]any{"type": "text", "text": "picked " + ans.Result.Outcome.OptionID}}) send(map[string]any{"jsonrpc": "2.0", "id": m.ID, "result": map[string]any{"stopReason": "end_turn"}}) } } } func agentOptions() toolnexus.ACPOptions { self, err := os.Executable() if err != nil { log.Fatal(err) } return toolnexus.ACPOptions{Command: self, Env: []string{"FAKE_ACP_AGENT=1"}} } func main() { if os.Getenv("FAKE_ACP_AGENT") == "1" { fakeAgent() return } acp, err := toolnexus.LoadACP(context.Background(), agentOptions()) if err != nil { log.Fatal(err) } defer acp.Close() client := toolnexus.CreateInProcessClient(toolnexus.InProcessOptions{ Model: "fake-acp", GenerateStream: acp.GenerateStream, // makes Client.Stream work }) ch, err := client.Stream(context.Background(), "list files", nil) if err != nil { log.Fatal(err) } var text strings.Builder var tools []string for ev := range ch { switch ev.Type { case "text": text.WriteString(ev.Delta) case "tool_call", "tool_result": // The agent ran this itself: Via "acp", never executed by the loop. tools = append(tools, ev.Type+":"+ev.Name+":"+ev.Via) case "error": log.Fatal(ev.Err) } } if text.String() != "picked allow" || strings.Join(tools, ",") != "tool_call:ls:acp,tool_result:ls:acp" { log.Fatalf("text=%q tools=%v", text.String(), tools) } fmt.Println("ok:", text.String(), tools) } ``` ## See also [Section titled “See also”](#see-also) * [`CreateClient`](/api/go/client/create/) — The unified client: system prompt, skills injection, parallel and chained tool calls, retries, memory. * [`Client.Stream`](/api/go/client/stream/) — The streaming loop: text deltas, tool-call events, and suspension events as they happen. * [`Hooks`](/api/go/client/hooks/) — Intercept before/after model calls and tool calls: audit, redact, veto, or rewrite. # ClientOptions.APIKeyEnv > 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. — Go. **Go** · package `github.com/muthuishere/toolnexus/golang` · [SPEC §8](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [golang/client.go](https://github.com/muthuishere/toolnexus/blob/main/golang/client.go) ```go type ClientOptions struct { // APIKey is sent as-is. Wins over everything else. APIKey string // APIKeyEnv is the NAME of the env var to read the key from (never the value). APIKeyEnv string // ... } ``` The client decides which key goes to which host, in this order: 1. `APIKey` set ⇒ only it. 2. Else `APIKeyEnv` set ⇒ the env var it names. 3. Else **only** the env var that belongs to the `BaseURL` host, over https: `api.openai.com` → `OPENAI_API_KEY`, `api.anthropic.com` → `ANTHROPIC_API_KEY`, `openrouter.ai` (and subdomains) → `OPENROUTER_API_KEY`. 4. Any other host, or plain http, with neither set ⇒ the run fails with `no API key: set APIKey or APIKeyEnv …`. Nothing is sent. Breaking in 0.22.0 Before 0.22.0 the client took the first of `OPENROUTER_API_KEY`, `OPENAI_API_KEY`, `ANTHROPIC_API_KEY` that was set and sent it to whatever `BaseURL` was configured — so an OpenRouter key could reach api.openai.com, or any key a local server or proxy. A host on a local or custom `BaseURL` that relied on that fallback must now set `APIKey` or `APIKeyEnv`. ## When to use it [Section titled “When to use it”](#when-to-use-it) Set `APIKeyEnv` whenever the `BaseURL` is not one of the three known hosts — a local llama.cpp or vLLM server, a corporate gateway, a proxy — and the key lives in the environment. Set `APIKey` when the host already has the value in hand (a secret manager, a test). For the three known hosts you can set neither: the matching env var is read automatically. ## Why this and not the alternative [Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative) APIKeyEnv over APIKey when the key is in the environment `APIKey: os.Getenv("MY_KEY")` works, but it copies the secret into your options struct, where a `%+v` log line can print it. `APIKeyEnv: "MY_KEY"` keeps only the name in your code; the value is read at request time and never logged. Use `APIKey` only when the value does not come from the environment. ## Examples [Section titled “Examples”](#examples) Each example uses a local `httptest.Server` or a capturing transport — no network, fake keys only. ### 1. The smallest useful call — a local server with `APIKeyEnv` [Section titled “1. The smallest useful call — a local server with APIKeyEnv”](#1-the-smallest-useful-call--a-local-server-with-apikeyenv) ```go package main import ( "context" "fmt" "log" "net/http" "net/http/httptest" "os" toolnexus "github.com/muthuishere/toolnexus/golang" ) func main() { os.Setenv("MY_LOCAL_LLM_KEY", "YOUR_KEY_HERE") var auth string srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { auth = r.Header.Get("Authorization") w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"choices":[{"message":{"role":"assistant","content":"hi"}}]}`)) })) defer srv.Close() client := toolnexus.CreateClient(toolnexus.ClientOptions{ BaseURL: srv.URL, Style: toolnexus.StyleOpenAI, Model: "local", APIKeyEnv: "MY_LOCAL_LLM_KEY", // the NAME, not the value }) res, err := client.Run(context.Background(), "hello", nil) if err != nil { log.Fatal(err) } if auth != "Bearer YOUR_KEY_HERE" { log.Fatalf("unexpected Authorization header %q", auth) } fmt.Println("ok:", res.Text) } ``` ### 2. The realistic case — a local host with no key fails before sending [Section titled “2. The realistic case — a local host with no key fails before sending”](#2-the-realistic-case--a-local-host-with-no-key-fails-before-sending) A provider key in the environment is never sent to a host it does not belong to. ```go package main import ( "context" "fmt" "log" "net/http" "net/http/httptest" "os" "strings" toolnexus "github.com/muthuishere/toolnexus/golang" ) func main() { os.Setenv("OPENAI_API_KEY", "YOUR_KEY_HERE") hits := 0 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { hits++ w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"choices":[{"message":{"role":"assistant","content":"hi"}}]}`)) })) defer srv.Close() client := toolnexus.CreateClient(toolnexus.ClientOptions{ BaseURL: srv.URL, Style: toolnexus.StyleOpenAI, Model: "local", }) _, err := client.Run(context.Background(), "hello", nil) if err == nil || !strings.Contains(err.Error(), "no API key") { log.Fatalf("expected a no-API-key error, got %v", err) } if hits != 0 { log.Fatal("the local server was called — OPENAI_API_KEY must never reach it") } fmt.Println("ok: refused,", hits, "requests sent") } ``` ### 3. The full surface — the host picks its own env var [Section titled “3. The full surface — the host picks its own env var”](#3-the-full-surface--the-host-picks-its-own-env-var) With several provider keys set, `https://api.openai.com` gets `OPENAI_API_KEY` and nothing else. A capturing `HTTPClient` stands in for the network. ```go package main import ( "context" "fmt" "io" "log" "net/http" "os" "strings" toolnexus "github.com/muthuishere/toolnexus/golang" ) type capture struct{ auth, host string } func (c *capture) RoundTrip(r *http.Request) (*http.Response, error) { c.auth, c.host = r.Header.Get("Authorization"), r.URL.Host return &http.Response{ StatusCode: 200, Header: http.Header{"Content-Type": []string{"application/json"}}, Body: io.NopCloser(strings.NewReader(`{"choices":[{"message":{"role":"assistant","content":"hi"}}]}`)), Request: r, }, nil } func main() { os.Setenv("OPENROUTER_API_KEY", "YOUR_OPENROUTER_KEY_HERE") os.Setenv("OPENAI_API_KEY", "YOUR_OPENAI_KEY_HERE") cap := &capture{} client := toolnexus.CreateClient(toolnexus.ClientOptions{ BaseURL: "https://api.openai.com/v1", Style: toolnexus.StyleOpenAI, Model: "gpt-4o-mini", HTTPClient: &http.Client{Transport: cap}, }) if _, err := client.Run(context.Background(), "hello", nil); err != nil { log.Fatal(err) } if cap.host != "api.openai.com" || cap.auth != "Bearer YOUR_OPENAI_KEY_HERE" { log.Fatalf("wrong key for host %s: %q", cap.host, cap.auth) } fmt.Println("ok:", cap.host, "got OPENAI_API_KEY only") } ``` ## See also [Section titled “See also”](#see-also) * [`CreateClient`](/api/go/client/create/) — The unified client: system prompt, skills injection, parallel and chained tool calls, retries, memory. * [`ListModels`](/api/go/client/list-models/) — the same key rules apply when listing a provider’s models. * [`Client.Run`](/api/go/client/run/) — Send a prompt, let the loop call tools until the model stops, get a RunResult. * [`Hooks`](/api/go/client/hooks/) — Intercept before/after model calls and tool calls: audit, redact, veto, or rewrite. # Conversation > Keep a transcript across turns so the model remembers what it already did. — Go. **Go** · package `github.com/muthuishere/toolnexus/golang` · [SPEC §8](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [golang/client.go](https://github.com/muthuishere/toolnexus/blob/main/golang/client.go) ```go func (c *Client) Conversation(tk *Toolkit) *Conversation type Conversation struct { Messages []any // the full running transcript } func (conv *Conversation) Send(ctx context.Context, prompt string) (RunResult, error) func (conv *Conversation) Reset() ``` `Conversation` is a stateful handle bound to one `Client` and `Toolkit`: each `Send` continues the same transcript automatically — the system prompt isn’t re-added, and every prior user/assistant/tool message is replayed as history — so the model remembers what it already said and did. Not safe for concurrent Send calls A `Conversation` owns one mutable `Messages` slice, updated after every `Send`. Call `Send` sequentially from one goroutine; for concurrent per-user conversations, give each user its own `Conversation` (or use `Client.Ask` with a distinct id per user, backed by a [`ConversationStore`](/api/go/client/store/)). ## When to use it [Section titled “When to use it”](#when-to-use-it) Reach for `Conversation` for in-process, multi-turn interactions — a CLI chat loop, a test harness driving several turns, an in-memory agent session — where you hold the handle for the process’s lifetime and don’t need the transcript to survive a restart. ## Why this and not the alternative [Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative) Need it to survive a restart, or keyed by request? `Conversation` keeps its transcript as a Go value in your process — restart the process and it’s gone. Need the transcript to survive that, or to be looked up by an id that arrives with each request (a web handler, an A2A peer)? Use `Client.Ask(ctx, prompt, tk, id)` instead — same “continue the transcript” behavior, but backed by a [`ConversationStore`](/api/go/client/store/) you can point at a file or a database. Don’t need memory at all? [`Client.Run`](/api/go/client/run/) is stateless and simpler. ## Examples [Section titled “Examples”](#examples) Each example points `CreateClient` at a local `httptest.Server` — no real network call, no real API key. ### 1. The smallest useful call — two turns, transcript grows [Section titled “1. The smallest useful call — two turns, transcript grows”](#1-the-smallest-useful-call--two-turns-transcript-grows) ```go package main import ( "context" "fmt" "log" "net/http" "net/http/httptest" toolnexus "github.com/muthuishere/toolnexus/golang" ) func main() { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"choices":[{"message":{"role":"assistant","content":"ack"}}]}`)) })) defer srv.Close() tk, err := toolnexus.CreateToolkit(context.Background(), toolnexus.Options{}) if err != nil { log.Fatal(err) } defer tk.Close() client := toolnexus.CreateClient(toolnexus.ClientOptions{BaseURL: srv.URL, Style: toolnexus.StyleOpenAI, Model: "m", APIKey: "test-key"}) conv := client.Conversation(tk) if _, err := conv.Send(context.Background(), "one"); err != nil { log.Fatal(err) } afterFirst := len(conv.Messages) if _, err := conv.Send(context.Background(), "two"); err != nil { log.Fatal(err) } if len(conv.Messages) <= afterFirst { log.Fatalf("transcript did not grow: %d then %d", afterFirst, len(conv.Messages)) } fmt.Println("ok: transcript grew from", afterFirst, "to", len(conv.Messages)) } ``` ### 2. The realistic case — the system prompt is sent once, not per turn [Section titled “2. The realistic case — the system prompt is sent once, not per turn”](#2-the-realistic-case--the-system-prompt-is-sent-once-not-per-turn) ```go package main import ( "context" "encoding/json" "fmt" "log" "net/http" "net/http/httptest" toolnexus "github.com/muthuishere/toolnexus/golang" ) func main() { var lastRoles []string srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var body struct { Messages []map[string]any `json:"messages"` } _ = json.NewDecoder(r.Body).Decode(&body) lastRoles = nil for _, m := range body.Messages { lastRoles = append(lastRoles, fmt.Sprint(m["role"])) } w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"choices":[{"message":{"role":"assistant","content":"ok"}}]}`)) })) defer srv.Close() tk, err := toolnexus.CreateToolkit(context.Background(), toolnexus.Options{Builtins: false}) if err != nil { log.Fatal(err) } defer tk.Close() client := toolnexus.CreateClient(toolnexus.ClientOptions{ BaseURL: srv.URL, Style: toolnexus.StyleOpenAI, Model: "m", APIKey: "test-key", SystemPrompt: "You are terse.", }) conv := client.Conversation(tk) if _, err := conv.Send(context.Background(), "first"); err != nil { log.Fatal(err) } // turn 1: [system, user] if len(lastRoles) != 2 || lastRoles[0] != "system" { log.Fatalf("turn1 roles = %v, want [system user]", lastRoles) } if _, err := conv.Send(context.Background(), "second"); err != nil { log.Fatal(err) } // turn 2: [system, user, assistant, user] — the system message is NOT repeated. systemCount := 0 for _, r := range lastRoles { if r == "system" { systemCount++ } } if systemCount != 1 || len(lastRoles) != 4 { log.Fatalf("turn2 roles = %v, want exactly one system message and 4 total", lastRoles) } fmt.Println("ok: system sent once, transcript on turn 2 =", lastRoles) } ``` ### 3. The full surface — `Reset` clears memory, then continues fresh [Section titled “3. The full surface — Reset clears memory, then continues fresh”](#3-the-full-surface--reset-clears-memory-then-continues-fresh) ```go package main import ( "context" "fmt" "log" "net/http" "net/http/httptest" toolnexus "github.com/muthuishere/toolnexus/golang" ) func main() { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"choices":[{"message":{"role":"assistant","content":"ack"}}]}`)) })) defer srv.Close() tk, err := toolnexus.CreateToolkit(context.Background(), toolnexus.Options{}) if err != nil { log.Fatal(err) } defer tk.Close() client := toolnexus.CreateClient(toolnexus.ClientOptions{BaseURL: srv.URL, Style: toolnexus.StyleOpenAI, Model: "m", APIKey: "test-key"}) conv := client.Conversation(tk) if _, err := conv.Send(context.Background(), "hello"); err != nil { log.Fatal(err) } if len(conv.Messages) == 0 { log.Fatal("expected a non-empty transcript after Send") } conv.Reset() if len(conv.Messages) != 0 { log.Fatalf("after Reset, Messages = %d, want 0", len(conv.Messages)) } // A fresh Send after Reset starts a new transcript from scratch. res, err := conv.Send(context.Background(), "hello again") if err != nil { log.Fatal(err) } if res.Turns != 1 || len(conv.Messages) != 2 { // [user, assistant] log.Fatalf("post-reset turn unexpected: turns=%d messages=%d", res.Turns, len(conv.Messages)) } fmt.Println("ok: reset then", len(conv.Messages), "messages after one fresh turn") } ``` ## Conversation surface [Section titled “Conversation surface”](#conversation-surface) | Member | What it does | | ------------------------------------------------ | -------------------------------------------------------------------------------------------------------------- | | `Client.Conversation(tk *Toolkit) *Conversation` | Builds a `Conversation` bound to this client and toolkit. | | `Messages []any` | The full running transcript — read it directly to inspect or persist it yourself. | | `Send(ctx, prompt) (RunResult, error)` | Sends the next user turn with prior history retained; updates `Messages` to the returned `RunResult.Messages`. | | `Reset()` | Clears `Messages` — the next `Send` starts a brand-new transcript. | ## See also [Section titled “See also”](#see-also) * [`CreateClient`](/api/go/client/create/) — The unified client: system prompt, skills injection, parallel and chained tool calls, retries, memory. * [`Client.Run`](/api/go/client/run/) — Send a prompt, let the loop call tools until the model stops, get a RunResult. * [`Client.Stream`](/api/go/client/stream/) — The streaming loop: text deltas, tool-call events, and suspension events as they happen. * [`NewInMemoryConversationStore`](/api/go/client/store/) — The `Client.Ask`-based alternative when memory must survive a restart or be keyed by a request id. # CreateClient > The unified client: system prompt, skills injection, parallel and chained tool calls, retries, memory. — Go. **Go** · package `github.com/muthuishere/toolnexus/golang` · [SPEC §8](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [golang/client.go](https://github.com/muthuishere/toolnexus/blob/main/golang/client.go) ```go func CreateClient(opts ClientOptions) *Client type ClientOptions struct { BaseURL string Style ClientStyle // StyleOpenAI | StyleAnthropic Model string APIKey string // "" ⇒ read from OPENROUTER_API_KEY / OPENAI_API_KEY / ANTHROPIC_API_KEY Headers map[string]string SystemPrompt string MaxTurns int // 0 ⇒ 10 // ... the full option list is below } ``` `CreateClient` builds the host loop — the one entry point that turns a `Toolkit` (from `CreateToolkit`) plus a wire style into something that can hold a conversation, call tools, retry transient failures, and report what it did. Everything else in `client/` — `Run`, `Stream`, `Hooks`, `Conversation`, `MetricEvent`, `ErrorInfo`, `ProviderError` — is a facet of the `Client` this call returns. ## When to use it [Section titled “When to use it”](#when-to-use-it) Reach for `CreateClient` the moment you have a `Toolkit` and want the model to actually drive it — send a prompt, let the loop call tools until the model stops, and get back a result. It is the first call in almost every non-trivial example in this repo: `CreateToolkit` builds the tool list, `CreateClient` builds the thing that talks to the LLM and uses that list. `CreateClient` is required (not optional) the moment any of these are true: you want retries on a flaky provider, you want a running conversation across turns (`Store`/`Conversation`), you want hooks around model/tool calls, you want metrics, or you want streaming. Hand-rolling the OpenAI/ Anthropic HTTP calls yourself gets you none of that for free. ## Why this and not the alternative [Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative) Don't need the loop at all? Call the provider directly `CreateClient` exists for the **tool-calling loop** — parallel/chained calls, retries, suspension resume, metrics. If you only need the toolkit’s schema (`tk.ToOpenAI()` / `tk.ToAnthropic()` / `tk.ToGemini()`) to feed your own hand-rolled request/response cycle against a provider SDK you already use, skip `CreateClient` and call the provider yourself — the toolkit’s adapters work standalone. Reach for `CreateClient` as soon as you want the loop to keep going after a tool call without you writing that loop. `CreateClient` takes no `context.Context` and returns no error — construction is synchronous and cannot fail (no I/O happens here; the client is a plain struct with defaults filled in). Contexts are supplied per call, to `Run`/`Ask`/`Stream`, exactly where a real network request happens — mirroring `CreateToolkit`’s `ctx` argument, but scoped to each turn instead of the whole client’s lifetime. ## Examples [Section titled “Examples”](#examples) ### 1. The smallest useful call — one prompt, one turn [Section titled “1. The smallest useful call — one prompt, one turn”](#1-the-smallest-useful-call--one-prompt-one-turn) ```go package main import ( "context" "fmt" "log" "net/http" "net/http/httptest" toolnexus "github.com/muthuishere/toolnexus/golang" ) func main() { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"choices":[{"message":{"role":"assistant","content":"hi"}}],"usage":{"prompt_tokens":4,"completion_tokens":1,"total_tokens":5}}`)) })) defer srv.Close() tk, err := toolnexus.CreateToolkit(context.Background(), toolnexus.Options{}) if err != nil { log.Fatal(err) } defer tk.Close() client := toolnexus.CreateClient(toolnexus.ClientOptions{ BaseURL: srv.URL, Style: toolnexus.StyleOpenAI, Model: "gpt-x", APIKey: "test-key", }) res, err := client.Run(context.Background(), "hi", tk) if err != nil { log.Fatal(err) } fmt.Println("ok:", res.Text) } ``` ### 2. The realistic case — MCP + skills + a native tool, driven by the loop [Section titled “2. The realistic case — MCP + skills + a native tool, driven by the loop”](#2-the-realistic-case--mcp--skills--a-native-tool-driven-by-the-loop) This is `golang/examples/agent/main.go`, run against the shared `examples/` fixtures — the same fixtures every port is tested on. `CreateToolkit` aggregates the tool sources; `CreateClient` is what actually calls them. ```go ctx := context.Background() tk, err := toolnexus.CreateToolkit(ctx, toolnexus.Options{ McpConfig: filepath.Join(root, "mcp.json"), SkillsDir: []string{filepath.Join(root, "skills")}, }) if err != nil { log.Fatal(err) } defer tk.Close() tk.Register(toolnexus.NativeToolReflect("add", "Add two numbers and return the sum.", func(_ context.Context, in addArgs) (string, error) { return strconv.FormatFloat(in.A+in.B, 'f', -1, 64), nil }, )) agent := toolnexus.CreateClient(toolnexus.ClientOptions{ BaseURL: "https://openrouter.ai/api/v1", Style: toolnexus.StyleOpenAI, Model: os.Getenv("OPENROUTER_MODEL"), APIKey: os.Getenv("OPENROUTER_API_KEY"), SystemPrompt: "You are a precise agent. Prefer issuing independent tool calls together in one step.", }) res, err := agent.Run(ctx, "Add 2 and 3, then tell me the sum.", tk) if err != nil { log.Fatal(err) } fmt.Println(res.Text, res.Turns, res.ToolCallCount) ``` ### 3. The full surface — every option at once [Section titled “3. The full surface — every option at once”](#3-the-full-surface--every-option-at-once) Not all of these combine meaningfully in one real client, but this shows every field `ClientOptions` exposes, grouped by what it controls. ```go client := toolnexus.CreateClient(toolnexus.ClientOptions{ // Required: where and how to call the model. BaseURL: "https://openrouter.ai/api/v1", Style: toolnexus.StyleOpenAI, // or toolnexus.StyleAnthropic Model: "openai/gpt-4o-mini", APIKey: os.Getenv("OPENROUTER_API_KEY"), // "" ⇒ env fallback chain Headers: map[string]string{"X-Org": "acme"}, // Prompting + loop shape. SystemPrompt: "You are a precise agent.", MaxTurns: 10, // 0 ⇒ 10 // Resilience — see resilience.mdx and provider-error.mdx. Retries: 2, // 0 ⇒ 2 RetryBaseMs: 500, // 0 ⇒ 500 RetryableStatuses: []int{520, 521}, // ADDED to the default set TimeoutMs: 30_000, // whole-run deadline; 0 ⇒ none OnError: func(toolnexus.ErrorInfo) toolnexus.Tier { return toolnexus.TierRetry }, HTTPClient: &http.Client{Timeout: 10 * time.Second}, // Memory across turns — see conversation.mdx and store.mdx. Store: myPersistentStore, // Observability — see metrics.mdx. OnMetric: func(ev toolnexus.MetricEvent) { forwardToStatsd(ev) }, // Interception — see hooks.mdx. Hooks: &toolnexus.Hooks{ BeforeToolCall: func(ctx context.Context, call toolnexus.ToolCall) (*toolnexus.ToolCall, error) { return &call, nil }, }, // §10 suspension resolver — resumes a tool that returned metadata.pending. WaitFor: func(req toolnexus.Request) (toolnexus.Answer, error) { return promptTheUser(req) }, // Request shaping (§8 gaps) — merge extra body keys or rewrite the body // just before it is sent, without a proxy in front of the provider. RequestParams: map[string]any{"max_tokens": 4096}, BodyTransform: func(body map[string]any) map[string]any { body["stream_options"] = map[string]any{"include_usage": true} return body }, // Multimodal content parts (§1B/§8A). MaxPartBytes: 0, // 0 ⇒ DefaultMaxPartBytes OnUnsupportedPart: "", // "" default | "error" | "text" }) ``` ## Options [Section titled “Options”](#options) The required fields are `BaseURL`, `Style`, `Model`, and `APIKey` (or an env var fallback). Every other field has a documented zero-value default, so an empty `ClientOptions{}` beyond those four is a valid, minimal client. | Field | Type | What it does | | --------------------------------------------- | ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `BaseURL` | `string` | The provider endpoint to call. | | `Style` | `ClientStyle` | `StyleOpenAI` \| `StyleAnthropic` — selects the wire format. | | `Model` | `string` | The model id sent with every request. | | `APIKey` | `string` | `""` ⇒ read from `OPENROUTER_API_KEY` / `OPENAI_API_KEY` / `ANTHROPIC_API_KEY`. Never printed. | | `Headers` | `map[string]string` | Extra request headers. | | `SystemPrompt` | `string` | Prepended to the toolkit’s `SkillsPrompt()`. | | `MaxTurns` | `int` | Caps the tool-calling loop. `0` ⇒ 10. | | `Retries`, `RetryBaseMs`, `RetryableStatuses` | resilience | Transient-failure retry budget and backoff. See [`ErrorInfo`](/api/go/client/resilience/). | | `TimeoutMs` | `int` | Whole-run deadline in ms. `0` ⇒ no deadline. | | `OnError` | `func(ErrorInfo) Tier` | Classify a failed LLM attempt as retry or fail. `nil` ⇒ default classifier. See [`ErrorInfo`](/api/go/client/resilience/). | | `HTTPClient` | `*http.Client` | Overrides the client used for LLM requests. `nil` ⇒ `http.DefaultClient`. | | `Store` | `ConversationStore` | Conversation provider for `Ask(prompt, id)`. `nil` ⇒ in-memory. See [`Conversation`](/api/go/client/conversation/) and [`NewInMemoryConversationStore`](/api/go/client/store/). | | `OnMetric` | `func(MetricEvent)` | Semantic observability sink, one event per LLM/tool/run. See [`MetricEvent`](/api/go/client/metrics/). | | `Hooks` | `*Hooks` | Lifecycle callbacks before/after model and tool calls. See [`Hooks`](/api/go/client/hooks/). | | `WaitFor` | `func(Request) (Answer, error)` | §10 suspension resolver; resumes a tool whose result carries `metadata.pending`. | | `RequestParams` | `map[string]any` | Extra top-level keys shallow-merged into every request body; wins on collision. | | `BodyTransform` | `func(map[string]any) map[string]any` | Rewrites the assembled body just before marshal, on every LLM call. | | `MaxPartBytes` | `int` | Caps a content part’s decoded byte length. `0` ⇒ `DefaultMaxPartBytes`. | | `OnUnsupportedPart` | `string` | `""` default (attached parts error, tool/MCP-derived parts degrade) \| `"error"` \| `"text"`. | A non-2xx provider response surfaces as a typed [`ProviderError`](/api/go/client/provider-error/), regardless of which of the options above is set. ## Which API key is sent where [Section titled “Which API key is sent where”](#which-api-key-is-sent-where) `APIKey` set ⇒ only it. Else `APIKeyEnv` (an env var **name**) if set. Else only the env var that belongs to the `BaseURL` host, over https: `api.openai.com` → `OPENAI_API_KEY`, `api.anthropic.com` → `ANTHROPIC_API_KEY`, `openrouter.ai` → `OPENROUTER_API_KEY`. Any other host — a local server, a proxy — without `APIKey`/`APIKeyEnv` fails with `no API key`. **Breaking in 0.22.0:** there is no longer a fallback across those env vars. See [`ClientOptions.APIKeyEnv`](/api/go/client/api-key/). Other options added in 0.22.0: [`PromptCache`](/api/go/client/prompt-cache/), [`ToolTiers`](/api/go/client/tool-tiers/), `WaitForCtx` ([WaitFor](/api/go/suspension/wait-for/)), and [`ListModels`](/api/go/client/list-models/) for model pickers. ## See also [Section titled “See also”](#see-also) * [`Client.Run`](/api/go/client/run/) — Send a prompt, let the loop call tools until the model stops, get a RunResult. * [`Client.Stream`](/api/go/client/stream/) — The streaming loop: text deltas, tool-call events, and suspension events as they happen. * [`Hooks`](/api/go/client/hooks/) — Intercept before/after model calls and tool calls: audit, redact, veto, or rewrite. * [`Conversation`](/api/go/client/conversation/) — Keep a transcript across turns so the model remembers what it already did. * [`ErrorInfo`](/api/go/client/resilience/) — Classify an LLM error into retry / fail, bound a run with a deadline, and cancel cleanly. * [`ProviderError`](/api/go/client/provider-error/) — A non-2xx response from the model endpoint raises a typed provider error. * [`MetricEvent`](/api/go/client/metrics/) — Emit one metric event per model call and per tool call: tokens, latency, outcome. # Hooks > Intercept before/after model calls and tool calls: audit, redact, veto, or rewrite. — Go. **Go** · package `github.com/muthuishere/toolnexus/golang` · [SPEC §8](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [golang/client.go](https://github.com/muthuishere/toolnexus/blob/main/golang/client.go) ```go type Hooks struct { BeforeLLM func(ctx context.Context, ev BeforeLLMEvent) (*LLMOverride, error) AfterLLM func(ctx context.Context, ev AfterLLMEvent) error BeforeTool func(ctx context.Context, ev BeforeToolEvent) (*ToolOverride, error) AfterTool func(ctx context.Context, ev AfterToolEvent) (*ToolOverride, error) } ``` Four lifecycle callbacks wired around the agent loop via `ClientOptions.Hooks`. A `nil` field is skipped — set only the ones you need. Any hook returning a non-nil `error` aborts the run with that error. `BeforeTool` and `BeforeLLM` can also mutate or short-circuit what happens next by returning a non-nil override. ## When to use it [Section titled “When to use it”](#when-to-use-it) Reach for `Hooks` whenever behavior needs to live *around* the loop rather than inside a tool: structured logging of every model/tool call, redacting secrets out of a tool’s output before it reaches the model, denying a dangerous tool call by policy, injecting or trimming conversation history before a call, or capturing cost/latency per call for your own telemetry pipeline. ## Why this and not the alternative [Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative) Only need numbers, not control? Use OnMetric instead `Hooks` gives you the actual request/response payloads and the power to change them — `BeforeTool` can deny a call outright, `AfterTool` can redact an output, `BeforeLLM` can rewrite the message history sent to the model. If all you want is counts/latency/tokens for observability with no interception, [`OnMetric`](/api/go/client/metrics/) is a lighter fit — it only observes, and it also feeds the built-in Prometheus registry (`Client.Metrics()`) for free. ## Examples [Section titled “Examples”](#examples) Each example points `CreateClient` at a local `httptest.Server` — no real network call, no real API key — so the hooks can be exercised deterministically. ### 1. The smallest useful call — deny a tool call with `BeforeTool` [Section titled “1. The smallest useful call — deny a tool call with BeforeTool”](#1-the-smallest-useful-call--deny-a-tool-call-with-beforetool) Returning a `ToolOverride` with `Result` set short-circuits the tool: the real function never runs. ```go package main import ( "context" "fmt" "log" "net/http" "net/http/httptest" toolnexus "github.com/muthuishere/toolnexus/golang" ) func main() { ran := false deleteAll := toolnexus.NativeTool( "delete_all", "delete everything", toolnexus.JSONSchema{"type": "object", "properties": map[string]any{}}, func(_ context.Context, _ map[string]any) (string, error) { ran = true // must never fire — the hook denies it first return "deleted", nil }, ) tk, err := toolnexus.CreateToolkit(context.Background(), toolnexus.Options{ExtraTools: []toolnexus.Tool{deleteAll}}) if err != nil { log.Fatal(err) } defer tk.Close() srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"choices":[{"message":{"role":"assistant","content":null,"tool_calls":[{"id":"c1","type":"function","function":{"name":"delete_all","arguments":"{}"}}]}}]}`)) })) defer srv.Close() client := toolnexus.CreateClient(toolnexus.ClientOptions{ BaseURL: srv.URL, Style: toolnexus.StyleOpenAI, Model: "m", APIKey: "test-key", Hooks: &toolnexus.Hooks{ BeforeTool: func(_ context.Context, ev toolnexus.BeforeToolEvent) (*toolnexus.ToolOverride, error) { if ev.Name == "delete_all" { return &toolnexus.ToolOverride{Result: &toolnexus.ToolResult{Output: "denied by policy", IsError: true}}, nil } return nil, nil }, }, }) res, err := client.Run(context.Background(), "delete everything", tk) if err != nil { log.Fatal(err) } if ran { log.Fatal("the real tool ran — BeforeTool should have short-circuited it") } if res.ToolCalls[0].Output != "denied by policy" || !res.ToolCalls[0].IsError { log.Fatalf("unexpected tool call record: %+v", res.ToolCalls[0]) } fmt.Println("ok:", res.ToolCalls[0].Output) } ``` ### 2. The realistic case — audit with `AfterLLM`, redact with `AfterTool` [Section titled “2. The realistic case — audit with AfterLLM, redact with AfterTool”](#2-the-realistic-case--audit-with-afterllm-redact-with-aftertool) `AfterLLM` observes each raw model response (useful for cost/tracing); `AfterTool` can rewrite a tool’s output before it goes back to the model — here, redacting a secret leaking out of a tool. ```go package main import ( "context" "fmt" "log" "net/http" "net/http/httptest" "strings" toolnexus "github.com/muthuishere/toolnexus/golang" ) func main() { lookup := toolnexus.NativeTool( "lookup_config", "lookup a config value", toolnexus.JSONSchema{"type": "object", "properties": map[string]any{}}, func(_ context.Context, _ map[string]any) (string, error) { return "db_password=hunter2", nil // a tool that accidentally over-shares }, ) tk, err := toolnexus.CreateToolkit(context.Background(), toolnexus.Options{ExtraTools: []toolnexus.Tool{lookup}}) if err != nil { log.Fatal(err) } defer tk.Close() turn := 0 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { turn++ w.Header().Set("Content-Type", "application/json") if turn == 1 { _, _ = w.Write([]byte(`{"choices":[{"message":{"role":"assistant","content":null,"tool_calls":[{"id":"c1","type":"function","function":{"name":"lookup_config","arguments":"{}"}}]}}]}`)) return } _, _ = w.Write([]byte(`{"choices":[{"message":{"role":"assistant","content":"done"}}]}`)) })) defer srv.Close() var llmCalls int client := toolnexus.CreateClient(toolnexus.ClientOptions{ BaseURL: srv.URL, Style: toolnexus.StyleOpenAI, Model: "m", APIKey: "test-key", Hooks: &toolnexus.Hooks{ AfterLLM: func(_ context.Context, _ toolnexus.AfterLLMEvent) error { llmCalls++ // audit hook — just counts, changes nothing return nil }, AfterTool: func(_ context.Context, ev toolnexus.AfterToolEvent) (*toolnexus.ToolOverride, error) { if strings.Contains(ev.Result.Output, "password=") { redacted := ev.Result redacted.Output = "db_password=***REDACTED***" return &toolnexus.ToolOverride{Result: &redacted}, nil } return nil, nil }, }, }) res, err := client.Run(context.Background(), "what's the db password?", tk) if err != nil { log.Fatal(err) } if llmCalls != 2 { log.Fatalf("AfterLLM fired %d times, want 2", llmCalls) } if res.ToolCalls[0].Output != "db_password=***REDACTED***" { log.Fatalf("AfterTool did not redact: %+v", res.ToolCalls[0]) } fmt.Println("ok: redacted ->", res.ToolCalls[0].Output) } ``` ### 3. The full surface — all four hooks in one run [Section titled “3. The full surface — all four hooks in one run”](#3-the-full-surface--all-four-hooks-in-one-run) `BeforeLLM` trims the message history before it goes out; `BeforeTool` rewrites the call’s arguments; the loop still runs the same request/response cycle underneath. ```go package main import ( "context" "fmt" "log" "net/http" "net/http/httptest" toolnexus "github.com/muthuishere/toolnexus/golang" ) func main() { echo := toolnexus.NativeTool( "echo", "echo a value", toolnexus.JSONSchema{"type": "object", "properties": map[string]any{"v": map[string]any{"type": "string"}}}, func(_ context.Context, args map[string]any) (string, error) { return fmt.Sprintf("%v", args["v"]), nil }, ) tk, err := toolnexus.CreateToolkit(context.Background(), toolnexus.Options{ExtraTools: []toolnexus.Tool{echo}}) if err != nil { log.Fatal(err) } defer tk.Close() turn := 0 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { turn++ w.Header().Set("Content-Type", "application/json") if turn == 1 { _, _ = w.Write([]byte(`{"choices":[{"message":{"role":"assistant","content":null,"tool_calls":[{"id":"c1","type":"function","function":{"name":"echo","arguments":"{\"v\":\"original\"}"}}]}}]}`)) return } _, _ = w.Write([]byte(`{"choices":[{"message":{"role":"assistant","content":"ok"}}]}`)) })) defer srv.Close() var beforeLLMTurns, afterLLMTurns, beforeToolCalls, afterToolCalls int client := toolnexus.CreateClient(toolnexus.ClientOptions{ BaseURL: srv.URL, Style: toolnexus.StyleOpenAI, Model: "m", APIKey: "test-key", SystemPrompt: "SYS", Hooks: &toolnexus.Hooks{ BeforeLLM: func(_ context.Context, ev toolnexus.BeforeLLMEvent) (*toolnexus.LLMOverride, error) { beforeLLMTurns++ return nil, nil // observe only — a non-nil override would replace Messages/Tools }, AfterLLM: func(_ context.Context, _ toolnexus.AfterLLMEvent) error { afterLLMTurns++ return nil }, BeforeTool: func(_ context.Context, ev toolnexus.BeforeToolEvent) (*toolnexus.ToolOverride, error) { beforeToolCalls++ // Rewrite the call's arguments before it runs. return &toolnexus.ToolOverride{Args: map[string]any{"v": "rewritten"}}, nil }, AfterTool: func(_ context.Context, ev toolnexus.AfterToolEvent) (*toolnexus.ToolOverride, error) { afterToolCalls++ return nil, nil // observe only }, }, }) res, err := client.Run(context.Background(), "echo original", tk) if err != nil { log.Fatal(err) } if beforeLLMTurns != 2 || afterLLMTurns != 2 || beforeToolCalls != 1 || afterToolCalls != 1 { log.Fatalf("hook counts = %d/%d/%d/%d, want 2/2/1/1", beforeLLMTurns, afterLLMTurns, beforeToolCalls, afterToolCalls) } if res.ToolCalls[0].Output != "rewritten" { log.Fatalf("BeforeTool did not rewrite args: %+v", res.ToolCalls[0]) } fmt.Println("ok:", res.ToolCalls[0].Output) } ``` ## Event and override types [Section titled “Event and override types”](#event-and-override-types) | Type | Passed to | Fields | | ----------------- | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | | `BeforeLLMEvent` | `BeforeLLM` | `Messages`, `Tools`, `Model`, `Turn`. | | `LLMOverride` | returned from `BeforeLLM` | `Messages`, `Tools` — a non-nil slice replaces that value. | | `AfterLLMEvent` | `AfterLLM` | `Response` (decoded provider payload, carries usage), `Model`, `Turn`. | | `BeforeToolEvent` | `BeforeTool` | `Name`, `Args`, `ID`, `Turn`. | | `AfterToolEvent` | `AfterTool` | `Name`, `Args`, `Result`, `ID`, `Turn`. | | `ToolOverride` | returned from `BeforeTool` / `AfterTool` | In `BeforeTool`: `Result` short-circuits (the real tool never runs), else `Args` rewrites the call. In `AfterTool`: `Result` replaces the result. | ## Tool hints and permission gates [Section titled “Tool hints and permission gates”](#tool-hints-and-permission-gates) `BeforeToolEvent.Hints` is the called tool’s `*ToolHints{ReadOnly, Destructive, OpenWorld}` — `nil` when unknown (treat as mutating). Every builtin sets them (`read`, `grep`, `glob`, `question`, `todowrite` read-only; `webfetch` read-only and open-world; `write`, `edit`, `apply_patch` destructive; `bash` destructive and open-world), and MCP tools map their `readOnlyHint`/`destructiveHint`/`openWorldHint` annotations. Hints are advisory and never sent to the model. A permission gate is just `BeforeTool` — there is no separate permit option: **allow** = return `nil, nil`; **deny** = return an error `Result` (the model sees it, the run continues); **ask** = return `Result: Pending(request)`, which `WaitFor` / `WaitForCtx` resolves. Under [`ToolTiers`](/api/go/client/tool-tiers/) the hook sees the real tool behind `call_tool`. ```go package main import ( "context" "fmt" "log" "net/http" "net/http/httptest" "os" "path/filepath" toolnexus "github.com/muthuishere/toolnexus/golang" ) func main() { dir, _ := os.MkdirTemp("", "gate") defer os.RemoveAll(dir) target := filepath.Join(dir, "x.txt") tk, err := toolnexus.CreateToolkit(context.Background(), toolnexus.Options{Builtins: true}) if err != nil { log.Fatal(err) } defer tk.Close() turn := 0 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { turn++ w.Header().Set("Content-Type", "application/json") if turn == 1 { _, _ = fmt.Fprintf(w, `{"choices":[{"message":{"role":"assistant","content":null,"tool_calls":[{"id":"c1","type":"function","function":{"name":"write","arguments":"{\"path\":\"%s\",\"content\":\"hi\"}"}}]}}]}`, target) return } _, _ = w.Write([]byte(`{"choices":[{"message":{"role":"assistant","content":"ok"}}]}`)) })) defer srv.Close() var seen *toolnexus.ToolHints client := toolnexus.CreateClient(toolnexus.ClientOptions{ BaseURL: srv.URL, Style: toolnexus.StyleOpenAI, Model: "m", APIKey: "YOUR_KEY_HERE", Hooks: &toolnexus.Hooks{BeforeTool: func(_ context.Context, ev toolnexus.BeforeToolEvent) (*toolnexus.ToolOverride, error) { seen = ev.Hints if ev.Hints != nil && ev.Hints.ReadOnly { return nil, nil // read-only: let it run } return &toolnexus.ToolOverride{Result: &toolnexus.ToolResult{Output: "denied: read-only mode", IsError: true}}, nil }}, }) if _, err := client.Run(context.Background(), "write a file", tk); err != nil { log.Fatal(err) } if seen == nil || !seen.Destructive { log.Fatalf("write should be hinted destructive: %+v", seen) } if _, err := os.Stat(target); err == nil { log.Fatal("the gate should have stopped the write") } fmt.Println("ok: write denied, hints =", *seen) } ``` ## See also [Section titled “See also”](#see-also) * [`CreateClient`](/api/go/client/create/) — The unified client: system prompt, skills injection, parallel and chained tool calls, retries, memory. * [`Client.Run`](/api/go/client/run/) — Send a prompt, let the loop call tools until the model stops, get a RunResult. * [`Client.Stream`](/api/go/client/stream/) — The streaming loop: text deltas, tool-call events, and suspension events as they happen. * [`MetricEvent`](/api/go/client/metrics/) — A lighter, observe-only alternative when you don’t need to intercept. # ListModels > List a provider's models (id, display name, context length when reported), sorted by id, under the same key rules as the client. — Go. **Go** · package `github.com/muthuishere/toolnexus/golang` · [SPEC §8](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [golang/list\_models.go](https://github.com/muthuishere/toolnexus/blob/main/golang/list_models.go) ```go func ListModels(ctx context.Context, opts ClientOptions) ([]ModelInfo, error) type ModelInfo struct { ID string Name string // display name, when the provider gives one ContextLength int // tokens, when reported (OpenRouter does); 0 = unknown } ``` One listing call against `opts.BaseURL`, no retries, sorted by `ID`: * `StyleOpenAI` — `GET {base}/models` (OpenRouter’s `context_length` is read when present). * `StyleAnthropic` — `GET {base}/v1/models`, following `has_more` / `last_id` pagination. It uses the same key rules as the client ([`APIKey` / `APIKeyEnv` / the host’s own env var](/api/go/client/api-key/)) and `opts.HTTPClient`. ## When to use it [Section titled “When to use it”](#when-to-use-it) A model picker or a `/model` command in a host UI, a startup check that the configured model exists, or sizing a context window (`ContextLength`) before building a [`ToolTiers`](/api/go/client/tool-tiers/) budget. ## Why this and not the alternative [Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative) Know the model already? Skip it `ListModels` is a network round trip with its own failure modes. If the model id is fixed in config, just set `ClientOptions.Model`; a wrong id fails on the first `Run` anyway. ## Examples [Section titled “Examples”](#examples) ### 1. The smallest useful call — an OpenAI-style server [Section titled “1. The smallest useful call — an OpenAI-style server”](#1-the-smallest-useful-call--an-openai-style-server) ```go package main import ( "context" "fmt" "log" "net/http" "net/http/httptest" toolnexus "github.com/muthuishere/toolnexus/golang" ) func main() { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/models" { http.NotFound(w, r) return } w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"data":[{"id":"zeta"},{"id":"alpha"}]}`)) })) defer srv.Close() models, err := toolnexus.ListModels(context.Background(), toolnexus.ClientOptions{ BaseURL: srv.URL, Style: toolnexus.StyleOpenAI, APIKey: "YOUR_KEY_HERE", }) if err != nil { log.Fatal(err) } if len(models) != 2 || models[0].ID != "alpha" { log.Fatalf("expected sorted ids, got %+v", models) } fmt.Println("ok:", models[0].ID, models[1].ID) } ``` ### 2. The realistic case — OpenRouter-style names and context lengths [Section titled “2. The realistic case — OpenRouter-style names and context lengths”](#2-the-realistic-case--openrouter-style-names-and-context-lengths) ```go package main import ( "context" "fmt" "log" "net/http" "net/http/httptest" toolnexus "github.com/muthuishere/toolnexus/golang" ) func main() { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"data":[{"id":"vendor/big","name":"Big Model","context_length":200000}]}`)) })) defer srv.Close() models, err := toolnexus.ListModels(context.Background(), toolnexus.ClientOptions{ BaseURL: srv.URL, Style: toolnexus.StyleOpenAI, APIKey: "YOUR_KEY_HERE", }) if err != nil { log.Fatal(err) } m := models[0] if m.Name != "Big Model" || m.ContextLength != 200000 { log.Fatalf("unexpected model info: %+v", m) } fmt.Printf("ok: %s (%s) %d tokens\n", m.ID, m.Name, m.ContextLength) } ``` ### 3. The full surface — Anthropic pagination and the key rule [Section titled “3. The full surface — Anthropic pagination and the key rule”](#3-the-full-surface--anthropic-pagination-and-the-key-rule) ```go package main import ( "context" "fmt" "log" "net/http" "net/http/httptest" "strings" toolnexus "github.com/muthuishere/toolnexus/golang" ) func main() { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") if r.URL.Query().Get("after_id") == "" { _, _ = w.Write([]byte(`{"data":[{"id":"claude-b","display_name":"B"}],"has_more":true,"last_id":"claude-b"}`)) return } _, _ = w.Write([]byte(`{"data":[{"id":"claude-a","display_name":"A"}],"has_more":false}`)) })) defer srv.Close() models, err := toolnexus.ListModels(context.Background(), toolnexus.ClientOptions{ BaseURL: srv.URL, Style: toolnexus.StyleAnthropic, APIKey: "YOUR_KEY_HERE", }) if err != nil { log.Fatal(err) } if len(models) != 2 || models[0].ID != "claude-a" || models[0].Name != "A" { log.Fatalf("expected both pages, sorted: %+v", models) } // A local host with no APIKey/APIKeyEnv is refused, as Run would be. _, err = toolnexus.ListModels(context.Background(), toolnexus.ClientOptions{BaseURL: srv.URL, Style: toolnexus.StyleOpenAI}) if err == nil || !strings.Contains(err.Error(), "no API key") { log.Fatalf("expected a no-API-key error, got %v", err) } fmt.Println("ok:", len(models), "models over 2 pages; keyless local call refused") } ``` ## See also [Section titled “See also”](#see-also) * [`ClientOptions.APIKeyEnv`](/api/go/client/api-key/) — the key rules `ListModels` shares with the client. * [`ToolTiers`](/api/go/client/tool-tiers/) — `ContextWindow` sizes the default tier budget. * [`CreateClient`](/api/go/client/create/) — The unified client: system prompt, skills injection, parallel and chained tool calls, retries, memory. # MetricEvent > Emit one metric event per model call and per tool call: tokens, latency, outcome. — Go. **Go** · package `github.com/muthuishere/toolnexus/golang` · [SPEC §8](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [golang/client.go](https://github.com/muthuishere/toolnexus/blob/main/golang/client.go) ```go type MetricEvent struct { Event string // "llm" | "tool" | "run" Model string Status string // "ok" | "error" ("llm") Ms int64 // ... plus Event-specific fields (see the table below) } // ClientOptions.OnMetric func(MetricEvent) func (c *Client) Metrics() string // Prometheus text exposition ``` `OnMetric` is a semantic observability sink: the client calls it once per LLM call (`Event: "llm"`), once per tool call (`Event: "tool"`), and once per run/ask (`Event: "run"`), as the loop runs. It’s a readable event, not a raw counter — forward it to statsd, structured logs, OpenTelemetry, anywhere. The same events also feed a built-in Prometheus registry, rendered by `Client.Metrics()`, at no cost when `OnMetric` is left `nil`. ## When to use it [Section titled “When to use it”](#when-to-use-it) Reach for `OnMetric` for observability that doesn’t need to change anything: per-call cost tracking, structured logs of every LLM/tool call, dashboards, alerting on error rate or latency. Reach for `Client.Metrics()` when you already run a Prometheus scraper and just want a `/metrics` endpoint — its text output is byte-identical across all eight ports. ## Why this and not the alternative [Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative) Need to change behavior, not just observe it? Use Hooks `OnMetric` only observes — it cannot deny a tool call, rewrite arguments, or replace an output. Reach for [`Hooks`](/api/go/client/hooks/) when you need interception (policy, redaction, short-circuiting); reach for `OnMetric` when you just need numbers and don’t want the extra plumbing `Hooks` requires to also get a metric out. ## Examples [Section titled “Examples”](#examples) Each example points `CreateClient` at a local `httptest.Server` — no real network call, no real API key. ### 1. The smallest useful call — one `OnMetric` sink [Section titled “1. The smallest useful call — one OnMetric sink”](#1-the-smallest-useful-call--one-onmetric-sink) ```go package main import ( "context" "fmt" "log" "net/http" "net/http/httptest" toolnexus "github.com/muthuishere/toolnexus/golang" ) func main() { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"choices":[{"message":{"role":"assistant","content":"hi"}}],"usage":{"prompt_tokens":4,"completion_tokens":1,"total_tokens":5}}`)) })) defer srv.Close() tk, err := toolnexus.CreateToolkit(context.Background(), toolnexus.Options{}) if err != nil { log.Fatal(err) } defer tk.Close() var events []toolnexus.MetricEvent client := toolnexus.CreateClient(toolnexus.ClientOptions{ BaseURL: srv.URL, Style: toolnexus.StyleOpenAI, Model: "gpt-x", APIKey: "test-key", OnMetric: func(ev toolnexus.MetricEvent) { events = append(events, ev) }, }) if _, err := client.Run(context.Background(), "hi", tk); err != nil { log.Fatal(err) } if len(events) != 2 { // one "llm", one terminal "run" log.Fatalf("events = %d, want 2", len(events)) } if events[0].Event != "llm" || events[0].Status != "ok" || events[1].Event != "run" { log.Fatalf("unexpected events: %+v", events) } fmt.Println("ok:", events[0].Event, events[1].Event) } ``` ### 2. The realistic case — `llm`, `tool`, and `run` events for a tool-calling turn [Section titled “2. The realistic case — llm, tool, and run events for a tool-calling turn”](#2-the-realistic-case--llm-tool-and-run-events-for-a-tool-calling-turn) ```go package main import ( "context" "fmt" "log" "net/http" "net/http/httptest" toolnexus "github.com/muthuishere/toolnexus/golang" ) func main() { add := toolnexus.NativeTool( "add", "add two numbers", toolnexus.JSONSchema{"type": "object", "properties": map[string]any{ "a": map[string]any{"type": "number"}, "b": map[string]any{"type": "number"}, }}, func(_ context.Context, args map[string]any) (string, error) { a, _ := args["a"].(float64) b, _ := args["b"].(float64) return fmt.Sprintf("%v", a+b), nil }, ) tk, err := toolnexus.CreateToolkit(context.Background(), toolnexus.Options{ExtraTools: []toolnexus.Tool{add}, Builtins: false}) if err != nil { log.Fatal(err) } defer tk.Close() turn := 0 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { turn++ w.Header().Set("Content-Type", "application/json") if turn == 1 { _, _ = w.Write([]byte(`{"choices":[{"message":{"role":"assistant","content":null,"tool_calls":[{"id":"c1","type":"function","function":{"name":"add","arguments":"{\"a\":2,\"b\":3}"}}]}}],"usage":{"prompt_tokens":5,"completion_tokens":4,"total_tokens":9}}`)) return } _, _ = w.Write([]byte(`{"choices":[{"message":{"role":"assistant","content":"5"}}],"usage":{"prompt_tokens":6,"completion_tokens":1,"total_tokens":7}}`)) })) defer srv.Close() var events []toolnexus.MetricEvent client := toolnexus.CreateClient(toolnexus.ClientOptions{ BaseURL: srv.URL, Style: toolnexus.StyleOpenAI, Model: "gpt-x", APIKey: "test-key", OnMetric: func(ev toolnexus.MetricEvent) { events = append(events, ev) }, }) if _, err := client.Run(context.Background(), "add them", tk); err != nil { log.Fatal(err) } var llmCount int var sawTool bool for _, e := range events { switch e.Event { case "llm": llmCount++ case "tool": sawTool = true if e.Tool != "add" || e.Source != "native" || e.IsError { log.Fatalf("unexpected tool event: %+v", e) } } } if llmCount != 2 || !sawTool { log.Fatalf("llmCount=%d sawTool=%v, want 2/true", llmCount, sawTool) } run := events[len(events)-1] if run.Event != "run" || run.Turns != 2 || run.ToolCalls != 1 || run.TotalTokens != 16 { log.Fatalf("unexpected run event: %+v", run) } fmt.Println("ok: llm x", llmCount, "tool calls:", run.ToolCalls, "tokens:", run.TotalTokens) } ``` ### 3. The full surface — `Client.Metrics()` Prometheus text [Section titled “3. The full surface — Client.Metrics() Prometheus text”](#3-the-full-surface--clientmetrics-prometheus-text) Every event that reaches `OnMetric` also feeds a built-in registry, rendered as Prometheus text exposition format — valid (only `# HELP`/`# TYPE` lines) even before any activity. ```go package main import ( "context" "fmt" "log" "net/http" "net/http/httptest" "strings" toolnexus "github.com/muthuishere/toolnexus/golang" ) func main() { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"choices":[{"message":{"role":"assistant","content":"hi"}}],"usage":{"prompt_tokens":3,"completion_tokens":2,"total_tokens":5}}`)) })) defer srv.Close() tk, err := toolnexus.CreateToolkit(context.Background(), toolnexus.Options{}) if err != nil { log.Fatal(err) } defer tk.Close() client := toolnexus.CreateClient(toolnexus.ClientOptions{BaseURL: srv.URL, Style: toolnexus.StyleOpenAI, Model: "gpt-x", APIKey: "test-key"}) // Before any activity: empty-but-valid — only comment lines, no samples. before := client.Metrics() if !strings.HasPrefix(before, "# HELP") { log.Fatalf("expected Metrics() to start with a HELP comment, got %q", before) } if _, err := client.Run(context.Background(), "hi", tk); err != nil { log.Fatal(err) } after := client.Metrics() if !strings.Contains(after, `toolnexus_llm_requests_total{model="gpt-x",status="ok"} 1`) { log.Fatalf("expected an llm_requests_total sample, got:\n%s", after) } if !strings.Contains(after, "toolnexus_run_errors_total") { log.Fatalf("expected the run_errors_total series to exist, got:\n%s", after) } fmt.Println("ok: Metrics() grew from", len(before), "to", len(after), "bytes") } ``` ## MetricEvent fields [Section titled “MetricEvent fields”](#metricevent-fields) | Field | Populated on | What it holds | | ----------------------------------- | ---------------- | ------------------------------------------------------------------- | | `Event` | always | `"llm"` \| `"tool"` \| `"run"`. | | `Model` | `"llm"`, `"run"` | The model used. | | `Status` | `"llm"` | `"ok"` \| `"error"`. | | `Ms` | always | Elapsed wall-clock time in milliseconds. | | `PromptTokens`, `CompletionTokens` | `"llm"` | Per-call token counts. | | `Tool`, `Source` | `"tool"` | Tool name; `"mcp"` \| `"skill"` \| `"native"` \| `"custom"`. | | `IsError` | `"tool"` | Whether the tool call failed. | | `Pending` | `"tool"` | `true` for a §10 suspension — classified `Pending`, never an error. | | `Turns`, `ToolCalls`, `TotalTokens` | `"run"` | Aggregated across the whole run. | | `Error` | `"run"` | The failure message, set only on a failed run. | ## Cache tokens [Section titled “Cache tokens”](#cache-tokens) `Usage` (on `RunResult` and per call) also carries `CacheReadTokens` and `CacheWriteTokens`: tokens served from, and written to, the provider’s prompt cache. Under Anthropic the real context size is `PromptTokens + CacheReadTokens + CacheWriteTokens` (Anthropic’s input count excludes cached tokens); under OpenAI `PromptTokens` already includes them. Turn caching on with [`PromptCache`](/api/go/client/prompt-cache/). ## See also [Section titled “See also”](#see-also) * [`CreateClient`](/api/go/client/create/) — The unified client: system prompt, skills injection, parallel and chained tool calls, retries, memory. * [`Client.Run`](/api/go/client/run/) — Send a prompt, let the loop call tools until the model stops, get a RunResult. * [`Hooks`](/api/go/client/hooks/) — Intercept before/after model calls and tool calls when observing alone isn’t enough. * [`ErrorInfo`](/api/go/client/resilience/) — Classify each failed LLM attempt; a failed run still fires a terminal `"run"` metric event with `Error` set. # ClientOptions.PromptCache > Mark the stable prompt prefix for the provider (Anthropic cache breakpoints, OpenAI prompt_cache_key) and read cacheRead/cacheWrite tokens from usage. — Go. **Go** · package `github.com/muthuishere/toolnexus/golang` · [SPEC §8](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [golang/prompt\_cache.go](https://github.com/muthuishere/toolnexus/blob/main/golang/prompt_cache.go) ```go type PromptCacheOptions struct { Key string // openai prompt_cache_key (one per session); ignored by anthropic LongTTL bool // anthropic ttl "1h" instead of the default short lifetime } // ClientOptions.PromptCache *PromptCacheOptions — nil ⇒ off, request bytes unchanged. type Usage struct { PromptTokens, CompletionTokens, TotalTokens int CacheReadTokens int // served from the provider's cache CacheWriteTokens int // written to it (anthropic only) } ``` `PromptCache` marks the stable request prefix for the provider’s cache: * **Anthropic** — `cache_control: {type: "ephemeral"}` (plus `ttl: "1h"` with `LongTTL`) on the last tool, on the system prompt (a string becomes one text block) and on the last content block of the latest message. * **OpenAI** — `prompt_cache_key = Key`; the prefix itself is cached automatically. * **Gemini** — nothing yet (explicit `cachedContent` is not built). It is applied before `RequestParams` and `BodyTransform`, and the saved transcript is never mutated. Usage reports `CacheReadTokens` / `CacheWriteTokens` either way, so a drop in cache hits is visible. Context size under Anthropic Anthropic’s `input_tokens` excludes cached tokens, so under `StyleAnthropic` the real context size is `PromptTokens + CacheReadTokens + CacheWriteTokens`. Under OpenAI `PromptTokens` already includes the cached part — do not add it twice. ## When to use it [Section titled “When to use it”](#when-to-use-it) Any multi-turn session with a large stable prefix — a long system prompt, a skills catalog, many tool schemas — where you pay for the same input tokens on every request. Turn it on once per client and give each session its own `Key` under OpenAI. ## Why this and not the alternative [Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative) Pair it with ToolTiers when tools change mid-session The cache only hits while the prefix is byte-identical. Registering a tool mid-session changes the tools array and so breaks the cache. If your host adds tools as it goes, also set [`ToolTiers`](/api/go/client/tool-tiers/): loaded tools arrive as tool results and the tools array never changes. ## Examples [Section titled “Examples”](#examples) ### 1. The smallest useful call — Anthropic breakpoints and cache usage [Section titled “1. The smallest useful call — Anthropic breakpoints and cache usage”](#1-the-smallest-useful-call--anthropic-breakpoints-and-cache-usage) ```go package main import ( "context" "encoding/json" "fmt" "io" "log" "net/http" "net/http/httptest" toolnexus "github.com/muthuishere/toolnexus/golang" ) func main() { var body map[string]any srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { b, _ := io.ReadAll(r.Body) _ = json.Unmarshal(b, &body) w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"content":[{"type":"text","text":"hi"}],"stop_reason":"end_turn", "usage":{"input_tokens":12,"output_tokens":3,"cache_read_input_tokens":900,"cache_creation_input_tokens":40}}`)) })) defer srv.Close() client := toolnexus.CreateClient(toolnexus.ClientOptions{ BaseURL: srv.URL, Style: toolnexus.StyleAnthropic, Model: "claude", APIKey: "YOUR_KEY_HERE", SystemPrompt: "You are a careful assistant.", PromptCache: &toolnexus.PromptCacheOptions{}, }) res, err := client.Run(context.Background(), "hello", nil) if err != nil { log.Fatal(err) } sys, _ := body["system"].([]any) if len(sys) != 1 || sys[0].(map[string]any)["cache_control"] == nil { log.Fatalf("system prompt not marked: %v", body["system"]) } u := res.Usage if u.CacheReadTokens != 900 || u.CacheWriteTokens != 40 { log.Fatalf("cache usage not reported: %+v", u) } fmt.Println("ok: context =", u.PromptTokens+u.CacheReadTokens+u.CacheWriteTokens, "tokens") } ``` ### 2. The realistic case — OpenAI `prompt_cache_key` per session [Section titled “2. The realistic case — OpenAI prompt\_cache\_key per session”](#2-the-realistic-case--openai-prompt_cache_key-per-session) ```go package main import ( "context" "encoding/json" "fmt" "io" "log" "net/http" "net/http/httptest" toolnexus "github.com/muthuishere/toolnexus/golang" ) func main() { var body map[string]any srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { b, _ := io.ReadAll(r.Body) _ = json.Unmarshal(b, &body) w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"choices":[{"message":{"role":"assistant","content":"hi"}}], "usage":{"prompt_tokens":1000,"completion_tokens":2,"total_tokens":1002,"prompt_tokens_details":{"cached_tokens":896}}}`)) })) defer srv.Close() client := toolnexus.CreateClient(toolnexus.ClientOptions{ BaseURL: srv.URL, Style: toolnexus.StyleOpenAI, Model: "m", APIKey: "YOUR_KEY_HERE", PromptCache: &toolnexus.PromptCacheOptions{Key: "session-42"}, }) res, err := client.Run(context.Background(), "hello", nil) if err != nil { log.Fatal(err) } if body["prompt_cache_key"] != "session-42" { log.Fatalf("prompt_cache_key missing: %v", body["prompt_cache_key"]) } // Under OpenAI PromptTokens already includes the cached part. fmt.Printf("ok: %d of %d prompt tokens cached\n", res.Usage.CacheReadTokens, res.Usage.PromptTokens) } ``` ### 3. The full surface — 1-hour lifetime on a turn with tools [Section titled “3. The full surface — 1-hour lifetime on a turn with tools”](#3-the-full-surface--1-hour-lifetime-on-a-turn-with-tools) ```go package main import ( "context" "encoding/json" "fmt" "io" "log" "net/http" "net/http/httptest" toolnexus "github.com/muthuishere/toolnexus/golang" ) func main() { echo := toolnexus.NativeTool("echo", "echo text", toolnexus.JSONSchema{"type": "object", "properties": map[string]any{"text": map[string]any{"type": "string"}}}, func(_ context.Context, a map[string]any) (string, error) { return fmt.Sprint(a["text"]), nil }) tk, err := toolnexus.CreateToolkit(context.Background(), toolnexus.Options{ExtraTools: []toolnexus.Tool{echo}}) if err != nil { log.Fatal(err) } defer tk.Close() var body map[string]any srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { b, _ := io.ReadAll(r.Body) _ = json.Unmarshal(b, &body) w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"content":[{"type":"text","text":"done"}],"stop_reason":"end_turn","usage":{"input_tokens":5,"output_tokens":1}}`)) })) defer srv.Close() client := toolnexus.CreateClient(toolnexus.ClientOptions{ BaseURL: srv.URL, Style: toolnexus.StyleAnthropic, Model: "claude", APIKey: "YOUR_KEY_HERE", PromptCache: &toolnexus.PromptCacheOptions{LongTTL: true}, }) if _, err := client.Run(context.Background(), "hello", tk); err != nil { log.Fatal(err) } msgs := body["messages"].([]any) content := msgs[len(msgs)-1].(map[string]any)["content"].([]any) mark, _ := content[len(content)-1].(map[string]any)["cache_control"].(map[string]any) if mark["ttl"] != "1h" { log.Fatalf("latest message not marked with ttl 1h: %v", mark) } fmt.Println("ok: latest message marked, ttl 1h") } ``` ## See also [Section titled “See also”](#see-also) * [`ToolTiers`](/api/go/client/tool-tiers/) — keep the tools array, and so the cached prefix, stable. * [`OnMetric`](/api/go/client/metrics/) — per-call token counts, including cache reads and writes. * [`CreateClient`](/api/go/client/create/) — The unified client: system prompt, skills injection, parallel and chained tool calls, retries, memory. * [`Client.Run`](/api/go/client/run/) — Send a prompt, let the loop call tools until the model stops, get a RunResult. # ProviderError > A non-2xx response from the model endpoint raises a typed provider error carrying the status code, a redacted+capped body, and the retry-after signal — never a bare unstructured exception. — Go. **Go** · package `github.com/muthuishere/toolnexus/golang` · [SPEC §8](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) ```go type ProviderError struct { Status int // the HTTP status of the failed response Body string // whole body, already redacted; NOT capped (Error() caps it) RetryAfter string // the raw Retry-After header, empty when absent } func (e *ProviderError) Error() string // "LLM : " // RedactProviderBody(status int, body string) string — the standalone policy fn, // for a host that logs its own provider calls and wants the same redaction. const RedactedMarker = "«redacted»" // U+00AB / U+00BB ``` A non-2xx response from the model endpoint raises a typed `*ProviderError` carrying the status code, a redacted+capped body, and the retry-after signal — never a bare unstructured exception a host has to string-match. It is built by the internal `newProviderError(status, body, retryAfter)` at every place a request fails against the provider, so the shape is the same whether the failure came from OpenAI, Anthropic or a proxy in front of either. ## When to use it [Section titled “When to use it”](#when-to-use-it) Reach for `*ProviderError` any time you need to branch on **why** an LLM call failed rather than just knowing that it did: rate limiting (429) with `RetryAfter`, a billing failure (402), an account-suspended body worth surfacing to an operator, or simply deciding whether a failure is retryable. Match it with `errors.As` on whatever `error` your `Client.Run`/`Ask`/`Stream` call returned — it is a plain Go error value, not a panic and not a sentinel string. ```go var pe *toolnexus.ProviderError if errors.As(err, &pe) && pe.Status == 429 { // pe.RetryAfter is the raw header value, e.g. "20" or an HTTP-date } ``` ## Why this and not the alternative [Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative) Just logging the error? Body is already safe to print You don’t need to build your own redaction pass before logging `pe.Body` or `pe.Error()` — both already have account identifiers (`user_id`, `account_id`, `org_id`, `organization`) replaced with `«redacted»`, and a 401/403 body is dropped to `""` entirely (ADR 0027: an auth failure routinely echoes back the credential or header that was sent). `pe.Error()` additionally caps the rendered message at 200 characters; the typed `Body` field is uncapped, so a host that explicitly reached for the struct gets the whole (still-redacted) body if it wants more than the message shows. If you log your **own** provider calls outside this client, call the exported `toolnexus.RedactProviderBody(status, body)` directly rather than re-deriving the same two rules differently. A `*ProviderError` is what you get back from `Run`/`Ask`/`Stream` **after** retries are exhausted. While a call is still being retried, [`Resilience`](/api/go/client/resilience/)’s `OnError(info)` sees the same `Status` as a plain int on `ErrorInfo`, not yet wrapped — the two features classify the identical failure at two different points (mid-retry vs. final return), rather than each inventing its own status representation. ## Examples [Section titled “Examples”](#examples) ### 1. The smallest useful call — match the type and read `Status` [Section titled “1. The smallest useful call — match the type and read Status”](#1-the-smallest-useful-call--match-the-type-and-read-status) ```go package main import ( "context" "errors" "fmt" "log" "net/http" "net/http/httptest" toolnexus "github.com/muthuishere/toolnexus/golang" ) func main() { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusTooManyRequests) _, _ = w.Write([]byte(`{"error":"rate limited"}`)) })) defer srv.Close() tk, err := toolnexus.CreateToolkit(context.Background(), toolnexus.Options{Builtins: false}) if err != nil { log.Fatal(err) } defer tk.Close() client := toolnexus.CreateClient(toolnexus.ClientOptions{ BaseURL: srv.URL, Style: toolnexus.StyleOpenAI, Model: "stub", APIKey: "k", Retries: -1, RetryBaseMs: 1, }) _, err = client.Run(context.Background(), "hi", tk) var pe *toolnexus.ProviderError if !errors.As(err, &pe) { log.Fatalf("expected a *ProviderError, got %T: %v", err, err) } if pe.Status != 429 { log.Fatalf("Status = %d, want 429", pe.Status) } fmt.Println("ok: status", pe.Status) } ``` ### 2. The realistic case — reading `RetryAfter` to back off [Section titled “2. The realistic case — reading RetryAfter to back off”](#2-the-realistic-case--reading-retryafter-to-back-off) ```go package main import ( "context" "errors" "fmt" "log" "net/http" "net/http/httptest" toolnexus "github.com/muthuishere/toolnexus/golang" ) func main() { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Retry-After", "20") w.WriteHeader(http.StatusTooManyRequests) _, _ = w.Write([]byte(`{"error":"rate limited"}`)) })) defer srv.Close() tk, err := toolnexus.CreateToolkit(context.Background(), toolnexus.Options{Builtins: false}) if err != nil { log.Fatal(err) } defer tk.Close() client := toolnexus.CreateClient(toolnexus.ClientOptions{ BaseURL: srv.URL, Style: toolnexus.StyleOpenAI, Model: "stub", APIKey: "k", Retries: -1, RetryBaseMs: 1, }) _, err = client.Run(context.Background(), "hi", tk) var pe *toolnexus.ProviderError if !errors.As(err, &pe) { log.Fatal("expected a *ProviderError") } if pe.RetryAfter != "20" { log.Fatalf("RetryAfter = %q, want %q", pe.RetryAfter, "20") } fmt.Println("ok: retry after", pe.RetryAfter, "seconds") } ``` ### 3. The full surface — an account-identifier body gets redacted, an auth body gets dropped [Section titled “3. The full surface — an account-identifier body gets redacted, an auth body gets dropped”](#3-the-full-surface--an-account-identifier-body-gets-redacted-an-auth-body-gets-dropped) ```go package main import ( "context" "errors" "fmt" "log" "net/http" "net/http/httptest" "strings" toolnexus "github.com/muthuishere/toolnexus/golang" ) func main() { turn := 0 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { turn++ if turn == 1 { // A 500 with an account_id in the body: redacted, not dropped. w.WriteHeader(http.StatusInternalServerError) _, _ = w.Write([]byte(`{"error":"internal","account_id":"acct_12345"}`)) return } // A 401: the whole body is dropped, since it routinely echoes the credential. w.WriteHeader(http.StatusUnauthorized) _, _ = w.Write([]byte(`{"error":"invalid api key sk-abc123"}`)) })) defer srv.Close() client := toolnexus.CreateClient(toolnexus.ClientOptions{ BaseURL: srv.URL, Style: toolnexus.StyleOpenAI, Model: "stub", APIKey: "k", Retries: -1, RetryBaseMs: 1, }) tk, err := toolnexus.CreateToolkit(context.Background(), toolnexus.Options{Builtins: false}) if err != nil { log.Fatal(err) } defer tk.Close() _, err = client.Run(context.Background(), "hi", tk) var pe *toolnexus.ProviderError if !errors.As(err, &pe) { log.Fatal("expected a *ProviderError") } if strings.Contains(pe.Body, "acct_12345") || !strings.Contains(pe.Body, toolnexus.RedactedMarker) { log.Fatalf("account_id must be redacted, got: %s", pe.Body) } _, err = client.Run(context.Background(), "hi", tk) if !errors.As(err, &pe) { log.Fatal("expected a *ProviderError") } if pe.Body != "" { log.Fatalf("a 401 body must be dropped in full, got: %q", pe.Body) } fmt.Println("ok: redacted, and auth body dropped") } ``` ## See also [Section titled “See also”](#see-also) * [`CreateClient`](/api/go/client/create/) — The unified client: system prompt, skills injection, parallel and chained tool calls, retries, memory. * [`Client.Run`](/api/go/client/run/) — Send a prompt, let the loop call tools until the model stops, get a RunResult. * [`Client.Stream`](/api/go/client/stream/) — The streaming loop: text deltas, tool-call events, and suspension events as they happen. * [`Hooks`](/api/go/client/hooks/) — Intercept before/after model calls and tool calls: audit, redact, veto, or rewrite. * [`Resilience`](/api/go/client/resilience/) — The retry/fail policy that classifies the same status mid-retry, before it would ever become a `*ProviderError`. # ErrorInfo > Classify an LLM error into retry / fail, bound a run with a deadline, and cancel cleanly. — Go. **Go** · package `github.com/muthuishere/toolnexus/golang` · [SPEC §8](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [golang/client.go](https://github.com/muthuishere/toolnexus/blob/main/golang/client.go) ```go type ErrorInfo struct { Err error // transport error (nil on a non-ok response) Status int // HTTP status (0 on a transport error) Attempt int // zero-based attempt index Retryable bool // true if Status/Err is in the default retryable set // (429, 500, 502, 503, 504, 529, or any transport error) } type Tier string const ( TierRetry Tier = "retry" TierFail Tier = "fail" ) // ClientOptions.OnError func(ErrorInfo) Tier // ClientOptions.Retries, RetryBaseMs, TimeoutMs int // ClientOptions.RetryableStatuses []int ``` Three independent resilience knobs on `ClientOptions`: retry classification (`OnError`, given an `ErrorInfo` per failed attempt, returns `TierRetry` or `TierFail`), a whole-run deadline (`TimeoutMs`), and `ctx` cancellation — both propagate to abort the in-flight HTTP request rather than let it run to completion unheeded. ## When to use it [Section titled “When to use it”](#when-to-use-it) The defaults (retry `429`, `500`, `502`, `503`, `504`, `529` and transport errors up to `Retries`, exponential backoff honoring `Retry-After`, no deadline) are right for most callers — you get resilience for free by doing nothing. Note that the default set is an exhaustive enumeration, not “429 plus any 5xx”: a `501`, a `507` or a Cloudflare `520` is terminal unless you say otherwise. Reach for `RetryableStatuses` when your provider emits a transient status outside that set; reach for `OnError` when a provider’s failure modes don’t fit the default classifier (e.g. treat a specific 400 as retryable, or stop retrying 429 early because you’re rate-limit-sensitive); reach for `TimeoutMs` to bound worst-case latency on a run; reach for `ctx` cancellation for the ordinary Go pattern of “the caller gave up, stop everything downstream.” ### Widening the default set with `RetryableStatuses` [Section titled “Widening the default set with RetryableStatuses”](#widening-the-default-set-with-retryablestatuses) A Cloudflare-fronted origin answers `520`–`527` when the edge cannot get a clean response from the origin. Those are transient, and not in the default set — list them and they classify as retryable: ```go client, err := toolnexus.CreateClient(toolnexus.ClientOptions{ Model: model, APIKey: apiKey, RetryableStatuses: []int{520, 521, 522, 523, 524, 525, 526, 527}, }) ``` `RetryableStatuses` is **additive to the defaults, never a replacement** — it can only make more statuses retryable, never fewer, so setting it cannot accidentally drop `429` and lose `Retry-After` handling along with it. It sets the *default classification* only: `OnError` still runs on every failed attempt and has the final say, so an `OnError` returning `TierFail` overrides a status you listed yourself. `Retry-After` handling is unaffected. ## Why this and not the alternative [Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative) A tool suspension is a different mechanism — not an error tier There is no `"suspend"` `Tier`. §10 suspension (a tool halting mid-call awaiting human/out-of-band input) is a distinct, deliberate pause — never routed through `OnError`, and a suspended tool call is recorded as `Pending`, not `IsError`, on the [`MetricEvent`](/api/go/client/metrics/). `OnError` governs only failed LLM HTTP attempts. ## Examples [Section titled “Examples”](#examples) Each example points `CreateClient` at a local `httptest.Server` that returns 429/503 or stalls — no real network call, no real API key. `RetryBaseMs`/`TimeoutMs` are set small so the examples run fast. ### 1. The smallest useful call — default retry on a transient 503 [Section titled “1. The smallest useful call — default retry on a transient 503”](#1-the-smallest-useful-call--default-retry-on-a-transient-503) No `OnError` set: the default classifier retries `429`/`500`/`502`/`503`/`504`/`529`/transport errors up to `Retries` (2 by default), with exponential backoff. ```go package main import ( "context" "fmt" "log" "net/http" "net/http/httptest" "sync/atomic" toolnexus "github.com/muthuishere/toolnexus/golang" ) func main() { var calls int32 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if atomic.AddInt32(&calls, 1) <= 2 { w.WriteHeader(http.StatusServiceUnavailable) _, _ = w.Write([]byte(`{"error":"try later"}`)) return } w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"choices":[{"message":{"role":"assistant","content":"hello"}}]}`)) })) defer srv.Close() tk, err := toolnexus.CreateToolkit(context.Background(), toolnexus.Options{}) if err != nil { log.Fatal(err) } defer tk.Close() client := toolnexus.CreateClient(toolnexus.ClientOptions{ BaseURL: srv.URL, Style: toolnexus.StyleOpenAI, Model: "m", APIKey: "test-key", RetryBaseMs: 1, }) res, err := client.Run(context.Background(), "hi", tk) if err != nil { log.Fatal(err) } if res.Text != "hello" || atomic.LoadInt32(&calls) != 3 { log.Fatalf("text=%q calls=%d, want hello/3", res.Text, calls) } fmt.Println("ok: succeeded after", calls, "attempts") } ``` ### 2. The realistic case — a custom `OnError` classifier [Section titled “2. The realistic case — a custom OnError classifier”](#2-the-realistic-case--a-custom-onerror-classifier) Return `TierFail` to stop retrying a status the default set would otherwise retry (rate-limit sensitive callers often want this for 429), or `TierRetry` to retry something the default set treats as terminal. ```go package main import ( "context" "fmt" "log" "net/http" "net/http/httptest" "sync/atomic" toolnexus "github.com/muthuishere/toolnexus/golang" ) func main() { var calls int32 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { atomic.AddInt32(&calls, 1) w.WriteHeader(http.StatusTooManyRequests) _, _ = w.Write([]byte(`{"error":"slow down"}`)) })) defer srv.Close() tk, err := toolnexus.CreateToolkit(context.Background(), toolnexus.Options{}) if err != nil { log.Fatal(err) } defer tk.Close() var seen toolnexus.ErrorInfo client := toolnexus.CreateClient(toolnexus.ClientOptions{ BaseURL: srv.URL, Style: toolnexus.StyleOpenAI, Model: "m", APIKey: "test-key", RetryBaseMs: 1, OnError: func(info toolnexus.ErrorInfo) toolnexus.Tier { seen = info return toolnexus.TierFail // fail fast on 429 instead of retrying }, }) _, err = client.Run(context.Background(), "hi", tk) if err == nil { log.Fatal("expected an error when OnError returns TierFail") } if atomic.LoadInt32(&calls) != 1 { log.Fatalf("calls = %d, want 1 (TierFail ⇒ no retry)", calls) } if seen.Status != 429 || !seen.Retryable || seen.Attempt != 0 || seen.Err != nil { log.Fatalf("ErrorInfo = %+v, want Status:429 Retryable:true Attempt:0 Err:nil", seen) } fmt.Println("ok: failed fast on attempt", seen.Attempt, "status", seen.Status) } ``` ### 3. The full surface — timeout, cancellation, and retry exhaustion together [Section titled “3. The full surface — timeout, cancellation, and retry exhaustion together”](#3-the-full-surface--timeout-cancellation-and-retry-exhaustion-together) A run-level `TimeoutMs` aborts an in-flight request that’s taking too long; `ctx` cancellation does the same for an ordinary “caller gave up,” and neither retries afterward — only the retry classifier governs retries, never a deadline or a cancellation. ```go package main import ( "context" "fmt" "log" "net/http" "net/http/httptest" "sync/atomic" "time" toolnexus "github.com/muthuishere/toolnexus/golang" ) func main() { tk, err := toolnexus.CreateToolkit(context.Background(), toolnexus.Options{}) if err != nil { log.Fatal(err) } defer tk.Close() // 1. TimeoutMs aborts a run that would otherwise hang. slow := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { select { case <-r.Context().Done(): case <-time.After(2 * time.Second): } })) defer slow.Close() timeoutClient := toolnexus.CreateClient(toolnexus.ClientOptions{ BaseURL: slow.URL, Style: toolnexus.StyleOpenAI, Model: "m", APIKey: "test-key", TimeoutMs: 80, }) start := time.Now() if _, err := timeoutClient.Run(context.Background(), "hi", tk); err == nil { log.Fatal("expected a timeout error") } if time.Since(start) > time.Second { log.Fatal("Run should have aborted well under 1s") } // 2. ctx cancellation aborts in-flight and is never retried. var calls int32 hang := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { atomic.AddInt32(&calls, 1) select { case <-r.Context().Done(): case <-time.After(2 * time.Second): } })) defer hang.Close() cancelClient := toolnexus.CreateClient(toolnexus.ClientOptions{ BaseURL: hang.URL, Style: toolnexus.StyleOpenAI, Model: "m", APIKey: "test-key", RetryBaseMs: 1, }) ctx, cancel := context.WithCancel(context.Background()) go func() { time.Sleep(50 * time.Millisecond); cancel() }() if _, err := cancelClient.Run(ctx, "hi", tk); err == nil { log.Fatal("expected a cancellation error") } if atomic.LoadInt32(&calls) != 1 { log.Fatalf("calls = %d, want 1 (cancellation must not retry)", calls) } // 3. Retries is a hard budget — exhausting it surfaces the failure, never loops forever. var failCalls int32 failing := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { atomic.AddInt32(&failCalls, 1) w.WriteHeader(http.StatusBadGateway) })) defer failing.Close() budgetClient := toolnexus.CreateClient(toolnexus.ClientOptions{ BaseURL: failing.URL, Style: toolnexus.StyleOpenAI, Model: "m", APIKey: "test-key", Retries: 1, RetryBaseMs: 1, }) if _, err := budgetClient.Run(context.Background(), "hi", tk); err == nil { log.Fatal("expected an error after the retry budget is exhausted") } if atomic.LoadInt32(&failCalls) != 2 { // 1 try + 1 retry log.Fatalf("calls = %d, want 2 (1 try + Retries:1)", failCalls) } fmt.Println("ok: timeout, cancellation, and retry exhaustion all abort cleanly") } ``` ## ClientOptions resilience fields [Section titled “ClientOptions resilience fields”](#clientoptions-resilience-fields) | Field | Type | Default | What it does | | ------------------- | ---------------------- | ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `Retries` | `int` | `2` | Retries on transient LLM errors (`429`, `500`, `502`, `503`, `504`, `529`, or a transport error), bounded — never unbounded even with a custom `OnError`. | | `RetryBaseMs` | `int` | `500` | Base backoff in ms; exponential + jitter, honoring a `Retry-After` header when present. | | `TimeoutMs` | `int` | `0` (no deadline) | When `> 0`, the whole run (and its in-flight request) is aborted once exceeded. | | `RetryableStatuses` | `[]int` | `nil` | Extra HTTP statuses to classify as retryable, **added** to the default `429`/`500`/`502`/`503`/`504`/`529`. Widens only, never shrinks; `OnError` still has the final say. | | `OnError` | `func(ErrorInfo) Tier` | the default classifier | Classify each failed attempt; `nil` ⇒ retryable status/network ⇒ `TierRetry`, else `TierFail` (byte-identical to no classifier at all). | | `HTTPClient` | `*http.Client` | `http.DefaultClient` | Overrides the client used for LLM requests (retries included); scope is the LLM path only. | ### 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: `ClassifierOptions.RetryBaseMs` (default `500`, `base * 2^attempt`, no jitter, `Retry-After` still wins). See [`CreateClassifier`](/api/go/judge/classifier/). ## See also [Section titled “See also”](#see-also) * [`CreateClient`](/api/go/client/create/) — The unified client: system prompt, skills injection, parallel and chained tool calls, retries, memory. * [`Client.Run`](/api/go/client/run/) — Send a prompt, let the loop call tools until the model stops, get a RunResult. * [`Hooks`](/api/go/client/hooks/) — Intercept before/after model calls and tool calls: audit, redact, veto, or rewrite. * [`MetricEvent`](/api/go/client/metrics/) — A failed run still fires a terminal `"run"` metric event with `Error` set. # Client.Run > Send a prompt, let the loop call tools until the model stops, get a RunResult. — Go. **Go** · package `github.com/muthuishere/toolnexus/golang` · [SPEC §8](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [golang/client.go](https://github.com/muthuishere/toolnexus/blob/main/golang/client.go) ```go func (c *Client) Run(ctx context.Context, prompt string, tk *Toolkit) (RunResult, error) ``` One turn of the agent loop, run to completion. `Run` posts `prompt` to the model, and for every tool call the model asks for it executes the tool against `tk`, feeds the result back, and repeats — up to `MaxTurns` — until the model stops calling tools or the loop is capped. It returns a single `RunResult` once the whole thing is over; there is no partial output along the way. ## When to use it [Section titled “When to use it”](#when-to-use-it) Reach for `Run` for request/response work: a CLI command, a backend endpoint, a batch job — any place where you want the finished answer and don’t need to show text as it streams in, and don’t need the exchange remembered for a later turn. ## Why this and not the alternative [Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative) Streaming UI or multi-turn memory? Look elsewhere first Want text on screen as the model produces it, or a live handle on tool calls as they happen? Use [`Client.Stream`](/api/go/client/stream/) — same loop, events on a channel instead of one final value. Want the exchange remembered automatically so the next call continues the same transcript? Use [`Conversation`](/api/go/client/conversation/) (in-process) or `Client.Ask` with an id (backed by a [`ConversationStore`](/api/go/client/store/)) — both call `RunWithHistory` under the hood, the same unexported machinery `Run` uses with no prior history. `Run` itself is stateless: call it twice and the model sees two unrelated prompts. ## Examples [Section titled “Examples”](#examples) All three examples point `CreateClient` at a local `httptest.Server` standing in for the LLM endpoint — no network call, no real API key. `Run` speaks whatever an OpenAI- or Anthropic-shaped `/chat/completions` / `/v1/messages` endpoint speaks; the stub only needs to shape its JSON to match. ### 1. The smallest useful call — a plain text answer [Section titled “1. The smallest useful call — a plain text answer”](#1-the-smallest-useful-call--a-plain-text-answer) No tools, one round trip: the model answers directly and the loop stops. ```go package main import ( "context" "fmt" "log" "net/http" "net/http/httptest" toolnexus "github.com/muthuishere/toolnexus/golang" ) func main() { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"choices":[{"message":{"role":"assistant","content":"Paris"}}],"usage":{"prompt_tokens":4,"completion_tokens":1,"total_tokens":5}}`)) })) defer srv.Close() tk, err := toolnexus.CreateToolkit(context.Background(), toolnexus.Options{}) if err != nil { log.Fatal(err) } defer tk.Close() client := toolnexus.CreateClient(toolnexus.ClientOptions{ BaseURL: srv.URL, Style: toolnexus.StyleOpenAI, Model: "gpt-4o-mini", APIKey: "test-key", }) res, err := client.Run(context.Background(), "capital of France?", tk) if err != nil { log.Fatal(err) } if res.Text != "Paris" || res.Status != "done" || res.Turns != 1 { log.Fatalf("unexpected: %+v", res) } fmt.Println("ok:", res.Text) } ``` ### 2. The realistic case — a tool call in the loop [Section titled “2. The realistic case — a tool call in the loop”](#2-the-realistic-case--a-tool-call-in-the-loop) The model asks for `add`, the loop executes it, feeds the result back, and the model answers on the second turn. This is the shape of nearly every real agent call. ```go package main import ( "context" "fmt" "log" "net/http" "net/http/httptest" toolnexus "github.com/muthuishere/toolnexus/golang" ) func main() { turn := 0 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { turn++ w.Header().Set("Content-Type", "application/json") if turn == 1 { // Turn 1: the model wants to call add(a=2,b=3). _, _ = w.Write([]byte(`{"choices":[{"message":{"role":"assistant","content":null,"tool_calls":[{"id":"c1","type":"function","function":{"name":"add","arguments":"{\"a\":2,\"b\":3}"}}]}}],"usage":{"prompt_tokens":10,"completion_tokens":8,"total_tokens":18}}`)) return } // Turn 2: the model has the tool result and answers. _, _ = w.Write([]byte(`{"choices":[{"message":{"role":"assistant","content":"5"}}],"usage":{"prompt_tokens":12,"completion_tokens":1,"total_tokens":13}}`)) })) defer srv.Close() add := toolnexus.NativeTool( "add", "add two numbers", toolnexus.JSONSchema{"type": "object", "properties": map[string]any{ "a": map[string]any{"type": "number"}, "b": map[string]any{"type": "number"}, }}, func(_ context.Context, args map[string]any) (string, error) { a, _ := args["a"].(float64) b, _ := args["b"].(float64) return fmt.Sprintf("%v", a+b), nil }, ) tk, err := toolnexus.CreateToolkit(context.Background(), toolnexus.Options{ExtraTools: []toolnexus.Tool{add}}) if err != nil { log.Fatal(err) } defer tk.Close() client := toolnexus.CreateClient(toolnexus.ClientOptions{ BaseURL: srv.URL, Style: toolnexus.StyleOpenAI, Model: "gpt-4o-mini", APIKey: "test-key", }) res, err := client.Run(context.Background(), "what is 2+3?", tk) if err != nil { log.Fatal(err) } if res.Text != "5" || res.Turns != 2 || res.ToolCallCount != 1 { log.Fatalf("unexpected: %+v", res) } if res.ToolCalls[0].Name != "add" || res.ToolCalls[0].Output != "5" { log.Fatalf("unexpected tool call record: %+v", res.ToolCalls[0]) } fmt.Println("ok:", res.Text, "in", res.Turns, "turns") } ``` ### 3. The full surface — options and the whole `RunResult` [Section titled “3. The full surface — options and the whole RunResult”](#3-the-full-surface--options-and-the-whole-runresult) `SystemPrompt` is prepended to the toolkit’s skills prompt; `MaxTurns` caps the loop and, when hit mid-tool-call with no final answer, the run comes back loudly as `"incomplete"` rather than silently `"done"`. ```go package main import ( "context" "fmt" "log" "net/http" "net/http/httptest" toolnexus "github.com/muthuishere/toolnexus/golang" ) func main() { // The model NEVER stops calling the tool — used to exercise the MaxTurns cap. loop := toolnexus.NativeTool( "ping", "ping", toolnexus.JSONSchema{"type": "object", "properties": map[string]any{}}, func(_ context.Context, _ map[string]any) (string, error) { return "pong", nil }, ) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"choices":[{"message":{"role":"assistant","content":null,"tool_calls":[{"id":"c1","type":"function","function":{"name":"ping","arguments":"{}"}}]}}],"usage":{"prompt_tokens":2,"completion_tokens":2,"total_tokens":4}}`)) })) defer srv.Close() tk, err := toolnexus.CreateToolkit(context.Background(), toolnexus.Options{ExtraTools: []toolnexus.Tool{loop}, Builtins: false}) if err != nil { log.Fatal(err) } defer tk.Close() client := toolnexus.CreateClient(toolnexus.ClientOptions{ BaseURL: srv.URL, Style: toolnexus.StyleOpenAI, Model: "gpt-4o-mini", APIKey: "test-key", SystemPrompt: "Be terse.", MaxTurns: 2, // hit the cap while the model is still mid tool-call }) res, err := client.Run(context.Background(), "keep pinging", tk) if err != nil { log.Fatal(err) } if res.Status != "incomplete" || res.Limit != "maxTurns" { log.Fatalf("expected incomplete/maxTurns, got status=%q limit=%q", res.Status, res.Limit) } if res.Turns != 2 || res.ToolCallCount != 2 || res.Model != "gpt-4o-mini" { log.Fatalf("unexpected: %+v", res) } // Partial work is preserved even on a loud stop. if len(res.Messages) == 0 || res.Usage.TotalTokens == 0 { log.Fatalf("expected partial transcript + usage to survive: %+v", res) } fmt.Println("ok: stopped at", res.Limit, "after", res.Turns, "turns,", res.ToolCallCount, "tool calls") } ``` ## RunResult fields [Section titled “RunResult fields”](#runresult-fields) | Field | Type | What it holds | | --------------- | ------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `Text` | `string` | The model’s final answer text (`""` on an incomplete run with no text yet). | | `Messages` | `[]any` | The full transcript — system/user/assistant/tool messages, provider-native shape. | | `ToolCalls` | `[]ToolCall` | One record per tool call: `Name`, `Args`, `Output`, `IsError`, `Metadata`. | | `ToolCallCount` | `int` | `len(ToolCalls)`. | | `Turns` | `int` | Number of LLM round trips this run took. | | `Usage` | `Usage` | `PromptTokens` / `CompletionTokens` / `TotalTokens`, summed across every turn. | | `Model` | `string` | The model used. | | `Status` | `string` | `"done"` normally; `"pending"` iff a tool suspended with no `WaitFor` configured (§10); `"incomplete"` iff `MaxTurns` was hit while the model was still emitting tool calls. | | `Limit` | `string` | Set (`"maxTurns"`) only when `Status == "incomplete"`. | | `Pending` | `*Request` | Set only when `Status == "pending"`. | ## See also [Section titled “See also”](#see-also) * [`CreateClient`](/api/go/client/create/) — The unified client: system prompt, skills injection, parallel and chained tool calls, retries, memory. * [`Client.Stream`](/api/go/client/stream/) — The streaming loop: text deltas, tool-call events, and suspension events as they happen. * [`Hooks`](/api/go/client/hooks/) — Intercept before/after model calls and tool calls: audit, redact, veto, or rewrite. * [`Conversation`](/api/go/client/conversation/) — Keep a transcript across turns so the model remembers what it already did. # NewInMemoryConversationStore > Swap in-memory history for your own store so a conversation survives a process restart. — Go. **Go** · package `github.com/muthuishere/toolnexus/golang` · [SPEC §8](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [golang/client.go](https://github.com/muthuishere/toolnexus/blob/main/golang/client.go) ```go type ConversationStore interface { Get(id string) ([]any, error) Save(id string, messages []any) error } func NewInMemoryConversationStore() *InMemoryConversationStore ``` `ConversationStore` is where `Client.Ask(ctx, prompt, tk, id)` remembers a transcript by id — two methods, `Get` (returns `nil` when `id` has no stored history yet) and `Save`. `CreateClient` uses `NewInMemoryConversationStore()` when `ClientOptions.Store` is left `nil`; supply your own implementation to persist conversations across process restarts (a file, Redis, a database table). ## When to use it [Section titled “When to use it”](#when-to-use-it) The default (in-memory) store is right for anything that lives inside one process’s lifetime — a CLI session, a short-lived worker. Reach for a custom `ConversationStore` when the id arrives with a request (a web handler keyed by user id, an A2A peer keyed by `contextId`) and the transcript must survive the process restarting, or be shared across processes. ## Why this and not the alternative [Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative) In-process only, and don't need a lookup key? Use Conversation If you already hold a Go value for the session’s lifetime and don’t need it keyed by an id that arrives per request, [`Conversation`](/api/go/client/conversation/) is simpler — it doesn’t touch `ConversationStore` at all, it just keeps `Messages` as a field. Reach for `ConversationStore` (via `Client.Ask`/`AskStream`/`StreamWithID`) specifically for the “keyed by id, needs to persist” case. ## Examples [Section titled “Examples”](#examples) Examples 2 and 3 point `CreateClient` at a local `httptest.Server` — no real network call, no real API key. The stub server echoes back the number of messages it received, so a growing count proves history round-tripped through the store. ### 1. The smallest useful call — `Get`/`Save` roundtrip [Section titled “1. The smallest useful call — Get/Save roundtrip”](#1-the-smallest-useful-call--getsave-roundtrip) `NewInMemoryConversationStore` works standalone too — it’s just the interface. ```go package main import ( "fmt" "log" toolnexus "github.com/muthuishere/toolnexus/golang" ) func main() { store := toolnexus.NewInMemoryConversationStore() // Nothing stored yet ⇒ nil, no error. got, err := store.Get("u1") if err != nil || got != nil { log.Fatalf("expected (nil, nil) for an unknown id, got (%v, %v)", got, err) } if err := store.Save("u1", []any{map[string]any{"role": "user", "content": "hi"}}); err != nil { log.Fatal(err) } got, err = store.Get("u1") if err != nil { log.Fatal(err) } if len(got) != 1 { log.Fatalf("expected 1 stored message, got %d", len(got)) } fmt.Println("ok: roundtripped", len(got), "message(s)") } ``` ### 2. The realistic case — `Client.Ask` remembers by id through the default store [Section titled “2. The realistic case — Client.Ask remembers by id through the default store”](#2-the-realistic-case--clientask-remembers-by-id-through-the-default-store) ```go package main import ( "context" "encoding/json" "fmt" "log" "net/http" "net/http/httptest" toolnexus "github.com/muthuishere/toolnexus/golang" ) func main() { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var body struct { Messages []any `json:"messages"` } _ = json.NewDecoder(r.Body).Decode(&body) w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]any{ "choices": []any{map[string]any{"message": map[string]any{"role": "assistant", "content": fmt.Sprint(len(body.Messages))}}}, }) })) defer srv.Close() // No skills/system prompt, so the count is exactly the turns. tk, err := toolnexus.CreateToolkit(context.Background(), toolnexus.Options{Builtins: false}) if err != nil { log.Fatal(err) } defer tk.Close() client := toolnexus.CreateClient(toolnexus.ClientOptions{BaseURL: srv.URL, Style: toolnexus.StyleOpenAI, Model: "m", APIKey: "test-key"}) ctx := context.Background() first, err := client.Ask(ctx, "first", tk, "c1") if err != nil { log.Fatal(err) } if first.Text != "1" { log.Fatalf("first turn: text = %q, want 1", first.Text) } second, err := client.Ask(ctx, "second", tk, "c1") if err != nil { log.Fatal(err) } // user(first) + assistant(1) + user(second) = 3 messages remembered via the store. if second.Text != "3" { log.Fatalf("same id remembers: text = %q, want 3", second.Text) } fmt.Println("ok:", first.Text, "->", second.Text) } ``` ### 3. The full surface — a custom store, plugged in via `ClientOptions.Store` [Section titled “3. The full surface — a custom store, plugged in via ClientOptions.Store”](#3-the-full-surface--a-custom-store-plugged-in-via-clientoptionsstore) Implement the two-method interface yourself for a file/db-backed store; `Client.ConversationStore()` returns whatever instance is in effect (the one you supplied, or the default) so callers never need a shadow copy. ```go package main import ( "context" "encoding/json" "fmt" "log" "net/http" "net/http/httptest" "sync" toolnexus "github.com/muthuishere/toolnexus/golang" ) // fileLikeStore stands in for a durable store (file/db/Redis) — same two methods. type fileLikeStore struct { mu sync.Mutex calls []string backing map[string][]any } func (s *fileLikeStore) Get(id string) ([]any, error) { s.mu.Lock() defer s.mu.Unlock() s.calls = append(s.calls, "get:"+id) return s.backing[id], nil } func (s *fileLikeStore) Save(id string, messages []any) error { s.mu.Lock() defer s.mu.Unlock() s.calls = append(s.calls, "save:"+id) s.backing[id] = messages return nil } func main() { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]any{ "choices": []any{map[string]any{"message": map[string]any{"role": "assistant", "content": "ack"}}}, }) })) defer srv.Close() tk, err := toolnexus.CreateToolkit(context.Background(), toolnexus.Options{Builtins: false}) if err != nil { log.Fatal(err) } defer tk.Close() store := &fileLikeStore{backing: map[string][]any{}} client := toolnexus.CreateClient(toolnexus.ClientOptions{ BaseURL: srv.URL, Style: toolnexus.StyleOpenAI, Model: "m", APIKey: "test-key", Store: store, }) // The instance you get back is the exact one you supplied. if client.ConversationStore() != toolnexus.ConversationStore(store) { log.Fatal("ConversationStore() should return the supplied instance") } if _, err := client.Ask(context.Background(), "hi", tk, "u1"); err != nil { log.Fatal(err) } if len(store.calls) != 2 || store.calls[0] != "get:u1" || store.calls[1] != "save:u1" { log.Fatalf("custom store calls = %v, want [get:u1 save:u1]", store.calls) } if _, ok := store.backing["u1"]; !ok { log.Fatal("custom store did not persist the transcript for u1") } fmt.Println("ok: custom store calls =", store.calls) } ``` ## ConversationStore surface [Section titled “ConversationStore surface”](#conversationstore-surface) | Member | What it does | | ----------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | | `Get(id string) ([]any, error)` | Returns the stored transcript for `id`, or `nil` (no error) when none exists. | | `Save(id string, messages []any) error` | Persists the (updated) transcript for `id`. | | `NewInMemoryConversationStore() *InMemoryConversationStore` | The default provider — process-lifetime memory, safe for concurrent use, copies on get/save so callers can’t mutate stored slices. | | `ClientOptions.Store ConversationStore` | Supply your own implementation here; `nil` ⇒ the in-memory default. | | `Client.ConversationStore() ConversationStore` | Returns whichever instance is in effect — the supplied one, or the default the client created. | ## See also [Section titled “See also”](#see-also) * [`CreateClient`](/api/go/client/create/) — The unified client: system prompt, skills injection, parallel and chained tool calls, retries, memory. * [`Client.Run`](/api/go/client/run/) — Send a prompt, let the loop call tools until the model stops, get a RunResult. * [`Client.Stream`](/api/go/client/stream/) — The streaming loop: text deltas, tool-call events, and suspension events as they happen. * [`Conversation`](/api/go/client/conversation/) — The in-process alternative when you don’t need a lookup key or durability. # Client.Stream > The streaming loop: text deltas, tool-call events, and suspension events as they happen. — Go. **Go** · package `github.com/muthuishere/toolnexus/golang` · [SPEC §8](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [golang/client.go](https://github.com/muthuishere/toolnexus/blob/main/golang/client.go) ```go func (c *Client) Stream(ctx context.Context, prompt string, tk *Toolkit) (<-chan StreamEvent, error) ``` The same agent loop as [`Client.Run`](/api/go/client/run/), but observed live: a goroutine drives the loop against the LLM’s streaming endpoint and pushes a `StreamEvent` onto the returned channel for every text delta, tool call, tool result, and usage update, closing the channel with a terminal `"done"` (carrying the full `RunResult`) or `"error"` event. Errors are a channel event, not a second return `Stream` itself only errors on setup (e.g. a `ConversationStore.Get` failure when streaming with an id). Once the channel is open, any failure mid-run — including context cancellation or a timeout — arrives as a `StreamEvent{Type: "error", Err: …}` and the channel closes right after. Always range over the channel to completion; don’t just check the initial `error` return. ## When to use it [Section titled “When to use it”](#when-to-use-it) Reach for `Stream` when you’re driving a UI or terminal that should show the model’s answer as it’s produced, or when a caller wants a live hook on tool calls (a progress spinner, a “calling `search`…” line) rather than waiting for the whole run to finish. ## Why this and not the alternative [Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative) Don't need live output? Run is simpler [`Client.Run`](/api/go/client/run/) is the same loop with one blocking call and one return value — reach for it whenever you just want the finished answer. Reach for `Stream` only when something downstream actually consumes events as they arrive; if you’d just drain the channel into a string anyway, `Run` does that for you with less code. `Client.Ask` also has a streaming variant — `AskStream(ctx, prompt, tk, id, onText)` — for the common case of “stream text, but still return one `RunResult` and remember the conversation.” ## Examples [Section titled “Examples”](#examples) Each example serves canned Server-Sent Events from a local `httptest.Server` — no real network call, no real API key. `StreamEvent.Type` is one of `"text"`, `"tool_call"`, `"tool_result"`, `"usage"`, `"pending"`, `"done"`, `"error"`; only the fields relevant to that `Type` are populated. ### 1. The smallest useful call — text deltas only [Section titled “1. The smallest useful call — text deltas only”](#1-the-smallest-useful-call--text-deltas-only) No tool calls: the SSE stream is a run of `content` deltas terminated by `[DONE]`. ```go package main import ( "context" "fmt" "log" "net/http" "net/http/httptest" "strings" toolnexus "github.com/muthuishere/toolnexus/golang" ) func main() { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/event-stream") fl := w.(http.Flusher) write := func(s string) { _, _ = w.Write([]byte(s)); fl.Flush() } write(`data: {"choices":[{"delta":{"content":"Bon"}}]}` + "\n\n") write(`data: {"choices":[{"delta":{"content":"jour"}}]}` + "\n\n") write(`data: {"usage":{"prompt_tokens":3,"completion_tokens":2,"total_tokens":5}}` + "\n\n") write("data: [DONE]\n\n") })) defer srv.Close() tk, err := toolnexus.CreateToolkit(context.Background(), toolnexus.Options{}) if err != nil { log.Fatal(err) } defer tk.Close() client := toolnexus.CreateClient(toolnexus.ClientOptions{ BaseURL: srv.URL, Style: toolnexus.StyleOpenAI, Model: "gpt-4o-mini", APIKey: "test-key", }) ch, err := client.Stream(context.Background(), "say hi in French", tk) if err != nil { log.Fatal(err) } var text strings.Builder for ev := range ch { switch ev.Type { case "text": text.WriteString(ev.Delta) case "error": log.Fatalf("stream error: %v", ev.Err) } } if text.String() != "Bonjour" { log.Fatalf("assembled text = %q, want Bonjour", text.String()) } fmt.Println("ok:", text.String()) } ``` ### 2. The realistic case — a streamed tool call, then a streamed answer [Section titled “2. The realistic case — a streamed tool call, then a streamed answer”](#2-the-realistic-case--a-streamed-tool-call-then-a-streamed-answer) OpenAI-style streaming assembles a tool call’s `name`/`arguments` across multiple deltas (keyed by `index`) before the loop can execute it. Turn 2 then streams the final text. ```go package main import ( "context" "fmt" "log" "net/http" "net/http/httptest" "strconv" "strings" "sync/atomic" toolnexus "github.com/muthuishere/toolnexus/golang" ) func main() { echo := toolnexus.NativeTool( "echo", "echo n", toolnexus.JSONSchema{"type": "object", "properties": map[string]any{"n": map[string]any{"type": "number"}}}, func(_ context.Context, args map[string]any) (string, error) { return strconv.FormatFloat(args["n"].(float64), 'f', -1, 64), nil }, ) tk, err := toolnexus.CreateToolkit(context.Background(), toolnexus.Options{ExtraTools: []toolnexus.Tool{echo}}) if err != nil { log.Fatal(err) } defer tk.Close() var turn int32 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/event-stream") fl := w.(http.Flusher) write := func(s string) { _, _ = w.Write([]byte(s)); fl.Flush() } if atomic.AddInt32(&turn, 1) == 1 { // Tool-call args arrive split across two chunks — the client assembles them. write(`data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"c0","function":{"name":"echo","arguments":"{\"n\":"}}]}}]}` + "\n\n") write(`data: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"7}"}}]}}]}` + "\n\n") write("data: [DONE]\n\n") return } write(`data: {"choices":[{"delta":{"content":"seven"}}]}` + "\n\n") write("data: [DONE]\n\n") })) defer srv.Close() client := toolnexus.CreateClient(toolnexus.ClientOptions{ BaseURL: srv.URL, Style: toolnexus.StyleOpenAI, Model: "gpt-4o-mini", APIKey: "test-key", }) ch, err := client.Stream(context.Background(), "echo 7", tk) if err != nil { log.Fatal(err) } var text strings.Builder var sawCall, sawResult bool for ev := range ch { switch ev.Type { case "text": text.WriteString(ev.Delta) case "tool_call": sawCall = true if ev.Name != "echo" || ev.Args["n"].(float64) != 7 { log.Fatalf("unexpected tool_call event: %+v", ev) } case "tool_result": sawResult = true if ev.Output != "7" { log.Fatalf("unexpected tool_result event: %+v", ev) } case "error": log.Fatalf("stream error: %v", ev.Err) } } if !sawCall || !sawResult || text.String() != "seven" { log.Fatalf("sawCall=%v sawResult=%v text=%q", sawCall, sawResult, text.String()) } fmt.Println("ok:", text.String()) } ``` ### 3. The full surface — every event type, Anthropic style [Section titled “3. The full surface — every event type, Anthropic style”](#3-the-full-surface--every-event-type-anthropic-style) Same interface, a different wire format: Anthropic streams `content_block_start` / `content_block_delta` / `message_delta` SSE events. `Stream` normalizes both providers to the same `StreamEvent` union, so downstream code never branches on `Style`. ```go package main import ( "context" "fmt" "log" "net/http" "net/http/httptest" toolnexus "github.com/muthuishere/toolnexus/golang" ) func main() { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/event-stream") fl := w.(http.Flusher) write := func(s string) { _, _ = w.Write([]byte(s)); fl.Flush() } write(`data: {"type":"message_start","message":{"usage":{"input_tokens":0,"output_tokens":0}}}` + "\n\n") write(`data: {"type":"content_block_start","index":0,"content_block":{"type":"text"}}` + "\n\n") write(`data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Namaste"}}` + "\n\n") write(`data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"input_tokens":6,"output_tokens":3}}` + "\n\n") })) defer srv.Close() tk, err := toolnexus.CreateToolkit(context.Background(), toolnexus.Options{}) if err != nil { log.Fatal(err) } defer tk.Close() client := toolnexus.CreateClient(toolnexus.ClientOptions{ BaseURL: srv.URL, Style: toolnexus.StyleAnthropic, Model: "claude-3-5-haiku-20241022", APIKey: "test-key", }) ch, err := client.Stream(context.Background(), "say hi in Hindi", tk) if err != nil { log.Fatal(err) } var text string var usageSeen, doneSeen bool var done *toolnexus.RunResult for ev := range ch { switch ev.Type { case "text": text += ev.Delta case "usage": usageSeen = true case "done": doneSeen = true done = ev.Result case "error": log.Fatalf("stream error: %v", ev.Err) } } if !usageSeen || !doneSeen || done == nil { log.Fatalf("usageSeen=%v doneSeen=%v done=%v", usageSeen, doneSeen, done) } if text != "Namaste" || done.Text != "Namaste" || done.Usage.TotalTokens != 9 { log.Fatalf("unexpected result: text=%q done=%+v", text, done) } fmt.Println("ok:", done.Text, "usage:", done.Usage.TotalTokens) } ``` ## StreamEvent fields [Section titled “StreamEvent fields”](#streamevent-fields) | Field | Populated on | What it holds | | ------------------- | ------------------------------ | -------------------------------------------------------------------------------------------------- | | `Type` | always | `"text"` \| `"tool_call"` \| `"tool_result"` \| `"usage"` \| `"pending"` \| `"done"` \| `"error"`. | | `Delta` | `"text"` | The incremental assistant text. | | `Name`, `ID` | `"tool_call"`, `"tool_result"` | Tool name and provider call id. | | `Args` | `"tool_call"` | Parsed tool arguments, assembled from any split chunks. | | `Output`, `IsError` | `"tool_result"` | The executed tool’s result. | | `Usage` | `"usage"`, `"done"` | Aggregated token usage so far. | | `Result` | `"done"` | The final `RunResult` — same shape `Client.Run` returns. | | `Request` | `"pending"` | A §10 suspension, emitted before `WaitFor` runs so a UI can push the link live. | | `Err` | `"error"` | The terminal error; the channel closes right after. | ## `Via` — where a tool event came from [Section titled “Via — where a tool event came from”](#via--where-a-tool-event-came-from) `StreamEvent.Via` is empty for an ordinary tool call. Under [`ToolTiers`](/api/go/client/tool-tiers/) a `call_tool` call is reported as the **real** tool (its name and inner arguments) with `Via: "call_tool"`. Over an ACP agent streamed through `InProcessOptions.GenerateStream`, the agent’s own tool calls arrive as `tool_call` / `tool_result` with `Via: "acp"` — the loop never runs them (see [ACP host hooks](/api/go/client/acp-hooks/)). `tool_result` events also carry the result’s `Metadata` (for example the `files` / `diff` of an edit). ## See also [Section titled “See also”](#see-also) * [`CreateClient`](/api/go/client/create/) — The unified client: system prompt, skills injection, parallel and chained tool calls, retries, memory. * [`Client.Run`](/api/go/client/run/) — Send a prompt, let the loop call tools until the model stops, get a RunResult. * [`Hooks`](/api/go/client/hooks/) — Intercept before/after model calls and tool calls: audit, redact, veto, or rewrite. * [`Conversation`](/api/go/client/conversation/) — Keep a transcript across turns so the model remembers what it already did. # ClientOptions.ToolTiers > 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. — Go. **Go** · package `github.com/muthuishere/toolnexus/golang` · [SPEC §8](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [golang/tool\_tiers.go](https://github.com/muthuishere/toolnexus/blob/main/golang/tool_tiers.go) ```go type ToolTierOptions struct { Core []string // always sent with full schemas Deferred []string // named only; every other tool is "listed" BudgetTokens int // cap on what is sent up front; 0 ⇒ 5% of ContextWindow ContextWindow int UsageCount func(name string) int // host usage stat, read when the plan is built CountTokens func(v any) int // default ceil(bytes/4) } // ClientOptions.ToolTiers *ToolTierOptions — nil ⇒ off, every schema sent as before. func (c *Client) ToolTierPlan(tk *Toolkit) map[string]ToolTier // TierCore | TierListed | TierDeferred func (c *Client) ResetToolTiers(tk *Toolkit) ``` With `ToolTiers` set, the tools array is the **core** tools sorted by name, then `tool_search` and `call_tool`. Every other tool is **listed** (`- name — first line` inside `tool_search`’s description, cut to at most 120 bytes) or **deferred** (name only there). * `tool_search {query, limit?}` — `select:a,b` for exact names, else keyword scoring (default 5 results). It returns the full definitions as JSON **in the tool result**, so the tools array — and the cached prefix — never changes mid-session. It is engine-internal and read-only (no `BeforeTool`). * A loaded tool is called by name, or as `call_tool {name, arguments}`. The loop unwraps `call_tool` **before** hooks, so `BeforeTool`, hints and permission gates see the real tool; stream `tool_call` / `tool_result` events carry the real name with `Via: "call_tool"`. * The plan is built on a toolkit’s first request and then **frozen**. `ResetToolTiers(tk)` rebuilds it — call it at session start. A tool registered later is deferred and found by search. * Budget: while core schemas plus the two meta tools exceed it, the least-used core tool moves to listed (ties: larger schema, then name), then the least-used listed tool to deferred. ## When to use it [Section titled “When to use it”](#when-to-use-it) A host with many tools — dozens of MCP tools, a full builtin pack, plugins — where sending every schema on every request costs tokens and dilutes the model’s choice, or where tools are added mid-session and would otherwise break the [prompt cache](/api/go/client/prompt-cache/). ## Why this and not the alternative [Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative) A handful of tools? Use DisableTools or nothing at all Tiers add a search round trip whenever the model needs a tool outside the core set. With a small, fixed tool set, sending every schema is simpler and costs one request less. To drop tools you never want offered, filter them with `DisableTools` instead. ## Examples [Section titled “Examples”](#examples) ### 1. The smallest useful call — only core schemas go out [Section titled “1. The smallest useful call — only core schemas go out”](#1-the-smallest-useful-call--only-core-schemas-go-out) ```go package main import ( "context" "encoding/json" "fmt" "io" "log" "net/http" "net/http/httptest" "strings" toolnexus "github.com/muthuishere/toolnexus/golang" ) func tool(name, desc string) toolnexus.Tool { return toolnexus.NativeTool(name, desc, toolnexus.JSONSchema{"type": "object", "properties": map[string]any{}}, func(context.Context, map[string]any) (string, error) { return name + " ran", nil }) } func main() { tk, err := toolnexus.CreateToolkit(context.Background(), toolnexus.Options{ExtraTools: []toolnexus.Tool{ tool("read_file", "Read a file"), tool("weather", "Current weather for a city"), tool("stocks", "Stock quote"), }}) if err != nil { log.Fatal(err) } defer tk.Close() var sent []string var searchDesc string srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var body struct { Tools []struct { Function struct{ Name, Description string } `json:"function"` } `json:"tools"` } b, _ := io.ReadAll(r.Body) _ = json.Unmarshal(b, &body) for _, t := range body.Tools { sent = append(sent, t.Function.Name) if t.Function.Name == "tool_search" { searchDesc = t.Function.Description } } w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"choices":[{"message":{"role":"assistant","content":"hi"}}]}`)) })) defer srv.Close() client := toolnexus.CreateClient(toolnexus.ClientOptions{ BaseURL: srv.URL, Style: toolnexus.StyleOpenAI, Model: "m", APIKey: "YOUR_KEY_HERE", ToolTiers: &toolnexus.ToolTierOptions{Core: []string{"read_file"}, Deferred: []string{"stocks"}}, }) if _, err := client.Run(context.Background(), "hello", tk); err != nil { log.Fatal(err) } if strings.Join(sent, ",") != "read_file,tool_search,call_tool" { log.Fatalf("unexpected tools array: %v", sent) } if !strings.Contains(searchDesc, "weather") || !strings.Contains(searchDesc, "stocks") { log.Fatalf("tool_search description does not name the other tools: %q", searchDesc) } plan := client.ToolTierPlan(tk) fmt.Println("ok:", sent, "weather =", plan["weather"], "stocks =", plan["stocks"]) } ``` ### 2. The realistic case — search, then `call_tool`, under a permission hook [Section titled “2. The realistic case — search, then call\_tool, under a permission hook”](#2-the-realistic-case--search-then-call_tool-under-a-permission-hook) The hook sees `weather`, never `call_tool`. ```go package main import ( "context" "fmt" "log" "net/http" "net/http/httptest" "sync" toolnexus "github.com/muthuishere/toolnexus/golang" ) func main() { weather := toolnexus.NativeTool("weather", "Current weather for a city", toolnexus.JSONSchema{"type": "object", "properties": map[string]any{"city": map[string]any{"type": "string"}}}, func(_ context.Context, a map[string]any) (string, error) { return fmt.Sprint("sunny in ", a["city"]), nil }) tk, err := toolnexus.CreateToolkit(context.Background(), toolnexus.Options{ExtraTools: []toolnexus.Tool{weather}}) if err != nil { log.Fatal(err) } defer tk.Close() replies := []string{ `{"choices":[{"message":{"role":"assistant","content":null,"tool_calls":[{"id":"c1","type":"function","function":{"name":"tool_search","arguments":"{\"query\":\"select:weather\"}"}}]}}]}`, `{"choices":[{"message":{"role":"assistant","content":null,"tool_calls":[{"id":"c2","type":"function","function":{"name":"call_tool","arguments":"{\"name\":\"weather\",\"arguments\":{\"city\":\"Paris\"}}"}}]}}]}`, `{"choices":[{"message":{"role":"assistant","content":"It is sunny in Paris."}}]}`, } var mu sync.Mutex turn := 0 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { mu.Lock() reply := replies[turn] turn++ mu.Unlock() w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(reply)) })) defer srv.Close() var gated []string client := toolnexus.CreateClient(toolnexus.ClientOptions{ BaseURL: srv.URL, Style: toolnexus.StyleOpenAI, Model: "m", APIKey: "YOUR_KEY_HERE", ToolTiers: &toolnexus.ToolTierOptions{}, Hooks: &toolnexus.Hooks{BeforeTool: func(_ context.Context, ev toolnexus.BeforeToolEvent) (*toolnexus.ToolOverride, error) { gated = append(gated, ev.Name) return nil, nil }}, }) res, err := client.Run(context.Background(), "weather in Paris?", tk) if err != nil { log.Fatal(err) } if len(gated) != 1 || gated[0] != "weather" { log.Fatalf("BeforeTool should see only the real tool, saw %v", gated) } fmt.Println("ok:", res.Text) } ``` ### 3. The full surface — a token budget, usage stats, and `ResetToolTiers` [Section titled “3. The full surface — a token budget, usage stats, and ResetToolTiers”](#3-the-full-surface--a-token-budget-usage-stats-and-resettooltiers) The plan is frozen once built; changing the usage stat only takes effect after `ResetToolTiers`. ```go package main import ( "context" "fmt" "log" "strings" toolnexus "github.com/muthuishere/toolnexus/golang" ) func main() { var tools []toolnexus.Tool for _, n := range []string{"alpha", "beta", "gamma"} { n := n tools = append(tools, toolnexus.NativeTool(n, "Tool "+n+" "+strings.Repeat("x", 200), toolnexus.JSONSchema{"type": "object", "properties": map[string]any{}}, func(context.Context, map[string]any) (string, error) { return n, nil })) } tk, err := toolnexus.CreateToolkit(context.Background(), toolnexus.Options{ExtraTools: tools}) if err != nil { log.Fatal(err) } defer tk.Close() usage := map[string]int{"alpha": 10, "beta": 1, "gamma": 5} client := toolnexus.CreateClient(toolnexus.ClientOptions{ BaseURL: "http://127.0.0.1:1", Style: toolnexus.StyleOpenAI, Model: "m", APIKey: "YOUR_KEY_HERE", ToolTiers: &toolnexus.ToolTierOptions{ Core: []string{"alpha", "beta", "gamma"}, BudgetTokens: 300, // the two meta tools plus exactly one core schema CountTokens: func(any) int { return 100 }, // every definition "costs" 100 UsageCount: func(name string) int { return usage[name] }, }, }) first := client.ToolTierPlan(tk) if first["alpha"] != toolnexus.TierCore || first["beta"] == toolnexus.TierCore { log.Fatalf("budget should keep the most-used tool core: %v", first) } usage["beta"] = 100 // new stats are ignored while the plan is frozen if client.ToolTierPlan(tk)["beta"] != first["beta"] { log.Fatal("plan changed without ResetToolTiers") } client.ResetToolTiers(tk) // e.g. at a new session start if client.ToolTierPlan(tk)["beta"] != toolnexus.TierCore { log.Fatalf("after reset beta should be core: %v", client.ToolTierPlan(tk)) } fmt.Println("ok: before", first, "after", client.ToolTierPlan(tk)) } ``` ## See also [Section titled “See also”](#see-also) * [`PromptCache`](/api/go/client/prompt-cache/) — tiers keep the tools array stable, so the cache keeps hitting. * [`Hooks`](/api/go/client/hooks/) — `BeforeTool` sees the real tool behind `call_tool`. * [`Client.Stream`](/api/go/client/stream/) — `tool_call` events carry `Via: "call_tool"`. * [`CreateClient`](/api/go/client/create/) — The unified client: system prompt, skills injection, parallel and chained tool calls, retries, memory. # Compactor > Summarise older turns when the transcript grows, so a long-lived agent stays inside its context window. — Go. **Go** · package `github.com/muthuishere/toolnexus/golang/agents` · [SPEC §7F](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [golang/agents/compaction.go](https://github.com/muthuishere/toolnexus/blob/main/golang/agents/compaction.go) ```go type CompactorOptions struct { MaxTokens int // compact only when the estimate exceeds this KeepTail int // tail tokens to retain; default MaxTokens/2 Summarize func(older []any) (string, error) // required; MAY call an LLM CountTokens func(messages []any) int // default EstimateTokens FlushToMemory bool // inject a pre-compact memory reminder } func Compactor(opts CompactorOptions) func(context.Context, tn.BeforeLLMEvent) (*tn.LLMOverride, error) func EstimateTokens(messages []any) int // the default estimator: ceil(chars/4) per message ``` `Compactor` builds a `BeforeLLM` hook (§8) — a pure `messages → messages` transform, not a new loop feature. Below `MaxTokens` it returns `nil` (no-op, byte-identical to no compactor at all). Above it, it keeps a leading `system` message verbatim, summarizes everything between that and a tail that fits `KeepTail`, and returns `[system, summary-system-message, (flush reminder?), …tail]`. The tail always starts at a `user` turn — that’s tool-pair safety, 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) Reach for `Compactor` on any agent that runs long enough to threaten the model’s context window — a persona with a heartbeat, a coordinator delegating many rounds, anything backed by a [`ConversationStore`](/api/go/client/store/). Wire the returned hook into `ClientOptions.Hooks.BeforeLLM` on a bare client, or into an [`agents.Spec`](/api/go/runtime/agent/)’s hooks for a §7D agent run — per agent, so two agents in one runtime can carry different budgets. ## Why this and not the alternative [Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative) Trimming history yourself with a raw BeforeLLM hook? You can always write your own `Hooks.BeforeLLM` that truncates or rewrites `ev.Messages`. `Compactor` exists because getting the boundary right is easy to get subtly wrong: split at the wrong point and you orphan a `tool` result from its `tool_use`/`tool_calls`, which every provider rejects. `Compactor` enforces the user-turn boundary and the leading-system-preserved rule for you, and is a no-op at rest — reach for a hand-written hook only when you need a transform `Compactor` doesn’t model (e.g. compacting by wall-clock age instead of token count). ## Examples [Section titled “Examples”](#examples) ### 1. The smallest useful call — under budget is a byte-identical no-op [Section titled “1. The smallest useful call — under budget is a byte-identical no-op”](#1-the-smallest-useful-call--under-budget-is-a-byte-identical-no-op) ```go package main import ( "context" "fmt" "log" tn "github.com/muthuishere/toolnexus/golang" "github.com/muthuishere/toolnexus/golang/agents" ) func main() { hook := agents.Compactor(agents.CompactorOptions{ MaxTokens: 100_000, // far above this tiny transcript Summarize: func(older []any) (string, error) { return "summary", nil }, }) msgs := []any{ map[string]any{"role": "system", "content": "You are Ava."}, map[string]any{"role": "user", "content": "hi"}, map[string]any{"role": "assistant", "content": "hello"}, } ov, err := hook(context.Background(), tn.BeforeLLMEvent{Messages: msgs}) if err != nil { log.Fatal(err) } if ov != nil { log.Fatalf("expected a nil override under budget, got %+v", ov) } fmt.Println("ok: under budget, no compaction ran") } ``` ### 2. The realistic case — compacts above budget, preserving system + tool pairing [Section titled “2. The realistic case — compacts above budget, preserving system + tool pairing”](#2-the-realistic-case--compacts-above-budget-preserving-system--tool-pairing) A synthetic transcript with many user/assistant/tool rounds, well over a small `MaxTokens`. The result keeps the leading system message, a summary, and a tail that starts cleanly at a `user` turn — no orphaned `tool` message. ```go package main import ( "context" "fmt" "log" tn "github.com/muthuishere/toolnexus/golang" "github.com/muthuishere/toolnexus/golang/agents" ) func round(i int) []any { id := fmt.Sprintf("c%d", i) return []any{ map[string]any{"role": "user", "content": fmt.Sprintf("question %d with some padding text here", i)}, map[string]any{"role": "assistant", "content": nil, "tool_calls": []any{ map[string]any{"id": id, "type": "function", "function": map[string]any{"name": "lookup", "arguments": "{}"}}, }}, map[string]any{"role": "tool", "tool_call_id": id, "content": fmt.Sprintf("result %d with more padding data", i)}, map[string]any{"role": "assistant", "content": fmt.Sprintf("answer %d", i)}, } } func main() { msgs := []any{map[string]any{"role": "system", "content": "You are Ava. SOUL."}} for i := 0; i < 30; i++ { msgs = append(msgs, round(i)...) } before := agents.EstimateTokens(msgs) hook := agents.Compactor(agents.CompactorOptions{ MaxTokens: 2000, KeepTail: 800, Summarize: func(older []any) (string, error) { return fmt.Sprintf("summarized %d messages", len(older)), nil }, }) ov, err := hook(context.Background(), tn.BeforeLLMEvent{Messages: msgs}) if err != nil { log.Fatal(err) } if ov == nil { log.Fatal("expected compaction to fire above budget") } after := agents.EstimateTokens(ov.Messages) if after >= before { log.Fatalf("expected the compacted transcript to shrink: before=%d after=%d", before, after) } if role(ov.Messages[0]) != "system" || ov.Messages[0].(map[string]any)["content"] != "You are Ava. SOUL." { log.Fatal("expected the leading system message preserved verbatim") } if role(ov.Messages[1]) != "system" { log.Fatal("expected a summary system message right after the preserved system prompt") } // The tail must start at a user turn — no orphaned tool_call_id. tailStart := ov.Messages[2] if role(tailStart) != "user" { log.Fatalf("expected the tail to start at a user turn, got role=%q", role(tailStart)) } fmt.Println("ok: compacted", before, "->", after, "estimated tokens, tail starts at a user turn") } func role(m any) string { mm, _ := m.(map[string]any) r, _ := mm["role"].(string) return r } ``` ### 3. The full surface — wired into a live `Client.Run`, plus `FlushToMemory` [Section titled “3. The full surface — wired into a live Client.Run, plus FlushToMemory”](#3-the-full-surface--wired-into-a-live-clientrun-plus-flushtomemory) The hook is handed to `ClientOptions.Hooks.BeforeLLM`, so an ordinary `Run` call compacts transparently before it ever reaches the (stubbed) provider. `FlushToMemory` adds one more system reminder telling the model to persist durable facts via the `memory` tool before the head is summarized. ```go package main import ( "bytes" "context" "encoding/json" "fmt" "io" "log" "net/http" "net/http/httptest" toolnexus "github.com/muthuishere/toolnexus/golang" "github.com/muthuishere/toolnexus/golang/agents" ) func round(i int) []any { id := fmt.Sprintf("c%d", i) return []any{ map[string]any{"role": "user", "content": fmt.Sprintf("question %d with padding text here", i)}, map[string]any{"role": "assistant", "content": nil, "tool_calls": []any{ map[string]any{"id": id, "type": "function", "function": map[string]any{"name": "lookup", "arguments": "{}"}}, }}, map[string]any{"role": "tool", "tool_call_id": id, "content": fmt.Sprintf("result %d with padding data", i)}, map[string]any{"role": "assistant", "content": fmt.Sprintf("answer %d", i)}, } } func main() { history := []any{map[string]any{"role": "system", "content": "You are Ava."}} for i := 0; i < 25; i++ { history = append(history, round(i)...) } var lastRequestBody []byte srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { lastRequestBody, _ = io.ReadAll(r.Body) w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"choices":[{"message":{"role":"assistant","content":"noted"}}],"usage":{"prompt_tokens":3,"completion_tokens":1,"total_tokens":4}}`)) })) defer srv.Close() hook := agents.Compactor(agents.CompactorOptions{ MaxTokens: 1500, KeepTail: 600, FlushToMemory: true, Summarize: func(older []any) (string, error) { return fmt.Sprintf("summarized %d messages", len(older)), nil }, }) client := toolnexus.CreateClient(toolnexus.ClientOptions{ BaseURL: srv.URL, Style: toolnexus.StyleOpenAI, Model: "gpt-4o-mini", APIKey: "test-key", Hooks: &toolnexus.Hooks{BeforeLLM: hook}, }) tk, err := toolnexus.CreateToolkit(context.Background(), toolnexus.Options{}) if err != nil { log.Fatal(err) } defer tk.Close() res, err := client.RunWithHistory(context.Background(), "one more question", tk, history) if err != nil { log.Fatal(err) } if res.Status != "done" || res.Text != "noted" { log.Fatalf("unexpected: %+v", res) } var sent map[string]any if err := json.Unmarshal(lastRequestBody, &sent); err != nil { log.Fatal(err) } sentMsgs, _ := sent["messages"].([]any) raw, _ := json.Marshal(sentMsgs) if !bytes.Contains(raw, []byte("summarized")) { log.Fatalf("expected the provider to see a compacted, summarized transcript: %s", raw) } if !bytes.Contains(raw, []byte("save it with the memory tool now")) { log.Fatal("expected the FlushToMemory reminder ahead of the tail") } fmt.Println("ok:", res.Text, "- the provider received a compacted, flush-reminded transcript") } ``` ## `OnCompact` — tell the user the context was compacted [Section titled “OnCompact — tell the user the context was compacted”](#oncompact--tell-the-user-the-context-was-compacted) `CompactorOptions.OnCompact(ctx, CompactInfo)` is called after each compaction (never on a no-op) with `TokensBefore`/`TokensAfter`, `MessagesBefore`/`MessagesAfter`, `Summarized`, the `Summary` text, and `Folded` — the summarized messages verbatim, because the loop saves only the compacted transcript. Since 0.22.0 the compactor also never summarizes away the sole user prompt of a turn (one prompt then only tool traffic ⇒ `[system?, prompt, summary, …]`), and it does nothing when the result would not be smaller. ```go package main import ( "context" "fmt" "log" "strings" tn "github.com/muthuishere/toolnexus/golang" "github.com/muthuishere/toolnexus/golang/agents" ) func main() { msgs := []any{map[string]any{"role": "system", "content": "sys"}} for i := 0; i < 20; i++ { msgs = append(msgs, map[string]any{"role": "user", "content": fmt.Sprintf("question %d %s", i, strings.Repeat("pad ", 20))}, map[string]any{"role": "assistant", "content": fmt.Sprintf("answer %d %s", i, strings.Repeat("pad ", 20))}) } var info agents.CompactInfo hook := agents.Compactor(agents.CompactorOptions{ MaxTokens: 400, KeepTail: 200, Summarize: func(older []any) (string, error) { return "the user asked many questions", nil }, OnCompact: func(_ context.Context, ci agents.CompactInfo) { info = ci }, }) if _, err := hook(context.Background(), tn.BeforeLLMEvent{Messages: msgs}); err != nil { log.Fatal(err) } if info.TokensAfter >= info.TokensBefore || len(info.Folded) != info.Summarized || info.Summarized == 0 { log.Fatalf("unexpected compact info: %+v", info) } fmt.Printf("ok: context compacted %d -> %d tokens, %d messages folded\n", info.TokensBefore, info.TokensAfter, info.Summarized) } ``` ## CompactorOptions fields [Section titled “CompactorOptions fields”](#compactoroptions-fields) | Field | Type | Default | What it does | | --------------- | ----------------------------------- | ---------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | | `MaxTokens` | `int` | — (required for compaction to ever fire) | Compact only when the estimate exceeds this; at or below ⇒ no-op, byte-identical to no compactor. | | `KeepTail` | `int` | `MaxTokens / 2` | Keep at least this many estimated tokens of the most recent tail. | | `Summarize` | `func(older []any) (string, error)` | — (required) | Produces the summary. MAY call an LLM — the library never calls a model on the host’s behalf. | | `CountTokens` | `func(messages []any) int` | `EstimateTokens` | The token estimator (`ceil(chars/4)` per message, summed). An estimator, not a tokenizer. | | `FlushToMemory` | `bool` | `false` | Injects a pre-compact system reminder to persist durable facts via the §7E `memory` tool before the head is summarized. | ## See also [Section titled “See also”](#see-also) * [`Client.Run`](/api/go/client/run/) — The loop that applies `Hooks.BeforeLLM` on every turn. * [`agents.New`](/api/go/runtime/agent/) — Wire a compactor per agent via `Spec` hooks in a §7D run. * [`ComposeSoul`](/api/go/persona/compose-soul/) — Builds the leading system prompt a compactor preserves verbatim. # HTTPTool > Turn an HTTP endpoint into a tool: URL, method, headers with ${ENV} expansion, and a schema. — Go. **Go** · module `github.com/muthuishere/toolnexus/golang` · [SPEC §7](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [golang/http.go](https://github.com/muthuishere/toolnexus/blob/main/golang/http.go) ```go func HTTPTool(opts HTTPToolOptions) Tool ``` Declares a REST endpoint as a [`Tool`](/api/go/types/tool/) — no client code, no handler function. You describe the URL, the method, the headers and the input schema; toolnexus routes the model’s arguments into path placeholders, querystring or body, makes the call, and turns the response into a `ToolResult` with `Source: "http"`. ## When to use it [Section titled “When to use it”](#when-to-use-it) * **You already have an API** and want the model to call it without writing a Go client per endpoint. * **The service is not yours** — a public REST API, a partner webhook, an internal microservice — and an MCP server for it does not exist. * **Credentials live in the environment.** Header values expand `` `${ENV_VAR}` `` at call time and are never logged, so the key stays out of your config and out of your prompts. ## Why this and not the alternative [Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative) Use NativeTool when the call needs logic [`NativeTool`](/api/go/native/define-tool/) hands you a function with a real HTTP client, and is the one to reach for *instead* of this the moment the call stops being a single request: pagination, retries with backoff, an OAuth refresh, response reshaping, several calls behind one tool, or an error taxonomy the model should read. `HTTPTool` deliberately covers only the declarative case — one request in, its body out. If the remote service already speaks MCP, prefer [`LoadMcp`](/api/go/mcp/load/): you get every one of its tools with descriptions and schemas already written, instead of declaring each endpoint by hand. ## Examples [Section titled “Examples”](#examples) ### 1. A GET with a path placeholder [Section titled “1. A GET with a path placeholder”](#1-a-get-with-a-path-placeholder) `{name}` in the URL is filled from the args — and **consumed**, so it is not also sent as a query parameter. ```go package main import ( "fmt" "log" "net/http" "net/http/httptest" toolnexus "github.com/muthuishere/toolnexus/golang" ) func main() { // A local test server — the docs examples never touch the public internet. srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { fmt.Fprintf(w, "hello %s", r.URL.Path[len("/greet/"):]) })) defer srv.Close() greet := toolnexus.HTTPTool(toolnexus.HTTPToolOptions{ Name: "greet", Description: "Greet someone by name", Method: "GET", URL: srv.URL + "/greet/{name}", InputSchema: toolnexus.JSONSchema{ "type": "object", "properties": map[string]any{"name": map[string]any{"type": "string"}}, "required": []string{"name"}, }, }) if greet.Source != toolnexus.SourceHTTP { log.Fatalf("unexpected source: %s", greet.Source) } res, err := greet.Execute(map[string]any{"name": "muthu"}, nil) if err != nil || res.IsError || res.Output != "hello muthu" { log.Fatalf("unexpected: %+v %v", res, err) } // Metadata always carries the status code. if res.Metadata["status"] != 200 { log.Fatalf("unexpected metadata: %v", res.Metadata) } fmt.Println("ok:", res.Output) } ``` ### 2. A POST with a JSON body and an env-backed header [Section titled “2. A POST with a JSON body and an env-backed header”](#2-a-post-with-a-json-body-and-an-env-backed-header) Everything left over after placeholders and querystring becomes the body. `Content-Type` is filled in for you unless you set it. ```go package main import ( "encoding/json" "fmt" "io" "log" "net/http" "net/http/httptest" "os" toolnexus "github.com/muthuishere/toolnexus/golang" ) func main() { var gotAuth, gotType, gotBody string srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { gotAuth = r.Header.Get("Authorization") gotType = r.Header.Get("Content-Type") b, _ := io.ReadAll(r.Body) gotBody = string(b) w.Header().Set("Content-Type", "application/json") fmt.Fprint(w, `{"id":"tkt-7","status":"open"}`) })) defer srv.Close() // The value lives in the environment, never in the config or the prompt. os.Setenv("DEMO_API_KEY", "YOUR_KEY_HERE") create := toolnexus.HTTPTool(toolnexus.HTTPToolOptions{ Name: "create_ticket", Description: "Open a support ticket", Method: "POST", URL: srv.URL + "/tickets", Headers: map[string]string{"Authorization": "Bearer ${DEMO_API_KEY}"}, InputSchema: toolnexus.JSONSchema{ "type": "object", "properties": map[string]any{ "title": map[string]any{"type": "string"}, "body": map[string]any{"type": "string"}, }, "required": []string{"title"}, }, }) res, err := create.Execute(map[string]any{"title": "Broken login", "body": "500 on submit"}, nil) if err != nil || res.IsError { log.Fatalf("unexpected: %+v %v", res, err) } if gotAuth != "Bearer YOUR_KEY_HERE" { log.Fatalf("header did not expand: %q", gotAuth) } if gotType != "application/json" { log.Fatalf("expected a json content type, got %q", gotType) } var sent map[string]any if err := json.Unmarshal([]byte(gotBody), &sent); err != nil { log.Fatal(err) } if sent["title"] != "Broken login" || sent["body"] != "500 on submit" { log.Fatalf("unexpected body: %v", sent) } // ResultMode defaults to "text": the response body, verbatim. if res.Output != `{"id":"tkt-7","status":"open"}` { log.Fatalf("unexpected output: %s", res.Output) } fmt.Println("ok:", res.Output) } ``` Never write the key itself `Headers` values are the only place a credential appears, and they expand from `os.Getenv` at call time — the expanded value is never logged. Write `` `Bearer ${MY_API_KEY}` `` in your config, and keep the actual key in the environment. ### 3. The full surface — query routing, form bodies, result modes and failures [Section titled “3. The full surface — query routing, form bodies, result modes and failures”](#3-the-full-surface--query-routing-form-bodies-result-modes-and-failures) ```go package main import ( "fmt" "io" "log" "net/http" "net/http/httptest" "strings" "time" toolnexus "github.com/muthuishere/toolnexus/golang" ) func main() { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { case strings.HasPrefix(r.URL.Path, "/search"): // `page` was declared in Query, so it rides in the querystring. b, _ := io.ReadAll(r.Body) fmt.Fprintf(w, `{"q":%q,"page":%q,"body":%q}`, r.URL.Query().Get("q"), r.URL.Query().Get("page"), string(b)) case r.URL.Path == "/form": _ = r.ParseForm() fmt.Fprintf(w, "name=%s", r.PostForm.Get("name")) case r.URL.Path == "/missing": w.WriteHeader(http.StatusNotFound) fmt.Fprint(w, "no such thing") case r.URL.Path == "/slow": time.Sleep(300 * time.Millisecond) fmt.Fprint(w, "eventually") } })) defer srv.Close() // Query names ride in the querystring even on a POST; the rest is the body. search := toolnexus.HTTPTool(toolnexus.HTTPToolOptions{ Name: "search", Description: "Search the catalog", Method: "POST", URL: srv.URL + "/search", Query: []string{"q", "page"}, ResultMode: "json", InputSchema: toolnexus.JSONSchema{ "type": "object", "properties": map[string]any{ "q": map[string]any{"type": "string"}, "page": map[string]any{"type": "number"}, "filter": map[string]any{"type": "string"}, }, "required": []string{"q"}, }, }) res, err := search.Execute(map[string]any{"q": "adapters", "page": 2, "filter": "docs"}, nil) if err != nil || res.IsError { log.Fatalf("unexpected: %+v %v", res, err) } if !strings.Contains(res.Output, `"q":"adapters"`) || !strings.Contains(res.Output, `"page":"2"`) { log.Fatalf("query routing failed: %s", res.Output) } if !strings.Contains(res.Output, `filter`) { log.Fatalf("expected the leftover arg in the body: %s", res.Output) } // Body: "form" urlencodes the leftover args instead of sending JSON. form := toolnexus.HTTPTool(toolnexus.HTTPToolOptions{ Name: "submit", Method: "POST", URL: srv.URL + "/form", Body: "form", }) fres, err := form.Execute(map[string]any{"name": "muthu"}, nil) if err != nil || fres.Output != "name=muthu" { log.Fatalf("unexpected form result: %+v %v", fres, err) } // A non-2xx is an error RESULT the model can read, not a returned error. missing := toolnexus.HTTPTool(toolnexus.HTTPToolOptions{ Name: "missing", Method: "GET", URL: srv.URL + "/missing", ResultMode: "status+text", }) mres, err := missing.Execute(nil, nil) if err != nil { log.Fatal(err) } // Note: the failure shape wins over ResultMode. if !mres.IsError || mres.Output != "HTTP 404: no such thing" || mres.Metadata["status"] != 404 { log.Fatalf("unexpected failure result: %+v", mres) } // Timeout: opts.Timeout is milliseconds; a ToolContext.Timeout overrides it. slow := toolnexus.HTTPTool(toolnexus.HTTPToolOptions{ Name: "slow", Method: "GET", URL: srv.URL + "/slow", Timeout: 50, }) sres, err := slow.Execute(nil, nil) if err != nil || !sres.IsError || !strings.Contains(sres.Output, "deadline exceeded") { log.Fatalf("expected a timeout result, got %+v %v", sres, err) } // Same tool, longer budget from the call site. sres2, err := slow.Execute(nil, &toolnexus.ToolContext{Timeout: 5000}) if err != nil || sres2.IsError || sres2.Output != "eventually" { log.Fatalf("unexpected override result: %+v %v", sres2, err) } fmt.Println("ok:", fres.Output, "|", mres.Output, "| override:", sres2.Output) } ``` ## Options [Section titled “Options”](#options) `HTTPToolOptions`: | Field | Type | What it does | | ------------- | ------------------- | ----------------------------------------------------------------------------------------------------------------- | | `Name` | `string` | The tool name the model calls. | | `Description` | `string` | What the model reads to decide whether to call it. | | `Method` | `string` | Upper-cased for you. Empty ⇒ `GET`. | | `URL` | `string` | May contain `{placeholder}` segments, filled and consumed from args, path-escaped. | | `Headers` | `map[string]string` | Static headers. `` `${ENV_VAR}` `` in a value expands from `os.Getenv` at call time and is never logged. | | `Query` | `[]string` | Arg names to send as querystring instead of in the body. On `GET`, **all** args go to the querystring regardless. | | `Body` | `string` | `"json"` (default), `"form"`, or `"raw"` (sends the `body` arg verbatim). Ignored for `GET`/`HEAD`. | | `InputSchema` | `JSONSchema` | The args the model supplies. `nil` ⇒ empty object schema. | | `Timeout` | `int` | Milliseconds. `0` ⇒ 30000. A non-zero `ToolContext.Timeout` overrides it. | | `ResultMode` | `string` | `"text"` (default, body verbatim), `"json"` (parse + re-encode), `"status+text"` (`"\n"`). | Result mapping: | Response | `ToolResult` | | ------------------------- | ------------------------------------------------------------------------------ | | 2xx | `{Output: , IsError: false, Metadata: {"status": }}` | | non-2xx | `{Output: "HTTP : ", IsError: true, Metadata: {"status": }}` | | transport error / timeout | `{Output: , IsError: true}` — `Execute`’s own error stays `nil` | Argument routing, in order: URL placeholders first (consumed), then querystring (`Query` names, or everything on a `GET`), then whatever is left becomes the body. ## See also [Section titled “See also”](#see-also) * [`NativeTool`](/api/go/native/define-tool/) — when the call needs real logic * [`LoadMcp`](/api/go/mcp/load/) — prefer this when the service already speaks MCP * [`Tool`](/api/go/types/tool/) — what you get back * [`ToolContext`](/api/go/types/context/) — where the timeout override comes from * [`CreateToolkit`](/api/go/toolkit/create/) — register it via `ExtraTools` # CreateClassifier > A sibling of the client: pre-declared typed questions in, calibrated answers out — no messages, no tool calling, no loop. — Go. **Go** · package `github.com/muthuishere/toolnexus/golang` · [SPEC §8B](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [golang/classifier.go](https://github.com/muthuishere/toolnexus/blob/main/golang/classifier.go) ```go func CreateClassifier(opts ClassifierOptions) (*Classifier, error) func (c *Classifier) Evaluate( ctx context.Context, state any, questions map[string]Question, ) (Decision, error) ``` A sibling of the client: pre-declared typed questions in, calibrated answers out — no messages, no tool calling, no loop. `state` is whatever the host already has (a string, a struct, a map, a slice); `questions` is a map from **caller-chosen keys** to question definitions. The keys are addressing, not content — they are never transmitted, so a key may be a tool, skill or agent name verbatim, and two evaluations differing only in their keys send identical bytes. Go returns `(T, error)` everywhere the other ports throw, and `Evaluate` takes a `context.Context` — cancel it and the call returns `ctx.Err()` without retrying. A classifier interprets; it never authorises A decision is **advisory**. Schema validity is not correctness: a decision can be confidently wrong, and “cannot hallucinate” means only that the returned value is inside the declared schema. Numeric limits, permission checks and allowlists stay in your `if` statements. Nothing on this page is a security control. ## When to use it [Section titled “When to use it”](#when-to-use-it) Reach for a classifier when the thing you need is a **judgment**, not an action: *is this command risky, does this turn need the billing skill, is this text an injection attempt, how urgent is this ticket*. `Tool` is the contract for an action; `Classifier` is the contract for a judgment. The concepts and the measurements live in [Typed decisions](/toolnexus/judge/); this page is the Go surface. A `Classifier` is **not** a model you can hand to `Client.Run` — it has no messages, no tool calling and no streaming, and it never enters the client loop. ## Why this and not the alternative [Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative) When NOT to use it If the answer is already decidable in code — a numeric threshold, a regex, an allowlist — use the code. It is free, instant and auditable. If the decision must also *act* (call tools, write prose, hold a conversation), you want [`CreateClient`](/api/go/client/create/) instead. A classifier sits between the two: fractions of a cent, \~0.3–0.5 s, one number you can threshold. ## Defaults [Section titled “Defaults”](#defaults) | Setting | Default | | ------------ | --------------------------------------------------------------------------------------------- | | `Style` | `StyleSystemOne` | | `BaseURL` | `https://api.typesafe.ai/v1` (`DefaultClassifierBaseURL`) | | `Model` | `jev-latest` (`DefaultClassifierModel`) — a floating alias; pin it once thresholds are tuned | | `APIKeyEnv` | `TYPESAFE_API_KEY` (`DefaultClassifierAPIKeyEnv`) — the **name** of an env var, never a value | | `Timeout` | 10 s (`DefaultClassifierTimeout`), bounding **one** request | | `Retries` | 2 | | `HTTPClient` | `http.DefaultClient` | ## `ClassifierOptions` [Section titled “ClassifierOptions”](#classifieroptions) ```go type ClassifierOptions struct { Style ClassifierStyle BaseURL string Model string APIKeyEnv string Headers map[string]string Timeout time.Duration HTTPClient *http.Client Retries int RetryableStatuses []int OnError func(ErrorInfo) Tier RequestParams map[string]any BodyTransform func(body map[string]any) map[string]any OnMetric func(MetricEvent) Client *Client Evaluate func(ctx context.Context, state any, questions map[string]Question) (Decision, error) Decisions []RecordedDecision } ``` | Field | Default | What it does | | ------------------- | ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `Style` | `StyleSystemOne` | Which backend answers: `StyleSystemOne`, `StyleLLM`, `StyleCustom`, `StyleStatic`. | | `BaseURL` | `https://api.typesafe.ai/v1` | API base. The request goes to `/systemone`. OpenRouter (`https://openrouter.ai/api/v1`) serves the same wire. | | `Model` | `jev-latest` | Model asked for. `Decision.Model` echoes what actually answered, which may be more specific. | | `APIKeyEnv` | `TYPESAFE_API_KEY` | The **name** of the env var holding the credential, read at call time and never logged. Unlike §8’s `APIKey`, this option deliberately never takes a value. | | `Headers` | — | Extra request headers. Values expand `${ENV_VAR}` at call time and are never logged. | | `Timeout` | 10 s | Bounds **one** request — a classifier has no loop to bound. | | `HTTPClient` | `http.DefaultClient` | Transport override, scoped to the classifier path only. | | `Retries` | 2 | Retries on a transient failure. Backoff is `RetryBaseMs * 2^attempt` with no jitter; a `Retry-After` header wins over it. | | `RetryBaseMs` | 500 | Base of that backoff, in ms. `0` ⇒ 500. The classifier’s half of the §8 `ClientOptions.RetryBaseMs` it mirrors — set it to `1` to take a retry test off the clock. | | `RetryableStatuses` | — | Extra HTTP statuses to treat as retryable. It **adds only** — it can never remove `429` and lose `Retry-After` handling with it. | | `OnError` | built-in | Classifies one failed attempt into `TierRetry` or `TierFail`. Reuses §8’s `ErrorInfo`/`Tier` verbatim; there is no second retry policy and no `suspend` tier here. | | `RequestParams` | — | Extra top-level body keys, shallow-merged after the classifier builds its own body. A `RequestParams` key **wins** on collision. | | `BodyTransform` | — | Receives the assembled body after the merge and returns the body to send; returning `nil` leaves it unchanged. Order: base body → `RequestParams` → `BodyTransform` → marshal. | | `OnMetric` | — | The §8 metric sink. Emits `MetricClassifierEvaluate` per call and `MetricClassifierWarning` for degenerate criteria. | | `Client` | — | The §8 `Client` to emulate over. **Required** for `StyleLLM`. | | `Evaluate` | — | Your own function. **Required** for `StyleCustom`; every wire option is ignored. | | `Decisions` | — | The recorded corpus. `StyleStatic` only. | A style whose required option is missing is rejected by `CreateClassifier`, before any call is made — `StyleLLM` without `Client`, `StyleCustom` without `Evaluate`, an unknown style at all. ## The four backends [Section titled “The four backends”](#the-four-backends) | `Style` | Where the answer comes from | `Calibrated` | | ---------------- | ------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------- | | `StyleSystemOne` | `POST /systemone` — TypeSafe’s own API, or any gateway serving that wire | `true` | | `StyleLLM` | One structured-output call on a §8 `Client` — the vendor-neutral exit when you have no System One credential | `false` (self-reported numbers, not token probabilities) | | `StyleCustom` | Your `Evaluate` func | whatever you return | | `StyleStatic` | A recorded corpus, matched on the canonical request **and** the state | whatever was recorded | The trade-offs — and why `static` is a test *contract* rather than a convenience — are in [Backends](/toolnexus/judge/backends/). ## Retries [Section titled “Retries”](#retries) The classifier’s default retryable set is `{408, 429, 500, 502, 503, 504, 529}` — the §8 client set plus **408**, which the classifier adds rather than inventing a second policy. `Retry-After` (delay-seconds) wins over the computed backoff. `RetryableStatuses` widens the set and never narrows it; `OnError` still runs per attempt and has the final say, so `OnError` returning `TierFail` overrides a status listed there. A cancelled `ctx` is never retried. A `401`/`403` body is never echoed into an error, a log or a metric — a gateway happily reflects a bad `Authorization` header into its own error text. ## Examples [Section titled “Examples”](#examples) ### 1. The smallest useful call — one question, replayed offline [Section titled “1. The smallest useful call — one question, replayed offline”](#1-the-smallest-useful-call--one-question-replayed-offline) `StyleStatic` needs no key and no network, so this is the whole surface in one file. ```go package main import ( "context" "fmt" "log" toolnexus "github.com/muthuishere/toolnexus/golang" ) func main() { state := "Ticket 4021: my card was charged twice and the second charge was not refunded." questions := map[string]toolnexus.Question{ "wants_money_back": toolnexus.NoulQuestion{ Instructions: "Is the customer asking for money to be returned?", }, } recorded := `{"model":"typesafe/jev-1.13-20260917", "answers":{"wants_money_back":{"type":"noul","noul":0.99}}, "usage":{"input_tokens":180,"output_tokens":8}}` judge, err := toolnexus.CreateClassifier(toolnexus.ClassifierOptions{ Style: toolnexus.StyleStatic, Model: "typesafe/jev-1.13", Decisions: []toolnexus.RecordedDecision{ {State: state, Questions: questions, Response: []byte(recorded)}, }, }) if err != nil { log.Fatal(err) } d, err := judge.Evaluate(context.Background(), state, questions) if err != nil { log.Fatal(err) } want, err := d.Noul("wants_money_back") if err != nil { log.Fatal(err) } if want.Noul < 0.9 || !d.Calibrated { log.Fatalf("unexpected decision: %+v", d) } fmt.Println("ok: wants_money_back =", want.Noul, "model:", d.Model) } ``` ### 2. The realistic case — your own backend, and the degenerate-criteria warning [Section titled “2. The realistic case — your own backend, and the degenerate-criteria warning”](#2-the-realistic-case--your-own-backend-and-the-degenerate-criteria-warning) `StyleCustom` hands the whole evaluation to you; every wire option is ignored. The client-side limits and the degenerate-criteria detection still run first, because they are properties of the questions, not of the transport. ```go package main import ( "context" "fmt" "log" toolnexus "github.com/muthuishere/toolnexus/golang" ) func main() { var warnings []string judge, err := toolnexus.CreateClassifier(toolnexus.ClassifierOptions{ Style: toolnexus.StyleCustom, Evaluate: func(_ context.Context, _ any, _ map[string]toolnexus.Question) (toolnexus.Decision, error) { return toolnexus.Decision{ Model: "house-rules-v1", Answers: map[string]toolnexus.DecisionAnswer{ "desk": toolnexus.ChoiceAnswer{ Choice: "billing", Probabilities: map[string]float64{"billing": 0.5, "technical": 0.5}, Confidence: 0.5, }, }, Calibrated: false, }, nil }, OnMetric: func(ev toolnexus.MetricEvent) { if ev.Event == toolnexus.MetricClassifierWarning { warnings = append(warnings, ev.Question) } }, }) if err != nil { log.Fatal(err) } // Every description is just its own option id — schema-valid, and it tells the // model nothing. Detection, never repair: the request goes out byte-unchanged. questions := map[string]toolnexus.Question{ "desk": toolnexus.ChoiceQuestion{ Instructions: "Which desk should own this ticket?", Criteria: map[string]string{"billing": "billing", "technical": "technical"}, }, } if _, err := judge.Evaluate(context.Background(), "charged twice", questions); err != nil { log.Fatal(err) } // Once per question key per classifier, so a per-turn judge never floods the sink. if _, err := judge.Evaluate(context.Background(), "charged twice", questions); err != nil { log.Fatal(err) } if len(warnings) != 1 || warnings[0] != "desk" { log.Fatalf("expected one warning naming the key, got %v", warnings) } fmt.Println("ok: warned once about question", warnings[0]) } ``` The warning travels in the event’s `Warning` field and `Error` stays empty — a consumer filtering the §8 sink on “has an error” must not count one. Why the library refuses to repair the criteria, and what a good encoding is worth in measured points, is in [The encoding obligation](/toolnexus/judge/encoding/). ### 3. The full surface — the `systemone` wire, against a local server [Section titled “3. The full surface — the systemone wire, against a local server”](#3-the-full-surface--the-systemone-wire-against-a-local-server) `BaseURL` points anywhere that serves the wire, so a `httptest` server exercises the request shaping, the retry budget and the metric sink with no network and no credential. ```go package main import ( "encoding/json" "fmt" "io" "log" "net/http" "net/http/httptest" "time" "context" toolnexus "github.com/muthuishere/toolnexus/golang" ) func main() { var seen map[string]any calls := 0 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { calls++ if calls == 1 { w.Header().Set("Retry-After", "0") // delay-seconds wins over the backoff w.WriteHeader(http.StatusTooManyRequests) return } body, _ := io.ReadAll(r.Body) _ = json.Unmarshal(body, &seen) w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"model":"jev-1.13","calibrated":true, "answers":{"risk":{"type":"score","score":1.21, "legend":{"0":"harmless","1":"reversible","2":"destructive"}, "probabilities":{"0":0.10,"1":0.59,"2":0.31},"confidence":0.71}}, "usage":{"input_tokens":210,"output_tokens":24}}`)) })) defer srv.Close() judge, err := toolnexus.CreateClassifier(toolnexus.ClassifierOptions{ Style: toolnexus.StyleSystemOne, BaseURL: srv.URL, Model: "jev-latest", APIKeyEnv: "TYPESAFE_API_KEY", // the NAME of an env var, never the value Timeout: 5 * time.Second, Retries: 2, RetryableStatuses: []int{520}, // adds only; 429 stays retryable RequestParams: map[string]any{"tenant": "acme"}, BodyTransform: func(b map[string]any) map[string]any { b["trace"] = "docs-example" return b }, OnError: func(info toolnexus.ErrorInfo) toolnexus.Tier { if info.Retryable { return toolnexus.TierRetry } return toolnexus.TierFail }, OnMetric: func(ev toolnexus.MetricEvent) { if ev.Event == toolnexus.MetricClassifierEvaluate { fmt.Println("metric:", ev.Event, ev.Status) } }, }) if err != nil { log.Fatal(err) } questions := map[string]toolnexus.Question{ "risk": toolnexus.ScoreQuestion{ Instructions: "How destructive is this shell command?", Criteria: []string{"harmless", "reversible", "destructive"}, }, } d, err := judge.Evaluate(context.Background(), "rm -rf ./build", questions) if err != nil { log.Fatal(err) } if calls != 2 { log.Fatalf("expected the 429 to be retried once, got %d calls", calls) } if seen["tenant"] != "acme" || seen["trace"] != "docs-example" { log.Fatalf("body shaping did not land: %v", seen) } risk, err := d.Score("risk") if err != nil { log.Fatal(err) } fmt.Printf("ok: risk=%v calibrated=%v after %d attempts\n", risk.Score, d.Calibrated, calls) } ``` ## See also [Section titled “See also”](#see-also) * [`NoulQuestion`](/api/go/judge/questions/) — The three question types, the criteria each one needs, and the limits enforced client-side before the request. * [`Decision`](/api/go/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](/toolnexus/judge/) — what a judgment is, and when it beats both an `if` and a chat turn. * [Backends](/toolnexus/judge/backends/) — `systemone`, `llm`, `custom`, `static`, and what each one costs. * [The encoding obligation](/toolnexus/judge/encoding/) — why option descriptions are the whole ball game. * [Cookbook: typed decisions](/toolnexus/cookbook/judge/) — a working judge in every language. # Decision > One answer per question under the caller's own keys, read through typed accessors that fail loudly rather than hand back a zero. — Go. **Go** · package `github.com/muthuishere/toolnexus/golang` · [SPEC §8B](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [golang/classifier.go](https://github.com/muthuishere/toolnexus/blob/main/golang/classifier.go) ```go type Decision struct { Model string Answers map[string]DecisionAnswer Usage ClassifierUsage Calibrated bool } func (d Decision) Noul(key string) (NoulAnswer, error) func (d Decision) Choice(key string) (ChoiceAnswer, error) func (d Decision) Score(key string) (ScoreAnswer, error) func NearUniform(probabilities map[string]float64) bool ``` One answer per question under the caller’s own keys, read through typed accessors that fail loudly rather than hand back a zero. `Model` echoes what actually answered, which may be more specific than the model you asked for (`jev-latest` → `typesafe/jev-1.13-20260917`). `DecisionAnswer` is a sealed interface with three implementations, and `AnswerType()` is the wire discriminator (`"noul" | "choice" | "score"`). It is named `DecisionAnswer` rather than `Answer` because §10 already owns [`Answer`](/api/go/suspension/wait-for/) — same idea, different seam. ## The three answer shapes [Section titled “The three answer shapes”](#the-three-answer-shapes) ```go type NoulAnswer struct { Noul float64 // 0..1. NO confidence: the number IS the answer. } type ChoiceAnswer struct { Choice string Probabilities map[string]float64 // one entry per OFFERED option Confidence float64 NearUniform bool // DERIVED on decode, never read from the wire } type ScoreAnswer struct { Score float64 // MAY fall between levels; always within the rubric Legend map[string]string // the rubric echoed back, keyed by level index Probabilities map[string]float64 // one entry per level index Confidence float64 } func (a ScoreAnswer) Levels() []string // the legend in level order, which the map loses ``` `Confidence` reports on the **question**, not on the answer. The worst working encoding measured carried the **highest** median confidence (0.82) — see [The encoding obligation](/toolnexus/judge/encoding/). ## When to use it [Section titled “When to use it”](#when-to-use-it) Read a `Decision` through the typed accessors, not by type-asserting `Answers[key]` by hand. The accessor is how a wrong-type read becomes a legible error instead of a silent zero value. ## Why this and not the alternative [Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative) Go returns an error where the other ports throw `Noul`/`Choice`/`Score` return `(T, error)`. A bare map lookup plus a type assertion would hand you a zero-valued `NoulAnswer` — `Noul: 0`, which reads as “definitely false” — for a key that was never asked, or asked as a `choice`. The accessors distinguish the two cases by name: * `classifier: no answer "urgency" in this decision` * `classifier: answer "urgency" is a choice answer, not score` Do **not** discard that error with `_`. It is the only thing standing between a typo in a question key and a threshold that silently always passes. ## `Usage`, and why cost may be absent [Section titled “Usage, and why cost may be absent”](#usage-and-why-cost-may-be-absent) ```go type ClassifierUsage struct { InputTokens int OutputTokens int Cost *float64 // nil = this backend does not report one } ``` `Cost` is a **pointer** because absent is not zero. TypeSafe’s own API never reports a cost; a gateway such as OpenRouter does. Printing `$0.00` for a backend that simply does not say would be a lie about money — print “not reported” instead. ## `Calibrated` and `NearUniform` [Section titled “Calibrated and NearUniform”](#calibrated-and-nearuniform) `Calibrated` travels with every decision. `StyleSystemOne` reports **true**; `StyleLLM` reports **false** unless it derived its probabilities from provider token probabilities. On the wire, an absent `calibrated` field decodes as `true` — the System One wire reports calibration by being itself, and a backend that is not calibrated says so explicitly. `NearUniform` is derived from the response on decode and never read from the wire — no wire change, no request change, no fixture change. With `n` the number of **entries in the probability map** and `p_i` their values **as returned**: ```plaintext NearUniform ⇔ max over i of |p_i − 1/n| ≤ 0.05 ``` * the tolerance is **0.05 absolute** and the comparison is **inclusive** — a maximum deviation of exactly 0.05 **is** near-uniform (`NearUniformTolerance`, computed in `float64`); * the probabilities are **never sorted, renormalised or rounded** before the comparison, and an offered option absent from the map counts as `0` by not being an entry; * `n == 1` ⇒ **true** (a single option is trivially uniform); an **empty map** ⇒ **false** (there is no distribution at all). Shared fixtures pin the boundary from both sides and never place a deviation within `1e-9` of the tolerance, so the rule is decidable in `float64` without a port-specific epsilon — but note that a *literal* `0.55` is `0.05000000000000004` away from `0.5`, so do not build your own boundary test out of decimals that do not represent exactly. It is absolute rather than relative because a relative band collapses below the noise floor: at 255 options `1/n` is 0.0039, finer than the two-decimal rounding the wire already applies. 0.05 is roughly 3σ of measured backend non-determinism, and it is the separation that matters — an undescribed-options encoding returned a median top probability of 0.29 on a four-option choice (deviation 0.04, inside), the described one 0.80 (deviation 0.55, far outside). What neither flag detects Both are **advisory**, and neither is a correctness signal. `NearUniform` 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** distinguish a good encoding from a subtly wrong one: a wrong-but-answerable question still reads as answerable, and comes back sharply peaked on the wrong option. `Calibrated` carries the same caveat — calibrated is not correct. And **a threshold tuned against one backend does not transfer to another**. Re-tune every threshold when you change `Style`, `BaseURL` or `Model`. A classifier interprets; it never authorises — the `if` that enforces the boundary stays in your code. ## Examples [Section titled “Examples”](#examples) ### 1. The smallest useful call — typed accessors, and how they fail [Section titled “1. The smallest useful call — typed accessors, and how they fail”](#1-the-smallest-useful-call--typed-accessors-and-how-they-fail) ```go package main import ( "context" "fmt" "log" "strings" toolnexus "github.com/muthuishere/toolnexus/golang" ) func main() { state := "rm -rf ./build" questions := map[string]toolnexus.Question{ "risk": toolnexus.ScoreQuestion{ Instructions: "How destructive is this shell command?", Criteria: []string{"harmless", "reversible", "destructive"}, }, } recorded := `{"model":"typesafe/jev-1.13-20260917","calibrated":true, "answers":{"risk":{"type":"score","score":1.21, "legend":{"0":"harmless","1":"reversible","2":"destructive"}, "probabilities":{"0":0.10,"1":0.59,"2":0.31},"confidence":0.71}}, "usage":{"input_tokens":210,"output_tokens":24}}` judge, err := toolnexus.CreateClassifier(toolnexus.ClassifierOptions{ Style: toolnexus.StyleStatic, Model: "typesafe/jev-1.13", Decisions: []toolnexus.RecordedDecision{{State: state, Questions: questions, Response: []byte(recorded)}}, }) if err != nil { log.Fatal(err) } d, err := judge.Evaluate(context.Background(), state, questions) if err != nil { log.Fatal(err) } risk, err := d.Score("risk") if err != nil { log.Fatal(err) } // Wrong type: named, not a silent zero. if _, err := d.Noul("risk"); err == nil || !strings.Contains(err.Error(), "is a score answer, not noul") { log.Fatalf("expected a typed read failure, got %v", err) } // Absent key: a different message, on purpose. if _, err := d.Score("urgency"); err == nil || !strings.Contains(err.Error(), "no answer") { log.Fatalf("expected an absent-key failure, got %v", err) } // The AUTHORITY stays here, in code — the decision only informs it. if risk.Score >= 1.5 { fmt.Println("would require approval") } fmt.Printf("ok: score=%v confidence=%v levels=%v\n", risk.Score, risk.Confidence, risk.Levels()) } ``` ### 2. The realistic case — `NearUniform` at the boundary [Section titled “2. The realistic case — NearUniform at the boundary”](#2-the-realistic-case--nearuniform-at-the-boundary) ```go package main import ( "fmt" "log" toolnexus "github.com/muthuishere/toolnexus/golang" ) func main() { cases := []struct { name string p map[string]float64 want bool }{ // Deviation of exactly 0.05 from 1/n = 0.25: the comparison is INCLUSIVE, // so this IS near-uniform. {"at the tolerance", map[string]float64{"a": 0.30, "b": 0.25, "c": 0.25, "d": 0.20}, true}, // Past it. {"just outside", map[string]float64{"a": 0.56, "b": 0.44}, false}, // A real answer: the model had something to rank on. {"decisive", map[string]float64{"a": 0.97, "b": 0.03}, false}, // Never renormalised: these sum to 0.5 and are still flat around 1/n. {"n == 1 is trivially uniform", map[string]float64{"only": 0.02}, true}, // No distribution at all. {"empty", map[string]float64{}, false}, } for _, c := range cases { if got := toolnexus.NearUniform(c.p); got != c.want { log.Fatalf("%s: NearUniform = %v, want %v", c.name, got, c.want) } } // The same rule is applied on decode, so ChoiceAnswer.NearUniform is already set. fmt.Println("ok: tolerance", toolnexus.NearUniformTolerance, "inclusive, five cases agree") } ``` ### 3. The full surface — a flat decision, `Calibrated`, and an unreported cost [Section titled “3. The full surface — a flat decision, Calibrated, and an unreported cost”](#3-the-full-surface--a-flat-decision-calibrated-and-an-unreported-cost) A `custom` backend lets the whole `Decision` be written by hand, which is the clearest way to show what the two flags mean when they are *not* the happy values. ```go package main import ( "context" "fmt" "log" "strconv" toolnexus "github.com/muthuishere/toolnexus/golang" ) func main() { judge, err := toolnexus.CreateClassifier(toolnexus.ClassifierOptions{ Style: toolnexus.StyleCustom, Evaluate: func(_ context.Context, _ any, _ map[string]toolnexus.Question) (toolnexus.Decision, error) { return toolnexus.Decision{ Model: "gpt-4o-mini", Answers: map[string]toolnexus.DecisionAnswer{ "desk": toolnexus.ChoiceAnswer{ Choice: "billing", Probabilities: map[string]float64{"billing": 0.34, "shipping": 0.33, "technical": 0.33}, Confidence: 0.95, // round self-reported numbers are an llm-style tell NearUniform: toolnexus.NearUniform(map[string]float64{"billing": 0.34, "shipping": 0.33, "technical": 0.33}), }, }, // An llm-style backend self-reports; the numbers are not token // probabilities, so no threshold tuned elsewhere transfers here. Calibrated: false, Usage: toolnexus.ClassifierUsage{InputTokens: 516, OutputTokens: 72}, // Cost nil }, nil }, }) if err != nil { log.Fatal(err) } d, err := judge.Evaluate(context.Background(), "charged twice", map[string]toolnexus.Question{ "desk": toolnexus.ChoiceOver("Which desk owns this?", map[string]string{ "billing": "own it here when money moved: a duplicate charge, a refund owed", "shipping": "own it here when a physical parcel is late or damaged", "technical": "own it here when the product itself errors", }), }) if err != nil { log.Fatal(err) } desk, err := d.Choice("desk") if err != nil { log.Fatal(err) } if !desk.NearUniform { log.Fatal("this distribution is flat within 0.05 of 1/3") } if d.Calibrated { log.Fatal("an llm-style decision reports Calibrated: false") } // Absent is NOT zero: printing $0.00 here would be a lie about money. cost := "not reported by this backend" if d.Usage.Cost != nil { cost = "$" + strconv.FormatFloat(*d.Usage.Cost, 'f', -1, 64) } fmt.Printf("ok: choice=%s nearUniform=%v calibrated=%v cost=%s\n", desk.Choice, desk.NearUniform, d.Calibrated, cost) } ``` A flat distribution with a high self-reported confidence is the signature this page exists to make legible: the model had nothing to rank on, and said so with numbers that look decisive. ## See also [Section titled “See also”](#see-also) * [`CreateClassifier`](/api/go/judge/classifier/) — A sibling of the client: pre-declared typed questions in, calibrated answers out — no messages, no tool calling, no loop. * [`NoulQuestion`](/api/go/judge/questions/) — The three question types, the criteria each one needs, and the limits enforced client-side before the request. * [The encoding obligation](/toolnexus/judge/encoding/) — why a flat distribution is usually your criteria, not the model. * [Typed decisions](/toolnexus/judge/) · [Backends](/toolnexus/judge/backends/) · [Cookbook](/toolnexus/cookbook/judge/) # NoulQuestion > The three question types, the criteria each one needs, and the limits enforced client-side before the request. — Go. **Go** · package `github.com/muthuishere/toolnexus/golang` · [SPEC §8B](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [golang/classifier.go](https://github.com/muthuishere/toolnexus/blob/main/golang/classifier.go) ```go type Question interface{ /* sealed: NoulQuestion | ChoiceQuestion | ScoreQuestion */ } type NoulCriteria struct { True string False string } type NoulQuestion struct { Instructions string Criteria *NoulCriteria // optional; nil omits the field entirely } type ChoiceQuestion struct { Instructions string Criteria map[string]string // option id -> what picking it would MEAN; 1..255 } type ScoreQuestion struct { Instructions string Criteria []string // 2..10 ORDERED levels; the order IS the numbering } func ChoiceOver(instructions string, items map[string]string) ChoiceQuestion ``` The three question types, the criteria each one needs, and the limits enforced client-side before the request. `Question` is a sealed interface — the three structs are the only implementations, so a `switch` over them is exhaustive and a fourth shape cannot be smuggled in. Questions are **independent**. One answer is never context for another; a backend that cannot guarantee that reports `Calibrated: false`. ## When to use it [Section titled “When to use it”](#when-to-use-it) | Type | You get back | Reach for it when | | ---------------- | --------------------------------------------------------------------------------------------------------- | ---------------------------------------------- | | `NoulQuestion` | one number in `0..1` — **no confidence; the number is the answer** | is this true, is this from an untrusted source | | `ChoiceQuestion` | one of **your** named options, a probability for every option, a confidence | routing to a skill, a tool, an agent, a move | | `ScoreQuestion` | a number against an ordered rubric of 2–10 levels (`1.21` is a real answer), with a probability per level | risk, urgency, severity | Declare them up front and pass them all in one `Evaluate` call: many questions, one round trip, one state ingest. ## Why this and not the alternative [Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative) When NOT to use a choice If the options are genuinely ordered — low/medium/high, harmless/reversible/destructive — use a `ScoreQuestion`. A `ChoiceQuestion` throws the ordering away, so you lose the between-levels answer (`0.49` = genuinely split between level 0 and level 1) and you get a flat distribution over labels that were never flat. Conversely, do **not** use a score for unordered alternatives: the rubric’s order is its numbering, and ordering things that have no order invents a signal. ## `criteria`, per type [Section titled “criteria, per type”](#criteria-per-type) * **noul** — optional. `Criteria` is a **pointer** because absent and empty are different values and both are preserved on the wire: `nil` omits `criteria` entirely, while a non-nil pointer to a zero value sends both labels as empty strings. * **choice** — required, `id → what picking that option would MEAN`. Not a label, not the id again: a sentence naming the consequence. * **score** — required, an ordered slice. Index 0 is level 0. Nothing sorts it; a “sort everything” canonicaliser would silently renumber the rubric, which is why arrays are never reordered in the canonical request. The option descriptions are the whole ball game For a `choice`, `Criteria[id]` is the **only** thing that differentiates one option from another to the model. Passing the id itself, an empty string, or one value repeated is schema-valid, returns HTTP 200 and a well-formed distribution — **and ranks at chance**. Measured: options described by consequence scored 17, 17, 17; the same state with each description replaced by its own id scored 0, 1, 0 — the floor of a shuffle control. The library **detects** this and emits one `MetricClassifierWarning` naming the question key; it never repairs it, because it has no way to know what your options mean. Full measurement: [The encoding obligation](/toolnexus/judge/encoding/). ## The client-side limits [Section titled “The client-side limits”](#the-client-side-limits) | Rule | Constant | Error | | ------------------------------------ | ------------------------------------------- | ------------------------------------------------------------------------- | | a `choice` needs 1..255 options | `MaxChoiceOptions = 255` | `classifier: question "": a choice needs 1..255 options, got N` | | a `score` needs 2..10 ordered levels | `MinScoreLevels = 2`, `MaxScoreLevels = 10` | `classifier: question "": a score needs 2..10 ordered levels, got N` | Both are enforced **before the request**: the error names the offending question key and the limit, and **no request is sent** — you find out faster and more legibly than from the backend’s own `400 "Too many 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. An empty `questions` map, or a `nil` question value, is an error for the same reason. ## Examples [Section titled “Examples”](#examples) ### 1. The smallest useful call — all three types in one round trip [Section titled “1. The smallest useful call — all three types in one round trip”](#1-the-smallest-useful-call--all-three-types-in-one-round-trip) ```go package main import ( "context" "fmt" "log" toolnexus "github.com/muthuishere/toolnexus/golang" ) func main() { state := "my card was charged twice for the annual plan and the second charge was not refunded" questions := map[string]toolnexus.Question{ "wants_money_back": toolnexus.NoulQuestion{ Instructions: "Is the customer asking for money to be returned?", Criteria: &toolnexus.NoulCriteria{ True: "the customer wants a payment reversed or credited back", False: "the customer wants something other than money returned", }, }, "department": toolnexus.ChoiceQuestion{ Instructions: "Which desk should own this ticket?", Criteria: map[string]string{ "billing": "own it here when the problem is money that moved: a duplicate charge, a refund owed", "technical": "own it here when the problem is the product itself: a login that fails, a feature that errors", }, }, "urgency": toolnexus.ScoreQuestion{ Instructions: "How fast does this ticket need a human?", Criteria: []string{ "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", }, }, } recorded := `{"model":"typesafe/jev-1.13","calibrated":true, "answers":{ "wants_money_back":{"type":"noul","noul":0.99}, "department":{"type":"choice","choice":"billing", "probabilities":{"billing":0.97,"technical":0.03},"confidence":0.94}, "urgency":{"type":"score","score":0.49, "legend":{"0":"working normally","1":"inconvenienced","2":"blocked"}, "probabilities":{"0":0.52,"1":0.48,"2":0},"confidence":0.27}}, "usage":{"input_tokens":516,"output_tokens":72}}` judge, err := toolnexus.CreateClassifier(toolnexus.ClassifierOptions{ Style: toolnexus.StyleStatic, Model: "typesafe/jev-1.13", Decisions: []toolnexus.RecordedDecision{{State: state, Questions: questions, Response: []byte(recorded)}}, }) if err != nil { log.Fatal(err) } d, err := judge.Evaluate(context.Background(), state, questions) if err != nil { log.Fatal(err) } dept, err := d.Choice("department") if err != nil { log.Fatal(err) } urg, err := d.Score("urgency") if err != nil { log.Fatal(err) } fmt.Printf("ok: desk=%s urgency=%v (a score may fall BETWEEN levels)\n", dept.Choice, urg.Score) } ``` ### 2. The realistic case — the limits fail before the request [Section titled “2. The realistic case — the limits fail before the request”](#2-the-realistic-case--the-limits-fail-before-the-request) ```go package main import ( "context" "fmt" "log" "strings" toolnexus "github.com/muthuishere/toolnexus/golang" ) func main() { // A custom backend that records whether it was ever reached. reached := false judge, err := toolnexus.CreateClassifier(toolnexus.ClassifierOptions{ Style: toolnexus.StyleCustom, Evaluate: func(_ context.Context, _ any, _ map[string]toolnexus.Question) (toolnexus.Decision, error) { reached = true return toolnexus.Decision{}, nil }, }) if err != nil { log.Fatal(err) } levels := make([]string, 11) // 11 > MaxScoreLevels for i := range levels { levels[i] = fmt.Sprintf("level %d", i) } _, err = judge.Evaluate(context.Background(), "anything", map[string]toolnexus.Question{ "urgency": toolnexus.ScoreQuestion{Instructions: "how urgent?", Criteria: levels}, }) if err == nil { log.Fatal("expected a client-side limit error") } // The error names the offending question KEY and the limit. if !strings.Contains(err.Error(), `"urgency"`) || !strings.Contains(err.Error(), "2..10") { log.Fatalf("unhelpful error: %v", err) } if reached { log.Fatal("no request may be sent once a limit fails") } // A choice is capped at 255 options, checked the same way. tooMany := make(map[string]string, 256) for i := 0; i < 256; i++ { tooMany[fmt.Sprintf("opt%03d", i)] = "pick this when it is the right one" } _, err = judge.Evaluate(context.Background(), "anything", map[string]toolnexus.Question{ "route": toolnexus.ChoiceQuestion{Instructions: "which?", Criteria: tooMany}, }) if err == nil || !strings.Contains(err.Error(), "1..255") { log.Fatalf("expected the 255-option cap, got %v", err) } fmt.Println("ok: both limits refused before any request") } ``` ### 3. The full surface — `ChoiceOver`, and `criteria` absent vs empty [Section titled “3. The full surface — ChoiceOver, and criteria absent vs empty”](#3-the-full-surface--choiceover-and-criteria-absent-vs-empty) `ChoiceOver` builds a `ChoiceQuestion` from any `(name, description)` pairs — a tool roster, a skill list, an A2A agent card’s skills. It copies the map, so later mutation of the caller’s map cannot reach the question. ```go package main import ( "fmt" "log" toolnexus "github.com/muthuishere/toolnexus/golang" ) func main() { q := toolnexus.ChoiceOver("Which tool answers this turn?", map[string]string{ "search_docs": "use this when the answer is written down somewhere in the product docs", "run_query": "use this when the answer has to be computed from the live database", }) // A noul's criteria: nil omits the field, a non-nil zero value sends both labels // empty. Absent and empty are DIFFERENT values, and both survive the wire. absent := toolnexus.NoulQuestion{Instructions: "is this urgent?"} empty := toolnexus.NoulQuestion{Instructions: "is this urgent?", Criteria: &toolnexus.NoulCriteria{}} withAbsent, err := toolnexus.CanonicalRequest("jev-latest", map[string]toolnexus.Question{"u": absent}) if err != nil { log.Fatal(err) } withEmpty, err := toolnexus.CanonicalRequest("jev-latest", map[string]toolnexus.Question{"u": empty}) if err != nil { log.Fatal(err) } if string(withAbsent) == string(withEmpty) { log.Fatal("absent criteria must not collapse into empty criteria") } // The canonical request covers `model` + `questions` only: keys sorted recursively // in ASCII order, arrays never reordered. `state` is outside the claim and is sent // verbatim, because numbers do not canonicalise across seven runtimes. canon, err := toolnexus.CanonicalRequest("jev-latest", map[string]toolnexus.Question{"tool": q}) if err != nil { log.Fatal(err) } fmt.Println("ok:", string(canon)[:40], "…") } ``` ## See also [Section titled “See also”](#see-also) * [`CreateClassifier`](/api/go/judge/classifier/) — A sibling of the client: pre-declared typed questions in, calibrated answers out — no messages, no tool calling, no loop. * [`Decision`](/api/go/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 obligation](/toolnexus/judge/encoding/) — what a described option is worth, measured. * [Typed decisions](/toolnexus/judge/) · [Backends](/toolnexus/judge/backends/) · [Cookbook](/toolnexus/cookbook/judge/) # ElicitationToRequest > Map an MCP server's elicitation request onto the §10 suspension contract, and map the answer back. — Go. **Go** · module `github.com/muthuishere/toolnexus/golang` · [SPEC §10](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [golang/mcp.go](https://github.com/muthuishere/toolnexus/blob/main/golang/mcp.go) ```go func ElicitationToRequest(params mcp.ElicitationParams) Request func AnswerToElicitResult(answer Answer) *mcp.ElicitationResult ``` An MCP server can reach back mid-`tools/call` and ask the human for input — `elicitation/create` is a **reverse request**, server → client. `ElicitationToRequest` maps that request onto toolnexus’s own suspension contract ([`Request`](/api/go/types/tool/)), so a host that already knows how to resolve a §10 suspension (a form, an auth link) can resolve an MCP elicitation the same way, with no separate code path. `AnswerToElicitResult` maps the resolution back onto the shape the MCP SDK expects. Two elicitation modes, two Request kinds `params.Mode == "url"` becomes `Kind: "authorization"` with `URL` set — the server wants the human to complete something at a link (e.g. an OAuth flow). Anything else (the default, `"form"`) becomes `Kind: "input"`, with the server’s JSON-Schema carried at `Data["schema"]` for the host to render a form from. ## When to use it [Section titled “When to use it”](#when-to-use-it) You call these two functions directly only if you are building your **own** MCP client integration outside `LoadMcp`/`LoadMcpWithContext`. In the normal path you never call them — you pass a `waitFor func(Request) (Answer, error)` to `LoadMcp`, and toolnexus wires this mapping internally (the `elicitationBridge` in `golang/mcp.go`). Reach for them directly when writing a test double for a host resolver, or when porting the same mapping into your own MCP client. ## Why this and not the alternative [Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative) waitFor on LoadMcp is the ordinary integration point Passing `waitFor` to [`LoadMcp`](/api/go/mcp/load/) gets you this mapping for free — every connected client advertises the elicitation capability and routes through it automatically. Reach for `ElicitationToRequest`/`AnswerToElicitResult` directly only when `LoadMcp`’s bridge is not the code path you are exercising (custom client, unit test, a different transport). ## Examples [Section titled “Examples”](#examples) ### 1. Form-mode elicitation becomes a `kind:"input"` Request [Section titled “1. Form-mode elicitation becomes a kind:"input" Request”](#1-form-mode-elicitation-becomes-a-kindinput-request) ```go package main import ( "fmt" "log" "github.com/mark3labs/mcp-go/mcp" toolnexus "github.com/muthuishere/toolnexus/golang" ) func main() { params := mcp.ElicitationParams{ Mode: "form", Message: "What is your deployment region?", RequestedSchema: map[string]any{ "type": "object", "properties": map[string]any{"region": map[string]any{"type": "string"}}, }, } req := toolnexus.ElicitationToRequest(params) if req.Kind != "input" { log.Fatalf("expected kind=input, got %q", req.Kind) } if req.Prompt != params.Message { log.Fatalf("expected prompt to carry the message, got %q", req.Prompt) } if req.ID == "" { log.Fatal("expected a generated, unique request id") } schema, ok := req.Data["schema"] if !ok || schema == nil { log.Fatal("expected the requested schema under data.schema") } fmt.Println("ok:", req.Kind, "|", req.Prompt) } ``` ### 2. URL-mode elicitation becomes a `kind:"authorization"` Request [Section titled “2. URL-mode elicitation becomes a kind:"authorization" Request”](#2-url-mode-elicitation-becomes-a-kindauthorization-request) ```go package main import ( "fmt" "log" "github.com/mark3labs/mcp-go/mcp" toolnexus "github.com/muthuishere/toolnexus/golang" ) func main() { params := mcp.ElicitationParams{ Mode: "url", Message: "Sign in to continue", URL: "https://example.com/oauth/authorize", } req := toolnexus.ElicitationToRequest(params) if req.Kind != "authorization" { log.Fatalf("expected kind=authorization, got %q", req.Kind) } if req.URL != params.URL { log.Fatalf("expected url to carry through, got %q", req.URL) } fmt.Println("ok:", req.Kind, "|", req.URL) } ``` ### 3. Round-tripping an Answer back to an ElicitationResult [Section titled “3. Round-tripping an Answer back to an ElicitationResult”](#3-round-tripping-an-answer-back-to-an-elicitationresult) `AnswerToElicitResult` covers three outcomes: accepted (with data), explicitly declined, and everything else treated as a cancellation — a dismissal, a timeout, an unspecified reason. ```go package main import ( "fmt" "log" "github.com/mark3labs/mcp-go/mcp" toolnexus "github.com/muthuishere/toolnexus/golang" ) func main() { accepted := toolnexus.AnswerToElicitResult(toolnexus.Answer{ Ok: true, Data: map[string]any{"region": "apac"}, }) if accepted.Action != mcp.ElicitationResponseActionAccept { log.Fatalf("expected accept, got %v", accepted.Action) } if content, ok := accepted.Content.(map[string]any); !ok || content["region"] != "apac" { log.Fatalf("expected content to carry the answer data, got %v", accepted.Content) } declined := toolnexus.AnswerToElicitResult(toolnexus.Answer{Ok: false, Reason: "declined"}) if declined.Action != mcp.ElicitationResponseActionDecline { log.Fatalf("expected decline, got %v", declined.Action) } cancelled := toolnexus.AnswerToElicitResult(toolnexus.Answer{Ok: false, Reason: "expired"}) if cancelled.Action != mcp.ElicitationResponseActionCancel { log.Fatalf("expected cancel for a non-declined refusal, got %v", cancelled.Action) } fmt.Println("ok:", accepted.Action, declined.Action, cancelled.Action) } ``` ## Mapping [Section titled “Mapping”](#mapping) | `ElicitationParams` | `Request` | | ------------------------------- | --------------------------------------------------------- | | `Mode == "url"` | `Kind: "authorization"`, `URL: params.URL` | | `Mode != "url"` (form, default) | `Kind: "input"`, `Data["schema"]: params.RequestedSchema` | | `Message` | `Prompt` | | — | `ID` — freshly generated, unique per call | | `Answer` | `ElicitationResult` | | ------------------------------- | --------------------------------------------------------- | | `Ok: true` | `Action: accept`, `Content: Answer.Data` (or `{}` if nil) | | `Ok: false, Reason: "declined"` | `Action: decline` | | `Ok: false`, any other reason | `Action: cancel` | ## See also [Section titled “See also”](#see-also) * [`LoadMcp`](/api/go/mcp/load/) — pass `waitFor` here for the ordinary integration path; this mapping runs automatically. * [`LoadMcpWithContext`](/api/go/mcp/load-with-context/) — the ctx-aware load, same elicitation bridge. * [`ListMcpTools`](/api/go/mcp/list-tools/) — list what each configured server would expose, plus per-server status, without wiring it into a toolkit. * [`ParseMcpConfig`](/api/go/mcp/parse-config/) — parse and validate config without connecting — the fast fail for a malformed or misspelled server block. # ListMcpTools > List what each configured server would expose, plus per-server status, without wiring it into a toolkit. — Go. **Go** · module `github.com/muthuishere/toolnexus/golang` · [SPEC §2](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [golang/mcp.go](https://github.com/muthuishere/toolnexus/blob/main/golang/mcp.go) ```go func ListMcpTools(ctx context.Context, input any) (*McpInventory, error) ``` Connects to every **enabled** server in the config, lists its tool definitions, and **disconnects before returning** — no `Tool.Execute` wiring, no live process left running, nothing to `Close()`. A bad server is isolated the same way as [`LoadMcp`](/api/go/mcp/load/): its status is `"failed"` and the call as a whole still succeeds. Unfiltered by design `McpInventory.Tools` is **not** filtered by each server’s `ServerConfig.Tools` allowlist — it exists so you can *see* every tool a server offers in order to author or validate that allowlist in the first place. [`LoadMcp`](/api/go/mcp/load/) is what applies the filter. ## When to use it [Section titled “When to use it”](#when-to-use-it) Reach for `ListMcpTools` when you want to **inspect** a server’s surface without committing to a live connection — building a CLI that prints “what would connect here,” validating an `mcp.json` in CI, or generating a per-server tool allowlist to hand back to `LoadMcp`. ## Why this and not the alternative [Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative) Use LoadMcp when you intend to call the tools `ListMcpTools` connects, lists, and disconnects in one shot — the returned `ToolInfo` values have no `Execute`. Reach for [`LoadMcp`](/api/go/mcp/load/) / [`LoadMcpWithContext`](/api/go/mcp/load-with-context/) instead the moment you actually intend to call a tool; reach for [`ParseMcpConfig`](/api/go/mcp/parse-config/) instead of `ListMcpTools` when you only need the config itself, with no network or process involved at all. ## Examples [Section titled “Examples”](#examples) ### 1. Inventory the shared fixture [Section titled “1. Inventory the shared fixture”](#1-inventory-the-shared-fixture) ```go package main import ( "context" "fmt" "log" "os" "path/filepath" toolnexus "github.com/muthuishere/toolnexus/golang" ) func main() { fixture := filepath.Join(os.Getenv("TOOLNEXUS_REPO"), "examples", "mcp.json") inv, err := toolnexus.ListMcpTools(context.Background(), fixture) if err != nil { log.Fatal(err) } if inv.Status["example-remote"] != toolnexus.StatusDisabled { log.Fatalf("expected example-remote disabled, got %v", inv.Status["example-remote"]) } // Disabled servers never get a Tools entry. if _, ok := inv.Tools["example-remote"]; ok { log.Fatal("expected no tool listing for a disabled server") } fmt.Println("ok: example-remote status =", inv.Status["example-remote"]) } ``` ### 2. A bad server is isolated, and nothing is left connected [Section titled “2. A bad server is isolated, and nothing is left connected”](#2-a-bad-server-is-isolated-and-nothing-is-left-connected) ```go package main import ( "context" "fmt" "log" toolnexus "github.com/muthuishere/toolnexus/golang" ) func main() { config := toolnexus.McpConfig{ "broken": toolnexus.ServerConfig{ Type: "local", Command: []string{"toolnexus-docs-does-not-exist"}, }, } inv, err := toolnexus.ListMcpTools(context.Background(), config) if err != nil { log.Fatalf("ListMcpTools itself should not fail: %v", err) } if inv.Status["broken"] != toolnexus.StatusFailed { log.Fatalf("expected 'broken' marked failed, got %v", inv.Status["broken"]) } if len(inv.Tools["broken"]) != 0 { log.Fatalf("expected no tool listing from a failed server, got %d", len(inv.Tools["broken"])) } // ListMcpTools never returns a live client to close — nothing to leak. fmt.Println("ok: broken server isolated as", inv.Status["broken"]) } ``` ### 3. Malformed config fails fast [Section titled “3. Malformed config fails fast”](#3-malformed-config-fails-fast) ```go package main import ( "context" "fmt" "log" toolnexus "github.com/muthuishere/toolnexus/golang" ) func main() { _, err := toolnexus.ListMcpTools(context.Background(), []byte(`{not valid json`)) if err == nil { log.Fatal("expected a parse error for malformed config") } fmt.Println("ok: malformed config rejected before connecting:", err) } ``` ## Parameters [Section titled “Parameters”](#parameters) | Parameter | Type | What it does | | --------- | ----------------- | --------------------------------------------------------------- | | `ctx` | `context.Context` | Bounds connect + list for every server. | | `input` | `any` | Anything [`ParseMcpConfig`](/api/go/mcp/parse-config/) accepts. | ## Return value [Section titled “Return value”](#return-value) | Field | Type | What it holds | | --------------------- | ----------------------- | --------------------------------------------------------------------- | | `McpInventory.Tools` | `map[string][]ToolInfo` | Server name → its full listed tool defs (original, unprefixed names). | | `McpInventory.Status` | `map[string]McpStatus` | Per-server `"connected"`, `"disabled"`, or `"failed"`. | `ToolInfo` mirrors the `Name`, `Description`, and `InputSchema` a converted `Tool` would carry, minus `Execute`. ## See also [Section titled “See also”](#see-also) * [`LoadMcp`](/api/go/mcp/load/) — read an mcp.json, connect every local stdio and remote streamable-HTTP server, expose each server tool as a Tool. * [`LoadMcpWithContext`](/api/go/mcp/load-with-context/) — the ctx-aware load: bound connection time and cancel a slow or hung server without leaking a child process. * [`ParseMcpConfig`](/api/go/mcp/parse-config/) — parse and validate config without connecting — the fast fail for a malformed or misspelled server block. * [`ElicitationToRequest`](/api/go/mcp/elicitation/) — map an MCP server’s elicitation request onto the §10 suspension contract, and map the answer back. # LoadMcp > Read an mcp.json, connect every local stdio and remote streamable-HTTP server, expose each server tool as a Tool. — Go. **Go** · module `github.com/muthuishere/toolnexus/golang` · [SPEC §2](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [golang/mcp.go](https://github.com/muthuishere/toolnexus/blob/main/golang/mcp.go) ```go func LoadMcp(input any, waitFor ...func(Request) (Answer, error)) (*McpSource, error) ``` Reads MCP server configuration (via [`ParseMcpConfig`](/api/go/mcp/parse-config/)), connects to every enabled server — local stdio child processes and remote streamable-HTTP/SSE servers — lists each one’s tools, and converts each into a uniform [`Tool`](/api/go/types/tool/). A bad server never fails the whole load: it is isolated, logged, and marked `failed` in `McpSource.Status`. waitFor is variadic, not optional-by-nil `waitFor ...func(Request) (Answer, error)` is Go’s way of expressing “this parameter is optional.” Pass nothing to skip elicitation entirely; pass one function to bridge MCP `elicitation/create` requests onto the §10 suspension contract (see [`ElicitationToRequest`](/api/go/mcp/elicitation/)). Passing more than one is a caller bug — only the first is used. ## When to use it [Section titled “When to use it”](#when-to-use-it) Reach for `LoadMcp` when you want tools from an `mcp.json` **without** the rest of the toolkit machinery — no skills, no builtins, no adapters. It is also what [`CreateToolkit`](/api/go/toolkit/create/) calls internally for its `McpConfig` option. ## Why this and not the alternative [Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative) Prefer CreateToolkit when MCP is one of several sources `LoadMcp` returns MCP tools alone, with `McpSource.Close()` owning the connected clients. Reach for [`CreateToolkit`](/api/go/toolkit/create/) instead when you also have skills, builtins, or your own `ExtraTools` — it merges every source once, by documented precedence, and owns the combined shutdown. Reach for [`LoadMcpWithContext`](/api/go/mcp/load-with-context/) instead of `LoadMcp` when you need to bound or cancel the connection phase itself — `LoadMcp` always connects with `context.Background()`. ## Examples [Section titled “Examples”](#examples) All three examples load the shared `examples/mcp.json` fixture — the same file every port is tested against. It has one **enabled** local server (`everything`, spawned via `npx`, which is not reachable in a hermetic CI sandbox) and one **disabled** remote server (`example-remote`). Because `npx` cannot actually reach the network here, these examples deliberately do not assert that the enabled server connects — they assert what `LoadMcp` guarantees regardless: the disabled server is skipped, a failed connection is isolated rather than fatal, and a malformed config still errors before any connection is attempted. ### 1. Load the shared fixture and read per-server status [Section titled “1. Load the shared fixture and read per-server status”](#1-load-the-shared-fixture-and-read-per-server-status) ```go package main import ( "fmt" "log" "os" "path/filepath" toolnexus "github.com/muthuishere/toolnexus/golang" ) func main() { // TOOLNEXUS_REPO is set by the docs test runner; in your own code just use a path. fixture := filepath.Join(os.Getenv("TOOLNEXUS_REPO"), "examples", "mcp.json") src, err := toolnexus.LoadMcp(fixture) if err != nil { log.Fatal(err) } defer src.Close() // The disabled remote server never gets a connection attempt. if src.Status["example-remote"] != toolnexus.StatusDisabled { log.Fatalf("expected example-remote disabled, got %v", src.Status["example-remote"]) } // The enabled local server has SOME status — connected or failed, sandbox-dependent — // but it is never silently absent from Status. status, ok := src.Status["everything"] if !ok { log.Fatal("expected a status entry for 'everything'") } fmt.Println("ok: example-remote=disabled, everything=", status) } ``` ### 2. A bad server is isolated, not fatal [Section titled “2. A bad server is isolated, not fatal”](#2-a-bad-server-is-isolated-not-fatal) Two servers in one config: one that can never start (`command` points at a binary that does not exist) and one that is disabled. `LoadMcp` still returns successfully — the failure is reported per server, not as a returned `error`. ```go package main import ( "fmt" "log" toolnexus "github.com/muthuishere/toolnexus/golang" ) func main() { config := toolnexus.McpConfig{ "broken": toolnexus.ServerConfig{ Type: "local", Command: []string{"toolnexus-docs-does-not-exist"}, }, } src, err := toolnexus.LoadMcp(config) if err != nil { log.Fatalf("LoadMcp itself should not fail: %v", err) } defer src.Close() if src.Status["broken"] != toolnexus.StatusFailed { log.Fatalf("expected 'broken' marked failed, got %v", src.Status["broken"]) } // A failed server contributes zero tools — the load degrades, it does not abort. if len(src.Tools) != 0 { log.Fatalf("expected no tools from a failed server, got %d", len(src.Tools)) } fmt.Println("ok: broken server isolated as", src.Status["broken"]) } ``` ### 3. Malformed config fails fast, before any connection [Section titled “3. Malformed config fails fast, before any connection”](#3-malformed-config-fails-fast-before-any-connection) `LoadMcp` calls `ParseMcpConfig` first — a syntax error in the JSON never reaches the network or spawns a process. ```go package main import ( "fmt" "log" toolnexus "github.com/muthuishere/toolnexus/golang" ) func main() { _, err := toolnexus.LoadMcp([]byte(`{not valid json`)) if err == nil { log.Fatal("expected a parse error for malformed config") } // A well-formed input of an unsupported type is also rejected up front. if _, err := toolnexus.LoadMcp(42); err == nil { log.Fatal("expected an error for an unsupported input type") } fmt.Println("ok: malformed config rejected before connecting:", err) } ``` ## Per-server readiness — `LoadMcpWithOptions` and `OnServer` [Section titled “Per-server readiness — LoadMcpWithOptions and OnServer”](#per-server-readiness--loadmcpwithoptions-and-onserver) ```go func LoadMcpWithOptions(ctx context.Context, input any, opts McpLoadOptions) (*McpSource, error) type McpLoadOptions struct { WaitFor func(Request) (Answer, error) // elicitation bridge, as in LoadMcpWithContext OnServer func(McpServerResult) // once per server: connected, failed or disabled } type McpServerResult struct { Name string Status McpStatus Tools []Tool // nil unless connected Error error } ``` `OnServer` fires the moment each server finishes, without waiting for slower ones, so a host can `Toolkit.Register` a fast server’s tools while a slow one is still connecting (`Register` is safe while a turn runs; a tool added mid-turn is offered from the next model call). Calls are serialized. The load still returns only after every server finishes; cancelling `ctx` aborts it and closes every client, including ones whose tools were already handed out. ```go package main import ( "context" "fmt" "log" "sort" toolnexus "github.com/muthuishere/toolnexus/golang" ) func main() { config := map[string]any{ "off": map[string]any{"command": []any{"node", "server.mjs"}, "enabled": false}, "broken": map[string]any{"command": []any{"/no/such/binary-for-docs"}}, } var seen []string src, err := toolnexus.LoadMcpWithOptions(context.Background(), config, toolnexus.McpLoadOptions{ OnServer: func(r toolnexus.McpServerResult) { seen = append(seen, r.Name+"="+string(r.Status)) }, }) if err != nil { log.Fatal(err) } defer src.Close() sort.Strings(seen) if len(seen) != 2 || seen[0] != "broken=failed" || seen[1] != "off=disabled" { log.Fatalf("unexpected readiness: %v", seen) } fmt.Println("ok:", seen) } ``` ## Parameters [Section titled “Parameters”](#parameters) | Parameter | Type | What it does | | --------- | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------- | | `input` | `any` | Anything [`ParseMcpConfig`](/api/go/mcp/parse-config/) accepts — path, raw bytes, `McpConfig`, or `map[string]any`. | | `waitFor` | `...func(Request) (Answer, error)` | Optional (variadic) host resolver for MCP elicitation (§10). Omit to skip. | ## Return value [Section titled “Return value”](#return-value) | Field | Type | What it holds | | ------------------- | ---------------------- | ------------------------------------------------------------------------------ | | `McpSource.Tools` | `[]Tool` | Every tool from every successfully connected server, namespaced `server_tool`. | | `McpSource.Status` | `map[string]McpStatus` | Per-server `"connected"`, `"disabled"`, or `"failed"`. | | `McpSource.Close()` | `func()` | Disconnects every connected client. Always `defer` this. | ## See also [Section titled “See also”](#see-also) * [`LoadMcpWithContext`](/api/go/mcp/load-with-context/) — the ctx-aware load: bound connection time and cancel a slow or hung server without leaking a child process. * [`ListMcpTools`](/api/go/mcp/list-tools/) — list what each configured server would expose, plus per-server status, without wiring it into a toolkit. * [`ParseMcpConfig`](/api/go/mcp/parse-config/) — parse and validate config without connecting — the fast fail for a malformed or misspelled server block. * [`ElicitationToRequest`](/api/go/mcp/elicitation/) — map an MCP server’s elicitation request onto the §10 suspension contract, and map the answer back. * [`CreateToolkit`](/api/go/toolkit/create/) — the aggregator; MCP is one of several sources. # LoadMcpWithContext > The ctx-aware load: bound connection time and cancel a slow or hung server without leaking a child process. — Go. **Go** · module `github.com/muthuishere/toolnexus/golang` · [SPEC §2](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [golang/mcp.go](https://github.com/muthuishere/toolnexus/blob/main/golang/mcp.go) ```go func LoadMcpWithContext(ctx context.Context, input any, waitFor ...func(Request) (Answer, error)) (*McpSource, error) ``` The context-aware entry point. `ctx` propagates through every connect, initialize, and list call (and bounds the streamable-HTTP → SSE fallback). A **per-server** timeout within budget marks only that server `failed` and the build continues — the same isolation as [`LoadMcp`](/api/go/mcp/load/). A **parent** cancellation or deadline is different: it aborts the whole load, closes every client that did connect, and `LoadMcpWithContext` returns `ctx.Err()`. `LoadMcp` keeps its simpler signature and delegates here with `context.Background()`. ## When to use it [Section titled “When to use it”](#when-to-use-it) Reach for `LoadMcpWithContext` whenever the load itself needs a budget or an external cancellation trigger — a request-scoped context in a server handler, a CLI command with a `--timeout` flag, or a shutdown signal that should stop connecting to MCP servers immediately rather than waiting for every one to time out on its own. ## Why this and not the alternative [Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative) Use LoadMcp when the process owns the whole lifetime [`LoadMcp`](/api/go/mcp/load/) is `LoadMcpWithContext` with `context.Background()` — reach for it when nothing outside the load needs to cancel it. Reach for [`CreateToolkit`](/api/go/toolkit/create/) instead of either when MCP is one of several tool sources; its own `ctx` argument bounds the whole toolkit build the same way. ## Examples [Section titled “Examples”](#examples) ### 1. A generous budget for the shared fixture [Section titled “1. A generous budget for the shared fixture”](#1-a-generous-budget-for-the-shared-fixture) ```go package main import ( "context" "fmt" "log" "os" "path/filepath" "time" toolnexus "github.com/muthuishere/toolnexus/golang" ) func main() { fixture := filepath.Join(os.Getenv("TOOLNEXUS_REPO"), "examples", "mcp.json") ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() src, err := toolnexus.LoadMcpWithContext(ctx, fixture) if err != nil { log.Fatal(err) } defer src.Close() if src.Status["example-remote"] != toolnexus.StatusDisabled { log.Fatalf("expected example-remote disabled, got %v", src.Status["example-remote"]) } fmt.Println("ok: loaded under a bounded context") } ``` ### 2. A cancelled parent context aborts the whole load [Section titled “2. A cancelled parent context aborts the whole load”](#2-a-cancelled-parent-context-aborts-the-whole-load) Cancel `ctx` before the call even starts — no server gets a chance to connect, and the returned error is `ctx.Err()` itself, not a per-server failure. ```go package main import ( "context" "errors" "fmt" "log" toolnexus "github.com/muthuishere/toolnexus/golang" ) func main() { config := toolnexus.McpConfig{ "everything": toolnexus.ServerConfig{ Type: "local", Command: []string{"npx", "-y", "@modelcontextprotocol/server-everything"}, }, } ctx, cancel := context.WithCancel(context.Background()) cancel() // cancelled before LoadMcpWithContext even runs src, err := toolnexus.LoadMcpWithContext(ctx, config) if err == nil { log.Fatal("expected a context error") } if !errors.Is(err, context.Canceled) { log.Fatalf("expected context.Canceled, got %v", err) } if src != nil { log.Fatal("expected a nil McpSource on abort") } fmt.Println("ok: aborted the whole load with", err) } ``` ### 3. A disabled server is reported without ever needing the budget [Section titled “3. A disabled server is reported without ever needing the budget”](#3-a-disabled-server-is-reported-without-ever-needing-the-budget) Disabled servers are skipped before any connection attempt — a small, live deadline is plenty, because nothing actually races against it. ```go package main import ( "context" "fmt" "log" "time" toolnexus "github.com/muthuishere/toolnexus/golang" ) func main() { disabled := true config := toolnexus.McpConfig{ "off": toolnexus.ServerConfig{ Type: "local", Command: []string{"unused"}, Disabled: &disabled, }, } ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() src, err := toolnexus.LoadMcpWithContext(ctx, config) if err != nil { log.Fatal(err) } defer src.Close() if src.Status["off"] != toolnexus.StatusDisabled { log.Fatalf("expected 'off' disabled, got %v", src.Status["off"]) } fmt.Println("ok: disabled server reported as", src.Status["off"]) } ``` ## Parameters [Section titled “Parameters”](#parameters) | Parameter | Type | What it does | | --------- | ---------------------------------- | ----------------------------------------------------------------------------------------- | | `ctx` | `context.Context` | Bounds connect + initialize + list for every server; cancellation aborts the whole build. | | `input` | `any` | Anything [`ParseMcpConfig`](/api/go/mcp/parse-config/) accepts. | | `waitFor` | `...func(Request) (Answer, error)` | Optional (variadic) host resolver for MCP elicitation (§10). | ## See also [Section titled “See also”](#see-also) * [`LoadMcp`](/api/go/mcp/load/) — the `context.Background()`-bound entry point this delegates to. * [`ListMcpTools`](/api/go/mcp/list-tools/) — list what each configured server would expose, plus per-server status, without wiring it into a toolkit. * [`ParseMcpConfig`](/api/go/mcp/parse-config/) — parse and validate config without connecting — the fast fail for a malformed or misspelled server block. * [`ElicitationToRequest`](/api/go/mcp/elicitation/) — map an MCP server’s elicitation request onto the §10 suspension contract, and map the answer back. # ParseMcpConfig > Parse and validate an mcp.json without connecting to anything — the fast fail for a malformed or misspelled server block. Go. **Go** · module `github.com/muthuishere/toolnexus/golang` · [SPEC §2](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [golang/mcp.go](https://github.com/muthuishere/toolnexus/blob/main/golang/mcp.go) ```go func ParseMcpConfig(input any) (McpConfig, error) ``` Reads MCP server configuration and normalises it into a flat map of server name → config. It **connects to nothing** — no child processes, no HTTP. That is the whole point: it is the cheap check you can run before paying for [`LoadMcp`](/api/go/mcp/load/). ## When to use it [Section titled “When to use it”](#when-to-use-it) * **Validate at startup or in a test**, so a typo in `mcp.json` fails immediately rather than halfway through connecting to five servers. * **Inspect or modify config before loading** — filter servers by environment, inject a header, disable one in CI. * **Accept config from somewhere other than a file** — a database, an env var, an API response. ## Why this and not just calling LoadMcp [Section titled “Why this and not just calling LoadMcp”](#why-this-and-not-just-calling-loadmcp) LoadMcp already parses — use this when you need the config itself [`LoadMcp`](/api/go/mcp/load/) calls `ParseMcpConfig` internally, so you do not need to call it first in the ordinary path. Reach for it when you want the *config as data*: to check it, change it, or report on it without launching anything. Go returns an error rather than panicking `ParseMcpConfig` returns `(McpConfig, error)` where JavaScript, Python, Java and C# raise. Check the error — a malformed file gives you a zero config, not a partial one. The `input` is `any` because it accepts a path string, raw JSON bytes, an already-typed `McpConfig`, or a parsed `map[string]any`. ## Examples [Section titled “Examples”](#examples) ### 1. Parse the shared fixture from disk [Section titled “1. Parse the shared fixture from disk”](#1-parse-the-shared-fixture-from-disk) This is `examples/mcp.json`, the fixture every port is tested against. ```go package main import ( "fmt" "log" "os" "path/filepath" "sort" toolnexus "github.com/muthuishere/toolnexus/golang" ) func main() { // TOOLNEXUS_REPO is set by the docs test runner; in your own code just use a path. fixture := filepath.Join(os.Getenv("TOOLNEXUS_REPO"), "examples", "mcp.json") config, err := toolnexus.ParseMcpConfig(fixture) if err != nil { log.Fatal(err) } // The `mcpServers` wrapper is unwrapped — you get the servers directly. var names []string for name := range config { names = append(names, name) } sort.Strings(names) if len(names) != 2 || names[0] != "everything" || names[1] != "example-remote" { log.Fatalf("unexpected servers: %v", names) } // Disabled servers are still returned — parsing does not filter. fmt.Println("ok:", names) } ``` Caution Parsing does **not** drop disabled servers. `enabled: false` is preserved in the returned config and is honoured later, by `LoadMcp`. If you are counting servers, count the enabled ones yourself. ### 2. Wrapper spellings and raw bytes [Section titled “2. Wrapper spellings and raw bytes”](#2-wrapper-spellings-and-raw-bytes) `mcpServers`, `servers` and `mcp` all mean the same thing, and raw JSON bytes are accepted too. ```go package main import ( "fmt" "log" toolnexus "github.com/muthuishere/toolnexus/golang" ) func main() { for _, raw := range []string{ `{"mcpServers":{"a":{"type":"local","command":["echo","hi"]}}}`, `{"servers":{"a":{"type":"local","command":["echo","hi"]}}}`, `{"mcp":{"a":{"type":"local","command":["echo","hi"]}}}`, } { config, err := toolnexus.ParseMcpConfig([]byte(raw)) if err != nil { log.Fatal(err) } if len(config) != 1 { log.Fatalf("expected one server, got %d", len(config)) } if _, ok := config["a"]; !ok { log.Fatalf("expected server 'a' in %v", config) } } fmt.Println("ok: 3 spellings -> a") } ``` ### 3. Validate before loading, and surface the error [Section titled “3. Validate before loading, and surface the error”](#3-validate-before-loading-and-surface-the-error) The pattern this function exists for — check the config, then decide whether to connect. ```go package main import ( "fmt" "log" "sort" "strings" toolnexus "github.com/muthuishere/toolnexus/golang" ) func validate(raw string) (enabled []string, problems []string, err error) { config, err := toolnexus.ParseMcpConfig([]byte(raw)) if err != nil { return nil, nil, err } for name, cfg := range config { // Disabled either way round. BOTH flags are *bool, so nil means "not set" // — dereference only after a nil check. if (cfg.Disabled != nil && *cfg.Disabled) || (cfg.Enabled != nil && !*cfg.Enabled) { continue } enabled = append(enabled, name) if cfg.Type == "remote" { if cfg.URL == "" { problems = append(problems, name+": remote server without a url") } } else if len(cfg.Command) == 0 { problems = append(problems, name+": local server without a command") } } sort.Strings(enabled) sort.Strings(problems) return enabled, problems, nil } func main() { enabled, problems, err := validate(`{"mcpServers":{ "ok_local":{"type":"local","command":["npx","server"]}, "ok_remote":{"type":"remote","url":"https://example.com/mcp"}, "off":{"type":"local","command":["x"],"enabled":false}}}`) if err != nil { log.Fatal(err) } if len(enabled) != 2 || len(problems) != 0 { log.Fatalf("unexpected: %v %v", enabled, problems) } _, badProblems, err := validate(`{"mcpServers":{ "broken_remote":{"type":"remote"}}}`) if err != nil { log.Fatal(err) } if len(badProblems) != 1 { log.Fatalf("expected 2 problems, got %v", badProblems) } // An empty local command fails the parse itself, naming the server (SPEC §2). if _, err := toolnexus.ParseMcpConfig([]byte(`{"mcpServers":{"broken_local":{"type":"local","command":[]}}}`)); err == nil || !strings.Contains(err.Error(), "broken_local") { log.Fatalf("expected an invalid-command error, got %v", err) } // Malformed JSON is an error, not a panic and not a partial config. if _, err := toolnexus.ParseMcpConfig([]byte(`{not json`)); err == nil { log.Fatal("expected a parse error") } fmt.Println("ok:", enabled, "| problems:", len(badProblems)) } ``` ## Accepted input [Section titled “Accepted input”](#accepted-input) | Input | Behaviour | | ----------------------- | --------------------------------------------------------------- | | `"./mcp.json"` (string) | Read from disk and unmarshalled. Error if missing or malformed. | | `[]byte(...)` | Raw JSON, unmarshalled directly. | | `McpConfig` | Returned as-is. | | `map[string]any` | Normalised. | Wrapped under `mcpServers`, `servers` or `mcp` — all three are unwrapped. ## See also [Section titled “See also”](#see-also) * [`LoadMcp`](/api/go/mcp/load/) — parse *and* connect * [`ListMcpTools`](/api/go/mcp/list-tools/) — what each server would expose * [`CreateToolkit`](/api/go/toolkit/create/) — takes `McpConfig` in exactly these forms # McpSource.ResourceTools > Opt-in list_mcp_resources / read_mcp_resource tools over every connected MCP server. Read-only. — Go. **Go** · package `github.com/muthuishere/toolnexus/golang` · [SPEC §2](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [golang/mcp\_resources.go](https://github.com/muthuishere/toolnexus/blob/main/golang/mcp_resources.go) ```go func (m *McpSource) ResourceTools() []Tool // [list_mcp_resources, read_mcp_resource] ``` Two tools bound to the servers an `McpSource` connected. Both are `SourceMCP`, carry `Hints{ReadOnly: true}`, and stop working once the source is closed. Nothing is added unless the host registers them. * `list_mcp_resources {server?}` — one line per resource, `server uri — name (mimeType): description`; `Metadata["resources"]` holds the same as structured items. No resources ⇒ `No resources.` * `read_mcp_resource {server, uri}` — text contents as text; binary contents are described (`[binary , N base64 chars]`), never inlined. An unknown server or a server error comes back as an error result the model can read. ## When to use it [Section titled “When to use it”](#when-to-use-it) Your MCP servers publish **resources** (files, schemas, docs) as well as tools, and you want the model to browse and read them on its own. MCP tools are loaded automatically; resources are not, because they can be large and many servers do not offer them. ## Why this and not the alternative [Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative) Know which resource you need? Read it in the host If the host already knows the resource (a fixed schema, a project README), read it once and put it in the system prompt or an attachment — that costs no tool round trip and keeps the prompt stable for [caching](/api/go/client/prompt-cache/). Register the resource tools when the model has to discover what is there. ## Examples [Section titled “Examples”](#examples) Each example serves a real MCP server from `httptest` (mcp-go, streamable HTTP) — no network. ### 1. The smallest useful call — list and read [Section titled “1. The smallest useful call — list and read”](#1-the-smallest-useful-call--list-and-read) ```go package main import ( "context" "fmt" "log" "net/http/httptest" "strings" "github.com/mark3labs/mcp-go/mcp" "github.com/mark3labs/mcp-go/server" toolnexus "github.com/muthuishere/toolnexus/golang" ) func main() { s := server.NewMCPServer("docs", "1.0", server.WithToolCapabilities(false), server.WithResourceCapabilities(false, false)) s.AddResource(mcp.NewResource("file:///notes.txt", "notes", mcp.WithMIMEType("text/plain")), func(_ context.Context, req mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) { return []mcp.ResourceContents{mcp.TextResourceContents{URI: req.Params.URI, MIMEType: "text/plain", Text: "ship on friday"}}, nil }) ts := httptest.NewServer(server.NewStreamableHTTPServer(s, server.WithStateLess(true))) defer ts.Close() src, err := toolnexus.LoadMcp(map[string]any{"docs": map[string]any{"url": ts.URL + "/mcp"}}) if err != nil { log.Fatal(err) } defer src.Close() tools := src.ResourceTools() list, read := tools[0], tools[1] out, err := list.Execute(map[string]any{}, nil) if err != nil || !strings.Contains(out.Output, "docs file:///notes.txt — notes (text/plain)") { log.Fatalf("list: %v %q", err, out.Output) } got, err := read.Execute(map[string]any{"server": "docs", "uri": "file:///notes.txt"}, nil) if err != nil || got.Output != "ship on friday" { log.Fatalf("read: %v %q", err, got.Output) } fmt.Println("ok:", list.Name, read.Name, "->", got.Output) } ``` ### 2. The realistic case — hand them to the model through the toolkit [Section titled “2. The realistic case — hand them to the model through the toolkit”](#2-the-realistic-case--hand-them-to-the-model-through-the-toolkit) ```go package main import ( "context" "fmt" "log" "net/http/httptest" "github.com/mark3labs/mcp-go/mcp" "github.com/mark3labs/mcp-go/server" toolnexus "github.com/muthuishere/toolnexus/golang" ) func main() { s := server.NewMCPServer("docs", "1.0", server.WithToolCapabilities(false), server.WithResourceCapabilities(false, false)) s.AddResource(mcp.NewResource("file:///readme.md", "readme"), func(_ context.Context, req mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) { return []mcp.ResourceContents{mcp.TextResourceContents{URI: req.Params.URI, Text: "# Hello"}}, nil }) ts := httptest.NewServer(server.NewStreamableHTTPServer(s, server.WithStateLess(true))) defer ts.Close() src, err := toolnexus.LoadMcp(map[string]any{"docs": map[string]any{"url": ts.URL + "/mcp"}}) if err != nil { log.Fatal(err) } defer src.Close() tk, err := toolnexus.CreateToolkit(context.Background(), toolnexus.Options{ExtraTools: src.ResourceTools()}) if err != nil { log.Fatal(err) } defer tk.Close() res, err := tk.Execute(context.Background(), "read_mcp_resource", map[string]any{"server": "docs", "uri": "file:///readme.md"}) if err != nil || res.Output != "# Hello" { log.Fatalf("%v %q", err, res.Output) } fmt.Println("ok: toolkit read", res.Output) } ``` ### 3. The full surface — read-only hints, metadata, and errors as results [Section titled “3. The full surface — read-only hints, metadata, and errors as results”](#3-the-full-surface--read-only-hints-metadata-and-errors-as-results) ```go package main import ( "context" "fmt" "log" "net/http/httptest" "strings" "github.com/mark3labs/mcp-go/mcp" "github.com/mark3labs/mcp-go/server" toolnexus "github.com/muthuishere/toolnexus/golang" ) func main() { s := server.NewMCPServer("docs", "1.0", server.WithToolCapabilities(false), server.WithResourceCapabilities(false, false)) s.AddResource(mcp.NewResource("file:///a.txt", "a", mcp.WithResourceDescription("first")), func(_ context.Context, req mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) { return []mcp.ResourceContents{mcp.TextResourceContents{URI: req.Params.URI, Text: "A"}}, nil }) ts := httptest.NewServer(server.NewStreamableHTTPServer(s, server.WithStateLess(true))) defer ts.Close() src, err := toolnexus.LoadMcp(map[string]any{"docs": map[string]any{"url": ts.URL + "/mcp"}}) if err != nil { log.Fatal(err) } defer src.Close() tools := src.ResourceTools() for _, t := range tools { if t.Hints == nil || !t.Hints.ReadOnly { log.Fatalf("%s should be read-only", t.Name) } } list, _ := tools[0].Execute(map[string]any{"server": "docs"}, nil) if list.Metadata["count"] != 1 { log.Fatalf("metadata: %v", list.Metadata) } bad, _ := tools[1].Execute(map[string]any{"server": "nope", "uri": "file:///a.txt"}, nil) if !bad.IsError || !strings.Contains(bad.Output, `no connected MCP server "nope"`) { log.Fatalf("expected an error result, got %+v", bad) } fmt.Println("ok: read-only, count =", list.Metadata["count"], "| unknown server ->", bad.IsError) } ``` ## See also [Section titled “See also”](#see-also) * [`LoadMcp`](/api/go/mcp/load/) — connect the servers whose resources these tools read. * [`ListMcpTools`](/api/go/mcp/list-tools/) — the MCP tools themselves, loaded automatically. * [`Hooks`](/api/go/client/hooks/) — `BeforeTool` sees `Hints.ReadOnly` and can let these run without asking. # collectTools — gather decorated functions > Sweep a module or class and collect every function marked as a tool. — Go. **Go** · package `github.com/muthuishere/toolnexus/golang` · [SPEC §6](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) Not available in Go JS/Java/C# expose an explicit collector; Python, Go and Elixir have no equivalent public symbol. 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) Go has no reflection-based “sweep a package/struct for tool-marked methods” collector — Go’s reflection cannot enumerate a package’s top-level functions or discover a struct method by an attribute the way JS decorators, Java annotations, or C# attributes can. Build the list explicitly instead: * **[`NativeTool`](/api/go/native/define-tool/)** for one tool at a time, with a hand-written schema. * **[`NativeToolReflect`](/api/go/native/reflect/)** when the input is a fixed-shape struct — it derives the schema from the struct’s `json` tags, which is the closest Go gets to “derive from the signature.” * A plain `[]toolnexus.Tool{...}` slice literal you assemble yourself and pass to `Options.ExtraTools` on [`CreateToolkit`](/api/go/toolkit/create/) — this is the idiomatic stand-in for a “collect them all” call: you already have to name every tool once to construct it, so a collector would only save one line, not a whole discovery mechanism. ```go tools := []toolnexus.Tool{ toolnexus.NativeToolReflect[SearchInput]("search", "Search the docs", searchFn), toolnexus.NativeToolReflect[PingInput]("ping", "Health check", pingFn), } tk, err := toolnexus.CreateToolkit(ctx, toolnexus.Options{ExtraTools: tools}) ``` ## See also [Section titled “See also”](#see-also) * [`NativeTool`](/api/go/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. * [`NativeToolReflect`](/api/go/native/reflect/) — Derive the schema from the function signature or annotation instead of writing it by hand. # NativeTool > Wrap a plain Go function with a name, description and schema — the shortest path from code you have to a tool the LLM can call. Go. **Go** · module `github.com/muthuishere/toolnexus/golang` · [SPEC §6](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [golang/native.go](https://github.com/muthuishere/toolnexus/blob/main/golang/native.go) ```go func NativeTool( name, description string, inputSchema JSONSchema, fn func(ctx context.Context, args map[string]any) (string, error), ) Tool ``` The shortest path from a function you already have to a tool a model can call. You supply four things — a name, a description, a JSON-Schema object, and the function — and get back a [`Tool`](/api/go/types/tool/) with `Source: "native"`, ready to register on a toolkit or hand to an adapter. ## When to use it [Section titled “When to use it”](#when-to-use-it) Whenever the capability is *your own code*: a database query, a pricing calculation, an internal RPC, a feature flag lookup. This is the workhorse of the library — most tools in a real agent are native ones, with MCP and skills filling in around them. ## Why this and not the alternative [Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative) Use NativeToolReflect when the args are a struct [`NativeToolReflect`](/api/go/native/reflect/) derives the JSON schema from a Go struct’s `json` tags and decodes the model’s args into that struct before your function runs — no hand-written schema, no `map[string]any` type assertions. Prefer it *instead* of `NativeTool` whenever your inputs have a fixed shape. Stay with `NativeTool` when the args are genuinely dynamic, when you need a schema construct reflection cannot express (enums, `minimum`, `oneOf`), or when the tool takes no input at all. Hand-building the [`Tool`](/api/go/types/tool/) struct yourself is the other alternative — reach for that only when you are generating many tools from data, since `NativeTool` already fills in `Source` and the error handling for you. ## Examples [Section titled “Examples”](#examples) ### 1. A function and four arguments [Section titled “1. A function and four arguments”](#1-a-function-and-four-arguments) `fn` returns `(string, error)`. That is the entire contract. ```go package main import ( "context" "fmt" "log" "strings" toolnexus "github.com/muthuishere/toolnexus/golang" ) func main() { upper := toolnexus.NativeTool( "shout", "Uppercase the given text", toolnexus.JSONSchema{ "type": "object", "properties": map[string]any{"text": map[string]any{"type": "string"}}, "required": []string{"text"}, }, func(ctx context.Context, args map[string]any) (string, error) { text, _ := args["text"].(string) return strings.ToUpper(text), nil }, ) if upper.Name != "shout" || upper.Source != toolnexus.SourceNative { log.Fatalf("unexpected tool: %+v", upper) } res, err := upper.Execute(map[string]any{"text": "hello"}, nil) if err != nil || res.IsError || res.Output != "HELLO" { log.Fatalf("unexpected: %+v %v", res, err) } fmt.Println("ok:", res.Output) } ``` ### 2. Returning an error, and the schema-less case [Section titled “2. Returning an error, and the schema-less case”](#2-returning-an-error-and-the-schema-less-case) A returned `error` is **not** a Go failure the caller must handle — it becomes model-facing output with `IsError: true`, so the model can read the message and correct itself. And a `nil` schema means “takes no arguments”. ```go package main import ( "context" "errors" "fmt" "log" "time" toolnexus "github.com/muthuishere/toolnexus/golang" ) func main() { lookup := toolnexus.NativeTool( "get_order", "Look up an order by id", toolnexus.JSONSchema{ "type": "object", "properties": map[string]any{"id": map[string]any{"type": "string"}}, "required": []string{"id"}, }, func(ctx context.Context, args map[string]any) (string, error) { id, _ := args["id"].(string) if id != "A-1" { return "", errors.New("no such order: " + id) } return "order A-1: shipped", nil }, ) ok, err := lookup.Execute(map[string]any{"id": "A-1"}, nil) if err != nil || ok.IsError || ok.Output != "order A-1: shipped" { log.Fatalf("unexpected: %+v %v", ok, err) } // The error message becomes the OUTPUT. Execute itself returns a nil error. bad, err := lookup.Execute(map[string]any{"id": "Z-9"}, nil) if err != nil { log.Fatalf("Execute should not surface the error: %v", err) } if !bad.IsError || bad.Output != "no such order: Z-9" { log.Fatalf("unexpected error result: %+v", bad) } // nil schema ⇒ {"type":"object","properties":{},"additionalProperties":false}. now := toolnexus.NativeTool("now", "Current server time", nil, func(ctx context.Context, args map[string]any) (string, error) { return time.Now().UTC().Format(time.RFC3339), nil }, ) if now.InputSchema["type"] != "object" { log.Fatalf("unexpected default schema: %v", now.InputSchema) } if now.InputSchema["additionalProperties"] != false { log.Fatalf("expected additionalProperties:false, got %v", now.InputSchema) } // nil args are normalised to an empty map before fn runs. res, err := now.Execute(nil, nil) if err != nil || res.IsError || res.Output == "" { log.Fatalf("unexpected: %+v %v", res, err) } fmt.Println("ok:", ok.Output, "| error path:", bad.Output) } ``` Args are untyped JSON Numbers arrive as `float64`, not `int` — they came through `encoding/json`. Type-assert with the two-value form (`v, ok := args["n"].(float64)`) and return an error rather than panicking, or use [`NativeToolReflect`](/api/go/native/reflect/) and let the decoder do it. ### 3. Context, cancellation, and registering on a toolkit [Section titled “3. Context, cancellation, and registering on a toolkit”](#3-context-cancellation-and-registering-on-a-toolkit) The `ctx` your function receives comes from the [`ToolContext`](/api/go/types/context/) the loop passes in — honour it for anything slow. ```go package main import ( "context" "encoding/json" "fmt" "log" toolnexus "github.com/muthuishere/toolnexus/golang" ) func main() { slow := toolnexus.NativeTool( "fetch_report", "Generate a report for a region", toolnexus.JSONSchema{ "type": "object", "properties": map[string]any{ "region": map[string]any{ "type": "string", "enum": []string{"emea", "apac"}, "description": "Which region to report on", }, }, "required": []string{"region"}, "additionalProperties": false, }, func(ctx context.Context, args map[string]any) (string, error) { // Bail out the moment the caller gives up. if err := ctx.Err(); err != nil { return "", err } return fmt.Sprintf("report for %v", args["region"]), nil }, ) // No ToolContext at all: fn still gets a usable context.Background(). res, err := slow.Execute(map[string]any{"region": "apac"}, nil) if err != nil || res.Output != "report for apac" { log.Fatalf("unexpected: %+v %v", res, err) } // A cancelled context surfaces as an error RESULT, not a returned error. cancelled, cancel := context.WithCancel(context.Background()) cancel() stopped, err := slow.Execute( map[string]any{"region": "emea"}, &toolnexus.ToolContext{Ctx: cancelled}, ) if err != nil || !stopped.IsError || stopped.Output != "context canceled" { log.Fatalf("unexpected cancellation: %+v %v", stopped, err) } // Register it on a toolkit alongside every other source. Builtins are ON by // default — turn them off so this toolkit holds only our tool. tk, err := toolnexus.CreateToolkit(context.Background(), toolnexus.Options{ ExtraTools: []toolnexus.Tool{slow}, Builtins: false, }) if err != nil { log.Fatal(err) } defer tk.Close() if _, ok := tk.Get("fetch_report"); !ok { log.Fatal("expected the tool on the toolkit") } out, err := tk.Execute(context.Background(), "fetch_report", map[string]any{"region": "emea"}) if err != nil || out.Output != "report for emea" { log.Fatalf("unexpected: %+v %v", out, err) } // The schema you wrote is what the model sees, enum and all. if len(tk.Tools()) != 1 { log.Fatalf("expected only our tool, got %d", len(tk.Tools())) } b, _ := json.Marshal(toolnexus.ToOpenAI(tk.Tools())[0]) var got map[string]any _ = json.Unmarshal(b, &got) params := got["function"].(map[string]any)["parameters"].(map[string]any) props := params["properties"].(map[string]any)["region"].(map[string]any) if len(props["enum"].([]any)) != 2 { log.Fatalf("enum did not survive: %v", props) } fmt.Println("ok:", res.Output, "| cancelled:", stopped.Output) } ``` ## Arguments [Section titled “Arguments”](#arguments) | Argument | Type | What it is | | ------------- | ------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | | `name` | `string` | The name the model calls. Must match `[a-zA-Z0-9_-]` — run untrusted names through `Sanitize`. | | `description` | `string` | What the model reads to decide whether to call it. This is prompt engineering, not a code comment. | | `inputSchema` | `JSONSchema` | A JSON-Schema **object** (`map[string]any`). `nil` ⇒ `{"type":"object","properties":{},"additionalProperties":false}`. | | `fn` | `func(context.Context, map[string]any) (string, error)` | Your code. `ctx` is never nil; `args` is never nil. | Return mapping: | `fn` returns | Resulting `ToolResult` | | --------------- | ------------------------------------------------------------------------------ | | `("text", nil)` | `{Output: "text", IsError: false}` | | `("", err)` | `{Output: err.Error(), IsError: true}` — and `Execute`’s own error stays `nil` | ## See also [Section titled “See also”](#see-also) * [`NativeToolReflect`](/api/go/native/reflect/) — derive the schema from a struct instead * [`Tool`](/api/go/types/tool/) — what you get back * [`ToolContext`](/api/go/types/context/) — where `ctx` comes from * [`CreateToolkit`](/api/go/toolkit/create/) — register it via `ExtraTools` # NativeToolReflect > Derive the schema from the function signature or annotation instead of writing it by hand. — Go. **Go** · module `github.com/muthuishere/toolnexus/golang` · [SPEC §6](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [golang/native.go](https://github.com/muthuishere/toolnexus/blob/main/golang/native.go) ```go func NativeToolReflect[T any]( name, description string, fn func(ctx context.Context, in T) (string, error), ) Tool ``` The generic sibling of [`NativeTool`](/api/go/native/define-tool/): instead of writing a JSON-Schema object by hand, you give it a Go struct type `T`, and it derives the schema from `T`’s fields via reflection over their `json` tags — then decodes the model’s arguments into a `T` before your function ever runs. No `map[string]any` type assertions inside `fn`. Type mapping and required-ness `string`→`string`, `int`/`float*`→`number`, `bool`→`boolean`, slice/array→`array`, struct/map→ `object`. A field is **required** unless its `json` tag carries `,omitempty`; `json:"-"` drops the field from the schema entirely. Pointer fields are dereferenced for the purpose of type mapping. ## When to use it [Section titled “When to use it”](#when-to-use-it) Reach for `NativeToolReflect` whenever your tool’s input has a **fixed shape** you’d otherwise write out as a struct anyway — most native tools. The schema and the decode step both come from one source of truth: the struct definition. ## Why this and not the alternative [Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative) Stay with NativeTool for dynamic or schema-construct-heavy inputs [`NativeTool`](/api/go/native/define-tool/) is still the right call when the args are genuinely dynamic, when you need a schema construct reflection cannot express (`enum`, `minimum`, `oneOf`), or when the tool takes no input at all. Reach for `NativeToolReflect` instead the moment the args are a plain struct — it removes the schema/decode duplication. ## Examples [Section titled “Examples”](#examples) ### 1. A struct in, a schema and a decoded value out [Section titled “1. A struct in, a schema and a decoded value out”](#1-a-struct-in-a-schema-and-a-decoded-value-out) ```go package main import ( "context" "fmt" "log" toolnexus "github.com/muthuishere/toolnexus/golang" ) type ShoutInput struct { Text string `json:"text"` } func main() { shout := toolnexus.NativeToolReflect[ShoutInput]( "shout", "Uppercase the given text", func(ctx context.Context, in ShoutInput) (string, error) { return in.Text + "!", nil }, ) if shout.Name != "shout" || shout.Source != toolnexus.SourceNative { log.Fatalf("unexpected tool: %+v", shout) } if shout.InputSchema["type"] != "object" { log.Fatalf("unexpected schema: %v", shout.InputSchema) } props := shout.InputSchema["properties"].(map[string]any) if _, ok := props["text"]; !ok { log.Fatalf("expected a 'text' property, got %v", props) } res, err := shout.Execute(map[string]any{"text": "hello"}, nil) if err != nil || res.IsError || res.Output != "hello!" { log.Fatalf("unexpected: %+v %v", res, err) } fmt.Println("ok:", res.Output) } ``` ### 2. `omitempty` controls what’s required, `json:"-"` drops a field [Section titled “2. omitempty controls what’s required, json:"-" drops a field”](#2-omitempty-controls-whats-required-json--drops-a-field) ```go package main import ( "context" "fmt" "log" toolnexus "github.com/muthuishere/toolnexus/golang" ) type SearchInput struct { Query string `json:"query"` Limit int `json:"limit,omitempty"` // optional Secret string `json:"-"` // never in the schema } func main() { search := toolnexus.NativeToolReflect[SearchInput]( "search", "Search the docs", func(ctx context.Context, in SearchInput) (string, error) { return fmt.Sprintf("query=%s limit=%d", in.Query, in.Limit), nil }, ) schema := search.InputSchema props := schema["properties"].(map[string]any) if _, ok := props["secret"]; ok { log.Fatal("expected json:\"-\" to drop the field from the schema") } required, _ := schema["required"].([]string) if len(required) != 1 || required[0] != "query" { log.Fatalf("expected only 'query' required, got %v", required) } // limit is omitted entirely — the decoder still zero-values it. res, err := search.Execute(map[string]any{"query": "adapters"}, nil) if err != nil || res.IsError { log.Fatalf("unexpected: %+v %v", res, err) } fmt.Println("ok:", res.Output) } ``` ### 3. Numbers, nested structs, and registering on a toolkit [Section titled “3. Numbers, nested structs, and registering on a toolkit”](#3-numbers-nested-structs-and-registering-on-a-toolkit) ```go package main import ( "context" "fmt" "log" toolnexus "github.com/muthuishere/toolnexus/golang" ) type Address struct { City string `json:"city"` } type OrderInput struct { Quantity float64 `json:"quantity"` Ship Address `json:"ship"` } func main() { order := toolnexus.NativeToolReflect[OrderInput]( "place_order", "Place an order for delivery", func(ctx context.Context, in OrderInput) (string, error) { return fmt.Sprintf("%v units to %s", in.Quantity, in.Ship.City), nil }, ) props := order.InputSchema["properties"].(map[string]any) qty := props["quantity"].(map[string]any) if qty["type"] != "number" { log.Fatalf("expected quantity to map to number, got %v", qty) } ship := props["ship"].(map[string]any) if ship["type"] != "object" { log.Fatalf("expected a nested object schema for ship, got %v", ship) } res, err := order.Execute(map[string]any{ "quantity": 3, "ship": map[string]any{"city": "Chennai"}, }, nil) if err != nil || res.IsError || res.Output != "3 units to Chennai" { log.Fatalf("unexpected: %+v %v", res, err) } tk, err := toolnexus.CreateToolkit(context.Background(), toolnexus.Options{ ExtraTools: []toolnexus.Tool{order}, Builtins: false, }) if err != nil { log.Fatal(err) } defer tk.Close() if _, ok := tk.Get("place_order"); !ok { log.Fatal("expected the tool on the toolkit") } fmt.Println("ok:", res.Output) } ``` ## Type parameters and arguments [Section titled “Type parameters and arguments”](#type-parameters-and-arguments) | Parameter | Type | What it is | | ------------- | ------------------------------------------ | ----------------------------------------------------------------------------- | | `T` | any struct type | The shape of the tool’s input; drives both the emitted schema and the decode. | | `name` | `string` | The name the model calls. | | `description` | `string` | What the model reads to decide whether to call it. | | `fn` | `func(context.Context, T) (string, error)` | Your code — receives an already-decoded `T`, not `map[string]any`. | ## Struct-to-schema mapping [Section titled “Struct-to-schema mapping”](#struct-to-schema-mapping) | Go kind | JSON-Schema `type` | | ------------------------- | ------------------------------------------------------ | | `string` | `string` | | `bool` | `boolean` | | `int*`, `uint*`, `float*` | `number` | | slice / array | `array`, with `items` from the element type | | `map`, nested `struct` | `object` (structs recurse into their own field schema) | ## See also [Section titled “See also”](#see-also) * [`NativeTool`](/api/go/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. # ComposeSoul > Build a persona's system prompt from its home directory: identity, memory, skills. — Go. **Go** · package `github.com/muthuishere/toolnexus/golang/agents` · [SPEC §7E](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [golang/agents/home.go](https://github.com/muthuishere/toolnexus/blob/main/golang/agents/home.go) ```go var BootstrapOrder = []string{ "AGENTS.md", "SOUL.md", "IDENTITY.md", "USER.md", "TOOLS.md", "HEARTBEAT.md", "MEMORY.md", } func ComposeSoul(dir string) (soul string, found []string) ``` `ComposeSoul` is the directory-is-the-agent primitive: it reads `BootstrapOrder`’s seven well-known filenames out of `dir`, in openclaw convention order (identity first, memory last), and joins each present one into a single soul string as a `"## "` section. Absent files are skipped silently. Each file is read with a 2 MB byte cap — a larger file is truncated with a notice appended, and the on-disk file is untouched. `found` lists exactly which files were present, in the order they were composed. ## When to use it [Section titled “When to use it”](#when-to-use-it) Reach for `ComposeSoul` when you’re building a persona-shaped agent by hand — composing the soul yourself into `agents.Spec.Soul` — rather than going through [`FromDir`](/api/go/persona/from-dir/), which calls it for you. It’s also the right call when you only want the composed prompt text (to log it, hash it for cache-stability checks, or feed it to a different agent constructor) without building a full `*agents.Agent`. ## Why this and not the alternative [Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative) Want a runnable Agent, not just the prompt string? `ComposeSoul` only builds the soul text — it doesn’t construct an agent, wire the `memory` tool, or pick a name. [`FromDir`](/api/go/persona/from-dir/) does all three in one call and is what nearly every persona should use; reach for `ComposeSoul` directly only when you need the composed string on its own or are assembling a `Spec` with non-default pieces. ## Examples [Section titled “Examples”](#examples) ### 1. The smallest useful call — compose from two files [Section titled “1. The smallest useful call — compose from two files”](#1-the-smallest-useful-call--compose-from-two-files) ```go package main import ( "fmt" "log" "os" "path/filepath" "strings" "github.com/muthuishere/toolnexus/golang/agents" ) func main() { dir, err := os.MkdirTemp("", "persona-*") if err != nil { log.Fatal(err) } defer os.RemoveAll(dir) if err := os.WriteFile(filepath.Join(dir, "SOUL.md"), []byte("You are Kip, a terse assistant."), 0o644); err != nil { log.Fatal(err) } if err := os.WriteFile(filepath.Join(dir, "MEMORY.md"), []byte("- prefers metric units"), 0o644); err != nil { log.Fatal(err) } soul, found := agents.ComposeSoul(dir) if len(found) != 2 || found[0] != "SOUL.md" || found[1] != "MEMORY.md" { log.Fatalf("unexpected found order: %v", found) } if !strings.Contains(soul, "## SOUL.md") { log.Fatalf("soul missing SOUL.md section: %s", soul) } fmt.Println("ok: composed from", found) } ``` ### 2. The realistic case — absent files skip silently, order is fixed [Section titled “2. The realistic case — absent files skip silently, order is fixed”](#2-the-realistic-case--absent-files-skip-silently-order-is-fixed) Only `AGENTS.md` and `USER.md` exist here; `ComposeSoul` still walks the full `BootstrapOrder`, composing only what it finds, in that canonical order — not directory listing order or creation order. ```go package main import ( "fmt" "log" "os" "path/filepath" "strings" "github.com/muthuishere/toolnexus/golang/agents" ) func main() { dir, err := os.MkdirTemp("", "persona-*") if err != nil { log.Fatal(err) } defer os.RemoveAll(dir) // Write USER.md first, on disk, but BootstrapOrder still puts AGENTS.md ahead of it. if err := os.WriteFile(filepath.Join(dir, "USER.md"), []byte("The user is Muthu."), 0o644); err != nil { log.Fatal(err) } if err := os.WriteFile(filepath.Join(dir, "AGENTS.md"), []byte("Reply in one line."), 0o644); err != nil { log.Fatal(err) } soul, found := agents.ComposeSoul(dir) if len(found) != 2 || found[0] != "AGENTS.md" || found[1] != "USER.md" { log.Fatalf("expected AGENTS.md before USER.md, got %v", found) } if strings.Index(soul, "AGENTS.md") > strings.Index(soul, "USER.md") { log.Fatal("expected AGENTS.md section to precede USER.md section in the composed text") } fmt.Println("ok: composed", found, "- SOUL.md/IDENTITY.md/etc were absent and skipped") } ``` ### 3. The full surface — wired into an agent as `Spec.Soul`, and a truncation notice [Section titled “3. The full surface — wired into an agent as Spec.Soul, and a truncation notice”](#3-the-full-surface--wired-into-an-agent-as-specsoul-and-a-truncation-notice) `ComposeSoul`’s output is exactly what `FromDir` assigns to `agents.Spec.Soul` — this example does that assembly explicitly, and also shows the 2 MB truncation notice on an oversized file. ```go package main import ( "bytes" "encoding/json" "fmt" "io" "log" "net/http" "os" "path/filepath" "strings" "github.com/muthuishere/toolnexus/golang/agents" ) func main() { dir, err := os.MkdirTemp("", "persona-*") if err != nil { log.Fatal(err) } defer os.RemoveAll(dir) if err := os.WriteFile(filepath.Join(dir, "IDENTITY.md"), []byte("You are Archivist."), 0o644); err != nil { log.Fatal(err) } // One byte over the 2 MB cap: ComposeSoul truncates with a notice, on-disk file untouched. oversized := bytes.Repeat([]byte("x"), 2*1024*1024+1) if err := os.WriteFile(filepath.Join(dir, "TOOLS.md"), oversized, 0o644); err != nil { log.Fatal(err) } soul, found := agents.ComposeSoul(dir) if len(found) != 2 || found[0] != "IDENTITY.md" || found[1] != "TOOLS.md" { log.Fatalf("unexpected found: %v", found) } if !strings.Contains(soul, "[truncated: exceeds 2 MB bootstrap cap]") { log.Fatal("expected a truncation notice for the oversized TOOLS.md") } onDisk, _ := os.ReadFile(filepath.Join(dir, "TOOLS.md")) if len(onDisk) != len(oversized) { log.Fatal("on-disk file must stay untouched by the read-time truncation") } // Assign the composed soul exactly as FromDir does. archivist := agents.New("archivist", agents.Spec{Does: "keeps records", Soul: soul, Model: "m-archivist"}) transport := roundTripFunc(func(req *http.Request) (*http.Response, error) { body, _ := json.Marshal(map[string]any{ "choices": []any{map[string]any{"message": map[string]any{"role": "assistant", "content": "archived"}}}, "usage": map[string]any{"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2}, }) return &http.Response{StatusCode: 200, Header: http.Header{"Content-Type": []string{"application/json"}}, Body: io.NopCloser(bytes.NewReader(body))}, nil }) r, _ := archivist.Run(agents.Options{Transport: transport}, "file this") if r.Status != "done" || r.Text != "archived" { log.Fatalf("unexpected run result: %+v", r) } fmt.Println("ok: soul carries", len(found), "sections and truncates oversized files safely") } type roundTripFunc func(*http.Request) (*http.Response, error) func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { return f(req) } ``` ## Fields and behavior [Section titled “Fields and behavior”](#fields-and-behavior) | Aspect | Behavior | | ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `BootstrapOrder` | `AGENTS.md`, `SOUL.md`, `IDENTITY.md`, `USER.md`, `TOOLS.md`, `HEARTBEAT.md`, `MEMORY.md` — fixed, identity-first, memory-last. | | Absent files | Skipped silently — no error, no placeholder section. | | Per-file cap | 2 MB (`2097152` bytes), byte-based (may split a multibyte character). Larger ⇒ truncated + `"\n[truncated: exceeds 2 MB bootstrap cap]"` appended; on-disk file is never modified. | | Section format | Each present file becomes `"## \n\n"`, joined with `"\n\n"`. | | `found` | The ordered list of filenames that were actually present — use it to know what shaped the soul. | | Timing | Composition happens once, at session start — the soul is a **frozen snapshot** for the run (the cache-stability rule). | ## See also [Section titled “See also”](#see-also) * [`FromDir`](/api/go/persona/from-dir/) — Point at an agent home directory and get a configured agent back. * [`MemoryTool`](/api/go/persona/memory-tool/) — The opt-in built-in that lets a persona write durable notes to its own home. * [`agents.New`](/api/go/runtime/agent/) — the constructor `Spec.Soul` feeds into. # FromDir > Point at an agent home directory and get a configured agent back. — Go. **Go** · package `github.com/muthuishere/toolnexus/golang/agents` · [SPEC §7E](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [golang/agents/home.go](https://github.com/muthuishere/toolnexus/blob/main/golang/agents/home.go) ```go type FromDirOptions struct { Does string // routing description (default "persona agent from ") Name string // agent name (default filepath.Base(dir)) Model string // model id (default "inherit") Tools []tn.Tool // extra tools beyond the memory builtin Memory *bool // nil ⇒ enabled; point at false to omit the memory tool } func FromDir(dir string, opts FromDirOptions) *Agent ``` `FromDir` is the one-call persona constructor: the directory *is* the agent. It calls [`ComposeSoul`](/api/go/persona/compose-soul/) to build the soul from `dir`’s bootstrap files, wires [`MemoryTool(dir)`](/api/go/persona/memory-tool/) into the tool list (unless `opts.Memory` points at `false`), and hands both to [`agents.New`](/api/go/runtime/agent/). The returned `*Agent` runs like any other — `Run` for a one-shot, `AsTool` to drop it into a classic toolkit, or [`StartAgent`](/api/go/runtime/agent/) for a heartbeat loop. ## When to use it [Section titled “When to use it”](#when-to-use-it) Reach for `FromDir` whenever a persona’s identity lives in files on disk — the openclaw convention of `AGENTS.md` / `SOUL.md` / `IDENTITY.md` / `USER.md` / `TOOLS.md` / `HEARTBEAT.md` / `MEMORY.md`. It’s the standard way to boot a long-lived, home-directory-backed agent: point it at a folder, get back something runnable, with durable memory already wired. ## Why this and not the alternative [Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative) Building the Spec fields yourself instead? Use [`agents.New`](/api/go/runtime/agent/) directly when your persona’s soul comes from somewhere other than a directory (a database row, a generated string, a template) or when you need `Spec` fields `FromDir` doesn’t expose (`Team`, `Budget`, `WaitFor`, hooks). `FromDir` is a convenience over `New` for the specific, common case: soul + memory tool, both rooted at one directory. Call [`ComposeSoul`](/api/go/persona/compose-soul/) and [`MemoryTool`](/api/go/persona/memory-tool/) yourself and pass them to `New` when you want that control back. ## Examples [Section titled “Examples”](#examples) ### 1. The smallest useful call — a persona from a bare directory [Section titled “1. The smallest useful call — a persona from a bare directory”](#1-the-smallest-useful-call--a-persona-from-a-bare-directory) No bootstrap files at all still works: the soul is empty, the name defaults to the directory’s base name, the model defaults to `"inherit"`, and the memory tool is wired anyway. ```go package main import ( "bytes" "encoding/json" "fmt" "io" "log" "net/http" "os" "github.com/muthuishere/toolnexus/golang/agents" ) func main() { dir, err := os.MkdirTemp("", "kip-*") if err != nil { log.Fatal(err) } defer os.RemoveAll(dir) kip := agents.FromDir(dir, agents.FromDirOptions{Model: "m-kip"}) if kip.Name != filepathBase(dir) { log.Fatalf("expected name to default to the dir base, got %q", kip.Name) } transport := roundTripFunc(func(req *http.Request) (*http.Response, error) { body, _ := json.Marshal(map[string]any{ "choices": []any{map[string]any{"message": map[string]any{"role": "assistant", "content": "hi"}}}, "usage": map[string]any{"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2}, }) return &http.Response{StatusCode: 200, Header: http.Header{"Content-Type": []string{"application/json"}}, Body: io.NopCloser(bytes.NewReader(body))}, nil }) r, _ := kip.Run(agents.Options{Transport: transport}, "hello") if r.Status != "done" || r.Text != "hi" { log.Fatalf("unexpected: %+v", r) } fmt.Println("ok:", kip.Name, "answered", r.Text) } type roundTripFunc func(*http.Request) (*http.Response, error) func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { return f(req) } func filepathBase(p string) string { for i := len(p) - 1; i >= 0; i-- { if p[i] == '/' { return p[i+1:] } } return p } ``` ### 2. The realistic case — bootstrap files shape the soul, and the model answers with it [Section titled “2. The realistic case — bootstrap files shape the soul, and the model answers with it”](#2-the-realistic-case--bootstrap-files-shape-the-soul-and-the-model-answers-with-it) `SOUL.md` on disk becomes part of the request the stub LLM receives — proving the composed soul actually reaches the provider as the system prompt. ```go package main import ( "bytes" "encoding/json" "fmt" "io" "log" "net/http" "os" "path/filepath" "strings" "github.com/muthuishere/toolnexus/golang/agents" ) func main() { dir, err := os.MkdirTemp("", "archivist-*") if err != nil { log.Fatal(err) } defer os.RemoveAll(dir) if err := os.WriteFile(filepath.Join(dir, "SOUL.md"), []byte("You are Archivist, terse and precise."), 0o644); err != nil { log.Fatal(err) } archivist := agents.FromDir(dir, agents.FromDirOptions{Name: "archivist", Does: "keeps records", Model: "m-archivist"}) var sawSoul bool transport := roundTripFunc(func(req *http.Request) (*http.Response, error) { b, _ := io.ReadAll(req.Body) if strings.Contains(string(b), "Archivist, terse and precise") { sawSoul = true } body, _ := json.Marshal(map[string]any{ "choices": []any{map[string]any{"message": map[string]any{"role": "assistant", "content": "logged"}}}, "usage": map[string]any{"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2}, }) return &http.Response{StatusCode: 200, Header: http.Header{"Content-Type": []string{"application/json"}}, Body: io.NopCloser(bytes.NewReader(body))}, nil }) r, _ := archivist.Run(agents.Options{Transport: transport}, "file this record") if r.Status != "done" || r.Text != "logged" { log.Fatalf("unexpected: %+v", r) } if !sawSoul { log.Fatal("expected the composed SOUL.md text in the request the provider received") } fmt.Println("ok:", archivist.Name, "ran with its composed soul") } type roundTripFunc func(*http.Request) (*http.Response, error) func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { return f(req) } ``` ### 3. The full surface — extra tools, and turning the memory builtin off [Section titled “3. The full surface — extra tools, and turning the memory builtin off”](#3-the-full-surface--extra-tools-and-turning-the-memory-builtin-off) `opts.Memory` set to a pointer-to-`false` yields a read-only persona: no `memory` tool at all, even though a home directory is wired. ```go package main import ( "bytes" "encoding/json" "fmt" "io" "log" "net/http" "os" "github.com/muthuishere/toolnexus/golang/agents" ) func main() { dir, err := os.MkdirTemp("", "readonly-*") if err != nil { log.Fatal(err) } defer os.RemoveAll(dir) noMemory := false readonly := agents.FromDir(dir, agents.FromDirOptions{ Name: "readonly", Model: "m-readonly", Memory: &noMemory, }) // A separate persona with memory enabled (the default) DOES get the memory tool — // the mock LLM below inspects each request to prove the toggle actually removes it. withMemory := agents.FromDir(dir, agents.FromDirOptions{Name: "withmemory", Model: "m-with"}) transport := roundTripFunc(func(req *http.Request) (*http.Response, error) { b, _ := io.ReadAll(req.Body) hasMemoryDecl := containsToolName(string(b), "memory") content := "no-memory-tool-declared" if hasMemoryDecl { content = "memory-tool-declared" } body, _ := json.Marshal(map[string]any{ "choices": []any{map[string]any{"message": map[string]any{"role": "assistant", "content": content}}}, "usage": map[string]any{"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2}, }) return &http.Response{StatusCode: 200, Header: http.Header{"Content-Type": []string{"application/json"}}, Body: io.NopCloser(bytes.NewReader(body))}, nil }) r1, _ := readonly.Run(agents.Options{Transport: transport}, "go") if r1.Text != "no-memory-tool-declared" { log.Fatalf("expected the readonly persona to declare no memory tool, got %q", r1.Text) } r2, _ := withMemory.Run(agents.Options{Transport: transport}, "go") if r2.Text != "memory-tool-declared" { log.Fatalf("expected the default persona to declare a memory tool, got %q", r2.Text) } fmt.Println("ok: Memory:&false omits the tool; the default wires it") } type roundTripFunc func(*http.Request) (*http.Response, error) func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { return f(req) } func containsToolName(body, name string) bool { return len(body) > 0 && indexOfSub(body, "\""+name+"\"") >= 0 } func indexOfSub(s, sub string) int { for i := 0; i+len(sub) <= len(s); i++ { if s[i:i+len(sub)] == sub { return i } } return -1 } ``` ## FromDirOptions fields [Section titled “FromDirOptions fields”](#fromdiroptions-fields) | Field | Type | Default | What it does | | -------- | ----------- | ---------------------------- | ------------------------------------------------------------------------------ | | `Does` | `string` | `"persona agent from "` | The routing description the delegating model (or a parent’s `task` tool) sees. | | `Name` | `string` | `filepath.Base(dir)` | The agent’s name. | | `Model` | `string` | `"inherit"` | The model id passed to `agents.Spec.Model`. | | `Tools` | `[]tn.Tool` | none | Extra tools beyond the memory builtin. | | `Memory` | `*bool` | `nil` (enabled) | Point at `false` to omit `MemoryTool(dir)` — a read-only persona. | ## See also [Section titled “See also”](#see-also) * [`ComposeSoul`](/api/go/persona/compose-soul/) — Build a persona’s system prompt from its home directory: identity, memory, skills. * [`MemoryTool`](/api/go/persona/memory-tool/) — The opt-in built-in that lets a persona write durable notes to its own home. * [`agents.New`](/api/go/runtime/agent/) — The lower-level constructor `FromDir` wraps. # MemoryTool > The opt-in built-in that lets a persona write durable notes to its own home. — Go. **Go** · package `github.com/muthuishere/toolnexus/golang/agents` · [SPEC §7E](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [golang/agents/home.go](https://github.com/muthuishere/toolnexus/blob/main/golang/agents/home.go) ```go func MemoryTool(dir string) tn.Tool ``` `MemoryTool` returns a single `memory` tool with three actions — `add`, `replace`, `remove` — over two files in `dir`: `MEMORY.md` (`target: "self"`, the default) and `USER.md` (`target: "user"`). Every action **writes to disk** immediately. A `replace`/`remove` whose `text` substring is not found in the target file is a loud `IsError: true`. Crucially, writing does **not** change the current session’s soul — it takes effect at the **start of the next session**, when [`ComposeSoul`](/api/go/persona/compose-soul/) re-reads the directory. This is the frozen-snapshot rule that keeps a long-lived persona cache-stable within a run. ## When to use it [Section titled “When to use it”](#when-to-use-it) Reach for `MemoryTool` directly when you’re assembling a persona’s tool list by hand (via [`agents.New`](/api/go/runtime/agent/) rather than [`FromDir`](/api/go/persona/from-dir/)) and want the same file-backed memory builtin `FromDir` wires automatically. It’s also the tool to point at when writing a `HEARTBEAT.md` that asks the agent to persist facts — “fold notes into `MEMORY.md` via the memory tool” is the entire recipe behind dream/consolidation patterns. ## Why this and not the alternative [Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative) Not one of the §4A default builtins `memory` is deliberately **not** included in a plain `CreateToolkit`’s default tool set — it only exists for a home-wired persona, because it needs a directory to write to. If you want a persona but no durable memory (a read-only agent), use [`FromDir`](/api/go/persona/from-dir/) with `Memory: &false` rather than wiring `MemoryTool` and then trying to suppress it — there is no separate “off” flag on the tool itself, only on whether it’s added. ## Examples [Section titled “Examples”](#examples) ### 1. The smallest useful call — add an entry, read it back from disk [Section titled “1. The smallest useful call — add an entry, read it back from disk”](#1-the-smallest-useful-call--add-an-entry-read-it-back-from-disk) ```go package main import ( "fmt" "log" "os" "path/filepath" "github.com/muthuishere/toolnexus/golang/agents" ) func main() { dir, err := os.MkdirTemp("", "memtool-*") if err != nil { log.Fatal(err) } defer os.RemoveAll(dir) mem := agents.MemoryTool(dir) if mem.Name != "memory" { log.Fatalf("expected tool name memory, got %q", mem.Name) } res, err := mem.Execute(map[string]any{"action": "add", "text": "prefers metric units"}, nil) if err != nil { log.Fatal(err) } if res.IsError { log.Fatalf("unexpected error: %s", res.Output) } onDisk, err := os.ReadFile(filepath.Join(dir, "MEMORY.md")) if err != nil { log.Fatal(err) } if got := string(onDisk); got != "- prefers metric units\n" { log.Fatalf("unexpected MEMORY.md contents: %q", got) } fmt.Println("ok:", res.Output) } ``` ### 2. The realistic case — replace, remove, and the loud miss [Section titled “2. The realistic case — replace, remove, and the loud miss”](#2-the-realistic-case--replace-remove-and-the-loud-miss) `replace`/`remove` on text that isn’t present is a real `IsError`, not a silent no-op — the model sees the miss and can retry with the right substring. ```go package main import ( "fmt" "log" "os" "path/filepath" "github.com/muthuishere/toolnexus/golang/agents" ) func main() { dir, err := os.MkdirTemp("", "memtool-*") if err != nil { log.Fatal(err) } defer os.RemoveAll(dir) mem := agents.MemoryTool(dir) if _, err := mem.Execute(map[string]any{"action": "add", "text": "timezone: UTC"}, nil); err != nil { log.Fatal(err) } replaced, err := mem.Execute(map[string]any{"action": "replace", "text": "UTC", "with": "IST"}, nil) if err != nil || replaced.IsError { log.Fatalf("unexpected replace failure: %+v err=%v", replaced, err) } onDisk, _ := os.ReadFile(filepath.Join(dir, "MEMORY.md")) if got := string(onDisk); got != "- timezone: IST\n" { log.Fatalf("unexpected MEMORY.md after replace: %q", got) } miss, err := mem.Execute(map[string]any{"action": "remove", "text": "nonexistent phrase"}, nil) if err != nil { log.Fatal(err) } if !miss.IsError { log.Fatal("expected a loud IsError on a remove miss") } fmt.Println("ok: replace ->", string(onDisk), "| miss ->", miss.Output) } ``` ### 3. The full surface — `target: "user"` writes USER.md, and next-session-only semantics [Section titled “3. The full surface — target: "user" writes USER.md, and next-session-only semantics”](#3-the-full-surface--target-user-writes-usermd-and-next-session-only-semantics) Writing memory during a run does not retroactively change that run’s soul — a fresh `ComposeSoul` call (the next session) is what picks it up. This example writes memory mid-run, then shows `ComposeSoul` only sees it on a subsequent read. ```go package main import ( "fmt" "log" "os" "path/filepath" "strings" "github.com/muthuishere/toolnexus/golang/agents" ) func main() { dir, err := os.MkdirTemp("", "memtool-*") if err != nil { log.Fatal(err) } defer os.RemoveAll(dir) if err := os.WriteFile(filepath.Join(dir, "SOUL.md"), []byte("You are Kip."), 0o644); err != nil { log.Fatal(err) } // Session 1: compose the soul BEFORE any memory write — MEMORY.md does not exist yet. soulBefore, foundBefore := agents.ComposeSoul(dir) for _, f := range foundBefore { if f == "MEMORY.md" { log.Fatal("MEMORY.md should not exist before any write") } } mem := agents.MemoryTool(dir) if _, err := mem.Execute(map[string]any{"action": "add", "target": "user", "text": "name is Muthu"}, nil); err != nil { log.Fatal(err) } if _, err := mem.Execute(map[string]any{"action": "add", "text": "always reply tersely"}, nil); err != nil { log.Fatal(err) } // The write does NOT retroactively change soulBefore — it's a frozen snapshot. if strings.Contains(soulBefore, "MEMORY.md") { log.Fatal("session-1 soul must not gain a MEMORY.md section after a later write") } userOnDisk, _ := os.ReadFile(filepath.Join(dir, "USER.md")) if string(userOnDisk) != "- name is Muthu\n" { log.Fatalf("unexpected USER.md: %q", userOnDisk) } // Session 2: composing again (a fresh ComposeSoul call) picks up both new files. soulAfter, foundAfter := agents.ComposeSoul(dir) if len(foundAfter) != 3 { // SOUL.md, USER.md, MEMORY.md log.Fatalf("expected 3 sections next session, got %v", foundAfter) } if !strings.Contains(soulAfter, "always reply tersely") || !strings.Contains(soulAfter, "name is Muthu") { log.Fatal("expected both memory writes to surface in the next session's composed soul") } fmt.Println("ok: memory writes land on disk immediately, load next session:", foundAfter) } ``` ## Actions [Section titled “Actions”](#actions) | `action` | Effect | Target file (`target`) | | --------- | -------------------------------------------------------------------------------- | -------------------------------------------------- | | `add` | Appends `"- \n"` to the file. | `self` ⇒ `MEMORY.md` (default); `user` ⇒ `USER.md` | | `replace` | Swaps the first occurrence of `text` with `with`. `IsError` if `text` is absent. | same | | `remove` | Deletes the first occurrence of `text`. `IsError` if `text` is absent. | same | ## See also [Section titled “See also”](#see-also) * [`ComposeSoul`](/api/go/persona/compose-soul/) — Build a persona’s system prompt from its home directory: identity, memory, skills. * [`FromDir`](/api/go/persona/from-dir/) — Point at an agent home directory and get a configured agent back — wires this tool for you. # agents.New — a composable sub-agent > Define a sub-agent with its own toolkit, prompt and budget, callable as a tool by its parent. — Go. **Go** · package `github.com/muthuishere/toolnexus/golang/agents` · [SPEC §7D](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [golang/agents/agent.go](https://github.com/muthuishere/toolnexus/blob/main/golang/agents/agent.go) ```go func New(name string, spec Spec) *Agent type Spec struct { Does string // routing description the delegating model sees Tools []tn.Tool // the toolkit VIEW for this agent — scoping is the security model Soul string // system prompt, inline SoulFile string // system prompt, from a file (wins over Soul if readable) Team []*Agent // delegation targets — no Team ⇒ no task tool Budget *Budget Model string // "" ⇒ "inherit" WaitFor func(tn.Request) (tn.Answer, error) // OnSpawn, OnClose, Hooks, OnMetric — see SPEC §7D } func (a *Agent) Run(opts Options, prompt string) (TaskResult, *Runtime) func (a *Agent) AsTool(opts Options) tn.Tool ``` `agents.New` is the SPEC §7D axiom made concrete: **an Agent is a Tool** — identity (`Soul`) × a filtered toolkit view (`Tools`) × the shipped client loop, invocable either as a one-shot (`Run`) or, via `AsTool`, dropped straight into a classic toolkit’s `ExtraTools`. Distinct from agents/agent — this one never leaves the process [`agents/agent`](/api/go/agents/agent/) (§7A) calls a REMOTE peer over HTTP/JSON-RPC — a URL, a poll loop, a network boundary. `agents.New` (§7D, this page) composes a LOCAL sub-agent — its own soul, its own scoped tools, its own budget — that runs in-process against the [`Runtime`](/api/go/runtime/runtime/) and never makes an HTTP call to reach its parent. They happen to share the word “agent”; the mechanisms are unrelated. ## When to use it [Section titled “When to use it”](#when-to-use-it) Reach for `agents.New` to build a coding-agent-style team — a coordinator that delegates focused subtasks to specialists, each scoped to only the tools and soul it needs — without standing up a server, a URL, or a protocol. Use `Run` for a one-shot; use `AsTool` to make the agent callable from an ordinary `Client.Run` loop, exactly like any other tool. ## Why this and not the alternative [Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative) Need more than one Run, or fine control over spawn/wake/wait? `Agent.Run` builds a fresh [`Runtime`](/api/go/runtime/runtime/) and tears it down (unless the run suspends). Reach for [`NewRuntime`](/api/go/runtime/runtime/) directly — with `Agent.Registry()` — when you need to keep multiple handles alive, fan work out concurrently, or drive the six host verbs (`Spawn`/`Post`/`Wake`/`Wait`/`Interrupt`/`Close`) yourself. ## Examples [Section titled “Examples”](#examples) All three use a scripted `http.RoundTripper` in place of a real model — the runtime’s documented hermetic seam (`Options.Transport`), the same mechanism the port’s own tests use. No network call is ever made, no real API key required. ### 1. The smallest useful call — one agent, one answer [Section titled “1. The smallest useful call — one agent, one answer”](#1-the-smallest-useful-call--one-agent-one-answer) ```go package main import ( "bytes" "encoding/json" "fmt" "io" "log" "net/http" "github.com/muthuishere/toolnexus/golang/agents" ) // textLLM answers every request with the same fixed text — no network involved. type textLLM struct{ reply string } func (t textLLM) RoundTrip(req *http.Request) (*http.Response, error) { body, _ := json.Marshal(map[string]any{ "choices": []any{map[string]any{"message": map[string]any{"role": "assistant", "content": t.reply}}}, "usage": map[string]any{"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2}, }) return &http.Response{ StatusCode: 200, Header: http.Header{"Content-Type": []string{"application/json"}}, Body: io.NopCloser(bytes.NewReader(body)), }, nil } func main() { greeter := agents.New("greeter", agents.Spec{Does: "says hello", Model: "m-greeter"}) r, _ := greeter.Run(agents.Options{Transport: textLLM{reply: "hello!"}}, "say hi") if r.Status != "done" || r.Text != "hello!" { log.Fatalf("unexpected: %+v", r) } fmt.Println("ok:", r.Text) } ``` ### 2. The realistic case — a coordinator delegating to a teammate [Section titled “2. The realistic case — a coordinator delegating to a teammate”](#2-the-realistic-case--a-coordinator-delegating-to-a-teammate) `Team` is what makes delegation possible: a def with a non-empty `Team` gets an auto-added `task` tool scoped to exactly those names (see [`runtime/task-tool`](/api/go/runtime/task-tool/)). ```go package main import ( "bytes" "encoding/json" "fmt" "io" "log" "net/http" "strings" "github.com/muthuishere/toolnexus/golang/agents" ) func openaiResp(msg map[string]any) *http.Response { full := map[string]any{"role": "assistant"} for k, v := range msg { full[k] = v } b, _ := json.Marshal(map[string]any{ "choices": []any{map[string]any{"message": full}}, "usage": map[string]any{"prompt_tokens": 5, "completion_tokens": 5, "total_tokens": 10}, }) return &http.Response{StatusCode: 200, Header: http.Header{"Content-Type": []string{"application/json"}}, Body: io.NopCloser(bytes.NewReader(b))} } // teamLLM scripts BOTH agents by model name — the runtime's turn gate keeps // this hermetic regardless of which agent is "in flight". type teamLLM struct{} func (teamLLM) RoundTrip(req *http.Request) (*http.Response, error) { b, _ := io.ReadAll(req.Body) var parsed struct { Model string `json:"model"` Messages []map[string]any `json:"messages"` } _ = json.Unmarshal(b, &parsed) sawToolResult := false for _, m := range parsed.Messages { if m["role"] == "tool" { sawToolResult = true } } switch parsed.Model { case "m-coordinator": if !sawToolResult { args, _ := json.Marshal(map[string]any{"agent": "explorer", "prompt": "find the answer"}) return openaiResp(map[string]any{"content": nil, "tool_calls": []any{ map[string]any{"id": "c1", "type": "function", "function": map[string]any{"name": "task", "arguments": string(args)}}, }}), nil } return openaiResp(map[string]any{"content": "delegation complete"}), nil case "m-explorer": return openaiResp(map[string]any{"content": "found it"}), nil default: return openaiResp(map[string]any{"content": "ok"}), nil } } func main() { explorer := agents.New("explorer", agents.Spec{Does: "read-only research", Model: "m-explorer"}) coordinator := agents.New("coordinator", agents.Spec{ Does: "splits work and delegates", Model: "m-coordinator", Team: []*agents.Agent{explorer}, }) r, _ := coordinator.Run(agents.Options{Transport: teamLLM{}}, "answer the question") if r.Status != "done" || !strings.Contains(r.Text, "delegation complete") { log.Fatalf("unexpected: %+v", r) } fmt.Println("ok:", r.Text) } ``` ### 3. The full surface — `AsTool` bridging into the classic client loop [Section titled “3. The full surface — AsTool bridging into the classic client loop”](#3-the-full-surface--astool-bridging-into-the-classic-client-loop) `AsTool` is the axiom’s other direction: an `Agent` dropped into an ordinary toolkit’s `ExtraTools`, invisible from an LLM’s point of view — the model just sees one more tool named `explorer`. ```go package main import ( "bytes" "context" "encoding/json" "fmt" "io" "log" "net/http" "net/http/httptest" toolnexus "github.com/muthuishere/toolnexus/golang" "github.com/muthuishere/toolnexus/golang/agents" ) // subLLM answers the inner agent's own model directly, no tool calls needed. type subLLM struct{} func (subLLM) RoundTrip(req *http.Request) (*http.Response, error) { b, _ := json.Marshal(map[string]any{ "choices": []any{map[string]any{"message": map[string]any{"role": "assistant", "content": "the answer is 42"}}}, "usage": map[string]any{"prompt_tokens": 2, "completion_tokens": 2, "total_tokens": 4}, }) return &http.Response{StatusCode: 200, Header: http.Header{"Content-Type": []string{"application/json"}}, Body: io.NopCloser(bytes.NewReader(b))}, nil } func main() { explorer := agents.New("explorer", agents.Spec{Does: "finds facts", Model: "m-explorer"}) explorerTool := explorer.AsTool(agents.Options{Transport: subLLM{}}) // The TOP-LEVEL model/loop is a completely separate, ordinary httptest stub // (same pattern as Client.Run) — it just sees "explorer" as one more tool. turn := 0 topLLM := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { turn++ w.Header().Set("Content-Type", "application/json") if turn == 1 { _, _ = w.Write([]byte(`{"choices":[{"message":{"role":"assistant","content":null,"tool_calls":[{"id":"c1","type":"function","function":{"name":"explorer","arguments":"{\"prompt\":\"what is the answer?\"}"}}]}}],"usage":{"prompt_tokens":5,"completion_tokens":5,"total_tokens":10}}`)) return } _, _ = w.Write([]byte(`{"choices":[{"message":{"role":"assistant","content":"done"}}],"usage":{"prompt_tokens":5,"completion_tokens":2,"total_tokens":7}}`)) })) defer topLLM.Close() tk, err := toolnexus.CreateToolkit(context.Background(), toolnexus.Options{ Builtins: false, ExtraTools: []toolnexus.Tool{explorerTool}, }) if err != nil { log.Fatal(err) } defer tk.Close() client := toolnexus.CreateClient(toolnexus.ClientOptions{BaseURL: topLLM.URL, Style: toolnexus.StyleOpenAI, Model: "gpt-4o-mini", APIKey: "k"}) res, err := client.Run(context.Background(), "what is the answer?", tk) if err != nil { log.Fatal(err) } if len(res.ToolCalls) != 1 || res.ToolCalls[0].Name != "explorer" { log.Fatalf("expected the top loop to call explorer as a plain tool, got %+v", res.ToolCalls) } if res.ToolCalls[0].Output != "the answer is 42" { log.Fatalf("unexpected sub-agent output: %q", res.ToolCalls[0].Output) } if res.ToolCalls[0].Metadata["agent"] != "explorer" { log.Fatalf("expected metadata.agent = explorer, got %v", res.ToolCalls[0].Metadata) } fmt.Println("ok:", res.Text, "via sub-agent output:", res.ToolCalls[0].Output) } ``` ## `AsToolWith` — a subagent under the parent’s gates [Section titled “AsToolWith — a subagent under the parent’s gates”](#astoolwith--a-subagent-under-the-parents-gates) ```go func (a *Agent) AsTool(opts Options) tn.Tool func (a *Agent) AsToolWith(per func(*tn.ToolContext) Options) tn.Tool ``` `AsTool` stops the child run when the calling turn is cancelled (the tool returns a cancelled error result). `AsToolWith` builds the runtime `Options` per call from the calling tool’s context, so the child runs under the **same** gates as the parent turn: `Options.Hooks` (the same `BeforeTool` permission gate — it also sees the child’s tool calls and results, and `AfterLLM` its replies, enough for a nested UI row), `Options.WaitFor` (answers a child’s suspension when no agent on its chain has its own `WaitFor`) and `OnMetric`. A `Def.Hooks` on the agent replaces `Options.Hooks`, so leave it unset when the gate must hold. There is no token-by-token stream of the child’s text. ```go gate := &tn.Hooks{BeforeTool: parentGate} tool := child.AsToolWith(func(tctx *tn.ToolContext) agents.Options { return agents.Options{Hooks: gate, WaitFor: askTheUser} // tctx.CallID names the parent row }) ``` ## Spec fields [Section titled “Spec fields”](#spec-fields) | Field | Type | What it does | | ------------------- | ------------------------------------- | --------------------------------------------------------------------------------------------- | | `Does` | `string` | The routing description the delegating model (or a parent’s `task` tool) sees. | | `Tools` | `[]tn.Tool` | The scoped toolkit view — scoping IS the security model; builtins are never added implicitly. | | `Soul` / `SoulFile` | `string` | The system prompt, inline or from a file (`SoulFile` wins when both are set and readable). | | `Team` | `[]*Agent` | Delegation targets. Empty ⇒ no `task` tool — recursion is opt-in. | | `Budget` | `*Budget` | Caps this agent’s subtree — see [`agents.Budget`](/api/go/runtime/budgets/). | | `Model` | `string` | `""` ⇒ `"inherit"` (the runtime’s LLM default). | | `WaitFor` | `func(tn.Request) (tn.Answer, error)` | This agent’s §10 interpreter authority for its subtree. | `Agent.Registry()` collects the agent plus the TRANSITIVE CLOSURE of its team graph — unreachable agents are never present. ## See also [Section titled “See also”](#see-also) * [`NewRuntime`](/api/go/runtime/runtime/) — The six host verbs that drive sub-agents, plus the read-only list and inspect views. * [`agents.Handle`](/api/go/runtime/handle/) — The state machine for one spawned agent: pending, running, suspended, done. * [`agents.Budget`](/api/go/runtime/budgets/) — Cap tool calls and wall-clock per agent and per team, enforced while the run is in flight. * [`agents.New` (§7A, remote)](/api/go/agents/agent/) — the REMOTE counterpart: call a peer over HTTP instead of composing one in-process. # agents.Budget — hierarchical, live-enforced > Cap tool calls and wall-clock per agent and per team, enforced while the run is in flight. — Go. **Go** · package `github.com/muthuishere/toolnexus/golang/agents` · [SPEC §7D](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [golang/agents/runtime.go](https://github.com/muthuishere/toolnexus/blob/main/golang/agents/runtime.go) ```go type Budget struct { MaxTurns int // LLM round trips per Run (default 6) MaxTokens int64 // token pool for this subtree MaxToolCalls int64 // tool-call pool for this subtree MaxWall time.Duration // wall-clock lifetime from spawn (SPEC field: maxWallMs) MaxChildren int // direct children cap (checked at spawn) MaxConcurrent int // simultaneously running children (default 8) MaxDepth int // subtree depth cap (checked at spawn, default 3) } ``` A `Budget` caps one handle’s whole subtree. Pooled fields (`MaxTokens`, `MaxToolCalls`) are **carved** at spawn — `effective = min(own, parent's remaining)` — then enforced with a **live** walk up the ancestor chain before every turn and every spawn, because carving alone misses spend a sibling racked up afterward. Money is deliberately excluded: usage is vendor-neutral (tokens, tool calls, turns, wall time); convert cost in your own accounting over the roll-up. ## When to use it [Section titled “When to use it”](#when-to-use-it) Set a `Budget` on any `agents.Spec` or pass one to `Spawn` whenever a sub-agent’s work is open-ended and you want a hard, structural ceiling — not a prompt asking it to be frugal. Every limit stop is **loud**: `Status: "incomplete"` naming the limit, never a silent `"done"`, never a crash — partial work and the transcript are preserved either way. ## Why this and not the alternative [Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative) Want the model to see its own limit and self-regulate? `Budget` is enforced by the RUNTIME — the model never sees it and can’t negotiate around it. If you want the model aware of a soft limit (so it can wrap up gracefully), say so in the agent’s `Soul`; `Budget` is the backstop for when it doesn’t. ## Examples [Section titled “Examples”](#examples) ### 1. `MaxTurns` — a model that never stops calling tools [Section titled “1. MaxTurns — a model that never stops calling tools”](#1-maxturns--a-model-that-never-stops-calling-tools) ```go package main import ( "bytes" "encoding/json" "fmt" "io" "log" "net/http" tn "github.com/muthuishere/toolnexus/golang" "github.com/muthuishere/toolnexus/golang/agents" ) var lookup = tn.Tool{ Name: "lookup", Description: "looks something up", Source: tn.SourceCustom, InputSchema: tn.JSONSchema{"type": "object", "properties": map[string]any{}}, Execute: func(_ map[string]any, _ *tn.ToolContext) (tn.ToolResult, error) { return tn.ToolResult{Output: "data"}, nil }, } // loopLLM ALWAYS wants another tool call — it never produces a final answer. type loopLLM struct{} func (loopLLM) RoundTrip(req *http.Request) (*http.Response, error) { body, _ := json.Marshal(map[string]any{ "choices": []any{map[string]any{"message": map[string]any{"role": "assistant", "content": nil, "tool_calls": []any{ map[string]any{"id": "c", "type": "function", "function": map[string]any{"name": "lookup", "arguments": "{}"}}, }}}}, "usage": map[string]any{"prompt_tokens": 2, "completion_tokens": 2, "total_tokens": 4}, }) return &http.Response{StatusCode: 200, Header: http.Header{"Content-Type": []string{"application/json"}}, Body: io.NopCloser(bytes.NewReader(body))}, nil } func main() { reg := map[string]agents.Def{ "looper": {Name: "looper", Does: "never finishes", Model: "m-loop", Tools: []tn.Tool{lookup}, Budget: &agents.Budget{MaxTurns: 3}}, } rt := agents.NewRuntime(agents.Options{Transport: loopLLM{}, Registry: reg}) h, err := rt.Spawn(rt.Root, "looper", nil) if err != nil { log.Fatal(err) } rt.Wake(h, "loop forever") r := rt.Wait(h, 0) if r.Status != "incomplete" { log.Fatalf("maxTurns cap must be a LOUD incomplete, not a silent done, got %q", r.Status) } fmt.Println("ok: stopped", r.Status, "after", r.Turns, "turns (MaxTurns=3)") } ``` ### 2. `MaxTokens` — carved at spawn, then drained by the live roll-up [Section titled “2. MaxTokens — carved at spawn, then drained by the live roll-up”](#2-maxtokens--carved-at-spawn-then-drained-by-the-live-roll-up) ```go package main import ( "bytes" "encoding/json" "fmt" "io" "log" "net/http" "github.com/muthuishere/toolnexus/golang/agents" ) type textLLM struct{} func (textLLM) RoundTrip(req *http.Request) (*http.Response, error) { body, _ := json.Marshal(map[string]any{ "choices": []any{map[string]any{"message": map[string]any{"role": "assistant", "content": "ok"}}}, "usage": map[string]any{"prompt_tokens": 20, "completion_tokens": 20, "total_tokens": 40}, }) return &http.Response{StatusCode: 200, Header: http.Header{"Content-Type": []string{"application/json"}}, Body: io.NopCloser(bytes.NewReader(body))}, nil } func main() { reg := map[string]agents.Def{"explore": {Name: "explore", Does: "research", Model: "m-explore"}} rt := agents.NewRuntime(agents.Options{Transport: textLLM{}, Registry: reg}) // Parent asks for 100; child asks for 500 — effective = min(100, 500) = 100. parent, err := rt.Spawn(rt.Root, "explore", &agents.Budget{MaxTokens: 100}) if err != nil { log.Fatal(err) } child, err := rt.Spawn(parent, "explore", &agents.Budget{MaxTokens: 500}) if err != nil { log.Fatal(err) } if got := rt.PoolTokens(child); got != 100 { log.Fatalf("carve: expected effective pool 100, got %d", got) } rt.Wake(child, "go") rt.Wait(child, 0) // spends 40 tokens if got := rt.PoolTokens(parent); got != 60 { log.Fatalf("the roll-up should drain the PARENT pool too, got %d, want 60", got) } fmt.Println("ok: child pool carved to 100; parent pool now", rt.PoolTokens(parent)) } ``` ### 3. The full surface — `MaxToolCalls`, `MaxChildren`, `MaxDepth` all refuse loudly, naming the limit [Section titled “3. The full surface — MaxToolCalls, MaxChildren, MaxDepth all refuse loudly, naming the limit”](#3-the-full-surface--maxtoolcalls-maxchildren-maxdepth-all-refuse-loudly-naming-the-limit) ```go package main import ( "bytes" "encoding/json" "fmt" "io" "log" "net/http" "strings" tn "github.com/muthuishere/toolnexus/golang" "github.com/muthuishere/toolnexus/golang/agents" ) var lookup = tn.Tool{ Name: "lookup", Description: "looks something up", Source: tn.SourceCustom, InputSchema: tn.JSONSchema{"type": "object", "properties": map[string]any{}}, Execute: func(_ map[string]any, _ *tn.ToolContext) (tn.ToolResult, error) { return tn.ToolResult{Output: "data"}, nil }, } // oneShotLLM calls lookup on the first turn only, then answers. type oneShotLLM struct{} func (oneShotLLM) RoundTrip(req *http.Request) (*http.Response, error) { b, _ := io.ReadAll(req.Body) var parsed struct { Messages []map[string]any `json:"messages"` } _ = json.Unmarshal(b, &parsed) sawToolResult := false for _, m := range parsed.Messages { if m["role"] == "tool" { sawToolResult = true } } var msg map[string]any if !sawToolResult { msg = map[string]any{"role": "assistant", "content": nil, "tool_calls": []any{ map[string]any{"id": "c", "type": "function", "function": map[string]any{"name": "lookup", "arguments": "{}"}}, }} } else { msg = map[string]any{"role": "assistant", "content": "done"} } body, _ := json.Marshal(map[string]any{"choices": []any{map[string]any{"message": msg}}, "usage": map[string]any{"prompt_tokens": 5, "completion_tokens": 5, "total_tokens": 10}}) return &http.Response{StatusCode: 200, Header: http.Header{"Content-Type": []string{"application/json"}}, Body: io.NopCloser(bytes.NewReader(body))}, nil } func main() { reg := map[string]agents.Def{ "explore": {Name: "explore", Does: "research", Model: "m-explore", Tools: []tn.Tool{lookup}}, "peer": {Name: "peer", Does: "a worker", Model: "m-peer"}, } rt := agents.NewRuntime(agents.Options{Transport: oneShotLLM{}, Registry: reg}) // MaxToolCalls: the pool is spent by the FIRST run; the second refuses. h, _ := rt.Spawn(rt.Root, "explore", &agents.Budget{MaxToolCalls: 1}) if r := rt.RunTurn(h, "go"); r.Status != "done" { log.Fatalf("first run (one lookup) should pass, got %q", r.Status) } r2 := rt.RunTurn(h, "go again") if r2.Status != "incomplete" || !strings.Contains(r2.Text, "toolCalls") { log.Fatalf("expected a loud incomplete naming toolCalls, got %q (%s)", r2.Status, r2.Text) } // MaxChildren: the third spawn under a 2-child cap is refused, naming the limit. capped, _ := rt.Spawn(rt.Root, "peer", &agents.Budget{MaxChildren: 2}) _, _ = rt.Spawn(capped, "peer", nil) _, _ = rt.Spawn(capped, "peer", nil) if _, err := rt.Spawn(capped, "peer", nil); err == nil || !strings.Contains(err.Error(), "maxChildren") { log.Fatalf("expected a maxChildren refusal, got %v", err) } // MaxDepth: three spawns deep is fine; a fourth is refused, naming the limit. deep := rt.Root var err error for i := 0; i < 3; i++ { deep, err = rt.Spawn(deep, "peer", nil) if err != nil { log.Fatalf("spawn %d within depth should pass: %v", i, err) } } if _, err = rt.Spawn(deep, "peer", nil); err == nil || !strings.Contains(err.Error(), "maxDepth") { log.Fatalf("expected a maxDepth refusal, got %v", err) } fmt.Println("ok: toolCalls, maxChildren, and maxDepth all refused loudly, naming the limit") } ``` ## Fields and defaults [Section titled “Fields and defaults”](#fields-and-defaults) | Field | Type | Default | Checked | | --------------- | --------------- | --------- | ------------------------------------------------------------ | | `MaxTurns` | `int` | 6 | Per turn, inside the client loop. | | `MaxTokens` | `int64` | unlimited | Carved at spawn; live ancestor walk before every turn/spawn. | | `MaxToolCalls` | `int64` | unlimited | Same carve + live walk as `MaxTokens`. | | `MaxWall` | `time.Duration` | unlimited | Live ancestor walk, measured against the runtime `Clock`. | | `MaxChildren` | `int` | unlimited | At `Spawn`. | | `MaxConcurrent` | `int` | 8 | At `Wake` (the concurrency gate; excess wakes queue FIFO). | | `MaxDepth` | `int` | 3 | At `Spawn`. | Any limit stop ⇒ `TaskResult{Status: "incomplete", ...}` with the limit named in `Text` — never silent, never a crash. Turn-level and spawn-level refusals are always immediate; pooled resources apply to the WHOLE subtree, not just the handle they were set on. ## See also [Section titled “See also”](#see-also) * [`agents.New`](/api/go/runtime/agent/) — Define a sub-agent with its own toolkit, prompt and budget, callable as a tool by its parent. * [`NewRuntime`](/api/go/runtime/runtime/) — The six host verbs that drive sub-agents, plus the read-only list and inspect views. * [`agents.Handle`](/api/go/runtime/handle/) — The state machine for one spawned agent: pending, running, suspended, done. # agents.Handle — a running sub-agent > The state machine for one spawned agent: pending, running, suspended, done. — Go. **Go** · package `github.com/muthuishere/toolnexus/golang/agents` · [SPEC §7D](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [golang/agents/runtime.go](https://github.com/muthuishere/toolnexus/blob/main/golang/agents/runtime.go) ```go type State string const ( StateIdle State = "idle" StateRunning State = "running" StateSuspended State = "suspended" StateClosed State = "closed" ) // states: idle → running → (idle | suspended | closed) // suspended → running ONLY via the Answer to its pending Request type Handle struct { ID string // deterministic, parent-scoped: root/coordinator.1/explore.2 // … everything else is runtime-owned; read it through the Runtime, below } func (rt *Runtime) StateOf(h *Handle) State func (rt *Runtime) Inspect(h *Handle) HandleView func (rt *Runtime) InboxLen(h *Handle) int func (rt *Runtime) PoolTokens(h *Handle) int64 func (rt *Runtime) UsageTokens(h *Handle) int ``` `Handle` is `{id, def, state, inbox, budget, children}` — SPEC §7D’s exact shape. Every field past `ID` is unexported and mutated under the runtime’s own mutex; you never poke a `Handle` directly. Instead, the `Runtime` that spawned it exposes race-safe read views: `StateOf` for the raw transition state, `Inspect`/`List` for the fuller `HandleView` (tokens, turns, inbox depth, the pending Request’s kind when suspended). ## When to use it [Section titled “When to use it”](#when-to-use-it) Read a `Handle`’s state to build observability (a dashboard of running sub-agents), to poll for a suspension before deciding whether to answer it, or to assert on transition traces in tests. You obtain a `Handle` from [`Spawn`](/api/go/runtime/runtime/) — never construct one yourself. ## Why this and not the alternative [Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative) Handles are capabilities, not a registry There’s no global “list all handles anywhere” — `Runtime.List()` walks only the handles under `Root` for THAT runtime, and a `Handle` value itself is meaningless outside the `Runtime` that returned it (SPEC §7D: “post/wake what you hold, wait only what you spawned”). Reach for [`agents.New`](/api/go/runtime/agent/) if what you actually want is to CREATE and run an agent, not inspect one that already exists. ## Examples [Section titled “Examples”](#examples) ### 1. `idle` right after spawn, before any wake [Section titled “1. idle right after spawn, before any wake”](#1-idle-right-after-spawn-before-any-wake) ```go package main import ( "fmt" "log" "github.com/muthuishere/toolnexus/golang/agents" ) func main() { reg := map[string]agents.Def{"peer": {Name: "peer", Does: "a worker", Model: "m-peer"}} rt := agents.NewRuntime(agents.Options{Registry: reg}) // no wake in this example — Transport unused h, err := rt.Spawn(rt.Root, "peer", nil) if err != nil { log.Fatal(err) } if rt.StateOf(h) != agents.StateIdle { log.Fatalf("expected idle right after spawn, got %s", rt.StateOf(h)) } v := rt.Inspect(h) if v.State != agents.StateIdle || v.Turns != 0 || v.PendingKind != "" { log.Fatalf("unexpected view: %+v", v) } fmt.Println("ok:", h.ID, "is", v.State) } ``` ### 2. `idle → running → idle (done)`, observed live [Section titled “2. idle → running → idle (done), observed live”](#2-idle--running--idle-done-observed-live) ```go package main import ( "bytes" "encoding/json" "fmt" "io" "log" "net/http" "time" "github.com/muthuishere/toolnexus/golang/agents" ) // gatedLLM blocks until released, so the example can OBSERVE the running state // before the turn completes. type gatedLLM struct{ gate chan struct{} } func (g gatedLLM) RoundTrip(req *http.Request) (*http.Response, error) { <-g.gate body, _ := json.Marshal(map[string]any{ "choices": []any{map[string]any{"message": map[string]any{"role": "assistant", "content": "finished"}}}, "usage": map[string]any{"prompt_tokens": 3, "completion_tokens": 3, "total_tokens": 6}, }) return &http.Response{StatusCode: 200, Header: http.Header{"Content-Type": []string{"application/json"}}, Body: io.NopCloser(bytes.NewReader(body))}, nil } func main() { reg := map[string]agents.Def{"worker": {Name: "worker", Does: "a worker", Model: "m-worker"}} gate := make(chan struct{}) rt := agents.NewRuntime(agents.Options{Transport: gatedLLM{gate: gate}, Registry: reg}) h, err := rt.Spawn(rt.Root, "worker", nil) if err != nil { log.Fatal(err) } rt.Wake(h, "go") for i := 0; i < 200 && rt.StateOf(h) != agents.StateRunning; i++ { time.Sleep(time.Millisecond) } if rt.StateOf(h) != agents.StateRunning { log.Fatal("expected to observe the running state before completion") } close(gate) // let the turn finish r := rt.Wait(h, 0) if r.Status != "done" || rt.StateOf(h) != agents.StateIdle { log.Fatalf("unexpected: status=%q state=%s", r.Status, rt.StateOf(h)) } v := rt.Inspect(h) if v.Turns != 1 || v.Tokens != 6 { log.Fatalf("unexpected view after done: %+v", v) } fmt.Println("ok: idle -> running -> idle, turns =", v.Turns, "tokens =", v.Tokens) } ``` ### 3. The full surface — `suspended`, `PendingKind`, and durable `Resume` [Section titled “3. The full surface — suspended, PendingKind, and durable Resume”](#3-the-full-surface--suspended-pendingkind-and-durable-resume) No `WaitFor` anywhere in the chain ⇒ the suspension goes durable: the handle parks in `StateSuspended` and `Runtime.Resume` is the ONLY way back to `running`. ```go package main import ( "bytes" "encoding/json" "fmt" "io" "log" "net/http" tn "github.com/muthuishere/toolnexus/golang" "github.com/muthuishere/toolnexus/golang/agents" ) // checkSecret needs human approval the first time; once ctx.Answer carries a // satisfied Answer, it succeeds. var checkSecret = tn.Tool{ Name: "check_secret", Description: "needs approval", Source: tn.SourceCustom, InputSchema: tn.JSONSchema{"type": "object", "properties": map[string]any{}}, Execute: func(_ map[string]any, tctx *tn.ToolContext) (tn.ToolResult, error) { if tctx != nil && tctx.Answer != nil && tctx.Answer.Ok { return tn.ToolResult{Output: "secret-token"}, nil } return tn.Pending(tn.Request{Kind: "approval", Prompt: "approve secret access?"}), nil }, } type approvalLLM struct{} func (approvalLLM) RoundTrip(req *http.Request) (*http.Response, error) { b, _ := io.ReadAll(req.Body) var parsed struct { Messages []map[string]any `json:"messages"` } _ = json.Unmarshal(b, &parsed) sawToolResult := false for _, m := range parsed.Messages { if m["role"] == "tool" { sawToolResult = true } } var resp map[string]any if !sawToolResult { args, _ := json.Marshal(map[string]any{}) resp = map[string]any{"role": "assistant", "content": nil, "tool_calls": []any{ map[string]any{"id": "c1", "type": "function", "function": map[string]any{"name": "check_secret", "arguments": string(args)}}, }} } else { resp = map[string]any{"role": "assistant", "content": "approved and done"} } body, _ := json.Marshal(map[string]any{ "choices": []any{map[string]any{"message": resp}}, "usage": map[string]any{"prompt_tokens": 3, "completion_tokens": 3, "total_tokens": 6}, }) return &http.Response{StatusCode: 200, Header: http.Header{"Content-Type": []string{"application/json"}}, Body: io.NopCloser(bytes.NewReader(body))}, nil } func main() { reg := map[string]agents.Def{ // No WaitFor on this def, and none higher in the chain: the suspension // goes durable instead of being answered inline. "asker": {Name: "asker", Does: "needs approvals", Model: "m-asker", Tools: []tn.Tool{checkSecret}}, } rt := agents.NewRuntime(agents.Options{Transport: approvalLLM{}, Registry: reg}) h, err := rt.Spawn(rt.Root, "asker", nil) if err != nil { log.Fatal(err) } rt.Wake(h, "get the secret") r := rt.Wait(h, 0) if r.Status != "pending" || rt.StateOf(h) != agents.StateSuspended { log.Fatalf("expected a durable pending, got status=%q state=%s", r.Status, rt.StateOf(h)) } v := rt.Inspect(h) if v.PendingKind != "approval" { log.Fatalf("expected PendingKind=approval, got %q", v.PendingKind) } // The out-of-band Answer resumes the DEEPEST suspended handle, and Resume // hands back the result of the topmost handle the cascade re-ran. final, err := rt.Resume(tn.Answer{ID: r.Pending.ID, Ok: true}) if err != nil { log.Fatal(err) } if final.Status != "done" || rt.StateOf(h) != agents.StateIdle { log.Fatalf("expected done+idle after resume, got status=%q state=%s", final.Status, rt.StateOf(h)) } fmt.Println("ok: suspended (", v.PendingKind, ") -> resumed ->", rt.StateOf(h)) } ``` ## HandleView fields [Section titled “HandleView fields”](#handleview-fields) | Field | Type | What it holds | | ------------- | -------- | ----------------------------------------------------------------- | | `ID` | `string` | The handle’s deterministic id. | | `State` | `State` | `idle` \| `running` \| `suspended` \| `closed`. | | `Tokens` | `int` | Rolled-up token usage of the subtree — the budget ledger. | | `Turns` | `int` | Cumulative LLM round trips. | | `Inbox` | `int` | Current inbox depth. | | `PendingKind` | `string` | The suspended Request’s `Kind`; `""` unless `State == suspended`. | ## See also [Section titled “See also”](#see-also) * [`agents.New`](/api/go/runtime/agent/) — Define a sub-agent with its own toolkit, prompt and budget, callable as a tool by its parent. * [`NewRuntime`](/api/go/runtime/runtime/) — The six host verbs that drive sub-agents, plus the read-only list and inspect views. * [`agents.Budget`](/api/go/runtime/budgets/) — Cap tool calls and wall-clock per agent and per team, enforced while the run is in flight. # 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. — Go. **Go** · package `github.com/muthuishere/toolnexus/golang/agents` · [SPEC §7D](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [golang/agents/loop.go](https://github.com/muthuishere/toolnexus/blob/main/golang/agents/loop.go) ```go func (a *Agent) Loop(opts tn.ClientOptions, tk *tn.Toolkit) *Loop type RunOpts struct { Model string // overrides the agent's model for THIS call only; "" ⇒ the agent's } func (l *Loop) Run(ctx context.Context, prompt string, opts RunOpts) (Outcome, error) func (l *Loop) Status() string // "idle" | "running" | done/incomplete/pending/error func (l *Loop) Turns() int func (l *Loop) Unsupported() []string type Outcome struct { Text string Status string // done | incomplete | pending | error — the SHIPPED vocabulary, no new strings StoppedBy string // named whenever Status != "done" — never a silent stop Attempts int Turns int Result tn.RunResult } type Guardrail func(ev tn.BeforeToolEvent) string // "" or "allow" ⇒ permit; anything else ⇒ deny reason type Completion struct { Verify func(tn.RunResult) (bool, string) // (ok, reason) — never a named Verdict type in Go MaxAttempts int // REQUIRED, >= 1 } func AllTodosDone(r tn.RunResult) (bool, string) // the built-in completion verifier func LoopUnsupported(sp Spec) []string // "tools" | "team" | "waitFor" | "onMetric", stable order ``` `agents.Loop` is the second of the “two doors on one agent” (SPEC §7D): `Agent.Run` is the plain one-shot over a fresh [`Runtime`](/api/go/runtime/runtime/); `Agent.Loop(opts, tk).Run` is the **gated** door — the same agent, driven directly over a `ClientOptions`/`Toolkit` the caller already built, under a `Guardrail` policy on every tool call and a `Completion` gate that decides whether the run is *actually* done rather than just having stopped talking. The placement law the package encodes: `Spec` (the harness) answers “**may** it?” — capability, ceilings, per problem. `RunOpts.Model` answers “with **what**?” — per call. `Loop`/`Outcome` answers “**did** it?” — status, turns, observed. None of them answer “is it **right**?” — that’s what a tool, a skill, or the human reviewing `Outcome.Text` is for. ## When to use it [Section titled “When to use it”](#when-to-use-it) Reach for `agent.Loop(opts, tk)` when you already have a `ClientOptions`/`*Toolkit` pair built by hand — not through the [`agents.Runtime`](/api/go/runtime/runtime/) registry — and want that same `Agent`’s `Guardrails` and `Completion` gate enforced over it, without standing up `Spawn`/`Wake`/ `Wait`. It’s the natural fit for a single top-level agent with no delegation: one conversation, one completion gate, driven turn by turn. ## Why this and not the alternative [Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative) Need delegation, suspension, or a per-agent metric sink? Use the Runtime instead `Loop` is deliberately a driver over a toolkit and client **the caller built**, so four `Spec` fields have nowhere to attach: no tool view to scope (`Tools`), no task tool to delegate through (`Team`), no handle to suspend on (`WaitFor`), and no per-agent sink the caller didn’t already wire in (`OnMetric`). `LoopUnsupported(spec)` reports exactly these four by name rather than silently dropping them or refusing to construct — call it as a pre-flight, and if it returns anything, drive that agent through [`agents.Runtime`](/api/go/runtime/runtime/) (`Agent.Run` or `Spawn`/`Wake`/ `Wait`) instead, where all four are honoured. Go idiom: (bool, string), not a named Verdict type Several sibling ports carry a named `Verdict`/`verdict` type for a completion check’s result (`{ok, reason}`). Go’s `Completion.Verify` returns the plain tuple `(bool, string)` instead — this is a deliberate per-language idiom difference, not a missing feature: Go multi-value returns make a one-off struct for a two-field result unidiomatic. `Completion` itself IS a named struct (unlike the verdict), because it also carries `MaxAttempts`. See the narrative [harness doc](/harness/) for how `harness`/`Loop`/`Completion` fit together across ports; this page covers only the Go API surface. ## Examples [Section titled “Examples”](#examples) All three point `ClientOptions.BaseURL` at a local `httptest.Server` — no real network call, no real API key. ### 1. The smallest useful call — no Completion, one attempt, done [Section titled “1. The smallest useful call — no Completion, one attempt, done”](#1-the-smallest-useful-call--no-completion-one-attempt-done) ```go package main import ( "context" "fmt" "log" "net/http" "net/http/httptest" tn "github.com/muthuishere/toolnexus/golang" "github.com/muthuishere/toolnexus/golang/agents" ) func main() { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"choices":[{"message":{"role":"assistant","content":"hi there"}}],"usage":{"prompt_tokens":2,"completion_tokens":2,"total_tokens":4}}`)) })) defer srv.Close() a := agents.New("plain", agents.Spec{Does: "answers", Soul: "be brief"}) tk, err := tn.CreateToolkit(context.Background(), tn.Options{Builtins: false}) if err != nil { log.Fatal(err) } defer tk.Close() opts := tn.ClientOptions{BaseURL: srv.URL, Style: tn.StyleOpenAI, Model: "m", APIKey: "k"} l := a.Loop(opts, tk) out, err := l.Run(context.Background(), "hello", agents.RunOpts{}) if err != nil { log.Fatal(err) } // Absent Completion ⇒ byte-identical to no gate at all: one attempt, no StoppedBy. if out.Status != "done" || out.Attempts != 1 || out.StoppedBy != "" { log.Fatalf("got status=%s attempts=%d stoppedBy=%q", out.Status, out.Attempts, out.StoppedBy) } if l.Status() != "idle" { log.Fatalf("Loop.Status() after a done run = %q, want idle", l.Status()) } fmt.Println("ok:", out.Status, out.Text) } ``` ### 2. The realistic case — a Completion gate blocks an early claim of “done”, then passes [Section titled “2. The realistic case — a Completion gate blocks an early claim of “done”, then passes”](#2-the-realistic-case--a-completion-gate-blocks-an-early-claim-of-done-then-passes) ```go package main import ( "context" "encoding/json" "fmt" "log" "net/http" "net/http/httptest" tn "github.com/muthuishere/toolnexus/golang" "github.com/muthuishere/toolnexus/golang/agents" ) func main() { todo := func(id, text string, done bool) any { return map[string]any{"id": id, "text": text, "completed": done} } callTodo := func(todos []any) map[string]any { args, _ := json.Marshal(map[string]any{"todos": todos}) return map[string]any{"role": "assistant", "content": nil, "tool_calls": []any{ map[string]any{"id": "t1", "type": "function", "function": map[string]any{"name": "todowrite", "arguments": string(args)}}}} } text := func(s string) map[string]any { return map[string]any{"role": "assistant", "content": s} } // attempt 1: claims done with an open item ⇒ gate rejects; attempt 2: all checked ⇒ gate passes. replies := []map[string]any{ callTodo([]any{todo("1", "draft", true), todo("2", "proofread", false)}), text("claiming done"), callTodo([]any{todo("1", "draft", true), todo("2", "proofread", true)}), text("really done"), } n := 0 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { msg := replies[n] if n < len(replies)-1 { n++ } body, _ := json.Marshal(map[string]any{ "choices": []any{map[string]any{"message": msg}}, "usage": map[string]any{"prompt_tokens": 5, "completion_tokens": 5, "total_tokens": 10}, }) w.Header().Set("Content-Type", "application/json") _, _ = w.Write(body) })) defer srv.Close() tk, err := tn.CreateToolkit(context.Background(), tn.Options{Builtins: tn.BuiltinsConfig{Tools: map[string]bool{ "todowrite": true, "bash": false, "read": false, "write": false, "edit": false, "glob": false, "grep": false, "webfetch": false, "apply_patch": false, "question": false, }}}) if err != nil { log.Fatal(err) } defer tk.Close() a := agents.New("worker", agents.Spec{Does: "works", Completion: &agents.Completion{Verify: agents.AllTodosDone, MaxAttempts: 3}}) opts := tn.ClientOptions{BaseURL: srv.URL, Style: tn.StyleOpenAI, Model: "m", APIKey: "k"} out, err := a.Loop(opts, tk).Run(context.Background(), "do it", agents.RunOpts{}) if err != nil { log.Fatal(err) } if out.Status != "done" { log.Fatalf("status=%s stoppedBy=%q", out.Status, out.StoppedBy) } if out.Attempts != 2 { log.Fatalf("expected the gate to force a 2nd attempt, got %d", out.Attempts) } fmt.Println("ok: done after", out.Attempts, "attempt(s)") } ``` ### 3. The full surface — `LoopUnsupported` names the four fields this door cannot honour [Section titled “3. The full surface — LoopUnsupported names the four fields this door cannot honour”](#3-the-full-surface--loopunsupported-names-the-four-fields-this-door-cannot-honour) ```go package main import ( "fmt" "log" "sort" tn "github.com/muthuishere/toolnexus/golang" "github.com/muthuishere/toolnexus/golang/agents" ) func main() { child := agents.New("helper", agents.Spec{Does: "helps"}) // A Spec that declares Tools, Team, WaitFor and OnMetric — every one of them // requires the agents.Runtime registry to be honoured; Loop cannot carry them. sp := agents.Spec{ Does: "coordinates", Tools: []tn.Tool{{Name: "extra", Description: "d"}}, Team: []*agents.Agent{child}, WaitFor: func(tn.Request) (tn.Answer, error) { return tn.Answer{}, nil }, OnMetric: func(tn.MetricEvent) {}, } missing := agents.LoopUnsupported(sp) sort.Strings(missing) want := []string{"onMetric", "team", "tools", "waitFor"} if fmt.Sprint(missing) != fmt.Sprint(want) { log.Fatalf("got %v, want %v", missing, want) } // Agent.Loop's own Unsupported() reports the same thing for a live Loop. a := agents.New("coordinator", sp) tk, _ := tn.CreateToolkit(nil, tn.Options{Builtins: false}) l := a.Loop(tn.ClientOptions{BaseURL: "http://unused", Style: tn.StyleOpenAI, Model: "m", APIKey: "k"}, tk) if len(l.Unsupported()) != 4 { log.Fatalf("Loop.Unsupported() = %v, want 4 entries", l.Unsupported()) } fmt.Println("ok: unsupported through this door:", missing) } ``` ## Signature [Section titled “Signature”](#signature) | Symbol | What it is | | ----------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | | `Agent.Loop(opts, tk)` | Opens a live `*Loop` for this agent over a caller-built `ClientOptions`/`*Toolkit`. | | `Loop.Run(ctx, prompt, RunOpts)` | Runs one request through the gate; `RunOpts.Model` overrides the agent’s model for this call only. | | `Loop.Status()` / `.Turns()` / `.Unsupported()` | Observed, never set by the caller. | | `Guardrail` | Policy check — `"" \| "allow"` permits, anything else denies with that reason. Composed first-deny-wins, ahead of any `Hooks.BeforeTool`. | | `Completion{Verify, MaxAttempts}` | The gate: `Verify` reports `(ok, reason)`; `MaxAttempts` bounds the retries a failing gate can force. | | `AllTodosDone` | The shipped structural verifier — reads the `todowrite` builtin’s last call; no plan declared ⇒ passes. | | `LoopUnsupported(spec)` | The four Spec fields (`tools`, `team`, `waitFor`, `onMetric`) this door cannot carry, in stable order. | ## See also [Section titled “See also”](#see-also) * [`agents.New`](/api/go/runtime/agent/) — Define a sub-agent with its own toolkit, prompt and budget, callable as a tool by its parent. * [`NewRuntime`](/api/go/runtime/runtime/) — The six host verbs that drive sub-agents, plus the read-only list and inspect views. * [`agents.Handle`](/api/go/runtime/handle/) — The state machine for one spawned agent: pending, running, suspended, done. * [`agents.Budget`](/api/go/runtime/budgets/) — Cap tool calls and wall-clock per agent and per team, enforced while the run is in flight. * [Harness (narrative)](/harness/) — How `harness`/`Loop`/`Completion` fit together as a capability layer, across all eight ports. # NewRuntime — spawn, wait, interrupt > The six host verbs that drive sub-agents, plus the read-only list and inspect views. — Go. **Go** · package `github.com/muthuishere/toolnexus/golang/agents` · [SPEC §7D](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [golang/agents/runtime.go](https://github.com/muthuishere/toolnexus/blob/main/golang/agents/runtime.go) ```go func NewRuntime(opts Options) *Runtime func (rt *Runtime) Spawn(parent *Handle, defName string, budget *Budget) (*Handle, error) func (rt *Runtime) Post(h *Handle, item InboxItem) PostResult func (rt *Runtime) Wake(h *Handle, prompt string) PostResult func (rt *Runtime) Wait(h *Handle, timeout time.Duration) TaskResult func (rt *Runtime) Interrupt(h *Handle) func (rt *Runtime) Close(h *Handle, opts *CloseOptions) func (rt *Runtime) List() []HandleView func (rt *Runtime) Inspect(h *Handle) HandleView func (rt *Runtime) Trace() []string ``` `NewRuntime` builds the substrate `agents.New` runs on: a handle table rooted at `Root`, the three backpressure gates (bounded inbox, per-parent concurrency, a global turn gate around the LLM HTTP call), hierarchical budgets, and one runtime-wide `ConversationStore`. The six verbs are the **entire** host surface — everything else (`Agent.Run`, `AsTool`, the `task` tool) compiles down to these. ## When to use it [Section titled “When to use it”](#when-to-use-it) Reach for `NewRuntime` directly — over the `Agent.Run` one-shot — when you need more than one handle alive at a time: fan work out to several children concurrently, keep a long-lived agent between calls, post unsolicited events into a running agent’s inbox, or drive interrupt/close yourself instead of letting `Run` do it implicitly. ## Why this and not the alternative [Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative) Just running one agent to completion? Agent.Run already does this `agents.New(...).Run(opts, prompt)` builds a `Runtime` from the agent’s `Registry()`, spawns it at `Root`, wakes it, waits, and closes — exactly `NewRuntime` + `Spawn` + `Wake` + `Wait` + `Close` under the hood. Reach for `NewRuntime` when that one-shot lifecycle doesn’t fit. ## Examples [Section titled “Examples”](#examples) All three run against a scripted `http.RoundTripper` (`Options.Transport`) — the runtime’s documented hermetic seam, zero network, zero real API key. ### 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) ```go package main import ( "bytes" "encoding/json" "fmt" "io" "log" "net/http" "github.com/muthuishere/toolnexus/golang/agents" ) type textLLM struct{ reply string } func (t textLLM) RoundTrip(req *http.Request) (*http.Response, error) { body, _ := json.Marshal(map[string]any{ "choices": []any{map[string]any{"message": map[string]any{"role": "assistant", "content": t.reply}}}, "usage": map[string]any{"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2}, }) return &http.Response{StatusCode: 200, Header: http.Header{"Content-Type": []string{"application/json"}}, Body: io.NopCloser(bytes.NewReader(body))}, nil } func main() { reg := map[string]agents.Def{ "peer": {Name: "peer", Does: "a worker", Model: "m-peer"}, } rt := agents.NewRuntime(agents.Options{Transport: textLLM{reply: "done work"}, Registry: reg}) h, err := rt.Spawn(rt.Root, "peer", nil) if err != nil { log.Fatal(err) } rt.Wake(h, "go") r := rt.Wait(h, 0) if r.Status != "done" || r.Text != "done work" { log.Fatalf("unexpected: %+v", r) } rt.Close(rt.Root, nil) fmt.Println("ok:", r.Text) } ``` ### 2. The realistic case — fan two children out concurrently [Section titled “2. The realistic case — fan two children out concurrently”](#2-the-realistic-case--fan-two-children-out-concurrently) Ids are deterministic and parent-scoped (`root/worker.1`, `root/worker.2`) — never random. ```go package main import ( "bytes" "encoding/json" "fmt" "io" "log" "net/http" "time" "github.com/muthuishere/toolnexus/golang/agents" ) type textLLM struct{ reply string } func (t textLLM) RoundTrip(req *http.Request) (*http.Response, error) { time.Sleep(2 * time.Millisecond) // makes concurrency observable body, _ := json.Marshal(map[string]any{ "choices": []any{map[string]any{"message": map[string]any{"role": "assistant", "content": t.reply}}}, "usage": map[string]any{"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2}, }) return &http.Response{StatusCode: 200, Header: http.Header{"Content-Type": []string{"application/json"}}, Body: io.NopCloser(bytes.NewReader(body))}, nil } func main() { reg := map[string]agents.Def{"worker": {Name: "worker", Does: "does a slice of the job", Model: "m-worker"}} rt := agents.NewRuntime(agents.Options{Transport: textLLM{reply: "ok"}, Registry: reg}) h1, err := rt.Spawn(rt.Root, "worker", nil) if err != nil { log.Fatal(err) } h2, err := rt.Spawn(rt.Root, "worker", nil) if err != nil { log.Fatal(err) } if h1.ID == h2.ID { log.Fatal("expected distinct deterministic ids") } rt.Wake(h1, "task A") rt.Wake(h2, "task B") r1 := rt.Wait(h1, 0) r2 := rt.Wait(h2, 0) if r1.Status != "done" || r2.Status != "done" { log.Fatalf("unexpected: %+v %+v", r1, r2) } rows := rt.List() if len(rows) != 2 { log.Fatalf("expected 2 handles listed, got %d", len(rows)) } rt.Close(rt.Root, nil) fmt.Println("ok:", h1.ID, h2.ID) } ``` ### 3. The full surface — post to the inbox, interrupt mid-flight, trace, close [Section titled “3. The full surface — post to the inbox, interrupt mid-flight, trace, close”](#3-the-full-surface--post-to-the-inbox-interrupt-mid-flight-trace-close) ```go package main import ( "bytes" "encoding/json" "fmt" "io" "log" "net/http" "strings" "time" "github.com/muthuishere/toolnexus/golang/agents" ) // gatedLLM never answers until released, or the turn's context is cancelled — // the seam Interrupt uses to prove it aborts the TURN, not the agent. type gatedLLM struct{ gate chan struct{} } func (g gatedLLM) RoundTrip(req *http.Request) (*http.Response, error) { select { case <-g.gate: body, _ := json.Marshal(map[string]any{ "choices": []any{map[string]any{"message": map[string]any{"role": "assistant", "content": "finished"}}}, "usage": map[string]any{"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2}, }) return &http.Response{StatusCode: 200, Header: http.Header{"Content-Type": []string{"application/json"}}, Body: io.NopCloser(bytes.NewReader(body))}, nil case <-req.Context().Done(): return nil, req.Context().Err() } } func main() { reg := map[string]agents.Def{"slow": {Name: "slow", Does: "takes a while", Model: "m-slow"}} gate := make(chan struct{}) rt := agents.NewRuntime(agents.Options{Transport: gatedLLM{gate: gate}, Registry: reg}) h, err := rt.Spawn(rt.Root, "slow", nil) if err != nil { log.Fatal(err) } // An unsolicited post lands in the inbox — no state transition on its own. pr := rt.Post(h, agents.InboxItem{From: "external", Channel: "peer", Text: "heads up"}) if !pr.Ok { log.Fatalf("post should succeed: %s", pr.Err) } rt.Wake(h, "start the slow task") for i := 0; i < 200 && rt.StateOf(h) != agents.StateRunning; i++ { time.Sleep(time.Millisecond) } rt.Interrupt(h) // aborts the TURN — the agent returns to idle, inbox intact r := rt.Wait(h, 0) if r.Status != "interrupted" { log.Fatalf("expected interrupted, got %q", r.Status) } if rt.StateOf(h) != agents.StateIdle { log.Fatalf("expected idle after interrupt, got %s", rt.StateOf(h)) } if rt.InboxLen(h) != 1 { log.Fatalf("the posted item should survive the aborted turn, got inbox len %d", rt.InboxLen(h)) } close(gate) // let any straggler goroutine finish; unused by this point rt.Close(rt.Root, nil) if rt.StateOf(h) != agents.StateClosed { log.Fatalf("expected closed, got %s", rt.StateOf(h)) } trace := rt.Trace() found := false for _, line := range trace { if strings.Contains(line, "interrupt") { found = true } } if !found { log.Fatal("expected the trace to record the interrupt transition") } fmt.Println("ok: interrupted then closed,", len(trace), "trace lines recorded") } ``` ## Options fields [Section titled “Options fields”](#options-fields) | Field | Type | Default | | -------------------- | ---------------------- | ------------------------------------------------------------------------- | | `Transport` | `http.RoundTripper` | `http.DefaultTransport` — a scripted RoundTripper makes fixtures hermetic | | `Registry` | `map[string]Def` | required — prefer `Agent.Registry()` over hand-building it | | `InboxCap` | `int` | 8 | | `MaxConcurrentTurns` | `int` | 8 — the global turn gate, wraps only the LLM HTTP call | | `Shutdown` | `time.Duration` | 200ms — how long a graceful `Close` waits before escalating to interrupt | | `LLM` | `*LLMOptions` | `nil` ⇒ mock placeholders (pair with `Transport`) | | `Store` | `tn.ConversationStore` | `nil` ⇒ in-memory | | `Clock` | `Clock` | `nil` ⇒ the real clock; inject a virtual one for deterministic fixtures | | `Hooks` / `OnMetric` | — | the §8 seams, applied to every agent unless a `Def` overrides them | ## See also [Section titled “See also”](#see-also) * [`agents.New`](/api/go/runtime/agent/) — Define a sub-agent with its own toolkit, prompt and budget, callable as a tool by its parent. * [`agents.Handle`](/api/go/runtime/handle/) — The state machine for one spawned agent: pending, running, suspended, done. * [`agents.Budget`](/api/go/runtime/budgets/) — Cap tool calls and wall-clock per agent and per team, enforced while the run is in flight. # The task tool — model-facing delegation > The opt-in tool that lets the model itself spawn a teammate. Default OFF. — Go. **Go** · package `github.com/muthuishere/toolnexus/golang/agents` · [SPEC §7D](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [golang/agents/runtime.go](https://github.com/muthuishere/toolnexus/blob/main/golang/agents/runtime.go) Not available as a constructible symbol in Go Only JS exposes a public `agents.taskTool(...)` you can build by hand. In Go the `task` tool is an unexported runtime detail (`(*Runtime).taskTool`) — you never construct or name it directly. The opt-in tool that lets the model itself spawn a teammate. Default OFF. ## What to use instead [Section titled “What to use instead”](#what-to-use-instead) Declare [`Spec.Team`](/api/go/runtime/agent/) on an agent — a non-empty `Team` is what turns the `task` tool on for that agent, scoped to exactly those names. There is nothing further to build: ```go coordinator := agents.New("coordinator", agents.Spec{ Does: "splits work and delegates", Team: []*agents.Agent{explorer, coder}, // ⇒ a "task" tool appears, targets = these two }) ``` An agent with an EMPTY `Team` gets no `task` tool at all — delegation (and recursion) is opt-in, never a default. The tool’s description is generated for you: each teammate’s name plus its `Does` string, sorted by name — the same pattern the §3 `skill` tool uses to describe what it can load. ## Examples [Section titled “Examples”](#examples) Hermetic: a scripted `http.RoundTripper` in place of a real model, same pattern as every other `runtime/*` page. ### The tool the model sees, team scoping, and idempotent reattachment [Section titled “The tool the model sees, team scoping, and idempotent reattachment”](#the-tool-the-model-sees-team-scoping-and-idempotent-reattachment) ```go package main import ( "bytes" "encoding/json" "fmt" "io" "log" "net/http" "strings" "github.com/muthuishere/toolnexus/golang/agents" ) func openaiResp(msg map[string]any) *http.Response { full := map[string]any{"role": "assistant"} for k, v := range msg { full[k] = v } b, _ := json.Marshal(map[string]any{ "choices": []any{map[string]any{"message": full}}, "usage": map[string]any{"prompt_tokens": 5, "completion_tokens": 5, "total_tokens": 10}, }) return &http.Response{StatusCode: 200, Header: http.Header{"Content-Type": []string{"application/json"}}, Body: io.NopCloser(bytes.NewReader(b))} } // rogueLLM tries to delegate OUTSIDE its declared team — proves the refusal. type rogueLLM struct{} func (rogueLLM) RoundTrip(req *http.Request) (*http.Response, error) { b, _ := io.ReadAll(req.Body) var parsed struct { Model string `json:"model"` Messages []map[string]any `json:"messages"` } _ = json.Unmarshal(b, &parsed) sawToolResult := false for _, m := range parsed.Messages { if m["role"] == "tool" { sawToolResult = true } } if parsed.Model == "m-rogue" && !sawToolResult { args, _ := json.Marshal(map[string]any{"agent": "stranger", "prompt": "hi"}) return openaiResp(map[string]any{"content": nil, "tool_calls": []any{ map[string]any{"id": "c1", "type": "function", "function": map[string]any{"name": "task", "arguments": string(args)}}, }}), nil } // Echo the tool result back verbatim, so the final answer proves what the // task tool actually returned (rather than masking it with a fixed reply). last := "" for _, m := range parsed.Messages { if m["role"] == "tool" { if s, ok := m["content"].(string); ok { last = s } } } return openaiResp(map[string]any{"content": last}), nil } func main() { explorer := agents.New("explorer", agents.Spec{Does: "read-only research", Model: "m-explorer"}) rogue := agents.New("rogue", agents.Spec{ Does: "tries to delegate outside its team", Model: "m-rogue", Team: []*agents.Agent{explorer}, }) // A def with NO Team declared gets no task tool at all. solo := agents.New("solo", agents.Spec{Does: "works alone", Model: "m-solo"}) if len(solo.Registry()) != 1 { log.Fatal("a teamless agent's registry should contain only itself") } r, _ := rogue.Run(agents.Options{Transport: rogueLLM{}}, "delegate to a stranger") if !strings.Contains(r.Text, "not in this agent's team") || !strings.Contains(r.Text, "explorer") { log.Fatalf("expected an out-of-team refusal naming the real team, got %q", r.Text) } fmt.Println("ok:", r.Text) } ``` ## What the tool looks like to the model [Section titled “What the tool looks like to the model”](#what-the-tool-looks-like-to-the-model) | | Value | | ------------- | ---------------------------------------------------------------------------------------------------------- | | `name` | `"task"` | | `description` | `"Delegate a subtask to an isolated subagent. Available agents — : ; ..."` (team, name-sorted) | | `inputSchema` | `{agent: string, prompt: string}`, both required | A call outside the declared team is refused with the team names listed — never silently ignored. A repeated call with the same `(agent, prompt)` pair REATTACHES to the existing child (by task key) instead of spawning a duplicate: settled ⇒ its recorded result, suspended ⇒ its pending, running ⇒ awaited. This is what makes a durable §10 resume idempotent across a re-invoked `task` call. ## See also [Section titled “See also”](#see-also) * [`agents.New`](/api/go/runtime/agent/) — Define a sub-agent with its own toolkit, prompt and budget, callable as a tool by its parent. * [`NewRuntime`](/api/go/runtime/runtime/) — The six host verbs that drive sub-agents, plus the read-only list and inspect views. * [`agents.Handle`](/api/go/runtime/handle/) — The state machine for one spawned agent: pending, running, suspended, done. * [`agents.Budget`](/api/go/runtime/budgets/) — Cap tool calls and wall-clock per agent and per team, enforced while the run is in flight. # agents.TaskStatus* / RunStatus* / agents.Limit* > 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. — Go. **Go** · package `github.com/muthuishere/toolnexus/golang` · [SPEC §7D](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) ```go // package agents (golang/agents/statusvocab.go) — the §7D task/agent vocabulary. const ( TaskStatusDone = "done" TaskStatusPending = "pending" TaskStatusIncomplete = "incomplete" TaskStatusInterrupted = "interrupted" TaskStatusClosed = "closed" TaskStatusTimeout = "timeout" // exclusively agents.Wait's — never a §8 RunResult status TaskStatusError = "error" ) const ( LimitMaxTurns = "maxTurns" LimitMaxTokens = "maxTokens" LimitMaxToolCalls = "maxToolCalls" LimitMaxWallMs = "maxWallMs" LimitMaxChildren = "maxChildren" LimitMaxConcurrent = "maxConcurrent" LimitMaxDepth = "maxDepth" LimitCompletion = "completion" LimitTimeout = "timeout" ) // package toolnexus (golang/statusvocab.go) — the SEPARATE, smaller §8 client vocabulary. const ( RunStatusDone = "done" RunStatusPending = "pending" RunStatusIncomplete = "incomplete" // "timeout" never appears here — see below ) const ( RunLimitMaxTurns = "maxTurns" RunLimitCompletion = "completion" RunLimitTimeout = "timeout" ) ``` Two closed string sets, deliberately kept apart even though they share a field name (`Status`) and overlapping vocabulary words. Confusing them is a real, previously-shipped bug (issue #92): a host that read `agents.TaskStatus*`’s documentation and then branched a `toolnexus.RunResult.Status` against `"timeout"` was matching a value that set can **never** produce. | | Package | Values | `"timeout"` means | | ------------------------------------ | -------------------------- | ----------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- | | **task/agent** (`TaskResult.Status`) | `agents` | 7: done, pending, incomplete, interrupted, closed, timeout, error | a `Wait(handle, timeout)` deadline expired while the **child kept running** | | **client/run** (`RunResult.Status`) | `toolnexus` (root package) | 3: done, pending, incomplete | never appears — a §8 whole-run deadline is reported as `RunStatusIncomplete` + `RunLimitTimeout` instead | The **limit** vocabulary follows the same split, at two different granularities: | | Package | Values | | ------------------------------------------------------------------------------ | -------------------------- | ------------------------------------------------------------------------------------------------------------ | | **task/agent limits** (`TaskResult.Limit`, paired with `TaskStatusIncomplete`) | `agents` | 9: `maxTurns, maxTokens, maxToolCalls, maxWallMs, maxChildren, maxConcurrent, maxDepth, completion, timeout` | | **client/run limits** (`RunResult.Limit`, paired with `RunStatusIncomplete`) | `toolnexus` (root package) | 3: `maxTurns, completion, timeout` — its OWN, smaller set; not a subset alias of the 9-value set | Go keeps no exported `CanonicalLimit`/canonicalization helper the way some sibling ports do — the mapping from a `Budget` field name to its `agents.Limit*` string is done inline where the limit is set, since Go’s `Budget` struct field names already match the wire spelling one-for-one. ## When to use it [Section titled “When to use it”](#when-to-use-it) Use the constants — never a bare string literal — whenever you branch on `TaskResult.Status`, `TaskResult.Limit`, `RunResult.Status`, or `RunResult.Limit`. A literal `"timeout"` compiles against either vocabulary and silently reads the wrong one if you branch on the wrong result type; the named constant at least makes which vocabulary you meant visible at the call site, and a rename anywhere in the closed set becomes a compile error instead of a silent drift. ```go if r.Status == agents.TaskStatusIncomplete && r.Limit == agents.LimitMaxToolCalls { // this child ran out of tool-call budget — not the same string as RunLimitMaxTurns } ``` ## Why this and not the alternative [Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative) Don't reach for the §8 client constants from agent code, or vice versa It is tempting to treat `RunLimitTimeout` and `LimitTimeout` as interchangeable because they’re both the string `"timeout"` — they are not the same **vocabulary**, and only one of them ever pairs with `TaskStatusTimeout` (which has no client-side equivalent at all: a §8 run deadline is always reported as `incomplete`, never `timeout`). If you’re driving [`agents.Runtime`](/api/go/runtime/runtime/) (`Spawn`/`Wake`/`Wait`/`Resume`), read `TaskResult.Status`/`.Limit` against the `agents.*` constants. If you’re driving the bare [`Client.Run`/`Ask`](/api/go/client/run/), read `RunResult.Status`/`.Limit` against the root-package `RunStatus*`/`RunLimit*` constants. Mixing the two is exactly the bug this page’s constants exist to make harder to write by accident. ## Examples [Section titled “Examples”](#examples) ### 1. The smallest useful call — branch on the §8 client vocabulary [Section titled “1. The smallest useful call — branch on the §8 client vocabulary”](#1-the-smallest-useful-call--branch-on-the-8-client-vocabulary) ```go package main import ( "context" "fmt" "log" "net/http" "net/http/httptest" toolnexus "github.com/muthuishere/toolnexus/golang" ) func main() { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") // Always emits a tool call, so MaxTurns is exhausted before "done". _, _ = w.Write([]byte(`{"choices":[{"message":{"role":"assistant","content":null,"tool_calls":[{"id":"c1","type":"function","function":{"name":"noop","arguments":"{}"}}]}}],"usage":{"prompt_tokens":2,"completion_tokens":2,"total_tokens":4}}`)) })) defer srv.Close() noop := toolnexus.Tool{ Name: "noop", Description: "does nothing", Source: toolnexus.SourceCustom, InputSchema: toolnexus.JSONSchema{"type": "object", "properties": map[string]any{}}, Execute: func(_ map[string]any, _ *toolnexus.ToolContext) (toolnexus.ToolResult, error) { return toolnexus.ToolResult{Output: "ok"}, nil }, } tk, err := toolnexus.CreateToolkit(context.Background(), toolnexus.Options{Builtins: false, ExtraTools: []toolnexus.Tool{noop}}) if err != nil { log.Fatal(err) } defer tk.Close() client := toolnexus.CreateClient(toolnexus.ClientOptions{BaseURL: srv.URL, Style: toolnexus.StyleOpenAI, Model: "stub", APIKey: "k", MaxTurns: 2}) r, err := client.Run(context.Background(), "go", tk) if err != nil { log.Fatal(err) } if r.Status != toolnexus.RunStatusIncomplete || r.Limit != toolnexus.RunLimitMaxTurns { log.Fatalf("got status=%q limit=%q, want %q/%q", r.Status, r.Limit, toolnexus.RunStatusIncomplete, toolnexus.RunLimitMaxTurns) } fmt.Println("ok:", r.Status, r.Limit) } ``` ### 2. The realistic case — branch on the §7D task/agent vocabulary from a spawned handle [Section titled “2. The realistic case — branch on the §7D task/agent vocabulary from a spawned handle”](#2-the-realistic-case--branch-on-the-7d-taskagent-vocabulary-from-a-spawned-handle) ```go package main import ( "fmt" "log" "github.com/muthuishere/toolnexus/golang/agents" ) func main() { // A task that ran out of its own tool-call budget reports at the AGENT level, // distinct from the client-level vocabulary above. tr := agents.TaskResult{Status: agents.TaskStatusIncomplete, Limit: agents.LimitMaxToolCalls, Text: "stopped: tool-call budget"} switch tr.Status { case agents.TaskStatusIncomplete: fmt.Println("ok: incomplete, limit =", tr.Limit) case agents.TaskStatusTimeout: log.Fatal("this branch would be a WAIT deadline, not this scenario") default: log.Fatalf("unexpected status %q", tr.Status) } if tr.Limit != agents.LimitMaxToolCalls { log.Fatalf("Limit = %q, want %q", tr.Limit, agents.LimitMaxToolCalls) } } ``` ### 3. The full surface — the two “timeout” strings side by side, and why they never collide [Section titled “3. The full surface — the two “timeout” strings side by side, and why they never collide”](#3-the-full-surface--the-two-timeout-strings-side-by-side-and-why-they-never-collide) ```go package main import ( "fmt" "log" toolnexus "github.com/muthuishere/toolnexus/golang" "github.com/muthuishere/toolnexus/golang/agents" ) func main() { // Same literal string, two closed vocabularies, two different meanings. if agents.TaskStatusTimeout != "timeout" || agents.LimitTimeout != "timeout" || toolnexus.RunLimitTimeout != "timeout" { log.Fatal("the shared literal is the whole point of the confusion this page documents") } // But RunStatus* has NO timeout member at all — a §8 run deadline is always // reported as incomplete, never as a fourth RunStatus value. runStatuses := []string{toolnexus.RunStatusDone, toolnexus.RunStatusPending, toolnexus.RunStatusIncomplete} for _, s := range runStatuses { if s == "timeout" { log.Fatal("RunStatus* must never contain \"timeout\"") } } // A §8 whole-run deadline pairs incomplete with RunLimitTimeout, not with a // RunStatus of its own. deadline := toolnexus.RunResult{Status: toolnexus.RunStatusIncomplete, Limit: toolnexus.RunLimitTimeout} if deadline.Status != toolnexus.RunStatusIncomplete { log.Fatal("unexpected") } fmt.Println("ok: agent-level timeout and client-level run-deadline are two different things") } ``` ## See also [Section titled “See also”](#see-also) * [`agents.New`](/api/go/runtime/agent/) — Define a sub-agent with its own toolkit, prompt and budget, callable as a tool by its parent. * [`NewRuntime`](/api/go/runtime/runtime/) — The six host verbs that drive sub-agents, plus the read-only list and inspect views. * [`agents.Handle`](/api/go/runtime/handle/) — The state machine for one spawned agent: pending, running, suspended, done. * [`agents.Budget`](/api/go/runtime/budgets/) — Cap tool calls and wall-clock per agent and per team, enforced while the run is in flight. # Toolkit.Serve — expose your toolkit as an A2A agent > Publish an Agent Card and answer JSON-RPC over the client loop — your toolkit becomes someone else's remote agent. — Go. **Go** · package `github.com/muthuishere/toolnexus/golang` · [SPEC §7B](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [golang/serve.go](https://github.com/muthuishere/toolnexus/blob/main/golang/serve.go) ```go func (tk *Toolkit) Serve(addr string, opts ServeOptions) (*ServeHandle, error) type ServeOptions struct { Client *Client // fulfils Tasks via its run loop A2A *A2AConfig // opt-in A2A profile — absent ⇒ no A2A routes mount OnTask OnTask // fires on each Task's terminal state MCP *MCPServeConfig // independent opt-in MCP profile, see ExposedMcpTools OnCall OnCall } ``` `Serve` stands up an HTTP server exposing this toolkit’s **skills** (never raw tools) as a real A2A agent: `GET /.well-known/agent-card.json` publishes the card, `POST /` answers JSON-RPC (`SendMessage` submits a Task and fulfils it asynchronously through `Client.Run`; `GetTask` polls it). Any A2A peer — including this same port’s own [`AgentTools`](/api/go/agents/agent-tools/) — can call it. Go names this Toolkit.Serve, not \`StartAgent\` Every other port’s serve entry point is a free function or static method (`startA2AServer`, `Toolkit.serve`, `A2AServer.start`, …). Go’s is a method: `(*Toolkit).Serve`. `StartAgent` (in `golang/agents`) is a different, unrelated symbol — the §7E **persona heartbeat** starter, not the inbound A2A server. ## When to use it [Section titled “When to use it”](#when-to-use-it) Reach for `Serve` when you want your toolkit to be **callable by other agents**, not just by your own `Client.Run` loop — a specialist toolkit another team’s agent delegates to, a service mesh of toolnexus toolkits, or a toolkit fronting both A2A and MCP for different kinds of callers at once. ## Why this and not the alternative [Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative) Only want MCP clients, not A2A peers? `ServeOptions.A2A` and `ServeOptions.MCP` are independent — set only `MCP` (see [`ExposedMcpTools`](/api/go/serve/mcp/)) to skip the A2A card/JSON-RPC surface entirely and expose tools instead of skills. Set neither and `Serve` mounts nothing (every request 404s). ## Examples [Section titled “Examples”](#examples) All three stand up a REAL local server on an ephemeral port (`"127.0.0.1:0"`) with a stubbed LLM (`httptest.Server` standing in for the model, same pattern as [`Client.Run`](/api/go/client/run/)) — no external network, no real API key. ### 1. The smallest useful call — publish the card [Section titled “1. The smallest useful call — publish the card”](#1-the-smallest-useful-call--publish-the-card) ```go package main import ( "context" "encoding/json" "fmt" "log" "net/http" "net/http/httptest" toolnexus "github.com/muthuishere/toolnexus/golang" ) func mockLLM(reply string) *httptest.Server { return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("content-type", "application/json") _ = json.NewEncoder(w).Encode(map[string]any{ "choices": []any{map[string]any{"message": map[string]any{"role": "assistant", "content": reply}}}, "usage": map[string]any{"prompt_tokens": 3, "completion_tokens": 2, "total_tokens": 5}, }) })) } func main() { llm := mockLLM("hi there") defer llm.Close() tk, err := toolnexus.CreateToolkit(context.Background(), toolnexus.Options{Builtins: false}) if err != nil { log.Fatal(err) } defer tk.Close() client := toolnexus.CreateClient(toolnexus.ClientOptions{BaseURL: llm.URL, Style: toolnexus.StyleOpenAI, Model: "x", APIKey: "k"}) handle, err := tk.Serve("127.0.0.1:0", toolnexus.ServeOptions{ Client: client, A2A: &toolnexus.A2AConfig{Name: "greeter-desk"}, }) if err != nil { log.Fatal(err) } defer handle.Stop() resp, err := http.Get(handle.URL + "/.well-known/agent-card.json") if err != nil { log.Fatal(err) } defer resp.Body.Close() var card map[string]any _ = json.NewDecoder(resp.Body).Decode(&card) if card["name"] != "greeter-desk" || card["protocolVersion"] != "0.3.0" { log.Fatalf("unexpected card: %+v", card) } fmt.Println("ok: card published for", card["name"]) } ``` ### 2. The realistic case — two toolkits, a real A2A round trip [Section titled “2. The realistic case — two toolkits, a real A2A round trip”](#2-the-realistic-case--two-toolkits-a-real-a2a-round-trip) Toolkit A is `Serve`d; toolkit B is a completely separate `Toolkit` that resolves toolkit A’s card and calls it via this port’s own outbound [`AgentTools`](/api/go/agents/agent-tools/) — the same shape a real external A2A peer would use. ```go package main import ( "context" "encoding/json" "fmt" "log" "net/http" "net/http/httptest" "os" "path/filepath" toolnexus "github.com/muthuishere/toolnexus/golang" ) func mockLLM(reply string) *httptest.Server { return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("content-type", "application/json") _ = json.NewEncoder(w).Encode(map[string]any{ "choices": []any{map[string]any{"message": map[string]any{"role": "assistant", "content": reply}}}, "usage": map[string]any{"prompt_tokens": 3, "completion_tokens": 2, "total_tokens": 5}, }) })) } func main() { llm := mockLLM("42") defer llm.Close() skillsDir := filepath.Join(os.TempDir(), "toolnexus-docs-serve-a2a-2") _ = os.MkdirAll(filepath.Join(skillsDir, "answer"), 0o755) _ = os.WriteFile(filepath.Join(skillsDir, "answer", "SKILL.md"), []byte("---\nname: answer\ndescription: answers a question\n---\n\nAnswer it.\n"), 0o644) defer os.RemoveAll(skillsDir) // Toolkit A: served over A2A with one skill. served, err := toolnexus.CreateToolkit(context.Background(), toolnexus.Options{ Builtins: false, SkillsDir: []string{skillsDir}, }) if err != nil { log.Fatal(err) } defer served.Close() client := toolnexus.CreateClient(toolnexus.ClientOptions{BaseURL: llm.URL, Style: toolnexus.StyleOpenAI, Model: "x", APIKey: "k"}) handle, err := served.Serve("127.0.0.1:0", toolnexus.ServeOptions{ Client: client, A2A: &toolnexus.A2AConfig{Name: "answer-desk"}, }) if err != nil { log.Fatal(err) } defer handle.Stop() // Toolkit B: a fresh, unrelated toolkit that points AT toolkit A over HTTP. caller, err := toolnexus.CreateToolkit(context.Background(), toolnexus.Options{ Builtins: false, Agents: []toolnexus.Agent{{ Card: handle.URL + "/.well-known/agent-card.json", PollEvery: 5, }}, }) if err != nil { log.Fatal(err) } defer caller.Close() res, err := caller.Execute(context.Background(), "answer-desk_answer", map[string]any{"task": "what is 6*7?"}) if err != nil || res.IsError || res.Output != "42" { log.Fatalf("unexpected: %+v %v", res, err) } fmt.Println("ok:", res.Output, "(via a real local A2A round trip)") } ``` ### 3. The full surface — a served skill, `OnTask`, and a real skill answer [Section titled “3. The full surface — a served skill, OnTask, and a real skill answer”](#3-the-full-surface--a-served-skill-ontask-and-a-real-skill-answer) ```go package main import ( "context" "encoding/json" "fmt" "log" "net/http" "net/http/httptest" "os" "path/filepath" toolnexus "github.com/muthuishere/toolnexus/golang" ) func mockLLM(reply string) *httptest.Server { return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("content-type", "application/json") _ = json.NewEncoder(w).Encode(map[string]any{ "choices": []any{map[string]any{"message": map[string]any{"role": "assistant", "content": reply}}}, "usage": map[string]any{"prompt_tokens": 3, "completion_tokens": 2, "total_tokens": 5}, }) })) } func main() { llm := mockLLM("TRANSCRIBED") defer llm.Close() skillsDir := filepath.Join(os.TempDir(), "toolnexus-docs-serve-a2a-skills") _ = os.MkdirAll(filepath.Join(skillsDir, "hello-world"), 0o755) _ = os.WriteFile(filepath.Join(skillsDir, "hello-world", "SKILL.md"), []byte("---\nname: hello-world\ndescription: says hello\n---\n\nSay hello.\n"), 0o644) defer os.RemoveAll(skillsDir) tk, err := toolnexus.CreateToolkit(context.Background(), toolnexus.Options{ Builtins: false, SkillsDir: []string{skillsDir}, }) if err != nil { log.Fatal(err) } defer tk.Close() client := toolnexus.CreateClient(toolnexus.ClientOptions{BaseURL: llm.URL, Style: toolnexus.StyleOpenAI, Model: "x", APIKey: "k"}) events := make(chan toolnexus.OnTaskEvent, 1) handle, err := tk.Serve("127.0.0.1:0", toolnexus.ServeOptions{ Client: client, A2A: &toolnexus.A2AConfig{ Name: "video-desk", Skills: []string{"hello-world"}, Provider: &toolnexus.A2AProvider{Organization: "acme", URL: "https://acme.example"}, }, OnTask: func(ev toolnexus.OnTaskEvent) { events <- ev }, }) if err != nil { log.Fatal(err) } defer handle.Stop() tools, err := toolnexus.AgentTools(context.Background(), toolnexus.Agent{ Card: handle.URL + "/.well-known/agent-card.json", PollEvery: 5, }) if err != nil { log.Fatal(err) } var hello *toolnexus.Tool for i := range tools { if tools[i].Name == "video-desk_hello-world" { hello = &tools[i] } } if hello == nil { log.Fatal("expected the served skill to resolve as video-desk_hello-world") } res, err := hello.Execute(map[string]any{"task": "do it"}, &toolnexus.ToolContext{Ctx: context.Background()}) if err != nil || res.IsError || res.Output != "TRANSCRIBED" { log.Fatalf("unexpected: %+v %v", res, err) } ev := <-events if ev.State != "completed" { log.Fatalf("expected OnTask to fire completed, got %q", ev.State) } fmt.Println("ok:", res.Output, "OnTask state =", ev.State) } ``` ## ServeOptions fields [Section titled “ServeOptions fields”](#serveoptions-fields) | Field | Type | What it does | | -------- | ----------------- | -------------------------------------------------------------------------------------------------------- | | `Client` | `*Client` | Fulfils each Task by running the prompt through this client’s loop. | | `A2A` | `*A2AConfig` | Opt-in — `Name`, `Description`, `Version`, `Provider`, `Skills` filter, `Store`. Absent ⇒ no A2A routes. | | `OnTask` | `OnTask` | Fires with `{ID, Skill, Task, Result, State}` on every terminal Task state. | | `MCP` | `*MCPServeConfig` | Independent opt-in MCP profile at `POST /mcp` — see [`ExposedMcpTools`](/api/go/serve/mcp/). | | `OnCall` | `OnCall` | Fires per inbound MCP `tools/call`. | `ServeHandle{URL, Stop()}` is returned; `Close()` aliases `Stop()`. `A2AConfig.Store` selects Task persistence — see [`NewFileTaskStore`](/api/go/serve/task-store/). ## See also [Section titled “See also”](#see-also) * [`BuildAgentCard`](/api/go/serve/agent-card/) — Construct the Agent Card that advertises your name, skills and endpoint. * [`NewFileTaskStore`](/api/go/serve/task-store/) — Persist inbound A2A tasks so a suspended request survives a restart. * [`ExposedMcpTools`](/api/go/serve/mcp/) — The inbound MCP profile: any MCP client can call your tools. * [`agents.New`](/api/go/agents/agent/) — the outbound counterpart: call someone else’s served toolkit. # BuildAgentCard > Construct the Agent Card that advertises your name, skills and endpoint. — Go. **Go** · package `github.com/muthuishere/toolnexus/golang` · [SPEC §7B](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [golang/serve.go](https://github.com/muthuishere/toolnexus/blob/main/golang/serve.go) ```go func BuildAgentCard(cfg *A2AConfig, skills []SkillInfo, url string) servedAgentCard ``` `BuildAgentCard` is the exact function [`Toolkit.Serve`](/api/go/serve/a2a/) calls to answer `GET /.well-known/agent-card.json` — exposed directly so you can build or inspect a card without standing up a server. `skills` must be the toolkit’s `SkillSource` entries (never raw tools, §7B); filtered to `cfg.Skills` when that’s set. The return type is unexported `servedAgentCard` is a private struct — you never name it. Capture the result with `:=` and `json.Marshal` it to see the wire shape; that’s exactly what a real peer receives. ## When to use it [Section titled “When to use it”](#when-to-use-it) Reach for `BuildAgentCard` in a test or a tool that wants to assert on the card’s shape without an HTTP round trip, or if you’re building your own transport around the A2A profile instead of using `Serve`. ## Why this and not the alternative [Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative) Serving for real? Use Toolkit.Serve `Serve` calls `BuildAgentCard` on every `GET` automatically and wires the matching `POST /` JSON-RPC handler alongside it. Reach for `BuildAgentCard` directly only when you want the card value itself, decoupled from a running server. ## Examples [Section titled “Examples”](#examples) ### 1. Defaults — no config, no skills [Section titled “1. Defaults — no config, no skills”](#1-defaults--no-config-no-skills) ```go package main import ( "encoding/json" "fmt" "log" toolnexus "github.com/muthuishere/toolnexus/golang" ) func main() { card := toolnexus.BuildAgentCard(nil, nil, "http://localhost:8080/") b, _ := json.Marshal(card) var got map[string]any _ = json.Unmarshal(b, &got) if got["name"] != "toolnexus-agent" || got["version"] != "0.1.0" || got["protocolVersion"] != "0.3.0" { log.Fatalf("unexpected defaults: %v", got) } caps := got["capabilities"].(map[string]any) if caps["streaming"] != false || caps["pushNotifications"] != false { log.Fatalf("unexpected capabilities: %v", caps) } if skills, ok := got["skills"].([]any); !ok || len(skills) != 0 { log.Fatalf("expected an empty skills array, got %v", got["skills"]) } fmt.Println("ok: defaults —", got["name"], got["version"]) } ``` ### 2. Named agent, filtered to a subset of skills [Section titled “2. Named agent, filtered to a subset of skills”](#2-named-agent-filtered-to-a-subset-of-skills) ```go package main import ( "encoding/json" "fmt" "log" toolnexus "github.com/muthuishere/toolnexus/golang" ) func main() { skills := []toolnexus.SkillInfo{ {Name: "search", Description: "Search the docs"}, {Name: "admin", Description: "Dangerous — internal only"}, } cfg := &toolnexus.A2AConfig{ Name: "video-desk", Description: "Transcribes and summarizes video", Skills: []string{"search"}, // never advertise "admin" } card := toolnexus.BuildAgentCard(cfg, skills, "http://localhost:8080/") b, _ := json.Marshal(card) var got map[string]any _ = json.Unmarshal(b, &got) if got["name"] != "video-desk" { log.Fatalf("unexpected name: %v", got["name"]) } cardSkills := got["skills"].([]any) if len(cardSkills) != 1 { log.Fatalf("expected exactly the filtered skill, got %v", cardSkills) } first := cardSkills[0].(map[string]any) if first["id"] != "search" || first["name"] != "search" || first["description"] != "Search the docs" { log.Fatalf("unexpected skill entry: %v", first) } fmt.Println("ok:", got["name"], "advertises", len(cardSkills), "of", len(skills), "skills") } ``` ### 3. The full surface — provider, version override, and the URL param [Section titled “3. The full surface — provider, version override, and the URL param”](#3-the-full-surface--provider-version-override-and-the-url-param) ```go package main import ( "encoding/json" "fmt" "log" toolnexus "github.com/muthuishere/toolnexus/golang" ) func main() { skills := []toolnexus.SkillInfo{{Name: "hello-world", Description: "says hello"}} cfg := &toolnexus.A2AConfig{ Name: "greeter", Version: "2.3.1", Provider: &toolnexus.A2AProvider{ Organization: "acme corp", URL: "https://acme.example", }, } card := toolnexus.BuildAgentCard(cfg, skills, "http://localhost:9090/") b, _ := json.Marshal(card) var got map[string]any _ = json.Unmarshal(b, &got) if got["version"] != "2.3.1" { log.Fatalf("expected the version override, got %v", got["version"]) } if got["url"] != "http://localhost:9090/" { log.Fatalf("url should be exactly the caller-supplied endpoint, got %v", got["url"]) } provider := got["provider"].(map[string]any) if provider["organization"] != "acme corp" || provider["url"] != "https://acme.example" { log.Fatalf("unexpected provider: %v", provider) } // Omitting Provider entirely omits it from the JSON, not a null field. bare := toolnexus.BuildAgentCard(&toolnexus.A2AConfig{Name: "bare"}, nil, "http://x/") bb, _ := json.Marshal(bare) var bareGot map[string]any _ = json.Unmarshal(bb, &bareGot) if _, ok := bareGot["provider"]; ok { log.Fatal("expected provider omitted when not configured") } fmt.Println("ok:", got["name"], got["version"], "by", provider["organization"]) } ``` ## Parameters and defaults [Section titled “Parameters and defaults”](#parameters-and-defaults) | | Type | What it does | | -------- | ------------- | ---------------------------------------------------------------------------------------------------------------------- | | `cfg` | `*A2AConfig` | `nil` ⇒ every field below falls back to its default. | | `skills` | `[]SkillInfo` | The toolkit’s advertised skills; filtered to `cfg.Skills` when set (non-nil, even empty, filters to exactly that set). | | `url` | `string` | The JSON-RPC endpoint peers `POST` to — used verbatim as `card.url`. | | Field | Default when `cfg` (or the field) is unset | | ----------------- | -------------------------------------------- | | `name` | `"toolnexus-agent"` | | `description` | `""` | | `version` | `"0.1.0"` | | `protocolVersion` | always `"0.3.0"` | | `capabilities` | `{streaming:false, pushNotifications:false}` | | `provider` | omitted from the JSON entirely | ## See also [Section titled “See also”](#see-also) * [`Toolkit.Serve`](/api/go/serve/a2a/) — Publish an Agent Card and answer JSON-RPC over the client loop — your toolkit becomes someone else’s remote agent. * [`NewFileTaskStore`](/api/go/serve/task-store/) — Persist inbound A2A tasks so a suspended request survives a restart. * [`ExposedMcpTools`](/api/go/serve/mcp/) — The inbound MCP profile: any MCP client can call your tools. # ExposedMcpTools — expose your toolkit over MCP > The inbound MCP profile: any MCP client can call your tools. — Go. **Go** · package `github.com/muthuishere/toolnexus/golang` · [SPEC §7C](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [golang/mcpserve.go](https://github.com/muthuishere/toolnexus/blob/main/golang/mcpserve.go) ```go type MCPServeConfig struct { Name string // advertised server name, default "toolnexus" Version string // advertised server version, default "0.1.0" Tools []string // filter; nil ⇒ all, unknown names ignored } func ExposedMcpTools(tools []Tool, cfg *MCPServeConfig) []Tool // mount it: independent of A2A, co-located on the same Serve() server tk.Serve(addr, toolnexus.ServeOptions{MCP: &toolnexus.MCPServeConfig{...}}) ``` Where [`Toolkit.Serve`](/api/go/serve/a2a/)’s A2A profile advertises **skills** and fulfils a whole Task through the client loop, the MCP profile advertises the toolkit’s **unified tools** — every source (mcp · skill · native · http · builtin · a2a) — and dispatches each inbound `tools/call` straight to `Tool.Execute`. `ExposedMcpTools` is the filtering step: it decides which of the toolkit’s tools actually reach `tools/list`. ## When to use it [Section titled “When to use it”](#when-to-use-it) Reach for the MCP profile when the caller is itself an MCP client (Claude Desktop, an IDE, another agent’s MCP-speaking host) rather than an A2A peer — `Toolkit.Serve` turns your aggregated toolkit (N MCP servers + skills + your own tools) into **one universal MCP gateway**. Call `ExposedMcpTools` directly when you want the filtered list itself, decoupled from a running server. ## Why this and not the alternative [Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative) MCP and A2A are independent profiles on the same Serve Set `ServeOptions.MCP` alone to skip the A2A card/JSON-RPC surface and expose raw tools instead of skills; set both to serve the same toolkit two ways at once, on different endpoints (`/mcp` vs `/` + the card path). Neither set ⇒ `Serve` mounts nothing. ## Examples [Section titled “Examples”](#examples) ### 1. The filter itself — omit, narrow, unknown names ignored [Section titled “1. The filter itself — omit, narrow, unknown names ignored”](#1-the-filter-itself--omit-narrow-unknown-names-ignored) ```go package main import ( "context" "fmt" "log" toolnexus "github.com/muthuishere/toolnexus/golang" ) func mkTool(name string) toolnexus.Tool { return toolnexus.NativeTool(name, "does "+name, toolnexus.JSONSchema{"type": "object", "properties": map[string]any{}}, func(_ context.Context, _ map[string]any) (string, error) { return name, nil }, ) } func main() { tools := []toolnexus.Tool{mkTool("echo"), mkTool("boom")} all := toolnexus.ExposedMcpTools(tools, nil) if len(all) != 2 { log.Fatalf("nil cfg should expose everything, got %d", len(all)) } narrowed := toolnexus.ExposedMcpTools(tools, &toolnexus.MCPServeConfig{Tools: []string{"echo"}}) if len(narrowed) != 1 || narrowed[0].Name != "echo" { log.Fatalf("expected only echo, got %+v", narrowed) } // An unknown name in the filter is ignored, never an error. stillOne := toolnexus.ExposedMcpTools(tools, &toolnexus.MCPServeConfig{Tools: []string{"echo", "nope"}}) if len(stillOne) != 1 { log.Fatalf("unknown filter names should be silently dropped, got %d", len(stillOne)) } fmt.Println("ok:", len(all), "->", len(narrowed), "tools after filtering") } ``` ### 2. The realistic case — a real MCP client, over real streamable-HTTP [Section titled “2. The realistic case — a real MCP client, over real streamable-HTTP”](#2-the-realistic-case--a-real-mcp-client-over-real-streamable-http) Two independent processes in spirit: a served toolkit, and a real `mark3labs/mcp-go` MCP client connecting to it over `POST /mcp` on a local ephemeral port — no external network. ```go package main import ( "context" "fmt" "log" mcpclient "github.com/mark3labs/mcp-go/client" "github.com/mark3labs/mcp-go/mcp" toolnexus "github.com/muthuishere/toolnexus/golang" ) func main() { echo := toolnexus.NativeTool("echo", "echo back the text", toolnexus.JSONSchema{"type": "object", "properties": map[string]any{"text": map[string]any{"type": "string"}}, "required": []string{"text"}}, func(_ context.Context, args map[string]any) (string, error) { return fmt.Sprintf("%v", args["text"]), nil }, ) tk, err := toolnexus.CreateToolkit(context.Background(), toolnexus.Options{Builtins: false, ExtraTools: []toolnexus.Tool{echo}}) if err != nil { log.Fatal(err) } defer tk.Close() handle, err := tk.Serve("127.0.0.1:0", toolnexus.ServeOptions{MCP: &toolnexus.MCPServeConfig{Name: "gateway"}}) if err != nil { log.Fatal(err) } defer handle.Stop() c, err := mcpclient.NewStreamableHttpClient(handle.URL + "/mcp") if err != nil { log.Fatal(err) } defer c.Close() if err := c.Start(context.Background()); err != nil { log.Fatal(err) } initRes, err := c.Initialize(context.Background(), mcp.InitializeRequest{}) if err != nil { log.Fatal(err) } if initRes.ServerInfo.Name != "gateway" { log.Fatalf("expected serverInfo.name = gateway, got %q", initRes.ServerInfo.Name) } req := mcp.CallToolRequest{} req.Params.Name = "echo" req.Params.Arguments = map[string]any{"text": "over mcp"} res, err := c.CallTool(context.Background(), req) if err != nil { log.Fatal(err) } text := res.Content[0].(mcp.TextContent).Text if text != "over mcp" { log.Fatalf("unexpected: %q", text) } fmt.Println("ok:", text) } ``` ### 3. The full surface — `tools/list` narrowed, and an `Execute` error becomes `isError`, never a crash [Section titled “3. The full surface — tools/list narrowed, and an Execute error becomes isError, never a crash”](#3-the-full-surface--toolslist-narrowed-and-an-execute-error-becomes-iserror-never-a-crash) ```go package main import ( "context" "errors" "fmt" "log" mcpclient "github.com/mark3labs/mcp-go/client" "github.com/mark3labs/mcp-go/mcp" toolnexus "github.com/muthuishere/toolnexus/golang" ) func main() { safe := toolnexus.NativeTool("safe", "always works", toolnexus.JSONSchema{"type": "object", "properties": map[string]any{}}, func(_ context.Context, _ map[string]any) (string, error) { return "ok", nil }, ) boom := toolnexus.Tool{ Name: "boom", Description: "always explodes", Source: toolnexus.SourceNative, InputSchema: toolnexus.JSONSchema{"type": "object", "properties": map[string]any{}}, Execute: func(_ map[string]any, _ *toolnexus.ToolContext) (toolnexus.ToolResult, error) { return toolnexus.ToolResult{}, errors.New("kaboom") }, } tk, err := toolnexus.CreateToolkit(context.Background(), toolnexus.Options{ Builtins: false, ExtraTools: []toolnexus.Tool{safe, boom}, }) if err != nil { log.Fatal(err) } defer tk.Close() calls := 0 handle, err := tk.Serve("127.0.0.1:0", toolnexus.ServeOptions{ MCP: &toolnexus.MCPServeConfig{Name: "narrow-gw", Tools: []string{"safe", "boom"}}, OnCall: func(ev toolnexus.OnCallEvent) { calls++ }, }) if err != nil { log.Fatal(err) } defer handle.Stop() c, err := mcpclient.NewStreamableHttpClient(handle.URL + "/mcp") if err != nil { log.Fatal(err) } defer c.Close() _ = c.Start(context.Background()) _, _ = c.Initialize(context.Background(), mcp.InitializeRequest{}) list, err := c.ListTools(context.Background(), mcp.ListToolsRequest{}) if err != nil || len(list.Tools) != 2 { log.Fatalf("expected exactly 2 tools listed, got %+v %v", list.Tools, err) } req := mcp.CallToolRequest{} req.Params.Name = "boom" res, err := c.CallTool(context.Background(), req) if err != nil { log.Fatal(err) // a tool ERROR must not be a protocol/transport error } if !res.IsError { log.Fatal("expected isError=true for a tool that returned an error") } if calls != 1 { log.Fatalf("expected OnCall to fire once, got %d", calls) } fmt.Println("ok:", len(list.Tools), "tools listed; boom isError =", res.IsError) } ``` ## MCPServeConfig fields [Section titled “MCPServeConfig fields”](#mcpserveconfig-fields) | Field | Type | Default | | --------- | ---------- | ---------------------------------------------------------------------------- | | `Name` | `string` | `"toolnexus"` | | `Version` | `string` | `"0.1.0"` | | `Tools` | `[]string` | `nil` ⇒ every toolkit tool; set ⇒ exactly that subset, unknown names ignored | `tools/call` maps `ToolResult.Output` to one `{type:"text", text}` content part and `IsError` propagates verbatim; an `Execute` error becomes `isError:true` with the error text — never a server crash. `OnCallEvent{Name, Source, Ms, IsError}` fires per call. ## See also [Section titled “See also”](#see-also) * [`Toolkit.Serve`](/api/go/serve/a2a/) — Publish an Agent Card and answer JSON-RPC over the client loop — your toolkit becomes someone else’s remote agent. * [`BuildAgentCard`](/api/go/serve/agent-card/) — Construct the Agent Card that advertises your name, skills and endpoint. * [`NewFileTaskStore`](/api/go/serve/task-store/) — Persist inbound A2A tasks so a suspended request survives a restart. # MCPHandler — an embeddable MCP server with per-request hooks > Mount a streamable-HTTP MCP server on your own mux, with auth, per-identity tools and a confirm gate. — Go. **Go** · package `github.com/muthuishere/toolnexus/golang` · [SPEC §7C.1](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [golang/mcphandler.go](https://github.com/muthuishere/toolnexus/blob/main/golang/mcphandler.go) ```go func (tk *Toolkit) MCPHandler(cfg MCPHandlerConfig) *MCPHTTPHandler // http.Handler + NotifyToolsChanged() type MCPHandlerConfig struct { Name, Title, Version, Instructions string ServerInfo func(ctx context.Context) MCPServerInfo ProtocolVersions []string // default ["2025-06-18"]; "2025-11-25" opt-in Authenticate func(r *http.Request) (any, error) WWWAuthenticate func(r *http.Request, err error) string ListTools func(ctx context.Context) ([]Tool, error) BeforeCall func(ctx context.Context, tool Tool, args json.RawMessage) (MCPDecision, error) ConfirmTimeout time.Duration OnCall func(ev MCPCallEvent) ListResources, ReadResource, ListPrompts, GetPrompt // optional hooks SessionIDs MCPSessionIDs Stateless bool StatelessSessionID string MaxBodyBytes int64 } IdentityFrom(ctx) any · RequestFrom(ctx) *http.Request · MCPSessionIDFrom(ctx) string · MCPRequestMetaFrom(ctx) map[string]any ``` [`Serve`](/api/go/serve/mcp/) owns a listener and exposes one static tool list. `MCPHandler` is the other shape: a plain `http.Handler` you mount on **your** mux, behind your own TLS, CORS and OAuth, where every policy is a hook. toolnexus has no notion of tenant, user, role or storage — the host decides those inside `Authenticate`, `ListTools` and `BeforeCall`. ## When to use it [Section titled “When to use it”](#when-to-use-it) Reach for `MCPHandler` when one MCP endpoint serves many callers who must see different tools: a multi-tenant SaaS, a gateway that maps an OAuth token to a workspace, or a server whose tools are built at request time from a database. `ListTools` runs per request for both `tools/list` and the `tools/call` lookup, so a name outside the caller’s list is answered with `-32602` and never runs. ## Why this and not the alternative [Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative) Serve vs MCPHandler `Serve(addr, {MCP})` is the zero-config gateway: one listener, one tool list, no auth. Once you need an identity, per-caller tools, a human confirmation before a destructive call or your own session store, `Serve` would have to grow every one of those as an option. `MCPHandler` makes each a hook instead and leaves the HTTP server, and every policy, to you. ## Examples [Section titled “Examples”](#examples) ### 1. Mount it — auth, 401 with `WWW-Authenticate`, identity on the context [Section titled “1. Mount it — auth, 401 with WWW-Authenticate, identity on the context”](#1-mount-it--auth-401-with-www-authenticate-identity-on-the-context) ```go package main import ( "context" "errors" "fmt" "io" "log" "net/http" "net/http/httptest" "strings" toolnexus "github.com/muthuishere/toolnexus/golang" ) func main() { tk, err := toolnexus.CreateToolkit(context.Background(), toolnexus.Options{}) if err != nil { log.Fatal(err) } defer tk.Close() whoami := toolnexus.Tool{ Name: "whoami", Description: "who is calling", Source: toolnexus.SourceNative, InputSchema: toolnexus.JSONSchema{"type": "object"}, Execute: func(_ map[string]any, tc *toolnexus.ToolContext) (toolnexus.ToolResult, error) { return toolnexus.ToolResult{Output: fmt.Sprint(toolnexus.IdentityFrom(tc.Ctx))}, nil }, } h := tk.MCPHandler(toolnexus.MCPHandlerConfig{ Stateless: true, Authenticate: func(r *http.Request) (any, error) { if r.Header.Get("Authorization") == "Bearer alice" { return "alice", nil } return nil, errors.New("bad token") }, WWWAuthenticate: func(*http.Request, error) string { return `Bearer realm="mcp"` }, ListTools: func(context.Context) ([]toolnexus.Tool, error) { return []toolnexus.Tool{whoami}, nil }, }) mux := http.NewServeMux() mux.Handle("/mcp", h) srv := httptest.NewServer(mux) defer srv.Close() post := func(token, body string) (*http.Response, string) { req, _ := http.NewRequest("POST", srv.URL+"/mcp", strings.NewReader(body)) req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", token) resp, err := http.DefaultClient.Do(req) if err != nil { log.Fatal(err) } b, _ := io.ReadAll(resp.Body) resp.Body.Close() return resp, string(b) } resp, _ := post("Bearer mallory", `{"jsonrpc":"2.0","id":1,"method":"ping"}`) if resp.StatusCode != 401 || resp.Header.Get("WWW-Authenticate") != `Bearer realm="mcp"` { log.Fatalf("expected 401 + challenge, got %d", resp.StatusCode) } _, body := post("Bearer alice", `{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"whoami"}}`) if !strings.Contains(body, `"text":"alice"`) { log.Fatalf("unexpected: %s", body) } fmt.Println("ok: 401 for mallory, identity alice reached the tool") } ``` ### 2. Per-identity tools — a name outside `ListTools` is never executed [Section titled “2. Per-identity tools — a name outside ListTools is never executed”](#2-per-identity-tools--a-name-outside-listtools-is-never-executed) ```go package main import ( "context" "fmt" "io" "log" "net/http" "net/http/httptest" "strings" toolnexus "github.com/muthuishere/toolnexus/golang" ) func main() { tk, _ := toolnexus.CreateToolkit(context.Background(), toolnexus.Options{}) defer tk.Close() ran := 0 wipe := toolnexus.Tool{ Name: "wipe", Description: "admin only", Source: toolnexus.SourceNative, Hints: &toolnexus.ToolHints{Destructive: true}, Execute: func(map[string]any, *toolnexus.ToolContext) (toolnexus.ToolResult, error) { ran++ return toolnexus.ToolResult{Output: "wiped"}, nil }, } h := tk.MCPHandler(toolnexus.MCPHandlerConfig{ Stateless: true, Authenticate: func(r *http.Request) (any, error) { return r.Header.Get("X-User"), nil }, ListTools: func(ctx context.Context) ([]toolnexus.Tool, error) { if toolnexus.IdentityFrom(ctx) == "admin" { return []toolnexus.Tool{wipe}, nil } return nil, nil }, }) srv := httptest.NewServer(h) defer srv.Close() call := func(user string) string { req, _ := http.NewRequest("POST", srv.URL, strings.NewReader(`{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"wipe"}}`)) req.Header.Set("Content-Type", "application/json") req.Header.Set("X-User", user) resp, err := http.DefaultClient.Do(req) if err != nil { log.Fatal(err) } defer resp.Body.Close() b, _ := io.ReadAll(resp.Body) return string(b) } if b := call("bob"); !strings.Contains(b, `"tool not found: wipe"`) { log.Fatalf("bob: %s", b) } if ran != 0 { log.Fatal("a tool outside ListTools ran") } if b := call("admin"); !strings.Contains(b, `"text":"wiped"`) { log.Fatalf("admin: %s", b) } fmt.Println("ok: bob got -32602, admin ran it once:", ran) } ``` ### 3. A confirm gate with a shaped fallback, structured results and `OnCall` [Section titled “3. A confirm gate with a shaped fallback, structured results and OnCall”](#3-a-confirm-gate-with-a-shaped-fallback-structured-results-and-oncall) ```go package main import ( "context" "encoding/json" "fmt" "io" "log" "net/http" "net/http/httptest" "strings" toolnexus "github.com/muthuishere/toolnexus/golang" ) func main() { tk, _ := toolnexus.CreateToolkit(context.Background(), toolnexus.Options{}) defer tk.Close() del := toolnexus.Tool{ Name: "delete_record", Description: "deletes a record", Source: toolnexus.SourceNative, Execute: func(map[string]any, *toolnexus.ToolContext) (toolnexus.ToolResult, error) { return toolnexus.ToolResult{Output: "deleted"}, nil }, } stats := toolnexus.Tool{ Name: "stats", Description: "counts", Source: toolnexus.SourceNative, OutputSchema: toolnexus.JSONSchema{"type": "object", "properties": map[string]any{"count": map[string]any{"type": "integer"}}}, Execute: func(map[string]any, *toolnexus.ToolContext) (toolnexus.ToolResult, error) { return toolnexus.ToolResult{Structured: map[string]any{"count": 3}}, nil }, } var decisions []string h := tk.MCPHandler(toolnexus.MCPHandlerConfig{ Stateless: true, // stateless ⇒ no elicitation ⇒ confirm takes the fallback path ListTools: func(context.Context) ([]toolnexus.Tool, error) { return []toolnexus.Tool{del, stats}, nil }, BeforeCall: func(_ context.Context, t toolnexus.Tool, _ json.RawMessage) (toolnexus.MCPDecision, error) { if t.Name == "delete_record" { return toolnexus.MCPDecision{Kind: toolnexus.MCPConfirm, Message: "Delete it?", Result: &toolnexus.ToolResult{Output: "re-call with nonce n-1"}}, nil } return toolnexus.MCPDecision{}, nil }, OnCall: func(ev toolnexus.MCPCallEvent) { decisions = append(decisions, ev.Name+"="+string(ev.Decision)) }, }) srv := httptest.NewServer(h) defer srv.Close() call := func(name string) string { resp, err := http.Post(srv.URL, "application/json", strings.NewReader(`{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"`+name+`"}}`)) if err != nil { log.Fatal(err) } defer resp.Body.Close() b, _ := io.ReadAll(resp.Body) return string(b) } if b := call("delete_record"); !strings.Contains(b, "re-call with nonce n-1") { log.Fatalf("fallback: %s", b) } if b := call("stats"); !strings.Contains(b, `"structuredContent":{"count":3}`) { log.Fatalf("structured: %s", b) } fmt.Println("ok:", strings.Join(decisions, ", ")) } ``` Confirm over elicitation When the session’s client declared `capabilities.elicitation` at `initialize`, a `Confirm` decision sends `elicitation/create` on the same POST’s event stream and waits (≤ `ConfirmTimeout`); `accept` runs the tool, anything else is `isError` “declined”. Client capabilities are cached on the instance that saw `initialize`, so a multi-instance host needs sticky routing for confirms. ## See also [Section titled “See also”](#see-also) * [`ExposedMcpTools`](/api/go/serve/mcp/) — The inbound MCP profile on `Serve`: any MCP client can call your tools. * [`Toolkit.Serve`](/api/go/serve/a2a/) — Publish an Agent Card and answer JSON-RPC over the client loop. # NewFileTaskStore > Persist inbound A2A tasks so a suspended request survives a restart. — Go. **Go** · package `github.com/muthuishere/toolnexus/golang` · [SPEC §7B](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [golang/serve.go](https://github.com/muthuishere/toolnexus/blob/main/golang/serve.go) ```go type TaskStore interface { Get(id string) (*A2ATask, error) // nil, nil ⇒ unknown id (never an error) Save(task A2ATask) error } func NewFileTaskStore(dir string) *FileTaskStore ``` `FileTaskStore` is one JSON file per Task id, under `dir` (created if missing). Writes are atomic — a temp file in the same directory, then an atomic rename — so a concurrent `Get` can never observe a half-written Task mid-poll. It implements the same `TaskStore` interface [`Toolkit.Serve`](/api/go/serve/a2a/) uses by default (in-memory); swap it in via `A2AConfig.Store` to survive a process restart. ## When to use it [Section titled “When to use it”](#when-to-use-it) Reach for `NewFileTaskStore` (or `A2AConfig{Store: "file:"}`) whenever a served Task might outlive the process — a long-running fulfilment, or a §10 suspension where the peer needs to poll `GetTask` again after your process restarts. ## Why this and not the alternative [Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative) Short-lived server, or tests? Stay on the in-memory default `A2AConfig.Store` left unset (or `"memory"`) uses `InMemoryTaskStore` — Tasks live only for the process lifetime, zero setup. Reach for `NewFileTaskStore` / `"file:"` specifically for the restart-survival guarantee; anywhere durability doesn’t matter, the default is simpler and faster. A custom `TaskStore` (e.g. backed by a database) is also a first-class `A2AConfig.Store` value — `ResolveStore` accepts a `TaskStore` value as-is. ## Examples [Section titled “Examples”](#examples) ### 1. Save a Task, read it back [Section titled “1. Save a Task, read it back”](#1-save-a-task-read-it-back) ```go package main import ( "fmt" "log" "os" toolnexus "github.com/muthuishere/toolnexus/golang" ) func main() { dir, err := os.MkdirTemp("", "toolnexus-docs-taskstore-1") if err != nil { log.Fatal(err) } defer os.RemoveAll(dir) store := toolnexus.NewFileTaskStore(dir) task := toolnexus.A2ATask{ID: "t1", Status: toolnexus.A2ATaskStatus{State: "completed"}} if err := store.Save(task); err != nil { log.Fatal(err) } got, err := store.Get("t1") if err != nil || got == nil { log.Fatalf("unexpected: %+v %v", got, err) } if got.Status.State != "completed" { log.Fatalf("unexpected state: %q", got.Status.State) } fmt.Println("ok: task", got.ID, "state", got.Status.State) } ``` ### 2. An unknown id is `(nil, nil)`, never an error [Section titled “2. An unknown id is (nil, nil), never an error”](#2-an-unknown-id-is-nil-nil-never-an-error) ```go package main import ( "fmt" "log" "os" toolnexus "github.com/muthuishere/toolnexus/golang" ) func main() { dir, err := os.MkdirTemp("", "toolnexus-docs-taskstore-2") if err != nil { log.Fatal(err) } defer os.RemoveAll(dir) store := toolnexus.NewFileTaskStore(dir) got, err := store.Get("does-not-exist") if err != nil { log.Fatalf("Get on a missing id should not error, got %v", err) } if got != nil { log.Fatalf("expected nil for a missing id, got %+v", got) } // Saving again under the same id overwrites (atomic rename — a concurrent // Get during the write always sees the old or the new file, never neither). _ = store.Save(toolnexus.A2ATask{ID: "t1", Status: toolnexus.A2ATaskStatus{State: "working"}}) _ = store.Save(toolnexus.A2ATask{ID: "t1", Status: toolnexus.A2ATaskStatus{State: "completed"}}) final, _ := store.Get("t1") if final.Status.State != "completed" { log.Fatalf("expected the latest save to win, got %q", final.Status.State) } fmt.Println("ok: unknown id →", got, "; overwrite settles at", final.Status.State) } ``` ### 3. The full surface — wired into `Toolkit.Serve`, then read straight off disk [Section titled “3. The full surface — wired into Toolkit.Serve, then read straight off disk”](#3-the-full-surface--wired-into-toolkitserve-then-read-straight-off-disk) A Task submitted through a REAL local A2A round trip lands on disk; a second, brand-new `FileTaskStore` pointed at the same directory (simulating a process restart) can read it directly. ```go package main import ( "bytes" "context" "encoding/json" "fmt" "log" "net/http" "net/http/httptest" "os" "time" toolnexus "github.com/muthuishere/toolnexus/golang" ) func mockLLM(reply string) *httptest.Server { return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("content-type", "application/json") _ = json.NewEncoder(w).Encode(map[string]any{ "choices": []any{map[string]any{"message": map[string]any{"role": "assistant", "content": reply}}}, "usage": map[string]any{"prompt_tokens": 3, "completion_tokens": 2, "total_tokens": 5}, }) })) } func main() { llm := mockLLM("done") defer llm.Close() dir, err := os.MkdirTemp("", "toolnexus-docs-taskstore-3") if err != nil { log.Fatal(err) } defer os.RemoveAll(dir) tk, err := toolnexus.CreateToolkit(context.Background(), toolnexus.Options{Builtins: false}) if err != nil { log.Fatal(err) } defer tk.Close() client := toolnexus.CreateClient(toolnexus.ClientOptions{BaseURL: llm.URL, Style: toolnexus.StyleOpenAI, Model: "x", APIKey: "k"}) handle, err := tk.Serve("127.0.0.1:0", toolnexus.ServeOptions{ Client: client, A2A: &toolnexus.A2AConfig{Name: "durable-desk", Store: "file:" + dir}, }) if err != nil { log.Fatal(err) } defer handle.Stop() // Drive a task through the wire protocol directly. body, _ := json.Marshal(map[string]any{ "jsonrpc": "2.0", "id": 1, "method": "SendMessage", "params": map[string]any{"message": map[string]any{"role": "user", "parts": []any{map[string]any{"kind": "text", "text": "go"}}}}, }) resp, err := http.Post(handle.URL, "application/json", bytes.NewReader(body)) if err != nil { log.Fatal(err) } var out map[string]any _ = json.NewDecoder(resp.Body).Decode(&out) resp.Body.Close() taskID := out["result"].(map[string]any)["id"].(string) // Poll GetTask to completion (bounded), then read the SAME id from a FRESH // FileTaskStore pointed at the same directory — as if the process restarted. for i := 0; i < 200; i++ { gb, _ := json.Marshal(map[string]any{"jsonrpc": "2.0", "id": 2, "method": "GetTask", "params": map[string]any{"id": taskID}}) gresp, _ := http.Post(handle.URL, "application/json", bytes.NewReader(gb)) var gout map[string]any _ = json.NewDecoder(gresp.Body).Decode(&gout) gresp.Body.Close() result, _ := gout["result"].(map[string]any) status, _ := result["status"].(map[string]any) if state, _ := status["state"].(string); state == "completed" { break } time.Sleep(5 * time.Millisecond) } reopened := toolnexus.NewFileTaskStore(dir) persisted, err := reopened.Get(taskID) if err != nil || persisted == nil { log.Fatalf("expected the task to survive on disk: %+v %v", persisted, err) } fmt.Println("ok: task", taskID, "read back after a simulated restart, state =", persisted.Status.State) } ``` ## See also [Section titled “See also”](#see-also) * [`Toolkit.Serve`](/api/go/serve/a2a/) — Publish an Agent Card and answer JSON-RPC over the client loop — your toolkit becomes someone else’s remote agent. * [`BuildAgentCard`](/api/go/serve/agent-card/) — Construct the Agent Card that advertises your name, skills and endpoint. * [`ExposedMcpTools`](/api/go/serve/mcp/) — The inbound MCP profile: any MCP client can call your tools. # ListSkills > Enumerate discovered skills and, crucially, the ones that were skipped and why. Go. **Go** · module `github.com/muthuishere/toolnexus/golang` · [SPEC §3](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [golang/skill.go](https://github.com/muthuishere/toolnexus/blob/main/golang/skill.go) ```go func ListSkills(opts LoadSkillsOptions) *SkillInventory type SkillInventory struct { Skills []SkillInfo Skipped []SkillSkip } type SkillSkip struct { Location string Reason SkillSkipReason } ``` Runs exactly the discovery and validation pass [`LoadSkills`](/api/go/skills/load/) runs, but builds **no tool** — it hands you the parsed skills *and* a typed list of every candidate that did not become a skill, with the reason. It is the answer to “I dropped a `SKILL.md` in and nothing showed up.” ## When to use it [Section titled “When to use it”](#when-to-use-it) * **Debugging a skill that will not load.** `Skipped` names the file and tells you whether the frontmatter was malformed, the `name` was missing, the file was unreadable, or the name collided. * **Authoring an allowlist.** The inventory is deliberately **unfiltered**, so you can list everything, then choose what a given agent gets via `LoadSkillsOptions.Filter`. * **A CI check or a `skills doctor` command** — fail the build when a skill directory contains a broken `SKILL.md`. ## Why this and not the alternative [Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative) Use LoadSkills / LoadSkillsWith when you want the tool [`LoadSkillsWith`](/api/go/skills/load-with/) takes the same `LoadSkillsOptions` and returns a `SkillSource` with the live `skill` tool — reach for it *instead* whenever you intend to actually serve the skills to a model. `ListSkills` deliberately gives you no tool and no filtering; it is a read-only inspection pass, so use it only to report on skills rather than to run them. Filter is ignored here — on purpose `LoadSkillsOptions.Filter` has no effect on the inventory. The whole point is to see everything so you can *write* the filter. `Skills` is also an unordered slice — sort it before printing. ## Examples [Section titled “Examples”](#examples) ### 1. Inventory the shared fixture [Section titled “1. Inventory the shared fixture”](#1-inventory-the-shared-fixture) ```go package main import ( "fmt" "log" "os" "path/filepath" toolnexus "github.com/muthuishere/toolnexus/golang" ) func main() { // TOOLNEXUS_REPO is set by the docs test runner; in your own code just use a path. dir := filepath.Join(os.Getenv("TOOLNEXUS_REPO"), "examples", "skills") inv := toolnexus.ListSkills(toolnexus.LoadSkillsOptions{Dirs: []string{dir}}) if len(inv.Skills) != 1 || inv.Skills[0].Name != "hello-world" { log.Fatalf("unexpected inventory: %+v", inv.Skills) } // A clean directory skips nothing. if len(inv.Skipped) != 0 { log.Fatalf("expected no skips, got %+v", inv.Skipped) } fmt.Println("ok:", inv.Skills[0].Name, "| skipped:", len(inv.Skipped)) } ``` ### 2. The reason a `SKILL.md` did not load [Section titled “2. The reason a SKILL.md did not load”](#2-the-reason-a-skillmd-did-not-load) Four typed reasons, and this is how each one shows up. ```go package main import ( "fmt" "log" "os" "path/filepath" toolnexus "github.com/muthuishere/toolnexus/golang" ) func write(dir, name, body string) { full := filepath.Join(dir, name) if err := os.MkdirAll(full, 0o755); err != nil { log.Fatal(err) } if err := os.WriteFile(filepath.Join(full, "SKILL.md"), []byte(body), 0o644); err != nil { log.Fatal(err) } } func main() { root, err := os.MkdirTemp("", "skills") if err != nil { log.Fatal(err) } defer os.RemoveAll(root) write(root, "good", "---\nname: good\ndescription: Fine.\n---\n\nBody.\n") // Fences present, YAML invalid — and `[` opens a construct the lenient // rescue refuses, so no name can be recovered. write(root, "broken", "---\nname: [oops\n---\n\nBody.\n") // Parses, but has no name — nothing to call it by. write(root, "unnamed", "---\ndescription: No name here.\n---\n\nBody.\n") inv := toolnexus.ListSkills(toolnexus.LoadSkillsOptions{Dirs: []string{root}}) if len(inv.Skills) != 1 || inv.Skills[0].Name != "good" { log.Fatalf("expected only 'good', got %+v", inv.Skills) } reasons := map[toolnexus.SkillSkipReason]string{} for _, s := range inv.Skipped { reasons[s.Reason] = s.Location } if _, ok := reasons[toolnexus.SkipMalformed]; !ok { log.Fatalf("expected a malformed-frontmatter skip, got %+v", inv.Skipped) } if _, ok := reasons[toolnexus.SkipMissingName]; !ok { log.Fatalf("expected a missing-name skip, got %+v", inv.Skipped) } // Reasons are typed constants with stable string values. if string(toolnexus.SkipMalformed) != "malformed-frontmatter" { log.Fatal("skip reason values are part of the contract") } fmt.Println("ok: 1 skill,", len(inv.Skipped), "skips") } ``` Caution A file with **no** frontmatter fences at all is not “malformed” — it parses to an empty header and is skipped as `missing-name`. `malformed-frontmatter` means the fences were there, the YAML inside them failed to parse, **and** the lenient line-wise rescue could not recover a `name` either. A near-miss like `name: "oops` is rescued and loads (as `"oops`); only a value that opens a YAML construct the rescue declines — `| > & * [ { !` — leaves nothing to recover. ### 3. Every source, duplicate detection, and authoring the allowlist [Section titled “3. Every source, duplicate detection, and authoring the allowlist”](#3-every-source-duplicate-detection-and-authoring-the-allowlist) Directories and data skills go into the same inventory, and the second skill to claim a name is reported as `duplicate-name` — first one wins. ```go package main import ( "fmt" "log" "os" "path/filepath" "sort" toolnexus "github.com/muthuishere/toolnexus/golang" ) func main() { repo := os.Getenv("TOOLNEXUS_REPO") // docs-runner detail shared := filepath.Join(repo, "examples", "skills") opts := toolnexus.LoadSkillsOptions{ Dirs: []string{shared}, Skills: []toolnexus.SkillDef{ { Name: "deploy", Description: "Ship the service to prod.", Content: "Run the taskfile.", Resources: []string{"skill://deploy/checklist.md"}, }, // Collides with the fixture on disk — dirs are collected first, so this loses. {Name: "hello-world", Description: "A shadow.", Content: "Ignored."}, // No name at all. {Description: "Anonymous.", Content: "Ignored."}, }, // Filter is accepted but has NO effect on the inventory. Filter: map[string]bool{"deploy": true}, } inv := toolnexus.ListSkills(opts) var names []string for _, s := range inv.Skills { names = append(names, s.Name) } sort.Strings(names) if len(names) != 2 || names[0] != "deploy" || names[1] != "hello-world" { log.Fatalf("unfiltered inventory expected, got %v", names) } var dup, missing int for _, s := range inv.Skipped { switch s.Reason { case toolnexus.SkipDuplicateName: dup++ case toolnexus.SkipMissingName: missing++ } } if dup != 1 || missing != 1 { log.Fatalf("expected 1 duplicate + 1 missing-name, got %+v", inv.Skipped) } // The fixture on disk won: hello-world is still the fs-sourced one. for _, s := range inv.Skills { if s.Name == "hello-world" && s.Origin != "fs" { log.Fatalf("first source should win, got origin %q", s.Origin) } if s.Name == "deploy" && s.Origin != "logical" { log.Fatalf("data skills are logical, got %q", s.Origin) } } // Now author the allowlist from what you actually found, and load for real. allow := map[string]bool{} for _, s := range inv.Skills { allow[s.Name] = s.Name == "deploy" } src := toolnexus.LoadSkillsWith(toolnexus.LoadSkillsOptions{ Dirs: opts.Dirs, Skills: opts.Skills, Filter: allow, }) if len(src.Skills) != 1 { log.Fatalf("expected the filter to narrow to 1, got %v", src.Skills) } fmt.Println("ok:", names, "| skipped:", len(inv.Skipped)) } ``` ## Options and results [Section titled “Options and results”](#options-and-results) `LoadSkillsOptions` — the same struct [`LoadSkillsWith`](/api/go/skills/load-with/) takes: | Field | Type | Effect on `ListSkills` | | ------------- | ----------------- | ------------------------------------------------------------------------------------- | | `Dirs` | `[]string` | Roots walked for `**/SKILL.md`. A missing root logs and contributes nothing. | | `Skills` | `[]SkillDef` | Skills supplied as data. Collected **after** `Dirs`, so directories win a name clash. | | `Filter` | `map[string]bool` | **Ignored** — the inventory is always unfiltered. | | `SampleLimit` | `int` | **Ignored** — no tool is built, so nothing is sampled. | `SkillSkipReason` — the typed values in `Skipped`: | Constant | Value | Means | | ------------------- | ----------------------- | ----------------------------------------------------------------------------- | | `SkipMissingName` | `missing-name` | Frontmatter parsed but carried no `name` (including “no frontmatter at all”). | | `SkipMalformed` | `malformed-frontmatter` | `---` fences present, YAML inside failed to parse. | | `SkipDuplicateName` | `duplicate-name` | Another candidate already claimed that name; the first one is kept. | | `SkipUnreadable` | `unreadable` | The `SKILL.md` could not be read (permissions, broken link). | ## See also [Section titled “See also”](#see-also) * [`LoadSkills`](/api/go/skills/load/) — discover and build the `skill` tool * [`LoadSkillsWith`](/api/go/skills/load-with/) — data skills, allowlists, sample caps * [`CreateToolkit`](/api/go/toolkit/create/) — wires the skill source into a toolkit # LoadSkills > Glob a skills directory and expose one skill tool with progressive disclosure. Go. **Go** · module `github.com/muthuishere/toolnexus/golang` · [SPEC §3](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [golang/skill.go](https://github.com/muthuishere/toolnexus/blob/main/golang/skill.go) ```go func LoadSkills(dirs ...string) *SkillSource type SkillSource struct { Skills map[string]SkillInfo Tool Tool } func (s *SkillSource) Prompt() string ``` Walks one or more roots for `**/SKILL.md`, parses each file’s YAML frontmatter, and returns a `SkillSource` holding the discovered skills plus **one** tool named `skill`. That single tool is the whole point: the model sees a short catalog in the system prompt and pays for a skill’s full instructions only when it loads one. That is progressive disclosure. ## When to use it [Section titled “When to use it”](#when-to-use-it) * **You keep prompt playbooks on disk** — a `skills/` folder of markdown files, each with a name and a description — and want the model to pull them in on demand. * **You want a catalog, not N tools.** Twenty skills still cost one tool slot and twenty catalog lines, instead of twenty full schemas. * **You are wiring a toolkit by hand** and want the `skill` tool without the rest of [`CreateToolkit`](/api/go/toolkit/create/). ## Why this and not the alternative [Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative) Use LoadSkillsWith for anything beyond plain directories [`LoadSkillsWith`](/api/go/skills/load-with/) takes a `LoadSkillsOptions` struct and is the one to reach for *instead* of this when your skills do not come from a plain directory — supplied as data (`Skills []SkillDef`), narrowed per agent (`Filter`), or with the sibling-file sample capped (`SampleLimit`). `LoadSkills(dirs...)` is exactly `LoadSkillsWith(LoadSkillsOptions{Dirs: dirs})`, kept as the short variadic path for the common case. A missing directory is a log line, not an error `LoadSkills` returns no error. A root that does not exist logs `[toolnexus] skills dir not found` and yields zero skills — so an empty `Skills` map is your signal that a path is wrong. ## Examples [Section titled “Examples”](#examples) ### 1. Load the shared fixture directory [Section titled “1. Load the shared fixture directory”](#1-load-the-shared-fixture-directory) `examples/skills` is the fixture every port is tested against. ```go package main import ( "fmt" "log" "os" "path/filepath" toolnexus "github.com/muthuishere/toolnexus/golang" ) func main() { // TOOLNEXUS_REPO is set by the docs test runner; in your own code just use a path. dir := filepath.Join(os.Getenv("TOOLNEXUS_REPO"), "examples", "skills") src := toolnexus.LoadSkills(dir) info, ok := src.Skills["hello-world"] if !ok { log.Fatalf("expected hello-world, got %v", src.Skills) } if info.Description == "" { log.Fatal("description comes from the SKILL.md frontmatter") } // Location is the ABSOLUTE path of the SKILL.md itself, not its directory. if filepath.Base(info.Location) != "SKILL.md" { log.Fatalf("unexpected location: %s", info.Location) } // One tool, always named "skill", regardless of how many skills were found. if src.Tool.Name != "skill" || src.Tool.Source != toolnexus.SourceSkill { log.Fatalf("unexpected tool: %+v", src.Tool) } fmt.Println("ok:", info.Name, "| tool:", src.Tool.Name) } ``` ### 2. Actually load a skill — what the model gets back [Section titled “2. Actually load a skill — what the model gets back”](#2-actually-load-a-skill--what-the-model-gets-back) The `skill` tool takes `{"name": ""}` and returns the instructions wrapped in ``, plus a base directory and a sampled file list. ```go package main import ( "fmt" "log" "os" "path/filepath" "strings" toolnexus "github.com/muthuishere/toolnexus/golang" ) func main() { // TOOLNEXUS_REPO is a docs-runner detail; use your own path in real code. dir := filepath.Join(os.Getenv("TOOLNEXUS_REPO"), "examples", "skills") src := toolnexus.LoadSkills(dir) res, err := src.Tool.Execute(map[string]any{"name": "hello-world"}, nil) if err != nil || res.IsError { log.Fatalf("unexpected: %+v %v", res, err) } for _, want := range []string{ ``, "# Skill: hello-world", "Base directory for this skill: file://", "", "", } { if !strings.Contains(res.Output, want) { log.Fatalf("missing %q in output:\n%s", want, res.Output) } } // Metadata carries the skill name and its on-disk directory. if res.Metadata["name"] != "hello-world" { log.Fatalf("unexpected metadata: %v", res.Metadata) } // An unknown name is an error RESULT the model can recover from, not a Go error. miss, err := src.Tool.Execute(map[string]any{"name": "nope"}, nil) if err != nil { log.Fatal(err) } if !miss.IsError || !strings.Contains(miss.Output, "Available skills:") { log.Fatalf("expected a recoverable miss, got %+v", miss) } fmt.Println("ok: loaded hello-world,", len(res.Output), "bytes") } ``` ### 3. Catalog, multiple roots, and wiring it into a prompt [Section titled “3. Catalog, multiple roots, and wiring it into a prompt”](#3-catalog-multiple-roots-and-wiring-it-into-a-prompt) `Prompt()` is what you paste into the system prompt: a fixed preamble plus one line per **described** skill, sorted by name. ```go package main import ( "fmt" "log" "os" "path/filepath" "strings" toolnexus "github.com/muthuishere/toolnexus/golang" ) func main() { repo := os.Getenv("TOOLNEXUS_REPO") // docs-runner detail shared := filepath.Join(repo, "examples", "skills") // A second root written on the fly — roots are merged, first name wins. extra, err := os.MkdirTemp("", "skills") if err != nil { log.Fatal(err) } defer os.RemoveAll(extra) deploy := filepath.Join(extra, "deploy") if err := os.MkdirAll(deploy, 0o755); err != nil { log.Fatal(err) } skillMD := "---\nname: deploy\ndescription: Ship the service to prod.\n---\n\nRun the taskfile.\n" if err := os.WriteFile(filepath.Join(deploy, "SKILL.md"), []byte(skillMD), 0o644); err != nil { log.Fatal(err) } // A SKILL.md with no name in its frontmatter is silently skipped. nameless := filepath.Join(extra, "nameless") if err := os.MkdirAll(nameless, 0o755); err != nil { log.Fatal(err) } if err := os.WriteFile(filepath.Join(nameless, "SKILL.md"), []byte("no frontmatter here\n"), 0o644); err != nil { log.Fatal(err) } src := toolnexus.LoadSkills(shared, extra) if len(src.Skills) != 2 { log.Fatalf("expected hello-world + deploy, got %v", src.Skills) } prompt := src.Prompt() if !strings.HasPrefix(prompt, toolnexus.SkillsPromptPreamble) { log.Fatalf("unexpected preamble:\n%s", prompt) } if !strings.Contains(prompt, "## Available Skills") { log.Fatal("expected the catalog header") } // Sorted by name: deploy before hello-world. if strings.Index(prompt, "**deploy**") > strings.Index(prompt, "**hello-world**") { log.Fatalf("catalog not sorted:\n%s", prompt) } // A skill from the second root loads through the very same tool. res, err := src.Tool.Execute(map[string]any{"name": "deploy"}, nil) if err != nil || res.IsError || !strings.Contains(res.Output, "Run the taskfile.") { log.Fatalf("unexpected: %+v %v", res, err) } // No skills at all still gives you a usable source and tool. empty := toolnexus.LoadSkills() if len(empty.Skills) != 0 || empty.Tool.Name != "skill" { log.Fatalf("unexpected empty source: %+v", empty) } if empty.Prompt() != "No skills are currently available." { log.Fatalf("unexpected empty prompt: %q", empty.Prompt()) } fmt.Println("ok: 2 skills across 2 roots") } ``` ## What you get back [Section titled “What you get back”](#what-you-get-back) | Field / method | Type | What it is | | -------------- | ---------------------- | --------------------------------------------------------------------------------- | | `Skills` | `map[string]SkillInfo` | Discovered skills keyed by frontmatter `name`. Unordered — sort before printing. | | `Tool` | `Tool` | The single loader tool, always named `skill`, `Source: "skill"`. | | `Prompt()` | `string` | Catalog for the system prompt; `"No skills are currently available."` when empty. | `SkillInfo` fields: | Field | Type | What it is | | ------------- | ---------- | --------------------------------------------------------------------------------------- | | `Name` | `string` | From frontmatter. A file without one is skipped. | | `Description` | `string` | From frontmatter. Skills with none are discovered but omitted from `Prompt()`. | | `Location` | `string` | Absolute path to the `SKILL.md` (directory-sourced) or the logical base (data-sourced). | | `Content` | `string` | The body after the frontmatter. | | `Origin` | `string` | `"fs"` or `"logical"` — how the skill was sourced. | | `Resources` | `[]string` | Logical resource list; data-sourced skills only. | | `Base` | `string` | Logical base; data-sourced skills only. | Discovery skips `node_modules` and `.git`, follows symlinks once (cycle-safe), and samples up to 10 sibling files into ``. ## See also [Section titled “See also”](#see-also) * [`LoadSkillsWith`](/api/go/skills/load-with/) — data skills, allowlists, sample caps * [`ListSkills`](/api/go/skills/list/) — inventory plus the skips and why * [`CreateToolkit`](/api/go/toolkit/create/) — takes `skills` directly and wires this for you * [`Tool`](/api/go/types/tool/) — what `Tool` is # LoadSkillsWith > Supply skills as data or from a lazy provider, and narrow them per agent with an allowlist. — Go. **Go** · module `github.com/muthuishere/toolnexus/golang` · [SPEC §3](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [golang/skill.go](https://github.com/muthuishere/toolnexus/blob/main/golang/skill.go) ```go func LoadSkillsWith(opts LoadSkillsOptions) *SkillSource ``` The options-taking sibling of [`LoadSkills`](/api/go/skills/load/) (`LoadSkills(dirs...)` is just `LoadSkillsWith(LoadSkillsOptions{Dirs: dirs})`). Where `LoadSkills` only globs directories, `LoadSkillsWith` also accepts skills supplied directly **as data** (`Skills []SkillDef`, §3 S1 — no filesystem involved) and a per-agent **allowlist** (`Filter map[string]bool`, §3 S2) so two agents sharing one skill catalog can each see a different subset. A lazy provider is a Toolkit-level concept, not a LoadSkillsOptions field `SkillProvider func(context.Context) ([]SkillDef, error)` lives on [`CreateToolkit`](/api/go/toolkit/create/)’s `Options`, not on `LoadSkillsOptions` — `CreateToolkit` resolves the provider once, merges the result into `Skills`, and *then* calls `LoadSkillsWith`. A provider failure there is isolated: the other skill sources still load. Call `LoadSkillsWith` directly when you already have the skill data in hand; go through `CreateToolkit` when the data needs to be fetched lazily (a database, an API). ## When to use it [Section titled “When to use it”](#when-to-use-it) Reach for `LoadSkillsWith` whenever plain directory globbing (`LoadSkills`) is not enough: skills that live in a database or come back from an API rather than `SKILL.md` files on disk, or a fleet of agents where each one should see a *different* slice of one shared catalog. ## Why this and not the alternative [Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative) LoadSkills is the shorthand for the common case [`LoadSkills`](/api/go/skills/load/) is `LoadSkillsWith` with only `Dirs` set — reach for it when every skill is a `SKILL.md` on disk and every agent should see all of them. Reach for [`ListSkills`](/api/go/skills/list/) instead of either when you want the discovery pass without a built `skill` tool — useful for validating a catalog and seeing what got skipped and why. ## Examples [Section titled “Examples”](#examples) ### 1. Skills supplied as data, no filesystem involved [Section titled “1. Skills supplied as data, no filesystem involved”](#1-skills-supplied-as-data-no-filesystem-involved) ```go package main import ( "fmt" "log" toolnexus "github.com/muthuishere/toolnexus/golang" ) func main() { src := toolnexus.LoadSkillsWith(toolnexus.LoadSkillsOptions{ Skills: []toolnexus.SkillDef{ {Name: "deploy", Description: "Ship a release", Content: "Run the release checklist."}, }, }) if len(src.Skills) != 1 { log.Fatalf("expected 1 skill, got %d", len(src.Skills)) } if _, ok := src.Skills["deploy"]; !ok { log.Fatal("expected the 'deploy' skill to be present") } res, err := src.Tool.Execute(map[string]any{"name": "deploy"}, nil) if err != nil || res.IsError { log.Fatalf("unexpected: %+v %v", res, err) } fmt.Println("ok:", src.Skills["deploy"].Name) } ``` ### 2. The shared fixture, filtered down for one agent [Section titled “2. The shared fixture, filtered down for one agent”](#2-the-shared-fixture-filtered-down-for-one-agent) Loaded from `examples/skills`, then narrowed to a subset via `Filter` — the allowlist is per-agent, not global, so a second `LoadSkillsWith` call over the same directory could see a different set. ```go package main import ( "fmt" "log" "os" "path/filepath" toolnexus "github.com/muthuishere/toolnexus/golang" ) func main() { dir := filepath.Join(os.Getenv("TOOLNEXUS_REPO"), "examples", "skills") all := toolnexus.LoadSkillsWith(toolnexus.LoadSkillsOptions{Dirs: []string{dir}}) if _, ok := all.Skills["hello-world"]; !ok { log.Fatal("expected 'hello-world' in the shared fixture") } // An allowlist naming a skill that doesn't exist filters everything out — // it does not fall back to "all". narrowed := toolnexus.LoadSkillsWith(toolnexus.LoadSkillsOptions{ Dirs: []string{dir}, Filter: map[string]bool{"some-other-skill": true}, }) if len(narrowed.Skills) != 0 { log.Fatalf("expected the filter to exclude every skill, got %d", len(narrowed.Skills)) } fmt.Println("ok: unfiltered=", len(all.Skills), "filtered=", len(narrowed.Skills)) } ``` ### 3. Directories and data merged, with a sample cap [Section titled “3. Directories and data merged, with a sample cap”](#3-directories-and-data-merged-with-a-sample-cap) `Dirs` and `Skills` combine into one catalog; `SampleLimit` bounds how many sibling files the loader tool reports per skill (`0` ⇒ default 10, `-1` ⇒ omit the file list entirely). ```go package main import ( "fmt" "log" "os" "path/filepath" "strings" toolnexus "github.com/muthuishere/toolnexus/golang" ) func main() { dir := filepath.Join(os.Getenv("TOOLNEXUS_REPO"), "examples", "skills") src := toolnexus.LoadSkillsWith(toolnexus.LoadSkillsOptions{ Dirs: []string{dir}, Skills: []toolnexus.SkillDef{ {Name: "from-data", Description: "Supplied directly, not from disk", Content: "Body."}, }, SampleLimit: -1, // omit entirely }) if len(src.Skills) != 2 { log.Fatalf("expected 2 skills (fs + data), got %d", len(src.Skills)) } res, err := src.Tool.Execute(map[string]any{"name": "from-data"}, nil) if err != nil || res.IsError { log.Fatalf("unexpected: %+v %v", res, err) } if strings.Contains(res.Output, "") { log.Fatal("expected SampleLimit:-1 to omit the skill_files block") } fmt.Println("ok:", len(src.Skills), "skills, no file sampling") } ``` ## Options (`LoadSkillsOptions`) [Section titled “Options (LoadSkillsOptions)”](#options-loadskillsoptions) | Field | Type | What it does | | ------------- | ----------------- | -------------------------------------------------------------------------------------------------------------------------------- | | `Dirs` | `[]string` | Skill roots to glob for `**/SKILL.md`. | | `Skills` | `[]SkillDef` | Skills supplied directly as data — no filesystem involved. | | `Filter` | `map[string]bool` | Per-agent allowlist. `nil`/empty ⇒ all; ≥1 `true` ⇒ allowlist; only-`false` ⇒ drop-list; unknown names are ignored (and logged). | | `SampleLimit` | `int` | `0` ⇒ default 10 sibling files sampled, `n>0` ⇒ that cap, `-1` ⇒ omit `` from the tool’s output. | ## Return value [Section titled “Return value”](#return-value) `*SkillSource` — same shape as [`LoadSkills`](/api/go/skills/load/) returns: `Skills map[string]SkillInfo` plus the built `Tool` named `skill`. ## See also [Section titled “See also”](#see-also) * [`LoadSkills`](/api/go/skills/load/) — glob a skills directory and expose one skill tool with progressive disclosure. * [`ListSkills`](/api/go/skills/list/) — enumerate discovered skills and, crucially, the ones that were skipped and why. * [`CreateToolkit`](/api/go/toolkit/create/) — the aggregator; its `SkillProvider` resolves lazily and then flows into this same options struct. # AnswerDeclined > Wraps a human's refusal into the Answer a suspended run resumes with, carrying a reason string that defaults to "declined". — Go. **Go** · package `github.com/muthuishere/toolnexus/golang` · [SPEC §10](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [golang/relay.go](https://github.com/muthuishere/toolnexus/blob/main/golang/relay.go) ```go func AnswerDeclined(id, reason string) Answer // reason == "" ⇒ "declined" type Answer struct { ID string `json:"id"` Ok bool `json:"ok"` // false here — a refusal, not an error Data map[string]any `json:"data,omitempty"` Reason string `json:"reason,omitempty"` } ``` `AnswerDeclined(id, reason)` builds the `Answer` a host returns when a human — or the host itself — **refused** the suspended request: no payload, `Ok: false`, and a `Reason` the loop feeds back to the model. An empty `reason` defaults to `"declined"` rather than an empty string, so the model never sees a refusal with nothing attached to explain it. A declined relay is an **error tool\_result, not an aborted run** (§10, ADR-0010 D6): the run keeps going, the model reads the refusal in its next turn, and can recover — try something else, ask a follow-up, or report back to the user that access was denied. ## When to use it [Section titled “When to use it”](#when-to-use-it) Reach for `AnswerDeclined` any time the party who would resolve a suspension says no: a human declines an approval prompt, a login was cancelled, a durable request expired unanswered and you want to close it out explicitly rather than leave it parked forever. Pass it to whichever resume path you’re using — inline as the return value of a `WaitFor` function, or out-of-band via [`Runtime.Resume`](/api/go/suspension/resume/) / `Client.RunWithAnswer`. ## Why this and not the alternative [Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative) The tool succeeded, just with a real answer? Use AnswerOutput `AnswerDeclined` is specifically for a **refusal** — nobody produced a result. When the human (or the executed tool) *did* produce a value and you want to hand it back as a success, reach for [`AnswerOutput`](/api/go/suspension/answer-output/) instead: `Ok: true` plus the output string, not `Ok: false` plus a reason. Building `Answer{ID: id, Ok: false}` by hand still works — the constructor changes nothing about the wire shape — but then you own remembering to default the empty-reason case yourself, which is the one behavior `AnswerDeclined` exists to fix in one place rather than at every call site (DECISIONS A9). ## Examples [Section titled “Examples”](#examples) ### 1. The smallest useful call — build the Answer and inspect its shape [Section titled “1. The smallest useful call — build the Answer and inspect its shape”](#1-the-smallest-useful-call--build-the-answer-and-inspect-its-shape) ```go package main import ( "fmt" "log" toolnexus "github.com/muthuishere/toolnexus/golang" ) func main() { ans := toolnexus.AnswerDeclined("req-1", "not authorized") if ans.ID != "req-1" || ans.Ok { log.Fatalf("unexpected answer: %+v", ans) } if ans.Reason != "not authorized" { log.Fatalf("Reason = %q, want %q", ans.Reason, "not authorized") } // An empty reason defaults rather than shipping blank. def := toolnexus.AnswerDeclined("req-2", "") if def.Reason != "declined" { log.Fatalf("default Reason = %q, want %q", def.Reason, "declined") } fmt.Println("ok:", ans.Reason, "/", def.Reason) } ``` ### 2. The realistic case — decline a relayed tool call and let the run finish anyway [Section titled “2. The realistic case — decline a relayed tool call and let the run finish anyway”](#2-the-realistic-case--decline-a-relayed-tool-call-and-let-the-run-finish-anyway) ```go package main import ( "context" "fmt" "log" "net/http" "net/http/httptest" "strings" toolnexus "github.com/muthuishere/toolnexus/golang" ) func main() { turn := 0 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { turn++ w.Header().Set("Content-Type", "application/json") if turn == 1 { _, _ = w.Write([]byte(`{"choices":[{"message":{"role":"assistant","content":null,"tool_calls":[{"id":"c1","type":"function","function":{"name":"refund","arguments":"{}"}}]}}],"usage":{"prompt_tokens":2,"completion_tokens":2,"total_tokens":4}}`)) return } _, _ = w.Write([]byte(`{"choices":[{"message":{"role":"assistant","content":"understood, no refund issued"}}],"usage":{"prompt_tokens":2,"completion_tokens":1,"total_tokens":3}}`)) })) defer srv.Close() refund := toolnexus.RelayTool("refund", "issue a refund", nil) tk, err := toolnexus.CreateToolkit(context.Background(), toolnexus.Options{Builtins: false, ExtraTools: []toolnexus.Tool{refund}}) if err != nil { log.Fatal(err) } defer tk.Close() client := toolnexus.CreateClient(toolnexus.ClientOptions{BaseURL: srv.URL, Style: toolnexus.StyleOpenAI, Model: "stub", APIKey: "k"}) halted, err := client.Run(context.Background(), "refund the customer", tk) if err != nil { log.Fatal(err) } if halted.Status != "pending" { log.Fatalf("expected a durable halt, got %q", halted.Status) } // The approver said no — the run still completes, it just knows it was refused. resumed, err := client.RunWithAnswer(context.Background(), tk, halted.Messages, *halted.Pending, toolnexus.AnswerDeclined(halted.Pending.ID, "amount exceeds refund policy")) if err != nil { log.Fatal(err) } if resumed.Status != "done" || !strings.Contains(resumed.Text, "no refund") { log.Fatalf("unexpected: %+v", resumed) } fmt.Println("ok:", resumed.Text) } ``` ### 3. The full surface — comparing a decline against a successful answer for the same call [Section titled “3. The full surface — comparing a decline against a successful answer for the same call”](#3-the-full-surface--comparing-a-decline-against-a-successful-answer-for-the-same-call) ```go package main import ( "fmt" "log" toolnexus "github.com/muthuishere/toolnexus/golang" ) func main() { declined := toolnexus.AnswerDeclined("req-1", "") output := toolnexus.AnswerOutput("req-1", "approved") if declined.Ok { log.Fatal("a decline must carry Ok:false") } if !output.Ok { log.Fatal("a real output must carry Ok:true") } if declined.Data != nil { log.Fatalf("a decline carries no payload, got %+v", declined.Data) } if output.Reason != "" { log.Fatalf("a success carries no Reason, got %q", output.Reason) } fmt.Println("ok: declined.Ok =", declined.Ok, "output.Ok =", output.Ok) } ``` ## See also [Section titled “See also”](#see-also) * [`Pending`](/api/go/suspension/pending/) — Return a Pending from a tool to park the run until someone answers. * [`AuthRequired`](/api/go/suspension/auth-required/) — The auth-shaped suspension: hand back a URL, resume once the user has granted access. * [`WaitFor`](/api/go/suspension/wait-for/) — The single hook where the host resolves a suspension — in-process prompt or durable queue, same contract. * [`PendingOf`](/api/go/suspension/pending-of/) — Detect that a RunResult is parked rather than finished, and get the Request that parked it. * [`AnswerOutput`](/api/go/suspension/answer-output/) — The success counterpart: wrap a real result instead of a refusal. * [`Runtime.Resume` / `RunWithAnswer`](/api/go/suspension/resume/) — Where a built `Answer` actually gets applied to a halted run. # AnswerOutput > Wraps a human's typed string reply into the Answer a suspended run resumes with — the success counterpart to answerDeclined. — Go. **Go** · package `github.com/muthuishere/toolnexus/golang` · [SPEC §10](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [golang/relay.go](https://github.com/muthuishere/toolnexus/blob/main/golang/relay.go) ```go func AnswerOutput(id, output string) Answer type Answer struct { ID string `json:"id"` // echoes the pending Request.ID Ok bool `json:"ok"` // satisfied, vs declined/aborted/expired Data map[string]any `json:"data,omitempty"` // kind-specific payload Reason string `json:"reason,omitempty"` } ``` `AnswerOutput(id, output)` builds the `Answer` for a **single outstanding relayed tool call**: `Ok: true` and the tool’s output stored under the one key ([`RelayOutputKey`](/api/go/suspension/relay/)) the resume path reads. It exists so a caller never has to hand-build `Answer{ID: id, Ok: true, Data: map[string]any{"output": output}}` and get that one required key wrong or misspelled — which used to be exactly how a malformed answer got `Ok: true` with no determinable result and turned into a fabricated tool result reaching the model (issue #89, ADR 0026). ## When to use it [Section titled “When to use it”](#when-to-use-it) Reach for `AnswerOutput` whenever you’re the host resolving a suspended [`RelayTool`](/api/go/suspension/relay/) call with the tool’s real output — inline through `WaitFor`, or out-of-band through [`Client.RunWithAnswer`/`AskWithAnswer`](/api/go/suspension/resume/). It is the success half of the pair; use [`AnswerDeclined`](/api/go/suspension/answer-declined/) instead when the human refused rather than executed the call. ## Why this and not the alternative [Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative) Multiple outstanding calls in one turn? Use RelayAnswer instead `AnswerOutput` covers the common case: exactly one relayed call parked the turn. When a single assistant turn issued **several** tool calls and more than one of them relayed, build the answer with `RelayAnswer(requestID, []RelayResult{...})` instead — one `{id, output, isError}` per call, keyed by the call’s own id rather than the single implicit slot `AnswerOutput` fills. Both constructors are sugar over the same `Answer{ID, Ok, Data}` shape; nothing stops you from building it by hand, but then you own getting the key name and the `Ok`/`Data` pairing right yourself. ## Examples [Section titled “Examples”](#examples) ### 1. The smallest useful call — build the Answer and inspect its shape [Section titled “1. The smallest useful call — build the Answer and inspect its shape”](#1-the-smallest-useful-call--build-the-answer-and-inspect-its-shape) ```go package main import ( "fmt" "log" toolnexus "github.com/muthuishere/toolnexus/golang" ) func main() { ans := toolnexus.AnswerOutput("req-1", "staging") if ans.ID != "req-1" || !ans.Ok { log.Fatalf("unexpected answer: %+v", ans) } out, ok := ans.Data[toolnexus.RelayOutputKey].(string) if !ok || out != "staging" { log.Fatalf("expected output %q under %q, got %+v", "staging", toolnexus.RelayOutputKey, ans.Data) } fmt.Println("ok:", ans.ID, out) } ``` ### 2. The realistic case — halt on a relayed tool call, then resume with the host’s real output [Section titled “2. The realistic case — halt on a relayed tool call, then resume with the host’s real output”](#2-the-realistic-case--halt-on-a-relayed-tool-call-then-resume-with-the-hosts-real-output) ```go package main import ( "context" "fmt" "log" "net/http" "net/http/httptest" toolnexus "github.com/muthuishere/toolnexus/golang" ) func main() { turn := 0 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { turn++ w.Header().Set("Content-Type", "application/json") if turn == 1 { _, _ = w.Write([]byte(`{"choices":[{"message":{"role":"assistant","content":null,"tool_calls":[{"id":"c1","type":"function","function":{"name":"weather","arguments":"{}"}}]}}],"usage":{"prompt_tokens":2,"completion_tokens":2,"total_tokens":4}}`)) return } _, _ = w.Write([]byte(`{"choices":[{"message":{"role":"assistant","content":"it is sunny"}}],"usage":{"prompt_tokens":2,"completion_tokens":1,"total_tokens":3}}`)) })) defer srv.Close() // A relay tool declares the call; only the HOST can actually execute it. weather := toolnexus.RelayTool("weather", "look up the weather", nil) tk, err := toolnexus.CreateToolkit(context.Background(), toolnexus.Options{Builtins: false, ExtraTools: []toolnexus.Tool{weather}}) if err != nil { log.Fatal(err) } defer tk.Close() client := toolnexus.CreateClient(toolnexus.ClientOptions{BaseURL: srv.URL, Style: toolnexus.StyleOpenAI, Model: "stub", APIKey: "k"}) halted, err := client.Run(context.Background(), "what's the weather?", tk) if err != nil { log.Fatal(err) } if halted.Status != "pending" { log.Fatalf("expected a durable halt, got %q", halted.Status) } // The host executed the real lookup out-of-band; hand its output back. resumed, err := client.RunWithAnswer(context.Background(), tk, halted.Messages, *halted.Pending, toolnexus.AnswerOutput(halted.Pending.ID, "sunny, 24C")) if err != nil { log.Fatal(err) } if resumed.Status != "done" { log.Fatalf("expected done, got %q", resumed.Status) } fmt.Println("ok:", resumed.Text) } ``` ### 3. The full surface — a malformed hand-built Answer errors instead of fabricating a result [Section titled “3. The full surface — a malformed hand-built Answer errors instead of fabricating a result”](#3-the-full-surface--a-malformed-hand-built-answer-errors-instead-of-fabricating-a-result) ```go package main import ( "context" "fmt" "log" "net/http" "net/http/httptest" toolnexus "github.com/muthuishere/toolnexus/golang" ) func main() { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"choices":[{"message":{"role":"assistant","content":null,"tool_calls":[{"id":"c1","type":"function","function":{"name":"weather","arguments":"{}"}}]}}],"usage":{"prompt_tokens":2,"completion_tokens":2,"total_tokens":4}}`)) })) defer srv.Close() weather := toolnexus.RelayTool("weather", "look up the weather", nil) tk, err := toolnexus.CreateToolkit(context.Background(), toolnexus.Options{Builtins: false, ExtraTools: []toolnexus.Tool{weather}}) if err != nil { log.Fatal(err) } defer tk.Close() client := toolnexus.CreateClient(toolnexus.ClientOptions{BaseURL: srv.URL, Style: toolnexus.StyleOpenAI, Model: "stub", APIKey: "k"}) halted, err := client.Run(context.Background(), "what's the weather?", tk) if err != nil { log.Fatal(err) } // Hand-built, wrong key ("value" instead of "output") — NOT what AnswerOutput builds. badAnswer := toolnexus.Answer{ID: halted.Pending.ID, Ok: true, Data: map[string]any{"value": "sunny"}} _, err = client.RunWithAnswer(context.Background(), tk, halted.Messages, *halted.Pending, badAnswer) if err == nil { log.Fatal("a malformed Ok:true answer must error to the host, never resume silently") } fmt.Println("ok: rejected malformed answer:", err) } ``` ## See also [Section titled “See also”](#see-also) * [`Pending`](/api/go/suspension/pending/) — Return a Pending from a tool to park the run until someone answers. * [`AuthRequired`](/api/go/suspension/auth-required/) — The auth-shaped suspension: hand back a URL, resume once the user has granted access. * [`WaitFor`](/api/go/suspension/wait-for/) — The single hook where the host resolves a suspension — in-process prompt or durable queue, same contract. * [`PendingOf`](/api/go/suspension/pending-of/) — Detect that a RunResult is parked rather than finished, and get the Request that parked it. * [`AnswerDeclined`](/api/go/suspension/answer-declined/) — The refusal counterpart: `Ok: false` plus a reason, when the human said no rather than supplied a result. * [`Runtime.Resume` / `RunWithAnswer`](/api/go/suspension/resume/) — Where a built `Answer` actually gets applied to a halted run. # AuthRequired > The auth-shaped suspension: hand back a URL, resume once the user has granted access. — Go. **Go** · package `github.com/muthuishere/toolnexus/golang` · [SPEC §10](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [golang/types.go](https://github.com/muthuishere/toolnexus/blob/main/golang/types.go) ```go func AuthRequired(url, prompt string) ToolResult ``` `AuthRequired` is sugar over [`Pending`](/api/go/suspension/pending/) for the single most common suspension shape: `kind: "authorization"` at a login URL. Pass `""` for `prompt` to get the default, `"Authorization required to continue"`. By SPEC convention `kind:"authorization"` follows OAuth2/OIDC authorization-code semantics — `url` is the authorize endpoint, and the host’s `WaitFor` performs the redirect → consent → callback (and any token exchange) out-of-band. **The kernel stays OIDC-agnostic**: no OIDC library and no token logic live in toolnexus itself; that all lives inside the host’s `WaitFor` at the edge. ## When to use it [Section titled “When to use it”](#when-to-use-it) Reach for `AuthRequired` from a tool that discovers, mid-call, that the caller’s session has expired or was never established — a token expired, a scope is missing, a login page is the only way forward. It’s the canonical instance the suspend/resume mechanism exists for, but it’s *just data*: nothing in the loop treats `"authorization"` specially beyond this convention. ## Why this and not the alternative [Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative) Not every suspension is a login `AuthRequired` only fits the URL-driven, out-of-band-auth shape. For a question that needs a typed answer rather than a redirect — “which environment?”, “confirm this refund” — use [`Pending`](/api/go/suspension/pending/) directly with `kind:"input"` or `kind:"approval"`. The SPEC’s trust boundary is explicit here: `kind:"input"`/`kind:"question"` **must not** be used to collect credentials — passwords, API keys, tokens, or payment secrets always go through `kind:"authorization"` with a `url`, so the secret is entered out-of-band and never transits the tool’s `Data`/`Answer` channel or the model’s context. ## Examples [Section titled “Examples”](#examples) ### 1. The smallest useful call — build the suspension and read it back [Section titled “1. The smallest useful call — build the suspension and read it back”](#1-the-smallest-useful-call--build-the-suspension-and-read-it-back) ```go package main import ( "fmt" "log" toolnexus "github.com/muthuishere/toolnexus/golang" ) func main() { res := toolnexus.AuthRequired("https://example.com/login", "") req := toolnexus.PendingOf(res) if req == nil { log.Fatal("expected a suspension") } if req.Kind != "authorization" || req.URL != "https://example.com/login" { log.Fatalf("unexpected request: %+v", req) } if req.Prompt != "Authorization required to continue" { log.Fatalf("expected the default prompt, got %q", req.Prompt) } fmt.Println("ok:", req.Kind, req.URL) } ``` ### 2. The realistic case — a custom prompt, and the human-readable fallback text [Section titled “2. The realistic case — a custom prompt, and the human-readable fallback text”](#2-the-realistic-case--a-custom-prompt-and-the-human-readable-fallback-text) ```go package main import ( "fmt" "log" "strings" toolnexus "github.com/muthuishere/toolnexus/golang" ) func main() { res := toolnexus.AuthRequired("https://accounts.example.com/oauth/authorize?client=crm", "Connect your CRM account to continue") req := toolnexus.PendingOf(res) if req.Prompt != "Connect your CRM account to continue" { log.Fatalf("unexpected prompt: %q", req.Prompt) } // Output is the human-readable fallback: prompt, then the URL on its own line. if !strings.Contains(res.Output, "Connect your CRM account") || !strings.Contains(res.Output, "accounts.example.com") { log.Fatalf("unexpected fallback output: %q", res.Output) } if !res.IsError { log.Fatal("a parked call is not a success") } fmt.Println("ok:", res.Output) } ``` ### 3. The full surface — wired into a live `Client.Run`, resolved by `WaitFor` [Section titled “3. The full surface — wired into a live Client.Run, resolved by WaitFor”](#3-the-full-surface--wired-into-a-live-clientrun-resolved-by-waitfor) The tool discovers it has no valid session, suspends with `AuthRequired`; `WaitFor` “performs” the OAuth dance (stubbed here) and answers `Ok: true`; the loop retries the same tool once with `ctx.Answer` set — and because this is `kind:"authorization"`, the tool ignores `Answer.Data` and just proceeds now that the world has changed. ```go package main import ( "context" "fmt" "log" "net/http" "net/http/httptest" toolnexus "github.com/muthuishere/toolnexus/golang" ) func main() { loggedIn := false // simulates session state the tool checks turn := 0 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { turn++ w.Header().Set("Content-Type", "application/json") if turn == 1 { _, _ = w.Write([]byte(`{"choices":[{"message":{"role":"assistant","content":null,"tool_calls":[{"id":"c1","type":"function","function":{"name":"fetch_crm_deals","arguments":"{}"}}]}}],"usage":{"prompt_tokens":4,"completion_tokens":4,"total_tokens":8}}`)) return } _, _ = w.Write([]byte(`{"choices":[{"message":{"role":"assistant","content":"3 open deals"}}],"usage":{"prompt_tokens":4,"completion_tokens":2,"total_tokens":6}}`)) })) defer srv.Close() fetchDeals := toolnexus.Tool{ Name: "fetch_crm_deals", Description: "fetches open deals from the CRM", InputSchema: toolnexus.JSONSchema{"type": "object", "properties": map[string]any{}}, Source: toolnexus.SourceCustom, Execute: func(_ map[string]any, ctx *toolnexus.ToolContext) (toolnexus.ToolResult, error) { if !loggedIn { if ctx != nil && ctx.Answer != nil { // The world changed out-of-band — the session is now valid, ignore Answer.Data. loggedIn = true } else { return toolnexus.AuthRequired("https://crm.example.com/oauth/authorize", ""), nil } } return toolnexus.ToolResult{Output: "3 open deals"}, nil }, } tk, err := toolnexus.CreateToolkit(context.Background(), toolnexus.Options{ExtraTools: []toolnexus.Tool{fetchDeals}}) if err != nil { log.Fatal(err) } defer tk.Close() var sawAuth bool client := toolnexus.CreateClient(toolnexus.ClientOptions{ BaseURL: srv.URL, Style: toolnexus.StyleOpenAI, Model: "gpt-4o-mini", APIKey: "test-key", WaitFor: func(req toolnexus.Request) (toolnexus.Answer, error) { if req.Kind != "authorization" { log.Fatalf("unexpected request kind: %q", req.Kind) } sawAuth = true // The host would redirect the user through OAuth here; stubbed as immediate success. return toolnexus.Answer{ID: req.ID, Ok: true}, nil }, }) res, err := client.Run(context.Background(), "how many open CRM deals do we have?", tk) if err != nil { log.Fatal(err) } if !sawAuth { log.Fatal("expected WaitFor to see the authorization request") } if res.Status != "done" || res.Text != "3 open deals" { log.Fatalf("unexpected: %+v", res) } fmt.Println("ok:", res.Text) } ``` ## See also [Section titled “See also”](#see-also) * [`Pending`](/api/go/suspension/pending/) — Return a Pending from a tool to park the run until someone answers. * [`WaitFor`](/api/go/suspension/wait-for/) — The single hook where the host resolves a suspension — in-process prompt or durable queue, same contract. * [`PendingOf`](/api/go/suspension/pending-of/) — Detect that a RunResult is parked rather than finished, and get the Request that parked it. * [`Client.Resume`](/api/go/suspension/resume/) — The answer-carrying entry point: resume a parked run in a different process. # Pending > Return a Pending from a tool to park the run until someone answers. — Go. **Go** · package `github.com/muthuishere/toolnexus/golang` · [SPEC §10](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [golang/types.go](https://github.com/muthuishere/toolnexus/blob/main/golang/types.go) ```go type Request struct { ID string Kind string // "authorization" | "approval" | "input" | ... (open vocabulary) Prompt string URL string `json:"url,omitempty"` Data map[string]any `json:"data,omitempty"` ExpiresAt string `json:"expiresAt,omitempty"` } func Pending(req Request) ToolResult ``` `Pending` is the producer helper for a suspension: it wraps `req` into a `ToolResult` whose `Metadata["pending"]` holds the `Request`, `IsError` set to `true`, and `Output` set to a human-readable fallback (`req.Prompt`, plus `req.URL` on a new line when present). If `req.ID` is empty, a unique id is generated for you. This is sugar — any `ToolResult` whose `Metadata["pending"]` is a `Request` counts as a suspension — but it’s the way every port builds one. ## When to use it [Section titled “When to use it”](#when-to-use-it) Reach for `Pending` inside any `Tool.Execute` that can’t finish in one shot: it needs a human to log in, approve something, answer a question, or supply data that isn’t available yet. Return `Pending(req)` instead of a normal result and the client loop takes over — calling `WaitFor` if one is configured, or halting the run with `RunResult.Status == "pending"` otherwise. ## Why this and not the alternative [Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative) Building the ToolResult by hand instead? You can construct `ToolResult{IsError: true, Metadata: map[string]any{"pending": req}}` yourself — the signature is untouched, so nothing stops you. `Pending` exists because it fills in the id (a `pnd-` prefixed, monotonic, collision-resistant value) and the human-readable `Output` fallback consistently, so every tool in a toolkit produces the same shape. For the specific “login required” case, reach for [`AuthRequired`](/api/go/suspension/auth-required/) instead — it’s `Pending` pre-filled for `kind:"authorization"`. ## Examples [Section titled “Examples”](#examples) ### 1. The smallest useful call — a tool that suspends on first call [Section titled “1. The smallest useful call — a tool that suspends on first call”](#1-the-smallest-useful-call--a-tool-that-suspends-on-first-call) ```go package main import ( "fmt" "log" toolnexus "github.com/muthuishere/toolnexus/golang" ) func main() { res := toolnexus.Pending(toolnexus.Request{Kind: "input", Prompt: "Which environment?"}) if !res.IsError { log.Fatal("a parked call is not a success") } req := toolnexus.PendingOf(res) if req == nil || req.Kind != "input" || req.Prompt != "Which environment?" { log.Fatalf("unexpected request: %+v", req) } if req.ID == "" { log.Fatal("expected a generated correlation id") } fmt.Println("ok:", req.Kind, req.ID != "") } ``` ### 2. The realistic case — a tool that suspends until it has a decision [Section titled “2. The realistic case — a tool that suspends until it has a decision”](#2-the-realistic-case--a-tool-that-suspends-until-it-has-a-decision) `kind` is an open vocabulary — `"approval"` here is just data the host’s `WaitFor` interprets however it likes; the mechanism doesn’t care what the string is. ```go package main import ( "fmt" "log" toolnexus "github.com/muthuishere/toolnexus/golang" ) func approveRefund(amount float64) toolnexus.ToolResult { if amount > 100 { return toolnexus.Pending(toolnexus.Request{ Kind: "approval", Prompt: fmt.Sprintf("Approve a $%.2f refund?", amount), Data: map[string]any{"amount": amount}, }) } return toolnexus.ToolResult{Output: "refund processed automatically"} } func main() { small := approveRefund(20) if small.IsError { log.Fatal("small refunds should not suspend") } big := approveRefund(500) req := toolnexus.PendingOf(big) if req == nil || req.Kind != "approval" { log.Fatalf("expected an approval suspension, got %+v", req) } if req.Data["amount"] != 500.0 { log.Fatalf("expected the amount to ride Data, got %v", req.Data) } fmt.Println("ok:", small.Output, "|", req.Prompt) } ``` ### 3. The full surface — wired into a live `Client.Run`, resolved by `WaitFor` [Section titled “3. The full surface — wired into a live Client.Run, resolved by WaitFor”](#3-the-full-surface--wired-into-a-live-clientrun-resolved-by-waitfor) A first call returns `Pending`; the loop calls `WaitFor`, then re-executes the same tool once with `ctx.Answer` set — the tool reads `Answer.Data` because this is a `kind:"input"` resolution. ```go package main import ( "context" "fmt" "log" "net/http" "net/http/httptest" toolnexus "github.com/muthuishere/toolnexus/golang" ) func main() { turn := 0 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { turn++ w.Header().Set("Content-Type", "application/json") if turn == 1 { _, _ = w.Write([]byte(`{"choices":[{"message":{"role":"assistant","content":null,"tool_calls":[{"id":"c1","type":"function","function":{"name":"pick_env","arguments":"{}"}}]}}],"usage":{"prompt_tokens":4,"completion_tokens":4,"total_tokens":8}}`)) return } _, _ = w.Write([]byte(`{"choices":[{"message":{"role":"assistant","content":"deploying to prod"}}],"usage":{"prompt_tokens":4,"completion_tokens":2,"total_tokens":6}}`)) })) defer srv.Close() pickEnv := toolnexus.Tool{ Name: "pick_env", Description: "asks which environment to deploy to", InputSchema: toolnexus.JSONSchema{"type": "object", "properties": map[string]any{}}, Source: toolnexus.SourceCustom, Execute: func(_ map[string]any, ctx *toolnexus.ToolContext) (toolnexus.ToolResult, error) { if ctx != nil && ctx.Answer != nil { env, _ := ctx.Answer.Data["env"].(string) return toolnexus.ToolResult{Output: env}, nil } return toolnexus.Pending(toolnexus.Request{Kind: "input", Prompt: "Which environment?"}), nil }, } tk, err := toolnexus.CreateToolkit(context.Background(), toolnexus.Options{ExtraTools: []toolnexus.Tool{pickEnv}}) if err != nil { log.Fatal(err) } defer tk.Close() client := toolnexus.CreateClient(toolnexus.ClientOptions{ BaseURL: srv.URL, Style: toolnexus.StyleOpenAI, Model: "gpt-4o-mini", APIKey: "test-key", WaitFor: func(req toolnexus.Request) (toolnexus.Answer, error) { if req.Kind != "input" { log.Fatalf("unexpected request kind: %q", req.Kind) } return toolnexus.Answer{ID: req.ID, Ok: true, Data: map[string]any{"env": "prod"}}, nil }, }) res, err := client.Run(context.Background(), "deploy the app", tk) if err != nil { log.Fatal(err) } if res.Status != "done" || res.Text != "deploying to prod" { log.Fatalf("unexpected: %+v", res) } fmt.Println("ok:", res.Text) } ``` ## Request fields [Section titled “Request fields”](#request-fields) | Field | Type | What it is | | ----------- | ---------------------------- | ----------------------------------------------------------------------------------- | | `ID` | `string` | Unique per suspension; the correlation key. Auto-generated by `Pending` when empty. | | `Kind` | `string` | `"authorization"` \| `"approval"` \| `"input"` \| … — open vocabulary. | | `Prompt` | `string` | What is being asked, in human words. | | `URL` | `string` (omitempty) | Present when the action happens at a link. | | `Data` | `map[string]any` (omitempty) | Kind-specific extra (choices, schema, …). | | `ExpiresAt` | `string` (omitempty) | RFC3339; the request is stale after this. | `Request`/`Answer` keys are fixed across all ports (they serialize over the wire and cross agent boundaries) — pinned exactly as above, not idiomatic-cased like `RunResult`. ## See also [Section titled “See also”](#see-also) * [`AuthRequired`](/api/go/suspension/auth-required/) — The auth-shaped suspension: hand back a URL, resume once the user has granted access. * [`WaitFor`](/api/go/suspension/wait-for/) — The single hook where the host resolves a suspension — in-process prompt or durable queue, same contract. * [`PendingOf`](/api/go/suspension/pending-of/) — Detect that a RunResult is parked rather than finished, and get the Request that parked it. * [`Client.Resume`](/api/go/suspension/resume/) — The answer-carrying entry point: resume a parked run in a different process. # PendingOf > Detect that a RunResult is parked rather than finished, and get the Request that parked it. — Go. **Go** · package `github.com/muthuishere/toolnexus/golang` · [SPEC §10](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [golang/types.go](https://github.com/muthuishere/toolnexus/blob/main/golang/types.go) ```go func PendingOf(result ToolResult) *Request ``` `PendingOf` reads the suspension back off a `ToolResult`: it returns `&Request` when `result.Metadata["pending"]` holds one (checking both the `Request` and `*Request` shapes), and `nil` for an ordinary result. It’s the read side of [`Pending`](/api/go/suspension/pending/) — the thing you call from test code, from a `WaitFor` implementation inspecting what a tool asked for, or from any code that needs to tell “this call suspended” apart from “this call failed.” ## When to use it [Section titled “When to use it”](#when-to-use-it) Reach for `PendingOf` wherever you have a `ToolResult` and need to know whether it’s a suspension: writing tests for your own suspending tools, building a `WaitFor` that branches on `req.Kind`, or inspecting `RunResult.ToolCalls[i]`-shaped data after a run. It’s also the correct way to distinguish “the tool suspended” from “the tool errored” — both set `IsError: true`, and `PendingOf` is the only reliable discriminator. ## Why this and not the alternative [Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative) IsError alone is not enough A suspension deliberately sets `IsError: true` — a parked call has not produced an answer. That means checking `result.IsError` to detect “the tool broke” will misfire on every suspension. Check `PendingOf(result) != nil` first; only treat a non-nil-error, non-suspended result as a genuine tool failure. This is also why the suspension’s own observability event carries `isError:false` + `pending:true` — the failure-rate signal is `PendingOf`-aware everywhere in the loop, and your own code should be too. ## Examples [Section titled “Examples”](#examples) ### 1. The smallest useful call — suspension vs plain result [Section titled “1. The smallest useful call — suspension vs plain result”](#1-the-smallest-useful-call--suspension-vs-plain-result) ```go package main import ( "fmt" "log" toolnexus "github.com/muthuishere/toolnexus/golang" ) func main() { suspended := toolnexus.Pending(toolnexus.Request{Kind: "input", Prompt: "which env?"}) plain := toolnexus.ToolResult{Output: "done"} if toolnexus.PendingOf(suspended) == nil { log.Fatal("expected a suspension") } if toolnexus.PendingOf(plain) != nil { log.Fatal("expected no suspension on a plain result") } fmt.Println("ok: suspension detected, plain result is not") } ``` ### 2. The realistic case — telling a real error apart from a suspension [Section titled “2. The realistic case — telling a real error apart from a suspension”](#2-the-realistic-case--telling-a-real-error-apart-from-a-suspension) Both set `IsError: true`; only `PendingOf` tells them apart. ```go package main import ( "fmt" "log" toolnexus "github.com/muthuishere/toolnexus/golang" ) func classify(res toolnexus.ToolResult) string { if req := toolnexus.PendingOf(res); req != nil { return "suspended: " + req.Kind } if res.IsError { return "failed: " + res.Output } return "ok: " + res.Output } func main() { suspended := toolnexus.AuthRequired("https://example.com/login", "") failed := toolnexus.ToolResult{Output: "connection refused", IsError: true} ok := toolnexus.ToolResult{Output: "42"} got := []string{classify(suspended), classify(failed), classify(ok)} want := []string{"suspended: authorization", "failed: connection refused", "ok: 42"} for i := range want { if got[i] != want[i] { log.Fatalf("case %d: got %q want %q", i, got[i], want[i]) } } fmt.Println("ok:", got) } ``` ### 3. The full surface — reading `RunResult.Pending` after a durable halt [Section titled “3. The full surface — reading RunResult.Pending after a durable halt”](#3-the-full-surface--reading-runresultpending-after-a-durable-halt) `PendingOf` works the same on a `ToolResult` you built by hand or one that flowed all the way through a real `Client.Run` — this reads the request back off the halted `RunResult` and off the underlying `ToolResult` that produced it. ```go package main import ( "context" "fmt" "log" "net/http" "net/http/httptest" toolnexus "github.com/muthuishere/toolnexus/golang" ) func main() { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"choices":[{"message":{"role":"assistant","content":null,"tool_calls":[{"id":"c1","type":"function","function":{"name":"pick_env","arguments":"{}"}}]}}],"usage":{"prompt_tokens":2,"completion_tokens":2,"total_tokens":4}}`)) })) defer srv.Close() var lastResult toolnexus.ToolResult pickEnv := toolnexus.Tool{ Name: "pick_env", Description: "asks which environment", InputSchema: toolnexus.JSONSchema{"type": "object", "properties": map[string]any{}}, Source: toolnexus.SourceCustom, Execute: func(_ map[string]any, _ *toolnexus.ToolContext) (toolnexus.ToolResult, error) { lastResult = toolnexus.Pending(toolnexus.Request{Kind: "input", Prompt: "which env?"}) return lastResult, nil }, } tk, err := toolnexus.CreateToolkit(context.Background(), toolnexus.Options{ExtraTools: []toolnexus.Tool{pickEnv}}) if err != nil { log.Fatal(err) } defer tk.Close() client := toolnexus.CreateClient(toolnexus.ClientOptions{BaseURL: srv.URL, Style: toolnexus.StyleOpenAI, Model: "gpt-4o-mini", APIKey: "test-key"}) res, err := client.Run(context.Background(), "deploy", tk) if err != nil { log.Fatal(err) } if res.Status != "pending" { log.Fatalf("expected a durable halt, got %q", res.Status) } // PendingOf on the tool's own ToolResult, and RunResult.Pending, describe the same suspension. fromTool := toolnexus.PendingOf(lastResult) if fromTool == nil || res.Pending == nil { log.Fatal("expected both views to see the suspension") } if fromTool.ID != res.Pending.ID || fromTool.Prompt != res.Pending.Prompt { log.Fatalf("expected matching requests: tool=%+v run=%+v", fromTool, res.Pending) } fmt.Println("ok:", fromTool.Kind, "-", fromTool.Prompt, "id matches:", fromTool.ID == res.Pending.ID) } ``` ## See also [Section titled “See also”](#see-also) * [`Pending`](/api/go/suspension/pending/) — Return a Pending from a tool to park the run until someone answers. * [`AuthRequired`](/api/go/suspension/auth-required/) — The auth-shaped suspension: hand back a URL, resume once the user has granted access. * [`WaitFor`](/api/go/suspension/wait-for/) — The single hook where the host resolves a suspension — in-process prompt or durable queue, same contract. * [`Client.Resume`](/api/go/suspension/resume/) — The answer-carrying entry point: resume a parked run in a different process. # RelayTool > Declare a tool the host executes, not the library: the call rides out on the suspension. — Go. **Go** · package `github.com/muthuishere/toolnexus/golang` · [SPEC §10 addendum](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [golang/relay.go](https://github.com/muthuishere/toolnexus/blob/main/golang/relay.go) STATUS: golang only — a preview, not part of the §0 conformance contract Relay ships in `golang/` as of 0.12.0. `js`, `python`, `java`, `csharp` and `elixir` do not implement it yet (tracked in `openspec/changes/add-tool-relay-mode/tasks.md`). A caller that needs cross-port behaviour today should use [`Translate`](/api/go/translate/translate/) instead (ADR-0011) — translation is the right mechanism for the pass-through posture; relay is for proxy-managed memory (toolnexus owns the conversation, the caller owns execution). ```go const RelayKind = "tool_call" // Request.Kind for a relayed call const RelayOutputKey = "output" // Answer.Data key: the host's tool output const RelayIsErrorKey = "isError" // Answer.Data key: whether the host's tool failed type RelayCall struct { ID string // the provider's tool-call id — the tool_result correlation key Name string // the tool the model called Input map[string]any // parsed arguments Arguments string // the RAW arguments JSON, so an OpenAI-shaped caller can echo it } func RelayTool(name, description string, schema JSONSchema) Tool func IsRelayRequest(req *Request) bool func RelayCallsOf(req *Request) []RelayCall ``` `RelayTool` builds a declaration-only `Tool` (`Source: SourceRelay`): it carries a schema, but nothing runs in toolnexus when the model calls it. Instead, the call suspends with `Request.Kind == "tool_call"`, carrying every relay call from that assistant turn (in tool-call order) under `Request.Data["calls"]` — read them with `RelayCallsOf`. The host executes each call itself and hands back the result as an `Answer`; the loop resumes and feeds each output back as that call’s `tool_result`, exactly as if the tool had run locally. This is what lets toolnexus act as a pure proxy while still declaring standard OpenAI-shaped function calling to the provider. ## When to use it [Section titled “When to use it”](#when-to-use-it) Reach for `RelayTool` when toolnexus owns the conversation loop (unlike [`Translate`](/api/go/translate/translate/), which owns neither loop nor state) but the **execution** of a specific tool belongs to the caller — a coding-agent client that runs shell commands itself, a browser extension that clicks buttons in a real tab, anything where “the tool” is inherently outside the process running toolnexus. Declare it, let the model call it, and resume with the caller’s own output via [`RunWithAnswer`/`AskWithAnswer`](/api/go/suspension/resume/). ## Why this and not the alternative [Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative) Translator posture, not agent-loop posture? Use Translate `RelayTool` still runs the full agent loop — turns, retries, conversation memory, the whole `Client.Run`/`Ask` machinery — with one tool’s execution deferred to the caller. [`Translate`](/api/go/translate/translate/) is a different posture entirely: no loop, no state, exactly one provider call, and the CALLER owns everything (execution, history, retries). Reach for `Translate` when the caller already has its own agent loop and just wants toolnexus to speak provider wire formats; reach for `RelayTool` when toolnexus’s loop should keep driving turns but one particular tool has to execute somewhere else. ## Examples [Section titled “Examples”](#examples) ### 1. The smallest useful call — the relay call reaches the host, nothing runs locally [Section titled “1. The smallest useful call — the relay call reaches the host, nothing runs locally”](#1-the-smallest-useful-call--the-relay-call-reaches-the-host-nothing-runs-locally) The model asks for `lookup`; the suspension carries the call; `WaitFor` supplies the output; the loop feeds it back as the tool’s result — and the run finishes normally, `Status == "done"`. ```go package main import ( "context" "fmt" "log" "net/http" "net/http/httptest" toolnexus "github.com/muthuishere/toolnexus/golang" ) func main() { turn := 0 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { turn++ w.Header().Set("Content-Type", "application/json") if turn == 1 { _, _ = w.Write([]byte(`{"choices":[{"message":{"role":"assistant","content":null,"tool_calls":[{"id":"c1","type":"function","function":{"name":"lookup","arguments":"{\"q\":\"weather\"}"}}]}}],"usage":{"prompt_tokens":4,"completion_tokens":4,"total_tokens":8}}`)) return } _, _ = w.Write([]byte(`{"choices":[{"message":{"role":"assistant","content":"it is sunny"}}],"usage":{"prompt_tokens":4,"completion_tokens":2,"total_tokens":6}}`)) })) defer srv.Close() lookup := toolnexus.RelayTool("lookup", "look something up", toolnexus.JSONSchema{ "type": "object", "properties": map[string]any{"q": map[string]any{"type": "string"}}, }) tk, err := toolnexus.CreateToolkit(context.Background(), toolnexus.Options{Builtins: false, ExtraTools: []toolnexus.Tool{lookup}}) if err != nil { log.Fatal(err) } defer tk.Close() var got toolnexus.Request client := toolnexus.CreateClient(toolnexus.ClientOptions{ BaseURL: srv.URL, Style: toolnexus.StyleOpenAI, Model: "stub", APIKey: "k", WaitFor: func(req toolnexus.Request) (toolnexus.Answer, error) { got = req return toolnexus.Answer{ID: req.ID, Ok: true, Data: map[string]any{toolnexus.RelayOutputKey: "sunny, 31C"}}, nil }, }) res, err := client.Run(context.Background(), "weather?", tk) if err != nil { log.Fatal(err) } if res.Status != "done" || res.Text != "it is sunny" { log.Fatalf("unexpected: %+v", res) } if !toolnexus.IsRelayRequest(&got) { log.Fatalf("expected a relay request, got kind=%q", got.Kind) } calls := toolnexus.RelayCallsOf(&got) if len(calls) != 1 || calls[0].ID != "c1" || calls[0].Name != "lookup" || calls[0].Input["q"] != "weather" { log.Fatalf("unexpected relay call: %+v", calls) } if res.ToolCalls[0].Output != "sunny, 31C" || res.ToolCalls[0].IsError { log.Fatalf("expected the host's output to become the tool result: %+v", res.ToolCalls) } fmt.Println("ok:", res.Text, "- relay call was", calls[0].Name+"("+calls[0].Arguments+")") } ``` ### 2. The realistic case — three relay calls in one turn ride a single suspension [Section titled “2. The realistic case — three relay calls in one turn ride a single suspension”](#2-the-realistic-case--three-relay-calls-in-one-turn-ride-a-single-suspension) `Request.Data["calls"]` grows to carry ALL of a turn’s relay calls, in tool-call order — the first-in-order halt rule is unchanged; only the surfaced request’s payload grows. This mirrors OpenAI’s `tool_calls` array one-to-one, so a caller never sees a truncated call list. Merging is a property of the durable halt The merge into ONE request happens on the path with no `WaitFor` configured — `resolvePending` calls a configured `WaitFor` once per relay call as each is hit, but when there’s no `WaitFor` the run collects every suspension from the turn and folds them into the first one before halting. This example omits `WaitFor` to show that merged shape directly on `RunResult.Pending`. ```go package main import ( "context" "fmt" "log" "net/http" "net/http/httptest" toolnexus "github.com/muthuishere/toolnexus/golang" ) func main() { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"choices":[{"message":{"role":"assistant","content":null,"tool_calls":[` + `{"id":"c1","type":"function","function":{"name":"alpha","arguments":"{}"}},` + `{"id":"c2","type":"function","function":{"name":"beta","arguments":"{}"}},` + `{"id":"c3","type":"function","function":{"name":"gamma","arguments":"{}"}}` + `]}}],"usage":{"prompt_tokens":4,"completion_tokens":4,"total_tokens":8}}`)) })) defer srv.Close() relay := func(name string) toolnexus.Tool { return toolnexus.RelayTool(name, "relayed to the caller", nil) } tk, err := toolnexus.CreateToolkit(context.Background(), toolnexus.Options{ Builtins: false, ExtraTools: []toolnexus.Tool{relay("alpha"), relay("beta"), relay("gamma")}, }) if err != nil { log.Fatal(err) } defer tk.Close() // No WaitFor: the run halts durably, and the SINGLE surfaced request carries all 3 calls. client := toolnexus.CreateClient(toolnexus.ClientOptions{BaseURL: srv.URL, Style: toolnexus.StyleOpenAI, Model: "stub", APIKey: "k"}) res, err := client.Run(context.Background(), "go", tk) if err != nil { log.Fatal(err) } if res.Status != "pending" || res.Pending == nil { log.Fatalf("expected a durable halt, got status=%q", res.Status) } calls := toolnexus.RelayCallsOf(res.Pending) if len(calls) != 3 { log.Fatalf("expected all 3 relay calls on ONE surfaced request, got %d: %+v", len(calls), calls) } for i, want := range []string{"alpha", "beta", "gamma"} { if calls[i].Name != want { log.Fatalf("call %d = %q, want %q (tool-call order preserved)", i, calls[i].Name, want) } } fmt.Println("ok:", len(calls), "relay calls rode one suspension, in order:", calls[0].Name, calls[1].Name, calls[2].Name) } ``` ### 3. The full surface — a real tool runs alongside a relay tool, and a declined relay does not abort the run [Section titled “3. The full surface — a real tool runs alongside a relay tool, and a declined relay does not abort the run”](#3-the-full-surface--a-real-tool-runs-alongside-a-relay-tool-and-a-declined-relay-does-not-abort-the-run) Relay composes with ordinary executing tools in the same turn (only the relay one suspends), and a declined relay call becomes an error `tool_result` — the loop continues, exactly like any other declined suspension. ```go package main import ( "context" "fmt" "log" "net/http" "net/http/httptest" toolnexus "github.com/muthuishere/toolnexus/golang" ) func main() { realRan := false real := toolnexus.Tool{ Name: "real_tool", Description: "runs locally", InputSchema: toolnexus.JSONSchema{"type": "object", "properties": map[string]any{}}, Source: toolnexus.SourceCustom, Execute: func(_ map[string]any, _ *toolnexus.ToolContext) (toolnexus.ToolResult, error) { realRan = true return toolnexus.ToolResult{Output: "real-output"}, nil }, } relayed := toolnexus.RelayTool("relayed_tool", "runs at the host", nil) tk, err := toolnexus.CreateToolkit(context.Background(), toolnexus.Options{ Builtins: false, ExtraTools: []toolnexus.Tool{real, relayed}, }) if err != nil { log.Fatal(err) } defer tk.Close() turn := 0 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { turn++ w.Header().Set("Content-Type", "application/json") switch turn { case 1: _, _ = w.Write([]byte(`{"choices":[{"message":{"role":"assistant","content":null,"tool_calls":[{"id":"c1","type":"function","function":{"name":"real_tool","arguments":"{}"}}]}}],"usage":{"prompt_tokens":2,"completion_tokens":2,"total_tokens":4}}`)) case 2: _, _ = w.Write([]byte(`{"choices":[{"message":{"role":"assistant","content":null,"tool_calls":[{"id":"c2","type":"function","function":{"name":"relayed_tool","arguments":"{}"}}]}}],"usage":{"prompt_tokens":2,"completion_tokens":2,"total_tokens":4}}`)) default: _, _ = w.Write([]byte(`{"choices":[{"message":{"role":"assistant","content":"handled the decline"}}],"usage":{"prompt_tokens":2,"completion_tokens":2,"total_tokens":4}}`)) } })) defer srv.Close() client := toolnexus.CreateClient(toolnexus.ClientOptions{ BaseURL: srv.URL, Style: toolnexus.StyleOpenAI, Model: "stub", APIKey: "k", WaitFor: func(req toolnexus.Request) (toolnexus.Answer, error) { // The caller declines the relayed call — the run must not abort. return toolnexus.Answer{ID: req.ID, Ok: false, Reason: "declined"}, nil }, }) res, err := client.Run(context.Background(), "do both", tk) if err != nil { log.Fatal(err) } if !realRan { log.Fatal("expected the ordinary tool to actually run") } if res.Status != "done" { log.Fatalf("a declined relay must not abort the run: %+v", res) } fmt.Println("ok: real tool ran locally, relay decline was handled without aborting:", res.Text) } ``` ## Wire shape and rules [Section titled “Wire shape and rules”](#wire-shape-and-rules) | Aspect | Detail | | ------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `Request.Kind` | `"tool_call"` (`RelayKind`) — a use of the suspension primitive, not a second mechanism. | | `Request.Data["calls"]` | `[]RelayCall` — `{id, name, input, arguments}`; `id` is stamped by the loop (a tool cannot know its own call id). | | Multiple calls, one turn | All of one assistant turn’s relay calls ride the **single** surfaced request, in tool-call order — the first-in-order halt rule is unchanged. | | Resolution | `Answer.Data["results"]` — `[]{id, output, isError}`, one per call. A single-call relay may use the shorthand `Answer.Data["output"]`/`Answer.Data["isError"]`. | | Declined (`Ok: false`) | Feeds back an error `tool_result` for that call; the run continues either way — a relayed *failure* is never an aborted run. | | Not a tool error | `isError:false` + `pending:true` on the `tool` observability event; no `afterTool` failure path. Relaying is normal operation for a translator. | | Collision guard | Toolkit construction **fails** if a relay tool’s name collides with a builtin’s — unconditionally, even with builtins disabled. | | Absent | No relay tool declared ⇒ not one observable difference from a plain toolkit. | ## See also [Section titled “See also”](#see-also) * [`Client.Resume`](/api/go/suspension/resume/) — `RunWithAnswer`/`AskWithAnswer`: the durable resolution path a relay suspension typically uses. * [`Pending`](/api/go/suspension/pending/) — Return a Pending from a tool to park the run until someone answers — the primitive relay is built on. * [`WaitFor`](/api/go/suspension/wait-for/) — The single hook where the host resolves a suspension — in-process prompt or durable queue, same contract. * [`Translate`](/api/go/translate/translate/) — The stateless, no-loop alternative for the caller-owns-everything posture. # Runtime.Resume > Route an Answer to the deepest suspended sub-agent handle and resume it in place. — Go. **Go** · package `github.com/muthuishere/toolnexus/golang/agents` · [SPEC §7D](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [golang/agents/runtime.go](https://github.com/muthuishere/toolnexus/blob/main/golang/agents/runtime.go) Not the §10 golang-preview resume — see the bottom of this page An earlier draft of this page led with `Client.RunWithAnswer`/`Client.AskWithAnswer` (SPEC §10’s golang-only durable-resume **preview**, `STATUS: golang only — a preview, NOT part of the §0 conformance contract`). That content is real and still documented below, but it answers a different question than this manifest entry: this page’s PRIMARY symbol is `Runtime.Resume` — the §7D sub-agent resume mechanism shipped, unconditionally, in all six ports. ```go func (rt *Runtime) Resume(answer tn.Answer) (TaskResult, error) ``` Routes an `Answer` to the **deepest suspended** handle in the runtime’s tree, resumes it from its checkpoint (a retry-with-answer of the halted tool — turns and token usage keep accumulating, never reset), then cascades upward: each suspended ancestor replays too, and its re-invoked `task` delegation call **reattaches** to the already-resumed child by task key rather than spawning a duplicate. Call [`rt.Wait(handle, 0)`](/api/go/runtime/runtime/) afterward for the finished `TaskResult`. ## When to use it [Section titled “When to use it”](#when-to-use-it) Reach for `rt.Resume(answer)` any time a spawned agent’s handle transitions to `StateSuspended` (`rt.Inspect(h).PendingKind` non-empty) and you have — or have just obtained — the `Answer` to that suspension: a human approved a payment, a login completed, a form was filled in. There is no separate resume path per handle; one call resolves whichever handle is currently the deepest suspended one in the tree. ## Why this and not the alternative [Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative) Set a WaitFor on the agent def instead when resolution fits inside the call A `Def`’s `WaitFor` interpreter resolves a suspension **inline** — the same posture as the bare client’s `WaitFor` — so the handle never settles into `StateSuspended` at all. Reach for `Resume` when resolution genuinely can’t happen inside the original turn: the process handling the answer is a webhook handler, a queue consumer, or simply a later turn of the same long-running process, not the code that spawned the handle. This crosses TIME, not necessarily a process boundary The suspended `Handle` tree lives in the `Runtime` instance’s memory. So `Resume` genuinely lets an answer arrive arbitrarily late within the **same running process** (a different goroutine, an hour later, driven by a webhook) — it does not, by itself, survive a process restart. That cross-restart case is precisely what the golang-only `RunWithAnswer`/`AskWithAnswer` preview below covers. ## Examples [Section titled “Examples”](#examples) ### 1. The smallest useful call — `suspended`, `PendingKind`, and durable `Resume` [Section titled “1. The smallest useful call — suspended, PendingKind, and durable Resume”](#1-the-smallest-useful-call--suspended-pendingkind-and-durable-resume) No `WaitFor` anywhere in the chain ⇒ the suspension goes durable: the handle parks in `StateSuspended` and `Runtime.Resume` is the ONLY way back to `running`. ```go package main import ( "bytes" "encoding/json" "fmt" "io" "log" "net/http" tn "github.com/muthuishere/toolnexus/golang" "github.com/muthuishere/toolnexus/golang/agents" ) // checkSecret needs human approval the first time; once ctx.Answer carries a // satisfied Answer, it succeeds. var checkSecret = tn.Tool{ Name: "check_secret", Description: "needs approval", Source: tn.SourceCustom, InputSchema: tn.JSONSchema{"type": "object", "properties": map[string]any{}}, Execute: func(_ map[string]any, tctx *tn.ToolContext) (tn.ToolResult, error) { if tctx != nil && tctx.Answer != nil && tctx.Answer.Ok { return tn.ToolResult{Output: "secret-token"}, nil } return tn.Pending(tn.Request{Kind: "approval", Prompt: "approve secret access?"}), nil }, } type approvalLLM struct{} func (approvalLLM) RoundTrip(req *http.Request) (*http.Response, error) { b, _ := io.ReadAll(req.Body) var parsed struct { Messages []map[string]any `json:"messages"` } _ = json.Unmarshal(b, &parsed) sawToolResult := false for _, m := range parsed.Messages { if m["role"] == "tool" { sawToolResult = true } } var resp map[string]any if !sawToolResult { args, _ := json.Marshal(map[string]any{}) resp = map[string]any{"role": "assistant", "content": nil, "tool_calls": []any{ map[string]any{"id": "c1", "type": "function", "function": map[string]any{"name": "check_secret", "arguments": string(args)}}, }} } else { resp = map[string]any{"role": "assistant", "content": "approved and done"} } body, _ := json.Marshal(map[string]any{ "choices": []any{map[string]any{"message": resp}}, "usage": map[string]any{"prompt_tokens": 3, "completion_tokens": 3, "total_tokens": 6}, }) return &http.Response{StatusCode: 200, Header: http.Header{"Content-Type": []string{"application/json"}}, Body: io.NopCloser(bytes.NewReader(body))}, nil } func main() { reg := map[string]agents.Def{ // No WaitFor on this def, and none higher in the chain: the suspension // goes durable instead of being answered inline. "asker": {Name: "asker", Does: "needs approvals", Model: "m-asker", Tools: []tn.Tool{checkSecret}}, } rt := agents.NewRuntime(agents.Options{Transport: approvalLLM{}, Registry: reg}) h, err := rt.Spawn(rt.Root, "asker", nil) if err != nil { log.Fatal(err) } rt.Wake(h, "get the secret") r := rt.Wait(h, 0) if r.Status != "pending" || rt.StateOf(h) != agents.StateSuspended { log.Fatalf("expected a durable pending, got status=%q state=%s", r.Status, rt.StateOf(h)) } v := rt.Inspect(h) if v.PendingKind != "approval" { log.Fatalf("expected PendingKind=approval, got %q", v.PendingKind) } // The out-of-band Answer resumes the DEEPEST suspended handle. if _, err := rt.Resume(tn.Answer{ID: r.Pending.ID, Ok: true}); err != nil { log.Fatal(err) } if rt.StateOf(h) != agents.StateIdle { log.Fatalf("expected idle after resume, got %s", rt.StateOf(h)) } fmt.Println("ok: suspended (", v.PendingKind, ") -> resumed ->", rt.StateOf(h)) } ``` ### 2. A declined answer — the run finishes, it just doesn’t do the thing [Section titled “2. A declined answer — the run finishes, it just doesn’t do the thing”](#2-a-declined-answer--the-run-finishes-it-just-doesnt-do-the-thing) `Resume`’s loop rule branches only on `Answer.Ok` — a decline is data, not a returned error. ```go package main import ( "bytes" "encoding/json" "fmt" "io" "log" "net/http" "strings" tn "github.com/muthuishere/toolnexus/golang" "github.com/muthuishere/toolnexus/golang/agents" ) var checkSecret = tn.Tool{ Name: "check_secret", Description: "needs approval", Source: tn.SourceCustom, InputSchema: tn.JSONSchema{"type": "object", "properties": map[string]any{}}, Execute: func(_ map[string]any, tctx *tn.ToolContext) (tn.ToolResult, error) { if tctx != nil && tctx.Answer != nil { return tn.ToolResult{Output: fmt.Sprintf("outcome ok=%v", tctx.Answer.Ok)}, nil } return tn.Pending(tn.Request{Kind: "approval", Prompt: "approve secret access?"}), nil }, } type approvalLLM struct{} func (approvalLLM) RoundTrip(req *http.Request) (*http.Response, error) { b, _ := io.ReadAll(req.Body) var parsed struct { Messages []map[string]any `json:"messages"` } _ = json.Unmarshal(b, &parsed) sawToolResult := false for _, m := range parsed.Messages { if m["role"] == "tool" { sawToolResult = true } } var resp map[string]any if !sawToolResult { args, _ := json.Marshal(map[string]any{}) resp = map[string]any{"role": "assistant", "content": nil, "tool_calls": []any{ map[string]any{"id": "c1", "type": "function", "function": map[string]any{"name": "check_secret", "arguments": string(args)}}, }} } else { resp = map[string]any{"role": "assistant", "content": "understood — access was declined"} } body, _ := json.Marshal(map[string]any{ "choices": []any{map[string]any{"message": resp}}, "usage": map[string]any{"prompt_tokens": 3, "completion_tokens": 3, "total_tokens": 6}, }) return &http.Response{StatusCode: 200, Header: http.Header{"Content-Type": []string{"application/json"}}, Body: io.NopCloser(bytes.NewReader(body))}, nil } func main() { reg := map[string]agents.Def{ "asker": {Name: "asker", Does: "needs approvals", Model: "m-asker", Tools: []tn.Tool{checkSecret}}, } rt := agents.NewRuntime(agents.Options{Transport: approvalLLM{}, Registry: reg}) h, err := rt.Spawn(rt.Root, "asker", nil) if err != nil { log.Fatal(err) } rt.Wake(h, "get the secret") r := rt.Wait(h, 0) if r.Status != "pending" { log.Fatalf("expected pending, got %q", r.Status) } if _, err := rt.Resume(tn.Answer{ID: r.Pending.ID, Ok: false, Reason: "declined"}); err != nil { log.Fatal(err) } // The RUN still completes — it just knows the request was declined. final := rt.Wait(h, 0) if final.Status != "done" || !strings.Contains(final.Text, "declined") { log.Fatalf("unexpected: %+v", final) } fmt.Println("ok:", final.Text) } ``` ### 3. Inspecting the parked handle before resuming — `List`/`Inspect` [Section titled “3. Inspecting the parked handle before resuming — List/Inspect”](#3-inspecting-the-parked-handle-before-resuming--listinspect) A host that stores the answer separately from the runtime (a queue, a database row) still needs a live handle to resume — `List()`/`Inspect()` give a read-only `HandleView` of what’s parked, including the pending request’s kind, without walking the tree by hand. ```go package main import ( "bytes" "encoding/json" "fmt" "io" "log" "net/http" tn "github.com/muthuishere/toolnexus/golang" "github.com/muthuishere/toolnexus/golang/agents" ) var checkSecret = tn.Tool{ Name: "check_secret", Description: "needs approval", Source: tn.SourceCustom, InputSchema: tn.JSONSchema{"type": "object", "properties": map[string]any{}}, Execute: func(_ map[string]any, tctx *tn.ToolContext) (tn.ToolResult, error) { if tctx != nil && tctx.Answer != nil && tctx.Answer.Ok { return tn.ToolResult{Output: "secret-token"}, nil } return tn.Pending(tn.Request{Kind: "approval", Prompt: "approve secret access?"}), nil }, } type approvalLLM struct{} func (approvalLLM) RoundTrip(req *http.Request) (*http.Response, error) { b, _ := io.ReadAll(req.Body) var parsed struct { Messages []map[string]any `json:"messages"` } _ = json.Unmarshal(b, &parsed) sawToolResult := false for _, m := range parsed.Messages { if m["role"] == "tool" { sawToolResult = true } } var resp map[string]any if !sawToolResult { args, _ := json.Marshal(map[string]any{}) resp = map[string]any{"role": "assistant", "content": nil, "tool_calls": []any{ map[string]any{"id": "c1", "type": "function", "function": map[string]any{"name": "check_secret", "arguments": string(args)}}, }} } else { resp = map[string]any{"role": "assistant", "content": "approved and done"} } body, _ := json.Marshal(map[string]any{ "choices": []any{map[string]any{"message": resp}}, "usage": map[string]any{"prompt_tokens": 3, "completion_tokens": 3, "total_tokens": 6}, }) return &http.Response{StatusCode: 200, Header: http.Header{"Content-Type": []string{"application/json"}}, Body: io.NopCloser(bytes.NewReader(body))}, nil } func main() { reg := map[string]agents.Def{ "asker": {Name: "asker", Does: "needs approvals", Model: "m-asker", Tools: []tn.Tool{checkSecret}}, } rt := agents.NewRuntime(agents.Options{Transport: approvalLLM{}, Registry: reg}) h, err := rt.Spawn(rt.Root, "asker", nil) if err != nil { log.Fatal(err) } rt.Wake(h, "get the secret") _ = rt.Wait(h, 0) // The read-only view: same handle state, reachable from Inspect() alone. view := rt.Inspect(h) if view.State != agents.StateSuspended || view.PendingKind != "approval" { log.Fatalf("unexpected view: %+v", view) } last := rt.Wait(h, 0) // fast-path: returns the cached pending TaskResult, does not block if _, err := rt.Resume(tn.Answer{ID: last.Pending.ID, Ok: true}); err != nil { log.Fatal(err) } settled := rt.Wait(h, 0) if settled.Status != "done" { log.Fatalf("unexpected: %+v", settled) } fmt.Println("ok:", view.State, "->", settled.Status) } ``` ## Signature [Section titled “Signature”](#signature) | Parameter | Type | What it is | | --------- | --------------------- | ------------------------------------------------------------------------------------------------------------------------------- | | `answer` | `tn.Answer` | Should echo the `ID` of the pending `Request` — routed to the deepest suspended handle. | | returns | `(TaskResult, error)` | The topmost re-run’s `TaskResult`. The `error` is non-nil when no handle in the tree is suspended — there is nothing to resume. | ## Go also has a durable, cross-restart resume (preview) [Section titled “Go also has a durable, cross-restart resume (preview)”](#go-also-has-a-durable-cross-restart-resume-preview) STATUS: golang only — a preview, not part of the §0 conformance contract Separately from `Runtime.Resume` above, Go ships `Client.RunWithAnswer`/`Client.AskWithAnswer` — SPEC §10’s “Durable resume — the answer-carrying entry point,” which resumes a **bare-client** run (no agent runtime involved) across a **process restart**, not just across time within one running process. The other six ports — `js`, `python`, `java`, `csharp`, `elixir` and `clojure` — do not implement this yet (tracked in `openspec/changes/add-tool-relay-mode/tasks.md`). See [`suspension/relay`](/api/go/suspension/relay/), which typically resumes via this same pair. ```go func (c *Client) RunWithAnswer(ctx context.Context, tk *Toolkit, history []any, pending Request, answer Answer) (RunResult, error) func (c *Client) AskWithAnswer(ctx context.Context, tk *Toolkit, id string, pending Request, answer Answer) (RunResult, error) ``` `RunWithAnswer` takes the transcript you persisted yourself; `AskWithAnswer` is the stateful counterpart — it loads the transcript for `id` from the [`ConversationStore`](/api/go/client/store/), resumes, and saves the result back. Both fill **every** `tool_result` slot left outstanding on the halted assistant turn — not just the halted call’s — replacing the halt’s placeholder rather than appending alongside it, so the transcript sent to the provider is balanced (one `tool_result` per `tool_use`/`tool_call`) and replayable. The resume continues from the transcript and appends **no new user turn**. **`Answer.Data` has exactly two recognised keys on this path**, in this precedence: `"results"` (`[]any` of `{id, output, isError}`, one per outstanding call) and the single-call shorthand `"output"` (a **string**, with optional `"isError"`) — `toolnexus.RelayResultsKey` and `toolnexus.RelayOutputKey`. Build them with `toolnexus.AnswerOutput(id, output)` or `toolnexus.RelayAnswer(id, results)` rather than by hand — and use `toolnexus.AnswerDeclined(id, reason)` for the human who said no, which is a resolution (`Ok: false` + `Reason`), not an error. All three ship in all eight ports. Any other shape — `{"answers": […]}`, `{"value": "staging"}` — is **not** a recognised payload: `Ok: true` with no determinable result for an outstanding call returns an **error to you**, never a fabricated tool result handed to the model alongside `Status: "done"` (issue #89). A non-string `output` is an error too, not a silent `""`. One asymmetry to know before building on this: the recognised payload is spliced into the transcript as the call’s result — the tool is **not** re-executed and never sees `ctx.Answer`, unlike the inline `WaitFor` path. Making a non-relay durable resume re-execute the tool is deferred to its own change (`openspec/changes/fix-consumer-issues-86-93`, D4). ### 1. The smallest useful call — halt, then resume with `RunWithAnswer` [Section titled “1. The smallest useful call — halt, then resume with RunWithAnswer”](#1-the-smallest-useful-call--halt-then-resume-with-runwithanswer) ```go package main import ( "context" "fmt" "log" "net/http" "net/http/httptest" toolnexus "github.com/muthuishere/toolnexus/golang" ) func main() { turn := 0 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { turn++ w.Header().Set("Content-Type", "application/json") if turn == 1 { _, _ = w.Write([]byte(`{"choices":[{"message":{"role":"assistant","content":null,"tool_calls":[{"id":"c1","type":"function","function":{"name":"lookup","arguments":"{}"}}]}}],"usage":{"prompt_tokens":2,"completion_tokens":2,"total_tokens":4}}`)) return } // Turn 2: after resume, the provider sees a balanced transcript and answers. _, _ = w.Write([]byte(`{"choices":[{"message":{"role":"assistant","content":"resumed"}}],"usage":{"prompt_tokens":2,"completion_tokens":1,"total_tokens":3}}`)) })) defer srv.Close() lookup := toolnexus.RelayTool("lookup", "look something up", nil) tk, err := toolnexus.CreateToolkit(context.Background(), toolnexus.Options{Builtins: false, ExtraTools: []toolnexus.Tool{lookup}}) if err != nil { log.Fatal(err) } defer tk.Close() client := toolnexus.CreateClient(toolnexus.ClientOptions{BaseURL: srv.URL, Style: toolnexus.StyleOpenAI, Model: "stub", APIKey: "k"}) halted, err := client.Run(context.Background(), "go", tk) if err != nil { log.Fatal(err) } if halted.Status != "pending" { log.Fatalf("expected a durable halt, got %q", halted.Status) } resumed, err := client.RunWithAnswer(context.Background(), tk, halted.Messages, *halted.Pending, toolnexus.Answer{ID: halted.Pending.ID, Ok: true, Data: map[string]any{toolnexus.RelayOutputKey: "sunny"}}) if err != nil { log.Fatal(err) } if resumed.Status != "done" || resumed.Text != "resumed" { log.Fatalf("unexpected: %+v", resumed) } fmt.Println("ok: resumed to", resumed.Status) } ``` ### 2. The realistic case — resume across a fresh `*Client`, and a mismatched answer errors loudly [Section titled “2. The realistic case — resume across a fresh \*Client, and a mismatched answer errors loudly”](#2-the-realistic-case--resume-across-a-fresh-client-and-a-mismatched-answer-errors-loudly) `RunResult.Messages` and `RunResult.Pending` round-trip through `encoding/json` exactly as a durable host would persist them; a completely new `*Client` resumes the run. A mismatched `Answer.ID` is rejected — never a silent continue. ```go package main import ( "context" "encoding/json" "fmt" "log" "net/http" "net/http/httptest" "strings" toolnexus "github.com/muthuishere/toolnexus/golang" ) func main() { turn := 0 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { turn++ w.Header().Set("Content-Type", "application/json") if turn == 1 { _, _ = w.Write([]byte(`{"choices":[{"message":{"role":"assistant","content":null,"tool_calls":[{"id":"c1","type":"function","function":{"name":"lookup","arguments":"{}"}}]}}],"usage":{"prompt_tokens":2,"completion_tokens":2,"total_tokens":4}}`)) return } _, _ = w.Write([]byte(`{"choices":[{"message":{"role":"assistant","content":"resumed elsewhere"}}],"usage":{"prompt_tokens":2,"completion_tokens":1,"total_tokens":3}}`)) })) defer srv.Close() lookup := toolnexus.RelayTool("lookup", "look something up", nil) tk, err := toolnexus.CreateToolkit(context.Background(), toolnexus.Options{Builtins: false, ExtraTools: []toolnexus.Tool{lookup}}) if err != nil { log.Fatal(err) } defer tk.Close() first := toolnexus.CreateClient(toolnexus.ClientOptions{BaseURL: srv.URL, Style: toolnexus.StyleOpenAI, Model: "stub", APIKey: "k"}) halted, err := first.Run(context.Background(), "go", tk) if err != nil { log.Fatal(err) } // Round-trip through JSON, as a durable host persisting to disk/DB would. blob, err := json.Marshal(map[string]any{"messages": halted.Messages, "pending": halted.Pending}) if err != nil { log.Fatal(err) } var restored struct { Messages []any `json:"messages"` Pending toolnexus.Request `json:"pending"` } if err := json.Unmarshal(blob, &restored); err != nil { log.Fatal(err) } second := toolnexus.CreateClient(toolnexus.ClientOptions{BaseURL: srv.URL, Style: toolnexus.StyleOpenAI, Model: "stub", APIKey: "k"}) // A stale/misrouted answer id is rejected, not silently accepted. _, err = second.RunWithAnswer(context.Background(), tk, restored.Messages, restored.Pending, toolnexus.Answer{ID: "not-the-right-id", Ok: true, Data: map[string]any{toolnexus.RelayOutputKey: "x"}}) if err == nil || !strings.Contains(err.Error(), "does not echo") { log.Fatalf("expected a mismatched-id error, got %v", err) } // The correct id resumes cleanly on the brand-new client. resumed, err := second.RunWithAnswer(context.Background(), tk, restored.Messages, restored.Pending, toolnexus.Answer{ID: restored.Pending.ID, Ok: true, Data: map[string]any{toolnexus.RelayOutputKey: "cross-process"}}) if err != nil { log.Fatal(err) } if resumed.Status != "done" { log.Fatalf("unexpected: %+v", resumed) } fmt.Println("ok: resumed on a fresh client after a rejected mismatched answer") } ``` ### 3. The full surface — `AskWithAnswer` round-trips through the conversation store [Section titled “3. The full surface — AskWithAnswer round-trips through the conversation store”](#3-the-full-surface--askwithanswer-round-trips-through-the-conversation-store) `AskWithAnswer` is `RunWithAnswer` plus store load/save: no explicit transcript handling on your part — just the conversation id. ```go package main import ( "context" "fmt" "log" "net/http" "net/http/httptest" toolnexus "github.com/muthuishere/toolnexus/golang" ) func main() { turn := 0 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { turn++ w.Header().Set("Content-Type", "application/json") if turn == 1 { _, _ = w.Write([]byte(`{"choices":[{"message":{"role":"assistant","content":null,"tool_calls":[{"id":"c1","type":"function","function":{"name":"lookup","arguments":"{}"}}]}}],"usage":{"prompt_tokens":2,"completion_tokens":2,"total_tokens":4}}`)) return } _, _ = w.Write([]byte(`{"choices":[{"message":{"role":"assistant","content":"stored answer"}}],"usage":{"prompt_tokens":2,"completion_tokens":1,"total_tokens":3}}`)) })) defer srv.Close() lookup := toolnexus.RelayTool("lookup", "look something up", nil) tk, err := toolnexus.CreateToolkit(context.Background(), toolnexus.Options{Builtins: false, ExtraTools: []toolnexus.Tool{lookup}}) if err != nil { log.Fatal(err) } defer tk.Close() client := toolnexus.CreateClient(toolnexus.ClientOptions{BaseURL: srv.URL, Style: toolnexus.StyleOpenAI, Model: "stub", APIKey: "k"}) halted, err := client.Ask(context.Background(), "go", tk, "conv-1") if err != nil { log.Fatal(err) } if halted.Status != "pending" { log.Fatalf("expected pending, got %q", halted.Status) } resumed, err := client.AskWithAnswer(context.Background(), tk, "conv-1", *halted.Pending, toolnexus.Answer{ID: halted.Pending.ID, Ok: true, Data: map[string]any{toolnexus.RelayOutputKey: "stored answer"}}) if err != nil { log.Fatal(err) } if resumed.Status != "done" { log.Fatalf("unexpected: %+v", resumed) } // The store now holds the resumed, balanced transcript under the same id. saved, err := client.ConversationStore().Get("conv-1") if err != nil { log.Fatal(err) } if len(saved) != len(resumed.Messages) { log.Fatalf("expected the store to hold the resumed transcript: saved=%d resumed=%d", len(saved), len(resumed.Messages)) } fmt.Println("ok: AskWithAnswer resumed and saved conv-1,", len(saved), "messages") } ``` ## See also [Section titled “See also”](#see-also) * [`agents.Handle`](/api/go/runtime/handle/) — The state machine for one spawned agent: pending, running, suspended, done. * [`NewRuntime`](/api/go/runtime/runtime/) — The six host verbs that drive sub-agents, plus the read-only list and inspect views. * [`Pending`](/api/go/suspension/pending/) — Return a Pending from a tool to park the run until someone answers. * [`RelayTool`](/api/go/suspension/relay/) — The declaration-only tool the durable resume path was built to cover. # WaitFor > The single hook where the host resolves a suspension — in-process prompt or durable queue, same contract. — Go. **Go** · package `github.com/muthuishere/toolnexus/golang` · [SPEC §10](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [golang/client.go](https://github.com/muthuishere/toolnexus/blob/main/golang/client.go) ```go type ClientOptions struct { // ... WaitFor func(Request) (Answer, error) } ``` `WaitFor` is the one host slot for resolving a suspension: `data → data`, blocking (Go has no `await`), on `ClientOptions`. When a tool call returns `Pending(request)`, the loop calls `c.opts.WaitFor(request)`. Its interior is entirely unconstrained — open a browser and poll, message a Slack channel and poll, write a file and watch it, forward the request over A2A to another agent. Whatever it does, it returns an `Answer`, and the loop takes it from there. ## When to use it [Section titled “When to use it”](#when-to-use-it) Set `WaitFor` whenever you want suspensions resolved **in-process** — the run blocks inside `Run`/`Stream`/`Ask` until the human or system answers, then continues automatically. This is the simplest posture: state lives on the live process, no persistence needed. Leave `WaitFor` unset when you want the **durable** posture instead — the run halts with `RunResult.Status == "pending"`, and you resume it later, possibly in another process, with [`Client.Resume`](/api/go/suspension/resume/) (`RunWithAnswer`/`AskWithAnswer`). ## Why this and not the alternative [Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative) Durable across a process boundary? Skip WaitFor `WaitFor` is a blocking call inside a single `Run` — it can’t survive a process restart, and it holds a goroutine parked while it waits. If the resolution might take minutes/hours/days, or the run needs to persist across a redeploy, don’t set `WaitFor` at all: let the run halt with `status:"pending"`, persist `RunResult.Pending` and `RunResult.Messages` yourself, and resume later via [`Client.Resume`](/api/go/suspension/resume/) — same wire data (`Request`/`Answer`), different delivery mechanism. ## Examples [Section titled “Examples”](#examples) ### 1. The smallest useful call — WaitFor resolves a suspension inline [Section titled “1. The smallest useful call — WaitFor resolves a suspension inline”](#1-the-smallest-useful-call--waitfor-resolves-a-suspension-inline) ```go package main import ( "context" "fmt" "log" "net/http" "net/http/httptest" toolnexus "github.com/muthuishere/toolnexus/golang" ) func main() { turn := 0 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { turn++ w.Header().Set("Content-Type", "application/json") if turn == 1 { _, _ = w.Write([]byte(`{"choices":[{"message":{"role":"assistant","content":null,"tool_calls":[{"id":"c1","type":"function","function":{"name":"ask","arguments":"{}"}}]}}],"usage":{"prompt_tokens":2,"completion_tokens":2,"total_tokens":4}}`)) return } _, _ = w.Write([]byte(`{"choices":[{"message":{"role":"assistant","content":"got it"}}],"usage":{"prompt_tokens":2,"completion_tokens":1,"total_tokens":3}}`)) })) defer srv.Close() ask := toolnexus.Tool{ Name: "ask", Description: "asks something", InputSchema: toolnexus.JSONSchema{"type": "object", "properties": map[string]any{}}, Source: toolnexus.SourceCustom, Execute: func(_ map[string]any, ctx *toolnexus.ToolContext) (toolnexus.ToolResult, error) { if ctx != nil && ctx.Answer != nil { return toolnexus.ToolResult{Output: "resolved"}, nil } return toolnexus.Pending(toolnexus.Request{Kind: "input", Prompt: "confirm?"}), nil }, } tk, err := toolnexus.CreateToolkit(context.Background(), toolnexus.Options{ExtraTools: []toolnexus.Tool{ask}}) if err != nil { log.Fatal(err) } defer tk.Close() client := toolnexus.CreateClient(toolnexus.ClientOptions{ BaseURL: srv.URL, Style: toolnexus.StyleOpenAI, Model: "gpt-4o-mini", APIKey: "test-key", WaitFor: func(req toolnexus.Request) (toolnexus.Answer, error) { return toolnexus.Answer{ID: req.ID, Ok: true}, nil }, }) res, err := client.Run(context.Background(), "please ask", tk) if err != nil { log.Fatal(err) } if res.Status != "done" || res.Text != "got it" { log.Fatalf("unexpected: %+v", res) } fmt.Println("ok:", res.Text) } ``` ### 2. The realistic case — no `WaitFor` set: the run halts durably instead [Section titled “2. The realistic case — no WaitFor set: the run halts durably instead”](#2-the-realistic-case--no-waitfor-set-the-run-halts-durably-instead) Omitting `WaitFor` entirely is the other, equally valid posture: the run does not hang or error — it comes back with `Status: "pending"` and the `Request` for you to resolve out of band. ```go package main import ( "context" "fmt" "log" "net/http" "net/http/httptest" toolnexus "github.com/muthuishere/toolnexus/golang" ) func main() { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"choices":[{"message":{"role":"assistant","content":null,"tool_calls":[{"id":"c1","type":"function","function":{"name":"ask","arguments":"{}"}}]}}],"usage":{"prompt_tokens":2,"completion_tokens":2,"total_tokens":4}}`)) })) defer srv.Close() ask := toolnexus.Tool{ Name: "ask", Description: "asks something", InputSchema: toolnexus.JSONSchema{"type": "object", "properties": map[string]any{}}, Source: toolnexus.SourceCustom, Execute: func(_ map[string]any, _ *toolnexus.ToolContext) (toolnexus.ToolResult, error) { return toolnexus.Pending(toolnexus.Request{Kind: "input", Prompt: "confirm?"}), nil }, } tk, err := toolnexus.CreateToolkit(context.Background(), toolnexus.Options{ExtraTools: []toolnexus.Tool{ask}}) if err != nil { log.Fatal(err) } defer tk.Close() // No WaitFor configured. client := toolnexus.CreateClient(toolnexus.ClientOptions{ BaseURL: srv.URL, Style: toolnexus.StyleOpenAI, Model: "gpt-4o-mini", APIKey: "test-key", }) res, err := client.Run(context.Background(), "please ask", tk) if err != nil { log.Fatal(err) } if res.Status != "pending" || res.Pending == nil { log.Fatalf("expected a durable pending halt, got status=%q pending=%v", res.Status, res.Pending) } if res.Pending.Prompt != "confirm?" { log.Fatalf("unexpected pending request: %+v", res.Pending) } fmt.Println("ok: halted durably with", res.Pending.Kind, "-", res.Pending.Prompt) } ``` ### 3. The full surface — declined vs granted, and the loop rule on retry [Section titled “3. The full surface — declined vs granted, and the loop rule on retry”](#3-the-full-surface--declined-vs-granted-and-the-loop-rule-on-retry) `Answer.Ok == false` feeds back a `"declined/expired: "` error result and the model decides what to do next — the run does not abort. ```go package main import ( "context" "fmt" "log" "net/http" "net/http/httptest" "strings" toolnexus "github.com/muthuishere/toolnexus/golang" ) func main() { turn := 0 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { turn++ w.Header().Set("Content-Type", "application/json") if turn == 1 { _, _ = w.Write([]byte(`{"choices":[{"message":{"role":"assistant","content":null,"tool_calls":[{"id":"c1","type":"function","function":{"name":"delete_record","arguments":"{}"}}]}}],"usage":{"prompt_tokens":2,"completion_tokens":2,"total_tokens":4}}`)) return } _, _ = w.Write([]byte(`{"choices":[{"message":{"role":"assistant","content":"understood, not deleting"}}],"usage":{"prompt_tokens":2,"completion_tokens":2,"total_tokens":4}}`)) })) defer srv.Close() del := toolnexus.Tool{ Name: "delete_record", Description: "deletes a record", InputSchema: toolnexus.JSONSchema{"type": "object", "properties": map[string]any{}}, Source: toolnexus.SourceCustom, Execute: func(_ map[string]any, ctx *toolnexus.ToolContext) (toolnexus.ToolResult, error) { if ctx != nil && ctx.Answer != nil { return toolnexus.ToolResult{Output: "deleted"}, nil } return toolnexus.Pending(toolnexus.Request{Kind: "approval", Prompt: "delete this record?"}), nil }, } tk, err := toolnexus.CreateToolkit(context.Background(), toolnexus.Options{ExtraTools: []toolnexus.Tool{del}}) if err != nil { log.Fatal(err) } defer tk.Close() client := toolnexus.CreateClient(toolnexus.ClientOptions{ BaseURL: srv.URL, Style: toolnexus.StyleOpenAI, Model: "gpt-4o-mini", APIKey: "test-key", WaitFor: func(req toolnexus.Request) (toolnexus.Answer, error) { return toolnexus.Answer{ID: req.ID, Ok: false, Reason: "declined"}, nil }, }) res, err := client.Run(context.Background(), "delete the record", tk) if err != nil { log.Fatal(err) } if res.Status != "done" { log.Fatalf("a decline continues the loop, not aborts it: %+v", res) } if !strings.Contains(res.ToolCalls[0].Output, "declined") { log.Fatalf("expected the declined error text fed back, got %q", res.ToolCalls[0].Output) } if !res.ToolCalls[0].IsError { log.Fatal("a declined resolution is an error tool_result") } fmt.Println("ok:", res.Text) } ``` ## The loop rule [Section titled “The loop rule”](#the-loop-rule) | `WaitFor` | `Answer.Ok` | What happens | | -------------- | ----------- | ---------------------------------------------------------------------------------------------------------------------------------------- | | configured | `true` | Retries the same tool once with `ctx.Answer` set. Still-suspended retry ⇒ error `"unresolved: "`, never loops forever. | | configured | `false` | Feeds back error `"declined/expired: "`. The loop continues; the model decides. | | not configured | — | The run halts: `RunResult.Status == "pending"`, `RunResult.Pending` set. Resume later via [`Client.Resume`](/api/go/suspension/resume/). | ## `WaitForCtx` — end a pending question with its turn [Section titled “WaitForCtx — end a pending question with its turn”](#waitforctx--end-a-pending-question-with-its-turn) ```go WaitForCtx func(ctx context.Context, req Request) (Answer, error) // ClientOptions ``` `WaitForCtx` is `WaitFor` that also receives the turn’s context, so a question waiting on a human ends when the turn is cancelled (a stop button, a timeout). When both are set, `WaitForCtx` wins; the §10 contract is otherwise the same. Ask permission through it from a `BeforeTool` gate that returns `Pending` (see [Hooks](/api/go/client/hooks/)). ```go package main import ( "context" "errors" "fmt" "log" "net/http" "net/http/httptest" "time" toolnexus "github.com/muthuishere/toolnexus/golang" ) func main() { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"choices":[{"message":{"role":"assistant","content":null,"tool_calls":[{"id":"c1","type":"function","function":{"name":"ask","arguments":"{}"}}]}}]}`)) })) defer srv.Close() ask := toolnexus.Tool{ Name: "ask", Description: "asks the user", InputSchema: toolnexus.JSONSchema{"type": "object", "properties": map[string]any{}}, Source: toolnexus.SourceCustom, Execute: func(_ map[string]any, _ *toolnexus.ToolContext) (toolnexus.ToolResult, error) { return toolnexus.Pending(toolnexus.Request{Kind: "input", Prompt: "deploy?"}), nil }, } tk, err := toolnexus.CreateToolkit(context.Background(), toolnexus.Options{ExtraTools: []toolnexus.Tool{ask}}) if err != nil { log.Fatal(err) } defer tk.Close() client := toolnexus.CreateClient(toolnexus.ClientOptions{ BaseURL: srv.URL, Style: toolnexus.StyleOpenAI, Model: "m", APIKey: "YOUR_KEY_HERE", WaitForCtx: func(ctx context.Context, req toolnexus.Request) (toolnexus.Answer, error) { <-ctx.Done() // the human never answers; the turn's cancel ends the wait return toolnexus.Answer{}, ctx.Err() }, }) ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) defer cancel() _, err = client.Run(ctx, "deploy it", tk) if !errors.Is(err, context.DeadlineExceeded) { log.Fatalf("expected the pending ask to end with the turn, got %v", err) } fmt.Println("ok: pending question ended with the turn:", err) } ``` ## See also [Section titled “See also”](#see-also) * [`Pending`](/api/go/suspension/pending/) — Return a Pending from a tool to park the run until someone answers. * [`AuthRequired`](/api/go/suspension/auth-required/) — The auth-shaped suspension: hand back a URL, resume once the user has granted access. * [`PendingOf`](/api/go/suspension/pending-of/) — Detect that a RunResult is parked rather than finished, and get the Request that parked it. * [`Client.Resume`](/api/go/suspension/resume/) — The answer-carrying entry point: resume a parked run in a different process. # CreateToolkit > Collapse MCP servers, agent skills, built-ins, remote A2A agents and your own functions into one Tool list — Go. **Go** · module `github.com/muthuishere/toolnexus/golang` · [SPEC §4](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md#4-toolkit-aggregator) · [golang/toolkit.go](https://github.com/muthuishere/toolnexus/blob/main/golang/toolkit.go) ```go func CreateToolkit(ctx context.Context, opts Options) (*Toolkit, error) ``` The aggregator. Every tool source — MCP servers, `SKILL.md` folders, the built-in file/shell tools, remote A2A agents, and your own Go functions — collapses into one flat `[]Tool` behind this call. Go takes a context, not a signal Unlike the JS port, `Options` has no cancellation field. The `ctx` argument bounds the whole load: cancel it and connects abort promptly without leaking child processes. This is the idiomatic Go shape of the same §2 guarantee. ## When to use it [Section titled “When to use it”](#when-to-use-it) Reach for `CreateToolkit` when you have **more than one source of tools** and want the model to see one list. An `mcp.json` plus a `skills/` folder is already two sources. It is also the only route to the provider adapters — `tk.ToOpenAI()`, `tk.ToAnthropic()` and `tk.ToGemini()` are methods on `*Toolkit`. ## Why this and not the alternative [Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative) `LoadMcp` and `LoadSkills` each return tools on their own. With exactly one source and your own loop, calling the source directly is less indirection. Prefer `CreateToolkit` when: you have two or more sources (name collisions are resolved here, once, by documented precedence); you want provider schema; you have MCP servers (they are live child processes — `tk.Close()` owns their shutdown); or you want `tk.Serve()`. ## Examples [Section titled “Examples”](#examples) ### 1. Skills only — the smallest useful toolkit [Section titled “1. Skills only — the smallest useful toolkit”](#1-skills-only--the-smallest-useful-toolkit) ```go ctx := context.Background() tk, err := toolnexus.CreateToolkit(ctx, toolnexus.Options{ SkillsDir: []string{"./examples/skills"}, }) if err != nil { log.Fatal(err) } defer tk.Close() res, err := tk.Execute(ctx, "skill", map[string]any{"name": "hello-world"}) if err != nil { log.Fatal(err) } fmt.Println(res.Output) ``` ### 2. MCP servers and skills together [Section titled “2. MCP servers and skills together”](#2-mcp-servers-and-skills-together) This is `golang/examples/basic/main.go`, run against the shared `examples/` fixtures — the same fixtures every port is tested on. ```go ctx := context.Background() tk, err := toolnexus.CreateToolkit(ctx, toolnexus.Options{ McpConfig: filepath.Join(root, "mcp.json"), SkillsDir: []string{filepath.Join(root, "skills")}, }) if err != nil { log.Fatal(err) } defer tk.Close() // Per-server connection status. fmt.Printf("MCP status: %v\n", tk.McpStatus()) // One flat list; Source says where each tool came from. for _, t := range tk.Tools() { fmt.Printf("%s (%s)\n", t.Name, t.Source) } // The skill catalog, ready for a system prompt. fmt.Println(tk.SkillsPrompt()) // Provider schema, straight off the toolkit. b, _ := json.MarshalIndent(tk.ToOpenAI(), "", " ") fmt.Println(string(b)) ``` MCP tools arrive namespaced as `server_tool`, so two servers exposing `search` don’t collide. ### 3. The full surface [Section titled “3. The full surface”](#3-the-full-surface) ```go ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() tk, err := toolnexus.CreateToolkit(ctx, toolnexus.Options{ McpConfig: "./mcp.json", SkillsDir: []string{"./skills", "./team-skills"}, // Skills as data — no filesystem involved (§3, S1). Skills: []toolnexus.SkillDef{{Name: "deploy", Description: "Ship a release", Content: "…"}}, SkillProvider: func(ctx context.Context) ([]toolnexus.SkillDef, error) { return fetchSkillsFromDB(ctx) }, // Narrow what this agent may see (§3, S2). SkillsFilter: map[string]bool{"deploy": true}, DisableSkills: []string{"dangerous-migration"}, // Drop tools by FINAL exposed name, after aggregation. DisableTools: []string{"github_delete_repo"}, Builtins: false, // no file/shell tools for this agent ExtraTools: []toolnexus.Tool{weatherTool}, // An MCP server may now elicit input from the human mid-call (§10). WaitFor: func(req toolnexus.Request) (toolnexus.Answer, error) { return promptTheUser(req) }, }) ``` Caution A `SkillProvider` failure is **isolated** — the other skill sources still load. Don’t rely on a provider error to fail the build. ## Options [Section titled “Options”](#options) | Field | Type | What it does | | ------------------ | ------------------------------------------- | --------------------------------------------------------------------- | | `McpConfig` | `any` | Path, raw JSON bytes, an `McpConfig`, or a parsed map. Nil skips MCP. | | `SkillsDir` | `[]string` | Skill roots. Empty skips skill loading. | | `Skills` | `[]SkillDef` | Skills supplied as data. | | `SkillProvider` | `func(context.Context) ([]SkillDef, error)` | Lazy provider, resolved once. Failure is isolated. | | `SkillsFilter` | `map[string]bool` | Per-agent skill allowlist. | | `SkillSampleLimit` | `int` | `0` ⇒ 10, `n` ⇒ cap, `-1` ⇒ omit ``. | | `DisableTools` | `[]string` | Drop by final exposed name, after aggregation. | | `DisableSkills` | `[]string` | Drop skills by name. | | `ExtraTools` | `[]Tool` | Your own tools. | | `Builtins` | `any` | `nil` ⇒ on, or a `bool` / `BuiltinsConfig`. | | `Agents` | `[]Agent` | Remote A2A peers; a top-level `agents` block is merged in too. | | `WaitFor` | `func(Request) (Answer, error)` | Host resolver for MCP elicitation (§10). | ## See also [Section titled “See also”](#see-also) * [`LoadMcp`](/api/go/mcp/load/) · [`LoadSkills`](/api/go/skills/load/) · [`CreateClient`](/api/go/client/create/) # Inbound translation — transcripts across providers > Convert an OpenAI-shaped transcript into Anthropic shape, including the tool-result merging a flattening translator gets wrong. — Go. **Go** · package `github.com/muthuishere/toolnexus/golang` · [SPEC §11](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) Not available in Go Go keeps openAIMessagesToAnthropic unexported (golang/translate.go:283) — inbound translation is reachable only through Translate. Convert an OpenAI-shaped transcript into Anthropic shape, including the tool-result merging a flattening translator gets wrong. ## What to use instead [Section titled “What to use instead”](#what-to-use-instead) Go’s `openAIMessagesToAnthropic` (`golang/translate.go:283`) does exactly what the exported symbol does in the other five ports — it’s not missing, it’s just not part of the public API. The Go port deliberately keeps the raw inbound converter and its siblings (`openAIToolsToAnthropic`, `openAIToolChoiceToAnthropic`) unexported, because the port’s stance is that a caller should never need to hand-assemble a provider-native payload: point [`Translate`](/api/go/translate/translate/) at `StyleAnthropic` and it runs the same conversion internally, on your behalf, with retries, request-param merging and the `llm` observability event all wired in — see `translate/translate` example 3 for the system-hoisting and `tool_use`/`tool_result` conversion in action against a live (stubbed) Anthropic-style endpoint. If you need the *converted messages themselves* rather than a completed response — say, to hand them to a different Anthropic SDK client — there is no exported escape hatch in Go today; that would be a new, deliberate addition to the public API, not a gap in feature parity. ## See also [Section titled “See also”](#see-also) * [`Translate`](/api/go/translate/translate/) — Declare a toolkit to a provider and translate one request/response without executing anything or keeping state. # Translate > Declare a toolkit to a provider and translate one request/response without executing anything or keeping state. — Go. **Go** · package `github.com/muthuishere/toolnexus/golang` · [SPEC §11](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [golang/translate.go](https://github.com/muthuishere/toolnexus/blob/main/golang/translate.go) ```go type TranslateRequest struct { Messages []any // OpenAI `messages`, verbatim — incl. assistant tool_calls, tool results Tools []any // OpenAI `tools`, verbatim — declaration-only, never executed Toolkit *Toolkit // an ordinary toolkit, DECLARED, never executed ToolChoice any // OpenAI `tool_choice`, verbatim System string // overrides the system prompt MaxTokens int // 0 ⇒ per-provider default } type TranslateResult struct { Text string ToolCalls []TranslatedToolCall // {ID, Name, Arguments string} FinishReason string // "stop" | "tool_calls" | "length" | "content_filter" Usage Usage Model string Raw map[string]any } func (c *Client) Translate(ctx context.Context, req TranslateRequest) (TranslateResult, error) ``` `Translate` is toolnexus used purely as a wire-format translator: OpenAI-shaped messages and tools go in, exactly **one** provider call happens (`StyleOpenAI` is near-passthrough, `StyleAnthropic` does real inbound/outbound translation), and an OpenAI-shaped result comes back. No tool executes, nothing suspends, no conversation is remembered — every call is self-contained, so a caller can run it statelessly and horizontally scale it. It’s the inbound half of the §5 adapters: `ToOpenAI`/`ToAnthropic`/`ToGemini` send a toolkit’s declarations *out*; `Translate` reads the provider’s tool calls back *in*. ## When to use it [Section titled “When to use it”](#when-to-use-it) Reach for `Translate` when the **caller** owns the conversation and executes tools itself — the standard OpenAI function-calling posture, where every request carries the full history including prior tool results, and the caller dispatches whatever `ToolCalls` come back. This is the shape of a stateless HTTP proxy: no state between turns, `c.opts.Style` picks the upstream provider, and the caller never builds provider-native payloads by hand. ## Why this and not the alternative [Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative) Want toolnexus to own the loop and execute tools? `Translate` never executes anything — a `Toolkit` you pass in is declared to the provider and nothing more. When you want the tool calls actually *run*, with retries, multi-turn looping and suspension all handled for you, use [`Client.Run`](/api/go/client/run/) / [`Client.Ask`](/api/go/client/store/) instead. If you want the loop AND caller-owned execution for one specific tool, that’s [`RelayTool`](/api/go/suspension/relay/) + `RunWithAnswer` — a third posture between the two. ## Examples [Section titled “Examples”](#examples) ### 1. The smallest useful call — no tools, one OpenAI-style round trip [Section titled “1. The smallest useful call — no tools, one OpenAI-style round trip”](#1-the-smallest-useful-call--no-tools-one-openai-style-round-trip) ```go package main import ( "context" "fmt" "log" "net/http" "net/http/httptest" toolnexus "github.com/muthuishere/toolnexus/golang" ) func main() { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"choices":[{"message":{"role":"assistant","content":"Paris"},"finish_reason":"stop"}],"usage":{"prompt_tokens":5,"completion_tokens":1,"total_tokens":6}}`)) })) defer srv.Close() client := toolnexus.CreateClient(toolnexus.ClientOptions{BaseURL: srv.URL, Style: toolnexus.StyleOpenAI, Model: "gpt-4o-mini", APIKey: "test-key"}) res, err := client.Translate(context.Background(), toolnexus.TranslateRequest{ Messages: []any{map[string]any{"role": "user", "content": "capital of France?"}}, }) if err != nil { log.Fatal(err) } if res.Text != "Paris" || res.FinishReason != "stop" || len(res.ToolCalls) != 0 { log.Fatalf("unexpected: %+v", res) } fmt.Println("ok:", res.Text) } ``` ### 2. The realistic case — tool calls come back, nothing executes [Section titled “2. The realistic case — tool calls come back, nothing executes”](#2-the-realistic-case--tool-calls-come-back-nothing-executes) The model asks for a tool; `Translate` hands the calls straight back to the caller, who owns execution — no `Tool.Execute` in this codebase ever runs. ```go package main import ( "context" "fmt" "log" "net/http" "net/http/httptest" toolnexus "github.com/muthuishere/toolnexus/golang" ) func main() { ran := false add := toolnexus.NativeTool( "add", "add two numbers", toolnexus.JSONSchema{"type": "object", "properties": map[string]any{ "a": map[string]any{"type": "number"}, "b": map[string]any{"type": "number"}, }}, func(_ context.Context, _ map[string]any) (string, error) { ran = true return "5", nil }, ) tk, err := toolnexus.CreateToolkit(context.Background(), toolnexus.Options{Builtins: false, ExtraTools: []toolnexus.Tool{add}}) if err != nil { log.Fatal(err) } defer tk.Close() srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"choices":[{"message":{"role":"assistant","content":null,"tool_calls":[{"id":"c1","type":"function","function":{"name":"add","arguments":"{\"a\":2,\"b\":3}"}}]}}],"usage":{"prompt_tokens":8,"completion_tokens":6,"total_tokens":14}}`)) })) defer srv.Close() client := toolnexus.CreateClient(toolnexus.ClientOptions{BaseURL: srv.URL, Style: toolnexus.StyleOpenAI, Model: "gpt-4o-mini", APIKey: "test-key"}) // Toolkit declares "add" to the provider — nothing runs, even though it's the // toolkit's real Execute. res, err := client.Translate(context.Background(), toolnexus.TranslateRequest{ Messages: []any{map[string]any{"role": "user", "content": "what is 2+3?"}}, Toolkit: tk, }) if err != nil { log.Fatal(err) } if res.FinishReason != "tool_calls" || len(res.ToolCalls) != 1 { log.Fatalf("unexpected: %+v", res) } if res.ToolCalls[0].Name != "add" || res.ToolCalls[0].Arguments != `{"a":2,"b":3}` { log.Fatalf("unexpected tool call: %+v", res.ToolCalls[0]) } if ran { log.Fatal("Translate must never execute a tool, even one declared via a real Toolkit") } fmt.Println("ok:", res.ToolCalls[0].Name, "declared via Toolkit, arguments:", res.ToolCalls[0].Arguments, "- never executed") } ``` ### 3. The full surface — Anthropic upstream doing real inbound translation [Section titled “3. The full surface — Anthropic upstream doing real inbound translation”](#3-the-full-surface--anthropic-upstream-doing-real-inbound-translation) Against an Anthropic-style upstream, `Translate` performs the real inbound conversion — OpenAI `tool_calls`/`tool` messages become native `tool_use`/`tool_result` content blocks, and a prior `system` message is hoisted into Anthropic’s separate `system` field. ```go package main import ( "context" "encoding/json" "fmt" "io" "log" "net/http" "net/http/httptest" "strings" toolnexus "github.com/muthuishere/toolnexus/golang" ) func main() { var lastBody []byte srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { lastBody, _ = io.ReadAll(r.Body) w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"content":[{"type":"text","text":"got it"}],"stop_reason":"end_turn","usage":{"input_tokens":6,"output_tokens":2}}`)) })) defer srv.Close() client := toolnexus.CreateClient(toolnexus.ClientOptions{BaseURL: srv.URL, Style: toolnexus.StyleAnthropic, Model: "claude-x", APIKey: "test-key"}) res, err := client.Translate(context.Background(), toolnexus.TranslateRequest{ Messages: []any{ map[string]any{"role": "system", "content": "You are terse."}, map[string]any{"role": "user", "content": "call echo with hi"}, map[string]any{"role": "assistant", "content": nil, "tool_calls": []any{ map[string]any{"id": "c1", "type": "function", "function": map[string]any{"name": "echo", "arguments": `{"text":"hi"}`}}, }}, map[string]any{"role": "tool", "tool_call_id": "c1", "content": "hi"}, }, }) if err != nil { log.Fatal(err) } if res.Text != "got it" || res.FinishReason != "stop" { log.Fatalf("unexpected: %+v", res) } var sent map[string]any if err := json.Unmarshal(lastBody, &sent); err != nil { log.Fatal(err) } if sent["system"] != "You are terse." { log.Fatalf("expected the system message hoisted into Anthropic's system field, got %v", sent["system"]) } raw, _ := json.Marshal(sent["messages"]) for _, want := range []string{`"tool_use"`, `"tool_result"`, `"tool_use_id":"c1"`} { if !strings.Contains(string(raw), want) { log.Fatalf("expected native content blocks in the translated messages, missing %s: %s", want, raw) } } fmt.Println("ok:", res.Text, "- system hoisted, tool_calls became tool_use/tool_result blocks") } ``` ## Behavior [Section titled “Behavior”](#behavior) | Aspect | Detail | | --------------------------- | --------------------------------------------------------------------------------------------------------------------------- | | One call, no loop | Exactly one provider call per `Translate`, ever — no retries beyond the shared resilience policy, no re-execution. | | Nothing executes | `Toolkit`/`Tools` are declaration-only inputs; there is no execution path in this entry point at all. | | `Tools` + `Toolkit` compose | Toolkit declarations come first, then `Tools`. | | `arguments` | Always a JSON **string** on the way out — the OpenAI wire form — even when a caller supplied it as an object on the way in. | | `finishReason` | A turn emitting any tool call is always `"tool_calls"`, regardless of the provider’s own stop reason. | | Hooks | `BeforeLLM`/`AfterLLM` each fire exactly once. Tool hooks never fire — no tool runs. | | Shared infrastructure | Retries/backoff, request-param merging, and the `llm` observability event are the same as the agent loop’s. | ## See also [Section titled “See also”](#see-also) * [`Client.Run`](/api/go/client/run/) — The stateful agent loop that executes tools, for when the caller does NOT want to own execution. * [`RelayTool`](/api/go/suspension/relay/) — The middle ground: toolnexus owns the loop, the caller owns one tool’s execution. * *translate/inbound* — Go keeps the raw inbound converter unexported; reachable only through `Translate` itself. # File > 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. — Go. **Go** · package `github.com/muthuishere/toolnexus/golang` · [SPEC §1B](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) ```go func Text(s string) ContentPart // a text content part // Edge constructors — every one reads and base64-encodes NOW, so a path or an // unread stream never enters a ContentPart. Errors are deferred onto the // returned part (ContentPart.Err()), surfaced when the part is actually used. func File(path string) ContentPart // reads the path; mime from the fixed extension table func Bytes(raw []byte, mimeType string) ContentPart // native bytes, explicit mime — no hand-base64 tax func Reader(r io.Reader, mimeType string) ContentPart // drained eagerly; NOT closed — caller owns it func FileHandle(f fs.File) ContentPart // an already-open *os.File & friends; mime from f.Stat().Name() func FSFile(fsys fs.FS, name string) ContentPart // embed.FS / os.DirFS / fstest.MapFS — a fixture shipped in the binary func URLPart(u, mimeType string) ContentPart // a data: URL is parsed into {mimeType, data}; any other URL is kept as URL func (p ContentPart) Bytes() int // the DECODED byte length this part carries (0 for text/url) // ClientOptions.OnUnsupportedPart string // "" (provenance rule) | "error" | "text" ``` Five constructors and `Text` turn a path, raw bytes, an `io.Reader`, an already-open file, an `fs.FS` entry, or a URL into the one wire shape every part collapses to — [`ContentPart`](/api/go/types/content-part/): `{Type, Text, MimeType, Data, URL, Name}`. They are the **write** half; `ContentPart` itself (and its `Err`/`Bytes`/`EstimatedTokens`/`Validate` readers) is the read-only shape a prompt, a tool result, or an MCP response actually carries. Every constructor reads and base64-encodes **at construction time** — never lazily, never on a retained handle — so a `ContentPart` can always be marshalled, persisted, or replayed without re-touching whatever it was built from. A construction failure (unreadable file, unknown extension, oversize payload) is never a panic: it’s deferred onto the returned part and surfaced the first time the part is actually used (`RunParts`, `Err()`), so these stay usable as one-liners inside a slice literal. ## When to use it [Section titled “When to use it”](#when-to-use-it) Reach for `File(path)` for the common case — a file already on disk. Reach for `Bytes` when you already hold the bytes (a generated screenshot, a downloaded payload) and don’t want a temp file. Reach for `Reader` for a stream you don’t want fully materialised as a `[]byte` yourself first (it still gets drained internally — the win is not writing that loop). Reach for `FileHandle` when the caller already has an open `*os.File` and would otherwise have to re-derive its path. Reach for `FSFile` when the asset ships **inside your binary** via `//go:embed` rather than on the host filesystem. Reach for `URLPart` for a remote image/file/audio URL, or a `data:` URL you want normalized into `{mimeType, data}` at construction rather than carried as a string. ## Why this and not the alternative [Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative) Building a ContentPart by hand instead? Nothing stops you from writing `ContentPart{Type: "image", MimeType: "image/png", Data: b64}` directly — the struct is exported and every field is public. The constructors exist so you never hand-derive the mime type from an extension, hand-base64-encode bytes, or hand-parse a `data:` URL differently at each call site; `File`/`FSFile`/`FileHandle` also fill `Name` from the source (used by `String()`’s human-readable rendering and by providers that display a filename), which a literal easily forgets. Reach for [`URLPart`](#) over your own `strings.HasPrefix(u, "data:")` check specifically because it’s the one place a `data:` URL’s two representations — inline vs. remote — are guaranteed to decode to the identical bytes downstream. OnUnsupportedPart — the provenance rule for a part the wire style can't carry Not every `ClientStyle` can represent every part type (Anthropic has no native audio block, for one). The default (`ClientOptions.OnUnsupportedPart == ""`) applies a **provenance rule**: a part **you attached** (via `RunParts`) fails loudly as a typed `*UnsupportedPartError` before any HTTP call — you’ll know immediately your prompt can’t reach that model. A part **derived from a tool or MCP result** instead degrades to a text placeholder (`"[unsupported audio part (audio/mpeg)]"`, never the bytes) and warns once, because a remote server returning something your style can’t render must not fail the whole run. Set `OnUnsupportedPart: "error"` to make both kinds fail loudly, or `"text"` to make both kinds degrade — see [`emit.go`](https://github.com/muthuishere/toolnexus/blob/main/golang/emit.go). ## Examples [Section titled “Examples”](#examples) ### 1. The smallest useful call — attach a file from disk [Section titled “1. The smallest useful call — attach a file from disk”](#1-the-smallest-useful-call--attach-a-file-from-disk) ```go package main import ( "fmt" "log" "os" "path/filepath" toolnexus "github.com/muthuishere/toolnexus/golang" ) func main() { // File's fixed extension table covers a closed set of media types // (png/jpg/jpeg/gif/webp/pdf/mp3/wav) — an unknown extension is a typed // error, not a guess. ".pdf" resolves to PartFile, which is also the one // part type File() names from the path (Name is not set for image/audio). dir := os.TempDir() path := filepath.Join(dir, "note.pdf") if err := os.WriteFile(path, []byte("%PDF-1.4 fake pdf bytes"), 0o644); err != nil { log.Fatal(err) } defer os.Remove(path) p := toolnexus.File(path) if err := p.Err(); err != nil { log.Fatalf("File: %v", err) } if p.Name != "note.pdf" || p.Bytes() != len("%PDF-1.4 fake pdf bytes") { log.Fatalf("got name=%q bytes=%d", p.Name, p.Bytes()) } fmt.Println("ok:", p.Name, p.Bytes(), "bytes") } ``` ### 2. The realistic case — `Bytes` for generated data, `URLPart` for a `data:` URL, one prompt [Section titled “2. The realistic case — Bytes for generated data, URLPart for a data: URL, one prompt”](#2-the-realistic-case--bytes-for-generated-data-urlpart-for-a-data-url-one-prompt) ```go package main import ( "encoding/base64" "fmt" "log" toolnexus "github.com/muthuishere/toolnexus/golang" ) func main() { // A 1x1 transparent PNG, generated in-memory — no filesystem round trip needed. png := []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A} screenshot := toolnexus.Bytes(png, "image/png") if err := screenshot.Err(); err != nil { log.Fatalf("Bytes: %v", err) } // Same bytes, arriving as a data: URL instead — URLPart normalizes it to the // identical {mimeType, data} shape at construction. dataURL := "data:image/png;base64," + base64.StdEncoding.EncodeToString(png) fromURL := toolnexus.URLPart(dataURL, "") if err := fromURL.Err(); err != nil { log.Fatalf("URLPart: %v", err) } if screenshot.Data != fromURL.Data || screenshot.MimeType != fromURL.MimeType { log.Fatal("a data: URL must decode to the identical bytes as Bytes()") } parts := []toolnexus.ContentPart{ toolnexus.Text("what's in this screenshot?"), screenshot, } fmt.Println("ok:", len(parts), "parts, same image two ways:", screenshot.Bytes() == fromURL.Bytes()) } ``` ### 3. The full surface — `FSFile` from an embedded asset, and the unsupported-part provenance rule [Section titled “3. The full surface — FSFile from an embedded asset, and the unsupported-part provenance rule”](#3-the-full-surface--fsfile-from-an-embedded-asset-and-the-unsupported-part-provenance-rule) ```go package main import ( "context" "errors" "fmt" "log" "net/http" "net/http/httptest" "testing/fstest" toolnexus "github.com/muthuishere/toolnexus/golang" ) func main() { // Stands in for a //go:embed fs.FS shipping a fixture inside the binary. // A non-image source (like File/FileHandle) also gets Name populated — // PartImage/PartAudio parts deliberately don't, since a provider block // never carries a filename for those. fsys := fstest.MapFS{"docs/report.pdf": &fstest.MapFile{Data: []byte("%PDF-1.4")}} report := toolnexus.FSFile(fsys, "docs/report.pdf") if err := report.Err(); err != nil { log.Fatalf("FSFile: %v", err) } if report.Name != "report.pdf" { log.Fatalf("Name = %q, want report.pdf", report.Name) } // An attached part the style cannot represent (Anthropic has no audio block) // errors BEFORE any HTTP call reaches the wire — the provenance rule's // "attached" half. srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { log.Fatal("no HTTP call should be made for an unrepresentable attached part") })) defer srv.Close() tk, err := toolnexus.CreateToolkit(context.Background(), toolnexus.Options{Builtins: false}) if err != nil { log.Fatal(err) } defer tk.Close() client := toolnexus.CreateClient(toolnexus.ClientOptions{BaseURL: srv.URL, Style: toolnexus.StyleAnthropic, Model: "m", APIKey: "k"}) audio := toolnexus.Bytes([]byte("fakeaudio"), "audio/mpeg") _, err = client.RunParts(context.Background(), []toolnexus.ContentPart{toolnexus.Text("listen"), audio}, tk) var ue *toolnexus.UnsupportedPartError if !errors.As(err, &ue) || ue.PartType != "audio" { log.Fatalf("expected an UnsupportedPartError naming the part type, got %v", err) } fmt.Println("ok:", report.Name, "embedded; unsupported attached audio errored:", ue.PartType) } ``` ## See also [Section titled “See also”](#see-also) * [`Tool`](/api/go/types/tool/) — The uniform shape every tool source collapses to: name, description, JSON-Schema parameters, execute. * [`ToolResult`](/api/go/types/tool-result/) — The result envelope: output text, optional error flag, optional non-text parts, and optional metadata that can carry a suspension. * [`ContentPart`](/api/go/types/content-part/) — The read-only non-text shape these constructors build: text | image | file | audio, carrying base64 bytes or a URL plus a mimeType — never a path. * [`ToolContext`](/api/go/types/context/) — Optional per-call context handed to execute: cancellation, identity, and host-supplied state. # ContentPart > The non-text half of a message: text | image | file | audio, carrying base64 bytes or a URL plus a mimeType — never a path. — Go. **Go** · package `github.com/muthuishere/toolnexus/golang` · [SPEC §1B](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [golang/content.go](https://github.com/muthuishere/toolnexus/blob/main/golang/content.go) ```go type ContentPart struct { Type string `json:"type"` // "text" | "image" | "file" | "audio" Text string `json:"text,omitempty"` MimeType string `json:"mimeType,omitempty"` Data string `json:"data,omitempty"` // standard base64, padded, no line breaks URL string `json:"url,omitempty"` Name string `json:"name,omitempty"` err error // deferred construction error — unexported, never serialised } // Edge constructors — they read and encode NOW, so a path or a stream // never enters a part. func Text(s string) ContentPart func File(path string) ContentPart // bytes read now; mime from the fixed extension table func Bytes(raw []byte, mimeType string) ContentPart // native bytes, explicit mime func Reader(r io.Reader, mimeType string) ContentPart // drained eagerly, NOT closed func FileHandle(f fs.File) ContentPart // *os.File & friends; mime from the file's own name func FSFile(fsys fs.FS, name string) ContentPart // embed.FS / os.DirFS / fstest.MapFS func URLPart(u, mimeType string) ContentPart // data: URL is parsed into {mimeType, data} func (p ContentPart) Err() error // the deferred construction error, or nil func (p ContentPart) Bytes() int // DECODED byte length func (p ContentPart) EstimatedTokens() int // max(85, floor(decodedBytes/750)) for a non-text part func (p ContentPart) Validate(maxBytes int) error func (p ContentPart) String() string // {type, mimeType, bytes} — Data is NEVER printed ``` The non-text half of a message: `text | image | file | audio`, carrying base64 bytes or a URL plus a `mimeType` — never a path. Exactly one of `Data` / `URL`; both, or neither, is a typed [`*PartError`](https://github.com/muthuishere/toolnexus/blob/main/golang/content.go). ## When to use it [Section titled “When to use it”](#when-to-use-it) Two places, and only two: * **Attaching media to a run.** [`Client.RunParts`](/api/go/client/create/) takes `[]ContentPart` where `Run` takes a string, so a screenshot, a PDF or an audio clip goes into the same first argument the prompt does: `c.RunParts(ctx, []ContentPart{Text("what is this?"), File("shot.png")}, tk)`. * **Returning media from a tool.** [`ToolResult.Parts`](/api/go/types/tool-result/) is `[]ContentPart`. A screenshot tool sets `Output` to a description and `Parts` to the image; the built-in `read` already does exactly this for a recognised media file. Everything else — how the part becomes an OpenAI `image_url` block or an Anthropic `image` block, where a tool result’s parts get relocated for a style that refuses images in a `tool` message — is the client’s job (SPEC §8A), not yours. ## 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 the constructor error is deferred **No paths.** The obvious design is `{path: "shot.png"}` and read it later. It does not survive the places a part actually goes: a transcript is **persisted and replayed** (the file may be gone, or changed), and a part crosses into **subagents**, a **served toolkit**, and an **A2A peer** — different processes, often different machines, where `shot.png` means nothing or, worse, means something else. So the edge constructors read and base64 the bytes **at construction**, and a stream is drained eagerly for the same reason: a half-read `io.Reader` survives the transcript boundary no better than a path does. **The deferred error.** Go has no overloading, and reading a file returns an `(bytes, error)` pair — so the honest signature would be `File(path) (ContentPart, error)`, which makes the common call site a three-line dance and gives you *two* places a bad attachment can blow up. Instead `File`, `Reader`, `FileHandle` and `FSFile` return **only** a `ContentPart`, carrying the failure in an unexported `err` (the `text/template` and `sql.Rows` idiom). [`RunParts`](/api/go/client/create/) validates every part and returns the first `*PartError` **before any HTTP call** — one `err` for the caller, at the point they were already checking one. The field is unexported, so it never marshals and never crosses a process boundary; reach for `p.Err()` if you want to check early. The alternative — `must`-style panics, or an `error` on every constructor — was rejected because it either crashes a server on a user-supplied file or taxes every call site to catch a failure the run already has to report. ## 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) The smallest useful call: a question plus the picture it is about. `File` reads and base64s the bytes now, and takes the mime type from the fixed extension table — never sniffed, never from a platform mime database. ```go package main import ( "fmt" "log" "os" "path/filepath" "strings" toolnexus "github.com/muthuishere/toolnexus/golang" ) func main() { // TOOLNEXUS_REPO is set by the docs test runner; in your own code just use a path. media := filepath.Join(os.Getenv("TOOLNEXUS_REPO"), "examples", "media") pngPath := filepath.Join(media, "fixture.png") parts := []toolnexus.ContentPart{ toolnexus.Text("What is in this image?"), toolnexus.File(pngPath), } text, image := parts[0], parts[1] if text.Type != "text" || text.Text != "What is in this image?" { log.Fatalf("unexpected text part: %+v", text) } if err := image.Err(); err != nil { log.Fatalf("File: %v", err) } if image.Type != toolnexus.PartImage || image.MimeType != "image/png" { log.Fatalf("got %s/%s, want image/image/png", image.Type, image.MimeType) } // The committed golden — assert against it, never against a re-encoding. golden, err := os.ReadFile(pngPath + ".base64") if err != nil { log.Fatal(err) } if image.Data != strings.TrimSpace(string(golden)) { log.Fatal("base64 does not match examples/media/fixture.png.base64") } // The path is gone: the part carries bytes, not a filename. if image.URL != "" || strings.Contains(image.Data, "fixture") { log.Fatal("a part must never carry a path") } // String() is what logs and §9 events show — Data is never printed. if image.String() != "{type:image, mimeType:image/png, bytes:82}" { log.Fatalf("unexpected rendering: %s", image.String()) } fmt.Println("ok:", image.String()) } ``` ### 2. The sources Go programs actually hold [Section titled “2. The sources Go programs actually hold”](#2-the-sources-go-programs-actually-hold) You rarely have a path. You have `[]byte` from an HTTP body, an `io.Reader` from a pipe, an `*os.File` you already opened, or an `embed.FS` with the asset baked into the binary. All four are first-class, and all four land as the same bytes-plus-mime part. ```go package main import ( "bytes" "encoding/json" "fmt" "io" "io/fs" "log" "os" "path/filepath" "strings" "testing/fstest" toolnexus "github.com/muthuishere/toolnexus/golang" ) func main() { media := filepath.Join(os.Getenv("TOOLNEXUS_REPO"), "examples", "media") pngPath := filepath.Join(media, "fixture.png") raw, err := os.ReadFile(pngPath) if err != nil { log.Fatal(err) } goldenB, err := os.ReadFile(pngPath + ".base64") if err != nil { log.Fatal(err) } golden := strings.TrimSpace(string(goldenB)) // (a) native bytes — an HTTP body, a database blob. Mime is explicit. fromBytes := toolnexus.Bytes(raw, "image/png") // (b) any io.Reader — drained EAGERLY, because a part must not hold a stream. fromReader := toolnexus.Reader(bytes.NewReader(raw), "image/png") // (c) an open file the caller already holds. *os.File is an fs.File, and the // mime comes from the file's own name, so no mimeType argument is needed. f, err := os.Open(pngPath) if err != nil { log.Fatal(err) } defer f.Close() fromHandle := toolnexus.FileHandle(f) // (d) an fs.FS + name — the shape embed.FS has. os.DirFS and fstest.MapFS // stand in for it here. var fsys fs.FS = os.DirFS(media) fromDirFS := toolnexus.FSFile(fsys, "fixture.png") fromMapFS := toolnexus.FSFile(fstest.MapFS{"assets/fixture.png": &fstest.MapFile{Data: raw}}, "assets/fixture.png") for name, p := range map[string]toolnexus.ContentPart{ "Bytes": fromBytes, "Reader": fromReader, "FileHandle": fromHandle, "FSFile(os.DirFS)": fromDirFS, "FSFile(fstest.MapFS)": fromMapFS, } { if err := p.Err(); err != nil { log.Fatalf("%s: %v", name, err) } if p.Type != toolnexus.PartImage || p.MimeType != "image/png" { log.Fatalf("%s: got %s/%s", name, p.Type, p.MimeType) } if p.Data != golden { log.Fatalf("%s: base64 does not match the committed golden", name) } if p.Bytes() != 82 { log.Fatalf("%s: decoded %d bytes, want 82", name, p.Bytes()) } // No source ever leaks into the part: not the directory, not the name, // not the deferred error field. blob, err := json.Marshal(p) if err != nil { log.Fatal(err) } if strings.Contains(string(blob), media) || strings.Contains(string(blob), "fixture.png") { log.Fatalf("%s: the part serialised a path: %s", name, blob) } if strings.Contains(string(blob), "err") { log.Fatalf("%s: the deferred error must never serialise: %s", name, blob) } } // The constructor does NOT close a handle it did not open — a closed *os.File // fails Stat, and rewinding still works. if _, err := f.Stat(); err != nil { log.Fatalf("the caller's file was closed by the constructor: %v", err) } if _, err := f.Seek(0, io.SeekStart); err != nil { log.Fatal(err) } again, err := io.ReadAll(f) if err != nil || len(again) != 82 { log.Fatalf("re-read of the caller's file: %d bytes, %v", len(again), err) } fmt.Println("ok: five sources, one part shape,", fromBytes.Bytes(), "bytes each") } ``` ### 3. Every way a part can be refused [Section titled “3. Every way a part can be refused”](#3-every-way-a-part-can-be-refused) Each failure is a typed `*PartError` with a stable `Kind` — `"both"`, `"neither"`, `"mime"`, `"extension"`, `"size"`, `"read"`, `"dataurl"` — so you can branch on the reason instead of matching on message text. The last one is the interesting one: a read that failed at construction is surfaced by `RunParts`, before a single byte goes on the wire. ```go package main import ( "context" "encoding/base64" "fmt" "log" "strings" toolnexus "github.com/muthuishere/toolnexus/golang" ) // A reader that fails. Its error has to reach the caller somehow — the part // carries it in an unexported field rather than a second return value. type errReader struct{} func (errReader) Read([]byte) (int, error) { return 0, fmt.Errorf("disk went away") } func kindOf(err error) string { pe, ok := err.(*toolnexus.PartError) if !ok { log.Fatalf("want a *PartError, got %#v", err) } return pe.Kind } func main() { // (1) Data AND URL — exactly one is allowed. Checked by Validate, which is // what the client runs over every part regardless of provenance. both := toolnexus.ContentPart{ Type: toolnexus.PartImage, MimeType: "image/png", Data: "aGk=", URL: "https://example.com/a.png", } if k := kindOf(both.Validate(0)); k != "both" { log.Fatalf("want kind \"both\", got %q", k) } neither := toolnexus.ContentPart{Type: toolnexus.PartImage, MimeType: "image/png"} if k := kindOf(neither.Validate(0)); k != "neither" { log.Fatalf("want kind \"neither\", got %q", k) } // (2) An unknown extension is refused BY NAME — mime is never sniffed. unknown := toolnexus.File("/tmp/notes.xyz") if k := kindOf(unknown.Err()); k != "extension" { log.Fatalf("want kind \"extension\", got %q", k) } if !strings.Contains(unknown.Err().Error(), "xyz") { log.Fatalf("the error must name the extension: %v", unknown.Err()) } // (3) MaxPartBytes. Validate(maxBytes) is the guarantee — it runs at request // assembly over every part, including ones from an MCP server that never // touched a constructor. DefaultMaxPartBytes additionally fast-fails at // the edge with a better message; that is a convenience, not the guarantee. big := toolnexus.Bytes(make([]byte, 4096), "image/png") if k := kindOf(big.Validate(1024)); k != "size" { log.Fatalf("want kind \"size\", got %q", k) } old := toolnexus.DefaultMaxPartBytes toolnexus.DefaultMaxPartBytes = 8 tooBig := toolnexus.Bytes(make([]byte, 4096), "image/png") toolnexus.DefaultMaxPartBytes = old if k := kindOf(tooBig.Err()); k != "size" { log.Fatalf("want an edge \"size\" failure, got %q", k) } // (4) EstimatedTokens is byte-derived: max(85, floor(decodedBytes/750)). // Not the mimeType string's length (a 5 MB image would score ~3 tokens // and be uncompactable), not chars/4 of the base64 (~1.7M tokens). tiny := toolnexus.Bytes(make([]byte, 82), "image/png") if tiny.EstimatedTokens() != 85 { log.Fatalf("floor is 85, got %d", tiny.EstimatedTokens()) } large := toolnexus.ContentPart{ Type: toolnexus.PartImage, MimeType: "image/png", Data: base64.StdEncoding.EncodeToString(make([]byte, 750000)), } if large.EstimatedTokens() != 1000 { log.Fatalf("750000/750 = 1000, got %d", large.EstimatedTokens()) } // (5) The deferred error, surfaced where it belongs. The failed read built no // half-part, and RunParts returns the *PartError before any HTTP call — // which is why the unreachable BaseURL below is never dialled. bad := toolnexus.Reader(errReader{}, "image/png") if k := kindOf(bad.Err()); k != "read" { log.Fatalf("want kind \"read\", got %q", k) } if bad.Data != "" || bad.MimeType != "" { log.Fatalf("a failed read must not build a part: %+v", bad) } tk, err := toolnexus.CreateToolkit(context.Background(), toolnexus.Options{Builtins: false}) if err != nil { log.Fatal(err) } defer tk.Close() c := toolnexus.CreateClient(toolnexus.ClientOptions{ BaseURL: "http://127.0.0.1:1", // never reached Style: toolnexus.StyleOpenAI, Model: "m", APIKey: "unused", }) _, runErr := c.RunParts(context.Background(), []toolnexus.ContentPart{toolnexus.Text("hi"), bad}, tk) if k := kindOf(runErr); k != "read" { log.Fatalf("RunParts should surface the deferred error, got %q", k) } fmt.Println("ok: both, neither, extension, size, and a deferred read error") } ``` ## Fields [Section titled “Fields”](#fields) | Field | Type | What it is | | ---------- | -------- | ------------------------------------------------------------------------------------- | | `Type` | `string` | `PartText` / `PartImage` / `PartFile` / `PartAudio`. The union is exactly these four. | | `Text` | `string` | Set only on a text part. | | `MimeType` | `string` | Required on every non-text part. From the fixed extension table, or explicit. | | `Data` | `string` | Standard base64 (RFC 4648 §4), padded, no line breaks. Never logged. | | `URL` | `string` | An `https:` URL kept as-is. A `data:` URL is parsed into `{MimeType, Data}` instead. | | `Name` | `string` | Filename, set by `File` / `FileHandle` / `FSFile` on a `file` part only. | ## See also [Section titled “See also”](#see-also) * [`Tool`](/api/go/types/tool/) — The uniform shape every tool source collapses to: name, description, JSON-Schema parameters, execute. * [`ToolResult`](/api/go/types/tool-result/) — The result envelope: output text, optional error flag, optional non-text parts, and optional metadata that can carry a suspension. * [`ToolContext`](/api/go/types/context/) — Optional per-call context handed to execute: cancellation, identity, and host-supplied state. # ToolContext > The optional per-call context handed to Execute — a context.Context for cancellation, a timeout override, and the answer that resolves a suspension. Go. **Go** · module `github.com/muthuishere/toolnexus/golang` · [SPEC §1](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [golang/types.go](https://github.com/muthuishere/toolnexus/blob/main/golang/types.go) ```go type ToolContext struct { // Ctx carries cancellation (mirrors the JS AbortSignal). Ctx context.Context // Timeout overrides the tool's default timeout, in milliseconds. Timeout int // Answer is present ONLY on a post-WaitFor retry (§10). Answer *Answer } ``` The second argument to `Execute`. It is a **pointer and may be nil**, and every field inside may be zero — so a tool that ignores it still works. But these three things are how a tool participates in cancellation, honours a deadline, and receives the answer to a question it asked. ## When to use it [Section titled “When to use it”](#when-to-use-it) Read `ctx` when your tool does anything **slow or interactive**: * **`Ctx`** — long work that should stop when the run is cancelled. * **`Timeout`** — the caller’s deadline for this specific call, in **milliseconds**. * **`Answer`** — you returned a suspension on a previous attempt and the host has now resolved it. A pure, fast, local computation can ignore `ctx` entirely. ## Why a struct pointer and not a plain context.Context [Section titled “Why a struct pointer and not a plain context.Context”](#why-a-struct-pointer-and-not-a-plain-contextcontext) Always nil-check, twice `ctx` is `*ToolContext` and may be nil, **and** `ctx.Ctx` may itself be nil. The safe form is `if ctx != nil && ctx.Ctx != nil && ctx.Ctx.Err() != nil`. `Execute` is called from the client loop, from a direct `tk.Execute()`, and from your own tests, and not all of them have a context to give. Go idiom would normally pass `context.Context` as the first argument. toolnexus wraps it in a struct because the §1 contract carries two more things — a per-call timeout and the §10 `Answer` — and threading three parameters through every tool source would be worse. The `context.Context` is still in there, doing its ordinary job. ## Examples [Section titled “Examples”](#examples) ### 1. Respecting cancellation [Section titled “1. Respecting cancellation”](#1-respecting-cancellation) Check `Ctx.Err()` before starting work and between steps. A cancelled tool should return promptly with an error *result*, not a returned `error`. ```go package main import ( "context" "fmt" "log" toolnexus "github.com/muthuishere/toolnexus/golang" ) func main() { crunch := toolnexus.Tool{ Name: "crunch", Description: "Do some work in steps, stopping if cancelled", InputSchema: toolnexus.JSONSchema{"type": "object"}, Source: "custom", Execute: func(args map[string]any, ctx *toolnexus.ToolContext) (toolnexus.ToolResult, error) { steps := 3 done := 0 for i := 0; i < steps; i++ { // Nil-check both levels. if ctx != nil && ctx.Ctx != nil && ctx.Ctx.Err() != nil { return toolnexus.ToolResult{ Output: fmt.Sprintf("cancelled after %d step(s)", done), IsError: true, }, nil } done++ } return toolnexus.ToolResult{ Output: fmt.Sprintf("completed %d step(s)", done), IsError: false, }, nil }, } // No context at all — the tool still runs. plain, err := crunch.Execute(map[string]any{}, nil) if err != nil || plain.Output != "completed 3 step(s)" { log.Fatalf("unexpected: %+v %v", plain, err) } // Cancelled before it starts. cancelled, cancel := context.WithCancel(context.Background()) cancel() stopped, err := crunch.Execute(map[string]any{}, &toolnexus.ToolContext{Ctx: cancelled}) if err != nil || !stopped.IsError || stopped.Output != "cancelled after 0 step(s)" { log.Fatalf("unexpected: %+v %v", stopped, err) } fmt.Println("ok:", plain.Output, "|", stopped.Output) } ``` ### 2. Honouring the caller’s timeout [Section titled “2. Honouring the caller’s timeout”](#2-honouring-the-callers-timeout) `Timeout` is **milliseconds**, and its zero value means “not set” — so treat `0` as “use my default”, not as “no time at all”. ```go package main import ( "fmt" "log" "strings" toolnexus "github.com/muthuishere/toolnexus/golang" ) func main() { fetchish := toolnexus.Tool{ Name: "fetchish", Description: "Pretend to fetch, bounded by the caller's timeout", InputSchema: toolnexus.JSONSchema{"type": "object"}, Source: "custom", Execute: func(args map[string]any, ctx *toolnexus.ToolContext) (toolnexus.ToolResult, error) { // Zero means "unset" — fall back to your own default. budget := 30000 if ctx != nil && ctx.Timeout > 0 { budget = ctx.Timeout } if budget < 100 { return toolnexus.ToolResult{ Output: fmt.Sprintf("budget %dms is too small to try", budget), IsError: true, }, nil } return toolnexus.ToolResult{ Output: fmt.Sprintf("fetched %v within %dms", args["url"], budget), IsError: false, }, nil }, } generous, _ := fetchish.Execute(map[string]any{"url": "/a"}, &toolnexus.ToolContext{Timeout: 5000}) if generous.Output != "fetched /a within 5000ms" { log.Fatalf("unexpected: %+v", generous) } stingy, _ := fetchish.Execute(map[string]any{"url": "/a"}, &toolnexus.ToolContext{Timeout: 10}) if !stingy.IsError { log.Fatal("expected the tiny budget to be refused") } defaulted, _ := fetchish.Execute(map[string]any{"url": "/a"}, nil) if !strings.Contains(defaulted.Output, "30000ms") { log.Fatalf("expected the default budget, got %q", defaulted.Output) } fmt.Println("ok:", generous.Output, "|", stingy.Output) } ``` ### 3. `Answer` — the second half of a suspension [Section titled “3. Answer — the second half of a suspension”](#3-answer--the-second-half-of-a-suspension) This is the field that makes the human-in-the-loop contract work. On the first call the tool returns a `Pending`. The host resolves it, then calls the **same tool again** with `ctx.Answer` set. The tool branches on whether the answer is there. ```go package main import ( "fmt" "log" toolnexus "github.com/muthuishere/toolnexus/golang" ) func main() { deploy := toolnexus.Tool{ Name: "deploy", Description: "Deploy, asking which environment first", InputSchema: toolnexus.JSONSchema{"type": "object"}, Source: "custom", Execute: func(args map[string]any, ctx *toolnexus.ToolContext) (toolnexus.ToolResult, error) { // Second pass: the host resolved the question and handed the answer back. if ctx != nil && ctx.Answer != nil { if !ctx.Answer.Ok { reason := ctx.Answer.Reason if reason == "" { reason = "no reason" } return toolnexus.ToolResult{Output: "declined: " + reason, IsError: true}, nil } env := fmt.Sprint(ctx.Answer.Data["env"]) return toolnexus.ToolResult{Output: "deployed to " + env, IsError: false}, nil } // First pass: park the run and ask. return toolnexus.Pending(toolnexus.Request{Kind: "input", Prompt: "Which environment?"}), nil }, } // First pass — a suspension, not an answer. first, _ := deploy.Execute(map[string]any{}, nil) req := toolnexus.PendingOf(first) if req == nil || req.Kind != "input" { log.Fatalf("expected an input suspension, got %+v", req) } // Second pass — the host supplies the resolution, echoing the request id. second, _ := deploy.Execute(map[string]any{}, &toolnexus.ToolContext{ Answer: &toolnexus.Answer{ID: req.ID, Ok: true, Data: map[string]any{"env": "staging"}}, }) if second.IsError || second.Output != "deployed to staging" { log.Fatalf("unexpected: %+v", second) } // A refusal is a normal outcome, not a crash. refused, _ := deploy.Execute(map[string]any{}, &toolnexus.ToolContext{ Answer: &toolnexus.Answer{ID: req.ID, Ok: false, Reason: "declined"}, }) if !refused.IsError { log.Fatal("expected a declined answer to be an error result") } fmt.Println("ok:", second.Output, "|", refused.Output) } ``` Read Answer.Data only when the answer IS the payload For `Kind: "input"` the value you asked for lives in `Answer.Data`. For `Kind: "authorization"` the world changed out-of-band — the session is simply valid now — so you re-run the real work and ignore `Data`. ## `CallID` — tell sibling calls apart [Section titled “CallID — tell sibling calls apart”](#callid--tell-sibling-calls-apart) A tool run by the client loop gets `ToolContext.CallID` = the model’s id for that call (empty outside the loop). The loop runs one message’s tool calls concurrently, so this is how a tool — or a host tracking which subagent call is the parent of which UI row — tells sibling calls apart. ```go package main import ( "context" "fmt" "log" "net/http" "net/http/httptest" "sort" "sync" toolnexus "github.com/muthuishere/toolnexus/golang" ) func main() { var mu sync.Mutex var ids []string probe := toolnexus.Tool{ Name: "probe", Description: "records its call id", InputSchema: toolnexus.JSONSchema{"type": "object", "properties": map[string]any{}}, Source: toolnexus.SourceCustom, Execute: func(_ map[string]any, tctx *toolnexus.ToolContext) (toolnexus.ToolResult, error) { mu.Lock() ids = append(ids, tctx.CallID) mu.Unlock() return toolnexus.ToolResult{Output: "ok"}, nil }, } tk, err := toolnexus.CreateToolkit(context.Background(), toolnexus.Options{ExtraTools: []toolnexus.Tool{probe}}) if err != nil { log.Fatal(err) } defer tk.Close() turn := 0 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { turn++ w.Header().Set("Content-Type", "application/json") if turn == 1 { _, _ = w.Write([]byte(`{"choices":[{"message":{"role":"assistant","content":null,"tool_calls":[ {"id":"call_a","type":"function","function":{"name":"probe","arguments":"{}"}}, {"id":"call_b","type":"function","function":{"name":"probe","arguments":"{}"}}]}}]}`)) return } _, _ = w.Write([]byte(`{"choices":[{"message":{"role":"assistant","content":"done"}}]}`)) })) defer srv.Close() client := toolnexus.CreateClient(toolnexus.ClientOptions{BaseURL: srv.URL, Style: toolnexus.StyleOpenAI, Model: "m", APIKey: "YOUR_KEY_HERE"}) if _, err := client.Run(context.Background(), "probe twice", tk); err != nil { log.Fatal(err) } sort.Strings(ids) if len(ids) != 2 || ids[0] != "call_a" || ids[1] != "call_b" { log.Fatalf("unexpected call ids: %v", ids) } fmt.Println("ok:", ids) } ``` ## Fields [Section titled “Fields”](#fields) | Field | Type | What it is | | --------- | ----------------- | ---------------------------------------------------------------------------------- | | `Ctx` | `context.Context` | Cancellation. Nil-check **both** `ctx` and `ctx.Ctx`. | | `Timeout` | `int` | This call’s budget, in **milliseconds**. Zero means unset. | | `Answer` | `*Answer` | Present **only** on a post-`WaitFor` retry — the resolution of a prior suspension. | ## See also [Section titled “See also”](#see-also) * [`Tool`](/api/go/types/tool/) — what receives this * [`ToolResult`](/api/go/types/tool-result/) — what `Execute` returns * [`Pending`](/api/go/suspension/pending/) — ask a question mid-call * [`WaitFor`](/api/go/suspension/wait-for/) — the host slot that produces `Answer` # Tool > The uniform shape every tool source collapses to — name, description, JSON-Schema parameters, execute. Go. **Go** · module `github.com/muthuishere/toolnexus/golang` · [SPEC §1](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [golang/types.go](https://github.com/muthuishere/toolnexus/blob/main/golang/types.go) ```go type Tool struct { Name string `json:"name"` Description string `json:"description"` InputSchema JSONSchema `json:"inputSchema"` Source ToolSource `json:"source"` // Execute runs the tool. ctx may be nil. Execute func(args map[string]any, ctx *ToolContext) (ToolResult, error) `json:"-"` } ``` The one type. An MCP server tool, an agent skill, a built-in shell tool, a remote A2A agent, an HTTP endpoint and a plain function of your own are all *the same thing* to an LLM — a named, described, schema’d callable. `Tool` is that thing, and every source in toolnexus produces it. ## When to use it [Section titled “When to use it”](#when-to-use-it) You mostly **receive** `Tool`s rather than construct them: `tk.Tools()` hands you a `[]Tool`, and that is what you range over, filter, and pass to an adapter. Construct one directly when you are writing a **new tool source** — something producing tools from a shape toolnexus doesn’t already cover (a table of prompts, an internal RPC registry, a plugin system). For a single ordinary function, don’t hand-build this. ## Why this and not the alternative [Section titled “Why this and not the alternative”](#why-this-and-not-the-alternative) Use NativeTool instead for your own functions [`NativeTool`](/api/go/native/define-tool/) builds a `Tool` for you and sets `Source: "native"`. Hand-writing the struct to wrap one function is more code and one more thing to keep in sync. Reach for the raw type only when you are generating tools programmatically. Go returns two values — and both matter `Execute` returns `(ToolResult, error)`, unlike JavaScript’s single `Promise`. These are **not** interchangeable. Return a `ToolResult` with `IsError: true` for an outcome the model should see and can recover from; return a non-nil `error` only for a genuine failure of your code. An `error` does not become tool output. ## Examples [Section titled “Examples”](#examples) ### 1. The smallest possible tool [Section titled “1. The smallest possible tool”](#1-the-smallest-possible-tool) A `Tool` is a plain struct. Nothing is embedded and nothing is registered — build it and it works. ```go package main import ( "fmt" "log" toolnexus "github.com/muthuishere/toolnexus/golang" ) func main() { echo := toolnexus.Tool{ Name: "echo", Description: "Return whatever it is given", InputSchema: toolnexus.JSONSchema{ "type": "object", "properties": map[string]any{"text": map[string]any{"type": "string"}}, "required": []string{"text"}, }, Source: "custom", Execute: func(args map[string]any, ctx *toolnexus.ToolContext) (toolnexus.ToolResult, error) { return toolnexus.ToolResult{Output: fmt.Sprint(args["text"]), IsError: false}, nil }, } res, err := echo.Execute(map[string]any{"text": "hello"}, nil) if err != nil { log.Fatal(err) } if res.Output != "hello" || res.IsError { log.Fatalf("unexpected: %+v", res) } fmt.Println("ok:", res.Output) } ``` `Source` is not free-form — it is one of `mcp`, `skill`, `builtin`, `native`, `http`, `a2a`, `custom`. Use `custom` for tools you construct yourself. ### 2. Error result vs returned error — the distinction that matters [Section titled “2. Error result vs returned error — the distinction that matters”](#2-error-result-vs-returned-error--the-distinction-that-matters) This is the field pairing people get wrong. `IsError` is an *outcome the model reads*; a returned `error` is *your code failing*. ```go package main import ( "errors" "fmt" "log" toolnexus "github.com/muthuishere/toolnexus/golang" ) func main() { divide := toolnexus.Tool{ Name: "divide", Description: "Divide two numbers", InputSchema: toolnexus.JSONSchema{ "type": "object", "properties": map[string]any{ "a": map[string]any{"type": "number"}, "b": map[string]any{"type": "number"}, }, "required": []string{"a", "b"}, }, Source: "custom", Execute: func(args map[string]any, ctx *toolnexus.ToolContext) (toolnexus.ToolResult, error) { a, aok := args["a"].(float64) b, bok := args["b"].(float64) if !aok || !bok { // Your code cannot proceed — a real error, not model-facing output. return toolnexus.ToolResult{}, errors.New("a and b must be numbers") } if b == 0 { // The model sees this text and can correct itself on the next turn. return toolnexus.ToolResult{Output: "Cannot divide by zero", IsError: true}, nil } return toolnexus.ToolResult{ Output: fmt.Sprintf("%g", a/b), IsError: false, Metadata: map[string]any{"title": "divide", "operands": []float64{a, b}}, }, nil }, } ok, err := divide.Execute(map[string]any{"a": 10.0, "b": 4.0}, nil) if err != nil || ok.Output != "2.5" { log.Fatalf("unexpected: %+v %v", ok, err) } // Recoverable outcome: no error, IsError set. bad, err := divide.Execute(map[string]any{"a": 1.0, "b": 0.0}, nil) if err != nil || !bad.IsError { log.Fatalf("expected an error RESULT, got %+v %v", bad, err) } // Genuine failure: a returned error, empty result. _, err = divide.Execute(map[string]any{"a": "x", "b": 1.0}, nil) if err == nil { log.Fatal("expected a returned error for bad input types") } fmt.Println("ok:", ok.Output, "| error path:", bad.Output, "| returned err:", err) } ``` ### 3. A generated tool source — the real reason this type is public [Section titled “3. A generated tool source — the real reason this type is public”](#3-a-generated-tool-source--the-real-reason-this-type-is-public) Producing many tools from data is where you build `Tool` directly. `Sanitize` makes each name schema-safe. ```go package main import ( "fmt" "log" toolnexus "github.com/muthuishere/toolnexus/golang" ) func main() { endpoints := []struct{ Key, Path string }{ {"get user", "/users/:id"}, {"list orders", "/orders"}, } var tools []toolnexus.Tool for _, e := range endpoints { e := e // capture per iteration tools = append(tools, toolnexus.Tool{ // Names must match [a-zA-Z0-9_-]; Sanitize does exactly that. Name: toolnexus.Sanitize(e.Key), Description: "Call " + e.Path, InputSchema: toolnexus.JSONSchema{ "type": "object", "properties": map[string]any{"id": map[string]any{"type": "string"}}, }, Source: "custom", Execute: func(args map[string]any, ctx *toolnexus.ToolContext) (toolnexus.ToolResult, error) { // ctx may be nil — always guard it. if ctx != nil && ctx.Ctx != nil && ctx.Ctx.Err() != nil { return toolnexus.ToolResult{Output: "cancelled", IsError: true}, nil } return toolnexus.ToolResult{ Output: fmt.Sprintf("%s <- %v", e.Path, args["id"]), IsError: false, }, nil }, }) } if tools[0].Name != "get_user" || tools[1].Name != "list_orders" { log.Fatalf("unexpected names: %s %s", tools[0].Name, tools[1].Name) } res, err := tools[0].Execute(map[string]any{"id": "42"}, nil) if err != nil || res.Output != "/users/:id <- 42" { log.Fatalf("unexpected: %+v %v", res, err) } fmt.Printf("ok: %s, %s\n", tools[0].Name, tools[1].Name) } ``` ## Fields [Section titled “Fields”](#fields) | Field | Type | What it is | | ------------- | -------------------------------------------------------- | --------------------------------------------------------------------------------- | | `Name` | `string` | The name the model calls. Must match `[a-zA-Z0-9_-]` — run it through `Sanitize`. | | `Description` | `string` | What the model reads to decide whether to call it. | | `InputSchema` | `JSONSchema` | A JSON-Schema **object** — a `map[string]any`. | | `Source` | `ToolSource` | One of `mcp`, `skill`, `builtin`, `native`, `http`, `a2a`, `custom`. | | `Execute` | `func(map[string]any, *ToolContext) (ToolResult, error)` | Runs the tool. `ctx` may be **nil** — guard it. | ## See also [Section titled “See also”](#see-also) * [`ToolResult`](/api/go/types/tool-result/) — what `Execute` returns * [`ToolContext`](/api/go/types/context/) — what `Execute` receives * [`NativeTool`](/api/go/native/define-tool/) — build a `Tool` from a function * [`CreateToolkit`](/api/go/toolkit/create/) — aggregate tools from every source # ToolResult > The result envelope every tool returns — output text, an error flag, and optional metadata that can carry a suspension. Go. **Go** · module `github.com/muthuishere/toolnexus/golang` · [SPEC §1](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md) · [golang/types.go](https://github.com/muthuishere/toolnexus/blob/main/golang/types.go) ```go type ToolResult struct { Output string `json:"output"` IsError bool `json:"isError"` Parts []ContentPart `json:"parts,omitempty"` Metadata map[string]any `json:"metadata,omitempty"` } ``` The first half of what every `Execute` returns. Four fields, and the whole tool-calling loop is built on them: `Output` is the text handed back to the model, `IsError` says whether the call failed, `Parts` carries non-text output (an image, a PDF, an audio clip), and `Metadata` is free-form — except for one reserved key that turns a result into a suspension. `Parts` is optional and omitted when empty, so a tool written before it existed serialises byte-identically. **`Output` stays required even when `Parts` is set** — it is what the transcript, compaction, token estimation and any text-only provider see. A tool returning an image sets `Output` to a description (`"screenshot, 1280x720 png"`) and `Parts` to the image; setting `Output` to `""` because “the picture says it” makes the call invisible to everything except the one provider that renders it. ## When to use it [Section titled “When to use it”](#when-to-use-it) Every time you write a tool. It is the first return value of [`Tool.Execute`](/api/go/types/tool/), so you construct one on every code path. ## Why an error flag *and* a returned error [Section titled “Why an error flag and a returned error”](#why-an-error-flag-and-a-returned-error) IsError and error are not the same thing Go’s `Execute` returns `(ToolResult, error)`. They mean different things and are not interchangeable: * **`IsError: true`** — an outcome. The text is fed back to the model, which can read “file not found”, pick another path, and carry on. * **a non-nil `error`** — your code failed. It does **not** become tool output. Use the flag for anything the model could plausibly recover from; return an `error` only for a genuine failure. `Output` is always a `string` — it is what the model reads. Serialize structured data yourself (`json.Marshal`) rather than expecting the loop to do it. Bytes go in [`Parts`](/api/go/types/content-part/), never base64d into `Output`. ## Examples [Section titled “Examples”](#examples) ### 1. Success and failure on the same tool [Section titled “1. Success and failure on the same tool”](#1-success-and-failure-on-the-same-tool) ```go package main import ( "fmt" "log" toolnexus "github.com/muthuishere/toolnexus/golang" ) var config = map[string]string{"region": "eu-west-1"} func readConfig(key string) toolnexus.ToolResult { v, ok := config[key] if !ok { // Recoverable: the model can read this and try another key. return toolnexus.ToolResult{Output: "No such config key: " + key, IsError: true} } return toolnexus.ToolResult{Output: v, IsError: false} } func main() { found := readConfig("region") if found.Output != "eu-west-1" || found.IsError { log.Fatalf("unexpected: %+v", found) } missing := readConfig("nope") if !missing.IsError { log.Fatal("expected IsError") } fmt.Println("ok:", found.Output, "|", missing.Output) } ``` ### 2. Structured output and metadata [Section titled “2. Structured output and metadata”](#2-structured-output-and-metadata) `Output` must be a string, so serialize deliberately. `Metadata` rides alongside for *your* code — the model never sees it, which makes it the right place for bookkeeping. ```go package main import ( "fmt" "log" "strings" toolnexus "github.com/muthuishere/toolnexus/golang" ) func search(q string) toolnexus.ToolResult { hits := []struct { ID int Title string }{ {1, "Getting started"}, {2, "Advanced usage"}, } var lines []string var ids []int for _, h := range hits { lines = append(lines, fmt.Sprintf("#%d %s", h.ID, h.Title)) ids = append(ids, h.ID) } return toolnexus.ToolResult{ // The model reads this. Make it legible, not just valid. Output: strings.Join(lines, "\n"), IsError: false, // Your code reads this. The model never sees it. Metadata: map[string]any{"title": "search: " + q, "count": len(hits), "ids": ids}, } } func main() { res := search("usage") if res.Metadata["count"] != 2 { log.Fatalf("unexpected count: %v", res.Metadata["count"]) } if !strings.Contains(res.Output, "Advanced usage") { log.Fatal("expected the second hit in output") } fmt.Println("ok:", res.Metadata["title"]) } ``` ### 3. The reserved key — `Metadata["pending"]` is a suspension [Section titled “3. The reserved key — Metadata\["pending"\] is a suspension”](#3-the-reserved-key--metadatapending-is-a-suspension) `Metadata` is free-form with one exception. A `pending` key holding a `Request` means “this tool cannot finish until something out-of-band happens” — the loop parks the run instead of returning. You rarely write this by hand; [`Pending`](/api/go/suspension/pending/) builds it for you. ```go package main import ( "fmt" "log" toolnexus "github.com/muthuishere/toolnexus/golang" ) func main() { // Pending() returns a ToolResult carrying Metadata["pending"] = Request. res := toolnexus.Pending(toolnexus.Request{Kind: "input", Prompt: "Which environment?"}) if !res.IsError { log.Fatal("a parked call is not a success") } req := toolnexus.PendingOf(res) if req == nil { log.Fatal("PendingOf should read the suspension back off the result") } if req.Kind != "input" || req.Prompt != "Which environment?" { log.Fatalf("unexpected request: %+v", req) } if req.ID == "" { log.Fatal("an id is generated as the correlation key") } // AuthRequired is sugar for the login case. Go takes the prompt explicitly — // pass "" to accept the default ("Authorization required to continue"). auth := toolnexus.AuthRequired("https://example.com/login", "") authReq := toolnexus.PendingOf(auth) if authReq.Kind != "authorization" || authReq.URL != "https://example.com/login" { log.Fatalf("unexpected auth request: %+v", authReq) } // An ordinary result has no suspension. if toolnexus.PendingOf(toolnexus.ToolResult{Output: "done"}) != nil { log.Fatal("expected no suspension on a plain result") } fmt.Println("ok:", req.Kind, "|", authReq.Kind) } ``` Caution A suspension sets `IsError: true`. That is deliberate — a parked call has not produced an answer. Do not treat `IsError` alone as “the tool broke”; check [`PendingOf`](/api/go/suspension/pending-of/) when suspension is in play. ### 4. Non-text output — `Parts` alongside `Output` [Section titled “4. Non-text output — Parts alongside Output”](#4-non-text-output--parts-alongside-output) A tool that produces an image fills both fields: `Output` describes it, `Parts` carries it. The built-in `read` does exactly this for a recognised media file, and is the shortest way to see the shape. ```go package main import ( "fmt" "log" "os" "path/filepath" "strings" toolnexus "github.com/muthuishere/toolnexus/golang" ) func main() { // TOOLNEXUS_REPO is set by the docs test runner; in your own code just use a path. png := filepath.Join(os.Getenv("TOOLNEXUS_REPO"), "examples", "media", "fixture.png") var read *toolnexus.Tool for _, t := range toolnexus.CreateBuiltinTools() { if t.Name == "read" { tool := t read = &tool } } res, err := read.Execute(map[string]any{"path": png}, nil) if err != nil || res.IsError { log.Fatalf("unexpected read result: %+v %v", res, err) } // Output is REQUIRED even with parts: it is what the transcript, compaction // and any text-only provider see. " (, bytes)". if !strings.HasSuffix(res.Output, "fixture.png (image/png, 82 bytes)") { log.Fatalf("unexpected output: %q", res.Output) } if len(res.Parts) != 1 { log.Fatalf("expected one part, got %d", len(res.Parts)) } img := res.Parts[0] if img.Type != toolnexus.PartImage || img.MimeType != "image/png" || img.Bytes() != 82 { log.Fatalf("unexpected part: %s", img) } // The bytes are in the part, never base64d into the text the model reads. if strings.Contains(res.Output, img.Data) { log.Fatal("Output must not carry the base64") } // A plain text file has no parts at all — byte-identical to pre-0.17 behaviour. plain, err := read.Execute(map[string]any{ "path": filepath.Join(os.Getenv("TOOLNEXUS_REPO"), "examples", "mcp.json"), }, nil) if err != nil || plain.IsError || len(plain.Parts) != 0 { log.Fatalf("a text read must carry no parts: %+v %v", plain, err) } fmt.Println("ok:", res.Output, "|", img.String()) } ``` ## Fields [Section titled “Fields”](#fields) | Field | Type | What it is | | ---------- | ---------------- | ----------------------------------------------------------------------------------------- | | `Output` | `string` | The text handed to the model. Always a string — serialize structured data yourself. | | `IsError` | `bool` | Whether the call failed. Fed back to the model, distinct from a returned `error`. | | `Parts` | `[]ContentPart` | Optional non-text output (§1B). Omitted when empty. `Output` stays required alongside it. | | `Metadata` | `map[string]any` | Free-form, for your code. **Reserved:** `pending` holds a §10 `Request`. | ## See also [Section titled “See also”](#see-also) * [`ContentPart`](/api/go/types/content-part/) — what goes in `Parts` * [`Tool`](/api/go/types/tool/) — what returns this * [`ToolContext`](/api/go/types/context/) — what `Execute` receives * [`Pending`](/api/go/suspension/pending/) — build a suspending result * [`PendingOf`](/api/go/suspension/pending-of/) — read a suspension back off a result # API Reference — Java > The io.github.muthuishere:toolnexus public surface — toolkit, client, skills, MCP, adapters — with each symbol's equivalents in the other five ports. Artifact **`io.github.muthuishere:toolnexus`** (Java 21). See the [combined API Reference](/api/) for every symbol shown in eight-language synced tabs. ## Toolkit [Section titled “Toolkit”](#toolkit) * `Toolkit.create(Toolkit.Options opts)` * `Options` builder: `.mcpConfig`, `.skillsDir`, `.skills`, `.skillProvider`, `.skillsFilter`, `.skillSampleLimit`, `.builtins`, `.agents`, `.extraTools`, `.waitFor`. * Equivalents: `createToolkit` (js) · `create_toolkit` (py) · `CreateToolkit` (go) · `Toolkit.CreateAsync` (c#) · `Toolnexus.create_toolkit` (elixir). ## Skills [Section titled “Skills”](#skills) * `SkillSource.load(String... dirs)` — back-compat. * `SkillSource.loadWith(SkillSource.LoadOptions opts)` * `SkillSource.listSkills(LoadOptions opts) -> SkillInventory` — `.skills`, `.skipped` (`SkillSkip.reason`; constants `SkillSkip.MISSING_NAME`, `MALFORMED`, `DUPLICATE_NAME`, `UNREADABLE`). * `new SkillSource.SkillDef(name, description, content[, resources, base])` * `LoadOptions` builder: `.dirs`, `.skills`, `.filter`, `.sampleLimit`. * `skillProvider` is a `Supplier>`. * Equivalents: `loadSkills` / `listSkills` (js) · `load_skills` / `list_skills` (py) · `LoadSkillsWith` / `ListSkills` (go) · `SkillSource.LoadWith` / `ListSkills` (c#) · `Toolnexus.Skill.load` / `Toolnexus.Skill.list` (elixir). ## MCP · Adapters · Client [Section titled “MCP · Adapters · Client”](#mcp--adapters--client) * `McpSource.load(config)` * `Adapters.toOpenAI(tk)` · `toAnthropic(tk)` · `toGemini(tk)` * `Client.create(Client.Options)` → `client.ask(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)` — overload takes `(args, ToolContext)`. * `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` (c#) · `Toolnexus.Native.define_tool` / `Toolnexus.Http.tool` (elixir). ## Sub-agents & teams [Section titled “Sub-agents & teams”](#sub-agents--teams) * `Agents.agent(name, new Agents.AgentSpec().does(...).tools(...).soulFile(...).team(...).budget(...))`. * `Agents.Agent.run(RuntimeOptions, prompt): TaskResult` — `.status()`, `.text()`, `.totalTokens()`. * `Agents.Agent.asTool(RuntimeOptions): Tool` · `Agents.composeSoul(Path): ComposedSoul`. * Equivalents: `agents.agent` / `.run` / `.asTool` / `composeSoul` (js) · `agent` (py) · `agents.New` (go) · `new Agent` (c#) · `Agents.agent` (elixir). ## A2A — serve & remote agents [Section titled “A2A — serve & remote agents”](#a2a--serve--remote-agents) * `tk.serve(addr, new Toolkit.ServeOptions().client(llm).a2a(new A2AServer.A2AConfig()...))`. * `A2A.agent(cardUrl)` + `Toolkit.Options().agents(List)` / `tk.addAgent(cardUrl)`. * Equivalents: `tk.serve` / `tk.addAgent` (js) · `serve` / `add_agent` (py) · `Serve` / `AddAgent` (go) · `ServeAsync` / `AddAgentAsync` (c#) · `Toolkit.serve` / `Toolkit.add_agent` (elixir). ## Suspension & the human loop [Section titled “Suspension & the human loop”](#suspension--the-human-loop) * `Options.waitFor(Function)` (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) · `WithWaitFor` / `ToolResult.Pending` (c#) · `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. * `Media.loadStyle(String path): Media.Style` · `Media.Style.parse(json)` · `Media.Style.of(map)`. * `Media.create(Style... styles)` · `Media.create(Style style, Options opts)` · `Media.create(List