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