Backends
Avenor talks to agents through backends. Each backend speaks a different protocol to a different runtime — OpenCode, Claude, Codex, Gemini, Cursor, Pi, agy, or the in-process Pony test backend. Pick the one that matches what's running locally and what you need from the orchestration layer.
Backend selection
Pass --backend to choose which runtime protocol to use. The default is opencode-acp:
avenor --backend opencode-http --server-url http://127.0.0.1:4096 --prompt "say hi"
avenor --backend opencode-acp --prompt "say hi"
avenor --backend codex-app-server --prompt "say hi"
avenor --backend agy --model gemini-2.5-flash --prompt "say hi"
avenor --backend gemini-acp --prompt "say hi"
avenor --backend cursor-acp --prompt "say hi"
avenor --backend pi --model anthropic/claude-sonnet-4-5 --prompt "say hi"
avenor --backend claude --prompt "say hi"claude requires claude on PATH, and tmux on PATH only when AVENOR_CLAUDE_TERMINAL=tmux.
Launch wrapper
Spawns accept an optional launch wrapper — an opaque argv prefix placed in front of the backend process command at launch:
avenor control --socket /tmp/avenor.sock spawn \
--prompt "find bugs" \
--launch-wrapper nice --launch-wrapper -n --launch-wrapper 5The wrapper applies to the backend process only. Version probes (claude --version, pi --help, agy --version) and helper processes (tmux, the shell tool, lsof) are never wrapped. Roster entries do not carry wrappers — they select backend/agent/model identity only, so the wrapper is a per-spawn parameter. Inside a loop or team, every phase inherits the parent run's wrapper; phases cannot override it.
The recorded PID for a wrapped backend is the wrapper's PID: the wrapper is the direct child of the launcher and owns the backend process tree.
Ownership is recorded per session: resuming with a different wrapper is rejected exactly like a backend, agent, or model mismatch. An omitted wrapper on resume inherits the session's authoritative wrapper.
The launch wrapper does not change the control socket's trust boundary: a caller that can present the owner capability could already choose backend commands. The wrapper is an additional capability for trusted callers, not a security feature.
MCP surfaces: operator allowlist
The launch wrapper on MCP-driven spawns (spawn tool) is disabled by default and gated by an operator allowlist. Start the supervisor with:
avenor stable --control-socket /tmp/avenor.sock --mcp-launch-wrapper-policy /etc/avenor/launch-wrapper-policy.jsonThe policy file is strict JSON:
{"allowed_argv": [["/usr/bin/nice", "-n", "5"]]}Unknown fields, trailing data, an empty allowlist, empty entries, empty elements, NUL bytes, and non-absolute executables are rejected at supervisor startup (mcp launch wrapper policy: <reason>). An MCP spawn carrying a wrapper must exactly match one allowed_argv entry — same executable (absolute path), same flags, same order, no extra or missing arguments. A wrapper that is not allowed is rejected with launch_wrapper: argv is not allowed by this supervisor before any process is created; a supervisor with no policy rejects every wrapped MCP spawn with launch_wrapper: disabled for MCP on this supervisor. Every MCP spawn goes through the gated spawn_mcp verb; the spawn tool has no route to the plain spawn verb, so omitting a field cannot skip the gate. MCP servers fail closed against older supervisors that predate spawn_mcp, wrapped or not.
What spawn_mcp gates:
- A caller-chosen launch.
spawn_mcprefuses a spawn whose effective launch the caller chose — a selected roster entry that declarescommand,argsorsettings, or a resume whose recorded session launch is not the one its backend resolves for itself — withroster entry "<name>": launch fields are disabled for MCP spawns (<field>), before any reservation, idempotency record or process. An entry that declares no launch field still spawns over MCP with the identity its backend resolves, so a roster of plain backend/agent/model entries keeps working. The gate resolves the roster once and the spawn it hands the request to starts with that same identity, so rewriting the roster between the check and the exec cannot change the launch. - Workflow mode.
loop_fileorteam_fileset is refused withspawn_mcp: loop_file and team_file are not accepted; workflow spawns use the spawn verb. A workflow template names the roster its phases select, and on this verb both paths come from the caller. Workflow spawns keep running through the trustedspawnverb. - The launch wrapper, as described above.
The message tool's resume is the other MCP path into a spawn, and its cold arm is gated the same way: continuing a run whose launch is not the backend's own default is refused, because the resume re-execs the run's inherited launch. A resume on the default launch continues.
The launch fields are not allowlist-gated: there is no MCP equivalent of allowed_argv for them, so the refusal is unconditional.
The policy path is picked up in this order, for both avenor stable and avenor mcp:
--mcp-launch-wrapper-policy <path>(wins),$AVENOR_MCP_LAUNCH_WRAPPER_POLICY,<config home>/launch-wrapper-policy.json— the shared avenor config home ($XDG_CONFIG_HOME/avenor, else~/.config/avenor), the same root the host-owned trusted allowlists use,- none of the above: the policy is disabled.
A path named by the flag or the environment must exist and load; a missing or malformed file is an error. The well-known file is optional — absent means disabled — but a malformed one is an error too, never a silent fallback to disabled. Creating that file is the durable, machine-local way to enable the allowlist, so a plugin's versioned .mcp.json never has to carry a machine-specific argv.
Approved wrappers are trusted code with supervisor privileges: a wrapper process receives the backend environment, including provider API keys and per-run broker credentials. The allowlist is an exposure control on which wrappers MCP clients may invoke — it is not sandboxing, and approving a wrapper accepts that residual risk.
The avenor mcp command accepts --mcp-launch-wrapper-policy only to forward the policy to a supervisor it autostarts; it resolves the same flag → environment → <config home>/launch-wrapper-policy.json chain and forwards the path it picked. Attaching to an already-running supervisor never changes that supervisor's policy — that supervisor's own pickup chain decided it.
The CLI (avenor control spawn) and ordinary control-socket spawn calls are operator-trusted surfaces: they are not allowlist-gated, they keep accepting a roster entry's launch fields, and they keep the pre-existing behavior above.
agy RPC host
The launch wrapper is not supported for the agy RPC transport (PTY RPC host). A spawn that combines a wrapper with the RPC host path fails explicitly with launch_wrapper: not supported for agy rpc host.
The wrapper is documented, not enforced. Whatever argv you supply must satisfy these requirements — avenor does not check them:
- exec semantics — stdio passes through (Avenor speaks the backend protocol over pipes), the backend's exit code must propagate as the wrapper's exit code, signals sent to the wrapper must reach the backend, and child processes must be torn down when the backend dies.
- Same-path mounts —
--dirand the paths Avenor injects (config files, event/sentinel files, permission-handler directories) must resolve identically inside the wrapper. - MCP-server reachability — stdio MCP servers launched by the backend inherit the wrapper's environment automatically; socket or HTTP MCP servers must be exposed by the wrapper (port forwarding, network namespace sharing, or equivalent).
Workflow templates declare their own named launch_wrappers registry — a trusted, author-reviewed set of executable configurations that run, loop, and team nodes select by ID. That is distinct from the per-spawn backend wrapper above and from the ad-hoc MCP allowlist. See Launch wrappers.
Launch identity
A backend that starts a harness process exports Avenor's launch identity in that process's environment. A local MCP server started by the backend can read it. The variable table, omission rules, attempt numbering and join recipe are in Event stream → Launch identity.
| Backend | Harness child receives identity |
|---|---|
pi | ✓ RPC subprocess |
opencode-acp, gemini-acp, cursor-acp | ✓ ACP subprocess |
codex-app-server | ✓ app-server subprocess |
poo | ✓ stdio subprocess |
agy | ✓ headless print process, and RPC host when the RPC transport is active |
claude | ✓ PTY or tmux pane |
opencode-http | ✗ no child process |
pony | ✗ in-process |
opencode-httpattaches to an externalopencode serveinstance. Avenor starts no backend process, so it cannot inject a per-session environment. MCP servers loaded by that server do not see the identity. A successful attach also leaves the Avenor process environment unchanged.ponyruns in-process. It has no harness child.- With the tmux launcher, Avenor removes inherited identity and exports the launch values inside the pane command, except the values a launch provisions for a broker peer: those go into a mode-0600 file in a mode-0700 directory the launch owns, which the pane command sources (
unset …; . <file>; exec claude) and which the launcher removes when the session is torn down. Atmux new-sessioncommand string is argv, readable by any local process throughpsor/proc/<pid>/cmdline, so no provisioned value may appear in it; the file's path may. The tmux server environment keeps its values.
Each spawning backend removes inherited identity before it adds the current launch values, and the same scrub removes the broker peer credentials (AVENOR_BROKER_URL, AVENOR_RUN_ID, AVENOR_BROKER_TOKEN) and the retired AVENOR_SESSION_NAME. A backend therefore cannot pass a launcher's peer credential down by forgetting a call: the strip list is runtime.ScrubbedEnvNames, applied by runtime.LaunchEnv, and the terminal launchers unset the same list in the pane command. Other variables keep their values, including AVENOR_TREE_BUDGET and backend selectors such as AVENOR_CLAUDE_TERMINAL and AVENOR_AGY_TRANSPORT.
pi also removes inherited PI_AGENT and PI_AGENT_PROFILE. It then adds the agent, the agent profile and the broker credentials of the current launch only. The pi agent process receives a broker value only when its own launch supplies all three, so an inherited or half-supplied credential never reaches it. The short pi --help capability probe runs with the inherited environment.
A claude child gets a one-shot attach credential in AVENOR_BROKER_TOKEN instead of the run's own: its plugin mods layer trades it at the broker's POST /attach for the run credential at session start, and the broker spends it there, so the run has one inbound consumer. A pi child gets the run's credential, because its extension authenticates each request itself. runtime.ProvisionPeer is the one place that decides whether a launch provisions a peer at all, and it appends the provisioned values after the shared scrub; no backend keeps an inherited copy.
A prompt to an already-running process cannot change that process's environment. A fresh start or resume that starts a process receives the identity of that request. A backend that reuses a live process keeps the identity of the original launch. The agy print continuation inside one attempt keeps that attempt's identity.
A launch wrapper receives the environment and passes it to the backend process. A wrapper that drops, filters or rewrites variables hides the identity from the backend's children. Avenor does not verify wrapper behavior.
Avenor's identity variables are informational and forgeable. They are never credentials and never authorization inputs. The broker variables are separate: AVENOR_RUN_ID is broker run identity, not the containing execution, and the credential a launch provisions (AVENOR_BROKER_TOKEN) is a capability — the run's token for pi, a one-shot attach credential for claude — which is never rendered into an argv.
A nested loop or team inside one CLI run shares the CLI run ID. AVENOR_KIND names the inner runner. Repeated nested phase names with the same iteration produce identical tuples, so the join is ambiguous. Avenor does not generate a scope ID.
Capability matrix
| Capability | opencode-acp | opencode-http | codex-app-server | agy | gemini-acp | cursor-acp | pi | claude | pony | poo |
|---|---|---|---|---|---|---|---|---|---|---|
| New sessions | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
| Session resume | ✓ | ✓ | ✓ | ✓ | — | — | ✓ | ⚠ | ✗ | ✓ |
| Prompt execution | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
| Cancel | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
| Event streaming | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
| Permission relay | ✓ | ✗ | ✓ | ⚠ | ✓ | ✓ | ✓ | ⚠ | ⚠ | ✓ |
| Model selection | ✓ | ✓ | ✓ | ✓ | ✗ | ✗ | ✓ | ✓ | ✓ | ✓ |
| Thinking control | ✗ | ✗ | ✓ | ✗ | ✗ | ✗ | ✓ | ⚠ | ✗ | ✗ |
| External server URL | ✗ | ✓ | ✗ | ✗ | ✗ | ✗ | ✗ | ✗ | ✓ | ✗ |
| Subprocess discovery | ✓ | ✗ | ✗ | ✗ | ✗ | ✗ | ✗ | ✓ | ✗ | ✗ |
— means not verified; ✗ means not supported, ⚠ means experimental. agy permission relay requires the explicit RPC transport.
Thinking control is native for Codex and Pi. Claude supports five startup values but no explicit resume value. Every other backend rejects explicit thinking. See Thinking level for values, version scope, validation order, and resume behavior.
Session resume for claude is in-memory only: a long-lived avenor process (stable mode) keeps sessions in its map and lets clients reattach by session_id while the session is alive. Cross-restart resume (avenor process exits, then a new one tries to pick up the same session) is not yet supported by this backend.
poo
poo drives a poo binary over its public stdio protocol. Each avenor session owns one poo process: the provider spawns the resolved binary, performs poo/initialize with a protocol-version check, opens the session with session/create or continues one with session/load, routes its events and tears the process down.
A poo roster entry is the only place command, args and settings are accepted:
{
"umpire-reviewer": {
"backend": "poo",
"command": "/opt/umpire-bot/bin/umpire-poo",
"args": ["serve"],
"settings": "/etc/umpire-bot/poo-settings.yaml",
"agent": "search"
}
}command is used as given and recorded as an absolute path; a relative command resolves against Avenor's working directory, not the roster file's directory. Without command Avenor runs poo from PATH. args defaults to ["serve"]. The launch argv is <bin> <args...> --settings <path>, with --settings only when the entry names a settings file. The resolved binary, args and settings path are part of the session's ownership record, so a resume that resolves differently is rejected before a provider exists. A backend that does not own these fields rejects them. A poo entry cannot carry thinking; the shared thinking policy refuses it like any backend without thinking support.
Messaging
Poo messaging requires poo v0.2.0 or later. The messaging spawn option enables the inbox feed, the avenor_message CLI entry and the child's broker credentials together. It defaults on for a delegation spawn and off for a workflow node, loop or team phase, and permission-decider run. An explicit value overrides the default, except that loop and team phases reject true.
The manifest binds the entry's message argument to stdin with --message-stdin (poo #112): a present message — even an explicitly empty one — rides stdin and never appears in argv, while an absent message emits no flag, so message-less actions such as withdraw are unaffected. This keeps the text out of process arguments and listings, not out of the tool call, the transcript or any other surface that shows the arguments. A poo binary without stdin delivery cannot load the binding.
Avenor checks for call_cli in the session's tools and waits up to 10 seconds for an authenticated /poll-control from the child's run ID after the spawn began. That poll proves the feed started, reached the broker and authenticated; it does not prove delivery into the session. An end-to-end test checks delivery. If messaging cannot be enabled, a default-on spawn is relaunched once with messaging off and a warning; an explicit messaging: true fails instead. The warning is an avenor.error event with source: "warning", so callers can see it on the event channel they already watch without a new event kind. A read-only profile cannot offer call_cli and follows the same policy. A resume keeps the effective choice; a session created with messaging on may not resume through avenor run --resume, where the default is off, because its recorded tools no longer match.
An inbox message can wake a parked poo session. While the supervisor is parked it retains the prompted turn's status, result and sentinel; events from the unattended run are not replayed into a later resumed turn, except permission requests and their answers. The session transcript still contains that run. A permission ask from a run Avenor did not prompt can be answered on the next turn, but poo refuses the answer if the ask has already timed out; avenor#574 tracks delivery without a turn.
At launch Avenor publishes rendered manifests under <state home>/poo/manifests/<digest>/. It prunes sibling digest directories older than seven days, never the directory being published. Each launch, including resume, publishes its own manifests again.
The broker token grants messaging as the child run, not supervisor authority. Avenor declares the broker URL, run ID and token through POO_CREDENTIAL_ENV. A poo binary that includes poo#103 keeps them for the inbox feed and avenor_message bindings, but removes them from a model-issued bash command's environment. Poo v0.2.0 predates this protection: it can run messaging, but its bash commands inherit the token.
This is environment scrubbing, not process isolation. A registered CLI explicitly bound to a credential can receive it; with poo's local executor, a command can also read poo's environment through procfs. Adding the token to tools.env.scrub removes it from the feed as well, so the feed fails to start. Claude children instead get a one-shot attach credential.
A poo run is a blocking prompt over a run that starts in the background. Prompt sends run/prompt, then waits on run/inspect{wait: {until: "settled"}} and re-asks when a wait runs out. A prompt that arrives while a run is active is sent as run/message{mode: "steer"} for that run instead of starting a second one, and a busy refusal from run/prompt falls back the same way. While the session is alive, Cancel sends run/cancel with the run id the prompt started or the session reports. Incoming events are routed by the notification's own session_id; events that arrive before the session/create response are buffered until the caller subscribes, so nothing is dropped.
poo's settle reason is classified into avenor's closed stop-reason set, and the exit code follows from the avenor reason:
| poo settle reason | avenor stop_reason | exit |
|---|---|---|
end_turn, finished, stop_requested | end_turn | 0 |
output_truncated | max_tokens | 3 |
max_turns | max_turn_requests | 4 |
cancelled | cancelled | 130 |
provider_limit with detail.provider.kind: rate_limited | rate_limited (plus retry_after) | 7 |
provider_limit with usage_limit or plan_inactive | quota_exhausted | 6 |
provider_limit with any other kind | generic failure, the kind in error_message | 1 |
any other reason, including provider_error, context_exhausted and invalid_turns | generic failure, the reason in classified_from | 1 |
A denial is not a failed run on this backend: poo hands the denied call back to the model as a tool result and the run continues, so the backend declares through runtime.DenialRecovery that a clean end_turn after a denied tool call is a completion. Backends that do not declare it keep the tool_denied reclassification.
A permission ask is relayed as avenor's permission.request. poo asks only a client that declared permission_answers, so the handshake declares it; each permission/ask request becomes one event whose request_id is poo's ask id, whose tool, question and description come from the ask's tool and summary, and whose options offer allow and reject. AnswerPermission replies to the pending request with poo's own {decision: allow|deny, message} result, poo hands the decision to the model as the call's outcome, and the run continues either way: a refused call reaches the model as a failed tool result carrying the resolver's message, not a failed run. The message is sent as the resolver wrote it; poo bounds what it shows the model.
An answer for an ask that is not pending returns an error and writes nothing: never asked, already answered, or already resolved by poo's own chain timeout. poo's ask budget and the backend's own view of pending asks are separate, so an answer that lands exactly as poo times the ask out is written and then ignored by poo. The backend does not predict that boundary. A run never waits on an unanswered ask forever: when poo's chain budget (permission.chain_timeout_s) runs out, the configured fallback decides and the run continues. poo's own permission.asked and permission.answered records are delivered as avenor.permission.asked and avenor.permission.answered, the prefix every backend-specific record passes through.
Resume continues a recorded session in a fresh process. The session's recorded launch starts that process, and the session id alone finds the records: poo's session/load reads them from the settings file's session directory, so nothing per-session is stored for a resume. The load resolves the run the dead process was in as interrupted, and the restored transcript is what the next prompt sends the model.
A resume of a session with no records fails as session not found: <id>: an ephemeral session keeps none, and an id this poo never wrote to its session directory is unknown. A resume whose settings no longer fit the session fails as an unfitting session, carrying poo's own code in the message — unknown_model when the recorded model is no longer named in models, unknown_profile, or session_incompatible when the recorded tools changed. The session is never continued under another model or profile. Either failure reaps the process the attempt started, so a refused resume leaves nothing running.
Prompt returns nil for every settle that is not a failure, like pi: end_turn, max_tokens, max_turn_requests, rate_limited and quota_exhausted all confirm the terminal event rather than reporting an error. Only cancelled and a generic failure (error) return one; the terminal event carries the reason and the exit code either way.
Teardown sends session/close, closes stdin, and gives the process 30 s to exit before SIGTERM and another 5 s before SIGKILL.
claude
Channel-less provider for Claude Code. Starts Claude in a terminal session (PTY by default), injects prompts via PTY paste, and reads progress from the JSONL transcript file that Claude writes to ~/.claude/projects/. No broker, no sidecar, no .mcp.json injection.
Launcher
Default launcher is PTY. Set AVENOR_CLAUDE_TERMINAL=tmux to use tmux instead.
# PTY (default)
avenor --backend claude --prompt "say hi"
# tmux
AVENOR_CLAUDE_TERMINAL=tmux avenor --backend claude --prompt "say hi"AVENOR_CLAUDE_TERMINAL selects the launcher; claude defaults to PTY.
Status reports (OSC 7501)
On the PTY launcher Avenor answers Claude Code's OSC 7501 (Program Status Protocol) detection query, so Claude reports its own state: idle, working, blocked (on a permission, question, or authentication), and done. Without the answer Claude reports nothing, and Avenor ignores any report that arrives before it answered. After the first report Avenor uses the reports alongside the pane and transcript:
- Readiness. The prompt waits for the input box and for Claude's root
idle(startup) ordone(after a completed turn) report. If no detection query arrives within about 2s of the input box appearing, or neither report within about 3s, the input box alone decides.agent.prompt_submittedcarriesready_signal(osc7501orpane). - MCP startup wait. Claude Code connects MCP servers, including claude.ai connectors, in the background after the input box is ready, so a first prompt sent on readiness can start its turn without their tools.
--claude-mcp-wait <duration>(at most60s) holds the first prompt until that long after launch; time spent reaching readiness counts toward it, later prompts never wait, and the wait runs before the prompt is armed so startup reports are not attributed to the turn. It is off by default because Claude Code reports no MCP-settled signal and claude.ai connectors come from an account API fetch, so Avenor cannot tell locally that no server is configured and a default wait would delay every run. Direct runs only: roster entries do not carry it yet. - Status. Each transition emits one
agent.statuswithsignal=osc7501(see Events); the 500ms pane heartbeat and the transcriptworkingevents stop. Aworkingreport also ends the Enter resend. - Permissions. A blocked-on-permission report brokers the dialog as soon as two consecutive screen reads show it fully painted, without waiting for the next pane scan. Each dialog is brokered once: after it is answered, the screen still shows it until Claude moves on, and it is not brokered again while Claude reports the same blocked state. A blocked report with a new message is a new dialog. The pending request survives a pane scan that shows the input box while Claude still reports blocked.
- Completion. The transcript
end_turnstill ends the turn. While Claude reports the turn working, anend_turn(from a background subagent) is held until Claude reportsdone,idle,clearorerror, the terminal resets, or reports are lost. A laterworkingreport before anydoneholds it again. A hold lasts at most 10 minutes. - Cancel. Claude reporting
idleafter the turn started working counts as a completed cancel, alongside the pane going idle.
Reports are untrusted: they never approve anything, and report text is display-only. Set AVENOR_CLAUDE_OSC7501=off (or 0, false) to leave the query unanswered; the session then behaves exactly as with scraping alone (ready_signal=pane, no signal=osc7501 events). The tmux launcher never answers the query and keeps scraping. The MCP startup wait applies to both launchers.
Requirements
- Claude Code installed and on
PATH. tmuxonPATHonly whenAVENOR_CLAUDE_TERMINAL=tmuxis set.
Capabilities
| Capability | Supported |
|---|---|
| New sessions | ✓ |
| Session resume | ⚠ (in-memory, while avenor is alive) |
| Prompt execution | ✓ (PTY paste) |
| Cancel | ✓ (Esc-Esc → reported or pane-scrape idle → forced kill) |
| Event streaming | ✓ |
| Permission relay | ⚠ (pane-scrape only, experimental) |
| Model selection | ✓ (via --model) |
| External server URL | ✗ |
| Subprocess discovery | ✓ |
Cancel semantics
Cancel() sends Esc-Esc and polls the pane for an idle prompt (up to ~3s). If the pane goes idle, or Claude reports idle after the turn started working, session.end stop_reason=cancelled is emitted and the terminal is killed. If the poll times out, the terminal is killed immediately and session.end stop_reason=cancelled_forced is emitted.
opencode-acp
Avenor spawns opencode acp --pure --log-level WARN and owns its lifecycle for the duration of the run.
When to use this: You want to avoid running a long-lived OpenCode server. Avenor starts one, uses it for one orchestration job, then tears it down. Simple and isolated.
When not to: You're running the same orchestration multiple times and want to reuse an OpenCode session across invocations. For that, use opencode-http and a separate server.
avenor \
--backend opencode-acp \
--agent jockey \
--model deepseek/deepseek-v4-pro \
--prompt "Review the changes in the current branch" \
--dir /repo \
--control-socket /tmp/avenor.sock \
--sentinel-file /tmp/done.envURL behavior
The ACP backend does not support network transport. If you pass --server-url or set AVENOR_OPENCODE_URL, the backend will fail cleanly. Either remove the URL or switch to opencode-http.
OpenCode version support
OpenCode 1.15.x–1.17.x report a turn aborted by a configured permission deny (for example permission.doom_loop: deny) as a clean end_turn — the ACP layer drops the assistant error. Avenor works around this by classifying denial-shaped turns (a tool call aborted with the interrupted footprint, or a turn ending end_turn right after an explicit reject) as the tool_denied stop reason instead of success. OpenCode 1.18.0+ fixed the upstream bug and reports the denial as a failed prompt; Avenor's classification stays on as defense in depth for those versions.
The pre-1.18 workaround is a deprecated compatibility shim, expected to be removed on 2027-07-14 (one year after the 1.18.0 fix). Runs on OpenCode 1.15.x–1.17.x that rely on a configured deny will silently report success again after removal — upgrade to 1.18.0+.
opencode-http
The default. Avenor talks to a running opencode serve instance over HTTP, with SSE for the event stream.
When to use this: You want to point multiple Avenor invocations at a single server. Useful for avenor stable setups where many sequential runs share the same OpenCode instance.
When not to: You're running a single orchestration job and don't want to manage a separate server process. For that, use opencode-acp.
Starting the server
opencode serve --port 4096 --pureRunning against it
avenor \
--backend opencode-http \
--server-url http://127.0.0.1:4096 \
--agent jockey \
--model deepseek/deepseek-v4-pro \
--prompt "Review the changes in the current branch" \
--on-event /tmp/avenor-http.ndjson \
--sentinel-file /tmp/avenor-http.envAuth
If the OpenCode server requires basic auth:
--server-url http://user:pass@127.0.0.1:4096Model format
The --model flag uses providerID/modelID:
--model deepseek/deepseek-v4-proKnown differences from ACP
- Event stream is global. The SSE
/eventendpoint streams events for all sessions on the server. Avenor filters by session ID locally. - Session end detection uses HTTP semantics. Avenor watches the message response finish status. SSE idle transitions arrive late and are not treated as terminal.
- Working directory is per-server, not per-session. Start
opencode servein the target directory. The--dirflag on Avenor does not work with HTTP mode — the server's working directory is the only one in play. - Permissions may auto-approve. Permission request/response behavior depends on the server's configuration. If the server is running with
--pure, permissions are relayed correctly. Otherwise the server may auto-approve based on its own allow list.
codex-app-server
Avenor spawns the Codex app-server subprocess and talks to it via JSON-RPC over stdio. Manages the lifecycle directly.
When to use this: You're running Codex and want Avenor to orchestrate a single run. Similar to opencode-acp — you get subprocess management for free.
avenor \
--backend codex-app-server \
--prompt "Implement the changes described in the plan" \
--dir /repo \
--sentinel-file /tmp/done.envgemini-acp
Avenor spawns gemini --acp --approval-mode default and talks via ACP over stdio.
Model selection caveat: Gemini reads its model from its own CLI config at spawn time. Passing --model to Avenor has no effect on gemini-acp. Set the model in your Gemini CLI config before invoking Avenor.
avenor \
--backend gemini-acp \
--prompt "Review the changes in the current branch" \
--dir /repo \
--sentinel-file /tmp/done.envagy
A headless agy run works until the agent needs an answer from outside the process. Tool approval, user questions, and mid-turn cancellation all need a control path that stdout JSONL cannot provide.
Avenor gives the dedicated agy backend three transport policies. None of them treats agy as ACP. The interactive path also does not scrape the terminal or send TUI keystrokes.
Headless is the default
With AVENOR_AGY_TRANSPORT unset or set to headless, Avenor runs agy in stream-JSON print mode and translates its stdout into canonical events. This is the least surprising choice for unattended text work.
avenor \
--backend agy \
--model gemini-2.5-flash \
--prompt "Review the changes in the current branch" \
--dir /repo \
--sentinel-file /tmp/agy-headless.envHeadless prompts use --print-timeout 24h, which keeps agy's shorter print timeout from ending a run before Avenor does. The ceiling is still a ceiling: a single headless prompt cannot run longer than 24 hours.
Headless mode cannot relay interactive permission decisions. agy denies tools that require confirmation, although its persisted allow rules still apply. Use explicit RPC mode when the run must stop and ask.
RPC carries the conversation
Set AVENOR_AGY_TRANSPORT=rpc in the environment of the Avenor process. For a stable, that means the environment of avenor stable, not an individual spawn request.
AVENOR_AGY_TRANSPORT=rpc avenor \
--backend agy \
--model gemini-2.5-flash \
--prompt "Reply with a one-line status update" \
--dir /repo \
--control-socket /tmp/avenor-agy.sock \
--sentinel-file /tmp/agy-rpc.envRPC mode hosts interactive agy in a PTY, but the PTY owns process lifetime only. Turns, events, permissions, questions, cancellation, and recovery use the loopback RPC connection. Model selection is exact: --model must contain a nonempty slug advertised by agy, and Avenor will not substitute another model.
RPC accepts stable semantic versions in major version 1, including newer minor and patch releases. Malformed versions, prereleases, and different major versions are rejected.
Permission choices and questions arrive as permission.request events. Questions that require user input do not go through generic auto-approval. An option marked requiresMessage needs a nonempty write-in message; see Control Protocol and File Permission Handler.
A stable can run multiple RPC sessions concurrently. Each session owns its workspace, conversation identity, PTY, and RPC host. A follow-up prompt sent to the same runtime reuses that host; resume starts from the requested conversation identity. After a normal turn, the stable keeps the host while that runtime waits for another prompt. Cancelling or shutting down the runtime cleans it up.
Cancellation does not treat a successful cancel request as proof that the agent stopped. Avenor reuses the host only after the remote session reaches a terminal state. If it does not converge, Avenor interrupts the exact hosted process and escalates to kill and reap when necessary.
For a long-lived stable, start the transport policy with the supervisor:
AVENOR_AGY_TRANSPORT=rpc avenor stable \
--control-socket /tmp/avenor-agy-stable.sock \
--idle-timeout 30mThen, from another shell, spawn an agy runtime normally:
avenor control --socket /tmp/avenor-agy-stable.sock spawn \
--backend agy \
--model gemini-2.5-flash \
--prompt "Review the changes in the current branch" \
--dir /repo \
--label agy-reviewAuto probes before committing
AVENOR_AGY_TRANSPORT=auto tries RPC for each new session. It falls back to headless only before the session is registered and only after the failed RPC host has been cleaned up.
AVENOR_AGY_TRANSPORT=auto avenor \
--backend agy \
--model gemini-2.5-flash \
--prompt "Summarize the current branch" \
--dir /repo \
--sentinel-file /tmp/agy-auto.envBecause the selected transport is not known in advance, auto does not advertise interactive permission capability conservatively. If permission relay is required, choose rpc explicitly rather than hoping the probe wins.
Security boundaries
RPC discovery accepts only loopback listeners owned by the exact hosted agy process. Its HTTP client does not use ambient proxies, prefers TLS, and does not enable plain-HTTP fallback in normal backend configuration. Private endpoints, certificates, and wire identities do not become public session identifiers or diagnostics.
Avenor never passes --dangerously-skip-permissions by default. RPC adds a control path; it does not remove agy's permission boundary.
cursor-acp
Avenor spawns agent acp and authenticates via cursor_login. Talks via ACP over stdio.
Model selection caveat: Like Gemini, model and configuration are managed through the agent binary's own config — not via session methods. Update your Cursor agent config before running Avenor if you need a different model.
avenor \
--backend cursor-acp \
--prompt "Review the changes in the current branch" \
--dir /repo \
--sentinel-file /tmp/done.envpi
Avenor spawns pi --mode rpc --no-session by default and talks via its own JSON-RPC protocol over stdio. Sessions are ephemeral: the transcript is discarded when the process exits. Set AVENOR_PI_SESSIONS=1 in the environment, or pass a per-run choice (see below), to opt in to persisted sessions.
One session per instance. This is a PI protocol constraint, not something Avenor can route around. If you try to start a second session before the first ends, the backend will error. If you need multiple concurrent sessions, run multiple Avenor invocations.
Model selection works at spawn time via --model:
avenor \
--backend pi \
--model anthropic/claude-sonnet-4-5 \
--prompt "Implement the changes described in the plan" \
--dir /repo \
--control-socket /tmp/avenor.sock \
--sentinel-file /tmp/done.envAgent profiles
When using pi with the @dougbots/pi-agents extension, you can select a configured agent profile with --agent instead of (or in addition to) --model:
avenor --backend pi --agent jockey --prompt "review this PR"The --agent flag resolves the model from pi's agents.json (looked up via PI_CODING_AGENT_DIR or ~/.pi/agent/). It also sets the PI_AGENT environment variable in the pi subprocess, which the @dougbots/pi-agents extension reads to apply the full agent profile (model, system prompt, tool restrictions, permission gates).
--model works fine on its own — no agent config or extension required. Use --agent when you want pi to load a named profile with its own configuration.
Persisted sessions and resume
By default a pi run's transcript is discarded with its process: the only records are avenor's event log and sentinel. Setting AVENOR_PI_SESSIONS=1 in the environment (of the process that spawns the runs — the supervisor, or the shell running avenor run) launches each pi child with a session directory instead of --no-session:
- the full transcript, prompt included, survives the process as a pi session file you can read or resume later with pi itself;
- the message tool's
resumeaction can continue a finished pi run across processes, the way it continues codex runs: the supervisor reads the prior run's session ID from its sentinel, and the resumed child launches bound to that session.
The per-run choice overrides the environment in both directions: a spawn with persist_sessions: true (MCP spawn argument, persist_sessions spawn parameter, --persist-sessions on avenor run and avenor control spawn) persists without the environment; persist_sessions: false stays ephemeral even when the environment opts in. Roster entries carry the same field, so loop, team, and workflow phases resolve it per entry (see the loop, team, and workflow roster docs). A run that inherited or forced persistence keeps it on resume: the resumed run carries the prior run's choice (a persisted run stays resumable even when the environment has since dropped the opt-in), and resuming a run recorded as ephemeral is refused at the resume gate rather than dying later on a missing session file.
Session files go to $XDG_STATE_HOME/avenor/pi-sessions (default ~/.avenor/pi-sessions), or to the directory named by AVENOR_PI_SESSION_DIR. The state home is outside run artifacts on purpose: artifact retention prunes aggressively, and a transcript someone wants later must not be pruned with them. The knob is opt-in rather than default because session files cost disk on every fan-out, and the shared session store is one of the suspects in the same-model startup race.
A resume still refuses a run whose sentinel says TIMEOUT or KILLED, and a run that launched without persisted sessions cannot be resumed — the refusal names AVENOR_PI_SESSIONS so the fix is discoverable. A roster entry without the field keeps the environment's choice (see the loop, team, and workflow roster docs for the entry field).
Diagnosing backend connectivity
If you're not sure whether opencode-acp is wired up correctly — wrong binary on PATH, ACP not supported in your OpenCode version, subprocess dies immediately — avenor probe runs a minimal diagnostic session and writes the full event transcript to a file.
avenor probe --out /tmp/probe-transcript.ndjson --dir /repo --timeout 2mThe transcript is NDJSON in the same format as --on-event. Read it with avenor watch or inspect it directly to see what events the backend emitted before failing.
avenor probe only works with opencode-acp — it spawns opencode acp --pure directly and is not intended for production use.
| Flag | Default | Description |
|---|---|---|
--out | required | Path to write the probe transcript |
--dir | . | Working directory for the probe session |
--timeout | 5m | Probe timeout |
--prompt | built-in | Override the default probe prompt |
See also
- events.md — event stream format and lifecycle
- permission-handler.md — permission request/response JSON shapes
- control-protocol.md — control socket methods and ownership rules