acpx FAQ
Drive coding agents headlessly through acpx (>= 0.15.1).
Command syntax: acpx --skill show acpx - the binary ships its own reference and is the
tiebreaker on syntax. That doc is strong on command shape and silent on failure semantics: it
has no exit-code table, no status state table, and does not mention --mcp-config at all.
This file is the other half.
Read the Invariants, then your agent's section in Per agent - each is a complete launch-to-result recipe. After that: Sessions, Completion, MCP, Limits, Failures.
Invariants
- Global flags precede the agent subcommand.
--cwd,--model,--approve-all,--agent,--format,--timeout,--ttl,--mcp-configare all global. The subcommand accepts only-s,--no-wait,-f(andexecalso takes--config-option). Putting a global flag after the agent exits 2 witherror: unknown option '--cwd'. -s <name>never creates a session. It resolves one, walking from--cwdup to the git root. No match exits 4. Create first withsessions ensure --name <n>.statusis not a turn signal. It reports the queue-owner process, not the turn, and keeps sayingrunningafter the turn has ended. Never gate automation on it.- Permission flags only gate requests the adapter chooses to raise. The claude adapter
spawns its binary with
--allow-dangerously-skip-permissions --setting-sources=project,local, so it never raises an ACP permission request and there is nothing for acpx to deny; codex writes through its terminal capability.--deny-allis not ignored, it is unreachable. Isolation comes from the--cwdyou hand it, never from a flag. [done] end_turnand exit 0 are not proof of success. A content-filter kill, an MCP load failure, or a truncated turn all end that way. Read the stream, or require the agent to write a result file you can check.
Per agent
Each section is self-contained: flags, launch, and the quirks that bite while it runs.
agy / Antigravity - the --agent escape hatch
acpx has no Antigravity adapter and never will get one. Issue #362 was closed as
externally blocked: Antigravity ships no supported ACP stdio mode. The built-in gemini agent
is the public Gemini CLI, a different product, and it is dead for Code Assist:
[error] RUNTIME: This client is no longer supported for Gemini Code Assist for individuals.
To continue using Gemini, please migrate to the Antigravity suite of products
The only route is Google's own signed ACP server, reached through the raw-command escape
hatch. What the .par is: installed by Antigravity under
~/.local/lib/antigravity-acp/ and sha512-verified against Google's release manifest. Neither
acpx nor this skill downloads it - if it is missing, install Antigravity. It is a readable zip
of a Google-built Python runtime, pinned at build agy_acp_server_20260818_01_RC01 because it
self-updates nothing. localharness_external ships beside it and must stay executable, or the
server starts anyway and only logs Localharness not found.
# macOS: --agent may point straight at the .par
D=/abs/real/dir # must exist: roots resolve through realpath
acpx --agent ~/.local/lib/antigravity-acp/agy_acp_server.par \
--cwd "$D" --model gemini-3.7-flash-medium --timeout 1800 \
exec 'Carry out $D/brief.md. Write your report to $D/report.md.'
On Linux, --agent points at a wrapper, not at the .par - the server needs --uid=
there (every ACP registry entry passes it for linux-*, none for darwin-arm64), and on NixOS
a CA bundle too. See Linux / NixOS below.
Default to
gemini-3.7-flash-medium. Measured on par with Opus for rubric-driven bulk work (99.5% verdict agreement across 213 items, and the better call on the one they disputed) and far faster;gemini-3.8-flash-highscored measurably worse on the same task despite being the bigger, higher-effort model. Pick another id only when a task argues for it.Effort lives in the model id -
gemini-3.7-flash-{low,medium,high},gemini-3.8-flash-high. This server exposes no separate effort option.Auth is a settings file, not an env var.
ACPX_AUTH_OAUTH_PERSONAL=1is dead in build20260818_01_RC01- the.parcarries the string "Environment-based auth selection has been removed." With no auth type configured the server does not error, it hangs onauthenticateforever, which reads exactly like a network stall. Write the type first:mkdir -p ~/.gemini/antigravity-acp echo '{"auth":{"type":"oauth-personal"}}' > ~/.gemini/antigravity-acp/settings.jsonTypes:
oauth-personal(the subscription path, the one you want),gemini-api-key,agent-platform. Then sign in once; the token persists and later runs need nothing.The OAuth browser opens silently; the URL is never printed. Capture it with a fake
xdg-open(oropen) early onPATHthat echoes its argument. The callback port is per-run: on a headless box,curl '<callback-url>'the redirect your browser could not deliver. A session created before auth finished stays broken - start a fresh one.Never pass a positional agent with
--agent- exit 2,Do not combine positional agent with --agent override.This lane is subscription-backed. Leave the auth alone. On the CLI side (
~/.gemini/settings.json, a different file from the ACP server's above),security.auth.selectedType: "oauth-personal"is the Google subscription path and the intended default; there is no tier setting, andagyhas no auth subcommand at all. If entitlement ever looks wrong it is a server-side lookup with no client-side lever - do not "fix" it by switching togemini-api-keyor a GCP project, which are separate billing arrangements rather than fixes.Ask an agy verification prompt for a bare reply, and read the whole log. agy will route an answer into a brain artifact file instead of the stream, so a
tailshows you anAbsolutePathand nothing else and you cannot tell "no tools" from "answered elsewhere". Add "Do not create any files" and capture the full output.agy hides MCP failures inside a successful-looking turn. A failed server appears as inline text -
MCP load failed for <name>: ... expect initialized request, but received: ... "server/discover"- in a stream that still ends[done] end_turnwith exit 0. Grep the output forMCP load failed, do not trust the exit code.The credential split is total, not just "two onboardings". The server reads
~/.gemini/antigravity-acp/acp_token.jsonand never looks at the CLI's~/.gemini/antigravity-cli/antigravity-oauth-token, so a fully authenticatedagydoes nothing for the ACP lane. Its own docstrings give the layout:<GEMINI_HOME>/antigravity-acp/holdssettings.json,acp_token.json,conversations/andbrain/, while<GEMINI_HOME>/antigravity-cli/skills/is shared across surfaces. SettingGEMINI_HOMErelocates the whole tree - that is how you run a second isolated lane.
Linux / NixOS
Three differences from macOS, all silent.
--uid=is required, so--agentpoints at a wrapper rather than the.par(~/.local/bin/agy-acp-server):#!/usr/bin/env bash export SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt # NixOS; see 2 exec ~/.local/lib/antigravity-acp/agy_acp_server.par --uid= "$@"Without that
SSL_CERT_FILE, NixOS gets a 502. The.parembeds a Google-built Python and OpenSSL whose compiled-in CA paths do not exist there, so every handshake withcloudcode-pa.googleapis.comfails. Its local proxy (ccpa_connection/proxy_server.py) swallows that and reports502 Bad Gateway: Failed to connect to backend API, and the turn still ends[done] end_turnwith exit 0 - invariant 5 with a concrete signature. The realCERTIFICATE_VERIFY_FAILEDappears only under--alsologtostderr.Install
agyby hand, not by piping the installer to a shell. Its last step runsagy install, which appends PATH blocks to~/.bashrcand~/.profile- useless when~/.local/binis already on PATH via home-manager, and broken when~/.zshrcis a read-only nix-store symlink. Both binaries link against/lib64/ld-linux-x86-64.so.2, which resolves through nix-ld; no patchelf needed.
Working invocation:
acpx --agent ~/.local/bin/agy-acp-server \
--cwd "$D" --model gemini-3.7-flash-medium \
--approve-all --timeout 1800 exec '<prompt>'
codex
D=/abs/dir
acpx --cwd "$D" codex sessions ensure --name work # idempotent; -s cannot create
acpx --cwd "$D" codex set model gpt-5.6-sol -s work # -> model set: gpt-5.6-sol
acpx --cwd "$D" codex set reasoning_effort high -s work # -> config set: ... (5 options)
acpx --cwd "$D" --approve-all --timeout 5400 --ttl 0 --format quiet --suppress-reads \
codex -s work 'Carry out ./brief.md. Write your report to ./report.md.' > run.log 2>&1 &
For a one-shot, skip the session entirely - exec --config-option sets model and effort
inline (0.14.0+), applied after --model and before the prompt:
acpx --cwd "$D" --timeout 1800 codex exec --config-option reasoning_effort=low 'Summarize ./DESIGN.md'
- The adapter floats.
@agentclientprotocol/codex-acp@^1.1.5is a caret on a 1.x, so it tracks the latest 1.x on everynpxresolution. A codex-side change can land without you upgrading acpx. - OpenAI's content filter kills benign turns and the turn ends looking clean
(
[done] end_turn, exit 0). Vocabulary like race / sweep / exploit / attack in a filename, comment or prompt triggers it. Read the transcript tail for the flag line before believing completion. Recover by re-prompting the same-s <name>(context survives): rename the artifact neutrally and list the remaining steps explicitly. - Queue a follow-up onto a live session by prompting the same name again - it runs after the current turn rather than interrupting. This is how you course-correct a running executor without relaunching it.
- Headful takeover:
sessions show <n>prints asessionIdthat is the ordinary codex rollout id, socodex resume <sessionId>opens the same thread in a TUI with model and effort intact. Close first -sessions close <n>- or you getalready has an active writer (code -32600). codex queue --threaddoes not reach an acpx-driven session (see What acpx cannot do).
claude
D=/abs/dir
acpx --cwd "$D" --model claude-opus-5 claude sessions ensure -s work
acpx --cwd "$D" --model claude-opus-5 --approve-all --suppress-reads --timeout 2400 \
claude -s work -f /abs/brief.md >> run.log 2>&1 &
- No effort knob.
set reasoning_effortreturnsInternal error. Only the model id. --modelandset modelvalidate differently, and this surprises people. The adapter advertises["default","opus[1m]","claude-fable-5[1m]","sonnet","haiku"], andset modelrefuses anything outside that list:Invalid value for config option model: claude-opus-5 (ACP -32603, adapter reported "Internal error"). The global--modelat session creation passes the id through to the harness instead, so--model claude-opus-5works fine. A genuinely unknown id still fails loudly:RUNTIME: Internal error: There's an issue with the selected model (<id>).- Fable runs:
--model 'claude-fable-5[1m]'(quote it - the brackets are shell globs). - User-scope skills are excluded on purpose. The adapter loads project and local settings
but not user settings, so a user-level skill or slash command comes back
Unknown command: /polish. Fix withACPX_CLAUDE_INCLUDE_USER_SETTINGS=1- and settings bind at session creation, so an existing session must be recreated, not re-prompted. - Second account: export
CLAUDE_CONFIG_DIRandCLAUDE_SECURESTORAGE_CONFIG_DIRbeforeacpx; the child inherits them. That is the whole mechanism for driving another lane. - The adapter is pinned far behind your CLI.
claude-agent-acp@^0.60.0resolves to exactly 0.60.0, which pins@anthropic-ai/claude-agent-sdk@0.3.215, which bundles claude 2.1.215- regardless of the version on your PATH. Consequences: the bundled binary predates the
cross-session messaging socket, so these sessions are unreachable by peer messaging, and any
newer harness feature is simply absent. Override with
--agentpointed at a newer adapter build if you need one.
- regardless of the version on your PATH. Consequences: the bundled binary predates the
cross-session messaging socket, so these sessions are unreachable by peer messaging, and any
newer harness feature is simply absent. Override with
- A session-creation stall is a known adapter combination bug; acpx's own error text
recommends
--approve-allwithnonInteractivePermissions=deny, upgrading both sides, or falling back toclaude execas a one-shot.
Sessions
A session is keyed on (agent command, absolute cwd, optional name). Because cwd is part
of the key, parallel executors each need their own directory - give every one its own git
worktree and their per-session model and effort settings cannot race.
| verb | behavior |
|---|---|
sessions ensure --name <n> |
returns the existing session or creates one - idempotent, safe before every prompt |
sessions new --name <n> |
soft-closes any current session and creates a fresh one ((replaced <id>)) |
a bare prompt with -s <n> |
never auto-creates; exits 4 with Create one: ... |
sessions show <n> |
lastActivity, lastPrompt, historyEntries, sessionId, closed |
sessions history <n> --limit N |
the actual turn content |
sessions close <n> |
releases the agent; required before codex resume and before sessions export |
sessions list --local |
local records including closed ones |
sessions prune |
deletes closed records - they persist indefinitely otherwise |
Resolution without -s walks from --cwd up to the git root. A session whose cwd you have
since deleted becomes an unreachable registry row: sessions close cannot target it without
its cwd, so prune it.
Completion
Never poll status. It is a local kill(pid,0)-style check on the queue owner and never
touches the agent. Its states are running, idle, dead, no-session, where dead means
the owner is gone or the last exit was abnormal. A turn that finished seconds ago still reads
running, because the owner survives for its idle TTL (default 300s, --ttl <seconds>,
--ttl 0 to keep it forever). A loop waiting for idle will spin past real completion and
time out.
What is actually correct:
- the foreground stream's terminating
[done] end_turn, plus the process exit code; sessions show <n>(lastActivity,historyEntries) andsessions history <n>;- best of all, a result file the brief required the agent to write - poll for the file.
--no-wait returns as soon as the queue owner acknowledges the submission - [queued] <id> in well under a second, even on an idle session. It is not delivery and not completion:
if a turn is already running, the prompt waits for the turn boundary. Background a normal
blocking prompt when you want the transcript; use --no-wait only to enqueue.
Supervising a backgrounded run: count tool lines and check mtime
(rg -c '^\[tool\]' run.log, stat -f '%Sm' run.log) rather than tailing the whole log, and
check liveness with pgrep -fl 'acpx|codex-acp|claude-agent-acp'.
Ctrl+C (and the cancel subcommand) sends ACP session/cancel first and force-kills only
if the agent does not stop in time.
MCP
--mcp-config <path> replaces the project/global mcpServers for that invocation;
relative paths inside resolve from --cwd.
The file shape is not the one every other tool uses. acpx wants a JSON array of named server objects. Handing it the standard object-keyed map throws an uncaught Node exception with a stack trace, not a clean CLI error:
Error: Invalid mcpServers in /path/to/config.json: expected array
at parseMcpServers (.../acpx/dist/cli.js:1393)
// WRONG - the Claude Code / standard shape
{ "mcpServers": { "example": { "command": "example", "args": ["mcp"] } } }
// RIGHT - an array, each entry carrying its own name
{ "mcpServers": [ { "name": "example", "type": "stdio", "command": "example", "args": ["mcp"] } ] }
stdio is the normal case
Most MCP servers are stdio, and acpx passes them straight through - one entry, nothing to run:
{ "mcpServers": [ { "name": "example", "type": "stdio", "command": "example", "args": ["mcp"] } ] }
If a stdio server dies only under agy, it is an MCP era mismatch, not an acpx fault. Since
MCP 2026-07-28 (SEP-2575) the initialize/initialized handshake is retired, and a dual-era
client is told to probe stdio with server/discover first and fall back to initialize when
the answer is not a modern one. A conforming legacy server replies with an error and the
fallback happens; one that aborts on the unexpected request kills the pipe instead, leaving
the client nothing to fall back from:
MCP load failed for <name>: Error: failed to start stdio MCP server
Caused by: expect initialized request, but received: ... method: "server/discover"
: connection closed: calling "initialize": client is closing: EOF
agy probes; the codex and claude adapters do not, which is the only reason the same config works for them. The fix belongs in the server (answer an error rather than exiting, or move it to a current MCP SDK). Reaching it over HTTP also sidesteps it, since there the probe is just one failed request rather than a dead process.
That turn still exits 0 and ends [done] end_turn - the failure is inline text, not an
error. Grep for MCP load failed before trusting any run that used MCP.
Two more traps:
- A live queue owner refuses a config change. The owner carries the config path and a
SHA-256 fingerprint; switching MCP config on a persistent session requires
sessions closefirst. --mcp-configreplaces the whole set, so a config written for one adapter is not automatically right for another. Verify with a one-shotexecthat asks the agent to name its own tools before dispatching real work.
${...} expansion inside an MCP config is evaluated in the launcher's environment,
because the acpx child inherits it - so a variable like a session id resolves to your id,
not the child's. Never let an identity flow in through the environment.
Limits
- Incoming ACP messages are capped at 64 MiB since 0.15.1 (previously unlimited). Raise
with
ACPX_MAX_ACP_MESSAGE_BYTES, or0to disable. A previously-working large-payload script can start failing here. - Terminal output retention is 64 KiB per call;
ACPX_TERMINAL_MAX_OUTPUT_BYTESadjusts it. --timeout <seconds>is the wall clock for the whole prompt. Long unattended work wants--timeout 5400 --ttl 0; a wrapping shelltimeoutis a reasonable belt-and-braces.- Output:
--format quietfor many backgrounded executors,textfor one you are watching,json(with--json-strict) when a script parses it.--suppress-readskeeps read-file contents out of the log and is worth setting on every long run. - Pass long briefs with
-f <path>or a path in the prompt, not inlined text: it keeps the content out of the shell command, where a driving harness's classifier may block on it.
What acpx cannot do
Structural, not bugs. Do not design around them.
- An ACP session does not wake. An externally injected message lands in the transcript but
starts no turn, because under ACP the turn loop belongs to the ACP client. The only inbound
channel to an acpx session is acpx itself (
-s <name>, optionally--no-wait). codex queue --threaddoes not reach an acpx-driven codex session, though it does wake a plain interactive codex. The ACP wrapper is not in the app-server's context.- acpx-spawned Claude sessions bind no IPC socket (
entrypoint: sdk-cli, no key file), soListAgents/SendMessagecannot see or reach them. - acpx cannot attach to a session it did not start. It owns the process it drives; a human-started headful session is out of reach.
- Permission flags are not a sandbox. See invariant 4: the claude adapter already passed
--allow-dangerously-skip-permissionsto its binary, so no permission request ever reaches acpx's policy layer. Verify it yourself withps -Ao args | grep claude-agent-sdkduring a turn.--no-terminalgenuinely removes the terminal capability (an agent that calls it gets a hard error), but for filesystem safety the only real control is which--cwdyou hand it.
Failures
Exit codes, which the shipped doc does not list:
| code | meaning |
|---|---|
| 0 | success (also cancel with nothing to cancel) |
| 1 | agent / protocol / runtime error - the catch-all |
| 2 | usage error: bad or conflicting flags, malformed --agent |
| 3 | --timeout exceeded |
| 4 | no session found by the directory walk |
| 5 | every permission request denied or cancelled, none approved |
| 130 | interrupted (cooperative cancel first) |
⚠ No acpx session found (searched up to <cwd>). (exit 4) - invariant 2. Emitted by
set, by -s prompts, by anything that resolves a session. Fix:
sessions ensure --name <n> first. Confirm --cwd is the directory you think it is.
error: unknown option '--cwd' (exit 2) - a global flag placed after the agent
subcommand. Move it before.
Do not combine positional agent with --agent override (exit 2) - drop the positional
claude/codex when --agent supplies the adapter.
Invalid mcpServers in <path>: expected array (uncaught, stack trace) - the MCP config is
the standard object map; convert it to an array. See MCP.
Invalid value for config option model: <id> (ACP -32603, adapter reported "Internal error")
set modelwith an id outside the adapter's advertised list. Use--modelat session creation, or pick an advertised id from--format json ... status.
RUNTIME: Internal error: There's an issue with the selected model (<id>) - the harness
itself rejects the id. This is a real typo or an entitlement problem, not the previous case.
Internal error from claude set reasoning_effort - there is no effort knob on the
claude adapter. Expected; ignore.
[error] RUNTIME: Authentication required - the agy ACP server has never been
authenticated. Write auth.type into ~/.gemini/antigravity-acp/settings.json, then complete
its own sign-in; the CLI's token does not count. See agy.
502 Bad Gateway: Failed to connect to backend API (agy ACP, turn still ends
[done] end_turn exit 0) - the server's local proxy could not reach
cloudcode-pa.googleapis.com. On NixOS this is a missing SSL_CERT_FILE, not a network or
model problem; confirm with --alsologtostderr and look for CERTIFICATE_VERIFY_FAILED.
authenticate never returns (agy ACP, no error, no URL) - no auth.type in
~/.gemini/antigravity-acp/settings.json. ACPX_AUTH_OAUTH_PERSONAL=1 no longer selects
anything in this build.
RUNTIME: This client is no longer supported for Gemini Code Assist for individuals - you
used the built-in gemini agent. It is the public Gemini CLI, not Antigravity; use --agent
with the .par server.
already has an active writer (code -32600) - codex resume while acpx still owns the
thread. sessions close <n> first.
Missing --skill action. (exit 1) - --skill needs show, list, install, export
or help.
error: unknown option '--one-shot' (exit 2, usage on stderr) - not a verb; exec is
the one-shot form.
Timeouts (exit 3) mean the wall clock ran out, not that the agent is stuck. Check
sessions show for lastActivity before assuming failure, and remember an adapter-side
timeout surfaces as exit 1 instead.
Silent failures worth an explicit check: a content-filter kill and an MCP load failure both
end [done] end_turn with exit 0; a --no-wait prompt that "sent" may still be queued behind
a running turn; status reporting running long after completion; and a session record whose
cwd no longer exists lingering until pruned.