In-process client: Copy-pasteable recipes in all eight ports: createInProcessClient, serving a toolkit to an agent (asMcpServer), driving any ACP agent, one CLI process per turn, and how to extend them. # In-process client — overview > createInProcessClient is the one seam for putting anything behind a toolnexus client — a local model, a coding agent over ACP, a CLI. You supply generate (or generateStream); toolnexus runs the tool loop. Plus how to hand your toolkit to an external agent as an MCP server. ## The seam [Section titled “The seam”](#the-seam) `createInProcessClient` builds an ordinary `Client` with no `baseUrl`, no `apiKey` and no `style`. Instead you pass the model itself: | you supply | receives | returns | streams? | | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | **`generate(request)`** (every port) | `messages`, `tools`, `model`, `body` (the assembled request for this turn) | one assistant message: `{ content }` to finish, or `{ toolCalls }` to ask for tools | no: `stream` / `runStream` is **refused** with an error naming why | | **`generateStream(request, emit)`** (every port since 0.25.0: Go `GenerateStream(ctx, …)`, Python `generate_stream`, Elixir `:generate_stream`, Clojure `:generate-stream`, …) | the same request, plus an `emit` function (Go also gets the run’s `ctx`) | the same one message | yes: every `emit({type:"text", delta})` becomes a stream delta as it happens (the deltas must add up to `content`); `thought`, `tool_call` and `tool_result` events pass through to the stream consumer and are never executed | Return `toolCalls` and toolnexus executes them against the toolkit, appends the results and calls you again. Return `content` and the run is over. The contract is the same one documented in [Run a model in-process](/toolnexus/cookbook/local-and-in-process-models/), which also covers retries (in-process failures are final by default) and the per-port request shapes. Python's generate is synchronous The Python client calls `generate` / `generate_stream` on a worker thread and refuses a coroutine. If your model is async, keep a reference to your event loop and block on it inside `generate` with `asyncio.run_coroutine_threadsafe(coro, loop).result()`. (The ACP client, `load_acp`, is already synchronous, so the ACP recipe needs none of this.) ## A complete example [Section titled “A complete example”](#a-complete-example) A “model” that asks for the `add` tool on its first turn and streams the answer on the second. This is the whole integration; swap the body of the generate function for your real model. * JavaScript main.mjs ```js import { createInProcessClient, createToolkit, defineTool } from "toolnexus" const add = defineTool({ name: "add", description: "Add two numbers.", inputSchema: { type: "object", properties: { a: { type: "number" }, b: { type: "number" } }, required: ["a", "b"] }, run: ({ a, b }) => `${a + b}`, }) // Your "model": first turn asks for a tool, second turn streams the answer. const client = createInProcessClient({ model: "my-model", generateStream: async (req, emit) => { const last = req.messages.at(-1) if (last.role !== "tool") return { toolCalls: [{ name: "add", arguments: { a: 2, b: 3 } }] } const words = ["The ", "sum ", "is ", String(last.content), "."] for (const w of words) emit({ type: "text", delta: w }) // deltas must add up to content return { content: words.join("") } }, }) const toolkit = await createToolkit({ builtins: false, extraTools: [add] }) for await (const ev of client.stream("What is 2 + 3?", { toolkit })) { if (ev.type === "text") process.stdout.write(ev.delta) else if (ev.type === "tool_call") console.log(`[calling ${ev.name}]`) } console.log() // The sum is 5. ``` * Python main.py ```python import asyncio from toolnexus import create_in_process_client, create_toolkit, define_tool def add(a: float, b: float) -> str: """Add two numbers.""" return str(int(a + b)) def generate_stream(req, emit): # sync: runs on its own thread last = req["messages"][-1] if last["role"] != "tool": return {"tool_calls": [{"name": "add", "arguments": {"a": 2, "b": 3}}]} words = ["The ", "sum ", "is ", str(last["content"]), "."] for w in words: emit({"type": "text", "delta": w}) # deltas must add up to content return {"content": "".join(words)} async def main(): client = create_in_process_client(model="my-model", generate_stream=generate_stream) toolkit = await create_toolkit(builtins=False, extra_tools=[define_tool(add, name="add")]) async for ev in client.stream("What is 2 + 3?", toolkit): if ev["type"] == "text": print(ev["delta"], end="", flush=True) elif ev["type"] == "tool_call": print(f"[calling {ev['name']}]") print() # The sum is 5. asyncio.run(main()) ``` * Go main.go ```go package main import ( "context" "fmt" "strconv" "strings" tn "github.com/muthuishere/toolnexus/golang" ) type addArgs struct { A float64 `json:"a"` B float64 `json:"b"` } func main() { ctx := context.Background() add := tn.NativeToolReflect("add", "Add two numbers.", func(_ context.Context, in addArgs) (string, error) { return strconv.FormatFloat(in.A+in.B, 'f', -1, 64), nil }) toolkit, err := tn.CreateToolkit(ctx, tn.Options{Builtins: false, ExtraTools: []tn.Tool{add}}) if err != nil { panic(err) } // GenerateStream: ctx-aware, and every emitted text delta reaches Client.Stream. client := tn.CreateInProcessClient(tn.InProcessOptions{ Model: "my-model", GenerateStream: func(ctx context.Context, req tn.InProcessRequest, emit func(tn.StreamEvent)) (tn.InProcessResponse, error) { last, _ := req.Messages[len(req.Messages)-1].(map[string]any) if last["role"] != "tool" { return tn.InProcessResponse{ToolCalls: []tn.InProcessToolCall{ {Name: "add", Arguments: map[string]any{"a": 2, "b": 3}}, }}, nil } words := []string{"The ", "sum ", "is ", fmt.Sprint(last["content"]), "."} for _, w := range words { emit(tn.StreamEvent{Type: "text", Delta: w}) // deltas must add up to Content } return tn.InProcessResponse{Content: strings.Join(words, "")}, nil }, }) events, err := client.Stream(ctx, "What is 2 + 3?", toolkit) if err != nil { panic(err) } for ev := range events { switch ev.Type { case "text": fmt.Print(ev.Delta) case "tool_call": fmt.Printf("[calling %s]\n", ev.Name) case "error": panic(ev.Err) } } fmt.Println() } ``` * Java Main.java ```java // run: java -cp 'lib/*' Main.java (lib/ = toolnexus-0.27.0.jar + its runtime deps) import io.github.muthuishere.toolnexus.InProcess; import io.github.muthuishere.toolnexus.LlmClient; import io.github.muthuishere.toolnexus.NativeTool; import io.github.muthuishere.toolnexus.Toolkit; import java.util.List; import java.util.Map; public class Main { public static void main(String[] argv) throws Exception { NativeTool add = NativeTool.of("add", "Add two numbers.", Map.of("type", "object", "properties", Map.of("a", Map.of("type", "number"), "b", Map.of("type", "number")), "required", List.of("a", "b")), args -> String.valueOf(((Number) args.get("a")).intValue() + ((Number) args.get("b")).intValue())); // Your "model": first turn asks for a tool, second turn streams the answer. LlmClient client = InProcess.createClient(new InProcess.Options() .model("my-model") .generateStream((req, emit) -> { @SuppressWarnings("unchecked") Map last = (Map) req.messages.get(req.messages.size() - 1); if (!"tool".equals(last.get("role"))) return InProcess.Response.toolCalls(new InProcess.ToolCall("add", Map.of("a", 2, "b", 3))); List words = List.of("The ", "sum ", "is ", String.valueOf(last.get("content")), "."); for (String w : words) emit.accept(LlmClient.StreamEvent.text(w)); // deltas must add up to content return InProcess.Response.content(String.join("", words)); })); try (Toolkit toolkit = Toolkit.create(new Toolkit.Options().builtins(false).extraTools(add))) { client.stream("What is 2 + 3?", toolkit, ev -> { switch (ev.type()) { case TEXT -> System.out.print(ev.delta()); case TOOL_CALL -> System.out.println("[calling " + ev.name() + "]"); default -> { } } }); System.out.println(); // The sum is 5. } } } ``` * C# Program.cs ```csharp // dotnet new console && dotnet add package Toolnexus && dotnet run using Toolnexus; using static Toolnexus.LlmClient; var add = NativeTool.Of("add", "Add two numbers.", new Dictionary { ["type"] = "object", ["properties"] = new Dictionary { ["a"] = new { type = "number" }, ["b"] = new { type = "number" } }, ["required"] = new[] { "a", "b" }, }, args => $"{Convert.ToDouble(args["a"]?.ToString()) + Convert.ToDouble(args["b"]?.ToString())}"); // Your "model": first turn asks for a tool, second turn streams the answer. var client = InProcess.CreateClient(new InProcess.Options { Model = "my-model", GenerateStream = async (req, emit, ct) => { var last = (System.Text.Json.JsonElement)req.Messages[^1]!; if (last.GetProperty("role").GetString() != "tool") return InProcess.Response.FromToolCalls(new InProcess.ToolCall { Name = "add", Arguments = new { a = 2, b = 3 } }); string[] words = ["The ", "sum ", "is ", last.GetProperty("content").GetString()!, "."]; foreach (var w in words) emit(new StreamEvent(StreamKind.Text, Delta: w)); // deltas must add up to content return InProcess.Response.FromContent(string.Concat(words)); }, }); await using var toolkit = await Toolkit.CreateAsync(new Toolkit.Options { Builtins = false, ExtraTools = [add] }); await client.StreamAsync("What is 2 + 3?", toolkit, ev => { if (ev.Type == StreamKind.Text) Console.Write(ev.Delta); else if (ev.Type == StreamKind.ToolCall) Console.WriteLine($"[calling {ev.Name}]"); }); Console.WriteLine(); // The sum is 5. ``` * Elixir main.exs ```elixir # usage: elixir main.exs Mix.install([{:toolnexus, "~> 0.27"}]) add = Toolnexus.define_tool( name: "add", description: "Add two numbers.", input_schema: %{"type" => "object", "properties" => %{"a" => %{"type" => "number"}, "b" => %{"type" => "number"}}, "required" => ["a", "b"]}, execute: fn %{"a" => a, "b" => b} -> "#{a + b}" end ) # Your "model": first turn asks for a tool, second turn streams the answer. client = Toolnexus.Client.create_in_process( model: "my-model", generate_stream: fn req, emit -> last = List.last(req.messages) if last["role"] != "tool" do %{tool_calls: [%{name: "add", arguments: %{"a" => 2, "b" => 3}}]} else words = ["The ", "sum ", "is ", to_string(last["content"]), "."] Enum.each(words, &emit.(%{type: "text", delta: &1})) # deltas must add up to content %{content: Enum.join(words)} end end ) toolkit = Toolnexus.create_toolkit!(builtins: false, extra_tools: [add]) for ev <- Toolnexus.Client.stream(client, "What is 2 + 3?", toolkit) do case ev.type do "text" -> IO.write(ev.delta) "tool_call" -> IO.puts("[calling #{ev.name}]") _ -> :ok end end IO.puts("") # The sum is 5. ``` * Clojure Clojure has no stream API: the emitted events reach `run`’s `:on-event` as they happen. main.clj ```clojure ;; deps.edn: {:deps {net.clojars.muthuishere/toolnexus {:mvn/version "0.27.0"}}} ;; run: clojure -M main.clj (ns main (:require [toolnexus.client :as client] [toolnexus.core :as tn] [toolnexus.native :as native])) (def add (native/native-tool {:name "add" :description "Add two numbers." :input-schema {:type "object" :properties {:a {:type "number"} :b {:type "number"}} :required ["a" "b"]} :run (fn [{:keys [a b]}] (str (+ a b)))})) ;; Your "model": first turn asks for a tool, second turn streams the answer. (def c (client/create-in-process-client {:model "my-model" :generate-stream (fn [req emit] (let [last-msg (last (:messages req))] (if (not= "tool" (:role last-msg)) {:tool-calls [{:name "add" :arguments {:a 2 :b 3}}]} (let [words ["The " "sum " "is " (str (:content last-msg)) "."]] (doseq [w words] (emit {:type "text" :delta w})) ; deltas must add up to content {:content (apply str words)}))))})) (def toolkit (tn/build {:builtins false :tools [add]})) ;; No stream API in this port: events go to run's :on-event sink as they happen. (client/run c "What is 2 + 3?" {:toolkit toolkit :on-event (fn [ev] (case (:type ev) "text" (do (print (:delta ev)) (flush)) "tool_call" (println (str "[calling " (:name ev) "]")) nil))}) (println) ; The sum is 5. (shutdown-agents) ``` * Rust main.rs ```rust // Cargo.toml: // toolnexus = "0.25" // tokio = { version = "1", features = ["full"] } // serde_json = "1" // run: cargo run use std::sync::Arc; use serde_json::json; use toolnexus::client::{ EmitFn, GenerateStreamFn, InProcessOptions, InProcessRequest, InProcessResponse, InProcessToolCall, StreamEvent, create_in_process_client, }; use toolnexus::{BuiltinsConfig, ToolkitOptions, create_toolkit, define_tool}; #[tokio::main] async fn main() -> Result<(), Box> { let schema = json!({"type": "object", "properties": {"a": {"type": "number"}, "b": {"type": "number"}}, "required": ["a", "b"]}); let add = define_tool("add", "Add two numbers.", Some(schema), |a, _ctx| async move { Ok::<_, String>((a["a"].as_f64().unwrap_or(0.0) + a["b"].as_f64().unwrap_or(0.0)).to_string()) }); // Your "model": first turn asks for a tool, second turn streams the answer. let model: GenerateStreamFn = Arc::new(|_cancel, req: InProcessRequest, emit: EmitFn| { Box::pin(async move { let last = req.messages.last().cloned().unwrap_or_default(); if last["role"] != "tool" { let call = InProcessToolCall { id: String::new(), name: "add".into(), arguments: json!({"a": 2, "b": 3}) }; return Ok(InProcessResponse { tool_calls: vec![call], ..Default::default() }); } let sum = last["content"].as_str().unwrap_or_default().to_string(); let words = ["The ", "sum ", "is ", &sum, "."]; for w in words { emit(StreamEvent::text(w)); // deltas must add up to content } Ok(InProcessResponse { content: words.concat(), ..Default::default() }) }) }); let client = create_in_process_client(InProcessOptions { model: "my-model".into(), generate_stream: Some(model), ..Default::default() }); let toolkit = create_toolkit(ToolkitOptions { builtins: Some(BuiltinsConfig::off()), extra_tools: vec![add], ..Default::default() }) .await?; let mut events = client.stream("What is 2 + 3?", &toolkit)?; while let Some(ev) = events.recv().await { match ev.kind.as_str() { "text" => print!("{}", ev.delta), "tool_call" => println!("[calling {}]", ev.name), _ => {} } } println!(); // The sum is 5. Ok(()) } ``` ## When to use which [Section titled “When to use which”](#when-to-use-which) There are two very different things you can put behind `generate`, and they split the tool loop differently: | | who runs the tools | what `generate` returns | recipe | | ----------------------------------------------------------------- | ------------------------------------------------------------------- | -------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | | **A plain model** (weights in-process, a CLI asked for one reply) | **toolnexus**: the model asks, the client executes | `toolCalls` or `content` | [One-shot CLI](/toolnexus/in-process-client/one-shot-cli/), [Run a model in-process](/toolnexus/cookbook/local-and-in-process-models/) | | **An agent** (Claude Code, Copilot, opencode… over ACP) | **the agent**: it has its own loop, and reaches your tools over MCP | only `content`: the agent’s final text | [ACP agent](/toolnexus/in-process-client/acp-agent/) | An agent already has a tool loop of its own. You do not want two loops fighting over the same call, so in the agent case you **give the agent your toolkit as an MCP server**, run the toolnexus client with an empty toolkit, and let `generate` return the agent’s answer. ## Give your toolkit to an external agent: `asMcpServer` [Section titled “Give your toolkit to an external agent: asMcpServer”](#give-your-toolkit-to-an-external-agent-asmcpserver) Any toolkit, whatever it aggregates (MCP servers, skills, native functions, HTTP tools, builtins, A2A agents), can be handed to an agent you launch as **one MCP server** with `toolkit.asMcpServer()` ([SPEC §8C](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md)): Go `AsMCPServer(nil)`, Python `as_mcp_server()`, Java `asMcpServer()`, C# `AsMcpServer()`, Elixir `Toolnexus.Toolkit.as_mcp_server/2`, Clojure `toolnexus.serve/as-mcp-server`, Rust `as_mcp_server` (crate feature `serve`). It returns `{url, token, close}`: * it listens on `127.0.0.1:` only, and `url` is `http://127.0.0.1:/mcp`; * every request must carry `Authorization: Bearer ` (a fresh 64-hex token per call), so other local processes cannot call your tools; a missing or wrong token is `401`; * `POST /mcp` is the stateless streamable-HTTP endpoint; any other method is `405` (there is no server stream to hold open), any other path `404`; * the optional config is the usual `{name, version, tools}`, and `tools` narrows what is exposed. You build whatever `mcpServers` entry your agent accepts; for an ACP agent that is the entry below. * JavaScript serve.mjs ```js import { createToolkit, defineTool } from "toolnexus" const add = defineTool({ name: "add", description: "Add two numbers.", inputSchema: { type: "object", properties: { a: { type: "number" }, b: { type: "number" } }, required: ["a", "b"] }, run: ({ a, b }) => `${a + b}`, }) // Any toolkit (MCP servers, skills, native, HTTP, builtins) can be served to a local agent. const toolkit = await createToolkit({ builtins: false, extraTools: [add] }) const server = await toolkit.asMcpServer() // { tools: [...] } narrows what is exposed console.log("MCP endpoint:", server.url) // http://127.0.0.1:/mcp, POST only // Every request needs the bearer token; this is the entry an ACP agent takes in session/new. const entry = { type: "http", name: "toolnexus", url: server.url, headers: [{ name: "Authorization", value: `Bearer ${server.token}` }] } const res = await fetch(entry.url, { method: "POST", headers: { "Content-Type": "application/json", Accept: "application/json, text/event-stream", Authorization: entry.headers[0].value }, body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list", params: {} }), }) console.log("tools/list:", res.status) await server.close() ``` * Python serve.py ```python import asyncio import json import urllib.request from toolnexus import create_toolkit, define_tool def add(a: float, b: float) -> str: """Add two numbers.""" return str(int(a + b)) async def main(): # Any toolkit (MCP servers, skills, native, HTTP, builtins) can be served to a local agent. toolkit = await create_toolkit(builtins=False, extra_tools=[define_tool(add, name="add")]) server = toolkit.as_mcp_server() # MCPServeConfig(tools=[...]) narrows what is exposed print("MCP endpoint:", server.url) # http://127.0.0.1:/mcp, POST only # Every request needs the bearer token; this is the entry an ACP agent takes in session/new. entry = {"type": "http", "name": "toolnexus", "url": server.url, "headers": [{"name": "Authorization", "value": "Bearer " + server.token}]} req = urllib.request.Request( entry["url"], json.dumps({"jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {}}).encode(), {"Content-Type": "application/json", "Accept": "application/json, text/event-stream", "Authorization": entry["headers"][0]["value"]}) with urllib.request.urlopen(req) as res: print("tools/list:", res.status) server.close() asyncio.run(main()) ``` * Go serve/main.go ```go package main import ( "context" "fmt" "net/http" "strconv" "strings" tn "github.com/muthuishere/toolnexus/golang" ) type addArgs struct { A float64 `json:"a"` B float64 `json:"b"` } func main() { ctx := context.Background() add := tn.NativeToolReflect("add", "Add two numbers.", func(_ context.Context, in addArgs) (string, error) { return strconv.FormatFloat(in.A+in.B, 'f', -1, 64), nil }) // Any toolkit (MCP servers, skills, native, HTTP, builtins) can be served to a local agent. toolkit, err := tn.CreateToolkit(ctx, tn.Options{Builtins: false, ExtraTools: []tn.Tool{add}}) if err != nil { panic(err) } server, err := toolkit.AsMCPServer(nil) // &tn.MCPServeConfig{Tools: []string{...}} narrows what is exposed if err != nil { panic(err) } defer server.Close() fmt.Println("MCP endpoint:", server.URL) // http://127.0.0.1:/mcp, POST only // Every request needs the bearer token; this is the entry an ACP agent takes in session/new. entry := map[string]any{"type": "http", "name": "toolnexus", "url": server.URL, "headers": []map[string]any{{"name": "Authorization", "value": "Bearer " + server.Token}}} req, _ := http.NewRequest("POST", entry["url"].(string), strings.NewReader(`{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}`)) req.Header.Set("Content-Type", "application/json") req.Header.Set("Accept", "application/json, text/event-stream") req.Header.Set("Authorization", "Bearer "+server.Token) res, err := http.DefaultClient.Do(req) if err != nil { panic(err) } res.Body.Close() fmt.Println("tools/list:", res.StatusCode) } ``` * Java Serve.java ```java // run: java -cp 'lib/*' Serve.java (lib/ = toolnexus-0.27.0.jar + its runtime deps) import io.github.muthuishere.toolnexus.McpServe; import io.github.muthuishere.toolnexus.NativeTool; import io.github.muthuishere.toolnexus.Toolkit; import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.util.List; import java.util.Map; public class Serve { public static void main(String[] argv) throws Exception { NativeTool add = NativeTool.of("add", "Add two numbers.", Map.of("type", "object", "properties", Map.of("a", Map.of("type", "number"), "b", Map.of("type", "number")), "required", List.of("a", "b")), args -> String.valueOf(((Number) args.get("a")).intValue() + ((Number) args.get("b")).intValue())); // Any toolkit (MCP servers, skills, native, HTTP, builtins) can be served to a local agent. try (Toolkit toolkit = Toolkit.create(new Toolkit.Options().builtins(false).extraTools(add)); McpServe.LocalServer server = toolkit.asMcpServer()) { // asMcpServer(cfg) narrows what is exposed System.out.println("MCP endpoint: " + server.url()); // http://127.0.0.1:/mcp, POST only // Every request needs the bearer token; this is the entry an ACP agent takes in session/new. Map entry = Map.of("type", "http", "name", "toolnexus", "url", server.url(), "headers", List.of(Map.of("name", "Authorization", "value", "Bearer " + server.token()))); HttpResponse res = HttpClient.newHttpClient().send(HttpRequest.newBuilder(URI.create(server.url())) .header("Content-Type", "application/json") .header("Accept", "application/json, text/event-stream") .header("Authorization", "Bearer " + server.token()) .POST(HttpRequest.BodyPublishers.ofString( "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/list\",\"params\":{}}")) .build(), HttpResponse.BodyHandlers.ofString()); System.out.println("tools/list: " + res.statusCode()); } } } ``` * C# Serve.cs ```csharp // dotnet new console && dotnet add package Toolnexus && dotnet run (this file as Program.cs) using System.Net.Http.Headers; using System.Text; using Toolnexus; var add = NativeTool.Of("add", "Add two numbers.", new Dictionary { ["type"] = "object", ["properties"] = new Dictionary { ["a"] = new { type = "number" }, ["b"] = new { type = "number" } }, ["required"] = new[] { "a", "b" }, }, args => $"{Convert.ToDouble(args["a"]?.ToString()) + Convert.ToDouble(args["b"]?.ToString())}"); // Any toolkit (MCP servers, skills, native, HTTP, builtins) can be served to a local agent. await using var toolkit = await Toolkit.CreateAsync(new Toolkit.Options { Builtins = false, ExtraTools = [add] }); await using var server = toolkit.AsMcpServer(); // an MCPServeConfig { Tools = ... } narrows what is exposed Console.WriteLine($"MCP endpoint: {server.Url}"); // http://127.0.0.1:/mcp, POST only // Every request needs the bearer token; this is the entry an ACP agent takes in session/new. var entry = new Dictionary { ["type"] = "http", ["name"] = "toolnexus", ["url"] = server.Url, ["headers"] = new[] { new Dictionary { ["name"] = "Authorization", ["value"] = "Bearer " + server.Token } }, }; using var http = new HttpClient(); var req = new HttpRequestMessage(HttpMethod.Post, (string)entry["url"]!) { Content = new StringContent("""{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}""", Encoding.UTF8, "application/json"), }; req.Headers.Accept.ParseAdd("application/json"); req.Headers.Accept.ParseAdd("text/event-stream"); req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", server.Token); var res = await http.SendAsync(req); Console.WriteLine($"tools/list: {(int)res.StatusCode}"); ``` * Elixir serve.exs ```elixir # usage: elixir serve.exs Mix.install([{:toolnexus, "~> 0.27"}]) add = Toolnexus.define_tool( name: "add", description: "Add two numbers.", input_schema: %{"type" => "object", "properties" => %{"a" => %{"type" => "number"}, "b" => %{"type" => "number"}}, "required" => ["a", "b"]}, execute: fn %{"a" => a, "b" => b} -> "#{a + b}" end ) # Any toolkit (MCP servers, skills, native, HTTP, builtins) can be served to a local agent. toolkit = Toolnexus.create_toolkit!(builtins: false, extra_tools: [add]) server = Toolnexus.Toolkit.as_mcp_server(toolkit) # %{"tools" => [...]} narrows what is exposed IO.puts("MCP endpoint: #{server.url}") # http://127.0.0.1:/mcp, POST only # Every request needs the bearer token; this is the entry an ACP agent takes in session/new. entry = %{"type" => "http", "name" => "toolnexus", "url" => server.url, "headers" => [%{"name" => "Authorization", "value" => "Bearer " <> server.token}]} # Plain OTP :httpc, so the recipe needs no HTTP dependency. {:ok, _} = Application.ensure_all_started(:inets) headers = [{~c"accept", ~c"application/json, text/event-stream"}, {~c"authorization", String.to_charlist(hd(entry["headers"])["value"])}] body = JSON.encode!(%{jsonrpc: "2.0", id: 1, method: "tools/list", params: %{}}) {:ok, {{_, status, _}, _, _}} = :httpc.request(:post, {String.to_charlist(entry["url"]), headers, ~c"application/json", body}, [], []) IO.puts("tools/list: #{status}") server.close.() ``` * Clojure serve.clj ```clojure ;; deps.edn: {:deps {net.clojars.muthuishere/toolnexus {:mvn/version "0.27.0"}}} ;; run: clojure -M serve.clj (ns serve (:require [koine.http :as http] ; koine ships with toolnexus; no extra dependency [koine.json :as json] [toolnexus.core :as tn] [toolnexus.native :as native] [toolnexus.serve :as serve])) (def add (native/native-tool {:name "add" :description "Add two numbers." :input-schema {:type "object" :properties {:a {:type "number"} :b {:type "number"}} :required ["a" "b"]} :run (fn [{:keys [a b]}] (str (+ a b)))})) ;; Any toolkit (MCP servers, skills, native, HTTP, builtins) can be served to a local agent. (def toolkit (tn/build {:builtins false :tools [add]})) (def server (serve/as-mcp-server toolkit)) ; (as-mcp-server tk {:tools [...]}) narrows what is exposed (println "MCP endpoint:" (:url server)) ; http://127.0.0.1:/mcp, POST only ;; Every request needs the bearer token; this is the entry an ACP agent takes in session/new. (def entry {:type "http" :name "toolnexus" :url (:url server) :headers [{:name "Authorization" :value (str "Bearer " (:token server))}]}) (def res (http/request {:method :post :url (:url entry) :headers {"Content-Type" "application/json" "Accept" "application/json, text/event-stream" "Authorization" (-> entry :headers first :value)} :body (json/write-str {:jsonrpc "2.0" :id 1 :method "tools/list" :params {}})})) (println "tools/list:" (:status res)) ((:close server)) (shutdown-agents) ``` * Rust serve.rs ```rust // Cargo.toml: // toolnexus = { version = "0.25", features = ["serve"] } # "serve" is on by default // tokio = { version = "1", features = ["full"] } // serde_json = "1" // run: cargo run use serde_json::json; use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::net::TcpStream; use toolnexus::{BuiltinsConfig, ToolkitOptions, create_toolkit, define_tool}; #[tokio::main] async fn main() -> Result<(), Box> { let schema = json!({"type": "object", "properties": {"a": {"type": "number"}, "b": {"type": "number"}}, "required": ["a", "b"]}); let add = define_tool("add", "Add two numbers.", Some(schema), |a, _ctx| async move { Ok::<_, String>((a["a"].as_f64().unwrap_or(0.0) + a["b"].as_f64().unwrap_or(0.0)).to_string()) }); // Any toolkit (MCP servers, skills, native, HTTP, builtins) can be served to a local agent. let toolkit = create_toolkit(ToolkitOptions { builtins: Some(BuiltinsConfig::off()), extra_tools: vec![add], ..Default::default() }) .await?; let server = toolkit.as_mcp_server(None).await?; // Some(McpServeConfig { tools, .. }) narrows it println!("MCP endpoint: {}", server.url); // http://127.0.0.1:/mcp, POST only // Every request needs the bearer token; this is the entry an ACP agent takes in session/new. let entry = json!({"type": "http", "name": "toolnexus", "url": server.url, "headers": [{"name": "Authorization", "value": format!("Bearer {}", server.token)}]}); // A bare HTTP/1.1 POST, so the recipe needs no HTTP client crate. let body = json!({"jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {}}).to_string(); let host = server.url.trim_start_matches("http://").split('/').next().unwrap_or_default(); let request = format!( "POST /mcp HTTP/1.1\r\nHost: {host}\r\nContent-Type: application/json\r\n\ Accept: application/json, text/event-stream\r\nAuthorization: {}\r\n\ Content-Length: {}\r\nConnection: close\r\n\r\n{body}", entry["headers"][0]["value"].as_str().unwrap_or_default(), body.len() ); let mut conn = TcpStream::connect(host).await?; conn.write_all(request.as_bytes()).await?; let mut reply = String::new(); conn.read_to_string(&mut reply).await?; let status = reply.split_whitespace().nth(1).unwrap_or("?"); println!("tools/list: {status}"); server.close(); Ok(()) } ``` To serve a toolkit to the network rather than to one local agent, use `toolkit.serve(addr, { mcp })` (owns a port, no token) or mount `toolkit.mcpHandler(cfg)` in your own HTTP server, behind your own auth: see [serve MCP](/toolnexus/api/javascript/serve/mcp/) and [mcpHandler](/toolnexus/api/javascript/serve/mcp-handler/). ## In this section [Section titled “In this section”](#in-this-section) * [ACP agent recipe](/toolnexus/in-process-client/acp-agent/): drive any ACP agent with `loadACP`, your toolkit attached through `asMcpServer`, streaming its text. * [One-shot CLI recipe](/toolnexus/in-process-client/one-shot-cli/): one CLI process per turn (`claude -p`, `codex exec`), toolnexus runs the tools. * [Extend it](/toolnexus/in-process-client/extend/): add your own agent, isolation, model selection, permissions. # Recipe — drive any ACP agent > A complete, copy-pasteable recipe in all eight ports that drives any Agent Client Protocol agent as the model behind a toolnexus client, with your toolkit attached as an MCP server and the agent's text streamed. Plus per-agent isolation and model-selection notes. ## How it works [Section titled “How it works”](#how-it-works) Three pieces, all public API since 0.25.0 ([SPEC §8C](https://github.com/muthuishere/toolnexus/blob/main/SPEC.md), ADR 0041): 1. **`toolkit.asMcpServer()`** serves your toolkit on `127.0.0.1` behind a fresh bearer token and returns `{url, token, close}` (see the [overview](/toolnexus/in-process-client/#give-your-toolkit-to-an-external-agent-asmcpserver)). 2. **`loadACP({command, args, mcpServers, …})`** spawns the agent, sends `initialize` and `session/new` with your `mcpServers` entries **verbatim**, and keeps the session warm. Put the served `url` and an `Authorization: Bearer ` header in an `http` entry; the agent connects and lists your tools. Per port: Go `LoadACP`, Python `load_acp(ACPOptions(…))`, Java `AcpClient.start(…)`, C# `AcpClient.StartAsync(…)`, Elixir `Toolnexus.Acp.connect/2`, Clojure `toolnexus.acp/connect`, Rust `AcpClient::load(…)`. 3. **`createInProcessClient({ generateStream: agent.generateStream })`**. Each turn is one `session/prompt`; every `agent_message_chunk` becomes a text delta the moment it arrives, `agent_thought_chunk` a `thought` event, and the agent’s own tool calls arrive as `tool_call` / `tool_result` events with `via: "acp"`: reported to you, never executed by your loop. The agent calls your tools itself, over MCP, inside its own loop. So the toolnexus client runs with an **empty toolkit**; toolnexus never sees a tool call it would execute a second time. What the ACP client does for you on the wire: answers `session/request_permission` (first `allow*` option unless you pass a permission hook), answers agent requests it does not implement (`fs/*`, `terminal/*`) with `-32601` so the agent never hangs on them, fails a pending request the moment the agent exits, and on `close` ends the agent’s **whole process group** (so `npx`-launched agents do not outlive you). Permissions: the default allows every request With no permission hook, every `session/request_permission` gets the first `allow_*` option. That is what you want for tools you served yourself and nothing else. If the agent still has its own shell or file tools, it may run them unasked. Turn those off with the isolation knobs below, or pass a policy: see [Extend it](/toolnexus/in-process-client/extend/#permissions). Raise the turn timeout for real agents The ACP client bounds one turn as a safety net against a hung agent, and the defaults are short: Python 5 s, Java and C# 10 s, JavaScript, Go, Rust, Elixir and Clojure 30 s. A real agent turn with tool calls can take minutes, so every recipe sets it to 300 s (`permissionTimeoutMs`, `permission_timeout`, `PermissionTimeout` + `RequestTimeout`, `timeoutMs`, `PromptTimeout`, `timeout:`, `:permission-timeout`). ## The recipe [Section titled “The recipe”](#the-recipe) One file per language. Run it with the agent’s launch command as arguments, for example `npx -y @agentclientprotocol/claude-agent-acp@0.100.0`, `copilot --acp`, or `opencode acp`. * JavaScript main.mjs ```js // usage: node main.mjs npx -y @agentclientprotocol/claude-agent-acp@0.100.0 import { createInProcessClient, createToolkit, defineTool, loadACP } from "toolnexus" const add = defineTool({ name: "add", description: "Add two numbers.", inputSchema: { type: "object", properties: { a: { type: "number" }, b: { type: "number" } }, required: ["a", "b"] }, run: ({ a, b }) => { console.log(`\n[tool] add(${a}, ${b})`); return `${a + b}` }, }) // 1. Serve YOUR toolkit on loopback, behind a fresh bearer token. const toolkit = await createToolkit({ builtins: false, extraTools: [add] }) const server = await toolkit.asMcpServer() // 2. Start the agent; mcpServers is sent to session/new exactly as written. const [command, ...args] = process.argv.slice(2) const agent = await loadACP({ command, args, mcpServers: [{ type: "http", name: "toolnexus", url: server.url, headers: [{ name: "Authorization", value: `Bearer ${server.token}` }] }], permissionTimeoutMs: 300_000, // one turn of a real agent can take minutes }) // 3. The agent runs the tools itself (over MCP), so the client's own toolkit is empty. const client = createInProcessClient({ model: command, generateStream: agent.generateStream }) const empty = await createToolkit({ builtins: false }) try { for await (const ev of client.stream("Use the add tool to add 17 and 25. Reply with just the sum.", { toolkit: empty })) { if (ev.type === "text") process.stdout.write(ev.delta) // live text as the agent writes it else if (ev.type === "tool_call" && ev.via === "acp") console.log(`\n[agent tool] ${ev.name}`) else if (ev.type === "done") console.log("\nfinal:", ev.result.text) } } finally { await agent.close() // ends the agent's whole process group await server.close() } ``` * Python main.py ```python # usage: python main.py npx -y @agentclientprotocol/claude-agent-acp@0.100.0 import asyncio import sys from toolnexus import ACPOptions, create_in_process_client, create_toolkit, define_tool, load_acp def add(a: float, b: float) -> str: """Add two numbers.""" print(f"\n[tool] add({a:g}, {b:g})") return str(int(a + b)) async def main(): # 1. Serve YOUR toolkit on loopback, behind a fresh bearer token. toolkit = await create_toolkit(builtins=False, extra_tools=[define_tool(add, name="add")]) server = toolkit.as_mcp_server() # 2. Start the agent; mcp_servers is sent to session/new exactly as written. agent = load_acp(ACPOptions( command=sys.argv[1], args=sys.argv[2:], mcp_servers=[{"type": "http", "name": "toolnexus", "url": server.url, "headers": [{"name": "Authorization", "value": "Bearer " + server.token}]}], permission_timeout=300, # bounds one turn; a real agent can take minutes )) # 3. The agent runs the tools itself (over MCP), so the client's own toolkit is empty. client = create_in_process_client(model=sys.argv[1], generate_stream=agent.generate_stream) empty = await create_toolkit(builtins=False) try: async for ev in client.stream("Use the add tool to add 17 and 25. Reply with just the sum.", empty): if ev["type"] == "text": print(ev["delta"], end="", flush=True) # live text as the agent writes it elif ev["type"] == "tool_call" and ev.get("via") == "acp": print(f"\n[agent tool] {ev['name']}") elif ev["type"] == "done": print("\nfinal:", ev["result"].text) finally: agent.close() # ends the agent's whole process group server.close() asyncio.run(main()) ``` * Go main.go ```go // usage: go run . npx -y @agentclientprotocol/claude-agent-acp@0.100.0 package main import ( "context" "fmt" "os" "strconv" "time" tn "github.com/muthuishere/toolnexus/golang" ) type addArgs struct { A float64 `json:"a"` B float64 `json:"b"` } func main() { ctx := context.Background() add := tn.NativeToolReflect("add", "Add two numbers.", func(_ context.Context, in addArgs) (string, error) { fmt.Printf("\n[tool] add(%g, %g)\n", in.A, in.B) return strconv.FormatFloat(in.A+in.B, 'f', -1, 64), nil }) // 1. Serve YOUR toolkit on loopback, behind a fresh bearer token. toolkit, err := tn.CreateToolkit(ctx, tn.Options{Builtins: false, ExtraTools: []tn.Tool{add}}) if err != nil { panic(err) } server, err := toolkit.AsMCPServer(nil) if err != nil { panic(err) } defer server.Close() // 2. Start the agent; MCPServers is sent to session/new exactly as written. agent, err := tn.LoadACP(ctx, tn.ACPOptions{ Command: os.Args[1], Args: os.Args[2:], MCPServers: []map[string]any{{"type": "http", "name": "toolnexus", "url": server.URL, "headers": []map[string]any{{"name": "Authorization", "value": "Bearer " + server.Token}}}}, PermissionTimeout: 5 * time.Minute, // one turn of a real agent can take minutes RequestTimeout: 5 * time.Minute, }) if err != nil { panic(err) } defer agent.Close() // ends the agent's whole process group // 3. The agent runs the tools itself (over MCP), so the client's own toolkit is empty. client := tn.CreateInProcessClient(tn.InProcessOptions{Model: os.Args[1], GenerateStream: agent.GenerateStream}) empty, err := tn.CreateToolkit(ctx, tn.Options{Builtins: false}) if err != nil { panic(err) } events, err := client.Stream(ctx, "Use the add tool to add 17 and 25. Reply with just the sum.", empty) if err != nil { panic(err) } for ev := range events { switch { case ev.Type == "text": fmt.Print(ev.Delta) // live text as the agent writes it case ev.Type == "tool_call" && ev.Via == "acp": fmt.Printf("\n[agent tool] %s\n", ev.Name) case ev.Type == "done": fmt.Println("\nfinal:", ev.Result.Text) case ev.Type == "error": panic(ev.Err) } } } ``` * Java `AcpClient.generateStream` throws `IOException`, so the lambda wraps it; `generate(agent)` plugs in as-is for `run`. Main.java ```java // run: java -cp 'lib/*' Main.java npx -y @agentclientprotocol/claude-agent-acp@0.100.0 // (lib/ = toolnexus-0.27.0.jar + its runtime deps) import io.github.muthuishere.toolnexus.InProcess; import io.github.muthuishere.toolnexus.LlmClient; import io.github.muthuishere.toolnexus.McpServe; import io.github.muthuishere.toolnexus.NativeTool; import io.github.muthuishere.toolnexus.Toolkit; import io.github.muthuishere.toolnexus.acp.AcpClient; import java.io.IOException; import java.io.UncheckedIOException; import java.util.List; import java.util.Map; public class Main { public static void main(String[] argv) throws Exception { NativeTool add = NativeTool.of("add", "Add two numbers.", Map.of("type", "object", "properties", Map.of("a", Map.of("type", "number"), "b", Map.of("type", "number")), "required", List.of("a", "b")), args -> { int a = ((Number) args.get("a")).intValue(), b = ((Number) args.get("b")).intValue(); System.out.println("\n[tool] add(" + a + ", " + b + ")"); return String.valueOf(a + b); }); // 1. Serve YOUR toolkit on loopback, behind a fresh bearer token. try (Toolkit toolkit = Toolkit.create(new Toolkit.Options().builtins(false).extraTools(add)); McpServe.LocalServer server = toolkit.asMcpServer(); // 2. Start the agent; mcpServers is sent to session/new exactly as written. AcpClient agent = AcpClient.start(new AcpClient.Options() .command(List.of(argv)) .mcpServers(List.of(Map.of("type", "http", "name", "toolnexus", "url", server.url(), "headers", List.of(Map.of("name", "Authorization", "value", "Bearer " + server.token()))))) .timeoutMs(300_000)); // one turn of a real agent can take minutes Toolkit empty = Toolkit.create(new Toolkit.Options().builtins(false))) { // 3. The agent runs the tools itself (over MCP), so the client's own toolkit is empty. LlmClient client = InProcess.createClient(new InProcess.Options().model(argv[0]) .generateStream((req, emit) -> { try { return agent.generateStream(req, emit); } catch (IOException e) { throw new UncheckedIOException(e); } })); client.stream("Use the add tool to add 17 and 25. Reply with just the sum.", empty, ev -> { switch (ev.type()) { case TEXT -> System.out.print(ev.delta()); // live text as the agent writes it case TOOL_CALL -> { if ("acp".equals(ev.via())) System.out.println("\n[agent tool] " + ev.name()); } case DONE -> System.out.println("\nfinal: " + ev.result().text); default -> { } } }); } // closing the AcpClient ends the agent's process; the server stops after it } } ``` * C# Program.cs ```csharp // usage: dotnet run -- npx -y @agentclientprotocol/claude-agent-acp@0.100.0 (dotnet add package Toolnexus) using Toolnexus; using Toolnexus.Acp; using static Toolnexus.LlmClient; var add = NativeTool.Of("add", "Add two numbers.", new Dictionary { ["type"] = "object", ["properties"] = new Dictionary { ["a"] = new { type = "number" }, ["b"] = new { type = "number" } }, ["required"] = new[] { "a", "b" }, }, args => { double a = Convert.ToDouble(args["a"]?.ToString()), b = Convert.ToDouble(args["b"]?.ToString()); Console.WriteLine($"\n[tool] add({a}, {b})"); return $"{a + b}"; }); // 1. Serve YOUR toolkit on loopback, behind a fresh bearer token. await using var toolkit = await Toolkit.CreateAsync(new Toolkit.Options { Builtins = false, ExtraTools = [add] }); await using var server = toolkit.AsMcpServer(); // 2. Start the agent; McpServers is sent to session/new exactly as written. using var agent = await AcpClient.StartAsync(new AcpStartOptions { Command = args[0], Arguments = args[1..], McpServers = [new Dictionary { ["type"] = "http", ["name"] = "toolnexus", ["url"] = server.Url, ["headers"] = new[] { new Dictionary { ["name"] = "Authorization", ["value"] = "Bearer " + server.Token } }, }], PromptTimeout = TimeSpan.FromMinutes(5), // one turn of a real agent can take minutes }); // 3. The agent runs the tools itself (over MCP), so the client's own toolkit is empty. var client = InProcess.CreateClient(new InProcess.Options { Model = args[0], GenerateStream = agent.GenerateStreamAsync }); await using var empty = await Toolkit.CreateAsync(new Toolkit.Options { Builtins = false }); await client.StreamAsync("Use the add tool to add 17 and 25. Reply with just the sum.", empty, ev => { if (ev.Type == StreamKind.Text) Console.Write(ev.Delta); // live text as the agent writes it else if (ev.Type == StreamKind.ToolCall && ev.Via == "acp") Console.WriteLine($"\n[agent tool] {ev.Name}"); else if (ev.Type == StreamKind.Done) Console.WriteLine($"\nfinal: {ev.Result!.Text}"); }); // `using` disposes the agent (its process) and then the server. ``` * Elixir main.exs ```elixir # usage: elixir main.exs npx -y @agentclientprotocol/claude-agent-acp@0.100.0 Mix.install([{:toolnexus, "~> 0.27"}]) add = Toolnexus.define_tool( name: "add", description: "Add two numbers.", input_schema: %{"type" => "object", "properties" => %{"a" => %{"type" => "number"}, "b" => %{"type" => "number"}}, "required" => ["a", "b"]}, execute: fn %{"a" => a, "b" => b} -> IO.puts("\n[tool] add(#{a}, #{b})"); "#{a + b}" end ) # 1. Serve YOUR toolkit on loopback, behind a fresh bearer token. toolkit = Toolnexus.create_toolkit!(builtins: false, extra_tools: [add]) server = Toolnexus.Toolkit.as_mcp_server(toolkit) # 2. Start the agent; mcp_servers is sent to session/new exactly as written. [command | _] = argv = System.argv() {:ok, agent} = Toolnexus.Acp.connect(argv, mcp_servers: [%{"type" => "http", "name" => "toolnexus", "url" => server.url, "headers" => [%{"name" => "Authorization", "value" => "Bearer " <> server.token}]}], timeout: 300_000 # one turn of a real agent can take minutes ) # 3. The agent runs the tools itself (over MCP), so the client's own toolkit is empty. client = Toolnexus.Client.create_in_process(model: command, generate_stream: Toolnexus.Acp.generate_stream(agent)) empty = Toolnexus.create_toolkit!(builtins: false) try do for ev <- Toolnexus.Client.stream(client, "Use the add tool to add 17 and 25. Reply with just the sum.", empty) do case ev do %{type: "text", delta: d} -> IO.write(d) # live text as the agent writes it %{type: "tool_call", via: "acp", name: n} -> IO.puts("\n[agent tool] #{n}") %{type: "done", result: r} -> IO.puts("\nfinal: #{r.text}") _ -> :ok end end after Toolnexus.Acp.close(agent) # ends the agent process server.close.() end ``` * Clojure Clojure has no stream API: the agent’s chunks reach `run`’s `:on-event` live, while the turn runs. main.clj ```clojure ;; deps.edn: {:deps {net.clojars.muthuishere/toolnexus {:mvn/version "0.27.0"}}} ;; run: clojure -M main.clj npx -y @agentclientprotocol/claude-agent-acp@0.100.0 (ns main (:require [toolnexus.acp :as acp] [toolnexus.client :as client] [toolnexus.core :as tn] [toolnexus.native :as native] [toolnexus.serve :as serve])) (def add (native/native-tool {:name "add" :description "Add two numbers." :input-schema {:type "object" :properties {:a {:type "number"} :b {:type "number"}} :required ["a" "b"]} :run (fn [{:keys [a b]}] (println (str "\n[tool] add(" a ", " b ")")) (str (+ a b)))})) ;; 1. Serve YOUR toolkit on loopback, behind a fresh bearer token. (def toolkit (tn/build {:builtins false :tools [add]})) (def server (serve/as-mcp-server toolkit)) ;; 2. Start the agent; :mcp-servers is sent to session/new exactly as written. (def command (vec *command-line-args*)) (def acp-agent (acp/connect command {:mcp-servers [{:type "http" :name "toolnexus" :url (:url server) :headers [{:name "Authorization" :value (str "Bearer " (:token server))}]}] :permission-timeout 300000})) ; one turn of a real agent can take minutes ;; 3. The agent runs the tools itself (over MCP), so the client's own toolkit is empty. (def c (client/create-in-process-client {:model (first command) :generate-stream (acp/generate-stream acp-agent)})) (try (let [result (client/run c "Use the add tool to add 17 and 25. Reply with just the sum." {:toolkit (tn/build {:builtins false}) :on-event (fn [ev] (cond (= "text" (:type ev)) (do (print (:delta ev)) (flush)) ; live text (and (= "tool_call" (:type ev)) (= "acp" (:via ev))) (println (str "\n[agent tool] " (:name ev)))))})] (println "\nfinal:" (:text result))) (finally (acp/close acp-agent) ; ends the agent's whole process tree ((:close server)) (shutdown-agents))) ``` * Rust main.rs ```rust // Cargo.toml: // toolnexus = { version = "0.25", features = ["serve"] } # "serve" is on by default // tokio = { version = "1", features = ["full"] } // serde_json = "1" // run: cargo run -- npx -y @agentclientprotocol/claude-agent-acp@0.100.0 use std::io::Write; use std::sync::Arc; use std::time::Duration; use serde_json::json; use toolnexus::acp::{AcpClient, AcpOptions}; use toolnexus::client::{InProcessOptions, acp_generate_stream, create_in_process_client}; use toolnexus::{BuiltinsConfig, ToolkitOptions, create_toolkit, define_tool}; #[tokio::main] async fn main() -> Result<(), Box> { let schema = json!({"type": "object", "properties": {"a": {"type": "number"}, "b": {"type": "number"}}, "required": ["a", "b"]}); let add = define_tool("add", "Add two numbers.", Some(schema), |a, _ctx| async move { println!("\n[tool] add({}, {})", a["a"], a["b"]); Ok::<_, String>((a["a"].as_f64().unwrap_or(0.0) + a["b"].as_f64().unwrap_or(0.0)).to_string()) }); // 1. Serve YOUR toolkit on loopback, behind a fresh bearer token. let off = || Some(BuiltinsConfig::off()); let toolkit = create_toolkit(ToolkitOptions { builtins: off(), extra_tools: vec![add], ..Default::default() }).await?; let server = toolkit.as_mcp_server(None).await?; // 2. Start the agent; mcp_servers is sent to session/new exactly as written. let argv: Vec = std::env::args().skip(1).collect(); let (command, args) = argv.split_first().ok_or("usage: main [args...]")?; let mut opts = AcpOptions::new(command).args(args); opts.mcp_servers = vec![json!({"type": "http", "name": "toolnexus", "url": server.url, "headers": [{"name": "Authorization", "value": format!("Bearer {}", server.token)}]})]; opts.permission_timeout = Duration::from_secs(300); // one turn of a real agent can take minutes let agent = Arc::new(AcpClient::load(opts).await?); // 3. The agent runs the tools itself (over MCP), so the client's own toolkit is empty. let client = create_in_process_client(InProcessOptions { model: command.clone(), generate_stream: Some(acp_generate_stream(agent.clone())), ..Default::default() }); let empty = create_toolkit(ToolkitOptions { builtins: off(), ..Default::default() }).await?; let mut events = client.stream("Use the add tool to add 17 and 25. Reply with just the sum.", &empty)?; while let Some(ev) = events.recv().await { match ev.kind.as_str() { "text" => { print!("{}", ev.delta); std::io::stdout().flush()?; } // live text "tool_call" if ev.via == "acp" => println!("\n[agent tool] {}", ev.name), "done" => println!("\nfinal: {}", ev.result.map(|r| r.text).unwrap_or_default()), "error" => eprintln!("\nerror: {:?}", ev.error), _ => {} } } agent.close().await; // ends the agent's whole process group server.close(); Ok(()) } ``` ## What was verified [Section titled “What was verified”](#what-was-verified) | | how | | -------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | all eight recipes | compiled and run on toolnexus 0.25.0 against a fake ACP agent ([`fake_acp_agent.py`](https://github.com/muthuishere/toolnexus/blob/main/tools/mock-llm/fake_acp_agent.py), stdlib Python: answers `initialize` / `session/new`, sends a `session/request_permission`, calls `tools/list` + `tools/call` on the served URL with the entry’s bearer header, streams two `agent_message_chunk`s). Each printed `[tool] add(17, 25)` and `final: The sum is 42.` | | JavaScript, Python, Go, Java, C#, Rust, live | `@agentclientprotocol/claude-agent-acp@0.100.0` on 2026-11-06: the agent found the tool (`[agent tool] ToolSearch`), called `mcp__toolnexus__add` over MCP with the bearer token, our `add` ran, and it answered `42`, streamed | | Elixir, Clojure, live | not run live; same library wire, verified against the fake only | The recipe sources are the files that were run: [`site/recipes/in-process-client/`](https://github.com/muthuishere/toolnexus/tree/main/site/recipes/in-process-client). ## Per-agent notes [Section titled “Per-agent notes”](#per-agent-notes) By default an agent loads its own world into every session: instruction files, skills, plugins, its own MCP servers, hooks and \~30 built-in tools. That is 3k to 34k tokens per turn you pay for and did not ask for. Isolation turns it off while subscription login and your MCP server keep working. The big win is always turning off the agent’s **built-in tools**, which you do not want it running anyway. | agent (verified on) | launch | isolation knob | context per trivial turn, default → isolated | your MCP server | gaps | | --------------------------------------------------------------------------------- | ------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Claude Code** (`@agentclientprotocol/claude-agent-acp@0.100.0`, claude 2.1.318) | `npx -y @agentclientprotocol/claude-agent-acp@0.100.0` | `session/new` `_meta`: `{"claudeCode":{"options":{"settingSources":[],"strictMcpConfig":true,"plugins":[],"skills":[],"tools":[]}}}` | 34,073 → 3,368 | works (HTTP verified live here); with `tools:[]` it calls your tool directly | none found; subscription login unaffected. Without isolation it inherits your `permissionMode` setting | | **Copilot CLI** (1.0.105) | `copilot --acp` | launch flags `--disable-builtin-mcps --no-custom-instructions --available-tools=toolnexus-,…` | 16,343 → \~3,000 | **HTTP only**: stdio servers in `session/new` are silently dropped. Tools appear as `toolnexus-` | `--available-tools` needs your tool names at launch; it emits an `Info: Disabled tools: …` message chunk you should filter from the answer | | **opencode** (2.0.23) | `opencode acp` | env `OPENCODE_DISABLE_PROJECT_CONFIG=1` and `OPENCODE_CONFIG_CONTENT={"tools":{"*":false,"execute":true,"toolnexus_*":true}}` | 11,859 → \~1,000 | works, reached through its `execute` code-mode tool | `"*":false` alone also hides your MCP tools: re-allow `execute` **and** `toolnexus_*`. An empty `OPENCODE_CONFIG_DIR` made it worse | | **Devin** (2026.8.18) | `devin acp` | `devin --agent-config acp` (keys `system-instructions`, `allowed-tools`, `permissions`, `mcp-servers`, `extensions`) | 24,040 → 24,039 (no effect) | **ignores `session/new` mcpServers**: write a `.mcp.json` in the session `cwd`, and hint the tool name in the prompt | no knob measurably shrank context; MCP discovery was flaky | | **Codex** (codex-acp 0.16.0) | `codex-acp` | `-c mcp_servers={} --disable apps --disable plugins --disable hooks -c project_doc_max_bytes=0 -c skills.include_instructions=false` | ACP unmeasurable; `codex exec` 26,905 → 17,204 | n/a | every model returns 400 over ACP with a ChatGPT account. Use the [one-shot recipe](/toolnexus/in-process-client/one-shot-cli/) with `codex exec` instead | | **Gemini CLI** (0.66.0) | ACP mode per `gemini --help` | from `--help`, unmeasured: `--extensions none`, `--allowed-mcp-server-names toolnexus`, `--skip-trust` | not measured | not measured | not authenticated on the test machine; unverified | | **goose** | n/a | profiles / `GOOSE_*` env per its docs | not measured | not measured | not installed; unverified | ### Passing the knobs [Section titled “Passing the knobs”](#passing-the-knobs) All three are plain `loadACP` options, passed through untouched: * **`_meta`** (Claude): `sessionMeta` (Python `session_meta`, Go `SessionMeta`, Elixir `:session_meta`, Clojure `:session-meta`). Verified live with the Claude meta above ([Extend it](/toolnexus/in-process-client/extend/) recipe, Python). * **env** (opencode): `env`, merged over the current environment (Go: `"KEY=value"` strings). * **flags** (Copilot, Codex, Devin): append them to `args`. ## Listing and choosing models [Section titled “Listing and choosing models”](#listing-and-choosing-models) Most ACP agents advertise their models in the `session/new` result and let you switch per session: 1. Read `configOptions` from the `session/new` result (your own ACP code: `loadACP` does not expose it) and take the entry with `category: "model"` (its values may be flat or grouped). Older agents use a top-level `models` object instead. 2. Select with `session/set_config_option { sessionId, configId: , value }`. Check that the returned `currentValue` is what you asked for; do not assume. | agent | models over ACP (2026-11-05) | other ways | | ------------- | ------------------------------------------------------------------------------------ | ------------------------------------------ | | Claude | 13 values (`default`, `opus`, `sonnet`, `haiku`, full ids); setting `haiku` verified | none from the CLI | | Copilot | only `auto` | `--model ` / `COPILOT_MODEL` at launch | | opencode | 592 `provider/model` values; setting one verified | `opencode models` | | Devin | 2 values; setting one verified | `--model`, `DEVIN_MODEL` | | Codex | 5 values, all rejected for ChatGPT accounts | `codex debug models`; `-m` on `codex exec` | | Gemini, goose | not measured | | Vendor-specific hints, such as Copilot’s `_meta.copilotUsage` or opencode’s `-free` suffix, are raw data; filter on them in your own code if you need to. To **pick** a model, pass `model` to `loadACP` ([Extend it](/toolnexus/in-process-client/extend/#model-selection)); toolnexus does not list models. # Extend it — your agent, isolation, models, permissions > How to grow the in-process recipes yourself — add another ACP agent or CLI, isolate it from the agent's own config, list and pick models over ACP, and decide permission requests. Everything below is an option on the ACP client (`loadACP` and its per-port equivalents) from the [ACP recipe](/toolnexus/in-process-client/acp-agent/), passed through to the agent untouched. The [recipe at the end](#the-recipe-isolation-model-and-a-permission-policy) uses all of them, in all eight ports. ## Add your own agent [Section titled “Add your own agent”](#add-your-own-agent) There is nothing to register. An ACP agent is a launch command plus a few pass-through options: | what changes per agent | `loadACP` option | | ---------------------------------------------------------------- | ----------------------------------------------------- | | how to start it (`copilot --acp`, `opencode acp`, a binary path) | `command` + `args` | | isolation and auth that live in the environment | `env` | | per-session options the agent reads from `session/new` | `sessionMeta` (sent as `_meta`) | | vendor flags on `initialize` | `clientCapsMeta` (sent as `clientCapabilities._meta`) | | which model | `model` | | which of its tool calls to allow | `onPermission` | | anything ACP does not carry (Devin’s `.mcp.json`, a prompt hint) | write it before `loadACP(...)`, in the `cwd` you pass | A CLI with no ACP mode goes in the [one-shot recipe](/toolnexus/in-process-client/one-shot-cli/) the same way: it is just `command` + `args`. Keep a small table of your agents in your own config, for example: ```js const AGENTS = { claude: { command: "npx", args: ["-y", "@agentclientprotocol/claude-agent-acp@0.100.0"], sessionMeta: { claudeCode: { options: { settingSources: [], strictMcpConfig: true, plugins: [], skills: [], tools: [] } } }, }, opencode: { command: "opencode", args: ["acp"], env: { OPENCODE_DISABLE_PROJECT_CONFIG: "1", OPENCODE_CONFIG_CONTENT: JSON.stringify({ tools: { "*": false, execute: true, "toolnexus_*": true } }), }, }, copilot: { command: "copilot", args: ["--acp", "--disable-builtin-mcps", "--no-custom-instructions"] }, } const agent = await loadACP({ ...AGENTS.claude, mcpServers: [toolnexusEntry], permissionTimeoutMs: 300_000 }) ``` ## Isolation [Section titled “Isolation”](#isolation) By default an agent brings its whole world into your session: its instruction files, skills, plugins, its own MCP servers, hooks and built-in tools. That costs 3k to 34k tokens a turn and lets it do things you did not ask for. The knobs per agent, with measured numbers, are in the [per-agent notes](/toolnexus/in-process-client/acp-agent/#per-agent-notes). Three rules from those measurements: 1. **Turn off built-in tools first.** That is the 10x win (Claude 34k → 3.4k, Copilot 16k → 3k, opencode 12k → 1k). “Settings off” alone saves far less. 2. **Re-allow your own tools explicitly** when the knob is a deny-all (opencode `"*": false` also hides MCP tools unless you allow `toolnexus_*`). 3. **Do not swap `HOME` / config dirs** to isolate. Subscription logins live there or in a keychain entry keyed by it; it breaks login and, for opencode, measured *worse*. Check it worked: ask a trivial question and compare the agent’s own token count with and without the knob. `onUpdate` sees every raw `session/update`, including `usage_update`; the recipe below logs those to stderr. ## Model selection [Section titled “Model selection”](#model-selection) Pass `model` to `loadACP`. toolnexus sends it unchanged after `session/new`, first as `session/set_config_option {configId: "model", value}` and, if the agent rejects that, as `session/set_model {modelId}`; if both fail, `loadACP` fails. It never lists or validates models. Verified live: Claude Code with `model: "haiku"` reported `claude-haiku-5-5` in its `usage_update`s. To **list** what an agent offers, read the `session/new` result yourself: `configOptions`, the entry with `category: "model"` (values flat or grouped), or a top-level `models` object on older agents. `loadACP` does not expose that result, so this is a few lines of your own ACP code (the [per-agent notes](/toolnexus/in-process-client/acp-agent/#listing-and-choosing-models) have what each agent returned). Always check what you got: some agents (Copilot) only offer `auto`, and Codex lists models a ChatGPT account cannot use. ## Permissions [Section titled “Permissions”](#permissions) ACP agents ask before running a tool: a `session/request_permission` request with `options`, each with a `kind` (`allow_once`, `allow_always`, `reject_once`, `reject_always`). With no hook, the client picks the first `allow*` option, which is right only when the agent has nothing but your tools. A policy is the `onPermission` hook: it gets the raw request params and returns the `optionId` to select; returning nothing answers with the first `reject*` option (or `cancelled` when the agent offered none). Other policies follow the same shape: ask a human through your UI and reply when they answer (the agent waits, up to the turn timeout), log every request, or route the decision to a [judge](/toolnexus/cookbook/judge/). Isolation is still the better first line: an agent with no shell tool cannot ask to run one. ## The recipe: isolation, model and a permission policy [Section titled “The recipe: isolation, model and a permission policy”](#the-recipe-isolation-model-and-a-permission-policy) Claude Code’s isolation `_meta`, the model from `ACP_MODEL` when set, a policy that allows only tool calls whose title names one of your toolkit’s tools, and `usage_update`s logged to stderr. * JavaScript main.mjs ```js // usage: node main.mjs npx -y @agentclientprotocol/claude-agent-acp@0.100.0 import { createInProcessClient, createToolkit, defineTool, loadACP } from "toolnexus" const add = defineTool({ name: "add", description: "Add two numbers.", inputSchema: { type: "object", properties: { a: { type: "number" }, b: { type: "number" } }, required: ["a", "b"] }, run: ({ a, b }) => `${a + b}`, }) const toolkit = await createToolkit({ builtins: false, extraTools: [add] }) const server = await toolkit.asMcpServer() // Permission policy: allow tool calls whose title names one of your tools, reject the rest. // Return an optionId to pick it; return undefined to answer with the first reject option. function allowOnly(names) { return (params) => { const title = params.toolCall?.title ?? "" const ok = names.some((n) => title.includes(n)) return params.options.find((o) => o.kind.startsWith(ok ? "allow" : "reject"))?.optionId } } const [command, ...args] = process.argv.slice(2) const agent = await loadACP({ command, args, mcpServers: [{ type: "http", name: "toolnexus", url: server.url, headers: [{ name: "Authorization", value: `Bearer ${server.token}` }] }], // Agent-specific knobs are opaque data: this one isolates Claude Code (see the per-agent notes). sessionMeta: { claudeCode: { options: { settingSources: [], strictMcpConfig: true, plugins: [], skills: [], tools: [] } } }, model: process.env.ACP_MODEL, // e.g. "haiku"; passed through, never validated onPermission: allowOnly(toolkit.tools().map((t) => t.name)), onUpdate: (u) => { if (u.update?.sessionUpdate === "usage_update") console.error("[usage]", JSON.stringify(u.update)) }, permissionTimeoutMs: 300_000, }) const client = createInProcessClient({ model: command, generate: agent.generate }) try { const result = await client.run("Use the add tool to add 17 and 25. Reply with just the sum.", { toolkit: await createToolkit({ builtins: false }) }) console.log(result.text) } finally { await agent.close() await server.close() } ``` * Python main.py ```python # usage: python main.py npx -y @agentclientprotocol/claude-agent-acp@0.100.0 import asyncio import json import os import sys from toolnexus import ACPOptions, create_in_process_client, create_toolkit, define_tool, load_acp def add(a: float, b: float) -> str: """Add two numbers.""" return str(int(a + b)) def allow_only(names): """Permission policy: allow tool calls whose title names one of your tools, reject the rest. Return an optionId to pick it; return None to answer with the first reject option.""" def decide(params): title = (params.get("toolCall") or {}).get("title") or "" ok = any(n in title for n in names) return next((o["optionId"] for o in params["options"] if o["kind"].startswith("allow" if ok else "reject")), None) return decide def log_usage(params): if params.get("update", {}).get("sessionUpdate") == "usage_update": print("[usage]", json.dumps(params["update"]), file=sys.stderr) async def main(): toolkit = await create_toolkit(builtins=False, extra_tools=[define_tool(add, name="add")]) server = toolkit.as_mcp_server() agent = load_acp(ACPOptions( command=sys.argv[1], args=sys.argv[2:], mcp_servers=[{"type": "http", "name": "toolnexus", "url": server.url, "headers": [{"name": "Authorization", "value": "Bearer " + server.token}]}], # Agent-specific knobs are opaque data: this one isolates Claude Code (see the per-agent notes). session_meta={"claudeCode": {"options": {"settingSources": [], "strictMcpConfig": True, "plugins": [], "skills": [], "tools": []}}}, model=os.environ.get("ACP_MODEL"), # e.g. "haiku"; passed through, never validated on_permission=allow_only([t.name for t in toolkit.tools()]), on_update=log_usage, permission_timeout=300, )) client = create_in_process_client(model=sys.argv[1], generate=agent.generate) try: result = await client.run("Use the add tool to add 17 and 25. Reply with just the sum.", await create_toolkit(builtins=False)) print(result.text) finally: agent.close() server.close() asyncio.run(main()) ``` * Go main.go ```go // usage: go run . npx -y @agentclientprotocol/claude-agent-acp@0.100.0 package main import ( "context" "encoding/json" "fmt" "os" "strconv" "strings" "time" tn "github.com/muthuishere/toolnexus/golang" ) type addArgs struct { A float64 `json:"a"` B float64 `json:"b"` } // allowOnly is a permission policy: allow tool calls whose title names one of your tools, // reject the rest. Return an optionId to pick it; "" answers with the first reject option. func allowOnly(names []string) func(context.Context, json.RawMessage) (string, error) { return func(_ context.Context, raw json.RawMessage) (string, error) { var p struct { ToolCall struct{ Title string } `json:"toolCall"` Options []struct{ OptionID, Kind string } } if err := json.Unmarshal(raw, &p); err != nil { return "", err } want := "reject" for _, n := range names { if strings.Contains(p.ToolCall.Title, n) { want = "allow" } } for _, o := range p.Options { if strings.HasPrefix(o.Kind, want) { return o.OptionID, nil } } return "", nil } } func main() { ctx := context.Background() add := tn.NativeToolReflect("add", "Add two numbers.", func(_ context.Context, in addArgs) (string, error) { return strconv.FormatFloat(in.A+in.B, 'f', -1, 64), nil }) toolkit, err := tn.CreateToolkit(ctx, tn.Options{Builtins: false, ExtraTools: []tn.Tool{add}}) if err != nil { panic(err) } server, err := toolkit.AsMCPServer(nil) if err != nil { panic(err) } defer server.Close() names := []string{} for _, t := range toolkit.Tools() { names = append(names, t.Name) } agent, err := tn.LoadACP(ctx, tn.ACPOptions{ Command: os.Args[1], Args: os.Args[2:], MCPServers: []map[string]any{{"type": "http", "name": "toolnexus", "url": server.URL, "headers": []map[string]any{{"name": "Authorization", "value": "Bearer " + server.Token}}}}, // Agent-specific knobs are opaque data: this one isolates Claude Code (see the per-agent notes). SessionMeta: map[string]any{"claudeCode": map[string]any{"options": map[string]any{ "settingSources": []any{}, "strictMcpConfig": true, "plugins": []any{}, "skills": []any{}, "tools": []any{}}}}, Model: os.Getenv("ACP_MODEL"), // e.g. "haiku"; passed through, never validated OnPermission: allowOnly(names), OnUpdate: func(raw json.RawMessage) { if strings.Contains(string(raw), `"sessionUpdate":"usage_update"`) { fmt.Fprintln(os.Stderr, "[usage]", string(raw)) } }, PermissionTimeout: 5 * time.Minute, RequestTimeout: 5 * time.Minute, }) if err != nil { panic(err) } defer agent.Close() client := tn.CreateInProcessClient(tn.InProcessOptions{Model: os.Args[1], GenerateStream: agent.GenerateStream}) empty, err := tn.CreateToolkit(ctx, tn.Options{Builtins: false}) if err != nil { panic(err) } result, err := client.Run(ctx, "Use the add tool to add 17 and 25. Reply with just the sum.", empty) if err != nil { panic(err) } fmt.Println(result.Text) } ``` * Java Main.java ```java // run: java -cp 'lib/*' Main.java npx -y @agentclientprotocol/claude-agent-acp@0.100.0 // (lib/ = toolnexus-0.27.0.jar + its runtime deps; optional env ACP_MODEL, e.g. haiku) import io.github.muthuishere.toolnexus.InProcess; import io.github.muthuishere.toolnexus.LlmClient; import io.github.muthuishere.toolnexus.McpServe; import io.github.muthuishere.toolnexus.NativeTool; import io.github.muthuishere.toolnexus.Tool; import io.github.muthuishere.toolnexus.Toolkit; import io.github.muthuishere.toolnexus.acp.AcpClient; import com.fasterxml.jackson.databind.ObjectMapper; import java.util.List; import java.util.Map; import java.util.function.Function; public class Main { // Permission policy: allow tool calls whose title names one of your tools, reject the rest. // Return an optionId to pick it; return null to answer with the first reject option. @SuppressWarnings("unchecked") static Function, String> allowOnly(List names) { return params -> { Map toolCall = (Map) params.get("toolCall"); String title = toolCall == null || toolCall.get("title") == null ? "" : toolCall.get("title").toString(); String want = names.stream().anyMatch(title::contains) ? "allow" : "reject"; for (Object o : (List) params.get("options")) { Map opt = (Map) o; if (String.valueOf(opt.get("kind")).startsWith(want)) return (String) opt.get("optionId"); } return null; }; } public static void main(String[] argv) throws Exception { NativeTool add = NativeTool.of("add", "Add two numbers.", Map.of("type", "object", "properties", Map.of("a", Map.of("type", "number"), "b", Map.of("type", "number")), "required", List.of("a", "b")), args -> String.valueOf(((Number) args.get("a")).intValue() + ((Number) args.get("b")).intValue())); ObjectMapper json = new ObjectMapper(); try (Toolkit toolkit = Toolkit.create(new Toolkit.Options().builtins(false).extraTools(add)); McpServe.LocalServer server = toolkit.asMcpServer(); AcpClient agent = AcpClient.start(new AcpClient.Options() .command(List.of(argv)) .mcpServers(List.of(Map.of("type", "http", "name", "toolnexus", "url", server.url(), "headers", List.of(Map.of("name", "Authorization", "value", "Bearer " + server.token()))))) // Agent-specific knobs are opaque data: this one isolates Claude Code (see the per-agent notes). .sessionMeta(Map.of("claudeCode", Map.of("options", Map.of("settingSources", List.of(), "strictMcpConfig", true, "plugins", List.of(), "skills", List.of(), "tools", List.of())))) .model(System.getenv("ACP_MODEL")) // e.g. "haiku"; null = unset; passed through, never validated .onPermission(allowOnly(toolkit.tools().stream().map(Tool::name).toList())) .onUpdate(u -> { if (u.get("update") instanceof Map up && "usage_update".equals(up.get("sessionUpdate"))) { try { System.err.println("[usage] " + json.writeValueAsString(up)); } catch (Exception ignored) { } } }) .timeoutMs(300_000)); Toolkit empty = Toolkit.create(new Toolkit.Options().builtins(false))) { LlmClient client = InProcess.createClient(new InProcess.Options().model(argv[0]).generate(agent)); LlmClient.RunResult result = client.run("Use the add tool to add 17 and 25. Reply with just the sum.", empty); System.out.println(result.text); } } } ``` * C# Program.cs ```csharp // usage: dotnet run -- npx -y @agentclientprotocol/claude-agent-acp@0.100.0 (dotnet add package Toolnexus) using System.Text.Json; using Toolnexus; using Toolnexus.Acp; var add = NativeTool.Of("add", "Add two numbers.", new Dictionary { ["type"] = "object", ["properties"] = new Dictionary { ["a"] = new { type = "number" }, ["b"] = new { type = "number" } }, ["required"] = new[] { "a", "b" }, }, args => $"{Convert.ToDouble(args["a"]?.ToString()) + Convert.ToDouble(args["b"]?.ToString())}"); await using var toolkit = await Toolkit.CreateAsync(new Toolkit.Options { Builtins = false, ExtraTools = [add] }); await using var server = toolkit.AsMcpServer(); // Permission policy: allow tool calls whose title names one of your tools, reject the rest. // Return an optionId to pick it; return null to answer with the first reject option. static Func AllowOnly(IEnumerable names) => p => { var title = p.TryGetProperty("toolCall", out var tc) && tc.TryGetProperty("title", out var t) ? t.GetString() ?? "" : ""; var ok = names.Any(title.Contains); foreach (var o in p.GetProperty("options").EnumerateArray()) if (o.GetProperty("kind").GetString()!.StartsWith(ok ? "allow" : "reject")) return o.GetProperty("optionId").GetString(); return null; }; using var agent = await AcpClient.StartAsync(new AcpStartOptions { Command = args[0], Arguments = args[1..], McpServers = [new Dictionary { ["type"] = "http", ["name"] = "toolnexus", ["url"] = server.Url, ["headers"] = new[] { new Dictionary { ["name"] = "Authorization", ["value"] = "Bearer " + server.Token } }, }], // Agent-specific knobs are opaque data: this one isolates Claude Code (see the per-agent notes). SessionMeta = new Dictionary { ["claudeCode"] = new { options = new { settingSources = Array.Empty(), strictMcpConfig = true, plugins = Array.Empty(), skills = Array.Empty(), tools = Array.Empty() } }, }, Model = Environment.GetEnvironmentVariable("ACP_MODEL"), // e.g. "haiku"; passed through, never validated OnPermission = AllowOnly(toolkit.Tools().Select(t => t.Name).ToList()), OnUpdate = u => { if (u.TryGetProperty("update", out var up) && up.TryGetProperty("sessionUpdate", out var k) && k.GetString() == "usage_update") Console.Error.WriteLine($"[usage] {up.GetRawText()}"); }, PromptTimeout = TimeSpan.FromMinutes(5), }); var client = InProcess.CreateClient(new InProcess.Options { Model = args[0], Generate = agent.Generate }); await using var empty = await Toolkit.CreateAsync(new Toolkit.Options { Builtins = false }); var result = await client.RunAsync("Use the add tool to add 17 and 25. Reply with just the sum.", empty); Console.WriteLine(result.Text); ``` * Elixir main.exs ```elixir # usage: elixir main.exs npx -y @agentclientprotocol/claude-agent-acp@0.100.0 Mix.install([{:toolnexus, "~> 0.27"}]) add = Toolnexus.define_tool( name: "add", description: "Add two numbers.", input_schema: %{"type" => "object", "properties" => %{"a" => %{"type" => "number"}, "b" => %{"type" => "number"}}, "required" => ["a", "b"]}, execute: fn %{"a" => a, "b" => b} -> "#{a + b}" end ) toolkit = Toolnexus.create_toolkit!(builtins: false, extra_tools: [add]) server = Toolnexus.Toolkit.as_mcp_server(toolkit) # Permission policy: allow tool calls whose title names one of your tools, reject the rest. # Return an optionId to pick it; return nil to answer with the first reject option. allow_only = fn names -> fn params -> title = get_in(params, ["toolCall", "title"]) || "" if Enum.any?(names, &String.contains?(title, &1)), do: Enum.find_value(params["options"], &(String.starts_with?(&1["kind"], "allow") && &1["optionId"])), else: nil end end argv = System.argv() model_opt = if m = System.get_env("ACP_MODEL"), do: [model: m], else: [] # e.g. "haiku"; never validated {:ok, agent} = Toolnexus.Acp.connect(argv, [ mcp_servers: [%{"type" => "http", "name" => "toolnexus", "url" => server.url, "headers" => [%{"name" => "Authorization", "value" => "Bearer " <> server.token}]}], # Agent-specific knobs are opaque data: this one isolates Claude Code (see the per-agent notes). session_meta: %{"claudeCode" => %{"options" => %{"settingSources" => [], "strictMcpConfig" => true, "plugins" => [], "skills" => [], "tools" => []}}}, on_permission: allow_only.(Enum.map(toolkit.tools, & &1.name)), on_update: fn u -> if get_in(u, ["update", "sessionUpdate"]) == "usage_update", do: IO.puts(:stderr, "[usage] " <> JSON.encode!(u["update"])) end, timeout: 300_000 ] ++ model_opt ) client = Toolnexus.Client.create_in_process(model: hd(argv), generate: Toolnexus.Acp.generate(agent)) try do result = Toolnexus.Client.run(client, "Use the add tool to add 17 and 25. Reply with just the sum.", Toolnexus.create_toolkit!(builtins: false)) IO.puts(result.text) after Toolnexus.Acp.close(agent) server.close.() end ``` * Clojure main.clj ```clojure ;; deps.edn: {:deps {net.clojars.muthuishere/toolnexus {:mvn/version "0.27.0"}}} ;; run: clojure -M main.clj npx -y @agentclientprotocol/claude-agent-acp@0.100.0 (ns main (:require [clojure.string :as str] [koine.json :as json] ; ships with toolnexus [toolnexus.acp :as acp] [toolnexus.client :as client] [toolnexus.core :as tn] [toolnexus.native :as native] [toolnexus.serve :as serve])) (def add (native/native-tool {:name "add" :description "Add two numbers." :input-schema {:type "object" :properties {:a {:type "number"} :b {:type "number"}} :required ["a" "b"]} :run (fn [{:keys [a b]}] (str (+ a b)))})) (def toolkit (tn/build {:builtins false :tools [add]})) (def server (serve/as-mcp-server toolkit)) ;; Permission policy: allow tool calls whose title names one of your tools, reject the rest. ;; Return an optionId to pick it; return nil to answer with the first reject option. (defn allow-only [names] (fn [params] (let [title (str (get-in params [:toolCall :title])) ok? (some #(str/includes? title %) names)] (some #(when (str/starts-with? (str (:kind %)) (if ok? "allow" "reject")) (:optionId %)) (:options params))))) (def command (vec *command-line-args*)) (def acp-agent (acp/connect command (cond-> {:mcp-servers [{:type "http" :name "toolnexus" :url (:url server) :headers [{:name "Authorization" :value (str "Bearer " (:token server))}]}] ;; Agent-specific knobs are opaque data: this one isolates Claude Code. :session-meta {:claudeCode {:options {:settingSources [] :strictMcpConfig true :plugins [] :skills [] :tools []}}} :on-permission (allow-only (tn/tool-names toolkit)) :on-update (fn [u] (when (= "usage_update" (get-in u [:update :sessionUpdate])) (binding [*out* *err*] (println "[usage]" (json/write-str (:update u)))))) :permission-timeout 300000} (System/getenv "ACP_MODEL") (assoc :model (System/getenv "ACP_MODEL"))))) ; e.g. "haiku"; never validated (def c (client/create-in-process-client {:model (first command) :generate (acp/generate acp-agent)})) (try (println (:text (client/run c "Use the add tool to add 17 and 25. Reply with just the sum." {:toolkit (tn/build {:builtins false})}))) (finally (acp/close acp-agent) ((:close server)) (shutdown-agents))) ``` * Rust main.rs ```rust // Cargo.toml: // toolnexus = { version = "0.25", features = ["serve"] } # "serve" is on by default // tokio = { version = "1", features = ["full"] } // serde_json = "1" // run: cargo run -- npx -y @agentclientprotocol/claude-agent-acp@0.100.0 use std::sync::Arc; use std::time::Duration; use serde_json::json; use toolnexus::acp::{AcpClient, AcpOptions, PermissionHandler, PermissionOutcome, permission_handler}; use toolnexus::client::{InProcessOptions, acp_generate, create_in_process_client}; use toolnexus::{BuiltinsConfig, ToolkitOptions, create_toolkit, define_tool}; // Permission policy: allow tool calls whose title names one of your tools, reject the rest. // Reject answers with the agent's first reject option. fn allow_only(names: Vec) -> PermissionHandler { permission_handler(move |req| { let title = req.tool_call["title"].as_str().unwrap_or_default().to_string(); let ok = names.iter().any(|n| title.contains(n.as_str())); let allow = req.first_allow().map(|o| o.option_id.clone()); async move { match allow { Some(id) if ok => PermissionOutcome::Selected(id), _ => PermissionOutcome::Reject, } } }) } #[tokio::main] async fn main() -> Result<(), Box> { let schema = json!({"type": "object", "properties": {"a": {"type": "number"}, "b": {"type": "number"}}, "required": ["a", "b"]}); let add = define_tool("add", "Add two numbers.", Some(schema), |a, _ctx| async move { Ok::<_, String>((a["a"].as_f64().unwrap_or(0.0) + a["b"].as_f64().unwrap_or(0.0)).to_string()) }); let off = || Some(BuiltinsConfig::off()); let toolkit = create_toolkit(ToolkitOptions { builtins: off(), extra_tools: vec![add], ..Default::default() }).await?; let server = toolkit.as_mcp_server(None).await?; let argv: Vec = std::env::args().skip(1).collect(); let (command, args) = argv.split_first().ok_or("usage: main [args...]")?; let names = toolkit.tools().into_iter().map(|t| t.name).collect(); let mut opts = AcpOptions::new(command) .args(args) .on_permission(allow_only(names)) .on_update(|u| { if u["update"]["sessionUpdate"] == "usage_update" { eprintln!("[usage] {}", u["update"]); } }); opts.mcp_servers = vec![json!({"type": "http", "name": "toolnexus", "url": server.url, "headers": [{"name": "Authorization", "value": format!("Bearer {}", server.token)}]})]; // Agent-specific knobs are opaque data: this one isolates Claude Code (see the per-agent notes). opts.session_meta = Some(json!({"claudeCode": {"options": { "settingSources": [], "strictMcpConfig": true, "plugins": [], "skills": [], "tools": []}}})); opts.model = std::env::var("ACP_MODEL").ok(); // e.g. "haiku"; passed through, never validated opts.permission_timeout = Duration::from_secs(300); let agent = Arc::new(AcpClient::load(opts).await?); let client = create_in_process_client(InProcessOptions { model: command.clone(), generate: Some(acp_generate(agent.clone())), ..Default::default() }); let empty = create_toolkit(ToolkitOptions { builtins: off(), ..Default::default() }).await?; let result = client.run("Use the add tool to add 17 and 25. Reply with just the sum.", &empty).await; agent.close().await; server.close(); println!("{}", result?.text); Ok(()) } ``` Verified on 0.25.0 against the fake ACP agent in all eight ports (the agent received the `_meta`, the policy answered `allow`, `The sum is 42.`), and live in Python against `@agentclientprotocol/claude-agent-acp@0.100.0` with `ACP_MODEL=haiku`. ## Cancelling a turn [Section titled “Cancelling a turn”](#cancelling-a-turn) ACP has a `session/cancel` notification (`{ method: "session/cancel", params: { sessionId } }`, no `id`): the pending `session/prompt` resolves with `stopReason: "cancelled"` and the process and session stay warm. Go and Rust send it for you: cancel the run’s `ctx` (Go `GenerateStream` / `GenerateCtx`) or fire the cancellation token (Rust). In the other ports the ACP client does not send it today; to stop a turn there, `close()` the agent and start a new one. ## Streaming more than text [Section titled “Streaming more than text”](#streaming-more-than-text) The ACP client’s `generateStream` already maps `agent_thought_chunk` to `thought` events and the agent’s `tool_call` / completed `tool_call_update` to `tool_call` / `tool_result` events with `via: "acp"`; the stream consumer gets them, the loop never executes them. Everything else (`plan`, `usage_update`, `available_commands_update`, …) reaches `onUpdate` as raw params. # Recipe — one CLI process per turn > A complete, copy-pasteable recipe in all eight ports that turns any prompt-in, text-out CLI (claude -p, codex exec, …) into a tool-calling model: render the request, run the process, parse the reply into text or tool calls. toolnexus runs the tools. ## How it works [Section titled “How it works”](#how-it-works) 1. **Render.** Turn `request.tools` and `request.messages` into one prompt, ending with the reply contract: *reply with only `{"tool_calls":[{"name","arguments"}]}` or `{"content":"…"}`*. 2. **Run.** Spawn the CLI with the prompt on stdin, wait, read stdout. A non-zero exit is an error (final, not retried, as for every in-process client). 3. **Parse.** Take the outermost `{…}` from the reply. `tool_calls` becomes `toolCalls`, `content` becomes the answer, anything else is treated as the answer as-is. toolnexus then executes the calls and calls `generate` again with the results in the transcript, until the CLI answers with `content`. Every turn is a fresh process, so the CLI holds no state: the transcript you render **is** the memory. ## The recipe [Section titled “The recipe”](#the-recipe) Two files per language: the recipe (copy it as-is) and a `main` that uses it. Pass the CLI command as arguments, for example: ```sh # Claude Code, lean: no settings, no MCP, no slash commands, no session files, no built-in tools claude -p --disable-slash-commands --strict-mcp-config --setting-sources "" --no-session-persistence --tools "" # Codex: no MCP servers, no AGENTS.md, nothing persisted, prompt read from stdin ("-") codex exec --skip-git-repo-check --ephemeral -c 'mcp_servers={}' -c project_doc_max_bytes=0 - ``` * JavaScript oneshot.mjs ```js import { spawnSync } from "node:child_process" import { createInProcessClient } from "toolnexus" // Render the whole request as one prompt: tools, transcript, and the reply contract. function render({ messages, tools = [] }) { const lines = ["You are the model inside a tool-calling loop. Tools you may call:"] for (const t of tools) lines.push(`- ${t.function.name}: ${t.function.description} args=${JSON.stringify(t.function.parameters)}`) lines.push("", "Conversation:") const called = {} // tool_call_id -> tool name, so results read "[tool add] 5" for (const m of messages) { if (m.role === "tool") lines.push(`[tool ${called[m.tool_call_id] ?? m.tool_call_id}] ${m.content}`) else if (m.tool_calls) { for (const c of m.tool_calls) called[c.id] = c.function.name lines.push(`[assistant called] ${JSON.stringify(m.tool_calls.map((c) => c.function))}`) } else lines.push(`[${m.role}] ${typeof m.content === "string" ? m.content : JSON.stringify(m.content)}`) } lines.push("", 'Reply with ONLY one JSON object: {"tool_calls":[{"name":"...","arguments":{...}}]} to call tools,', 'or {"content":"..."} with the final answer. No prose, no code fences.') return lines.join("\n") } // Pull the first {...} out of the reply, tolerating fences or chatter around it. function parse(out) { const json = out.slice(out.indexOf("{"), out.lastIndexOf("}") + 1) try { const r = JSON.parse(json) if (Array.isArray(r.tool_calls) && r.tool_calls.length) return { toolCalls: r.tool_calls.map((c) => ({ name: c.name, arguments: c.arguments ?? {} })) } if (typeof r.content === "string") return { content: r.content } } catch {} return { content: out.trim() } // not our contract: treat it as the answer } // One process per turn. The prompt goes in on stdin; the reply comes back on stdout. export function oneShotClient({ command, args = [], model = command, timeoutMs = 300_000 }) { return createInProcessClient({ model, generate: (req) => { const r = spawnSync(command, args, { input: render(req), encoding: "utf8", timeout: timeoutMs, maxBuffer: 64 << 20 }) if (r.status !== 0) throw new Error(`${command} exited ${r.status}: ${r.stderr || r.error}`) return parse(r.stdout) }, }) } ``` main.mjs ```js import { createToolkit, defineTool } from "toolnexus" import { oneShotClient } from "./oneshot.mjs" const add = defineTool({ name: "add", description: "Add two numbers.", inputSchema: { type: "object", properties: { a: { type: "number" }, b: { type: "number" } }, required: ["a", "b"] }, run: ({ a, b }) => `${a + b}` }) const tk = await createToolkit({ builtins: false, extraTools: [add] }) const [command, ...args] = process.argv.slice(2) const r = await oneShotClient({ command, args }).run("Use the add tool to add 19 and 23, then tell me the sum.", { toolkit: tk }) console.log("text:", JSON.stringify(r.text), "toolCalls:", JSON.stringify(r.toolCalls)) ``` * Python oneshot.py ```python import json import subprocess from toolnexus import create_in_process_client def render(req): """Render the whole request as one prompt: tools, transcript, and the reply contract.""" lines = ["You are the model inside a tool-calling loop. Tools you may call:"] for t in req.get("tools") or []: f = t["function"] lines.append(f"- {f['name']}: {f.get('description', '')} args={json.dumps(f.get('parameters', {}))}") lines += ["", "Conversation:"] called = {} # tool_call_id -> tool name, so results read "[tool add] 5" for m in req["messages"]: if m["role"] == "tool": lines.append(f"[tool {called.get(m.get('tool_call_id'), m.get('tool_call_id'))}] {m['content']}") elif m.get("tool_calls"): for c in m["tool_calls"]: called[c["id"]] = c["function"]["name"] lines.append(f"[assistant called] {json.dumps([c['function'] for c in m['tool_calls']])}") else: c = m.get("content") lines.append(f"[{m['role']}] {c if isinstance(c, str) else json.dumps(c)}") lines += ["", 'Reply with ONLY one JSON object: {"tool_calls":[{"name":"...","arguments":{...}}]} to call tools,', 'or {"content":"..."} with the final answer. No prose, no code fences.'] return "\n".join(lines) def parse(out): """Pull the first {...} out of the reply, tolerating fences or chatter around it.""" try: r = json.loads(out[out.index("{"): out.rindex("}") + 1]) if r.get("tool_calls"): return {"tool_calls": [{"name": c["name"], "arguments": c.get("arguments") or {}} for c in r["tool_calls"]]} if isinstance(r.get("content"), str): return {"content": r["content"]} except ValueError: pass return {"content": out.strip()} # not our contract: treat it as the answer def one_shot_client(command, *, model=None, timeout=300): """One process per turn. The prompt goes in on stdin; the reply comes back on stdout.""" def generate(req): # sync: toolnexus calls it on a worker thread r = subprocess.run(command, input=render(req), capture_output=True, text=True, timeout=timeout) if r.returncode != 0: raise RuntimeError(f"{command[0]} exited {r.returncode}: {r.stderr}") return parse(r.stdout) return create_in_process_client(model=model or command[0], generate=generate) ``` main.py ```python import asyncio, sys from toolnexus import create_toolkit, define_tool from oneshot import one_shot_client def add(a: float, b: float) -> str: """Add two numbers.""" return str(int(a + b)) async def main(): tk = await create_toolkit(builtins=False, extra_tools=[define_tool(add, name="add")]) r = await one_shot_client(sys.argv[1:]).run("Use the add tool to add 19 and 23, then tell me the sum.", tk) print("text:", repr(r.text), "calls:", r.tool_calls) asyncio.run(main()) ``` * Go The function is passed as `GenerateStream` only to get the run’s `ctx`, so cancelling the run kills the process; it emits nothing. oneshot.go ```go package main import ( "context" "encoding/json" "fmt" "os/exec" "strings" "time" tn "github.com/muthuishere/toolnexus/golang" ) // render turns the whole request into one prompt: tools, transcript, and the reply contract. func render(req tn.InProcessRequest) string { var b strings.Builder b.WriteString("You are the model inside a tool-calling loop. Tools you may call:\n") for _, t := range req.Tools { f, _ := t.(map[string]any)["function"].(map[string]any) params, _ := json.Marshal(f["parameters"]) fmt.Fprintf(&b, "- %v: %v args=%s\n", f["name"], f["description"], params) } b.WriteString("\nConversation:\n") called := map[string]any{} // tool_call_id -> tool name, so results read "[tool add] 5" for _, raw := range req.Messages { m, _ := raw.(map[string]any) calls, _ := m["tool_calls"].([]any) switch { case m["role"] == "tool": name := called[fmt.Sprint(m["tool_call_id"])] fmt.Fprintf(&b, "[tool %v] %v\n", name, m["content"]) case len(calls) > 0: fns := []any{} for _, c := range calls { cm, _ := c.(map[string]any) fn, _ := cm["function"].(map[string]any) called[fmt.Sprint(cm["id"])] = fn["name"] fns = append(fns, fn) } j, _ := json.Marshal(fns) fmt.Fprintf(&b, "[assistant called] %s\n", j) default: fmt.Fprintf(&b, "[%v] %v\n", m["role"], m["content"]) } } b.WriteString("\nReply with ONLY one JSON object: {\"tool_calls\":[{\"name\":\"...\",\"arguments\":{...}}]} to call tools,\n" + "or {\"content\":\"...\"} with the final answer. No prose, no code fences.\n") return b.String() } // parse pulls the first {...} out of the reply, tolerating fences or chatter around it. func parse(out string) tn.InProcessResponse { var r struct { ToolCalls []struct { Name string `json:"name"` Arguments map[string]any `json:"arguments"` } `json:"tool_calls"` Content *string `json:"content"` } i, j := strings.Index(out, "{"), strings.LastIndex(out, "}") if i >= 0 && j > i && json.Unmarshal([]byte(out[i:j+1]), &r) == nil { if len(r.ToolCalls) > 0 { calls := make([]tn.InProcessToolCall, 0, len(r.ToolCalls)) for _, c := range r.ToolCalls { calls = append(calls, tn.InProcessToolCall{Name: c.Name, Arguments: c.Arguments}) } return tn.InProcessResponse{ToolCalls: calls} } if r.Content != nil { return tn.InProcessResponse{Content: *r.Content} } } return tn.InProcessResponse{Content: strings.TrimSpace(out)} // not our contract: treat it as the answer } // OneShotClient runs one process per turn: prompt on stdin, reply on stdout. func OneShotClient(command []string, timeout time.Duration) *tn.Client { return tn.CreateInProcessClient(tn.InProcessOptions{ Model: command[0], GenerateStream: func(ctx context.Context, req tn.InProcessRequest, _ func(tn.StreamEvent)) (tn.InProcessResponse, error) { ctx, cancel := context.WithTimeout(ctx, timeout) defer cancel() cmd := exec.CommandContext(ctx, command[0], command[1:]...) cmd.Stdin = strings.NewReader(render(req)) var stderr strings.Builder cmd.Stderr = &stderr out, err := cmd.Output() if err != nil { return tn.InProcessResponse{}, fmt.Errorf("%s: %w: %s", command[0], err, stderr.String()) } return parse(string(out)), nil }, }) } ``` main.go ```go package main import ( "context" "fmt" "os" "strconv" "time" tn "github.com/muthuishere/toolnexus/golang" ) type addArgs struct { A float64 `json:"a"` B float64 `json:"b"` } func main() { ctx := context.Background() add := tn.NativeToolReflect("add", "Add two numbers.", func(_ context.Context, in addArgs) (string, error) { return strconv.FormatFloat(in.A+in.B, 'f', -1, 64), nil }) tk, err := tn.CreateToolkit(ctx, tn.Options{Builtins: false, ExtraTools: []tn.Tool{add}}) if err != nil { panic(err) } r, err := OneShotClient(os.Args[1:], 5*time.Minute).Run(ctx, "Use the add tool to add 19 and 23, then tell me the sum.", tk) if err != nil { panic(err) } fmt.Printf("text: %q calls: %+v\n", r.Text, r.ToolCalls) } ``` * Java OneShot.java ```java import io.github.muthuishere.toolnexus.InProcess; import io.github.muthuishere.toolnexus.LlmClient; import com.fasterxml.jackson.databind.ObjectMapper; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import java.io.UncheckedIOException; import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.concurrent.CompletableFuture; import java.util.concurrent.TimeUnit; /** One CLI process per turn: the prompt goes in on stdin; the reply comes back on stdout. */ @SuppressWarnings("unchecked") public final class OneShot { private static final ObjectMapper JSON = new ObjectMapper(); private static String json(Object v) { try { return JSON.writeValueAsString(v); } catch (IOException e) { throw new UncheckedIOException(e); } } // Render the whole request as one prompt: tools, transcript, and the reply contract. static String render(InProcess.Request req) { List lines = new ArrayList<>(List.of("You are the model inside a tool-calling loop. Tools you may call:")); for (Object o : req.tools) { Map f = (Map) ((Map) o).get("function"); lines.add("- " + f.get("name") + ": " + f.get("description") + " args=" + json(f.get("parameters"))); } lines.add(""); lines.add("Conversation:"); Map called = new HashMap<>(); // tool_call_id -> tool name, so results read "[tool add] 5" for (Object o : req.messages) { Map m = (Map) o; Object content = m.get("content"); if ("tool".equals(m.get("role"))) { lines.add("[tool " + called.getOrDefault(m.get("tool_call_id"), m.get("tool_call_id")) + "] " + content); } else if (m.get("tool_calls") != null) { List fns = new ArrayList<>(); for (Object c : (List) m.get("tool_calls")) { Map fn = (Map) ((Map) c).get("function"); called.put(((Map) c).get("id"), fn.get("name")); fns.add(fn); } lines.add("[assistant called] " + json(fns)); } else { lines.add("[" + m.get("role") + "] " + (content instanceof String s ? s : json(content))); } } lines.add(""); lines.add("Reply with ONLY one JSON object: {\"tool_calls\":[{\"name\":\"...\",\"arguments\":{...}}]} to call tools,"); lines.add("or {\"content\":\"...\"} with the final answer. No prose, no code fences."); return String.join("\n", lines); } // Pull the first {...} out of the reply, tolerating fences or chatter around it. static InProcess.Response parse(String out) { int start = out.indexOf('{'), end = out.lastIndexOf('}'); if (start >= 0 && end > start) { try { Map r = JSON.readValue(out.substring(start, end + 1), Map.class); if (r.get("tool_calls") instanceof List calls && !calls.isEmpty()) { List tcs = new ArrayList<>(); for (Object c : calls) { Map call = (Map) c; Object args = call.get("arguments"); tcs.add(new InProcess.ToolCall((String) call.get("name"), args == null ? Map.of() : args)); } return InProcess.Response.toolCalls(tcs.toArray(InProcess.ToolCall[]::new)); } if (r.get("content") instanceof String s) return InProcess.Response.content(s); } catch (IOException | ClassCastException ignored) { } } return InProcess.Response.content(out.strip()); // not our contract: treat it as the answer } public static LlmClient client(List command) { return client(command, command.get(0), 300_000); } public static LlmClient client(List command, String model, long timeoutMs) { return InProcess.createClient(new InProcess.Options().model(model).generate(req -> { try { Process p = new ProcessBuilder(command).start(); CompletableFuture out = CompletableFuture.supplyAsync(() -> readAll(p.getInputStream())); CompletableFuture err = CompletableFuture.supplyAsync(() -> readAll(p.getErrorStream())); try (OutputStream in = p.getOutputStream()) { in.write(render(req).getBytes(StandardCharsets.UTF_8)); } if (!p.waitFor(timeoutMs, TimeUnit.MILLISECONDS)) { p.destroyForcibly(); throw new IOException(command.get(0) + " timed out after " + timeoutMs + " ms"); } if (p.exitValue() != 0) throw new IOException(command.get(0) + " exited " + p.exitValue() + ": " + new String(err.join(), StandardCharsets.UTF_8)); return parse(new String(out.join(), StandardCharsets.UTF_8)); } catch (IOException e) { throw new UncheckedIOException(e); } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new RuntimeException(e); } })); } private static byte[] readAll(InputStream s) { try (s) { return s.readAllBytes(); } catch (IOException e) { return new byte[0]; } } } ``` Main.java ```java // run (Java 22+, multi-file source launcher): java -cp 'lib/*' Main.java python3 tools/mock-llm/fake_cli.py // (lib/ = toolnexus-0.27.0.jar + its runtime deps; OneShot.java sits next to this file) import io.github.muthuishere.toolnexus.LlmClient; import io.github.muthuishere.toolnexus.NativeTool; import io.github.muthuishere.toolnexus.Toolkit; import com.fasterxml.jackson.databind.ObjectMapper; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; public class Main { @SuppressWarnings("unchecked") public static void main(String[] argv) throws Exception { ObjectMapper json = new ObjectMapper(); // Parsed from JSON so key order (and so the rendered prompt) matches every other port. Map schema = json.readValue("{\"type\":\"object\",\"properties\":{\"a\":{\"type\":\"number\"}," + "\"b\":{\"type\":\"number\"}},\"required\":[\"a\",\"b\"]}", Map.class); NativeTool add = NativeTool.of("add", "Add two numbers.", schema, args -> String.valueOf(((Number) args.get("a")).intValue() + ((Number) args.get("b")).intValue())); try (Toolkit tk = Toolkit.create(new Toolkit.Options().builtins(false).extraTools(add))) { LlmClient.RunResult r = OneShot.client(List.of(argv)) .run("Use the add tool to add 19 and 23, then tell me the sum.", tk); List> calls = r.toolCalls.stream() .map(c -> { Map m = new LinkedHashMap<>(); m.put("name", c.name); m.put("args", c.args); m.put("output", c.output); m.put("isError", c.isError); return m; }).toList(); System.out.println("text: " + json.writeValueAsString(r.text) + " toolCalls: " + json.writeValueAsString(calls)); } } } ``` * C# OneShot.cs ```csharp using System.Diagnostics; using System.Text.Json; using System.Text.Json.Nodes; using Toolnexus; public static class OneShot { // JSON.stringify-style escaping, so the prompt text matches the other languages byte for byte. static readonly JsonSerializerOptions Js = new() { Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping }; // Render the whole request as one prompt: tools, transcript, and the reply contract. public static string Render(InProcess.Request req) { var lines = new List { "You are the model inside a tool-calling loop. Tools you may call:" }; foreach (var t in req.Tools.Select(o => JsonSerializer.SerializeToNode(o)!["function"]!)) lines.Add($"- {t["name"]}: {t["description"]} args={t["parameters"]?.ToJsonString(Js)}"); lines.AddRange(["", "Conversation:"]); var called = new Dictionary(); // tool_call_id -> tool name, so results read "[tool add] 5" foreach (var m in req.Messages.Select(o => JsonSerializer.SerializeToNode(o)!)) { var role = (string)m["role"]!; if (role == "tool") { var id = (string)m["tool_call_id"]!; lines.Add($"[tool {called.GetValueOrDefault(id, id)}] {m["content"]}"); } else if (m["tool_calls"] is JsonArray calls) { foreach (var c in calls) called[(string)c!["id"]!] = (string)c["function"]!["name"]!; lines.Add($"[assistant called] {new JsonArray(calls.Select(c => c!["function"]!.DeepClone()).ToArray()).ToJsonString(Js)}"); } else lines.Add($"[{role}] {(m["content"] is JsonValue v && v.TryGetValue(out var s) ? s : m["content"]?.ToJsonString(Js))}"); } lines.AddRange(["", """Reply with ONLY one JSON object: {"tool_calls":[{"name":"...","arguments":{...}}]} to call tools,""", """or {"content":"..."} with the final answer. No prose, no code fences."""]); return string.Join("\n", lines); } // Pull the first {...} out of the reply, tolerating fences or chatter around it. public static InProcess.Response Parse(string output) { try { int start = output.IndexOf('{'), end = output.LastIndexOf('}'); var r = JsonNode.Parse(output[start..(end + 1)])!; if (r["tool_calls"] is JsonArray { Count: > 0 } calls) return new InProcess.Response { ToolCalls = calls.Select(c => new InProcess.ToolCall { Name = (string)c!["name"]!, Arguments = c["arguments"]?.ToJsonString() ?? "{}" }).ToList() }; if (r["content"] is JsonValue v && v.TryGetValue(out var content)) return InProcess.Response.FromContent(content); } catch (Exception) { } return InProcess.Response.FromContent(output.Trim()); // not our contract: treat it as the answer } // One process per turn. The prompt goes in on stdin; the reply comes back on stdout. public static LlmClient Client(string command, IReadOnlyList args, string? model = null, int timeoutMs = 300_000) => InProcess.CreateClient(new InProcess.Options { Model = model ?? command, Generate = req => { var psi = new ProcessStartInfo(command) { RedirectStandardInput = true, RedirectStandardOutput = true, RedirectStandardError = true }; foreach (var a in args) psi.ArgumentList.Add(a); using var p = Process.Start(psi)!; var stdout = p.StandardOutput.ReadToEndAsync(); var stderr = p.StandardError.ReadToEndAsync(); p.StandardInput.Write(Render(req)); p.StandardInput.Close(); if (!p.WaitForExit(timeoutMs)) { p.Kill(true); throw new TimeoutException($"{command} timed out"); } if (p.ExitCode != 0) throw new InvalidOperationException($"{command} exited {p.ExitCode}: {stderr.Result}"); return Parse(stdout.Result); }, }); } ``` Program.cs ```csharp // usage: dotnet run -- (dotnet add package Toolnexus; OneShot.cs alongside) using System.Text.Json; using Toolnexus; var add = NativeTool.Of("add", "Add two numbers.", new Dictionary { ["type"] = "object", ["properties"] = new Dictionary { ["a"] = new { type = "number" }, ["b"] = new { type = "number" } }, ["required"] = new[] { "a", "b" }, }, args => $"{Convert.ToDouble(args["a"]?.ToString()) + Convert.ToDouble(args["b"]?.ToString())}"); await using var tk = await Toolkit.CreateAsync(new Toolkit.Options { Builtins = false, ExtraTools = [add] }); var r = await OneShot.Client(args[0], args[1..]).RunAsync("Use the add tool to add 19 and 23, then tell me the sum.", tk); Console.WriteLine($"text: {JsonSerializer.Serialize(r.Text)} toolCalls: {JsonSerializer.Serialize(r.ToolCalls.Select(c => new { name = c.Name, args = c.Args, output = c.Output }))}"); ``` * Elixir `System.cmd` cannot feed stdin, so the prompt goes through a temp file and `sh -c '… < file'`. There is no per-turn timeout here; wrap the call in a `Task` with a timeout if you need one. oneshot.exs ```elixir defmodule OneShot do @moduledoc "One CLI process per turn: the prompt goes in on stdin, the reply comes back on stdout." # Render the whole request as one prompt: tools, transcript, and the reply contract. def render(%{messages: messages} = req) do tools = for t <- req.tools || [], f = t["function"], do: "- #{f["name"]}: #{f["description"]} args=#{JSON.encode!(f["parameters"])}" {convo, _called} = Enum.flat_map_reduce(messages, %{}, fn m, called -> cond do m["role"] == "tool" -> {["[tool #{called[m["tool_call_id"]] || m["tool_call_id"]}] #{m["content"]}"], called} m["tool_calls"] -> called = Enum.reduce(m["tool_calls"], called, &Map.put(&2, &1["id"], &1["function"]["name"])) {["[assistant called] " <> JSON.encode!(Enum.map(m["tool_calls"], & &1["function"]))], called} true -> c = if is_binary(m["content"]), do: m["content"], else: JSON.encode!(m["content"]) {["[#{m["role"]}] #{c}"], called} end end) Enum.join( ["You are the model inside a tool-calling loop. Tools you may call:"] ++ tools ++ ["", "Conversation:"] ++ convo ++ ["", ~s(Reply with ONLY one JSON object: {"tool_calls":[{"name":"...","arguments":{...}}]} to call tools,), ~s(or {"content":"..."} with the final answer. No prose, no code fences.)], "\n") end # Pull the outermost {...} out of the reply, tolerating fences or chatter around it. def parse(out) do json = case {:binary.match(out, "{"), :binary.matches(out, "}") |> List.last()} do {{s, _}, {e, _}} when e > s -> binary_part(out, s, e - s + 1) _ -> "" end case JSON.decode(json) do {:ok, %{"tool_calls" => [_ | _] = calls}} -> %{tool_calls: Enum.map(calls, &%{name: &1["name"], arguments: &1["arguments"] || %{}})} {:ok, %{"content" => c}} when is_binary(c) -> %{content: c} _ -> %{content: String.trim(out)} # not our contract: treat it as the answer end end # System.cmd cannot feed stdin, and a Port cannot half-close it to send EOF, so the prompt # goes through a temp file that `sh` redirects into the command's stdin. def client(command, args, model \\ nil) do Toolnexus.Client.create_in_process( model: model || command, generate: fn req -> path = Path.join(System.tmp_dir!(), "oneshot-#{System.unique_integer([:positive])}.txt") File.write!(path, render(req)) try do case System.cmd("sh", ["-c", ~s(f="$1"; shift; exec "$@" < "$f"), "sh", path, command | args]) do {out, 0} -> parse(out) {out, code} -> raise "#{command} exited #{code}: #{out}" end after File.rm(path) end end ) end end ``` main.exs ```elixir # usage: elixir main.exs [args...] (oneshot.exs sits next to this file) Mix.install([{:toolnexus, "~> 0.27"}]) Code.require_file("oneshot.exs", __DIR__) add = Toolnexus.define_tool( name: "add", description: "Add two numbers.", input_schema: %{"type" => "object", "properties" => %{"a" => %{"type" => "number"}, "b" => %{"type" => "number"}}, "required" => ["a", "b"]}, execute: fn %{"a" => a, "b" => b} -> "#{a + b}" end ) tk = Toolnexus.create_toolkit!(builtins: false, extra_tools: [add]) [command | args] = System.argv() r = Toolnexus.Client.run(OneShot.client(command, args), "Use the add tool to add 19 and 23, then tell me the sum.", tk) IO.puts("text: #{JSON.encode!(r.text)} toolCalls: #{JSON.encode!(r.tool_calls)}") ``` * Clojure koine’s JSON writer sorts object keys, so the rendered tool schemas differ in key order from the other ports; the prompt’s wording is the same. oneshot.clj ```clojure ;; One CLI process per turn, as an in-process client. Loaded by main.clj. ;; JSON + subprocess come from koine, which toolnexus already depends on (no extra deps). (ns oneshot (:require [clojure.string :as str] [koine.json :as json] [koine.process :as proc] [toolnexus.client :as client])) ;; Render the whole request as one prompt: tools, transcript, and the reply contract. (defn- render [{:keys [messages tools]}] (let [called (atom {}) ; tool_call_id -> tool name, so results read "[tool add] 5" lines (transient ["You are the model inside a tool-calling loop. Tools you may call:"])] (doseq [{f :function} tools] (conj! lines (str "- " (:name f) ": " (:description f) " args=" (json/write-str (:parameters f))))) (conj! lines "") (conj! lines "Conversation:") (doseq [m messages] (cond (= "tool" (:role m)) (conj! lines (str "[tool " (get @called (:tool_call_id m) (:tool_call_id m)) "] " (:content m))) (seq (:tool_calls m)) (do (doseq [c (:tool_calls m)] (swap! called assoc (:id c) (get-in c [:function :name]))) (conj! lines (str "[assistant called] " (json/write-str (mapv :function (:tool_calls m)))))) :else (conj! lines (str "[" (:role m) "] " (if (string? (:content m)) (:content m) (json/write-str (:content m))))))) (conj! lines "") (conj! lines "Reply with ONLY one JSON object: {\"tool_calls\":[{\"name\":\"...\",\"arguments\":{...}}]} to call tools,") (conj! lines "or {\"content\":\"...\"} with the final answer. No prose, no code fences.") (str/join "\n" (persistent! lines)))) ;; Pull the outermost {...} out of the reply, tolerating fences or chatter around it. (defn- parse [out] (or (try (let [r (json/read-str (subs out (str/index-of out "{") (inc (str/last-index-of out "}"))))] (cond (and (sequential? (:tool_calls r)) (seq (:tool_calls r))) {:tool-calls (mapv (fn [c] {:name (:name c) :arguments (or (:arguments c) {})}) (:tool_calls r))} (string? (:content r)) {:content (:content r)})) (catch Throwable _ nil)) {:content (str/trim out)})) ; not our contract: treat it as the answer ;; One process per turn. The prompt goes in on stdin; the reply comes back on stdout. (defn one-shot-client [{:keys [command model timeout-ms] :or {timeout-ms 300000}}] (client/create-in-process-client {:model (or model (first command)) :generate (fn [req] (let [r (proc/sh (vec command) {:in (render req) :timeout-ms timeout-ms})] (when-not (= 0 (:exit r)) (throw (ex-info (str (first command) " exited " (:exit r) ": " (:err r)) {:result r}))) (parse (:out r))))})) ``` main.clj ```clojure ;; deps.edn: {:paths ["."] :deps {net.clojars.muthuishere/toolnexus {:mvn/version "0.27.0"}}} ;; run: clojure -M -m main claude -p (any CLI that reads a prompt on stdin) (ns main (:require [koine.json :as json] [oneshot :refer [one-shot-client]] [toolnexus.client :as client] [toolnexus.core :as tn] [toolnexus.native :as native])) (def add (native/native-tool {:name "add" :description "Add two numbers." :input-schema {:type "object" :properties {:a {:type "number"} :b {:type "number"}} :required ["a" "b"]} :run (fn [{:keys [a b]}] (str (+ a b)))})) (defn -main [& command] (let [tk (tn/build {:builtins false :tools [add]}) r (client/run (one-shot-client {:command command}) "Use the add tool to add 19 and 23, then tell me the sum." {:toolkit tk})] (println "text:" (json/write-str (:text r)) "toolCalls:" (json/write-str (:tool-calls r))) (shutdown-agents))) ``` * Rust oneshot.rs ```rust // One CLI process per turn: the whole request goes in on stdin, the reply comes back on stdout. use std::collections::HashMap; use std::io::Write; use std::process::{Command, Stdio}; use std::sync::Arc; use serde_json::{Value, json}; use toolnexus::client::{ Client, InProcessOptions, InProcessRequest, InProcessResponse, InProcessToolCall, create_in_process_client, }; // Render the whole request as one prompt: tools, transcript, and the reply contract. fn render(req: &InProcessRequest) -> String { let mut lines = vec!["You are the model inside a tool-calling loop. Tools you may call:".to_string()]; for t in &req.tools { let f = &t["function"]; lines.push(format!("- {}: {} args={}", f["name"].as_str().unwrap_or_default(), f["description"].as_str().unwrap_or_default(), f["parameters"])); } lines.extend(["".into(), "Conversation:".into()]); let mut called: HashMap = HashMap::new(); // tool_call_id -> tool name for m in &req.messages { if m["role"] == "tool" { let id = m["tool_call_id"].as_str().unwrap_or_default(); let name = called.get(id).map_or(id, String::as_str); lines.push(format!("[tool {name}] {}", text(&m["content"]))); } else if let Some(calls) = m["tool_calls"].as_array() { for c in calls { called.insert(text(&c["id"]), text(&c["function"]["name"])); } let fns: Vec<&Value> = calls.iter().map(|c| &c["function"]).collect(); lines.push(format!("[assistant called] {}", json!(fns))); } else { lines.push(format!("[{}] {}", text(&m["role"]), text(&m["content"]))); } } lines.extend(["".into(), r#"Reply with ONLY one JSON object: {"tool_calls":[{"name":"...","arguments":{...}}]} to call tools,"#.into(), r#"or {"content":"..."} with the final answer. No prose, no code fences."#.into()]); lines.join("\n") } fn text(v: &Value) -> String { v.as_str().map_or_else(|| v.to_string(), String::from) } // Pull the outermost {...} out of the reply, tolerating fences or chatter around it. fn parse(out: &str) -> InProcessResponse { let json = match (out.find('{'), out.rfind('}')) { (Some(a), Some(b)) if b > a => &out[a..=b], _ => "", }; if let Ok(r) = serde_json::from_str::(json) { if let Some(calls) = r["tool_calls"].as_array().filter(|c| !c.is_empty()) { let tool_calls = calls.iter().map(|c| InProcessToolCall { id: String::new(), name: text(&c["name"]), arguments: if c["arguments"].is_null() { json!({}) } else { c["arguments"].clone() }, }).collect(); return InProcessResponse { tool_calls, ..Default::default() }; } if let Some(content) = r["content"].as_str() { return InProcessResponse { content: content.into(), ..Default::default() }; } } InProcessResponse { content: out.trim().into(), ..Default::default() } // not our contract: it is the answer } pub fn one_shot_client(command: String, args: Vec) -> Client { let model = command.clone(); let generate = Arc::new(move |req: InProcessRequest| { let (command, args) = (command.clone(), args.clone()); Box::pin(async move { // spawn_blocking: the child process blocks; keep the async runtime free. tokio::task::spawn_blocking(move || { let mut child = Command::new(&command).args(&args) .stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped()) .spawn().map_err(|e| format!("{command}: {e}"))?; child.stdin.take().ok_or("no stdin")?.write_all(render(&req).as_bytes()).map_err(|e| e.to_string())?; let out = child.wait_with_output().map_err(|e| e.to_string())?; if !out.status.success() { return Err(format!("{command} exited {}: {}", out.status, String::from_utf8_lossy(&out.stderr))); } Ok(parse(&String::from_utf8_lossy(&out.stdout))) }) .await .map_err(|e| e.to_string())? }) as _ }); create_in_process_client(InProcessOptions { model, generate: Some(generate), ..Default::default() }) } ``` main.rs ```rust // Cargo.toml: // toolnexus = "0.25" // tokio = { version = "1", features = ["full"] } // serde_json = "1" // run: cargo run -- python3 tools/mock-llm/fake_cli.py (any CLI that reads stdin and prints a reply) mod oneshot; use serde_json::json; use toolnexus::{BuiltinsConfig, ToolkitOptions, create_toolkit, define_tool}; #[tokio::main] async fn main() -> Result<(), Box> { let schema = json!({"type": "object", "properties": {"a": {"type": "number"}, "b": {"type": "number"}}, "required": ["a", "b"]}); let add = define_tool("add", "Add two numbers.", Some(schema), |a, _ctx| async move { Ok::<_, String>((a["a"].as_f64().unwrap_or(0.0) + a["b"].as_f64().unwrap_or(0.0)).to_string()) }); let tk = create_toolkit(ToolkitOptions { builtins: Some(BuiltinsConfig::off()), extra_tools: vec![add], ..Default::default() }).await?; let argv: Vec = std::env::args().skip(1).collect(); let (command, args) = argv.split_first().ok_or("usage: main [args...]")?; let r = oneshot::one_shot_client(command.clone(), args.to_vec()) .run("Use the add tool to add 19 and 23, then tell me the sum.", &tk).await?; println!("text: {} toolCalls: {}", json!(r.text), json!(r.tool_calls)); Ok(()) } ``` ## What was verified [Section titled “What was verified”](#what-was-verified) | | how | | --------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | all eight recipes | compiled and run on toolnexus 0.25.0 against a fake CLI ([`fake_cli.py`](https://github.com/muthuishere/toolnexus/blob/main/tools/mock-llm/fake_cli.py): replies with a `tool_calls` object, then with `content` once it sees `[tool add] …` in the prompt). Each made one `add(19, 23)` call and answered “The sum is 42.” The Java prompt was diffed against the JavaScript one: identical | | Go, live (0.25.0) | `claude -p` with the lean flags above: called `add(19, 23)` through toolnexus, answered “The sum of 19 and 23 is 42.” | | JavaScript, Python, live (0.24.0) | `claude -p` with the lean flags above (Claude Code 2.1.318), same call and answer in \~5 s; JavaScript also with `codex exec` (codex-cli 0.160.0): “The sum is 42.” in \~15 s | ## Per-CLI notes [Section titled “Per-CLI notes”](#per-cli-notes) | CLI (verified on) | command | flags and what they turn off | what still loads | context per trivial turn | | ----------------------------- | ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- | | **Claude Code** (2.1.318) | `claude -p` (prompt on stdin, answer on stdout) | `--disable-slash-commands` (no skills / slash commands), `--strict-mcp-config` (only MCP from `--mcp-config`, here none), `--setting-sources ""` (no user / project / local settings, hooks, plugins), `--no-session-persistence` (nothing written to disk), `--tools ""` (no built-in tools; toolnexus runs yours) | Claude Code’s base system prompt; subscription login (keychain) | **36,535** default → **21,076** with the first four flags → **2,959** adding `--tools ""` | | **Codex** (codex-cli 0.160.0) | `codex exec … -` (prompt on stdin) | `--skip-git-repo-check` (run outside a repo), `--ephemeral` (no session files), `-c mcp_servers={}`, `-c project_doc_max_bytes=0` (no AGENTS.md); also available: `--disable apps --disable plugins --disable hooks`, `-c skills.include_instructions=false`, `-m ` | codex’s base prompt and its built-in tools (no verified knob removes them); its progress goes to stderr, only the final message to stdout | 26,905 → 17,204 (codex-acp 0.16.0 research, same knobs) | Any other CLI fits as long as it reads a prompt and prints an answer. If it only takes the prompt as an argument, pass `render(req)` as the last argument instead of stdin (mind the OS argument length limit for long transcripts). It is a prompt contract, not a protocol The CLI model is asked to reply in JSON; nothing forces it to. `parse` falls back to treating the reply as the final answer, which ends the run instead of looping forever. If a model keeps answering in prose, tighten the contract text, or ask the CLI for structured output if it has a flag for it (Claude Code: `--output-format json` wraps the reply, it does not constrain it).