spikes/cli-client/ (Go; one-shot path live-verified against 5 CLIs, ACP-with-toolkit path
against 6 agents; see SPIKE.md and acp-results.json)Many users already pay for a coding-agent subscription: Claude Code, Codex (ChatGPT), GitHub Copilot, opencode, Devin, Gemini CLI, goose. Each ships a CLI with a one-shot mode that reads one prompt and prints one answer. The owner wants those subscriptions usable as the model behind a normal toolnexus client, with real tool calling, in every port, with the common case being one line of configuration.
toolnexus already has the seam: an in-process client (§8 "In-process models") takes a generate
function and gives the full client — loop, hooks, memory, suspension, metrics, scheduling. What is
missing is a generate that runs a CLI once, and a way to get tool calls out of a CLI that has no
tool-call channel.
createCliClient({ style, command, model?, …every in-process client option }) (idiomatic per port, e.g. Go CreateCliClient(CliOptions{Style: CliStyles.Claude, Command: "/usr/local/bin/claude", Model: "sonnet"})). It returns an ordinary in-process client. style is a real enum, never a string
(owner 2026-11-04: "no string"): spelled CliStyles.Claude, CliStyles.Codex, … in every port (Go: a CliStyles struct of typed CliStyle values), then TS/Java/C#/Rust
enum CliStyles, Python Enum, Elixir/Clojure a closed set of atoms/keywords checked at construction.
Members: Claude, Codex, Copilot, Opencode, Devin, Gemini, Goose, Custom. The lowercase name is
used only internally to find the shared style file; no API accepts a style as free text.claude, codex, copilot,
opencode, devin, gemini, goose) in one shared directory, embedded byte-identically by
every port. A style holds no binary: it is the argv template, the prompt mode
(stdin | arg | file), the answer source (stdout | out file), the model flag as an
optional group, and a verified note (which CLI version's --help it was checked against, or
UNVERIFIED).command is required (path or name). Nothing is embedded or defaulted: the library never
decides which executable runs. A style describes a CLI family's shape, so style: "claude"
drives any claude-compatible binary at any path, and style: "codex" any codex-compatible one.
A missing command fails with cli: Command is required (path or name of the CLI binary).
(Owner, 2026-11-04: "we dont want the cli to be embedded let them give manually".)
Why: toolnexus then never ships, picks or launches a vendor's product by itself. The user
chooses and runs their own installed CLI under their own subscription, which keeps us out of
vendor terms disputes and lets anyone (a vendor, a fork, a wrapper) bring a compatible CLI.style: "custom" with an explicit args template, prompt and answer;
or a user style file via loadCliStyle(path | json). A missing style fails at construction.{{prompt}}, {{file}} (temp file holding the prompt),
{{out}} (temp file the CLI writes its answer to), {{model}}. An element may be an optional
group (array), kept only when a model is set. The child is spawned with the argv directly,
never through a shell. With prompt mode stdin the prompt is written to stdin; otherwise stdin
is empty and closed at once.PREAMBLE + "\nREQUEST:\n" + {"messages", "tools"}: the OpenAI-shaped request, unmodified, under the seven-line preamble
designed for ACP tool calling (change add-acp-tool-calling, PR #114). The reply is parsed by
the same tolerant six-step parser (fences, prose around JSON, choices[0].message and
message wrappers, string or object arguments; anything that is not an envelope passes
through as content). Tool calls go back to the toolnexus loop, which executes them, so hooks,
MCP, skills, builtins, suspension and tool scheduling all apply. The CLI never runs our tools.messages entry inside the envelope,
not as a CLI flag. Memory (ask), max turns, hooks, metrics, waitFor, tool tiers and tool
scheduling work unchanged. usage is zero (a CLI reports none), and that is documented.model fills {{model}}. A model set on a style without {{model}} is a
construction error (cli: style "goose" takes no model); {{model}} outside an optional group
with no model set is also an error.thought event). The one-shot CLI transport stays one chunk for every style — the envelope is
content or tool calls, known only once the whole reply has parsed.cli: command not found: <bin>, cli: timed out after <ms>ms (default 300000 ms — owner 2026-11-04; the whole process group is killed), cli: exit <code>: <stderr, capped at 1000 code points>, cli: no output file, cli: empty reply. Failures are
final — no retries — as for every in-process model. Temp files are removed on every path.
env values expand ${VAR} and are never logged. The library never reads, stores or proxies
a CLI's credentials: the CLI uses its own login.The first draft rejected ACP. The second spike reverses that: when an agent speaks ACP, the user's
own toolkit (MCP servers from mcp.json, skills, defineTool functions) runs inside the agent
as ordinary MCP tools, with the agent's native tool calling instead of our JSON envelope. Live:
claude, copilot, devin, gemini and opencode passed all three checks (MCP, skill, native) over ACP;
codex passed through the one-shot fallback.
CliStyles.X selects ACP when the style declares an ACP launch (acp
argv, e.g. ["acp"], ["--acp"], or an adapter command) and the given binary supports
it; otherwise the one-shot CLI client above is used. The same CreateCliClient(CliOptions{Style: CliStyles.X, Command: …}) call covers both; the user never picks a transport by string.
A style without an ACP launch (codex today: its codex-acp adapter cannot reach current
account models) is CLI-only.Toolkit.Serve's MCP
profile (SPEC §7C) on 127.0.0.1:<ephemeral>/mcp and listed in session/new mcpServers:
agentCapabilities.mcpCapabilities.http →
{"type":"http","name":"toolnexus","url":"http://127.0.0.1:<port>/mcp","headers":[…]};{"name":"toolnexus","command":<bridge>,"args":[…,"<url>"],"env":[]}, a stdio
⇄ HTTP bridge (no type field);mcpServers
(devin: .mcp.json in a fresh temp cwd). The server, the bridge and any temp dir are closed
with the session. The served endpoint gets a per-session bearer token (spike gap: none yet).toolCallId against the tool our server is about to run, not by title text (titles
differ per agent and devin sends none).OnCall), not in the client loop. The one-shot
fallback keeps the full loop (decision 6).golang/acp.go session/new sends "mcpServers": [] today; wiring decision 12 into the
library is the first Go task.--tools "", codex
--sandbox read-only / ACP mode read-only); where none exists, the permission hook rejects
the agent's own tools. Styles record which applies.UNVERIFIED when not runnable on the verifying machine.
(gemini was since verified over ACP with --acp --skip-trust; goose remains unverified.)provider/model; over ACP the id goes to session/set_config_option (configId: "model"),
falling back to session/set_model.CliStyles.X, never a string; Command is required; there is no default
binary. (Restated from decisions 1 and 3.)The standing rule (ADR 0021 era, memory "scope: tool-calling, not vendor clients") was that no
vendor name appears in the public API; ACP presets and CreateDevinClient were rejected on that
ground. On 2026-11-04 the owner revised it for CLI styles only: style names such as "claude"
or "codex" are allowed as data identifiers passed to the one generic constructor, and the
style files ship inside the library. The rule still holds everywhere else: there is no per-vendor
function, type, option or code path, and adding a CLI is adding a JSON file.
PR #114 ("ACP agents as real tool-calling models (7 ports) + session delta (Go only)") carries two OpenSpec changes. They are judged separately.
Evidence.
add-acp-tool-calling makes the generic ACP model source
(LoadACP / createAcpClient) tool-calling by sending tool schemas in our JSON envelope and
keeping session/new mcpServers: []; the toolnexus loop runs the tools. This change serves the
toolkit as MCP into session/new and the agent calls the tools natively. Shipping both gives an
ACP agent two ways to call the same tools.SPEC.md §8 preamble block is byte-identical to
conformance/cli-styles/preamble.txt, and its six-step reply parser is the parser of decision 6.spikes/cli-client/acp-results.json, golang/examples/cliclient).CONFLICTING, 116 commits behind main, predates the Rust port, and
flips LoadACP's permission default to reject-first; this change decides permissions per
toolCallId instead.Decision.
add-acp-tool-calling as an ACP feature. Tool calling over ACP has one way:
the toolkit served as MCP (decisions 11–14). The envelope is not added to the generic ACP
model source; it lives only in the one-shot CLI transport, where a CLI has no other channel.SUPERSEDES-ALL-PRIOR tail: a one-shot CLI has no prior
session to supersede.add-acp-session-delta (Go only). It is about how much
of the conversation each turn re-sends into a warm session, which this change's ACP path still
does in full. It should be rebased onto this change after it merges, applied to the ACP model
source that both paths share, without its mcpServers: [] assumption.add-acp-session-delta.custom escape hatch.arg-mode prompt is one argv element, capped at 128 KiB on Linux (MAX_ARG_STRLEN). Styles
prefer stdin or file where the CLI supports it.Can you improve this documentation?Edit on GitHub
cljdoc builds & hosts documentation for Clojure/Script libraries
| Ctrl+k | Jump to recent docs |
| ← | Move to previous article |
| → | Move to next article |
| Ctrl+/ | Jump to the search field |