The openagents coder terminal coding agent
Date: 2026-08-23
Status: Specification. None of it is implemented.
Commits measured: d035a1186ee0 on openagents/main for this repository; df62c1fe353d in the openagents monorepo for packages/openagents-cli (version 0.2.1 in that tree, 0.3.0 on npm — see section 11); the probe repository at 192a5e4 for the current Rust tree, at e0c78a7 for the archived OpenTUI work, and at origin/codex/issue-142-coder-compat for the ratatui lineage.
Question: What has to be true for npm i -g @openagentsinc/cli followed by openagents coder to give you a terminal coding agent that uses the account you already signed in with, calls the APIs openagents.com already serves, and creates no second backend?
Method: direct reading of lib/openagents_web/router.ex and every pipeline it declares, the chat, delegation, computer, Box, and inference controllers and their contexts, INVARIANTS.md, and docs/taxonomy.md; direct reading of all 22 source files in packages/openagents-cli/src/ in the openagents monorepo; and direct reading of the probe repository's five crates, two published packages, zerobase specification, rendering-gap audit, archived OpenTUI commits, and the ratatui work preserved on its codex/* branches. Claims this repository cannot settle are in section 11 with the command or file that settles them.
0. Summary
openagents coder is one more subcommand in the CLI you already install. It
mints a budgeted inference grant with the token openagents auth login stored,
launches the OpenAgents coding-agent runtime as a child process, speaks Agent
Client Protocol v1 to it over standard input and output, and renders the
session in a full-screen OpenTUI interface where you approve what it does.
The central decision is that the CLI is an ACP client, not a second coding agent. Five findings support it.
- The agent already exists and is almost complete. The
proberepository at192a5e4ships a sans-I/O Rust core with a turn state machine (crates/probe-core/src/turn.rs), permission as data (crates/probe-core/src/permission.rs), six real tools with path confinement and output caps (crates/probe-bin/src/tools.rs), an ACP v1 server (crates/probe-acp/src/server.rs), OpenAI-compatible and Gemini wire lowerings, and a WebAssembly build whose 452 KB artifact is checked in and whose ABI is deliberately synchronous — lines and events in, a JSON command array out — so the JavaScript host owns every asynchronous concern. Its native host readsPROBE_INFERENCE_GRANTandPROBE_INFERENCE_URLfrom the environment. It is already pointed at this server. - The authority path exists and is one route short.
POST /api/inference/proxy(lib/openagents_web/router.ex:342) is an OpenAI-compatible chat-completions surface a delegated coding agent calls with anOpenAgents.Inference.Grantas its bearer; the provider credential never leaves the server. Three call sites mint grants (lib/openagents/work/scv.ex:133,lib/openagents/work/coding.ex:64,lib/openagents/work/delegation_server.ex:168) and every one is server-initiated. No route mints a grant for a client holding a user token. That single missing route is the whole server-side gap, andlib/openagents/work/coding.ex:64already proves the schema acceptsmachine_id: nil. - probe wrote down the channel it lacked, and the CLI is that channel.
probe's zerobase specification says in its A4 appendix that the paired
computer controller has "no channel by which a per-delegation inference
grant can reach probe," and calls the amendment that would create one the
single thing the free-agent loop requires. A CLI holding the user's own
token needs no amendment.
openagents coderis that observation turned into a command. - Being the client is what makes approvals work. probe's TypeScript era
spent three commits failing to prompt for permission from inside a tool
handler, because the chat loop owned standard input while tools ran in a
forked fiber (
efcb799,29459f1,cfdd422); it ended with a handler that always allowed. The fix it drew is that permission is a protocol, not a prompt. A terminal that is a separate process from the agent gets that for free:session/request_permissionarrives as a JSON-RPC request and your keystroke is the response. - The CLI has no streaming and no interactive code at all.
ApiTransportreads the whole response body as text and parses it (packages/openagents-cli/src/api-transport.ts:103), there is noEventSource, no readline, no raw mode, and no ANSI anywhere in the package.terminal-session.tsis 17 lines that report whether standard input and output are TTYs. The interface is genuinely new capability and must degrade when it cannot run.
The recommendation is to ship the smallest honest slice first: one server route that mints a session-scoped grant, one CLI subcommand that spawns the runtime and drives ACP in line-oriented mode, and only then the OpenTUI interface, writes, and delegation.
1. What openagents coder is
1.1 Install to first turn
npm install --global @openagentsinc/cli
openagents auth login
cd your-repository
openagents coder
Three commands, one of which you already ran. auth login stores a token in
the operating-system credential store keyed by API origin
(packages/openagents-cli/src/credential-store.ts:60); coder reads that same
token through the same resolveApiSession every other command calls
(packages/openagents-cli/src/session.ts:47). There is no second sign-in, no
provider key, and no configuration file to write.
openagents coder "fix the failing milestone test" runs one turn
non-interactively and exits, so the command works from a script and from
another agent.
1.2 How the pieces fit
your terminal openagents.com
┌────────────────────────────┐ ┌───────────────────────────┐
│ openagents coder │ │ │
│ │ token │ POST /api/v3/coder/ │
│ CoderSession ─────────────┼────────►│ sessions │
│ mints, revokes │◄────────┤ → sig_… grant │
│ │ grant │ │
│ AcpClient │ │ │
│ session/new │ │ │
│ session/prompt │ │ │
│ session/update ▲ │ │ │
│ request_permission │ │ │ │
│ OpenTUI renderer │ │ │ │
└───────────────────────┼────┘ │ │
stdio │ │ │
┌───────────────────────▼────┐ grant │ POST /api/inference/proxy │
│ the coding runtime ├────────►│ meters, fans to the │
│ turn loop, tools, scrub │◄────────┤ provider, returns SSE │
└────────────────────────────┘ SSE └───────────────────────────┘
│
▼ reads, edits, shell, git — in your working directory
Three processes, one token, one grant. The CLI never sees a provider credential; the runtime never sees your account token; the server never executes anything on your disk.
1.3 Why it belongs in the CLI
A separate binary would have to re-solve four problems the CLI has solved, and would then hold them at a different version than the CLI does.
- Identity. The device-authorization flow, the credential store, the
OPENAGENTS_TOKENescape hatch, and the "no supported credential adapter on this platform" refusal took a device flow, a macOS keychain adapter, asecret-tooladapter, and read-back verification to get right (packages/openagents-cli/src/credential-store.ts,device-client.ts). - API origin. Profiles are named origins with a seven-step resolution order
and an origin normalizer that refuses non-loopback HTTP
(
packages/openagents-cli/src/endpoint.ts:82,:41). A second binary that guessed the origin would send a staging token to production the first time someone set one environment variable and not the other. - Git push authority.
openagents auth setup-gitinstalls the same binary as a Git credential helper, which is how an ordinarygit pushauthenticates against the smart HTTP transport atlib/openagents_web/router.ex:318. An agent that cannot push is a code reader. Reusing the CLI means the agent inherits push authority from a command you already ran. - Error and exit-code discipline. Fifteen tagged errors map to eight exit
codes through an exhaustive switch
(
packages/openagents-cli/src/errors.ts:141). An agent invoked from CI needs those codes to mean what they mean foropenagents repo create.
The counter-argument is real: a coding agent is a larger program than a
repository client, and folding it in makes @openagentsinc/cli a bigger
install for everyone who wants openagents api. The ACP-client decision is
what answers it. The CLI gains a session service, a JSON-RPC client, and a
renderer; the loop, the tools, and the transports stay in the runtime package
where they already are.
2. What it takes from probe, and what it leaves
2.1 What it consumes rather than reimplements
Everything below is built and tested in the probe repository at 192a5e4.
openagents coder calls it and adds none of it.
| Concern | Where it lives | What it already guarantees |
|---|---|---|
| Turn loop | crates/probe-core/src/turn.rs |
Tools execute strictly one at a time; a bounded sixteen tool rounds; cancellation is an ordinary transition from any state that keeps partial text |
| Permission | crates/probe-core/src/permission.rs |
A typed request carrying tool, ACP kind, title, command string, and a content-free FNV-1a input digest; a denial still reaches the model as a tool result |
| Tools | crates/probe-bin/src/tools.rs |
Six tools; paths refused on .., absolute form, and .git; 48 KiB output cap; /bin/sh -c with no standard input and a 240-second ceiling; grant variables removed from every child environment |
| Edit safety | crates/probe-core/src/editing.rs |
Byte-order-mark and line-ending preservation, exact-match planning, and a stale-content guard whose failure is typed rather than swallowed |
| Redaction | crates/probe-core/src/redact.rs |
A SecretSet of values, a RedactedText with no constructor that skips it, and a receipt type that is content-free by construction |
| ACP surface | crates/probe-acp/ |
Protocol version 1, one active prompt per session, an event-to-session/update mapping that chunks text under a 32 KiB budget, and a 2 MiB line cap set at half the controller's silent-drop threshold |
| Wire | crates/probe-wire/ |
OpenAI-compatible and Gemini lowerings with incremental SSE parse state and endpoint provenance |
Two details make it consumable from Node without a native toolchain. The
WebAssembly ABI is synchronous by design — handleLine, onProviderEvent,
onProviderFailure, and onToolOutcome each return a JSON command array — so
the JavaScript host owns every asynchronous concern and no JavaScript Promise
Integration is required. And the built artifact is checked in at
packages/probe/wasm/probe_wasm_bg.wasm, so consuming it is an npm install
rather than a Rust build.
2.2 The four lessons that shape the client
Permission is a protocol, not a prompt. The failure was specific and worth
naming, because a full-screen renderer would reproduce it exactly. probe's
permission.ts created a second readline interface on standard input while the
chat loop already owned one; the escape-interrupt handler in raw mode consumed
the answer (29459f1). The replacement read raw bytes with the prompt on
standard error, and still could not work, because the tool ran in a forked
fiber that could not block on the loop's stream (cfdd422). The module's final
comment states the rule: any interactive permission experience must be plumbed
through the main loop by a side channel. Being a separate process is the
strongest possible form of that side channel.
The runtime offers exactly two options — allow_once and reject_once — and
never an "always" (crates/probe-acp/src/server.rs). It fails closed: a
cancelled response, an error response, and an unreadable body all decode as
denied. The client must not weaken either property. Section 7.3 explains how a
session-scoped approval lives in the client's policy without adding a third
option to the wire.
Redaction belongs in the type. The grant is the secret that matters, and it
is scrubbed on two walls: the runtime registers it in its SecretSet and
scrubs every outgoing line and every tool result, and the client scrubs
everything it renders and writes. The grant value never reaches the transcript
file, a log line, or a tool's environment.
Untested code is where every defect was. probe's archived edit module
advertised a stale-content guard that a blanket catch swallowed, so a detected
concurrent modification reported success — and so did a genuine EACCES. Its
OpenTUI renderer had zero test coverage. The renderer here is a pure function
from a session-state snapshot to a renderable tree, fixture-tested, with the
ACP client unaware that it exists.
The conformance contract is already written. probe's A2 appendix
enumerates what an ACP client must supply and what the agent must satisfy:
protocol version 1 on initialize, loadSession: true advertised before resume
is attempted, client capabilities fs: false and terminal: false with only
session/request_permission and session/update registered, oversize
JSON-RPC lines dropped whole rather than erroring, tool_call frames carrying
toolCallId, kind, title, and rawInput.command, agent_thought_chunk
droppable so nothing user-relevant may live only there, and any option whose
id, name, or kind matches /bypass/i refused. That is the client's test-fixture
list, not a design exercise.
2.3 The OpenTUI era, measured honestly
probe integrated @opentui/core at version 0.3.4 in three commits over ten
minutes on 2026-06-08: f046a51 ("Phase 0: Integrate @opentui/core"),
593d425 ("Phase 1: Rich tool output via CodeRenderable +
LineNumberRenderable"), and e0c78a7. Exactly one source file existed,
packages/runtime/src/opentui-renderer.ts, and it was deleted with the rest of
the TypeScript tree in f24253f. What it actually contained:
createCliRenderer({ exitOnCtrlC: true, targetFps: 30, screenMode: "main-screen" }), inline rather than full-screen, activated only behind--tuiand only when standard output was a TTY.- One
ScrollBoxRenderableadded torenderer.root. No panes, no footer, no composer, no header, no split. - One
MarkdownRenderableper turn withstreaming: true,conceal: true, andinternalBlockMode: "top-level"; each text delta appended to.contentand OpenTUI reconciled blocks incrementally. - Tool calls rendered as one flat
TextRenderableline. Tool results routed through four typed branches — error, file read, directory listing, code search — each a dim header plus aLineNumberRenderablewrapping aCodeRenderablewith the filetype inferred from the path. - Tool errors in the one
BoxRenderablein the file, bordered red. DiffRenderablewas imported and re-exported and never instantiated. There is no recoverable diff-rendering code from that era.- Escape-to-interrupt (
86bf9cd) worked only in the plain ANSI path. The TUI path forked the same fiber and handled the interrupted case but installed no keypress listener, so under--tuithere was no way to trigger it.
The zerobase specification's own verdict is that the file was "90% a color
table," and that is fair: a 24-entry GitHub-dark SyntaxStyle and a 45-entry
extension map.
What survives is the reasoning, recorded in that repository's
docs/probe-rendering-gap-audit.md, which measured what hand-rolled ANSI cost:
code blocks rendered plain gray because the language argument was ignored; the
diff you approved compared lines positionally, truncated to ten a side, and
filtered to only + and - lines; tool results were one-line summaries, so
what the agent read went to the model and not to you; and streaming Markdown
used regular-expression replacement, so a fenced block spanning two chunks
rendered as raw backticks. @opentui/core answers each with a composable
renderable over a Yoga flexbox layout, and its imperative API needs no JSX,
which suits a package built by plain tsc with no bundler.
openagents coder builds the interface probe's audit planned and never
reached: its P2 row, a scrollable transcript, and its P3 row, a
TextareaRenderable composer in split-footer mode.
2.4 The ratatui lineage, and why it is not the model
probe's codex/* branches carry a far richer terminal interface than the
OpenTUI era ever had: a probe-tui crate on ratatui, crossterm, and syntect
with a typed overlay stack (ScreenId::{Chat, Help, SetupOverlay, ApprovalOverlay}), a thirty-variant UiEvent enum, a real composer with
grapheme-aware cursor movement, a twenty-four-entry history, slash commands and
mentions, a retained transcript model, insta snapshot tests pinning every
screen including the approval overlay, and — most relevant — a resumable
approval broker that persists pending tool approvals per session and resolves
them on a worker thread, which is the shape the OpenTUI era failed to reach.
That branch also carries docs/101-openagents-coder-runtime-adapter.md, which
is prior art for this document and should be read before Stage 1.
It is not the model here for one reason: it is Rust in the agent process, and
the whole argument of section 0 is that the interface belongs in the client, in
the language the CLI is already written in. The ideas port; the crate does not.
Recover the design documents with git show origin/codex/issue-142-coder-compat:docs/45-...-resumable-approval-broker.md
and its siblings.
2.5 What it deliberately does not take
- A second model transport family. probe supports OpenAI-compatible, Gemini, and stub transports. The coder configures exactly one: the OpenAgents inference proxy under a grant. A user who wants their own key is describing a different product.
- In-process permission prompting, in any form. See section 2.2.
- The archived CLI monolith. probe's
cli.tsreached 1,974 lines holding argument parsing, an ANSI Markdown renderer, tool implementations, per-backend formatters, and two chat loops. The layering in section 3 exists to keep that from recurring. - A
--tuiopt-in flag. The interface is the default when the terminal supports it, and--plainopts out. An interface nobody turns on gets no test coverage, which is exactly what happened.
3. The OpenTUI interface
The interface is one screen. There is no conversation list, no workspace chrome, and no settings pane, which matches how the web surface is governed (UI-001) and matches what a terminal is good at.
3.1 Layout
createCliRenderer({ screenMode: "split-footer" }) gives a scrollback region
and a fixed footer, which is the shape the interaction needs: the transcript
grows upward and the composer stays put. probe's era used "main-screen"
because it was preserving inline output; there is nothing to preserve here.
┌──────────────────────────────────────────────────────────┐
│ transcript ScrollBoxRenderable, viewport-culled │
│ │
│ you plain text, dim rule above │
│ assistant MarkdownRenderable, streaming: true │
│ tool call header line, expandable body │
│ diff DiffRenderable, unified │
│ │
├──────────────────────────────────────────────────────────┤
│ status repository · branch · model · calls · tokens │
├──────────────────────────────────────────────────────────┤
│ composer TextareaRenderable, multi-line │
└──────────────────────────────────────────────────────────┘
The status line is the one piece of chrome that earns its row. An agent
spending a metered grant must show what it has spent: call_count against
max_calls, and token usage against max_total_tokens, both of which the
grant already tracks and the proxy already meters
(lib/openagents/inference.ex). An agent that exhausts its budget mid-edit
without ever having shown the budget is an agent that lost your work.
An approval request replaces the composer with a decision pane rather than opening a modal over the transcript, so the diff under discussion stays visible.
3.2 Keybindings
| Key | Action |
|---|---|
Enter |
Submit the composer |
Shift+Enter |
Newline in the composer |
Esc Esc |
Interrupt the running turn, within a five-second window |
Ctrl+C |
Interrupt if a turn is running, otherwise exit |
Ctrl+D |
Exit on an empty composer |
PageUp / PageDown |
Scroll the transcript |
Ctrl+O |
Expand or collapse the focused tool call |
y / n / a |
Approve once, refuse, or approve this class for the session, in the decision pane only |
Ctrl+R |
Toggle the diff between unified and split |
Interruption is double-escape inside a five-second window, which is probe's
design (86bf9cd) and is not a stylistic choice: in raw mode an arrow key
arrives as 0x1b followed by more bytes, so a single escape cannot be
distinguished from the start of a sequence without a timeout that makes every
arrow key feel slow. The first escape prints a muted "again to interrupt", any
other byte cancels the armed state, and the second escape sends
session/cancel. The zerobase specification kept this pattern explicitly and
the current tree has no host CLI to put it in; this is that host.
Interruption is a protocol message, not a signal. The runtime treats
cancellation as an ordinary state transition, keeps the partial assistant text,
and lets an already-running tool finish and record its outcome. Ctrl+C at the
process level must still exit 130, which the package already asserts against a
live process (packages/openagents-cli/test/signals.test.ts:70).
3.3 Streaming assistant output
The client receives session/update notifications and renders them; it does
not parse provider events, because the runtime already lowered them into a
neutral union and mapped that union to ACP. agent_message_chunk appends to an
accumulator assigned to MarkdownRenderable.content with streaming: true, so
per-block reconciliation leaves settled paragraphs alone while a code fence
three blocks down is still arriving. That is what makes an incomplete fence
render as code rather than as backticks.
agent_thought_chunk renders dim and collapsed by default. The runtime already
assumes a client may drop it entirely, so nothing user-relevant may live only
there.
One honest caveat about how far "streaming" reaches today. The inference proxy
builds the complete chat-completions SSE body and sends it once
(lib/openagents_web/controllers/inference_proxy_controller.ex:155–:170)
because its current consumer reads the whole body before parsing; the runtime's
own JavaScript transport does the same. Until both change, a response arrives
in one piece and the interface must not fake a progressive cursor — the status
line shows waiting. Chunking the proxy is a small, separable, additive server
change and is listed in section 6.2.
3.4 Tool-call rendering
A tool call renders as a collapsed one-line header that expands in place:
▸ read_file lib/openagents/inference.ex 142 lines
▸ grep_files "mint(" 3 matches
▾ shell mix test test/openagents/inference_test.exs exit 1
…output rendered in a CodeRenderable, capped…
The header is built from the ACP frame the runtime already populates:
toolCallId for correlation, kind for the glyph, title — which the runtime
sets to rawInput.command when present and to the tool name otherwise — and
status for the terminal marker. This is a header line composed by the client,
not the carriage-return rewrite probe used in its plain ANSI path (c51637c);
that technique cannot survive a renderer that owns the screen.
Rules that follow from probe's measurements:
- File reads render through
CodeRenderablewrapped inLineNumberRenderable, with the filetype inferred from the extension and plain text as the fallback, which is exactly what593d425built. - Edits render through
DiffRenderableagainst a real unified patch. probe generated one withcreateTwoFilesPatchat three lines of context and never wired it to a renderable; this is the wiring. - Shell output is captured with ANSI stripped and rendered in a bounded scroll region. A terminal emulator inside a renderable is a separate project.
- Every rendered body is capped and says so when it truncates. The display cap is not the model's cap: the runtime already bounds tool output at 48 KiB and chunks ACP content under a 32 KiB text budget. Where the two differ, show both. A summary that hides the difference is how "the agent said it read the file" becomes unfalsifiable.
3.5 Approval prompts
An approval is a session/request_permission request. The decision pane shows
the tool, the ACP kind, the exact command string or the exact patch, the path
relative to the working directory, and the decisions. It shows no free-form
model-authored justification above the fold, because the argument for an action
is the action.
┌ approve edit ────────────────────────────────────────┐
│ lib/openagents/inference.ex │
│ │
│ @@ -41,7 +41,7 @@ │
│ - max_calls: max_calls(), │
│ + max_calls: max_calls(kind), │
│ │
│ [y] approve once [a] approve edits this session │
│ [n] refuse and tell the agent why │
└──────────────────────────────────────────────────────┘
Refusal is a typed outcome the agent must handle, not an error that fails the turn: the runtime turns a denial into a tool result the model reads, so the next round knows it was refused and why. That mirrors TOOL-002 on the server, where a refused tool produces a bounded typed result rather than a silent substitution.
3.6 What happens without a real terminal
terminal-session.ts already reports whether both standard input and output
are TTYs (packages/openagents-cli/src/terminal-session.ts:8), and auth login already branches on it. coder branches the same way.
| Condition | Mode |
|---|---|
TTY, renderer available, no --json |
Full OpenTUI interface |
Not a TTY, or --json, or --plain |
Line-oriented stream to standard output, no cursor control; approvals refused unless --approve names an effect class |
| Renderer's artifact unavailable | Line-oriented mode with one notice on standard error, exit code unchanged |
The interface is optional. The agent is not.
4. Sessions, turns, and receipts
4.1 Say which record you mean
Four durable families are in scope and only one of them is the coder's.
- Turns (
turns,account_chat_runs,account_chat_events) are Sarah's conversation. TURN-001 permits one active turn per conversation, enforced by a partial unique index. DATA-002 permits one conversation per authenticated user, enforced by a unique index.AccountTurns.submit/3callsConversations.ensure_conversation(user), so every caller ofPOST /api/v3/chat/turnslands in that one conversation. - Work jobs (
work_jobs) are delegation, not execution. Adeep_work.v1call starts one; a Computer delegation starts one of kinddelegation; a self-edit starts one of kindcoding(WORK-001,lib/openagents/work/coding.ex). - Inference grants (
inference_grants) are model authority: budgeted in tokens, calls, and estimated cost, time-bounded, revocable, and generation-fenced by conversation. - Push receipts are the forge's durable record of every accepted push, and they are what makes a commit explicable after the session ends.
4.2 What a coder session is
A coder session is a coding-agent session in the sense docs/taxonomy.md
already defines: a complete interaction with a coding agent from start to
finish, spanning one or more turns, whose durable pieces today are turns, work
jobs, and SCV runs. The taxonomy is explicit that the named product unit is
proposed and not built. This document does not build it either, and that is the
point.
Concretely, for the first slice:
- One session mints one inference grant. The grant is the durable server-side anchor. It already carries the owner, the conversation, the model, the budget, the call count, the usage map, and a terminal status. "How much did this session spend" is a query against a table that exists.
- One coder turn is one
session/promptand its bounded tool loop. It is not a row inturnsand not a row inaccount_chat_runs. Writing it there would put a second author into the account's single conversation and would collide with TURN-001 the first time you ranopenagents coderin two checkouts at once. The runtime's own sixteen-round tool bound happens to match TURN-005's sixteen; that is a coincidence worth keeping rather than a shared implementation. - The local transcript is local. The client writes append-only JSON Lines
under the CLI's configuration root, mode
0600, one file per session, soopenagents coder --resumeandopenagents coder --show <id>work offline. It is a convenience, never authority, and it holds no grant value. The runtime cannot supply this: its sessions are an in-memory map, and although it advertisesloadSession: true, nothing restores a transcript across processes. Section 10 keeps that gap in the client, where it is cheap. - What lands on the server is the evidence the server already collects: metered grant usage per call, the push receipt for whatever the session committed, and — when the session delegates — the Box run or work job the delegation created.
The honest gap this leaves is that a commit produced by openagents coder
carries no durable explanation of why. Closing it is the checkpoint receipt
family and the commit trailer that docs/taxonomy.md already names as
proposed. The coder should be the first producer of those receipts and should
not invent a private table in the meantime. That is Stage 4.
4.3 The rule this section exists to state
Do not create a second work record. If the session runs locally, the grant
ledger and the forge receipt are the record. If the session delegates, the
delegation's own substrate record — a Box run or a work_jobs row — is the
record, reached through OpenAgents.Delegations, which stores no state of its
own and derives every projection from those substrates (IDENTITY-009). There is
no third ledger.
5. Authentication
openagents coder adds no authentication. It calls
resolveApiSession(endpointOverrides(flags))
(packages/openagents-cli/src/session.ts:47), the same function every other
command calls, and gets back {endpoint, token} or a typed
AuthenticationRequired that exits 3.
What that inherits, exactly:
- Token lookup order.
OPENAGENTS_TOKENin the environment wins withsource: "environment"; otherwise the operating-system credential store, keyed by resolved origin, serviceopenagents-cli(packages/openagents-cli/src/credential-store.ts:46,packages/openagents-cli/src/session.ts:30). macOS usessecurity, Linux usessecret-tool, and every other platform refuses withCredentialPersistenceUnavailableand tells you to setOPENAGENTS_TOKENfor the invocation. - Profiles. A profile is a named API origin, not an account:
productionishttps://openagents.com,stagingishttps://staging.openagents.com,localishttp://localhost:4000(packages/openagents-cli/src/endpoint.ts:5). Resolution runs--api-url,--profile,OPENAGENTS_API_URL,OPENAGENTS_PROFILE, the config file'sapi_url, the config file'sprofile, then production (packages/openagents-cli/src/endpoint.ts:82). Because credentials are keyed by resolved origin,openagents --profile staging coderuses the staging token against the staging origin with no further ceremony. - Scopes. The token needs
chat:accountfor the grant-mint route in section 6.2, andforge:writefor issue and pull-request tools. Those are separate scopes on separate pipelines (lib/openagents_web/router.ex:57,:39). The coder asks for the narrow scope each tool needs rather than requiring a token that can do everything, and a tool whose scope the token lacks is a typed refusal naming the missing scope, not a403traceback. - Git. Pushing uses the credential helper
openagents auth setup-gitinstalled, so the agent'sgit pushis yourgit push. If the helper is not installed, the coder says so once and offers the exact command rather than writing credentials itself.
The token never reaches the runtime. The client exchanges it for a grant
and passes only the grant, in the child process environment at spawn, exactly
as probe's A4 appendix specifies: PROBE_INFERENCE_GRANT and
PROBE_INFERENCE_URL, never in argv, never in a file, never in a prompt. The
runtime removes both from any shell it spawns.
openagents coder --token-stdin does not exist and should not. If you want a
one-invocation token, OPENAGENTS_TOKEN already does that for every command.
6. The API surface, route by route
6.1 What exists today
Every route below is live at d035a1186ee0, with its router line.
Model access.
| Route | Line | Authority |
|---|---|---|
POST /api/inference/proxy |
router.ex:342 |
An inference_grants bearer, not a user token |
The proxy translates an OpenAI-compatible body into
OpenAgents.Providers.Request, fans it into the configured provider, meters
usage against the grant, and returns chat-completions SSE. The grant pins the
model; a request body cannot select another
(lib/openagents_web/controllers/inference_proxy_controller.ex:44). This is
the coder's only model transport, and the runtime already speaks it.
Repository and tracker context, for read-only tools.
| Route | Line |
|---|---|
GET /api/v3/repos/:owner/:repo |
router.ex:517 |
GET /api/v3/repos/:owner/:repo/issues |
router.ex:518 |
GET /api/v3/repos/:owner/:repo/issues/:issue_number |
router.ex:519 |
GET /api/v3/repos/:owner/:repo/issues/:issue_number/dependencies |
router.ex:521 |
GET /api/v3/repos/:owner/:repo/pulls |
router.ex:525 |
GET /api/v3/repos/:owner/:repo/pulls/:pull_number |
router.ex:526 |
GET /api/v3/repos/:owner/:repo/stacks |
router.ex:532 |
GET /api/v3/repos/:owner/:repo/projectsV2 |
router.ex:539 |
These sit on :optional_forge_api, which uses
OpenAgentsWeb.Plugs.OptionalApiTokenAuth with scope forge:write
(router.ex:102–:106), so a signed-in coder reads private repositories and an
anonymous caller reads public ones.
Tracker and code writes, for write tools.
| Route | Line |
|---|---|
POST /api/v3/repos/:owner/:repo/issues |
router.ex:404 |
POST /api/v3/repos/:owner/:repo/issues/:issue_number/comments |
router.ex:405 |
PATCH /api/v3/repos/:owner/:repo/issues/:issue_number |
router.ex:587 |
POST /api/v3/repos/:owner/:repo/pulls |
router.ex:588 |
POST /api/v3/repos/:owner/:repo/stacks |
router.ex:591 |
Issue and comment creation run on :agent_participation_api with
DualPrincipalAuth (router.ex:45–:49); the rest run on :forge_write_api.
Delegation, for handing work off.
| Route | Line |
|---|---|
GET /api/v3/conversations/:conversation_id/delegation-targets |
router.ex:420 |
POST /api/v3/conversations/:conversation_id/delegations |
router.ex:421 |
GET /api/v3/conversations/:conversation_id/delegations/:id |
router.ex:422 |
DELETE /api/v3/conversations/:conversation_id/delegations/:id |
router.ex:423 |
GET /api/v3/computers |
router.ex:429 |
POST /api/v3/computers/:computer_id/agent-jobs |
router.ex:431 |
GET /api/v3/computer-agent-jobs/:id |
router.ex:432 |
POST /api/v3/conversations/:conversation_id/boxes |
router.ex:472 |
POST /api/v3/conversations/:conversation_id/boxes/:box_id/runs |
router.ex:478 |
Git transport. GET /:owner/:repo/info/refs and the two pack routes at
router.ex:318–:320, reached through ordinary git with the CLI's
credential helper.
Identity. GET /api/v3/user (router.ex:576) for the status line, and the
two device-authorization routes at router.ex:350–:351 that auth login
already uses.
6.2 What must be built
Four items, in dependency order. The first is the only one the first slice needs.
1. A route that mints a session-scoped inference grant to a user token.
POST /api/v3/coder/sessions → {"session_id", "grant", "inference_url",
"model", "expires_at", "max_calls",
"max_total_tokens"}
GET /api/v3/coder/sessions/:id → metered usage and status
DELETE /api/v3/coder/sessions/:id → revoked
Pipeline :chat_account_api, scope chat:account
(lib/openagents_web/router.ex:57). OpenAgents.Inference.mint/1 needs
owner_visitor_id, conversation_id, and machine_id, and
lib/openagents/work/coding.ex:64 already passes machine_id: nil, so the
schema takes a machine-less grant today. conversation_id resolves through
Conversations.ensure_conversation(user), which keeps the grant
generation-fenced without creating a second conversation.
This route is where every abuse control lives and must not be a thin wrapper.
It needs ceilings independent of the delegation ceilings, a cap on concurrent
active grants per account, and revocation on DELETE and on process exit. It
also needs a decision about identity that section 11 records: a delegation
grant is fenced by a machine and a conversation, and a CLI grant has no
machine.
2. Chunked delivery from the inference proxy. The proxy builds the whole
SSE body and sends it once
(lib/openagents_web/controllers/inference_proxy_controller.ex:155–:170)
because its current consumer reads the full body before parsing. Sending each
event as a chunk as the provider produces it is additive, changes no contract,
and is what turns section 3.3's waiting into real streaming. The runtime's
JavaScript transport buffers too and would need the matching change in the
probe repository, where an incremental parse state already exists and is
unused by that path.
3. A cursor on the chat event feed, if the coder ever reads it. GET /api/v3/chat/events returns every event for the account's conversation in one
unbounded array: the controller ignores its parameters and
AccountTurns.list_events/1 has no bound. A since cursor over the (run inserted_at, run id, event sequence) ordering the query already uses would make
the feed pollable. This is required only if the coder surfaces Sarah's
conversation, which section 11 argues against for the first slice.
4. Checkpoint receipts and the commit trailer that links to them. Both are
named as proposed in docs/taxonomy.md. This is Stage 4 and it is what makes a
coder-authored commit explicable. It is deliberately last, because designing a
receipt for work whose shape you have not observed produces a receipt nobody
writes.
Two things are not on this list, on purpose. There is no new tool-execution
endpoint: local tools run locally and delegated tools go through the delegation
routes that exist. And there is no POST /api/v3/coder/turns: a coder turn is
not a durable server record, for the reasons in section 4.2.
7. Tools and the approval policy
7.1 What runs locally
These execute in the runtime process, against the directory the client passed
as cwd on session/new, and never touch the network.
| Tool | ACP kind | Bound |
|---|---|---|
read_file |
read |
Offset and limit; 48 KiB output cap |
list_files |
read |
500 entries; .git, node_modules, target, dist skipped |
grep_files |
search |
200 lines |
write_file |
edit |
Path confined to the workspace, never inside .git |
edit_file |
edit |
Exact match, byte-order-mark and line-ending preserved, stale-content guard |
shell |
execute |
/bin/sh -c, no standard input, 60-second default and 240-second ceiling, grant variables removed from the child environment |
Path resolution refuses a NUL byte, an absolute path, and any parent-directory
component; a mutation additionally refuses anything under .git. This is the
runtime's behavior today, not a proposal.
Two tools the client adds, because they are the client's business and not the runtime's:
git, in two shapes. Read-only invocations (status,diff,log) are routed like any read.commitandpushare external effects and use the CLI's installed credential helper.delegate, described in section 8.
7.2 What runs on the server
Ordinary authenticated API calls the client makes with your token, carrying no special local authority: reading and writing issues, reading pull requests and stacks, and reading repository metadata, through the routes in section 6.1. They are subject to the same ladder, because "open an issue" is an external effect even though nothing on your disk changed.
7.3 The approval ladder
The runtime ships a default policy table: read, edit, search, and think
allow; delete, move, execute, fetch, and other require approval. The
edit choice is right for its original setting, where a delegated agent's
in-workspace edits are disclosed as tool-call frames under a controller's tier
policy. It is wrong for a terminal where the workspace is your own checkout and
you are watching. The client therefore configures the policy table it wants
rather than accepting the default, and edit moves to approval-required.
| Level | Applies to | Behavior |
|---|---|---|
| Automatic | read, search, think; tracker reads |
Runs, disclosed in the transcript |
| Ask once | edit inside the workspace |
Prompts; a approves that kind for the session |
| Ask every time | execute, fetch, delete, move, git push, tracker writes, delegation |
Prompts every call; a is unavailable |
| Refuse | Paths outside the workspace, unlisted tools, anything the token's scope does not cover | Typed refusal with the reason |
The rules underneath:
- The model's request never widens the level. This is the client-side mirror of TOOL-002: tool name, arguments, and prompt content grant no authority. The level comes from the configured policy table and the tool kind, decided before anything runs.
ais a client policy, not a wire option. The runtime offers exactlyallow_onceandreject_onceand never an "always", and the client must not add a third.arecords a session-scoped, kind-scoped decision in the client and answers subsequent requests of that exact kind withallow_once. Every automatic approval is still disclosed in the transcript, and the decision is discarded when the process exits. There is no binding that means "stop asking".- Refusal is a bounded typed outcome the agent must handle, never a silent substitution and never a fabricated success. That is DEGRADE-002 read as a client contract, and the runtime already implements the agent half.
--approve <class>exists for non-interactive use and names classes explicitly. There is no--yes, no--dangerously-*, and no option whose id, name, or kind matches/bypass/i— the paired-computer controller already refuses such an option, and a command that offered one locally would be lying about what it can be trusted with.- Every outcome names its executor. A tracker write the server performed and a file edit the runtime performed render differently, because TOOL-004's principle holds here: one interface must never imply OpenAgents performed hidden work.
8. Delegation: Box and connected Computer
OpenAgents.Delegations is the unified read-through facade over two
substrates: per-conversation Box VMs (OpenAgents.Box) and paired Computers
(OpenAgents.Machines, reached through OpenAgents.ComputerAgentJobs). It
stores no delegation state and derives every identifier and projection from
those substrate records, and IDENTITY-009 requires that it never widen what the
substrate allows. The coder consumes it and adds nothing.
Outbound: the coder as a delegating client. A delegate tool lists targets
through GET /api/v3/conversations/:id/delegation-targets
(router.ex:420), starts one through POST .../delegations (router.ex:421),
and polls GET .../delegations/:id (router.ex:422). Target identifiers are
opaque and kind-prefixed — box:<uuid> and computer:<uuid> — and the client
treats them as opaque. Delegation is an external effect and sits at "ask every
time". This is how a local session hands a twenty-minute build to a Box without
holding your terminal.
Inbound: the same runtime, a different client. This needs no new concept
and no new code in the CLI. ComputerProjection.project/1 already publishes an
acp_agents array for each paired Computer from the machine's last probe
document, with id, version, source, auth_ready, model, and mode;
ComputerAgentJobs.start/5 validates a requested agent_id against that
advertised list before creating a work_jobs row. The local computer
controller spawns the same runtime openagents coder spawns. The two paths
differ only in who holds the client end of the ACP connection: your terminal,
or the controller. Interactively, session/request_permission is answered by
your keystroke; under delegation, it is answered by the controller's tier
policy. Neither prompts inside the loop, which is exactly why section 2.2 is
not negotiable.
Two consequences worth stating.
- This is the agent whose
auth_readycan be honestlytrueon every paired computer. Every other catalog entry needs its own credential on the machine. This one needs the account token the machine's owner already has, and the grant is minted per session. - The interface is the only thing the terminal adds. If the OpenTUI layer is the differentiator, it is a differentiator over the same agent, which is the correct place for it to be.
Issue #127 covers the missing half of the inbound path: a delegation working a
repository issue on a connected Computer has no scoped push identity today.
openagents coder does not solve that and must not work around it. Where an
assignment credential is absent, the session reads, edits, and reports, and
says plainly that it cannot land the work.
9. Failure, offline, and expiry
No network. Local reads, grep_files, and read-only git keep working.
Every model call fails, the runtime reports a provider failure as a JSON-RPC
error on the prompt request, and the client renders it as a typed
NetworkRefused or TransportError — exit code 6 through the existing table.
The transcript is already on disk, so --resume recovers it.
The token expires mid-session. This is the sharpest gap the CLI has today.
DeviceToken.expires_in is decoded and then discarded
(packages/openagents-cli/src/device-client.ts:21, :96); nothing persists an
expiry, there is no refresh token, and no call site retries a 401. For a
command that runs for seconds this is invisible. For a session that runs for
hours it is the common failure.
The behavior: the token is used only to mint and to revoke, so an expiry
mid-session does not interrupt the current grant, which keeps working until its
own budget or clock runs out. When the next mint returns 401, the client
surfaces a typed AuthenticationRequired in the status line and suspends
rather than terminating. Any in-flight tool finishes and records its outcome.
You run openagents auth login in another terminal and press a key to retry.
Nothing is lost except elapsed time. Persisting the server-reported expiry
alongside the credential so the status line can warn before the wall arrives is
a small CLI change and belongs in Stage 2.
The grant is exhausted, expired, or revoked. Inference.resolve/1 fails
closed with grant_exhausted, grant_expired, grant_budget_reached, or
grant_revoked, each distinct. The interface shows the reason, not a generic
failure, because "you are out of budget" and "your session expired" call for
different actions. Exhaustion offers to mint a new session grant; revocation
does not.
The turn is interrupted. The runtime treats cancellation as a state
transition and returns a cancelled stop reason with the partial text intact.
Tool outcomes already recorded stay recorded, and the next prompt carries the
honest history. That is TURN-004's principle applied locally: interrupted work
becomes explicit failure, never something left presented as in progress.
The runtime dies. The client detects the closed pipe, marks the turn failed, drains and discards the child's standard error rather than rendering it, and offers to restart. A restart is a new session identifier and a new prompt, because nothing restores a transcript across runtime processes today. The client's own transcript survives, which is why section 4.2 keeps it in the client.
The renderer cannot start. Fall back to line-oriented mode, print one notice on standard error, and continue.
The process is killed. SIGINT and SIGTERM exit 130 and terminate child
processes, which the package already proves against a live process
(packages/openagents-cli/test/signals.test.ts:70, :151). The coder adds two
obligations: send SIGTERM to the runtime with SIGKILL behind it, and revoke
the grant on the way out on a best-effort basis without blocking exit. A grant
that outlives its session is a budget another process can spend.
10. Delivery
Five stages. Each names the repository it touches. Stage 1 is usable on its own, which is the test of whether the staging is honest.
Stage 1 — a grant, a client, and read-only tools.
This repository: POST, GET, and DELETE /api/v3/coder/sessions on
:chat_account_api, with ceilings, per-account concurrency limits, and
revocation, plus controller and context tests.
The openagents monorepo: openagents coder [prompt] registered at
packages/openagents-cli/src/cli.ts:909; a CoderSession service that mints
and revokes; an AcpClient service that spawns the runtime with the grant in
the child environment and speaks newline-delimited JSON-RPC over its standard
streams; line-oriented output only; the local transcript file.
The probe repository: nothing, if its published package already serves ACP
over standard input and output with the six tools; otherwise raise its
JavaScript host to parity with its native host, which today ships two of the
six.
Done when: openagents coder "what does this repository do" in a fresh
checkout answers from files it read, and openagents api "coder/sessions/<id>"
shows the metered calls.
Stage 2 — the OpenTUI interface.
The openagents monorepo only. @opentui/core as an optional dependency;
the renderer as a pure function from a session snapshot to a renderable tree,
fixture-tested; the split-footer layout; streaming Markdown; CodeRenderable
and LineNumberRenderable for tool output; the status line with grant budget;
double-escape interruption; the three-mode degradation table from section 3.6;
persisted token expiry so the status line can warn.
Done when: the interface renders on macOS and Linux and the fallback path is
a test rather than a claim.
Stage 3 — writes, approvals, and Git.
The openagents monorepo: the approval decision pane with DiffRenderable;
the configured policy table from section 7.3; the session-scoped a decision;
--approve <class>; the git tool through the existing credential helper.
The probe repository: whatever parity gaps Stage 1 exposed in the
JavaScript host's tools.
Done when: a session opens a pull request against this repository and every
mutation in it was approved through the pane.
Stage 4 — tracker tools and checkpoint receipts.
This repository: the checkpoint receipt family and the commit trailer that
links a commit to it, both currently proposed in docs/taxonomy.md; the
INVARIANTS.md entry and its executable proof.
The openagents monorepo: issue and pull-request tools over the routes in
section 6.1, and trailer emission on commit.
Done when: a forge commit page shows the session behind the change.
Stage 5 — delegation, in both directions.
The openagents monorepo: the delegate tool over the delegation routes.
This repository: nothing new, if the runtime is already advertised in probe
documents; otherwise the catalog work that puts it there.
Done when: a paired Computer advertises the runtime and Sarah delegates an
issue to it from the web surface while you drive the same runtime locally.
Chunked delivery from the inference proxy (section 6.2, item 2) is independent of all five and can land whenever someone wants real streaming.
11. Open questions
Is the runtime package installable today? The whole architecture rests on
openagents coder spawning an already-built agent rather than reimplementing
one. Its WebAssembly artifact is checked in and its ABI is synchronous, which
removes the hard obstacles, but its JavaScript host ships two of the six tools
its native host has, and whether the package is published at all is not
settleable from this repository. Settle it with: npm view @openagentsinc/probe
and, in the probe repository, git log --oneline -- packages/probe/src/tools.ts.
Does the client spawn the JavaScript host or a native binary? The
JavaScript host runs anywhere Node runs and needs no platform matrix; the
native binary is faster and has the full tool set. Shipping both means a
platform matrix inside a global npm install. Settle it with: the tool-parity
answer above, plus a measurement of WebAssembly startup on a cold npx.
What fences a CLI grant? A delegation grant is fenced by a conversation and
a machine. A CLI grant has a conversation and no machine. Whether that is
sufficient, and what per-account concurrency ceiling replaces the missing
machine fence, is a policy decision this document proposes and does not settle.
Settle it with: the ceilings in lib/openagents/inference.ex and a decision
from the owner on what a signed-in account may spend without a paired computer.
Does a coder session need an invariant of its own? Every authority boundary
here has an INVARIANTS.md entry with a named executable proof, and an
invariant without a passing proof is a wish. A client-minted, machine-less
inference grant is a new authority shape. Settle it with: whether the Stage 1
route is covered by IDENTITY-005's and TOOL-002's existing proofs or needs a
CODER-001.
Which package version is real? The openagents monorepo at df62c1fe353d
has packages/openagents-cli at version 0.2.1 with three subcommand groups
and no forum; npm serves 0.3.0, whose help output lists a forum group.
Separately, packages/openagents-cli/src/cli.ts:31 declares
VERSION = "0.1.7" while package.json says 0.2.1, which makes
scripts/verify-packed-install.mjs fail its own version assertion. Every CLI
citation here is against the monorepo tree, not against npm. Settle it with:
npm view @openagentsinc/cli versions and, in that monorepo,
git log --oneline -- packages/openagents-cli.
Can the OpenTUI artifact ship through a global npm install?
@opentui/core at 0.3.4 resolves one of eight platform-specific optional
native packages and peers web-tree-sitter. Whether npm install --global
gets a working renderer on macOS arm64, macOS x64, Linux x64, and Linux arm64
decides whether Stage 2's optional dependency is a convenience or a
requirement. Settle it with: npm view @opentui/core for the current
optional-dependency list, and one global install on each target.
Where does the local transcript live, and for how long? The CLI reads
~/.config/openagents/config.json and writes
~/.config/openagents/device-authorizations.json, and has never written a data
file of unbounded size. A session transcript is a different artifact and may
want its own root, a retention bound, and a coder clean. Settle it with:
packages/openagents-cli/src/persisted-configuration.ts:39 for the current
directory rules, and a decision on retention.
Do you want Sarah's conversation in the terminal at all? GET /api/v3/chat/events and POST /api/v3/chat/turns are reachable with a
chat:account token, so openagents coder could surface the same conversation
the web surface shows. It would also mean two authors writing into one
conversation under TURN-001's single-active-turn constraint. This document says
no for the first slice. Settle it with: whether the product wants one
conversation across surfaces, which is a question for the owner and not for the
router.
What does the ratatui prior art already answer? The probe repository's
origin/codex/issue-142-coder-compat branch carries
docs/101-openagents-coder-runtime-adapter.md and a contract test named for a
coder runtime adapter, plus design documents for a retained transcript, a typed
overlay stack, and a resumable approval broker. Some of section 3 may be
re-deciding what that lineage settled. Settle it with: git show origin/codex/issue-142-coder-compat:docs/101-openagents-coder-runtime-adapter.md
in that repository, before Stage 1 starts.