Skip to content

Recipe — one CLI process per turn

  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.

Two files per language: the recipe (copy it as-is) and a main that uses it. Pass the CLI command as arguments, for example:

Terminal window
# 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 -
oneshot.mjs
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
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))
how
all eight recipes compiled and run on toolnexus 0.25.0 against a fake CLI (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
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 <model> 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).