AGL
AGL is a self-hosted workspace for working with OpenCode, FlexHarness, Codex, and Claude Code from one secure browser interface. It runs the agent runtimes behind a local controller, keeps conversations organized by project, and adds durable resources, model management, permission handling, workspace history, and safe lifecycle management.
The default setup is local-first: AGL listens only on 127.0.0.1, opens no agent runtime directly to the network, and protects the browser with a passkey. A trusted TLS reverse proxy can provide remote access when needed.
Issue Reporting and Security
For reporting bugs, issues, or security vulnerabilities, please visit community.foss.global/. This is the central community hub for all issue reporting. Developers who sign and comply with our contribution agreement and go through identification can also get a code.foss.global/ account to submit Pull Requests directly.
What AGL Provides
- One browser workspace for OpenCode, FlexHarness, native Codex, and Claude Code conversations.
- Explicit projects backed by existing directories on disk.
- Live text, reasoning, tool activity, todos, permissions, and questions.
- OpenCode, Flex, Codex, and Claude Code model selection, including per-conversation choices.
- One session list across every harness and project, with search, rename, archive, and deletion.
- Synchronized drafts, file attachments, scratchpads, tasks, and goals.
- Native slash commands with transcript and Git-aware undo/redo.
- Optional detached Git worktrees owned and validated by the controller.
- Durable terminal resources that survive browser reloads.
- Claude chats: controller-owned Claude Code terminals that resume the same conversation after a restart or upgrade.
- Durable browser resources for human use and permission-gated agent automation.
- Workspace resources that serve a project's working tree to the browser, confined to the project and its secret-file policy.
- Read-only Session Intelligence for questions about an OpenCode, Codex, Claude Code or Flex conversation and other managed sessions, on the Flex model selected in Settings.
- Passkey authentication, temporary CLI passwords, and bounded audit records.
- Detached start, verified stop, health status, and coordinated in-place upgrades.
OpenCode owns OpenCode sessions, FlexHarness owns Flex sessions, Codex app-server owns Codex threads, Claude Code owns Claude Code conversations, and every mutation is sent to the runtime that owns the conversation.
Flex compaction point
In a Flex conversation, open More options → Compaction point to choose the model default, a positive token count, a whole percent from 1 to 90, or Off. Changes apply from the next run. FlexHarness owns the budget: the default is the catalog's automatic compaction point when lower, otherwise 90% of the model's served window, and token overrides are capped at that 90% ceiling. OpenAI uses the catalog's context_window, never its extended maximum; the Claude API-key pool uses its catalog's max_input_tokens.
The Tokens section of the conversation's session sidebar shows these values as of the last settled run: the model's served window as Context window, then the effective Compaction point, or a note saying why proactive compaction is off. A model without a known window has no proactive compaction by default (an explicit token override can enable it). Last observed input is marked historical: it is not a measurement of the current context. A changed setting shows there after the next run settles.
Startup explicitly migrates any stored legacy contextCompactionBytes: Off stays Off, a numeric byte budget moves to the model default, and an existing token setting wins. No byte count is presented as an exact token count. Other session settings and history are preserved.
Flex delivery confirmation
AGL gives each Flex head prompt its operation identity. On loading a conversation and after child recovery, it checks messages held as uncertain or interrupted against the native transcript, including undone turns. An exact identity with retained input clears the hold; terminal input proven not retained joins the next turn as stopped input. Missing identities and unavailable or ambiguous history stay Delivery unconfirmed, with Discard as the only action. Older heads without a native identity cannot be matched by their text. An unchanged conversation and unchanged held input reuse the last complete inconclusive inspection; transcript paging does not occupy the session mutation queue. Held messages continue to count toward the 16-message limit until settled or discarded.
When a usage-limit failure kept its input in context, Send now and Start at reset continue the answer without adding another user message. Input that was not retained is sent again. Continuation mode survives stored limit holds; Stop never makes retained input eligible to resend. FlexHarness may refuse a context it cannot safely continue, and AGL reports the failure without replaying the input. The continuation keeps read access to its original input's still-materialized files for that run. Upload paths remain temporary: after a controller restart, previously retained file paths may no longer resolve even though the stored files are kept.
A stopped Flex head appears once while held. Rejoining replaces its earlier non-retained audit appearance in the browser by exact input identity; it does not hide another message merely because its text is the same. Native history and lookup ids are preserved.
How It Works
Browser
-> AGL controller and authenticated TypedSocket API
-> official OpenCode SDK
-> private OpenCode server on 127.0.0.1
-> private framed IPC
-> isolated FlexHarness child
-> native Codex app-server protocol over owned stdio or an explicit WebSocket endpoint
-> Claude Agent SDK
-> one Claude Code process per active conversation, on a lease of the AGL proxy's Claude pool
-> controller-owned terminals, browsers, uploads, and Git state
-> SmartData persistence and audit records
OpenCode and FlexHarness are supervised independently. Flex can restart without taking down OpenCode, and an unsupported Flex runtime does not prevent OpenCode sessions or controller-owned terminals from working. The session list stays readable while a runtime or Codex profile restarts: a harness or project whose read fails keeps its previous rows until the next read succeeds.
An OpenCode replacement reports starting until its controller-owned event stream connects and session identities have been reconciled. Ordinary requests remain fenced until that recovery completes; only then does AGL report the runtime ready and announce its availability to the browser.
OpenCode is automatically recovered only after AGL observes the exact owned child exit. AGL immediately fences the exited runtime generation, aborts its in-flight requests, completes generation cleanup, and retries a fresh health-checked runtime before reopening admission. Generic or otherwise uncertain OpenCode failure remains fail-closed and is not automatically restarted. A Flex request the IPC transport refuses before writing any of its bytes (closed transport, invalid message, oversized frame, full outbound queue) fails definitely: the controller log names the method, the reason and the frame sizes, never the content, and the Flex child keeps running. A write that fails after it started may have reached the child, so its outcome is unknown and the child generation is failed closed. Fatal Flex child failures close admission and use bounded status and resource cleanup; if graceful disposal stalls, a watchdog forces the isolated child to exit so exact-generation recovery can proceed.
At startup, AGL can reclaim a detached OpenCode listener left by an earlier controller only when its launch metadata records that controller's PID and Linux process start time and that exact owner is no longer live. This works whether Linux reparents the orphan to PID 1, user systemd or a container subreaper. The listener must still match AGL's private runtime directory, user, executable, arguments and verified process group. New launches receive fresh AGL_OPENCODE_OWNER_IDENTITY metadata, never an inherited value. A live owner or missing, unreadable, malformed or inconsistent metadata prevents recovery; unmarked listeners from older releases require manual cleanup rather than guessed ownership.
Each Flex project has its own request queue in the Flex child, so a slow request of one project never holds another project's requests. When a request is cancelled — a browser tab that reloads or closes cancels its in-flight reads — the child answers at once: a request still waiting in its project's queue leaves the queue without starting, and a running read is answered as cancelled and stops holding the queue while it finishes in the background. A running write is always carried to its end, and a child that does not settle a cancelled request within two seconds is still stopped and replaced, with the request that missed the grace named in the controller log. Each child generation holds its Flex writer leases under a writer identity of its own. Once AGL has confirmed that a generation's whole process group has exited, the next generation takes over that generation's leases at once, fenced by a higher epoch, instead of waiting 30 seconds for them to expire; a lease whose writer is not proven gone still protects it until it expires. A child that fails to start reports its cause, which the controller log names.
The browser receives normalized AGL data. It never receives the OpenCode server password, provider credentials, private database descriptors, raw provider responses, or Flex private snapshots.
Live Transcript Delivery
Controller protocol v28 sends bounded live Assistant text and reasoning deltas when the browser already owns the exact preceding transcript revision. Every delta carries its stream epoch, revision, and UTF-8 coordinates; Flex deltas also carry the harness's authoritative transcript order. The browser applies only contiguous, correctly sized updates to the canonical transcript; a gap, stale owner, invalid coordinate, or queue overflow blocks further deltas from that epoch and requests authoritative session-detail hydration instead. Only adjacent compatible pending deltas may be coalesced, so interleaved updates retain their original order.
Streaming text is rendered without Markdown parsing. AGL refreshes the affected messages and enables normal Markdown rendering after the harness reports terminal state or authoritative hydration settles the transcript. This keeps long responses responsive without creating a second browser-side message graph.
Consecutive Assistant messages containing only reasoning share one header and time range. Each thought keeps its own disclosure and active thinking state; user messages, text, tools and attention requests end the group.
Each run of a turn's tool calls, with the reasoning between them, folds into one line of the transcript. While the run works, the line says what is under way: the latest heading of its reasoning, else its newest tool. Once it is done, the line says how long it worked and what it did ("Worked for 2m 13s · 6 tools", and how many failed). A click on the line shows its rows and another hides them; the line never opens or closes on its own. Text, user messages, errors, subagent calls and attention requests never fold.
Requirements
- Node.js 24, 25, or 26.
- pnpm 11.21 or newer.
- Git 2.43 or newer for workspace history and managed worktrees.
- A WebAuthn-capable browser.
- Linux or macOS for detached
start,stop, and managed upgrades. - A prebuilt
node-ptyplatform package for Linux, macOS, or Windows on x64 or arm64. - For FlexHarness, a usable local TPM2, access to its resource-manager device (normally through the
tssgroup), and/usr/bin/systemd-credswith its TPM2 runtime libraries.
FlexHarness currently runs on Linux x64 with Node.js 24 or 25 and fails closed when its TPM requirements are unavailable. Other supported controller platforms still provide OpenCode and terminal functionality.
AGL includes an embedded NoSQLDB engine (@lossless.org/nosqldb), so a separate database server is optional. It also pins the official OpenCode SDK and platform binary packages to one tested version.
Install
Install the public package:
pnpm add --global agl
To install from a specific registry, use pnpm's normal registry option:
pnpm add --global agl \
--registry=https://registry.npmjs.org
Confirm the installation:
agl --version
agl help
AGL owns authswitch as an internal account-management module and also provides the authswitch command, the command line of the accounts service. The command and the service share AGL's version and release cycle.
authswitch list # every account: plan, live usage and resets, and which tool runs on it
authswitch limits # one row per account and usage window
authswitch account list # the stored records, with the ids the account commands take
authswitch account set <account-id> --max-usage 80 # keep 20% of each usage window for use outside AGL
authswitch account set <account-id> --lapses-at 2026-11-01T00:00:00Z # a canceled subscription ends then
authswitch account recheck <account-id> # read a Claude account now; a healthy read clears its payment problem
authswitch --help
The accounts service runs as a separate systemd user service, so a controller restart does not interrupt credential renewal or command-line recovery. Install and start it through AGL's authswitch (authswitch authority service install, then enable and start); agl upgrade coordinates the service with the package replacement. Existing service names, account data, socket paths, native credential homes and AUTHSWITCH_CODEX_BIN remain valid. authswitch --version --json identifies the containing agl package. See the account-management manual.
Quick Start
Start AGL without creating an implicit project:
agl start
The first start prints a local URL and a one-time setup code. Open the URL, enter the code, and create the controller's passkey.
To register one existing directory during startup:
agl start --directory /absolute/path/to/project
AGL never derives a project from the shell's current directory and never creates project directories. Additional projects can be registered from the browser.
Check controller and harness health:
agl status
agl status --json
The default ports are:
- Browser URL and default WebAuthn origin:
http://localhost:4097. - Controller bind endpoint:
127.0.0.1:4097. - Private OpenCode server:
127.0.0.1:4098.
Accounts
Settings uses a section menu for General, Session Intelligence, Configuration, Projects, Accounts, and Codex connections. The modal keeps its size while each section scrolls independently of the heading and actions; navigation moves above the content on phones. General drafts and the Accounts screen's state survive section changes. The footer's Accounts entry opens Settings at Accounts. The theme button just left of legal switches the whole UI between the bright and the dark theme and back to the system's colour scheme; its glyph and tooltip say where it goes (a sun, a moon, or a monitor for Follow the system theme, offered once the picked theme is the one the system shows). Until it is used the UI follows the system's colour scheme, and once it is, the browser keeps the theme picked for the next visit.
The accounts service (authswitch's authority daemon) is the one place accounts and their logins live; Flex, Codex, OpenCode and Claude Code only use its accounts. Settings → Accounts is one list of its accounts, grouped under ChatGPT, Claude, OpenAI API keys and Anthropic API keys, one row per account. A row names the account, its email and plan, and one status in plain words: Ready; Needs sign-in or Needs a fresh sign-in, with Sign in again on the row; Renewal retrying, with the next attempt; Refreshed by the Codex app, Refreshed by Claude Code or Refreshed by OpenCode for a login that tool renews itself, with where to sign it in again when it needs that; and, while the accounts service is not current, the service's state (Accounts service not running, Accounts service needs a restart, …) on each of its rows. A chip names each harness that uses the account, and where: Flex · agl, website for the projects whose Flex conversations run on it, Codex · AGL for an account whose own Codex app-server serves AGL projects set to it, Claude Code · AGL terminals for the account new Claude terminals run on, Claude Code · agl for a project whose Claude terminals still run on it, and Codex · this host or Claude Code · this host for the tool whose own sign-in on this host runs on it. A harness that does not use the account has no chip. The account's usage follows as one meter per window, with the reset in local date and time, and As of 14:02 for when the provider last stated it; a window the accounts service estimated since its last read is marked (est.), and a quiet note says when the service's last read was refused by the provider for too many requests, with the time of the next one. The screen asks the accounts service for what it holds when it opens and again with Refresh; it never makes the service read the provider. An account that cannot take work carries a second badge beside its status, judged by the rule the proxy and Flex place by: Reserved (80% of weekly used) once a window reached the account's maximum usage, At its limit (weekly) once one is used up, each with when it takes work again.
An account may keep part of each usage window for use outside AGL -- to reserve 20% for Claude Design, its maximum usage is 80% (Max usage in its Details, or authswitch account set <account-id> --max-usage 80). Once any window of the account reaches it, the account is reserved, treated as at its limit until that window resets: the proxy places no new session on it and moves the sessions on it at their next request, and Flex holds a prompt for it. Unset, an account is judged on the windows its limit has always been read from; set, every window counts, a single model's as well, for the whole account. An account whose provider reports no usage has no window to reach it. Lift reserve in the account's Details opens a modal with a required choice of whole hours from 1 to 24. The accounts service saves the expiry while keeping the maximum usage unchanged. Until that time, AGL may use the reserved share; the row shows Reserve lifted until with the expiry in local date and time. Refreshing or reopening Accounts reads the same lift. At the expiry, placement and usage reads automatically apply the saved maximum again, including after a browser, controller, proxy or accounts-service restart. The lift never bypasses a provider's exhausted window or refusal, a 429 cooldown, an ended or invalid sign-in, a lapsed subscription, or account permissions. The protected request is controller.accounts.reserve.lift { accountId, expectedRevision, hours }, audited as accounts.reserve.lift; concurrent account edits are refused as account_changed.
A canceled subscription is named by its end: authswitch account set <account-id> --lapses-at <date-time> (any date
and time Date.parse reads; stored in UTC, and a past one means the account lapsed already), cleared with
--clear-lapse once it renews again. Neither provider states a cancellation to AGL -- Claude's profile and ChatGPT's
sign-in carry the plan, not whether it renews -- so the date is the owner's to set. Until the date the account is
canceled: the proxy places a new session on it within its last 24 hours only when no other account can take one.
From the date on it is lapsed: the proxy routes nothing to it again, its sessions move at their next request, and
authswitch account preuse --all leaves it out. authswitch list, limits and account list show the lifecycle and
the date.
Flex uses every OpenAI account the service holds with a login of its own: each is the Flex connection flex:authority:<accountId>, listed wherever Flex accounts are chosen. Flex keeps no login of its own.
A Flex session on a ChatGPT account runs through AGL's inference proxy, on the controller's lease of that one account's pool (proxy client flex, policy accountIds: [<accountId>]): the account the session chose serves it, and the proxy authenticates as the account, applies its maximum usage and limits, and counts every answer's tokens in the token usage under the client flex. At the account's limit the run reports the limit and its reset; it never moves to another account. The account's model catalog is read by the proxy on that account too. The Flex child is given only the proxy's address and the lease credential, never an account token. When the proxy is not running, a Flex request on a ChatGPT account fails with INFERENCE_PROXY_UNAVAILABLE; there is no direct route to ChatGPT. An account whose access ChatGPT keeps rejecting, or the accounts service cannot renew, fails with PROVIDER_REAUTH_REQUIRED, repaired by signing it in again in Settings → Accounts. Removing an account ends the controller's lease of it at once.
Flex runs Claude models through AGL's inference proxy and only on an Anthropic API key: the proxy's Claude sign-ins serve Claude Code alone, never Flex. Add account → Anthropic API key in Settings → Accounts, or authswitch account add anthropic-api-key [--label <label>] [--spend-limit <dollars>], adds a key (see API keys and their spend limit); each key is an account of its own, listed under Anthropic API keys with the login Anthropic API key. While a key serves in the proxy, the keys are one Flex connection, flex:proxy:anthropic (Anthropic API keys), whose models are the ones every key offers -- Opus, Sonnet and Haiku 5.5, each with the efforts it states as its variants -- read through the proxy and refreshed when a key joins, leaves or is added again. A Flex session on one runs on the controller's lease of the proxy's API-key pool (proxy client flex): the proxy places it on the key with the most of its monthly spend limit left for the requests in flight on it, the keys without a limit after those with one and the least busy of them first, with the in-flight limits and placements of every session it serves, and moves it off a key Anthropic rejects, answers with a 429 until that key's cooldown ends, or that reached its spend limit. A key's usage is its month's spend as the proxy reported it, against its spend limit; a maximum usage does not apply to a key. A key Anthropic rejects needs a fresh key (add it again), and fails the request with that reason. With no key added, a Flex request for a Claude model is refused with Flex runs Claude models only on an Anthropic API key, and none is added; only the host's driver session falls back to a model that is not Claude.
A ChatGPT account whose login the accounts service renews offers Use for Codex on its row: it switches Codex's own sign-in on this host to the account (authswitch.authority.codex.switch), so the conversations of this host's Codex -- AGL's and every other client's -- continue on it. It asks first, saying what changes: Codex restarts for the switch, so Codex turns running now end, in AGL and everywhere else on this host, and existing conversations stay and continue on the chosen account. While the accounts service stops Codex's app-server, writes the account's sign-in, starts it again and has Codex confirm which account it runs on, which can take a few minutes, the row says so and the screen's other actions wait; then the screen says how the switch settled: done, undone with the reason -- Codex reported an API key or another login its environment or configuration selects, its app-server did not come back -- or held until it is resolved, with Read diagnostics to see what holds it. The account Codex runs on carries Codex · this host and offers Hand to AGL instead (see below). The service switches only a Codex sign-in it manages and only to a login it renews; a row says which is missing, and while a switch is under way or could not be settled, the others wait. A Claude account and a removed account are not offered it. See Codex on an account of the accounts service.
Hand to AGL on the row of the account Codex runs on gives Codex's own sign-in to the accounts service and leaves Codex on no account (controller.accounts.codex.release { accountId, endRunningTurns }, the service's authswitch.authority.codex.release). Codex keeps working: its model requests go through the AGL proxy, which is the condition -- while Codex's own answer says it still needs its ChatGPT login (it is not routed through the proxy), the hand-over is refused and the screen points to Settings → Configuration → Inference. The service restarts Codex's app-server for it, so while Codex runs turns the hand-over is refused, unless the owner picks Hand to AGL, ending running turns. Codex renews its sign-in once more first; the service then stops Codex, asks the provider -- without renewing anything -- whether the sign-in is live, and keeps it only then (a sign-in the provider rejects or whose access has expired is not kept: it ends and the account needs a new sign-in, which the screen says), removes Codex's auth.json (its own signed-out state; nothing else of Codex's state changes), starts Codex again and has it confirm that it runs on no login, or puts the sign-in back. Remote Control on this host stops until Codex is switched to an account again, since Codex has no sign-in to pair with; the account's pairings stay saved with the account and come back with it. From then on the account is one AGL's runtimes use like any other, and Use for Codex on any account gives Codex a sign-in again. Remove on the account Codex runs on becomes Hand to AGL and remove: it hands the sign-in over first, then removes the account, and says before it runs that the account's remote-control pairings go with it. The hand-over is recorded in AGL's audit trail as accounts.codex.release.
Codex renews the login of the account it runs on, and a login has one renewer, so the accounts service refuses the switch while any runtime is bound to the account. AGL's own use of it -- Flex, the inference gateway, the account's own Codex app-server -- is therefore stopped and its bindings released first, the same step Remove takes, and nothing of AGL binds the account while the switch runs. While this host's Codex runs on an account, the account is Codex's alone: its row carries Flex · not while this host's Codex runs on it, the Flex account picker shows it disabled with the reason, its models are not offered, and Flex work still on it fails with ACCOUNT_IN_HOST_CODEX ("This account now runs this host's Codex, …") instead of moving to another account. A switch the service refuses or undoes leaves the account to AGL again at its next use, and once this host's Codex is switched to another account, the previous one is Flex's again by itself.
New Claude terminals above the list says what new Claude terminals of AGL run on, and switches it: Claude Code's own configuration (which AGL Configuration's Inference section can route through the proxy's Claude accounts), or ChatGPT models through the AGL proxy, which routes them across its ChatGPT accounts and moves a conversation to another account when one runs out (the claudeTerminals controller setting, controller.settings.update { claudeTerminals: 'claude_code' | 'chatgpt' }). Terminals already created keep what they were created with. No account row chooses Claude Code any more. See Claude chats on ChatGPT models.
Add Claude account starts a Claude sign-in in the accounts service: it makes a private Claude Code home for it and names the command that signs Claude Code in there. The sign-in waits above the list with that command, Open in a terminal, which closes Settings and runs the command in a new terminal of the project in view, and Cancel sign-in. Once Claude Code signed in, the service takes the login over -- it is the login's only renewer from then on -- and the new account appears in the list; the screen says how the sign-in ended. Add Codex's current sign-in adds the account Codex on this host is already signed in to, once (the codex_native source of the service's import inventory): the service manages Codex's sign-in from then on and can switch it, and Codex keeps renewing its own login. The service restarts Codex's app-server to prove the sign-in. It is offered until the service manages Codex's sign-in on this host.
Claude Code on this host, below New Claude terminals, says what Claude Code's own home on this host holds (controller.accounts.claude.host.get, the service's claude.host read) and offers the one action it calls for, each confirmed with what it does first. Claude Code is not switched between accounts: once AGL Configuration routes it through the AGL proxy, its own sign-in is handed to AGL with Hand to AGL (controller.accounts.claude.release { operationId, accountId, loginId, endNativeSessions }, naming the account and sign-in the read named) -- the sign-in an earlier AGL adopted moves into the service's custody, and a sign-in of an account the service already renews is signed out of Claude Code's files, which keep everything else. Before Claude Code is routed the hand-over waits and Open Inference settings opens AGL Configuration on its Inference section. A Claude Code process that started before the routing may still use the sign-in: the screen names it by pid and start, and Hand to AGL and end their sign-in hands it over anyway, after saying those processes fail at their next request until restarted. A Claude Code process on a release whose credential handling AGL has not verified (2.1.280 to 2.1.282, 2.1.288, the one AGL's own Claude chats run through the Agent SDK, and 2.1.306 are; a newer release, a remote agent's build such as 2.1.295-8b2928c330c4 or another Agent SDK's build is not yet) blocks the hand-over, also when its sign-in would be ended: the screen names each such process by pid, start and release, to be closed or restarted on a verified release first. A process counts as running on the home when its credential store is there, also when CLAUDE_SECURESTORAGE_CONFIG_DIR puts it there from another configuration directory; one configured on the home whose store is elsewhere blocks the hand-over too. A sign-in an earlier AGL adopted that can no longer be handed over -- the service holds it after a hand-over or switch it could not settle, or Claude Code's files now hold another account, none, or no readable one -- is Repaired instead (controller.accounts.claude.repair { operationId }): the service forgets it, leaves Claude Code's files as they are, and the adopted account needs a new sign-in unless a renewal the held hand-over kept is confirmed by the provider. The browser names each hand-over and repair by an operation id of its own, so one whose answer is lost is read again by it: a hand-over with controller.accounts.claude.release.get, a repair by sending it again with the same id, which the service answers with the repair it committed; both are recorded in AGL's audit trail (accounts.claude.release, accounts.claude.repair). While Claude Code renews an adopted sign-in in its routed home, its usage is not read, and its row says so.
Add account also offers OpenAI API key and Anthropic API key, which add a key as an account of its own; see API keys and their spend limit.
Add ChatGPT account starts a device sign-in in the accounts service, and Sign in again starts one for a login the service renews. The sign-in waits in the list -- above it for a new account, in the account's row otherwise -- with where to confirm it and the code, and can be cancelled there; once it ends the screen says how, and a new account appears in the list. One new-account sign-in runs at a time.
A Claude account Anthropic refused with a billing error carries a Payment problem badge beside its sign-in status, with a line saying when it was seen and that AGL routes nothing to it until a usage read shows its subscription changed or a re-check reads it healthy (see Payment problems); Re-check sits next to Show latest usage in its Details while the problem is held (controller.accounts.usage.recheck { accountId, loginId }, a read, not audited), and the screen says what the read found. A Claude account's Details also list what its profile states of the subscription: Subscription status, the value as received followed by what it means or (not described yet) -- no value is described yet -- and Subscription created, as received. AGL derives no renewal or cancellation date from either.
Identifiers stay in each row's Details: the provider, the usage state, the maximum usage (80% (20% reserved) or Not set), the account and login references, who renews each login, when the service renews it next and last checked it. Details also hold Rename, offered only for a name the service's own label rule accepts and refused if the account changed since the screen showed it; Max usage, a whole percent from 1 to 100 saved with Save maximum usage or removed with Clear maximum usage (controller.accounts.maxusage { accountId, expectedRevision, maxUsagePercent }, refused like a rename if the account changed, and recorded in AGL's audit trail as accounts.maxusage); Remove, confirmed first, which stops AGL's own use of the account before it asks the service — Flex's bindings of it are released and none is made while the removal runs, and the account's own Codex app-server is stopped; once the account is gone, projects set to it return to this host's Codex — and is refused while a runtime AGL cannot stop still uses the account, naming it, or while a tool on this host still keeps one of its logins (the inference proxy's routing of the account is no such use: the removal releases it); for the account Codex on this host runs on it is Hand to AGL and remove, see above; Show latest usage, which shows what the service holds now (the service reads the provider on its own schedule, see Usage reads); Test this login, which spends real quota and therefore runs only behind a confirmation, and whose receipt names a usage or rate limit that kept the test from running, with its window and reset, and the stated cause of a sent test that failed; and, for an account Flex can use, Refresh Flex models.
The accounts service's own panel sits collapsed at the top, headed by its status (Accounts service · Current, Last known 14:02:11 or Unavailable). Opened, it shows the connection, how many accounts the service holds and how many sign-ins wait, the service's stored diagnostics once Read diagnostics is pressed, and what AGL cannot do from the screen with the command that does it: OpenCode renews its own sign-in on this host, so running it on another account is opencode auth login in a terminal.
Nothing on the screen polls: it reads again when the controller announces a change, after its own actions, and when asked. When the service is not installed or not running, the screen names that state and what it means for running work instead of showing an empty list; when it runs an account-service protocol older than AGL requires — it was not restarted after an upgrade — the screen names the restart that repairs it (authswitch authority service stop, then authswitch authority service start) and connects again by itself once it is done; when it stopped answering, the last values it delivered stay on screen, dimmed and timestamped.
The accounts service owns provider sign-in, the switches of this host's Codex sign-in and the custody of Claude Code's own sign-in on this host. In a terminal, authswitch account add openai|claude signs an account in, authswitch account add openai-api-key|anthropic-api-key [--label <label>] [--spend-limit <dollars>] adds an API key, authswitch account set <account-id> --spend-limit <dollars>|--clear-spend-limit sets or clears its monthly spend limit, authswitch account use codex <account-id> switches this host's Codex to it, authswitch account release codex <account-id> [--end-running-turns] hands the sign-in this host's Codex runs on to the service and leaves Codex on no account (once Codex runs through the AGL proxy), authswitch account release claude <account-id> hands Claude Code's own sign-in to the service, authswitch account repair claude forgets an adopted home that can no longer be handed over, and authswitch list shows where each account is used. AGL's own passkey authentication remains separate. AGL runs no account manager of its own; it is only a client of the accounts service. Remote Codex connections keep their own account and are not changed by this host's accounts service.
API keys and their spend limit
An OpenAI or Anthropic API key is an account of its own in the accounts service, beside the ChatGPT and Claude sign-ins.
Add account in Settings → Accounts offers OpenAI API key and Anthropic API key; the key's dialog asks
for the key (masked, with no autofill, never written into the page), an optional name and an optional monthly spend
limit in dollars; authswitch account add openai-api-key|anthropic-api-key [--label <label>] [--spend-limit <dollars>]
does the same in a terminal and reads the key from standard input (a line typed at a terminal is not echoed), never from
an argument. A key that begins the way the other provider's keys do is refused before anything is sent. The accounts
service then asks the provider for the models the key may use and keeps nothing unless the provider accepts it; while it
asks, the dialog's fields are locked and it says so. A rejected key, an OpenAI key that may not read models (a
restricted key needs read access to models) and a provider that cannot be asked are refused in the dialog with what to
do. An accepted key is sealed like every other credential and never shown again: the dialog closes, the screen says the
spend limit the account has, which is the one it had when a removed key is added again without a new one, and the key's
row, under OpenAI API keys or Anthropic API keys, reads OpenAI API key ending …abcd. A key is one account:
adding a held key again is refused, adding a removed or rejected one brings its account back. Remove does not revoke
the key at the provider, as its confirmation says; revoke it in the OpenAI platform or the Claude Console. A key its
provider no longer accepts reads Key rejected and needs a new key: add one, then remove the old account.
authswitch account add openai-api-key --label "Codex key" --spend-limit 50 # asks for the key, not echoed
authswitch account set <account-id> --spend-limit 80
authswitch account set <account-id> --clear-spend-limit
authswitch account recheck <account-id> # lift a key's payment hold
AGL's inference proxy relays requests on keys when no subscription can take them (see Claude accounts first, then API
keys and ChatGPT accounts first, then OpenAI API
keys), and Flex's Claude models run on the Anthropic keys alone. A key has no
maximum usage and no reserve to lift; its limit is its spend. Spend limit under Limits on the key's page (its own
Save, or No limit), or authswitch account set <account-id> --spend-limit <dollars> (--clear-spend-limit
removes it), sets what AGL's own traffic on the key may cost in a calendar month (UTC), a whole number of cents from
$0.01 to $1,000,000. The proxy prices each answer it
relays on a key once, as the answer ends, with the model registry's price table (FlexHarness): by the model that
answered and what its usage states -- input, cache reads and writes, output, service tier, Fast mode, a region's
surcharge and server-tool calls such as web searches -- never by the request. An answer whose stream the client cuts
counts the usage it stated by then; Anthropic may bill more for it, and the Claude Console's own limit is the backstop.
The answer counts against the key's limit from then on: the proxy writes it to its own database at once and reports it
to the accounts service, which records it in the key's monthly ledger, and until the accounts service has acknowledged
it the proxy judges the key on the ledger's reading plus the answers it still holds, those of the current UTC month
only, across a restart of the proxy too. At the limit the proxy places nothing more on the key until 00:00 UTC on the first of
the next month, and its sessions move to another account; answers already running finish, so a month can end over the
limit by what the answers in flight at that moment cost. An answer the proxy could not price stops a key with a limit
until the month ends or the limit is removed, and goes to the proxy's journal. So a key with a limit is sent the request
as the price check read it, encoded again with service_tier: "standard_only" (Priority Tier has no published price),
and takes no request the price table cannot price -- a model it does not list, another service tier,
a speed or inference_geo without a price, a server tool whose exact type has no price (code execution, the advisor,
an undated tool search alias or the MCP connector; web search, web fetch and the dated tool search tools are priced): such
a request goes to an account that can take it, else it is refused with HTTP 400
invalid_request_error naming what has no price and the fix. A key without a limit takes it, and its answer counts as
one that could not be priced. A key with a limit whose spend the accounts service cannot read takes nothing until it
can; a key without a limit keeps serving. While the accounts service cannot be read, the proxy judges a key on the limit
of the last reading it holds, so a limit lowered meanwhile applies at the next successful read. If the accounts service
refuses the proxy's report of a key's answers, the proxy keeps them and counts them, says so in its journal and sends
them again every ten minutes and at every start, never dropping them; meanwhile a key with a limit takes nothing. So it
does while an answer is held in neither the proxy's database, which could not write it, nor the accounts service's
ledger. A key's answers are reported on the login they were served on, so a key removed before they were reported still
has them in its month when it is added again; an answer for which no login of the key can be named still counts and
holds a key with a limit until the accounts service names one. The limit counts AGL's traffic only, never the key's use elsewhere, and the
provider's own monthly limit (the OpenAI platform's, the Claude Console's) stays the backstop: set AGL's limit below it.
A key's month survives its removal.
A key's row in the list shows its month as its meter, in dollars (without a limit its spend alone), and the key's page a
Monthly spend meter with a line such as $12.40 of $50.00 this month · AGL traffic only · as of 14:02, or Spent
$12.40 this month · no limit · as of 14:02 without a limit, with how many answers could not be priced. Both turn to
the attention colour from 80% of the limit; at it the row's status reads At spend limit · back …, grey like any
limit and counted as held back by usage (Unpriced answer · back … for an answer that could not be priced), and the
page says At its spend limit, resets …. The overview counts the keys together (1 ChatGPT · 2 API keys). The
figure is the one the accounts service held when the screen last read it, at the time it names. The screen reads it
again on Refresh, on Show latest usage and whenever the controller announces the account as changed: at a new
month's first spend, when the limit changes and when the key reaches or leaves its limit. Nothing polls, so between
those the figure, and the 80% tone, are those of the last reading. authswitch list shows the same month in dollars.
A key its provider refuses for billing or spend is held with a payment problem that says why (see Payment
problems): a billing error, the provider's monthly spend cap (lifted at the start of the next
month), or a used-up credit or spend limit, its row's status Spend cap reached for the cap. Re-check on its page
(authswitch account recheck <account-id>) lifts the hold at once, a spend cap's too, without asking the provider; if
the provider still refuses, the next request moves to another account before a byte reaches the client and the hold is
set again.
The first start of this release's accounts service creates the spend ledger and gives every account its spend limit and
key hint. There is no downgrade past this release except restoring the accounts service's store
(~/.local/share/authswitch/authority, or $XDG_DATA_HOME/authswitch/authority) from a backup taken before the upgrade:
an older accounts service refuses to start on it.
Inference proxy
agl proxy routes inference over the accounts held by AGL's internal account authority: ChatGPT accounts
for Codex, OpenCode and other OpenAI clients, whose Responses requests fall back to the OpenAI API keys when no ChatGPT
account can take one (see ChatGPT accounts first, then OpenAI API keys),
and Claude accounts for Claude Code, which falls back to the Anthropic API
keys when no Claude account can take a request (see Claude accounts first, then API keys). The authority must be
running and signed in; the controller does not need to be running. The requested model stays the same.
Eligibility comes from each account's live model catalog, current usage and sign-in ownership. A plan
label alone is not proof of an active subscription. Accounts whose usage is stale, whose sign-in needs
attention, or whose login a native Codex or Claude Code home holds are unavailable. Optional exact plan and
account filters restrict the pool.
The proxy runs as the agl-proxy.service user unit on a fixed loopback port (4100 unless --port says
otherwise), restarted whenever it stops and never held back by systemd's start rate limit; agl proxy serve
runs the same service in a terminal instead. An installed release's proxy starts only while the agl on its PATH
answers as that release, as the pnpm-global agl the unit runs does: its credential command runs that agl, which
survives an upgrade. A proxy run from a checkout runs the checkout's own cli.js.
agl proxy service install [--port 4100]
agl proxy service enable
agl proxy service start
agl proxy status [--json]
agl proxy credential opencode --delivery file # the proxy keeps it in <AGL home>/proxy/credentials/opencode
agl proxy credential mytool --write ~/.config/mytool/agl-proxy.key
agl proxy credential codex --rotate
agl proxy credential my-claude-gpt --provider openai
agl proxy run codex --model <catalog-model> -- exec 'Explain this repository'
agl proxy run claude -- # Claude Code on Claude accounts
agl proxy run claude --provider openai --model <catalog-model> --
agl proxy run opencode --model <catalog-model> -- run 'Explain this repository'
Every client -- codex, claude, opencode, or a name of your own -- has one credential. The proxy keeps it
TPM-sealed in its own database and hands it out only over its private control socket (agl proxy credential),
so a harness reads it through a credential command (Codex's provider auth command, Claude Code's
apiKeyHelper, both running agl proxy credential <client>, the one form that prints the credential and is meant for
those commands, not for a terminal; AGL Configuration has them run it through the proxy's launcher,
<AGL home>/proxy/credential-helper <client>) or from a private 0600 file: one the proxy keeps itself with --delivery file, rewritten on every
rotation and removed with --delivery none (OpenCode's {file:...}), or one --write replaces once. The
credential survives restarts; --rotate replaces it, and the old one stops working at once. AGL Configuration
routes Codex, Claude Code and OpenCode through the proxy this way (Inference through the AGL proxy, below).
agl status shows the proxy's state; Settings → Configuration → Inference shows its unit, release and clients. run asks the
running proxy for a private credential and its pool's models, starts the child with them, and the credential
ends when the child exits.
Such a credential is a lease: it lives as long as the control connection of its holder (agl proxy run, or the
controller for a Claude terminal on ChatGPT models). A lease survives a restart of the proxy, as agl upgrade makes
one. The proxy keeps each open lease in its database -- the SHA-256 digest of the credential, never the credential, with
the client whose sessions it continues, its routing policy and when it was issued -- and a restarted proxy serves those
leases again at once, so a child's requests are not refused while it restarts: a request still has to present the
credential whose digest the lease holds, exactly as before the restart. Each holder notices its connection close,
reconnects with a bounded backoff (for up to two minutes) and takes its lease back by presenting the credential
(agl.proxy.reclaim); the lease then lives as long as that new connection, with the same pool and sessions. A lease
no holder takes back within 60 seconds of the first proxy start that served it without its holder ends and is
forgotten; the proxy keeps that time with the lease, so restarting again within the 60 seconds does not extend them.
A lease whose holder ends it is forgotten at once. The proxy answers every credential it does not keep unclaimed alike -- unknown, expired or still
held. A holder whose lease is refused, or whose proxy does not come back in time, does not leave its child failing
on a dead credential: agl proxy run stops the child and ends with the reason, and a Claude terminal stops with the
failure inference_lost and the reason, its conversation kept to restore. The proxy keeps its identity, client credentials and session routing in its own
database under the AGL home, which it alone opens; a second proxy for the same AGL home refuses to start. When
the proxy is not running, harnesses pointed at it fail visibly: there is no fallback to their own sign-ins.
agl proxy status shows the unit, the clients with their pools and the requests in flight, and each account it
routes to with its maximum usage, its headroom and whether it takes work, is reserved, at its limit or cooling down
after a provider 429 -- judged on the usage a request would route on now, which the status asks the account service
for (it answers from its own cache; a read slower than a second leaves the proxy's held reading, and an account with
none shows as unavailable), so a pool no request used since the proxy started is shown as its next request would
find it -- naming as of when, whether its
newest values came from provider answers or the account service's own read, whether a window is estimated since, and
whether the service's last read was rate-limited -- with its lifecycle (canceled with its lapse date, or lapsed), its
placement score and its rank for the next new session (--json: lifecycle, lapsesAt, placementScore,
placementRank, lastResort; an OpenAI or Anthropic API key shows its month in place of a maximum usage, as of when
the account service read its spend: available ($12.40 of $50.00 this month) with what is left of its limit as its
headroom and placement score, available ($3.20 this month, no spend limit) with neither, at its spend limit (or
held until its month ends, for an answer that could not be priced) with the month's end -- the month counts the
answers the accounts service has not recorded yet -- unavailable with unavailable.reason: spend_unread while it has a
limit and its spend could not be read, or spend_unrecorded while it has a limit and spend relayed on it is recorded
nowhere yet (a refused report, an answer neither the proxy's database nor the accounts service took, or an answer for
which no login of the key could be named to report it on), cooling down
after a 429, or held with a payment problem (the proxy's own or the account service's while it
holds), which shows it unavailable with unavailable.cause (billing, spend_cap or quota) and, for a spend cap,
retryAt; an account a request would refuse for a reason of its own shows as unavailable with that reason,
unavailable, and takes no new session; a provider's Claude or ChatGPT accounts rank before its API keys), the account each provider's next new
session of a client that names no credential goes to (nextPlacement), the latest 20
sessions placed or moved with why (recentPlacements), the count of usage reports handed to the account service and
of hand-overs that failed, and exits non-zero while the proxy is down.
Usage reads
The accounts service reads each account's usage from the provider once every 10 minutes and no more often, on its
own schedule; nobody else starts a read. The proxy, Flex, the Accounts screen and the command lines get what it holds,
at once: no question waits for a read or for a login's unseal. A read the provider refuses -- for too many requests
(429) or otherwise -- keeps the last reading, marked stale with its time; the next read waits for the provider's
retry-after or the next 10-minute slot, whichever is later, and repeated failures double the wait up to an hour;
the read after a failure comes up to a tenth of that wait later, so it does not land on the retry-after itself. A
usage service refusing a read never counts as the provider refusing the account. When the service starts it continues
where it stopped: an account read less than 10 minutes before is read next at its slot, every other one at once, the
reads spread over a few seconds and none before a retry-after the provider named.
Every provider answer the proxy relays states the serving account's usage in its headers -- a Claude account's
five-hour and weekly utilization (anthropic-ratelimit-unified-*), a ChatGPT account's main-limit windows
(x-codex-*) -- including a 429 and an answer whose stream is cut later, since headers come first. The proxy reads
them and hands them to the account service, which joins them to its own reading (at most one report per account at a
time, and one that states nothing new only every 30 seconds). Such an answer re-anchors the windows it names; it never
starts or postpones a read. The proxy's own refusals are never read. A Claude account whose usage service reports
nothing is judged on the windows its answers state, so a maximum usage applies to it as well.
Between reads the service estimates a Claude account's windows from the tokens the proxy relays: the proxy reads each
Claude answer's usage (input, cache reads, cache writes, output) as its bytes pass, without changing a byte or its
timing, and reports them with the headers. The tokens are weighted by Anthropic's published prices relative to the
model's base input price (cache writes 1.25x for 5 minutes and 2x for an hour, cache reads 0.1x, output 5x), and
carried forward with a percent per token the service learns per account, window and model from two reads and the tokens
between them. Before one is learned for a model it takes the highest any other account learned for that model, then a
ratio learned for another model of its family (Opus, Sonnet, Haiku, ...), then a seed for the family scaled by
Anthropic's published prices, and the value is marked estimated; a model whose id names no family takes a conservative
default. A window's estimate never falls below its last stated value and never exceeds 100; a model's own weekly window
counts only that model's tokens. Once a window's stated reset passes it starts again from 0 with only the tokens
counted since the reset, and a ChatGPT window starts again from 0, its main limit no longer refused once each used-up
window has (each of its windows where the reading names none used up), so a used-up account takes work again at its
reset without waiting for the next read. Placement, maximum
usage and the screens decide on the estimate. Whether a subscription's limits follow API prices is not stated by
Anthropic; the learned ratio absorbs the difference. The service keeps the learned ratios, the tokens counted and the
newest provider answer per account in its database, written at most once a minute and when it stops, so a restart
continues from them.
An inference request that answers with a rate-limit error (429) makes the service read that account's usage at once
-- one read however many requests hit the limit, as soon as the usage service allows and no sooner than five minutes
after the account's last read that landed, and the 10-minute clock starts again after it. The account cools down meanwhile so sessions move; once the fresh reading is held, a cool-down the
provider's Retry-After named runs its course and any other ends, so an account whose window is used up rests until
that window resets and one with room takes work again.
Each client is served from one pool. The claude client starts on Claude accounts, every other client on
ChatGPT accounts; agl proxy credential <client> --provider openai|anthropic moves a client to the other
pool and keeps its credential. agl proxy run claude uses Claude accounts unless --provider openai asks for
ChatGPT models behind Claude Code; run codex and run opencode use ChatGPT accounts.
The listener binds only 127.0.0.1 and requires the exact host and port, no browser Origin, and
Authorization: Bearer <credential> (or x-api-key).
- A ChatGPT pool serves
/openai/v1/responsesand/openai/v1/models(the pooled Codex catalog in Codex's own shape when Codex names itsclient_version), and/anthropic/v1/messagesand/anthropic/v1/modelsfor Claude Code on ChatGPT models. Raw Responses requests retain their storage and streaming choices. Claude Code's Messages requests are translated by the shared provider package;/anthropic/v1/messages/count_tokensanswers 404 there by design, on which Claude Code estimates the count itself. Other OpenAI endpoints, such as Chat Completions, and WebSocket inference are not served. - A Claude pool serves Claude Code's own requests:
/anthropic/v1/messages,/anthropic/v1/messages/count_tokensand/anthropic/v1/models. The request body goes to Anthropic byte for byte, with Claude Code's own headers (anthropic-version,anthropic-beta,user-agent,x-app,x-claude-code-session-id,x-stainless-*); the proxy authenticates as the account with its OAuth access token and theoauth-2025-04-20beta, and never forwards the client's credential. Anthropic's status, body and headers (request-id,retry-after,anthropic-ratelimit-*) come back unchanged, except that a 401 or 403 about the chosen account is the proxy's own error./anthropic/v1/modelslists the models every account of the pool offers.agl proxy run claudekeeps Claude Code's own configuration and home, pointsANTHROPIC_BASE_URLandANTHROPIC_AUTH_TOKENat the proxy (which takes precedence over a stored claude.ai login, left untouched), and setsCLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY=1andCLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1. The account authority renews each Claude login exactly once, for the proxy and for its usage reads alike; switching this host's Claude Code to a login, or moving it into a container home, releases the proxy's binding of it in the same change.
The router places a new session by how much usage each account can spend per hour, and keeps that account for the
session: the prompt cache always comes first, so a session is never placed again while its account can serve it. An
account's placement score is the least, over the windows its limit counts, of the headroom left below its threshold
divided by the hours until that window resets -- or until the account lapses, if that comes first -- and at least one
hour; a window whose reset passed but was not read again counts to the end of its next period
(ts_authswitch/authority-placement.ts). A new session goes to the account with the highest score per request it
serves now (score / (1 + in flight)), so new sessions spread over the accounts in proportion to what each can spend:
quota that resets soon, or is lost when the account lapses, is used first, and an account that resets in days is
spared. An account that lapses within a day comes after every other, and an account without a counted window after
every scored one. Within the client's pool, only current quota exhaustion, a definite provider HTTP 429 before
forwarding output, the account lapsing or leaving the pool, or a Claude account taking no request for a reason of its
own (below) permits moving the session to another eligible account.
The proxy records every session it places and every move, with why (new, exhausted, reserved, cooling,
provider_429, left_pool, outside_restriction, lapsed, unavailable, returned), in its own database for 30 days; a failed record is
reported on its standard error and never fails the request. The replacement stays selected until it
also reaches its limit; an earlier account recovering does not pull the session back, except that a session on an
Anthropic API key returns to a Claude account (below). An earlier AGL's agl proxy status fails while a returned
placement is among the latest 20; routing is unaffected. An account
restriction is the operator's choice: a session whose account --account leaves out moves the same way to
an eligible account of the restriction, and is refused only when none of them can serve it. Plan filter changes,
transport failures, 5xx responses and Anthropic's 529 overload leave the mapping unchanged and return an error; so do a
missing usage reading, sign-in failures and unavailable models for a ChatGPT session. A Claude session, whose requests
carry no state bound to an account, moves instead (unavailable) when its account takes no request for a reason of its own:
no usage reading to route on (usage_unread), no model list (catalog_failed, with the HTTP status or failure), a
sign-in to renew (needs_login), no access from the account service (access_failed), a model list without the
requested model (model_missing), or a payment problem (payment_problem, see Payment problems);
the refusal of a session that cannot move names the account and the reason. When it moves and no other account takes
the request, it gets the 429 only while another account is at its limit; otherwise the 503 for want of an account (the
404 when its account lacked the model, the 402 for a payment problem). Limits and cool-downs are judged before an account's model list is read, so an account at its limit is never read for a
request: its last model list says whether it would serve the model, and without one it counts as at its limit. A model
list is read again once it is a minute old; a model-list read that fails keeps the account on the list read before, and
with none the account is unavailable. A sign-in or access failure, met reading the list or resolving the chosen
account's access for a request, makes the account unavailable even with a list held; a new session then goes to the
next account. A failed model-list read or sign-in is not repeated for a minute; a failed access, usually a brief outage
of the account service, is tried again after 5 seconds, together with a fresh model-list read. A model-list 429 never
cools the account down, and each change of an account's reason is one line on the proxy's standard error. Started streams are never replayed. A rate-limited
account cools down for the provider's Retry-After interval, or 60 seconds when none is supplied -- ended early by a
usage reading made after the 429 -- and its usage must also permit use. A ChatGPT account is at its limit when its usage reading says so or a window
reaches 100%. A Claude account is at its limit when one of its account-wide windows (the five-hour session, the
week) reaches 100%, the provider's retry time lies ahead, or its latest answer refused it until a reset; its per-model weekly windows are not read, because
the reading does not say which models they cover. An account with a maximum usage is reserved once any of its
windows -- per-model and named extra limits included -- reaches it, and is treated exactly as at its limit: its
sessions move at their next request, and a pool whose accounts are all at their limits or their reserve answers
with the earliest time one takes work again ("All eligible accounts are at their limits or their reserve.").
count_tokens uses no quota, so the session's account
answers it even at its limit -- not with a payment problem -- and the session never moves for it. Each request tries at most the 64 accounts
allowed in its pool.
Claude accounts first, then API keys
A Claude pool whose client names no credential -- Claude Code's own lease, agl proxy run claude and the claude
client -- is served by its Claude accounts first and by the Anthropic API keys only when no Claude account can take a
request, for whatever reason: every one at its limit or reserve, cooling down, without a usage reading, model list or
access, or with a payment problem. A new session then goes to the key with the most of its monthly spend limit left
for the requests in flight on it, the keys without a limit after those with one and the least busy of them first, and
a session on a Claude account moves to one at its next request. A session on a key returns to a Claude account at the
next request one can take (returned in the placement journal), with no waiting period; Claude Code's request reaches
the key unchanged, as it reaches an account (thinking signatures carry; the prompt cache starts cold). count_tokens never moves a session. A key at its
monthly spend limit takes nothing until the month ends, and a key with a limit whose spend could not be read takes
nothing until it can (see API keys and their spend limit). When every Claude account
is at its limit and every key is cooling down after a provider 429 or at its spend limit, Claude Code gets the 429 with
the earliest time any of them takes work again, naming each key at its spend limit with its month in dollars; a 503
for want of an account names the keys whose spend could not be read or recorded, and a 400 invalid_request_error
names what of a request a key with a limit could not price, when nothing else takes it. The
pool's models (/anthropic/v1/models) are those of its best tier with an account whose model list could be read: the
Claude accounts' while one of them answers its list, else the keys'. A pool that names its credential keeps to it:
Flex's API-key pool never takes a Claude account, a pool of the sign-ins alone (credential: 'subscription') never
takes a key, and a lease with a plan restriction never reaches a key, which has no plan.
ChatGPT accounts first, then OpenAI API keys
A ChatGPT pool whose client names no credential -- Codex's, OpenCode's and agl proxy run codex|opencode's -- is served
by its ChatGPT accounts first and by the OpenAI API keys only when no ChatGPT account can take a request, as a Claude
pool falls back to its keys; a pool of keys alone (credential: 'api_key', or no ChatGPT account at all) is served by
its keys. A key takes Responses requests (/v1/responses) alone: a translated Messages request (Claude Code on ChatGPT
models) never goes to a key, and the Codex catalog (/v1/models?client_version=…) is the ChatGPT accounts' only, so on
a pool of keys alone Codex answers 503 there and runs on the catalog it bundles. The proxy's own model list
(/v1/models, and an agl proxy run lease) is the ChatGPT accounts' while one of them answers its catalog, else the
models every key lists (OpenAI's GET /v1/models, read again after a minute) that the model registry describes.
A request goes to OpenAI's API (https://api.openai.com/v1/responses) on the key's own credential, as the client sent
it with only its model rewritten: no ChatGPT, organization or project header, and a Codex turn token only to the
account that issued it. A key with a monthly spend limit is sent the request as the price check read it, encoded again
with the service tier it was checked for -- default (Standard) when the client sent auto or none, else the tier it
named if the model has a price for it (flex, priority, fast; scale has none) -- and takes no request the price
table cannot price: a model or dated snapshot it does not list, a background response (background: true), or a server
tool whose exact type has no call fee (web_search_preview, a dated web search, code interpreter, image generation, a
hosted MCP server, hosted tool search, a shell in OpenAI's container). Tools the client runs itself cost tokens only:
functions, custom tools, the local shell, apply patch, computer use, and tool search the client runs
(execution: "client", as Codex sends it); web search and file search are priced per call. Such a request goes to an
account that can take it, else it is refused with HTTP 400 model_unpriced naming what has no price. A key without a
limit takes it.
An answer is priced as it ends by the model and the service tier OpenAI states for it, with every web_search_call and
file_search_call item one call whatever its action -- so AGL's figure can exceed OpenAI's bill for searches OpenAI does
not charge -- and a completed, incomplete or failed answer is priced on the usage it states. An answer as a model the
price table does not list, a dated snapshot included, one with no service tier, and one whose final event is too large
to read (over 8 MiB) cannot be priced: it holds a key with a limit until the month ends, and the proxy's journal names
the model. An answer that ends without OpenAI stating its usage -- the client left, the stream was cut, or the response
failed stating none -- is not counted at all and no usage is estimated for it; the journal says so for each one. So AGL's figure can be below OpenAI's
bill, and the OpenAI project's own budget is the backstop: OpenAI's 429 project_spend_limit_exceeded (or any quota
refusal) holds the key with a payment problem and its sessions move.
A session on a key moves to another account when the key reaches its limit or OpenAI refuses it for its quota, as a
session on a ChatGPT account at its limit does: its conversation state stays behind (x-agl-account-state-reset). A key
that takes no request for a reason of its own -- its spend could not be read or recorded, its model list could not be
read, the accounts service answered no access for it -- refuses the session's request with a 503 instead, since the
session's state is bound to it; a new session goes to the next key. A session on a key
returns to a ChatGPT account at a request one can take, but only while the request carries no account state (no
reference, encrypted content or item id, which a Codex conversation carries from its second turn on). A key OpenAI
rejects (401) is ended: the request gets a 503
account_unauthorized saying to add a valid key and remove the rejected one. A key OpenAI refuses with 403 during a request
(it lacks a permission that request needs) answers that request with 403 provider_forbidden; the key is not held and
the session does not move, so grant the key the permission or remove it. When every ChatGPT account and key is at
its limit, Codex gets usage_limit_reached naming each key at its spend limit with its month in dollars.
Clients see limits in their own terms. Codex receives the serving account's x-codex-* rate-limit headers,
and usage_limit_reached with the earliest reset and plan once every account is exhausted. Claude Code
receives the serving account's anthropic-ratelimit-unified-* headers, and once every Claude account is
exhausted a rate_limit_error with anthropic-ratelimit-unified-status: rejected, the reset and
x-should-retry: false, so it shows the limit instead of retrying. Other clients receive HTTP 429 with
Retry-After.
Payment problems
A Claude account that Anthropic answers with HTTP 402 whose JSON body names error.type: billing_error (the proxy reads
at most 64 KiB of a 402) has a payment problem. So has an API key that its provider refuses for its organization's
billing or spend: Anthropic's 402 billing_error, Anthropic's 429 rate_limit_error with error.details.error_code: enforced_spend_limit_reached (the organization reached its usage tier's monthly spend cap, which holds until 00:00 UTC
on the first day of the next month), or OpenAI's 429 whose error.code is credit_balance_exhausted,
organization_spend_limit_exceeded, project_spend_limit_exceeded or organization_usage_limit_exceeded, or whose
error.type is insufficient_quota. The proxy takes the key out of rotation the same way and tells the accounts service, which holds the
problem on the key's usage with its cause: a spend cap until it ends, anything else until a re-check reads the key
healthy. An accounts service that does not hold a key's payment problem leaves it to the proxy, which holds a spend cap
until it ends and anything else until it restarts. A spend limit set in the Claude Console answers HTTP 400 and
is not read as a payment problem: set AGL's own limit below it. The proxy takes it out of rotation at once and tells the accounts service
(authswitch.authority.usage.paymentProblem { accountId, loginId, observedAt }), which holds the problem on the
account's usage record, so it survives a restart of either. The request goes to another eligible account before a byte
reaches Claude Code and is never sent to that account again; with no other account to take it, the client gets the
provider's own answer unchanged. Any other 402 -- a body that is not JSON, names another error type or is larger than 64 KiB
-- passes through as it came, and so does every 402 of count_tokens, which is never reported. A session on the account moves at its next request (unavailable in the placement journal), and
count_tokens skips the account too. When payment problems are all that is left, Claude Code gets a 402
billing_error that names the accounts; while another account is merely at its limit, the 429 rate_limit_error
stands: a payment problem never counts as a limit. agl proxy status shows such an account as unavailable (Anthropic refused a request on it with a billing error (HTTP 402), a payment problem), and authswitch list --json carries it as
paymentProblem { at, subscription }, beside each Claude account's subscription facts.
The account comes back once a usage read of the accounts service -- its 10-minute read, or the one a 429 asks for --
finds the profile, the sign-in accepted and the identity matched, and states a subscription (subscription_status,
subscription_created_at) other than the one it held at the 402, or once a re-check reads it healthy: Re-check in
the account's Details, or authswitch account recheck <account-id>, which reads the account at once and clears the
problem whatever subscription it finds. A read that fails, or finds the same subscription, clears nothing; a re-check
that clears nothing says why (the sign-in was rejected, no token was usable, the profile could not be read, it named
another account, or the read did not run). A problem reported before the service read the account since it last
started holds no subscription to compare yet: the next healthy read writes the subscription it finds into it without
clearing it, and from then on a read clears it only when it finds a different one. A further 402 holds the account
again with the subscription as read then. A billing_error inside an answer that is already streaming is out of scope: the proxy reads
a 402 only before any byte has reached the client. An accounts service of 40.2.0 or older does not know the report; the
proxy then keeps the account out of rotation itself until it restarts, and says so once on its standard error.
Token usage
The proxy also records the tokens every answer states -- input, output, cache reads and cache writes -- per minute and per
hour, model, client name, account and the session id the client sent, minutes for a day and hours for eight days, also
for an Anthropic API key; it keeps no prompt, answer or credential, and a write that fails is reported on its standard
error and retried with the next. The controller reads these only while a browser watches a usage window:
controller.metrics.usage.watch { window } (1h by minute, 24h or 7d by hour) answers the window's buckets with
a watchId, the feed's epoch and a revision, and the changes follow on that connection alone as usage.changed
events, each continuing the revision before it, until controller.metrics.usage.unwatch { watchId } or the
connection's end; a browser applies a delta only on its epoch and window at the next revision and watches again
otherwise, which a reset delta asks for after the proxy restarted, the controller fell behind its writes or a change
was too large. While a window is watched the controller asks the proxy (agl.proxy.usage on its control socket: a
snapshot page of a span, or the bucket writes since a revision) every five seconds, and it keeps only the spans the
watched windows show, from the start of the longest; with no watch it asks nothing and keeps nothing. A window of more
than 20,000 buckets is refused (usage_too_large) without refusing a shorter one; a watched window that grows past it
is told to watch again and refused. An unreachable proxy refuses a watch with proxy_unavailable.
The web app's header has a Metrics button. It shows the token usage in the main pane, as the Board button
shows the board; leaving it ends its watch. A toggle chooses the last hour, day or week. Totals for input, output,
cache reads, cache writes and calls head the view; below them a line chart shows the tokens per minute by model, each
model's line drawn from zero (each completed minute for the hour, the hourly average for the day and week, the current
hour's over its minutes so far), and pointing at a moment lists every model's value there with the time. A table lists
every model the window counted, sorted by total tokens. A model's row opens a second table of its sessions by client and session id. The
first load shows placeholders and says Still loading chart… after 10 s; a refused watch shows its reason with
Retry, and the view follows the usage.changed deltas without reading the window again.
Large requests and stream failures
The proxy accepts JSON request bodies up to 32 MiB and HTTP request headers up to 64 KiB in total
(raised from Node's 16 KiB default). These are transport bounds, not a model's context window. A body over
the bound returns JSON HTTP 413 with request_too_large; oversized headers return JSON HTTP 431 with
request_headers_too_large, even when Node rejects them before the Responses handler runs. Both refusals
are logged without request content, headers or credentials. The server also limits requests in flight,
header receipt to 10 seconds and request receipt to 60 seconds.
A successful streaming Responses answer must end with response.completed, response.failed or
response.incomplete. An empty upstream HTTP 200 or a provider failure before any response body byte
reaches the client becomes a logged JSON HTTP 502, not an empty successful stream. A failure after output
starts is logged and ends with a Codex-readable response.failed event, never a fabricated completion or
a replay. Provider SSE error events are mapped to explicit failures; context_length_exceeded before
output returns HTTP 400 with that code. Valid provider terminal events and successful output remain unchanged.
UND_ERR_SOCKET is not retried by the proxy, even before the first response body byte. Only the existing
one-time same-account credential renewal on HTTP 401 and pre-output account hand-over on HTTP 429 replay
requests. A client may retry a transient HTTP 502, but must not repeatedly resend an identical oversized
history on HTTP 413 or context_length_exceeded. Codex compaction sends the history too: requesting
compaction cannot rescue a history that the compaction request itself cannot fit. The client must reduce
its compaction input while preserving complete tool-call/result pairs, or start a new thread with a bounded
handover. A 431 requires reducing headers. The proxy never silently truncates history or changes accounts
to hide an input-size or provider transport failure.
Custom clients should send a stable x-agl-session-id for every turn. In its absence the gateway
uses, in order, x-claude-code-session-id, x-session-affinity (which OpenCode sends to a custom provider,
beside its prompt_cache_key), or the Responses prompt_cache_key. An identifier must be nonempty and at
most 512 bytes. A credential with no session identifier names one shared session, so clients multiplexing
conversations must supply identifiers. Identifiers belong to the client that sends them: the same identifier
from two clients names two sessions, each with its own account and in-flight limit. A client of the service
(agl proxy credential <name>) keeps its sessions across proxy restarts and credential rotations, and
agl proxy run <harness> continues the sessions of the client named like the harness, so a resumed Codex or
Claude Code session keeps its account across runs. Sessions are kept per pool: the same identifier sent to a
ChatGPT pool and to a Claude pool names two sessions, so agl proxy run claude --provider openai beside the
claude client on Claude accounts never moves the other's session between pools.
Switching accounts starts a cold cache. Full-history requests retain their text and tool calls;
encrypted reasoning, thinking signatures and inline provider item IDs belonging to earlier accounts
are not sent to the replacement. On a Claude pool Claude Code's request goes to the new account unchanged, as
Anthropic's documentation describes: thinking signatures are portable, except that Claude Sonnet 5.5's thinking is
account-bound, so Anthropic silently drops that model's thinking another account signed and answers without it. The
response reports a move or a removal through x-agl-account-state-reset: true.
Opaque response IDs, conversation IDs and item references cannot transfer: if they would cross accounts,
the gateway returns 409 session_history_required before dispatch or changing the mapping. Resend the
complete history without those references to continue on the replacement account. Unknown or expired
provider state also returns 409. Opaque state ownership is immutable even when the session moves.
Session mappings contain domain-separated hashes, account IDs and concurrency generations in the proxy's own database and do not expire when a provider cache expires. Separate opaque-state affinity records have a 30-day expiration renewed when the provider emits that state again. They contain no prompts, response text, credentials or raw reasoning. The service releases account bindings on shutdown and reconciles the bindings a crashed predecessor of the same installation left once it holds its port; the installation is the proxy's own persisted identity, not its port. Removing an account, switching this host's Codex or Claude Code to its login, or moving its Claude login into a container home releases the proxy's binding with it, and the sessions it served move at their next request. Limits are 64 accounts per pool, 16 requests in flight per session, 256 per client credential, 512 in all, 32 MiB per request and 8 MiB per inspected response event (or non-streaming JSON response).
Native Codex Harness
Choose New → Chat session and pick the Codex harness to use Codex inside AGL. Native threads support text and reasoning streaming, tool activity, the Codex model and reasoning-effort catalog, command and file approvals, ordinary user-input questions, interruption, history pagination, rename, archive, and deletion. AGL Ask/Yolo controls apply to the exact pending native approval request; approvals never outlive its server request.
Install Codex CLI 0.156.0 or newer and sign in using Codex before starting AGL. The local connection uses the existing Codex app-server control socket when present. This is the WebSocket-over-Unix transport described by the Codex app-server protocol, at CODEX_HOME/app-server-control/app-server-control.sock (the default home is ~/.codex). Codex publishes that path as a link to a socket in its private per-user directory, and AGL attaches only to that published link. AGL owns only its connection to that shared server, and accepts it only when the Codex home the server reports in initialize is AGL's own. If nothing exists at the path, AGL starts its own codex app-server --listen stdio:// child, unless Codex's managed daemon serves this home: its state under CODEX_HOME/app-server-daemon/ (daemon.lock, settings.json, daemon.pid or the older app-server.pid) proves it, and AGL then waits for the daemon instead of starting a second writer for the same threads. Codex never removes that state, so start the daemon with codex app-server daemon start; AGL rejoins it on its own or on Reconnect. Anything else there, including a socket bound directly at the path or a link whose socket is gone after the daemon died, is reported as a connection failure, as is a published socket that does not answer; AGL never starts a private server beside it. Set AGL_CODEX_EXECUTABLE to an absolute executable path when Codex is not on the controller's PATH; an explicit absolute CODEX_HOME is forwarded to the owned child.
When stopping its own Codex child, AGL verifies and signals the process group before closing the stdio connection, keeping the ownership proof while background children change. A group whose ownership cannot be verified still refuses signaling and reports incomplete cleanup.
Open Settings → Codex to save named remote connections, test them, or reconnect one profile. A project can also be set to a ChatGPT account of the accounts service, whose own Codex app-server then runs the project's new conversations apart from this host's Codex (see Codex on an account of the accounts service). The usage of the account the local Codex runs on is on its row in Settings → Accounts. Use ws:// for a loopback endpoint or wss:// for remote TLS. Enter a bearer token separately; credentials are sealed through the controller host's TPM and persisted encrypted through SmartData. URLs remain immutable so an existing conversation cannot be redirected to another server. Credential replacement disconnects the old connection before activating the new revision. Retiring a profile prevents new conversations from using it while preserving existing origins.
For each project, select its connection and server-native directory. AGL checks that directory through Codex before saving it. Existing conversations retain their original server and directory when the project mapping changes. Models, reasoning efforts, availability and local-attachment support follow that same conversation or draft context. Remote connections disable local file attachments because the remote host cannot access AGL's upload directory. Existing AGL_CODEX_SERVER_URL and AGL_CODEX_SERVER_TOKEN configuration is imported once during the versioned migration; subsequent changes use Settings.
The sidebar automatically discovers non-archived CLI and Desktop conversations from the mapped project directory. Manual enrollment remains available in Settings for an explicit lookup. Opening the same thread on the same app-server shares its native history and live state with other clients, including a Desktop remote connection; sharing an account alone does not select the same thread. AGL never starts or stops an externally owned server. The open conversation's context menu offers Stop following in AGL while AGL is following the conversation, which unsubscribes AGL after its own active and queued prompts have settled, and Resume in AGL on a released conversation to join again; while AGL owns a running turn, or prompts of its own are still queued, there is nothing to hand over and neither action is offered. Between the transcript and composer, Codex reports another client's turn, a released conversation, a model reroute, a paused prompt queue, and — while AGL runs the turn — which model Steer and Queue will use. A completed diff is summarized as added and removed line counts; View edits opens that turn's captured patch. Binary, incomplete, and unparseable patches are labelled instead of receiving invented counts.
When another Codex client owns the active turn, its conversation is marked orange in the sidebar and the whole composer stays read-only and copyable. An agent message (MCP session_message) is the one input that reaches such a turn: it steers exactly the turn named in its expectedTurnId (turn/steer with that turn's identity) and never makes it AGL's — the other client keeps the turn and the conversation. A steer that turn no longer takes is refused with turn_ended instead of starting an AGL turn, a steer the turn dropped because it was stopped or failed is held for the owner, and a message that would have to queue behind that turn is refused. Attachments, model, mode, effort, send, steer, and queue controls remain unavailable until ownership returns. Stop is independent: it is enabled only when the controller can interrupt the exact observed turn. Local unsent text and attachments survive ownership changes, refreshes, and rejected steering. While another client runs a turn, AGL follows its live updates and accepts the next prompt after that turn finishes. Neither client needs to release the conversation first.
While AGL owns a running Codex turn, Steer sends the message into that exact turn and Queue retains a separate follow-up with its selected model (see Steering and pending messages). A steer that no turn can take any more — the turn ended, or another one runs — becomes the next turn's input. Codex records a steer still pending when its turn completes into the conversation; one it dropped because the turn was stopped or failed is held. Queued prompts survive an isolated Codex restart or reconnect within the running controller. Stop holds queued work instead of cancelling it. An undispatched failure preserves the queued prompt and offers Resume queue; an uncertain dispatched request is never replayed. Interrupted tools show Stopped, and native plans, diffs and observed model reroutes remain attached to their turn.
AGL records a durable creation intent and selected connection before asking Codex for a server-assigned thread ID. It admits that exact ID into managed conversation state and finalizes the requested title, if one was given, and model before sending any turn. A conversation created without a title keeps no native name, so Codex's own preview, its first prompt, names it once that prompt lands. The last answer of each turn AGL watched shows the turn's model and the tokens it used (from thread/tokenUsage/updated, the growth of the thread's running total over the turn); Codex keeps no usage in its turn history, so AGL stores each watched turn's model and tokens in its own database (agl_controller_codex_turns, keyed to the conversation and turn, at most 256 turns per conversation) and shows them after a reload or a controller restart; turns that ran while AGL was not watching show none. A stopped turn shows the reply it was interrupted in, followed by the marker The turn was stopped. (the marker alone when it was not answering yet): Codex discards an interrupted reply, so AGL keeps it in the same record, capped at 64 KiB of UTF-8. The records stay while their conversation is archived and are deleted with the conversation or its project. New public IDs include their profile identity; shipped legacy IDs remain unchanged. The durable origin outlives creation-intent retention. If initial settings cannot be finalized, the composer keeps that conversation and its unsent draft; the next send finishes setup on the same thread. Local shutdown verifies the owned process group before releasing its uploads; losing a shared or remote connection is not proof that its server stopped. An unavailable generation stays fenced until a reconnect succeeds. When the local shared app-server goes away, for example because its daemon restarted or updated, AGL rejoins it on its own: after the lost generation is cleaned up it retries after 1 second, doubling to 30 seconds, for up to 10 minutes, and each attempt needs the published socket, a supported Codex version and a server that reports AGL's Codex home. Once the profile has connected to the shared server since the controller started, the attempt also needs the server identity (socket, private socket and Codex home) AGL recorded at that last connection; a profile whose shared server was absent at startup is checked by the socket and Codex home alone until it connects. While it waits, a Codex operation fails with harness_unavailable naming the profile, its diagnostic and the reconnection state, and the conversation says it is reconnecting. A returned server with another identity stays fenced with its own diagnostic, and after the deadline the conversation offers Reconnect, the same explicit reconnect as Settings → Codex. Both run the same recovery, and an explicit reconnect cancels the automatic one or, while an attempt is connecting, waits for it and then runs as explicit; queued prompts that were never dispatched resume, while a queue paused by a failed dispatch waits for Resume queue or an explicit reconnect. Remote profiles and AGL's own local child are reconnected only explicitly. The codex entry of agl status and controller.status is healthy only when every connected and required profile is, and lists each under profiles with its reconnection state. An unresolved remote creation requires the original server's thread identity to be resolved before recovery can safely proceed. Codex migration or connection failure leaves OpenCode and Flex available.
Native Codex root conversations in the mapped folder appear in the workspace regardless of which client created them. Discovery preserves each conversation's server origin when the project mapping changes. Native Codex uses its selected server's account; AGL's Flex account selector does not change it. The supported Codex commands are /model, /plan [prompt], /fork, /review [instructions], /rename <title>, /new, /clear, /resume, /copy, /status, /pwd, /usage, /agent (also /subagents), /archive, and /delete. They are discovered and authorized by the controller before AGL opens the corresponding picker, conversation/sidebar view, confirmation dialog, or native operation. /permissions, /mention, and /compact remain visible with their unavailable reason; Codex does not return the compaction turn ID needed to correlate /compact safely. A mode mutation blocks subsequent mode-dependent dispatch until an exact native settings notification confirms the applied mode. A transport-unknown outcome stays explicitly unconfirmed and is never treated as success from a cached session read. Unknown command-shaped input is rejected without becoming a paid chat prompt, while absolute paths remain ordinary prompt text. Secret-input and unsupported custom host requests are rejected explicitly. Session Intelligence reads managed Codex conversations, including externally owned and released ones, without resuming them, starting or steering a turn, or changing their writer (see Session Intelligence). Browser resources can attach to Codex conversations. A directly launched Codex task can control its attached browser through agl mcp; trusted Flex run channels remain a separate integration.
Codex on an account of the accounts service
Codex runs on the accounts the accounts service holds in two ways: this host's own Codex switches between them, and a project can run on an account's own Codex app-server apart from it.
Use for Codex (controller.codex.account.use { accountId }) switches this host's own Codex home -- CODEX_HOME or ~/.codex -- to a ChatGPT account whose login the service renews. The service (authswitch.authority.codex.switch) stops Codex's app-server (codex app-server daemon stop), so running Codex turns end; puts the outgoing sign-in into its own custody and the incoming one into Codex's auth.json; starts the app-server again with the environment it ran with; and commits only once Codex itself reports the incoming account. The conversations in that home continue on the account, and AGL rejoins the app-server as after any restart. The controller follows the switch with the service's long poll (watchCodexNativeHandoff) until it is settled and answers it: committed, aborted with the problem or proof failure that undid it, or quarantined, which holds Codex's sign-in until the owner repairs it and is listed by authswitch authority doctor. An account Codex already runs on changes nothing; from no account, after a hand-over, the switch takes the account's sign-in into a home that holds none, and is refused while Codex holds a sign-in of its own again (sign it out with codex logout first). The service switches only a home it adopted: Add Codex's current sign-in (controller.accounts.codex.adopt) submits the codex_native source of its import inventory, once. Codex's sign-in is one grant per account: Codex renews the login it runs on, and the service every other one.
A project set to an account runs its new conversations on the account's own Codex app-server, apart from this host's Codex. The project's connection picker in Settings → Codex lists every ChatGPT account the service holds; choosing one without a profile makes it (controller.codex.account.profile { accountId }, "Codex · ") before the project is mapped to it. The account this host's Codex runs on is listed as unavailable with the reason and refused by the controller (codex_account_unavailable), since its one grant is in this host's Codex; so is an account without a login the service renews. The service is the login's only refresher on such a server. Codex's own credential store for it is ephemeral and no auth.json is written. Codex sends its token-refresh request (account/chatgptAuthTokens/refresh) to every attached client and takes the first answer, so AGL leaves it unanswered on every Codex connection and never keeps it pending: AGL never supplies a login to Codex, and whoever did answers.
- The service runs one app-server per account for this AGL installation, in the scope
agl/<installationId>/codex/<accountId>, on the account's own Codex home,$AGL_HOME/codex-homes/<accountId>, which holds that server's configuration and threads. AGL accepts the server only when it reports that home. - Every conversation keeps the profile it was created on; a project's other conversations stay where they are when its connection changes.
- The app-server is the service's and outlives the controller. A controller that restarts, or whose connection ended, asks the service again and rejoins the same server, or one the service starts anew, with the automatic reconnection the shared app-server has. While the service cannot give one, the profile's diagnostic is
account_unavailable. - Removing the account in Settings → Accounts stops its app-server first; the service refuses to remove an account whose managed Codex may still run. The Codex home stays.
- Once Codex can no longer run on an account apart from this host -- the service no longer holds it, removed in AGL or anywhere else, while the controller runs or before it starts, or this host's Codex was switched to it, which stops its app-server -- no project stays mapped to it: every project mapped to its profile moves to the default, or to this host's own Codex while the default is a remote server, and a default on it returns to this host's own Codex. The controller log names the accounts and how many projects moved. Only a current report of the service decides this; the last-known one kept while it is away does not.
Native Claude Code Harness
Choose New → Chat session and pick Claude Code to run Claude Code inside AGL. AGL drives it through the Claude Agent SDK, one Claude Code process for each active conversation. Conversations support text and thinking streaming, tool cards, permission and question cards, steering and queued follow-ups, Stop, attachments, the model and effort pickers, rename, archive, and deletion. Claude Code reads its user, project and local settings and runs with its own system prompt. Only conversations created in AGL are listed; AGL records each one with its project and directory.
Claude Code runs on the AGL inference proxy's Claude pool, never on a key or login of its own. The controller takes one lease of the pool and starts every Claude Code process with only the lease's endpoint and credential (ANTHROPIC_BASE_URL, ANTHROPIC_AUTH_TOKEN). Its requests therefore go through the proxy's account placement, usage reads and limits like all other Claude traffic. A process runs with the controller's own Claude Code configuration folder (its CLAUDE_CONFIG_DIR, by default ~/.claude), where Claude Code stores the transcript. While the proxy cannot give a lease, a send fails with harness_unavailable. When the lease ends, the processes on it are closed, and the next message starts one on a new lease.
- Models. The model picker offers the models the proxy's Claude pool lists, which are those every account of the pool offers. The effort picker offers the efforts the catalog states for the selected model, narrowed to those Claude Code lists for it (
supportedModels) once one of its processes has listed the model. A process lists Claude Code's aliases and the model it started with, so a model no process has listed yet keeps the catalog's efforts. The pool names no default model. A conversation without a choice runs on the default model saved in Settings (settings.defaultModels) while the pool offers it, else on Claude Code's own default, which AGL does not name (see Models And Provider Accounts). If the pool does not offer Claude Code's own default, the turn fails with Claude Code's message. Changing the model or effort applies from the next turn. - Ask and Yolo. Ask runs Claude Code in its default permission mode. Every tool call Claude Code asks about becomes a permission card, answered with Allow or Reject. Yolo makes AGL answer each card once, automatically; it never switches Claude Code to
bypassPermissions.AskUserQuestionbecomes a question card andExitPlanModea permission card carrying the plan. - Transcript. Thinking, text and tool calls stream into the transcript as Claude Code produces them. A
Tasksubagent is shown as its tool card. The transcript is Claude Code's own: AGL reads it from Claude Code's store, so a conversation reopens with its history after a reload, a controller restart or an upgrade. Its next message resumes the same Claude Code conversation. - Steer and Queue. While AGL runs a turn, Steer hands the message to Claude Code, which takes it in between tool rounds or, if the turn ends first, runs it as its own turn right after. Queue keeps a follow-up in AGL's queue until the conversation is idle (see Steering and pending messages). A steer that a stop or a failure cancelled before Claude Code took it is held for the owner, as for Codex.
- Stop and failures. Stop interrupts the running turn and keeps the partial reply. Where Claude Code supports it (
interrupt_cancel_queued_v1), it also cancels the messages queued inside Claude Code. A failed turn leaves the conversation in the error state with Claude Code's message until the next turn starts, as a failed Codex turn does. A Claude Code process that ends during a turn fails that turn. - Lifecycle. At most 16 Claude Code processes run at once. A process closes after 15 idle minutes, and the least recently used idle one makes room for a new one. Archive is AGL's own; Claude Code has no archive. Deletion removes the Claude Code transcript and AGL's record, and is refused while the conversation is working.
- Agents and commands. An agent message (MCP
session_message) reaches a Claude Code conversation as a message carrying its sender's attribution in the text. AGL offers no slash commands for Claude Code conversations. Session Intelligence reads a Claude Code conversation from Claude Code's store without starting a process.
Claude Code conversations are separate from Claude chats, which are terminal resources running the claude CLI.
MCP Server
The installed agl command includes a unified stdio MCP server that combines 84 tools on one connection:
- Nine CrossHarness tools for explicit OpenCode and Codex harness-server connections.
- Six system inspection and reclaim-planning tools.
- Thirteen durable
tstasktools. - Fifty-six AGL controller tools for projects, task discovery, control and messaging, models, scratchpads, resource lifecycle and attachment, managed jobs, driver fleet operations, the shared project ticket board, and challenge-only worker enrollment.
Configure an MCP host to execute:
agl mcp --port 4097
--port takes precedence over AGL_CONTROLLER_PORT; otherwise the controller port defaults to 4097. agl mcp starts and exposes all 84 tools without a running AGL controller. Only the AGL controller tools require a ready controller on the configured port; the CrossHarness, system, and durable-task tools remain available independently. agl mcp requires Linux x64 with Node.js >=24.12.0 <27.
On supported systems, a ready controller publishes a process- and generation-fenced private loopback endpoint under $AGL_HOME/runtime/mcp. The descriptor and bearer token remain owner-only runtime state. They are discovered and verified automatically; do not copy them into MCP configuration. The endpoint accepts only the explicitly registered, bounded AGL methods and is removed before controller shutdown proceeds.
Controller upgrades
A running agl mcp keeps serving its MCP session when the controller moves to a newer build, for example after agl upgrade. The first AGL tool call that finds the controller running a newer release than its own moves the process onto that build in place: it re-executes itself with process.execve, keeping its PID, stdin, stdout and stderr, and the new build continues the session. The call that noticed the upgrade is answered by the new build, since it never reached the controller; the client receives notifications/tools/list_changed, because the new build may offer other tools; and nothing restarts from the MCP host's point of view.
The target is the build the controller itself runs: the cli.js named on the command line of the controller process that the verified runtime descriptor identifies, inside a package whose package.json is this package at the controller's version. The process never re-executes into an older or equal release, and a process that was re-executed for a release but did not land on it never tries that release again, so a mismatched installation cannot start a re-exec loop. The client's initialize request, its initialized notification, the calls to re-run and any stdin bytes read but not yet parsed pass to the new build in a one-shot record, mode 0600 in $AGL_HOME/runtime/mcp-handover, which the new build deletes as it reads it. The new build refuses a record that names another process ID, and the next handover removes records older than ten minutes that no build consumed.
Some state lives only in the MCP process, and a re-exec would lose it. While the process holds durable-task claim authority (a tstask lease it claimed and still holds), an open CrossHarness connection or a CrossHarness dispatch record (a send_message_async still running, or a settled reply check_reply can still return), the move is deferred: the tool call returns BRIDGE_UPGRADE_DEFERRED naming both releases and what is still held, and the next AGL tool call after it is released tries again. New tstask claims are fenced from the moment the process checks until it re-executes, so no claim starts in between; the fence is lifted when the move is deferred or fails. Other MCP requests still in flight are awaited for up to five seconds before the move is deferred as well. BRIDGE_UPGRADE_FAILED means the controller's build could not be verified or re-executed, and its message says whether to call again (the controller was not ready) or to restart the MCP server; BRIDGE_UPGRADE_UNSUPPORTED means this Node.js has no process.execve, and the MCP server must be restarted. process.execve is experimental in Node.js; every supported Node.js release provides it. Before Node.js 26.1 a failed execve ends the process instead of returning an error, so the process checks the Node.js executable before it re-executes.
Caller identity
Every MCP call carries the identity of the chat it serves, and the controller authorizes on that identity instead of on a task id supplied in the request. When AGL starts a chat it mints a per-start caller credential and writes it into that process's environment as AGL_MCP_CALLER_CREDENTIAL; the agl mcp server the chat launches reads it from the environment and presents it on the private endpoint. The credential is never an argument, never inheritable, never persisted, never logged, and never echoed in a response. It dies with the chat: a terminal exit, a task losing its exact managed identity, an OpenCode restart, or controller shutdown all revoke it, and the next start mints a new one.
A caller is one of three subjects:
- Terminal — a chat running as a terminal resource, such as a Claude chat. The terminal conversation itself is the subject: resources are attached to the terminal, and the caller sees and acts on exactly what is attached to that conversation. The fence is the coding agent's own conversation id, so an attachment survives a terminal restart that resumes the same conversation and is refused once the conversation changes. A terminal subject manages an attached browser — create, attach, rename, start, stop, retire — and drives it as its own conversation: BrowserRuntime issues driving authority to a conversation, and the conversation that owns the terminal is one. A plain shell terminal owns no conversation and therefore drives nothing.
- Runtime — a shared harness server. OpenCode runs one server per controller and addresses MCP servers per directory rather than per task, so an OpenCode-launched caller is identified as that runtime, not as one chat: it may act for its own tasks in the project named in each request. This is deliberately coarser than a terminal subject, and narrowing it requires per-task MCP servers upstream in OpenCode.
- Task — a chat AGL manages as a task, acting only for that task in that task's project. The subject exists in the model and is enforced everywhere, but no task credential is issued yet: AGL cannot stamp a per-task credential into a harness that shares one server across tasks. Until OpenCode can carry a task id into the MCP server it launches, OpenCode chats act through the runtime subject.
Reading is open to every caller that holds the descriptor token, identified or not: controller_status, projects_list, sessions_list, context_resolve, models_list, session_read and session_scratchpad_read behave exactly as before. That keeps the cross-harness workflow intact, where a chat running outside any AGL terminal — and therefore without an AGL identity — inspects AGL-managed tasks. resources_list shows the unattached project resources to such a caller.
Writing is not open. Task mutations (session_send, session_scratchpad_update, session_model_set, session_rename, session_archive, session_stop) require the calling chat to be the task itself, or its shared harness runtime; a terminal caller owns no task and an unidentified host owns nothing. Resource mutations, project_add, session_create and browser_action require an AGL identity as well, and so does every job tool: jobs belong to a project, and any identified caller of that project may inspect, wait for or stop them. Every caller must still run as the controller's local OS user; a remote MCP process cannot access this local endpoint without a separate authenticated transport.
One agent messages another task with session_message, and any caller holding the descriptor token may, identified or not: a chat running outside AGL can consult an AGL-managed task. The target is an active root task of the project named in the request; archived tasks and child conversations take no messages. The message is not the task's own input, so the controller puts a header in front of it that the receiving model and every client showing the native transcript read — [Agent message via AGL from "<sender>" — <subject>; not from the user], a blank line, then the text. The sender is the label the caller declares (1 to 64 printable characters, required). The subject is what the controller proved about the caller: task <harness>:"<title>", terminal "<title>", OpenCode runtime, or unidentified local caller (self-declared sender); titles are quoted with control and separator characters removed, so neither a label nor a title can forge that line. The caller chooses the delivery: steer into the running turn, next-turn or queue (see Steering and pending messages), and the result names how the message was delivered (delivery, with turnId when a Codex turn took it). OpenCode cannot be steered, and expectedTurnId applies only to a Codex steer (DELIVERY_UNSUPPORTED otherwise). A Codex steer names the turn it is for in expectedTurnId, the codexActivity.turnId of session_read: it is required to steer a turn another Codex client runs and optional for AGL's own turn, and a message for any other turn, or for one that ended, is refused before anything is sent (TURN_ENDED, CONCURRENT_CHANGE). A message waiting in a conversation says who sent it (IControllerPendingPrompt.sender), and every request that reaches the controller, refusals included, is audited as session.message with the caller and the declared sender (senderLabel); a request that fails the tool's schema is rejected before that. Text is limited to 16 KiB, and one caller may send one task at most 6 messages a minute and 60 an hour (MESSAGE_RATE_LIMITED); unidentified callers share one such budget per task. The per-conversation and controller-wide message limits apply as well. Settings → Projects has an Agent messages switch per project, on by default (controller.project.agentmessages.update); turned off, the project's tasks refuse agent messages with AGENT_MESSAGES_DISABLED.
Agent messages are attribution and the owner's control, not a security boundary: any process of the same OS user can read the descriptor or reach a Codex app-server socket directly. The ownership rule for task mutations keeps an agent from acting as another task through the sanctioned tools; session_message lets it speak to one, labelled as such.
| Tools | Behavior |
|---|---|
controller_status, projects_list, project_add |
Inspect the controller and register existing project directories. |
context_resolve, sessions_list |
Resolve an exact canonical project folder and discover its nonarchived native root tasks from the configured OpenCode, Flex, and Codex connections. |
session_read, session_send |
Read a task, or submit text through the existing harness connection. Reading is open to any caller; sending requires the calling chat to own the task. |
session_message |
Deliver an attributed message to another task of the project: steered into its running turn, as the next turn's input, or queued. Open to any caller unless the owner turned agent messages off for the project. |
models_list, session_model_set |
Inspect available models and reasoning variants, the model each harness's new conversations run on (defaults, with a stored default the harness no longer offers named as unavailableSetting) and the order AGL's account placement takes Flex accounts in (flexAccountPlacement); save the model used by subsequent AGL submissions. Pass project and task IDs to use a particular Codex task's connection. Saving requires the calling chat to own the task. |
session_create, session_rename, session_archive, session_stop |
Create or rename a task, archive idle work, or interrupt a running task. Creation does not send a prompt and requires an AGL identity; the rest require the calling chat to own the task. |
session_scratchpad_read, session_scratchpad_update |
Read or revise shared task notes. Reading is open to any caller; revising requires the calling chat to own the task. |
resources_list, resource_create, resource_rename |
Inspect and create project browser, terminal or workspace resources. The listing shows the resources attached to the calling chat plus the unattached ones. A chat creates a workspace at the project directory; where its tree starts is the human's choice. |
resource_attach, resource_detach |
Attach a resource to the calling chat, or detach a resource already attached to it, using its current attachment revision. A shared runtime caller names which of its own tasks to attach to; attaching a resource to another chat is a workspace-UI operation. |
resource_start, resource_stop, resource_retire |
Control the lifecycle of a resource attached to the calling chat or of an unattached one. Retirement removes it from the active resource list. |
browser_action |
Inspect, navigate, click, fill, press keys, or capture an image in a browser attached to the calling chat, alongside human viewers. The caller acts as its own conversation — for a terminal subject, the conversation that owns the terminal. |
workspace_reveal |
Show one path in a workspace attached to the calling chat: a file opens in the editor, a directory in the file tree. The path is project-relative and must lie inside the workspace's base directory. The reply is an acknowledgement and the entry type; the tool never reads a file. |
job_start, jobs_list, job_status |
Start a host command as a managed job that outlives chats and controller restarts, list a project's newest jobs, or read one job's state, exit code or signal, times and log size. |
job_tail, job_wait, job_stop |
Read a bounded range of a job's log, wait up to four minutes for it to end, or stop its whole process group. |
tickets_list, ticket_get, ticket_create, ticket_update, ticket_move, ticket_comment, ticket_link |
Capture and progress shared project tickets with verified worker identity, atomic board ordering, comments, activity and branch evidence for Review. |
ticket_question_ask |
Ask the owner a question on the caller's own assigned ticket, with optional choices; the answer arrives in the caller's session as a ticket notice. |
To work with resources for an externally launched task, call context_resolve with its absolute directory and optional { harnessId, nativeId } task ID. A raw Codex thread ID is accepted only if exactly one configured connection matches; an ambiguous ID requires selecting the qualified ID returned by sessions_list. Discovery records the verified native task in AGL without resuming it or acquiring its Codex writer. Subsequent operations reuse the normal UI validation, audit, admission, and ownership checks. Targets must belong to the exact project and configured native connection. Adding a project does not connect an arbitrary external harness server.
Use the returned project and task IDs with resources_list or resource_create, then resource_attach with the resource's attachment.revision as expectedAttachmentRevision. Then call browser_action with the project and resource IDs, the current expectedAttachmentRevision, and an action such as { "action": "snapshot" } or { "action": "navigate", "url": "https://example.com/" }. The same workflow works for OpenCode, Flex, and Codex attachments. Several chats may be attached to one browser and all of them can drive it; each acts as itself, another chat attaching or detaching does not end your authority, and your own actions are still ordered against theirs by the attachment revision you observed. A terminal subject drives the browsers attached to its terminal as the conversation that owns that terminal, so an agent running in an AGL terminal drives its own browser; borrowing another chat's conversation is still refused rather than allowed, and a plain shell terminal, which owns no conversation, drives nothing. Browser actions have a 20-second deadline. Screenshots return MCP image content from that action's own lease and remove the temporary artifact after reading it; images are limited to 512 KiB, so use JPEG with lower quality if necessary. Live terminal input continues to use its resource transport.
Private controller requests are limited to 256 KiB and responses to 1 MiB. AGL text tool results are limited to 512 KiB; screenshot image bytes are separately limited to 512 KiB before base64 encoding, and session_send accepts at most 64 KiB of UTF-8 text.
session_send submits its supplied text directly through normal AGL session admission, model validation, queueing, audit, and harness acknowledgement. It never reads, clears, or emits browser composer draft state. If a mutation reports OUTCOME_UNKNOWN, inspect current state before acting again: the operation may already have succeeded. The MCP client does not replay mutations automatically.
session_scratchpad_read returns the shared durable scratchpad and its current revision. session_scratchpad_update accepts at most 32,768 characters and 128 KiB of UTF-8 text, requires the revision returned by the read, and reports CONCURRENT_CHANGE instead of overwriting a newer browser, Session Intelligence, or agent edit. Successful MCP updates are attributed to the agent.
Task mutations are limited to the calling chat's own task. Every task operation revalidates the recorded project, origin, generation, and deletion/archive boundaries, and can discover an externally created native root before use. Browser-only OpenCode direct-child scopes, child attention, child transcript reads, and child replies are never exposed through the private MCP endpoint.
Managed jobs
A job runs one long host command — a release, a multi-gigabyte proof download, a CI poll — so that it finishes no matter what happens to the chat that started it. The controller launches a small supervisor in its own session; the supervisor starts the command in a process group of its own, copies the command's output into the job log, and atomically writes an exit record once the command and every process it left in its group have ended. Stopping or upgrading the controller never signals a job. The next controller adopts every running job: a supervisor whose PID and kernel start time still match is watched again, a job whose exit record appeared meanwhile gets that exit recorded, and a job with neither is marked lost together with the log size it reached. lost means the supervisor vanished without recording an end: when something outside AGL killed the supervisor itself, the command may still be running, because it runs in its own process group and AGL can no longer prove which group that is.
job_start { projectId, argv, cwd, title, env? }runsargvdirectly, never through a shell; pass["bash", "-lc", "…"]explicitly when a shell is really wanted.argv[0]is resolved to an absolute program path against the controller's own sanitizedPATH(or againstcwdwhen it contains a/) before anything starts, and every job reports it asexecutablePath; the program still seesargv[0]as its name.cwdmust be an existing directory inside the project, reached without symbolic links.envadds up to 64 variables to the sanitized controller environment; only the variable names are recorded, never their values. A project runs at most 16 jobs at once.envmay set neither a controller-reserved variable nor a known execution-altering variable; such a request fails withjob_env_forbidden. The denylist is defense in depth, not a guarantee: no list of such variables is complete, so a policy hook must evaluate theenvnames andcwdtogether withargv. The denylist (jobExecutionAlteringEnvironment, matched case-insensitively) covers the program search path and configuration roots (PATH,HOME,XDG_CONFIG_HOME,SHELL); shell startup (ENV,BASH_ENV,BASHOPTS,SHELLOPTS,PROMPT_COMMAND,PS4,IFS,ZDOTDIR,BASH_FUNC_*); the dynamic loader (LD_*,DYLD_*,GCONV_PATH); interpreter preloads and module paths (NODE_OPTIONS,NODE_PATH,NODE_REPL_EXTERNAL_MODULE,PERL5OPT,PERL5LIB,PERLLIB,PERL5DB,PYTHONPATH,PYTHONHOME,PYTHONSTARTUP,PYTHONWARNINGS,PYTHONBREAKPOINT,PYTHONUSERBASE,PYTHONEXECUTABLE,RUBYOPT,RUBYLIB,LUA_INIT,LUA_PATH,LUA_CPATH,PHPRC,PHP_INI_SCAN_DIR,JAVA_TOOL_OPTIONS,_JAVA_OPTIONS,JDK_JAVA_OPTIONS); the git repository selection, whose hooks and configuration then run (GIT_DIR,GIT_COMMON_DIR,GIT_WORK_TREE); the helper commands of git, ssh, sudo, editors and pagers (GIT_*_COMMAND,GIT_CONFIG*,GIT_SSH,GIT_ASKPASS,GIT_EXEC_PATH,GIT_EDITOR,GIT_SEQUENCE_EDITOR,GIT_PAGER,GIT_EXTERNAL_DIFF,GIT_TEMPLATE_DIR,SSH_ASKPASS,SSH_ASKPASS_REQUIRE,SUDO_ASKPASS,GNUPGHOME,EDITOR,VISUAL,PAGER,MANPAGER,BROWSER); build tools and toolchains (CC,CXX,CPP,LD,AR,MAKE,MAKEFLAGS,MFLAGS,MAKEFILES,GOFLAGS,GOTOOLCHAIN,GOENV,GOROOT,RUSTC,RUSTC_WRAPPER,RUSTC_WORKSPACE_WRAPPER,RUSTDOC,RUSTUP_TOOLCHAIN,RUSTUP_HOME,CARGO_HOME,CARGO_BUILD_RUSTC,CARGO_BUILD_RUSTC_WRAPPER,CARGO_BUILD_RUSTC_WORKSPACE_WRAPPER,CARGO_BUILD_RUSTDOC,CARGO_TARGET_*_RUNNER,CARGO_TARGET_*_LINKER,JAVA_HOME,CLASSPATH,GRADLE_USER_HOME,GRADLE_OPTS,MAVEN_OPTS,ANT_OPTS,GEM_HOME,GEM_PATH,BUNDLE_GEMFILE,NPM_CONFIG_*); cloud CLI configuration that can name credential commands (KUBECONFIG,AWS_CONFIG_FILE,AWS_SHARED_CREDENTIALS_FILE,AWS_PROFILE,AWS_DEFAULT_PROFILE,TF_CLI_CONFIG_FILE); and the Docker endpoint and configuration (DOCKER_HOST,DOCKER_CONTEXT,DOCKER_CONFIG).job_statusandjobs_listreport the state —running,exited,stoppedorlost— with the exit code or signal, start and end times,executablePath, and the caller that started the job, recorded as its subject kind and audit digest, never as its credential.job_statusadds the log size and, once the log reached its cap,logTruncatedAt.job_tail { jobId, fromOffset?, maxBytes?, stripAnsi? }returns at most 64 KiB (default 32 KiB) and thenextOffsetto continue from; a chunk never splits a UTF-8 character. WithoutfromOffsetit returns the newest bytes.stripAnsiremoves terminal escape sequences; offsets always count raw log bytes.job_wait { jobId, timeoutMs }returns as soon as the job ends, or withtimedOut: trueafter at most 240,000 ms, which fits a four-minute heartbeat. The tool waits in controller slices of at most 20 seconds, so a controller restart during a long wait surfaces as an error to retry, never as a lost job.job_stop { jobId, signal? }asks the supervisor to end the command's process group:SIGTERM(orSIGINT) reaches the whole group andSIGKILLfollows after 10 seconds, orSIGKILLends it at once; the job becomesstopped, neverexited. The controller signals only the supervisor, and only while its PID proof holds, so a reused PID is never hit. When the command ends by itself, processes it left behind in its group get the sameSIGTERM, thenSIGKILL, before the exit is recorded.- The log keeps at most 256 MiB of output. At the cap the supervisor stops appending, writes one
agl job: output truncated after <n> bytesline and a log record, and keeps the job running;job_statusandjob_tailreportlogTruncatedAt. When a write to the log fails, for example on a full disk, the supervisor stops logging the same way, recordslogFailurewith the error code when it still can, and keeps supervising: the job still ends, stops and records its exit as usual. Should the supervisor itself hit a fault it cannot handle, it ends the command's process group before it exits, so the job reads aslostrather than running unsupervised. Output is copied through the supervisor, so stdout and stderr each keep their order but interleave by arrival.
Job records live in the controller database as state transitions and are never deleted. The log (output.log), the exit record (exit.json) and the log record (log.json) are written by the detached supervisor, which has to survive the controller, under $AGL_HOME/runtime/jobs/<jobId>/ with mode 0600. They are disposable process artifacts, not application data: the controller reads exit.json to tell exited from lost, and the durable job state is the database record. If a later release keeps job logs as lasting history, they move to @lossless.org/client/objectstorage instead of staying on the filesystem. Jobs run on the host as the controller's user. A service manager that kills the controller's whole control group on stop, such as a systemd unit with KillMode=control-group, kills jobs as well; agl upgrade does not. The web UI view of jobs and log retention follow in a later release.
The optional @modelprofile.com/mcp-tstask dependency is required when starting agl mcp. If the package manager omitted it, the server exits with a clear startup error instead of exposing a partial tool manifest.
AGL API for Flex sessions
A Flex session reaches the AGL controller tools without agl mcp. Its root run finds them in the same tool catalog as its MCP servers' tools (see Configuration), named after their controller methods (agl.agl_mcp_project_create for project_add), which search_tools finds, describe_tools declares and execute or a run_code program calls; subagents do not get them. Their declarations and documentation are generated from the controller's typed requests.
The controller answers each call while the run is active, as the session's task subject (see Caller identity), with the authority an MCP caller of that session has. The owner token never crosses, so the methods that take it are not offered: Configuration, controller settings, the fleet operations and answering ticket questions. Neither are worker enrollment, session_send, session_archive and session_stop, which would act on the calling session itself, nor browser_action, which Flex's own browser tools cover. A method that acts on a project acts on the session's own project, which AGL fills in; resource_attach and resource_detach act for the calling session; and job_start takes no env, whose values no model enters. The Driver session's catalog has no ticket methods: it keeps its own ticket tools.
Reads run at once and concurrently. session_read, context_resolve, job_status, job_tail and job_wait run on every call, since what they read changes while the run waits. A change is proposed, so a run_code program's changes run in order once the program finished, and asks a permission first:
agl.api.changefor a normal change, which the session remembers for all of them once allowed;agl.api.warning, remembered per method, forsession_message,project_add,session_create,session_model_set,resource_stopandjob_stop;agl.api.danger, asked on every call, forresource_retire;shell.executeforjob_start, the permission Flex's shell tools ask.
A refusal reaches the model in the controller's words, with its code and, for a failure, the reference to look up in the controller log.
Everyday Workflows
Driver fleet: land, release and audit
Fleet operations run on the controller's host. There is no central repository list: fleet
discovers every canonical checkout (a directory whose .git is a directory, not a linked
worktree) up to three levels below the controller's standard project directories, plus each
registered project that is itself a canonical checkout. A checkout belongs to the fleet when
its .smartconfig.json declares a @git.zone/cli release or a fleet section; "fleet": false
opts it out. Each setting is read from the repository itself:
id: the directory name, orparent/namewhile two checkouts share a name;remote:git config branch.main.remote, elseorigin;publishedPackages: when the npm release target is enabled, thepackage.jsonname (unless private) and everyts*/tspublish.jsonname, which the gitzone plan must match;registries:@git.zone/cli.release.targets.npm.registries;- the land settings below: the
@modelprofile.com/agl.fleetsection, else empty.
{
"@modelprofile.com/agl": {
"fleet": {
"tests": ["test/test.feature.node.ts"],
"guards": ["test/test.webhelpers.*.node.ts"],
"fullSuitePaths": ["ts/shared/**", "ts_interfaces/**"],
"ciBuildSteps": [
{"command": "pnpm", "args": ["run", "build"]},
{"command": "pnpm", "args": ["run", "build:control"]}
],
"environmentFrom": {"EXAMPLE_RELEASE_MACOS_SSH": "EXAMPLE_RELEASE_MACOS_SSH"}
}
}
}
agl fleet audit lists each discovered repository with the file and key of every setting in
sources, and each skipped checkout with its reason (opted out, an invalid section, an
unreadable file or an ambiguous id). A repository name that matches no discovered checkout
refuses with that reason. remote names the remote that canonical main tracks, such as
lossless; fleet fetches, bases and pushes through it alone. List every build step CI requires
in ciBuildSteps, including auxiliary builds. Test and guard paths support globs under test/;
every matching file runs separately and must report a non-empty passing count. Configure a
repository's full guard set explicitly. fullSuitePaths lists repository-relative globs for
shared code: a landing that changes a matching file runs pnpm test instead of the named tests
and guards. "toolchain": "deno" verifies with deno task check and deno task test,
installing pnpm dependencies first only when the repository has a pnpm-lock.yaml.
environment supplies non-secret literal settings; environmentFrom maps destination
variables to host value names. Host-specific values such as a Mac builder address do not belong
in a repository: keep them in the Configuration master's fleet.environment, which
environmentFrom reads first, before the controller's own environment. Save it with the
existing agl config master get and agl config master save --expected-revision <revision>
workflow, preserving the other master fields. Never store credentials in either place.
A master saved before discovery that still holds fleet.repositories reads without it; its
literal environment values move into fleet.environment, where existing values win.
The audit also lists, as unboundDrivers, the Claude Code background jobs (claude --bg) in the
host's and every fleet profile's Claude Code home that look like drivers and that no fleet worker
binds. A job looks like a driver when its name starts with drv-, or when a ticket comment by the
driver role names its session, which AGL records only for a local CLI caller whose process it
proved runs inside that job (see Shared project tickets). Each entry gives the job's session id,
short id, name, directory, state (running, settled, failed or unknown), the fleet profile
whose Claude Code home holds it (or none) and the one command that adopts it once it settles,
agl fleet worker adopt NAME --native-id ID --cwd DIR --profile PROFILE --ticket TICKET, with
the job's name, directory and profile filled in. A job in the host's own Claude Code home also
carries nativeMessage, the agl fleet worker native-message ID --message-file FILE --sender LABEL
command that wakes it once it settles; a job in a fleet profile's home has none, since
native-message refuses it. A job that an archived worker still binds is listed with
adoptable: false and that worker's name, and neither command, since a session stays bound to any worker.
The search reads each inventory and the comments for at most 20 seconds; what it could not read
is listed in unboundDriverErrors.
agl fleet land example feat/example --ticket AGL-7 --tests test/test.feature.node.ts --json
agl fleet release example --ticket AGL-7 --plan '1.0.0 -> 1.1.0' --json
agl fleet tooling example --ticket AGL-7 --pnpm 12.8.2 @git.zone/tsbuild@^6.0.0 @git.zone/tstest@^7.2.0 --json
agl fleet reconcile --ticket AGL-7 --event-key SAVED_EVENT_KEY --json
agl fleet audit --json
agl fleet audit --repo example --cleanup --json
The CLI requires a ready controller and uses the same short-lived local-owner capability
as agl config. It observes a long operation in ten-minute windows, each under a fresh
capability, so a pending agl upgrade waits at most one window for the CLI. Landing
rebases a clean branch worktree onto its tracking remote's main, preserves its own Pending
changelog entries and main's released history, resolves supported dependency-range and
lockfile conflicts, and regenerates admitted generated files. It runs production and test
typechecks, the build, named tests and configured guards before fast-forwarding and
pushing, so a test that loads the built bundle sees the branch's own build. A repository
that declares a test:before script runs it in place of the early build, and the build
follows the tests. Named tests are resolved in the branch worktree after the rebase, so a test the
branch adds, or one main received through another landing, can qualify it; a name that matches
no file there refuses the landing before verification. The released-changelog guard compares released sections by
position, so repeated headings stay distinct. Canonical main must be clean and equal to its
configured remote/main. After the remote proves the pushed tip, fleet archives .nogit and
.playwright-mcp evidence under the canonical .nogit/archive, then removes the clean
worktree and local branch. Build caches stay out of the archive: node_modules, target,
*-target, target-*, tsrust-matrix, tsbundle-temp and any directory holding a Cargo
CACHEDIR.TAG or .rustc_info.json; the result lists what was left out. A local branch that no worktree holds lands from a temporary
worktree at .nogit/worktrees/fleet-land-<branch>-<id> (in the system temporary directory
when the repository does not ignore .nogit); fleet removes it after the landing or a
refusal, without force, and archives nothing from it. A refusal keeps the branch. A pushed copy of the branch is deleted from the remote when its tip
is contained in the landed tip or in the branch before its rebase; otherwise it is preserved
and the result says so. A rebase first records the branch's tip as a unique backup ref,
refs/fleet/backups/<branch>/<unix-seconds>-<id>, named in the result and in any refusal;
restore it with git branch <name> <ref>. A successful landing deletes its backup. A
backup of a landing that did not complete stays for seven days.
Releases queue with landings under the same repository lock, including across processes.
Fleet updates the pnpm pin and active @git.zone/* and @types/node dev dependencies,
including majors, while honoring the repository's effective release-age exclusions and
eligible third-party versions. It verifies the tooling change and makes its own Maintenance
commit, runs the configured CI builds and full tests (deno task test for Deno repositories),
asserts the version plan, and runs gitzone release -y --detach with the .smartconfig.json
targets. It follows the detached run through gitzone release inspect --json until the
version's journal records its completion, for up to two hours. A run that ends with targets
incomplete is continued with gitzone release resume <version> -y --detach, adding
--recover-attempt=<id> when a publishing attempt has no live publisher; a live publisher is
waited for. Fleet resumes at most four times. Registry conflicts and runs that fail before
tagging stop with the run's log path for a human. When the wait budget ends, the operation
fails while the detached release continues; inspect it with gitzone release inspect before
acting. Registry verification uses the plan's actual published packages, including modules
whose root package is private, and retries delayed registry availability. Fleet never starts
a second version bump as recovery.
agl fleet tooling makes the same tooling commit without a release, under the same lock.
Named name@version specs set those dev dependencies (an exact pin stays exact; ^ or ~
is kept otherwise) and --pnpm sets the packageManager pin; without them it uses the
release defaults above. It adds the - release tooling: Maintenance line, installs, restores
sha512 lockfile integrity, runs a frozen install, the typechecks, build, named tests, guards,
CI builds and full suite, then commits chore(deps): release tooling currency: … on
canonical main and proves the push on the tracking remote. Any failure before the commit
restores the tooling files and the install. Tooling that is already current ends the
operation without checks or a commit; a commit is commented on the ticket.
Audit reads local main, without fetching or modifying refs. It reports ancestry, patch
equivalence and subject matches separately, plus dirty canonical checkouts and stale,
detached or dirty worktrees. Backup entries are superseded (proven on main), expired
(older than seven days) or retained; the refs/heads/backup/*-pre-fleet-rebase-*
branches older versions kept are audited as backups, aged from their reflog. Cleanup
compare-deletes only unused local refs proven by ancestry or patch equivalence, and
superseded or expired backups. Subject matches alone never authorize deletion; dirty
canonical checkouts prevent cleanup. Worktrees and remote branches are not removed by audit.
The MCP tools are fleet_land, fleet_release, fleet_tooling, fleet_reconcile, fleet_audit and fleet_status. Like the
CLI, they acquire a local-owner capability internally; capabilities never appear in tool
arguments or results. The MCP process needs the controller's local database environment
and OS-user access. The first five return an operation handle immediately; fleet_status
accepts its id and returns its state and final result, including checks. The private RPC
refuses requests without an authority-verified owner capability. Operations
remain in the controller process and a disconnect does not cancel them. Controller shutdown
cancels their commands and drains owned process groups; after a restart, inspect the
repository, pre-rebase backups and release journal before retrying.
Land and release require --ticket AGL-<n> (ticketKey through MCP). Only a verified
owner or driver may submit them. A successful landing links the repository, branch and full
commit SHA and moves the ticket to Landed. A verified release links each actually published
package and version and moves the ticket to Released. Each package uses its own atomic effect.
Native completion remains separate from ticketSync: a ticket refusal or unknown commit
does not erase a successful landing or release. The CLI prints both and exits unsuccessfully
when ticket synchronization needs attention. Watch emits ticket.applied, ticket.failed
or ticket.unknown with the saved effect keys.
Fleet saves each exact attributed request and its idempotency key in the controller database
before ticket application. reconcile reapplies those saved requests, including their original
actor; it never reruns native work. Supply an effect key to check an exact receipt, or omit it
to reconcile pending, failed and unknown effects for the ticket. An exact replay is a no-op;
a changed payload or attribution under the same key refuses. Audit and status accept an
optional ticket key; status refuses a key that differs from the operation's association.
Fleet workers and watch
The Configuration master's optional fleet.workerProfiles defines native worker settings
and shared lane preambles. Profiles require a unique id, harness, model and effort.
Claude profiles require permissionMode and may set home to a Claude configuration directory.
Claude workers inherit the controller's environment without AGL_HOME, with the directory of
the controller's own Node.js first on PATH, so their tooling runs even when a service manager
started the controller with a minimal PATH.
Codex profiles require modelProvider, approvalPolicy and sandbox; they use the controller's
existing Codex home and published shared daemon. Fleet never starts a private Codex server.
New fleet threads receive the controller-approved agl MCP server as a per-thread configuration
override: the absolute Node executable, AGL CLI entry and arguments, and the master's non-secret
server environment. Enrollment therefore does not depend on the shared daemon's PATH or its
native agl launcher. Caller identity must still be proved before the first turn runs.
For example, add these to the master's fleet field:
"workerProfiles": [
{"id":"claude-lane","harness":"claude","model":"opus","effort":"high","permissionMode":"auto","preamble":"Follow the approved lane and report its result."},
{"id":"codex-lane","harness":"codex","model":"gpt-6.1-sol","effort":"xhigh","modelProvider":"agl_proxy","approvalPolicy":"never","sandbox":"danger-full-access"}
]
agl fleet worker start lane --ticket AGL-7 --profile claude-lane --cwd /srv/repos/example-worktree --prompt-file /tmp/lane.md --json
agl fleet worker adopt lane --ticket AGL-7 --profile codex-lane --cwd /srv/repos/example-worktree --native-id THREAD_ID --json
agl fleet worker resolve-lost lane --ticket AGL-7 --reason "Failed start; native absence verified" --json
agl fleet worker reconfirm lane --ticket AGL-7 --reason "Revival fence; same session settled" --json
agl fleet worker adopt claude-lane --ticket AGL-7 --profile claude-lane --cwd /srv/repos/example-worktree --native-id SESSION_UUID --relaunch-file /tmp/relaunch.md --json
agl fleet worker adopt claude-lane --ticket AGL-7 --profile claude-lane --cwd /srv/repos/example-worktree --native-id COPY_UUID --rebind --reason "Lane continued as a copy outside AGL" --json
agl fleet worker steer lane --ticket AGL-7 --message-file /tmp/message.md --json
agl fleet worker message 0f3c9a1e --ticket AGL-7 --message-file /tmp/message.md --json
agl fleet worker native-message 84965d6b --message-file /tmp/message.md --sender drv-agl --json
agl fleet worker report lane --ticket AGL-7 --json
agl fleet worker refresh lane --ticket AGL-7 --next-name lane2 --handoff-file /tmp/handoff.md --json
agl fleet worker refresh lane2 --ticket AGL-7 --next-name lane3 --profile claude-lane-xhigh --handoff-file /tmp/handoff.md --json
agl fleet worker move lane --ticket AGL-7 --next-name codex-lane2 --profile codex-lane --handoff-file /tmp/handoff.md --json
agl fleet worker stop lane --ticket AGL-7 --json
agl fleet worker archive lane --ticket AGL-7 --json
agl fleet worker list --ticket AGL-7 --archived --json
agl fleet worker status JOB_ID --ticket AGL-7 --json
agl fleet watch --ticket AGL-7 --json
agl fleet watch --lines --site owner-app=http://app.example.test:3000/ --ci push.rocks/smartfile --ci example/repo@release
To bring a Claude session started directly with claude --bg under fleet, adopt it with its
full sessionId from claude agents --json --all as --native-id, a working directory and a
profile whose home matches the configuration directory it runs under. The fleet name is yours
to choose; the session keeps its own. Adoption proves the identity and sends nothing; the
running process keeps the environment it was launched with, and a stopped or reaped session can
be adopted too. A Claude session's identity is its session UUID, a background job with an agent
id: the cwd and name the inventory reports are where the session is now and what it is called
now, and a worker that changes into a worktree or is renamed stays observable and steerable.
Adoption by reference leaves the session's MCP configuration as it was launched, so it holds no
fleet caller launch and its MCP ticket writes are not admitted as the worker.
--relaunch-file FILE adopts a settled Claude session with a verified identity instead: the
fleet resumes it under its own control as a new worker, claude --bg --resume <id> with the
worker's profile options, its caller overlay and the file's message after the profile preamble,
and binds the session that continues it. Claude Code continues a background job as a copy under a
new session id, the original keeping its saved options and staying as it is; it continues an
interactive or print session in place. The worker is polled after the history the copy carried
over. The relaunch refuses before Claude runs while the session still runs outside the fleet (it
has a process or is working): its owner stops it first, with claude stop <id> or by exiting it,
and the fleet never stops a session it does not manage. It also refuses a session outside the
profile home's inventory, one without a saved conversation and one a fleet worker is bound to.
The name must be new, or belong to a worker of the same ticket whose own start or relaunch was
refused before its session started: such a refusal leaves the worker stopped, so the same adopt
can be sent again.
A Codex thread gains its identity at its next steer and is adopted without --relaunch-file.
A worker whose lane continues in another Claude session (a claude --bg --resume <id> run with
flags outside AGL starts a copy under a new id) is moved there by the verified owner/driver with
adopt NAME --profile ID --cwd PATH --native-id NEW_UUID --rebind --reason TEXT --ticket KEY: the
worker keeps its name, ticket, cwd, profile, attribution and report, and only its native session
changes. This sends and resumes nothing; the next steer wakes the new session in place with its
saved options, as any revival does. A fresh inventory of the profile home must list the new
session once as a reaped or stopped background job (one that still runs is stopped by its owner
first, claude stop <id>; the fleet never stops it) and the worker's own session as settled or
not at all; the inventory is read again after the transcripts and must show the same. The new
session must be related to the worker's: its transcript holds the newest record of the worker's
transcript by uuid (Claude Code 2.1.282's copy repeats its original's records with their uuids),
proof relation: continues, or else the inventory lists it under the worker's name and cwd,
relation: named, which proves no shared conversation (a lane taken over by a fresh session is
only named). It refuses a worker with a pending, unknown or lost dispatch, a running job, a
predecessor or successor, a reconfirmation whose ticket comment is not saved, and a session any
worker of any owner is bound to, archived ones included. The re-bind stores rebound (resolver,
reason, time and proof, with the previous session and its status), drops a reconfirmation that
named the previous session, and becomes an idempotent ticket comment attributed to the resolver
under fleet:rebound:RESOLUTION_ID; repeating the same re-bind only synchronizes that comment
from the saved record. Codex workers are not re-bound and fail closed.
message NAME|NATIVE_ID --message-file FILE steers one worker addressed by its fleet name or by
the native session id it is bound to, or a unique prefix of that id of at least 8 characters; an
unbound session must be adopted first.
steer --messages-file FILE accepts a JSON array of {name,text} objects for multiple
recipients. File contents are delivered unchanged. Claude starts with --bg, obtains its
full session identity through agents --json --all, and never stops or resumes a live
session to steer it. A failed Claude command's error, on the job and the worker, carries a
credential-redacted diagnostic tail bounded to 1 KiB of UTF-8. A start includes both stderr
and stdout, sharing that budget when both are present; a relay's stdout is its transcript,
holding the relayed messages, and never enters an error. A relay that exits non-zero instead
names, within that budget, its final result (subtype, error flag, API status, terminal reason
and listed errors) and the API errors Claude recorded, never the relay model's text or a tool's
input. When that transcript is complete (Claude's init and a final result) and shows no
SendMessage call, nothing was sent: the job fails and the worker keeps its state without a
dispatch fence. A truncated or unreadable transcript, or one with a SendMessage call, keeps the
fence. Claude's output is read without its colour sequences, which it prints even through a pipe
when FORCE_COLOR is set. Claude's background launcher requires
workspace trust even with piped output. Start preflight detects missing persisted trust at an
ordinary Git checkout root: trust in a parent directory does not cross that Git boundary.
Run claude interactively in the refused directory with the same profile and accept the trust
prompt, or use a trusted working directory. The preflight never grants trust; Claude remains
authoritative for linked-worktree and other trust checks. A start that exits non-zero without a
backgrounded line and whose inventory gained no session of that name and directory was
refused: the worker is stopped without a dispatch fence and can be archived. Any other
failed start keeps the fence. Relays group recipients by their observed permission mode, close
stdin and pass one --allowedTools=SendMessage,Read,ToolSearch argument. A relay addresses
each recipient by its session name from agents --json --all, which is how SendMessage
resolves a recipient; a name that another live session shares refuses before anything is sent.
Each exact native tool receipt must prove queued delivery (success: true with a msg_id);
held messages, permission changes, missing or unreachable recipients refuse. A send counts as
held, refused or dropped only by Claude's own delivery notice or held-message record, never by
words in a receipt, a relayed message or the relay model's reply. A receipt means
accepted/queued, not read. When
Claude refused every send of a finished relay (success: false, for example an unreachable
recipient), nothing was delivered: the job fails and the worker keeps its state without a
dispatch fence. A partial or unproven outcome keeps the fence. A report is the
newest assistant text with a line starting result: or needs input: (or a ready-to-land,
ready-to-release or Handoff line, a line addressed to the driver: driver:, @driver or
question for the driver, or a direct report's owner question:, cross-subject: or
spec question:); a turn that continues past its report keeps that report.
Claude API error records are never reports: login, organization and apiKeyHelper failures
raise worker.credential.failed when watch reads the record. Reports and permission checks
use an isolated transcript adapter verified on Claude Code 2.1.306 and on 2.1.282. The fleet runs
2.1.282 and every newer release: claude --version must print X.Y.Z (Claude Code) at 2.1.282 or above,
and an older release, other output or a failed run refuses, naming what it printed. On a newer
release the adapter's own checks are where a change in Claude Code's output shows: an inventory,
note or transcript record it cannot read fails visibly, never as a resend. Every command checks
the current executable identity, and a repointed launcher or changed binary requires version
verification again. plan mode cannot currently prove peer-relay compatibility and refuses steering.
The first time AGL meets a Claude Code release newer than 2.1.306 (the fleet's version check or
the configuration's), it records the release in its database and publishes one
harness.release.discovered fleet event to every owner, with the release as version: the
built-in Driver receives it, and agl fleet watch shows it
(10:41:07Z fleet: harness.release.discovered 2.1.307 Claude Code 2.1.307 is newer than 2.1.306, …).
Its text names the release, whether handing Claude Code's own sign-in to the accounts service
works on it (only once its credential handling was read, as 2.1.306's was) or stays refused, and the
"Claude Code compatibility check" list in
ts_authswitch/native-contracts.md, so the driver can file a compatibility check for it on the
board. A release is mentioned once: neither a later sighting nor a restart mentions it again,
and one recorded but not yet announced, when the controller stopped in between, is announced at
the next start.
Claude can reap idle background processes while retaining their jobs and conversations.
Fleet uses agents --json --all: a live PID/status receives a relay, including when idle;
a retained done or blocked job without PID/status is reaped. Steering that worker, or one
claude stop stopped, runs --bg --resume with its full UUID and the message, retaining the
native job's saved options. Fleet confirms the original identity and checks all output for copy
notices. Claude Code 2.1.282 hands a woken job to its process a moment after the command exits:
its inventory lists the job working without a PID, then with a PID before a status. Fleet
polls it (every 250 ms, up to 30 s) until the same job is live with PID and status, binds its
caller launch to that process, or accepts it reaped after a quick turn; a failed, stopped,
absent or other job, a status without a PID, an unknown state and a job not live after 30 s
leave the revival unconfirmed. A failed or uncertain revival fails the job and retains its dispatch fence; it never
resends automatically. The error names the condition that failed (the exit code, no
backgrounded session, another session than the original, or a note other than Claude's wake in
place) and the command's credential-redacted output. When the original session continued the
steer, reconfirm clears the fence (see below). Failed, recovering or removed jobs require inspection. Reports preserve the last
assistant text and expose reaped; watch emits worker.reaped once per observed dormant
period, including an initial dormant observation. This reports observed state, not a reap time.
Codex attaches the published shared daemon with its existing identity checks. A live turn
uses its exact turn/steer identity. Subscribing to that existing thread preserves its native
settings and never interrupts it. A proven idle thread can start the next turn. Reports and
watch polls read paginated native history without loading/resuming a turn. Stop confirms
native completion; archive requires a settled worker. A worker whose start was refused has no
native session, and archiving it touches no harness. Claude archive marks only the AGL
registry, preserving the transcript and native worktree. Codex archives its native thread.
Watch retains native reportId alongside workerId so consumers can correlate reports
and updates without treating each watch cursor as a new report. A failed observation, a
transcript gap for example, is published once as worker.observation.failed; the fleet then
requests a fresh report itself: the worker's next poll starts from a new baseline, and its
newest report, when it has one, is published as worker.report.mid_turn and commented on its
ticket. The same failure stays quiet on later polls until an observation succeeds.
agl fleet watch --lines writes one plain line per event instead of each poll's JSON page, and
nothing for an empty page or a host.load sample, so a monitor that wakes on output wakes only
for a change: 10:41:07Z worker lane [AGL-7]: report: result: feat/x @ abc123 ready. A Claude
session started outside fleet comes into this stream once it is adopted. The same watch probes
what lies outside the controller, from the CLI process:
--site NAME=URL(repeatable) sends a GET without following redirects; a status below 400 isup, any other status or a failed requestdown.--ci OWNER/REPO[@REF](repeatable,REFdefaults tomain) reads the Gitea combined commit status ofREF(/api/v1/repos/OWNER/REPO/commits/REF/status): its state and short commit, with the failed contexts as detail.--gitea URLdefaults tohttps://code.foss.global. Public repositories need no token;--gitea-token-file FILEsends the file's token, over https only, for private ones.--probe-interval SECONDS(default 60, at least 5) paces both; each request times out after 15 seconds.
The first round writes every subject's state, later rounds one line per change, as a
{"change":…} JSON line or, with --lines, 10:42:00Z site owner-app: down 502 (was up 200).
Another error for a site that stays down is no change. --once probes one round. The probes
start again with no memory when the watch restarts.
Claude background prompts, including their profile preamble, and revival messages are bounded to 96 KiB; each message file is bounded to 128 KiB and a steering batch to 512 KiB. Shared preambles stay in the Configuration master and durable binding, and are omitted from public worker results.
Worker names and native bindings persist in AGL's existing database with the selected
profile snapshot. Each mutation records a dispatch fence before native effects. Jobs are
controller-owned: a disconnect leaves them running, while shutdown cancels management
children and closes observer connections without stopping native workers. Jobs and event
history are transient. An unresolved dispatch keeps its durable fence across restart and
refuses retries, including a crash that leaves the worker marked starting or live. Inspect
native state and explicitly adopt the same name/profile/cwd and proven identity to reconcile
it; this never sends another prompt or proves delivery of the old prompt. A fence records
its controller's host and process identity: another live controller or another host cannot
release it. The original controller also refuses recovery while its job is still running.
When a Claude start fence has no native identity, or its saved native conversation has been
deleted, the verified owner/driver can explicitly resolve-lost NAME --ticket KEY --reason TEXT.
This sends nothing. A fresh, complete inventory of the saved profile home must contain no
session of the start's name and cwd. Other sessions are excluded when their exact native UUID
is bound to another registered Claude fleet worker, across every owner and including archived
workers: that unique, retained binding proves whose session it is. Every remaining session
needs a proven creation time before the fence's startedAt, even if its name and cwd differ,
because the started session could have changed both. Refusals identify the candidate's session
UUID, name, cwd and startedAt, so the driver can adopt or inspect it. The same-name-and-cwd
refusal applies even to a bound session. These rules apply to old fences without a settlement
timestamp as well. No time cutoff is inferred from a settled start job: the backend waits for
the short-lived --bg management child to close and then polls native inventory, which does
not establish a final bound on background session creation. For a known native UUID, its
transcript must be absent across the profile home's complete project-directory scan, and inventory must prove no running or
recovering native job. Claude may retain settled job metadata after deleting its conversation;
that metadata alone is insufficient without transcript absence. Failed or malformed inventory
reads, unproven times, unreadable paths, symlinks and scan-limit gaps refuse resolution.
The host/process and running-job refusals above apply to this recovery as well.
Resolution stores lostDispatch.state: "lost", the original dispatch fence, verified resolver,
reason, resolution time and native absence proof in the durable worker record; list and job
status show it. The worker becomes stopped, stops native polling and reports gone.
Stop/archive now settle the registry without touching the harness; steering, revival, adoption
and refresh of that lost binding refuse. The loss also becomes an idempotent ticket comment
attributed to the resolver. Ticket synchronization has its own status: if interrupted or failed,
repeat resolve-lost to synchronize the saved resolution with its original attribution, or
reconcile its fleet:lost:RESOLUTION_ID effect after it has been saved. Repeating resolution
does not repeat native checks or send anything. Codex absence resolution is unsupported and
fails closed.
When a steer's native outcome is unknown but its Claude session still exists, the verified
owner/driver can explicitly reconfirm NAME --ticket KEY --reason TEXT, the counterpart to
resolve-lost. This sends nothing. The worker must hold an unknown steer fence and its native
UUID, with no lost resolution. A fresh inventory of the saved profile home must list that UUID
exactly once as the same background job, idle, reaped or stopped (a busy, blocked or waiting
session refuses until its turn ends) before and after its transcript is read. A live job whose
turn ended (status idle, state done or blocked, as a woken job shows once its turn is over)
counts as idle; reports keep showing the latter as blocked. The transcript
then shows whether the message arrived: since the fence's startedAt, a user record of human
origin with the text (a revival) or of peer origin with the body (a relay) whose SHA-256 matches
the digest a steer fence keeps of its message. The digest ignores line endings and surrounding
whitespace; Claude Code 2.1.282 stores both records with the text as sent. The proof's delivery is
delivered (with deliveredAt), not-found when the read reaches back to the fence without it,
or unproven when the fence keeps no digest, as fences from before AGL-291 do, a message since
the fence has no plain text to compare, or a read truncated to the transcript's last 4 MiB
holds no record from before the fence; an unproven
proof names firstMessageAt, the first message since the fence. Failed or malformed inventory
reads, a missing transcript and a session that changed during the read refuse. The host/process
and running-job refusals apply as well.
Reconfirmation stores reconfirmedDispatch (the original fence, verified resolver, reason,
resolution time and session proof), clears the fence and its error, and leaves the worker live,
or stopped for a stopped session. It becomes an idempotent ticket comment attributed to the
resolver under fleet:reconfirmed:RESOLUTION_ID; repeating reconfirm after the fence cleared
only synchronizes that comment, until the worker's next dispatch, which drops reconfirmedDispatch
(the comment keeps it); a worker without a fence or a current reconfirmation refuses with
Worker has no fence to reconfirm. The next dispatch drops it only once the comment is saved:
until then a steer, refresh or move refuses with Worker reconfirmation ID has no saved ticket comment; reconfirm the worker again before dispatching to it., while stop and archive keep the
record for that retry. Nothing is resent: when the proof says not-found or
unproven, steer again deliberately. Codex has no reconfirmation and fails closed.
Refresh and Claude-to-Codex moves require a settled predecessor and its self-contained
Handoff assistant report. The supplied handoff must appear unchanged in that report.
AGL reserves and durably identifies the successor before stopping/archiving the predecessor.
A failed successor leaves the predecessor available. Predecessor/successor bindings retain
both sides if retirement is uncertain; inspect them before taking further action.
A worker's recorded profile is never changed in place: a Claude background job keeps the model,
effort and permission mode it was started with at every later wake, and a Codex thread keeps the
provider, approval policy and sandbox it was started with, so a worker changes profile only with
a new session. A refresh starts its successor on the predecessor's recorded profile, or with
--profile ID (MCP profile) on that profile as the Configuration master holds it now,
preamble included. The profile must run the predecessor's harness (a Claude lane on another
Claude profile, in its own home too, a Codex lane on another Codex profile); only a move
changes the harness. An unknown profile or another harness is refused before the predecessor is
read or fenced. Running a failed refresh again finishes it on the recorded successor only when
it asks for the profile that successor runs: the same --profile, or none for a successor on the
recorded profile.
The fourteen MCP tools are fleet_worker_start, fleet_worker_adopt, fleet_worker_resolve_lost, fleet_worker_reconfirm, fleet_worker_steer,
fleet_worker_report, fleet_worker_stop, fleet_worker_archive, fleet_worker_refresh,
fleet_worker_move, fleet_worker_list, fleet_worker_status, fleet_worker_native_message and fleet_watch. They share
the local-owner capability boundary. Mutation and report tools return job handles immediately;
the CLI polls jobs and prints their final outcome. Worker status accepts the job id.
Worker mutations and reports require a ticket key; mismatched associations refuse before
native effects. Refresh and moves retain the ticket and the associating driver's attribution.
Existing unassociated bindings remain readable and may be explicitly adopted after proving
their native identity. List, status and watch accept optional ticket filters.
Explicit reports and observed mid-turn or settled reports become ticket comments, attributed to the verified owner or driver who associated the worker. Comments include the native session and report identities. Repeated observations use the same saved request/key; changed report content creates a new revision comment. Ticket-sync failure is visible separately from the report. Native worker MCP caller enrollment awaits the ticket identity lane's prepare/bind/revoke hooks; these transcript imports do not claim an enrolled worker identity.
The single watch stream combines turn endings, assistant reports emitted during turns,
worker credential failures, release finished/failed events and host-load samples. Watch
returns an epoch/cursor; continue from that cursor to consume every retained page (at most 64 events per response).
reset: true explicitly reports a controller restart or a gap beyond the 1,024-event buffer;
either way the page continues at the oldest event still retained, so a cursor from before a
restart replays the new controller's events from its start. Events a stopped controller had
not yet delivered are lost. A fresh watch establishes a baseline. CLI watch emits
newline-delimited JSON; --once reads one page and --cursor JSON continues a previous
observation. Ctrl-C stops observation only.
CLI watch runs until stopped in ten-minute windows, each under a fresh local-owner capability,
and carries its cursor from one window to the next, so a pending agl upgrade waits at most one
window for it. When a window fails — the controller restarts, is upgrading or is not running —
the watch writes {"disconnected":{"at","error","retryInMs"}}, waits holding neither
capability nor start admission (1 s, doubling to 30 s), and then continues from its cursor; the
first page after a restart reports reset: true. --once still fails at once.
Owner events are isolated; ticket filters preserve shared host-load samples. The stream also
includes ticket application outcomes for native operations and report imports.
Driver session
Flex conversations, including the Driver, have no model-round limit by default. An explicitly supplied positive maxSteps still bounds a job; exhaustion remains a failure, not a successful answer. Failed and stopped turns retain their observed text, reasoning and tool outcomes in history and subsequent model context, with their unsuccessful outcome recorded. Retaining history does not repeat tools or resolve unknown effects. Stop, permissions, provider errors and owned-child cleanup still apply.
Each host has one driver: a Flex session whose job is to keep AGL's projects, board and
workers moving. No session list, conversation search or Session Intelligence chat list shows
it or its subagents, and it cannot be opened as a task, archived or deleted
(DRIVER_SESSION_PROTECTED). The
browser reaches it through three protected requests:
controller.driver.ensurereturns the driver and creates it when none exists or its session is gone. Concurrent calls share one creation.projectIdnames the registered project it lives in; without it the driver usesfleet.driver.projectDirectory, registering that directory as a project on first use.controller.driver.getreturns its status without creating it:absent,idle,working,needs_input(a permission prompt waits, or its last turn ended on aneeds input:line) orerror, with the number of turns that ended since the owner last looked.controller.driver.seenresets that unread count. Every status change is pushed as adriver.changedevent.
In the web UI the Driver button leads the top bar, before Terminals, Browsers, Workspaces,
Board and Settings. It opens the driver in the main area as a full conversation: transcript,
composer and details, attached resources included. Its badge shows the activity as a dot,
pulsing while the driver works, in the warning colour when it needs input and the error colour
after an error, and the unread count beside it; opening the driver in a visible tab marks its
turns seen. A host that sets no fleet.driver.projectDirectory asks for a project on the first
open.
The Driver view's right-hand panel lists the owner's active direct reports (see below) under
Direct reports (controller.directreport.list), never the main sidebar, which holds the
owner's own conversations. A row shows the report's status, harness and projects, its newest
result: line and a badge with the number of ticket questions open for the owner. The list is
read when the driver comes on screen and again on directreports.changed (a report appointed,
adopted, retired, moved or given a newer result), on fleet.workers.changed and, while the
driver is shown, on tickets.changed. The workers a report runs are not listed here; they stay
in the session list of the main sidebar.
Opening a report shows it in the same modal a subagent's conversation opens in, over the
driver: its status, projects, driver ticket and newest result, the open questions (three named,
the rest counted), and, for a report the fleet runs, its worker's transcript with a steer box.
The transcript is read-only and projected like the worker's harness conversation
(controller.fleet.worker.read); a read returns the newest page of turns, and earlier says
whether older ones exist; while the worker works, the modal reads again every 3 seconds. The
steer box messages the report with the owner's own message (controller.directreport.message):
the fleet delivers it through the dispatch fence of the report's driverTicketKey, headed
[Message from the user via the AGL Driver view; not from your driver], and the request
resolves once the fleet job settled. The draft stays until the fleet delivered it; when no
answer arrives within 120 seconds, the modal says the outcome is unknown, so the transcript is
checked before sending again.
A report that is a Codex thread the fleet does not run (a referenced thread) opens as the
conversation of the AGL project whose folder the thread works in, as that project lists its
folder's Codex threads (controller.directreport.read); a thread in no project's folder, or in a
folder two projects share, is refused with that reason. Its steer box sends the owner's message
as MCP session_message delivers an agent message, headed [Agent message via AGL from "Owner" — the owner, in the AGL Driver view; not from the user]: steered into the running turn or else
the next one, through the host's shared Codex server, so AGL never becomes a second writer. A
turn another Codex client runs stays that client's; the message steers it only with the turn the
modal last read (expectedTurnId), and is refused once another turn runs. The usual agent
message limits apply: 16 KiB of text, the project's Agent messages switch and the per-task
rate. While a turn runs the modal reads again every 3 seconds. A report that is a Claude Code
session the fleet does not run opens read-only, without transcript or steer box: AGL reaches a
Claude Code session only by resuming it in a process of its own, a second writer beside the one
that runs it, so the modal says to adopt the session with a relaunch once it has settled.
Leaving the driver closes the modal.
A fleet worker opened from the session list shows the same transcript in the main area; its
composer steers the worker itself (controller.fleet.worker.steer) through the ticket the
worker is bound to, so a worker bound to no ticket is steered only by its driver. The view has
the same session panel as a conversation (scratchpad, Session Intelligence, metrics, todos and
pending permission requests and questions) for the worker's native session, and its steer
carries attachments within the usual draft limits (attachments on
controller.fleet.worker.steer and, for a report the fleet runs,
controller.directreport.message); the files stay in the upload folder until the 24-hour sweep.
A Codex thread report takes text only. The protected
controller.fleet.worker.list still returns the owner's fleet workers that are not archived, but
no AGL client sends it since the Driver view lists direct reports: MCP fleet_worker_list and
agl fleet worker list list workers through agl.mcp.fleet.worker.list, which aglapi excludes.
Direct reports are sessions that report to the owner directly, each with its own ticket the owner steers it through. Six protected requests manage them, and the driver has the same operations as tools (see below):
controller.directreport.appointnames a fleet worker that is bound to a native session and a ticket. That ticket becomes the report'sdriverTicketKey.controller.directreport.adoptmakes any other Claude Code session or Codex thread on the host a report by harness and native id, with adriverTicketKey. A Codex thread is referenced: no fleet worker is created. A Claude Code session needsrelaunch: { profile, prompt, cwd?, workerName? }: the fleet relaunches it as a new worker, asagl fleet worker adopt --relaunch-filedoes, named as the report and in the session's directory unlessworkerNameandcwdsay otherwise, and the report is that worker's. A session still running outside the fleet is refused, never stopped; its owner stops it first. If the report cannot be recorded after the relaunch, the error names the worker to appoint. A session a fleet worker is bound to is appointed instead.controller.directreport.listreturns the active reports (retired: trueadds the retired ones). Each comes with a status derived on the read and its open ticket questions: those its session asked and those on tickets of itsprojects(by default the project of itsdriverTicketKey), the oldest 20 listed and all counted.controller.directreport.retireretires one and leaves its session as it is.controller.directreport.read { name, limit? }returns an active report's newestlimitbundles (1-200, default 50): a fleet worker's transcript ascontroller.fleet.worker.readreads it (kind: 'fleet-worker'), or a Codex thread's conversation in its project (kind: 'session', withprojectId,sessionId,earlierand the runningturnId), audited assession.read.controller.directreport.message { name, text, expectedTurnId? }steers an active report the fleet runs with the owner's own message through itsdriverTicketKeyand returns the settled job, or sends a Codex thread the owner's agent message and returns itsdeliveryandturnId, audited assession.messagewith the senderOwner;expectedTurnIdapplies only to a Codex thread. A Claude Code session the fleet does not run is refused.
Each write that lands pushes directreports.changed.
A report's name stays taken after it is retired, and one native session is held by one active
report at a time. A fleet worker's report follows its lane: a refresh or move points the report
at the successor and its native session before the predecessor is stopped and archived. If that
write fails, the refresh or move fails with the predecessor left running and the error recorded
on it; running the same refresh or move again finishes it on the recorded successor without
starting another session. When the fleet adopts a session that an active report references,
that report becomes the adopted worker's report, keeping its name and projects and taking the
worker's ticket as its driverTicketKey. Adopting a successor that a failed refresh or move left
unknown moves its predecessor's report to it. If either write fails, the adoption fails with the
error on the adopted worker, and adopting it again as the same identity moves the report;
agl fleet reconcile (fleet_reconcile) repairs every report first, so one left behind by a
crash follows its lane. A report moves only from the version that was read, so concurrent repairs
move it once. Repairs run one after another, and the later one's outcome is what stays on a
worker: the error of a report that stayed on it, or no error once a repair moves that report on.
Its newest result: line is kept with it: for a fleet worker, from the
worker's reports as the fleet observes them; a referenced session has no such source yet. A
fleet worker that a report's session starts, adopts, refreshes or moves through the MCP fleet
tools records the report as reportsTo, and its successors inherit it. A report asks the owner
mid-turn with an owner question: line, names work for another subject with a cross-subject:
line and asks for a specification its own context must not answer with a spec question: line;
each is a report the fleet relays mid-turn.
The driver record (its project and session) is stored per host, so the same driver survives controller restarts and upgrades. Its settings live in the Configuration master beside the other fleet settings, and all four are optional:
{
"fleet": {
"driver": {
"instructions": "# You are the AGL driver on this host\n...",
"projectDirectory": "/srv/agl-driver",
"model": { "providerID": "anthropic", "modelID": "claude-opus-5-5", "variant": "xhigh" },
"keepCacheWarm": true
}
}
}
instructions replaces the packaged driver prompt; it is read at the start of every run.
model is the model a new driver session starts on; without it the driver uses the Flex
default. The owner can switch the model of a running driver like any session's. A Claude
model runs only on an Anthropic API key (see Accounts): while none is added,
the driver runs each turn of a Claude choice on model when that is not Claude, else on
Flex's default model on the ChatGPT account AGL's placement takes, and it is back on Claude
once a key is added. Without either, its turn is refused with the reason like any other Flex session's.
While the board changed within the last hour, the driver keeps its prompt cache warm, so its
next turn reads the cached prefix instead of writing it again. Every board change (a ticket
created, changed, moved, commented, answered or assigned) sets the driver session's warm
deadline to one hour from then, and Flex runs a warm-up turn before the provider's cache would
expire. An hour after the last board change, warming stops until the next one. Warm-up turns
add nothing to the driver's conversation: no message in its transcript, no working status,
no unread turn and no batch of fleet events. They leave the session's state as its last real
turn left it, so a driver whose last turn failed still shows that error. The controller logs each
warm-up turn with the tokens it read from the cache. keepCacheWarm: false turns warming off
and clears a deadline already set. Known limits:
- The deadline lives in the running Flex process: after the controller or its Flex process restarts, warming stays off until the next board change sets it again.
- Flex keeps a warm-up turn's two messages in the session, hidden. They count toward the newest 2,048 messages a session's transcript holds, so long warm periods shorten how far back the driver's transcript reaches.
- Each warm-up turn makes the controller read the driver session once more, although nothing it shows changed.
The driver's tools are the fleet, worker and ticket tools of the MCP server, under the same
names and schemas, plus ticket_question_answer, the direct report tools, driver_notes_read
and driver_notes_update, the shell and file tools
every Flex session has, and the AGL API without its ticket methods. The controller runs each of these tool calls as the driver: actor
driver:<controller id> with the ticket role driver. A tool that changes fleet or ticket
state first asks for the agl.driver permission, which the session can remember. The notes
are the driver session's scratchpad. They are added to its system prompt on every run, so the
driver's working state survives compaction and restarts.
ticket_question_answer records the owner's answer to an open ticket question as the driver,
with the schema of agl ticket answer; the MCP server has no such tool, so a worker cannot
answer. The direct report tools are the driver's side of the direct report requests above:
| Tool | Does |
|---|---|
directreport_list |
controller.directreport.list: status, newest result: line and open owner questions of each report. |
directreport_appoint |
controller.directreport.appoint. |
directreport_adopt |
controller.directreport.adopt, attributed to the driver; a Claude Code session only with relaunch. |
directreport_retire |
controller.directreport.retire. |
directreport_read |
The newest limit turns (default 10, at most 50) of the report's fleet worker, as controller.fleet.worker.read reads them, or of its Codex thread, as controller.directreport.read reads them, with the running turnId. |
directreport_message |
Steers the report's fleet worker with text, unchanged, through the report's driverTicketKey as a driver steer, and returns the job; fleet_worker_status follows it. A Codex thread gets text as an agent message from Driver (subject the AGL Driver session), steered or for the next turn, and the call returns the delivery; expectedTurnId is the turnId directreport_read returned. A Claude Code session the fleet does not run is refused. |
Only a report the fleet runs is read or messaged; a report that references a session the fleet
does not run is refused with how to bring it into the fleet. The packaged instructions describe
the driver's role over its reports: it never gates a report's landings, releases or deploys, it
routes cross-subject: lines to the owning report, answers spec question: lines or takes them
to the owner, and brings the reports' owner question: questions to the owner and records the
answers.
Fleet events reach the driver as queued agent messages from "AGL fleet": worker turn endings
and mid-turn reports, reaped workers, failed observations, worker credential failures,
finished or failed releases, failed or unknown ticket effects, others' ticket changes
(ticket.changed), ticket notices that could not reach an assignee
(ticket.notification.failed, naming the session), stalled tickets the board pump found
(ticket.stalled) and Claude Code releases newer than the verified one
(harness.release.discovered). Worker status changes, host-load samples,
applied ticket effects and delivered ticket notices are not delivered. A batch waits 15
seconds for further events and is delivered at most once a minute. It waits while the driver runs a turn, including permission waits, using the current session state.
If a batch is still queued when further events arrive, they merge into that same message,
keeping its position and sender. The merged text is stored before the merge is acknowledged,
so a restart or a later hold retains every merged batch. Each message stays within the relay byte budget; excess
events remain buffered for a later turn. Dispatched and held messages are never edited.
A queued Flex continuation also stays unchanged: it sends no new input, so further fleet events queue as a separate message.
An idle fleet starts no driver turn. Events of the workers a direct report runs (whose
reportsTo names an active report) go to that report, not to the driver; a report's own worker
still reaches the driver. If the relay cannot read which workers those are, it reads the same
events again after the retry delay instead of delivering them. The relay follows the controller's in-memory fleet event
stream: a driver that existed when the controller started receives the events since that start,
and a newly created driver receives the events from its creation on. Events from while the
controller was down are not delivered. After a gap in the stream, the batch says so and asks the
driver to re-read workers and the board.
Board pump
The controller checks the board every few minutes and tells the driver about tickets that stalled, so an answered question or an orphaned lane does not wait for someone to notice:
- in_progress without a live fleet worker (none bound to the ticket or its assignee's
session, or the last one stopped, reaped or gone) and without the label
waiting: the notice names the last worker and offersfleet_worker_steerto resume it. Label a ticketwaitingwhen it is parked on purpose. - needs_input whose questions are all answered (since it entered the column): the answers go
to the ticket's live worker as a steer starting
[AGL board pump for AGL-<n> "Title"; from the ticket board, not from the user]. Without a live worker, or when the steer is refused, the driver is told instead, with the answers. - ready and not picked up: the driver is told.
- landed for longer than
landedAfterHours: the driver is asked to propose its release, withfleet_releasenamed for each landed repository. - a comment nobody hears, in every column but
released: the ticket's newest discussion entry (comments and questions, the pump's own left out) is a comment by someone not in the driver role, nothing by the driver role or the assignee followed it, and no live fleet worker holds the ticket, so no session was told. The notice quotes the comment and lists the Claude Code background jobs that look like drivers and that no fleet worker binds (asfleet auditlists them), each with its adopt command, and with itsnative-messagecommand when it runs in the host's own Claude Code home (native-message refuses a profile home's job). It is told once per comment (board-pump:<ticket>:comment:<comment id>); a newer comment is told anew. A question written through the CLI is recorded as the driver role and is not flagged.
A condition must hold for one full interval before the pump acts, so a ticket moved a moment ago
is left alone. Each stalled state is told once: the pump writes a ticket comment by "AGL board
pump" under an idempotency key derived from the ticket, its column entry and the state (the last
worker's registry record, or the answers), so later passes and controller restarts do not repeat
it, and a comment whose write was interrupted is completed before anything new is decided. A new
state (another move into the column, a revived worker that stalled again, a new answer) is told
anew. The comment appears in fleet watch as ticket.stalled (with the column as its status), or
as ticket.notified when the answers reached the worker, and reaches the driver through the
relay. The pump never starts a worker, never moves a ticket and never answers a question, and its
comments are not relayed to the assignee as ticket notices.
The pump is on by default; configure it in the Configuration master:
{
"fleet": {
"boardPump": { "enabled": true, "intervalMinutes": 5, "landedAfterHours": 24 }
}
}
intervalMinutes is a whole number from 1 to 1440 (default 5), landedAfterHours a number
above 0 and up to 720 (default 24). A saved master takes effect at once.
Shared project tickets
One board spans every project. Tickets have global AGL-<n> keys and these fixed columns:
backlog, ready, in_progress, needs_input, review, landed, released (done).
Use a project filter rather than creating separate boards. Each ticket is an epic, task or
subtask, with title, markdown description, priority, labels, assignee, original source words
and attachments, branch/commit/release links, actor attribution and timestamps. Tasks can
belong to epics; subtasks require a task parent in the same project.
Workers are identified by the controller's MCP caller registry, not by a name supplied in
a request. They can read everything, create unassigned backlog tickets and comment on any
ticket. They can update/link/move only tickets assigned to their verified session, and
only within ready, in_progress, needs_input, review. Review requires a branch link
with its full 40- or 64-character hexadecimal sha; a commit link alone does not qualify.
Only the driver or owner assigns, lands or releases tickets. The authenticated web owner
has full access; the CLI reuses agl config's short-lived local-owner capability. There
is no automatic dispatch. Attachment URLs must use HTTP or HTTPS; AGL stores the
reference and does not fetch its payload.
Unidentified local MCP callers may use tickets_list and ticket_get, including comments
and activity returned with the ticket. Their Mine filter is empty and every returned ticket
permission is false. Every mutation refuses with TICKET_IDENTITY_REQUIRED; neither a
claimed name nor a native session id supplies identity. Plain Claude background sessions
started outside AGL stay unidentified. The web board still requires owner authentication.
Atomic fleet ticket events
Trusted fleet supervision calls OpenCodeController.applyFleetEvent(event, signal?).
TTicketFleetEvent always supplies ticketKey, a globally unique idempotencyKey, and an
actor derived from verified controller authority, never an untrusted public identity claim:
kind: 'landed'requiresrepo,branchand a fullsha.kind: 'released'requirespackageandversion.kind: 'comment'requiresbody(markdown or ticket reference content).- Landing/release may also supply
body; its comment commits with the evidence and move.
Only owner/driver may land or release, including on replay. The existing ticket rules and
contiguous source/destination ordering apply. Evidence links, moves, comments, activity,
the board revision and an immutable receipt commit in one database transaction through
the client document models. A first application returns applied: true, its board
revision, the ticket snapshot, affected ticketIds and optional comment; it emits one
post-commit board notification when the commit is confirmed.
Fleet must persist and reuse the same key and exact request for the same effect. An exact
replay returns applied: false with the original snapshot, even after database/controller
restart or subsequent ticket changes, without further writes or notifications. Reusing a
key with a different ticket, kind, evidence, body or actor refuses
ticket_fleet_event_conflict. A transaction whose commit cannot be confirmed refuses
outcome_unknown; explicitly submit the identical event/key to reconcile, never use a new
key or replay separate link/move/comment mutations. This internal callback is not a public
MCP actor-claiming tool and does not implement fleet launch or release supervision.
AGL-launched worker caller hooks
OpenCodeController.workerCallerHooks exposes IFleetWorkerCallerHooks for fleet launch
supervision, not for public identity claims. It provides prepareWorkerCaller,
bindWorkerCaller, getWorkerCallerStatus and idempotent revokeWorkerCaller.
Launches carry { launchId, projectId, harnessId }; Codex also requires
codexOrigin naming the exact local connection that creates the thread: a controller
connection profile ({ source: 'profile', profileId, supervisorGeneration }) or the fleet's
own shared-daemon connection ({ source: 'fleet', supervisorGeneration }). The controller
captures that adapter and proves the local daemon process/socket; remote, unproved or
superseded origins refuse preparation.
- Claude: pass the complete returned
claudeMcpConfigthrough supported inline--mcp-config, preserving all other launch options and MCP servers. It derives theaglentry from the Configuration master and adds only non-secretAGL_MCP_LAUNCH_IDandAGL_MCP_LAUNCH_GENERATION. Never put a caller credential in native settings or saved respawn options. First bind usesexpectedBindingRevision: 0, the full native UUID andclaudeProcess: { jobId, pid, startTicks }from the exact launched job's supported agents listing. A native respawn of the same UUID requires the latest revision and fresh process observation; a stopped/revoked worker requires a new launch ID and supported overlay. - Codex: pass the returned
codexConfigtothread/start, or tothread/resumebefore binding an unloaded worker. An already loaded thread keeps its running MCP bridge; never unload or restart it to apply launch settings.thread/startprovides the native thread ID. AwaitbindWorkerCallerbefore the firstturn/start. AGL calls nativemcpServer/tool/callon that exact thread, serveragl, toolworker_caller_enroll, with only a one-use challenge. Codex stamps the real_meta.threadId; a supplied ID, shared root identity orresources/readis not proof. Tool results contain readiness only, never credentials. Renewal re-proves the current bridge before dispatch and uses another exact-thread challenge for a new bridge. - Readiness includes
callerPending,bindingRevisionand the canonicalticketSessionId(claude:<UUID>orcodex:<threadId>). Claude may start its prompt before full-ID binding or MCP readiness. Early calls stay read-only: list/get work, Mine is empty, and mutations refuse. AGL never queues or replays them.getWorkerCallerStatusreports the last enrollment state; fleet must still observe native lifecycle changes. - Revoke on failure, stop, replacement, cancellation or controller-generation change. Revocation fences new admissions immediately, removes credentials, and waits for already admitted ticket requests. Native-ID uniqueness and binding revision checks fence races. Controller restart invalidates every old handle; existing native workers remain unidentified until fleet performs an authorized controlled resume/relaunch with a fresh overlay. Hooks do not automatically terminate or adopt outside sessions. Revoked launch IDs stay recorded so they are never prepared again; the oldest give way once 65,536 launches are recorded.
Fleet workers use these hooks themselves, so a worker's MCP ticket writes are admitted as that worker, not as the driver or anonymously:
- Every fleet Claude start, wake and resume prepares a fresh launch and writes its
claudeMcpConfigto the worker's private overlay file below<AGL home>/fleet-mcp(directory mode 0700, also enforced on an existing directory, which must belong to the controller's user and not be a link; file 0600, written atomically and named by a digest of the worker id), passed as--mcp-config <file> -- <prompt>. Claude Code saves that path and reads it again at every wake, so the fleet rewrites it before each one; the launch ID and generation reach only theaglMCP server's environment, never the Claude process's. Once the agents listing shows the job's process, the fleet binds the launch to it. A start, wake or relaunch that fails at any step revokes its launch. Stop revokes the launch and archive removes the overlay. A start fails closed before Claude runs when the Configuration master has noaglMCP server. - Fleet Codex workers prepare the launch on the fleet's own connection before
thread/startand bind it before the firstturn/start. Before steering a thread whose launch is unbound, from an earlier connection or from before a controller restart, the fleet loads the thread and binds a fresh launch; a refusal fails the steer undispatched. - Launches live in memory. After a controller restart a Claude worker stays unidentified until its next fleet start, steer or resume, and a Codex worker is bound again at its next steer.
The bridge exchanges credentials only through the private controller endpoint after Linux direct-loopback socket-inode ownership, approved executable/argv and current native parent/PID/startTicks checks. Credentials live in memory and grant only existing worker ticket rules, never task/resource/job ownership. Non-ticket calls retain their existing anonymous semantics or independently verified local-owner capability gates. This does not claim isolation from a hostile same-user process capable of inspecting the live worker.
Both harnesses already receive their agl MCP definition from the Configuration master;
no generated-native-file edit is needed. The master must contain one non-secret stdio
agl entry for each target, using the supported AGL pnpm shim or absolute Node/cli.js
entry. Ambiguous entries, wrapper commands, static caller credentials and secret envFrom
definitions fail closed. Fleet owns spawning, native observations and hook calls.
MCP tools are tickets_list (mine/project/status/label/text filters), ticket_get (ticket,
comments, complete activity and questions), ticket_create, ticket_update, ticket_move,
ticket_comment, ticket_link and ticket_question_ask. ticket_comment.body accepts markdown text or the
kanban's text/reference-content array. A Claude worker's assignment uses
claude:<agent-session-id> and a Codex worker's uses codex:<native-id>; the same value
goes in assignee.sessionId (or assignee.id for a kanban person with no session field).
Plain terminals and the shared OpenCode runtime cannot prove a worker session and therefore
cannot mutate assigned tickets, but verified callers can still read/create/comment.
agl ticket create --project agl --title 'Add the shared board' --source 'The owner’s words' --json
agl ticket assign AGL-1 --expected-revision <revision-from-get> --session-id codex:<native-id> --name 'Backend worker' --json
agl ticket move AGL-1 --column ready --json
agl ticket list --project agl --json
agl ticket link AGL-1 --repo modelprofile.com/agl --branch feat/tickets --sha <full-sha> --json
agl ticket move AGL-1 --column review --json
agl ticket get AGL-1 --json
agl ticket comment AGL-1 --body '**Checks passed**' --json
agl ticket update AGL-1 --expected-revision <revision-from-get> --changes '{"priority":"high","labels":["backend"]}' --json
agl ticket ask AGL-1 --text 'Which store?' --options '["Memory",{"label":"Database","description":"Durable"}]' --json
agl ticket list --label openQuestion --json
agl ticket answer AGL-1 --question Q1 --choice 2 --text 'Keep it **small**.' --json
All ten CLI actions accept --port and --json; agl ticket --help documents their
options. The running controller is required. update and assign require --expected-revision
from the ticket the editor read; neither command fetches a newer revision before saving.
assign --clear unassigns. Session names
are display-only; a worker's activity always carries the registry-derived actor.
The CLI (and an agl mcp server holding the local-owner capability) writes as the driver
role, named after the OS user. A write also records the Claude Code session the CLI runs inside
(sessionId: claude:<session id> on its actor) when the controller proves it from the kernel:
the process the capability names holds the CLI's end of the request's loopback connection, and
exactly one process in its parent chain is the process a Claude Code inventory (the host's or a
fleet profile home's) lists for a session, unchanged after the inventory was read. Anything short
of that records no session; AGL never infers one from names or directories. fleet audit uses
these comments to find drivers that run outside the fleet.
Questions, AutoLabels and ticket notices
A question is an item of kind question in a ticket's discussion: ticket_get and
controller.ticket.get return them as questions, oldest first. It has an id per ticket
(Q1, Q2, …), Markdown question, up to 20 options ({ id, label, description? }, ids
1 to n in order), multiSelect, allowCustom (free text, default true), askedBy,
createdAt, status (open or answered) and, once answered, answer: { choices, text?, answeredBy, answeredAt }, the chosen option ids in option order plus the free text. The
field names follow dees-catalog's question card (IHarnessQuestionRequest), and
ticketQuestionCardRequest and ticketAnswerFromCard in ts_interfaces/tickets.ts map a
question to the card and the card's response to an answer.
An assigned worker asks on its own tickets through ticket_question_ask; the owner and the
driver (agl ticket ask) may ask on any ticket. Only the owner (controller.ticket.answer)
and the driver (agl ticket answer) answer; a worker's answer is refused with
ticket_answer_forbidden. A question takes one answer: repeating the same answer returns the
answered question unchanged, and a different one is refused with ticket_question_answered.
A choice must name an option, multiple choices need multiSelect, and text needs
allowCustom. Asking and answering each advance the ticket's revision and add an asked or
answered activity entry; neither takes an expected revision.
Every ticket carries autoLabels, computed on read and never stored. openQuestion is
present while the ticket has an unanswered question. Label filters (--label, label in
list, board and search, through the web, CLI and MCP) match hand labels and AutoLabels alike.
A hand label may not use an AutoLabel's name (ticket_label_reserved), and AutoLabels cannot
be set or removed.
On the Board, a card whose ticket has an unanswered question carries the Open question
chip. The ticket's discussion shows each question among the comments, by when it was asked, as
a question card that the owner answers in place; a hint at the top of the ticket counts the
waiting questions and its Answer brings the oldest into view. A refused answer shows the
controller's sentence on the card, which waits again with the answer kept. Ask as question
beside Comment asks one in words. The discussion names who asked each question and who
answered it; the history keeps to the ticket's changes. The board follows tickets.changed, so
an answer from any browser or from agl ticket answer removes the chip everywhere.
When someone other than the assignee changes an assigned ticket (an answer, a question, a
comment, a move, an edit, an assignment change or a link, or an edit refused as stale), AGL
tells the assignee's session through the fleet's native steering: a Claude worker receives a
relay, or a revival if it was reaped, and a Codex worker its exact turn/steer or next turn.
A reassignment also tells the previous assignee, and an answer also tells the session that asked
the question, which hears of it even when it never held the ticket. Changes to one ticket for one session gather
for 5 seconds and then go out as one notice, at most one every 30 seconds, listing up to 20
changes and a count of the rest. A ticket shifted by another ticket's move is not told. A notice starts with [AGL ticket notice for AGL-<n> "Title"; from the ticket board, not from the user], names who made each change, and ends with the ticket's revision and
column. An assignee never hears of its own changes, of the fleet's mirror of its own
report, or of the board pump's comments. Delivery needs the assignee's session to be a fleet worker (agl fleet worker adopt);
otherwise, and on any other delivery failure, watch emits ticket.notification.failed. Every
change except such shifts, the driver's own, the board pump's and the fleet's mirrors also appears in fleet watch as
ticket.changed, and each delivered notice as ticket.notified, so the Driver sees them in
its event stream.
Kanban provider API
Use the authenticated typedrequest channel and the shared contracts in
ts_interfaces/tickets.ts and ts_interfaces/requests.tickets.ts. The provider callbacks
map directly to these methods; unwrap the named response field.
| Method | Request | Response |
|---|---|---|
controller.ticket.board |
{ mine?, project?, status?, label?, text? } |
{ revision, columns, tickets, viewer } |
controller.ticket.list |
same filters | { tickets } |
controller.ticket.get |
{ ticketId } |
{ ticket, comments, activity, questions } |
controller.ticket.search |
{ query, label? } |
{ tickets } |
controller.ticket.comments |
{ ticketId } |
{ comments } |
controller.ticket.comment |
{ ticketId, body } |
{ comment } |
controller.ticket.update |
{ ticketId, expectedRevision, changes } |
{ ticket } |
controller.ticket.move |
{ ticketId, columnId, beforeTicketId? } |
{ tickets } |
controller.ticket.create |
{ project, kind?, title, columnId?, parentId?, ... } |
{ ticket } |
controller.ticket.link |
{ ticketId, link } |
{ ticket } |
controller.ticket.ask |
{ ticketId, question, options?, multiSelect?, allowCustom? } |
{ question, ticket } |
controller.ticket.answer |
{ ticketId, questionId, choices?, text? } |
{ question, ticket } |
viewer is the person the controller identifies the caller as, the id its tickets name in
createdBy and assignee: the board's "assigned to me" compares against it. The columns carry
display titles (Backlog … Released) next to their fixed ids.
id equals key. Every returned ticket carries its numeric revision, advanced by every
change to that ticket (including comments, links and move/reorder changes). Existing stored
tickets with no revision expose zero until their next change. Updates must send the revision
the editor started from; an older or otherwise mismatched value refuses the whole transaction
with ticket_revision_conflict and currentRevision. MCP reports TICKET_REVISION_CONFLICT
with the same currentRevision. The Board preserves the dirty draft's original baseline during
refreshes and offers to reload after a conflict; it never silently retries the save. Moves do
not take an edit revision and keep their existing transactional ordering.
Returned permissions (edit, move, comment, createChild, ask, answer) are
actor-specific UI capabilities, never authorization input; ask and answer follow the
question rules above, so a worker sees ask only on its own tickets and never answer.
changes supports the kanban's title/description/priority/assignee/parentId plus project and
labels; null clears assignee/parentId. The provider closes over
the selected project when creating tickets. Ordering is global within each column even
when the board filters by project: always send the target ticket id, not a client-assigned
order. A move returns every record whose order or column changed, including reordered
neighbors outside the project filter. The store allocates order and fences the sequence,
authorization and activity in the same embedded-database transaction. A failure leaves
none of them changed. outcome_unknown requires reading before retrying a mutation.
Listen for tickets.changed on the existing controller push channel. It carries
ticketRevision, ticketIds and timestamp, only after commit; reload the board and the
open detail when it changes. Read board.revision to reconcile out-of-order invalidations.
Comments/activity are immutable, ordered by committed board revision and retained across
controller restarts. New collections initialize at startup without rewriting existing data.
Existing transfer bounds still apply: 512 KiB per MCP tool result and 5 MiB per private
typedresponse. Oversized reads fail explicitly rather than silently truncating records.
The Board
The web app's header has a Board button. It shows the board in the main pane; pressing it while the board is shown opens nothing new. The board has no row in the session list, which lists conversations only: a conversation, the Terminals view, the configuration or another project takes the pane, and the Board button shows the board again. A reload does not reopen it.
The board is dees-kanban over the provider API above, one board for every project. It
fills the main pane, and each column scrolls its own cards below its header. Its first load
shows placeholder cards after 200 ms and says Still loading board… after 10 s. Refreshes
keep the shown tickets while loading; a failed fetch ends loading and shows its reason.
The search field filters by text; the filter button narrows by project, epic, assignee and
label, and Assigned to me keeps the tickets whose assignee is the viewer. The web owner
is identified by the authentication document's stable user principal, not a credential.
Every passkey, browser profile, temporary password and owner capability shares the same
"mine". Startup migrates existing owner-kind assignments to that principal; worker and
other-person assignments and historical authors/activity stay unchanged. New task and
New epic ask for the new ticket's project, starting at the one chosen in the filters, else
its epic's. Every ticket belongs to one, so the dialog offers no "No project", says so below the
field and, confirmed without a project, opens the projects instead of creating; the controller
refuses a ticket without one as well. Released starts folded to its count and its newest
three cards; the button in its header opens it. Assignees offered are the viewer and AGL's
tracked conversations that are not archived; a ticket's existing assignee is kept exactly as the
controller holds it.
The owner can drag a ticket to any column. The controller decides every change: on a change
it refuses, the board and the open ticket first read themselves again, so another client's
change the refused one was made against shows at once, and then the refusal shows its reason,
which stays until the ticket changes again; the card returns to where the controller holds
it, and an open editor keeps its draft. The board reads itself again on every
tickets.changed, so a worker's or another browser's change shows without a reload. A card
opens its ticket: description, links (repository, branch, short sha), the
source words, the discussion with its composer, and the ticket's history.
Configuration: one master for every harness
AGL keeps one master configuration and projects it onto Claude Code, Codex, OpenCode and Flex. The master is harness-agnostic and versioned in AGL's database; you never edit a harness's native files by hand again.
- Master — shared instructions (the
AGENTS.mdbody) plus optional notes for one harness, the drivers (the model and reasoning effort each harness runs on, in that harness's own names — e.g. Claude Code onclaude-opus-5-5[1m]athigh, Codex ongpt-6-astraatxhigh, OpenCode onopenai/gpt-6-astra, Flex on an account connection and model), skills, subagents, slash commands, MCP servers and the command policy. Secrets never enter it: MCP servers name the host environment variables that hold them (envFrom,headersFrom), and a master carrying credential-shaped text (private keys, provider or forge tokens, JWTs, URLs with a password) is refused. - Project — a separate Flex run for each harness, on the master's Flex driver, adapts its instructions. The runs proceed concurrently, each with a ten-minute deadline; a failure cancels and joins the others, and no partial projection is saved. Each run adapts: its tool names, how it starts subagents, loads skills and runs commands. Everything a harness enforces is mapped by code, exactly or not at all: models and efforts (
settings.jsonmodel/effortLevel,config.tomlmodel/model_reasoning_effort,opencode.jsoncmodel, agent front-matter), agents, commands (Codex runs them as skills), skills, MCP servers and the command policy. An element a harness has no exact form for is left out of that harness and reported — for example an MCP server whose environment comes from host variables on Claude Code or OpenCode. The adapted text lands only in the instruction file, and is refused when it drops a heading or content, adds a URL, or carries a credential. Projections are immutable. Master documents cross the browser and owner-CLI protocol as JSON text so native permission-rule ordering survives transport. Controller protocol 39 requires matching clients; the CLI's JSON input and output stay unchanged. - Review — each harness shows its state (In sync, Changes pending, Edited outside AGL, Blocked, Applying, Not connected, or Conflict while an apply that stopped on one holds it) and every file the projection changes, with the current and projected text; notes that repeat one message are one note listing the paths they name. A change that replaces a file or setting AGL did not manage yet, or one edited since AGL wrote it, says so; the reviewed diff is the consent. Native settings AGL does not set stay as they are.
- Apply — writes the reviewed changes of the selected harnesses. Each harness's review is pinned: when its files changed after the review, the apply is refused and the harness has to be reviewed again.
~/.claude.jsonis the exception: Claude Code keeps its own state there and rewrites it while it runs, so a review pins only its user MCP servers (mcpServers), and the apply merges them into the file as it is then. Native harnesses are written only after their home is connected (Connect home), and only by the installed AGL on its canonical home and database. - History — every apply, each saying on which harnesses it is still installed, with Roll back to this… on earlier ones: a rollback reviews and applies that earlier projection again. Review restore… (Restore the files as they were before AGL) reviews and returns Claude Code, Codex and OpenCode to their files from before AGL (below). A conflicted apply or restore offers Retry and Abandon; one whose writer died offers Recover.
What a restore guarantees. The first time an apply writes a native path, AGL keeps what the path held before: the exact bytes and mode of a file, a link's target, or that the path did not exist, including the folders the apply creates. This happens before the write, for every file an apply changes, creates or removes — CLAUDE.md with its @~/.config/opencode/AGENTS.md import line, settings.json with every setting and hook AGL does not set, ~/.claude.json, config.toml with its comments, rules/agl.rules, opencode.jsonc, AGENTS.md, and every agent, command and skill file; a skills folder that was a link keeps its link. Later applies and rollbacks keep nothing new for that path. A restore then returns every path that still holds AGL's last write to that state byte for byte, mode included, removes the files, links and empty folders AGL created, puts back a link AGL replaced with a folder, and releases everything AGL managed on the harness; the next apply starts from the restored files. A file changed outside AGL since AGL last wrote it is shown as a conflict and blocks its harness's restore: it is never overwritten. The exception is a settings file AGL merges its settings into (settings.json, ~/.claude.json, config.toml, opencode.jsonc), which other tools write too: there the settings AGL changed return to their values before AGL and the outside edits made since AGL's last write stay, unless one of them changed a setting AGL changed, which is a conflict. A restore keeps only the outside edits made since AGL last wrote a file: an outside edit that a later apply merged over is part of that write, so the restore reverts it along with AGL's settings; the review's per-setting diff shows it. ~/.claude.json always returns setting by setting: Claude Code keeps its own state there and rewrites it while it runs, so a restore returns only AGL's user MCP servers (mcpServers) and the file's mode to their state before AGL and keeps everything Claude Code wrote there, including what an apply merged over; its review pins those servers and the mode, not the file's bytes, and a server AGL wrote that was edited outside AGL since is a conflict. A folder AGL created that now holds files AGL did not write stays. Rolling back to an earlier projection still applies that projection: it does not restore files. The state before AGL is kept only from this release on: files AGL wrote under an earlier release have no such state, the restore review lists them, and a restore leaves them as they are. What is kept is bounded: at most one state per native path, in AGL's database, its bytes stored once and deleted when the path's state changes; a file larger than 8 MiB is not written at all (the review blocks its harness with baseline_too_large). These copies hold whatever the native files held, credentials included, so they have the protection of AGL's database; the review withholds a text that looks like a credential.
The command policy is a list of allow, ask or deny rules on command globs; the last matching rule decides, and a command no rule matches is asked. Globs read as OpenCode reads them: \ counts as / in the command and the glob, * matches any run of characters, ? one character, and a trailing * makes the arguments optional, so git push * matches git push as well as git push origin main but not git pushx. AGL's Claude Code hook uses the same matcher. OpenCode receives it as its Bash permission table, as a whole and in order behind a leading "*": "ask", so OpenCode asks for an unmatched command too instead of falling back to its own default. Claude Code enforces every deny through AGL's PreToolUse hook (agl-policy-hook.mjs with agl-policy.json), which replaces another Bash policy hook for the same matcher and keeps unrelated hooks. The hook denies a payload it cannot read, unparsable JSON, a payload without a tool name, a Bash call without a string command or a job without a string argv, so a renamed payload field blocks the call instead of skipping the rules. The hook runs on the AGL controller's own node, named by absolute path, so it needs nothing on the harness's PATH; when it cannot run, because that node is gone or the hook crashed, the call is blocked with a reason. A review reports a hook that still runs node from PATH or another or a missing interpreter, and applying it moves the hook to the controller's node. Codex receives prefix rules in rules/agl.rules: ask and deny only narrow, and an allow is written only when every command its prefix admits is one the master allows — a rule without an exact Codex form is reported instead. A subagent's own command policy is enforced by OpenCode; Claude Code and Codex run subagents under the master policy. Flex has no command rules and keeps asking before each command it has no permission for.
Flex reports a projection active only after a managed run of an exact captured session generation reaches configuration-backed model preparation after the apply; older sessions keep the projection they captured. For a bound Flex session, the effective system text follows the model base, the Flex instructions of its projection, the current project-root AGENTS.md (32 KiB, read anew on each run), any caller addition or the selected subagent's body, and the skill catalogue. The skill_read tool pages a skill's files of the captured projection in UTF-8-safe ranges of at most 16 KiB.
A Flex session reaches the tools of its projection's MCP servers through a tool catalog instead of one tool definition each: search_tools finds them, describe_tools reads their declarations, and execute or a run_code program calls them by their <server>__<tool> names, which agents' allowedTools restrict. Every call asks for mcp.call permission. A call is proposed and runs in order, a run_code program's only once the program finished, so a program that fails calls nothing. The exception is a tool that a server whose master entry sets trustReadOnlyHints: true annotates readOnlyHint: true: its calls run at once and concurrently with the other reads of a step, and a program reads their results while it runs. With mcp.call on ask, the permission requests of such concurrent reads are pending together.
A master slash command targeting Flex, including one with no explicit targets, must use Flex's native command-name grammar and cannot claim a built-in Flex name. Names for commands limited to other harnesses retain the shared master grammar.
Settings → Configuration → Open configuration shows the configuration in the main pane; opening it while it is shown opens nothing new. The configuration has no row in the session list, which lists conversations only: a conversation, the Terminals view, the Board or another project takes the pane, and while the pane holds unsaved master edits, leaving it asks first. A reload does not reopen it.
A strip under the configuration's header follows the master to the harnesses, Master → Projection → Harnesses → Installed, each step with its own state; what is installed is read per connected harness, so it can say OpenCode and Flex at 7c1e0a94 · Claude Code at be44f8f2 · Codex not connected. The header offers the one next step, Save master while the master holds edits (beside Discard…, which asks first) or Project while the master is newer than the projection, and otherwise says where things stand; a harness page applies that harness itself (Apply Codex), also while the master holds unsaved edits, and Apply and history applies the harnesses you check, warning where an apply replaces edits made outside AGL. The navigation marks every section whose draft differs from the saved master. Shared instructions and each harness's notes are tabs of one Markdown editor, in agents and commands too; skills, agents, commands and MCP servers are a filterable list beside the chosen item, a skill's files are tabs, and the harnesses an item goes to are chips. Skill source files, command policy and arbitrary native JSON use code editors; opening an editor does not rewrite its saved source. Save master, project, review and apply remain separate steps.
The master also owns Harness settings: non-secret Claude permissions and hooks, Codex sandbox and approval settings, OpenCode built-in agent overrides and tool permissions, and supported plugin enablement. Models and MCP servers remain in their dedicated sections. Native settings are validated before saving; credentials, sign-ins, plugin caches and session history remain with their harness. OpenCode permission objects retain insertion order through storage, projection and apply, including drift detection.
Inference through the AGL proxy. The master's inference field routes a harness through AGL's inference proxy (see Inference proxy), one harness at a time: "inference": { "claude": "agl_proxy", "codex": "agl_proxy", "opencode": "agl_proxy" }; a harness it leaves out keeps its own sign-in. In the pane this is the Inference section. Projecting such a master asks the running proxy for its endpoint (and, for OpenCode, its ChatGPT models) and fails, naming how to start the proxy, while none runs. The projection writes the route into each harness's own settings, never a credential. The credential command is the launcher the proxy writes whenever it starts, <AGL home>/proxy/credential-helper <client>: a private shell script that sets the AGL home, puts the proxy's own node first on PATH, and runs agl proxy credential <client> through the launcher an upgrade keeps, by absolute path: the pnpm-global agl the proxy's unit runs (the agl on PATH that answers as the proxy's release), or a checkout's own cli.js. A harness started from any environment -- without node on its PATH, even from env -i -- reads its credential, including while an upgrade has replaced the release the proxy started from. credential-helper --version prints the release it runs without asking the proxy. A proxy started on another release or node (agl upgrade restarts it) writes the launcher anew, so the projected command keeps working without projecting again; a node or agl removed under a running proxy is caught by the review below:
- Claude Code
settings.json:apiKeyHelperruns<AGL home>/proxy/credential-helper claude, andenvsetsANTHROPIC_BASE_URL,CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY=1andCLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1(a settings file'senvwins over the environment Claude Code starts with). Claude Code's own Remote Control is unavailable while it routes through the proxy. - Codex
config.toml:model_provider = "agl_proxy"and[model_providers.agl_proxy]on the proxy's/openai/v1, whoseauthcommand is<AGL home>/proxy/credential-helperwith the argumentcodex(re-run only after the proxy refused a credential, with a 10 s timeout;agl proxy credentialloads only the proxy's control-socket client and answers in about 0.05 s). Codex keeps its own ChatGPT sign-in for Remote Control -- unless that sign-in was handed to AGL (Hand to AGL,authswitch account release codex): Codex then has no sign-in, and Remote Control on this host stops until Codex is switched to an account again. - OpenCode
opencode.jsonc: anagl_proxyprovider on the proxy's/openai/v1with the pool's models, its key read from{file:<AGL home>/proxy/credentials/opencode}, andenabled_providers = ["agl_proxy"], so a model of another provider cannot fall back to OpenCode's own sign-in. OpenCode models are writtenagl_proxy/<model>in the master. The proxy keeps that file (0600) only while OpenCode is routed, rewrites it on every rotation, and removes it when OpenCode is switched off; OpenCode reads it once, at start, so AGL's own OpenCode server needs a restart afterwards (Inference → Restart OpenCode, offered when its configuration or credential changed after it started).
The review blocks a routed harness while the proxy is not running, listens at another address than the projection names, while the projection's route is in a form this AGL no longer renders (inference_outdated, a projection of AGL 38.8.0 or earlier: project the master again), while the proxy's credential launcher cannot run from any environment -- missing, not the form the proxy writes, not your own executable that only you can change, written for another AGL home, or naming a node or launcher that is gone (proxy_command_unrunnable: restart the proxy, which writes it anew) -- or is not the one the projection names (proxy_command_changed: project the master again), offers its pool no model, or serves the claude client from ChatGPT accounts; while a setting AGL does not own would take the harness elsewhere (a Claude Code ANTHROPIC_AUTH_TOKEN, ANTHROPIC_API_KEY, provider switch or forced login; a Codex requires_openai_auth, env_key or experimental_bearer_token beside the proxy's auth; an OpenCode disabled_providers naming agl_proxy). It warns when the proxy's unit is not enabled, runs another AGL release, or cannot reach the accounts service, when its ChatGPT models changed since the projection, when Claude Code still holds its own login -- read from Claude Code's own home, whether an earlier AGL adopted it or Claude Code signed in by itself: once Claude Code is routed, hand that login to AGL (Settings → Accounts → Claude Code on this host, or authswitch account release claude <account id>) so the proxy can serve the account -- and when the home an earlier AGL adopted can no longer be handed over until it is repaired. The Inference section shows the same as one line, with Open Accounts. A routed harness whose proxy is down fails with the proxy's error; there is no fallback to its own sign-in.
A credential rotated while a harness runs (agl proxy credential <client> --rotate) reaches it without a restart for Codex and Claude Code: the proxy refuses the old credential, and Codex re-runs its auth command, Claude Code its apiKeyHelper, and the turn goes on. OpenCode reads its key once, at start: a running OpenCode keeps failing until it restarts.
Switching a harness off is a projection like any other: its route goes from its settings at the next apply, and rolling back or restoring returns the settings it had. A harness whose own login was handed to AGL has no sign-in left after that, so switching it back means signing in to it again.
Agents and commands can set targets to limit where they appear and harnessInstructions to preserve a native body that differs from the shared instructions. Omitting targets projects to every harness. Agent native.codex preserves sandbox_mode and approval_policy; a read-only agent stays read-only. MCP servers also support targets and validated native extras, including Codex tool approval settings. A native-only definition therefore need not become an unrestricted agent on another harness.
Authoring the master from the command line
agl config trusts the local OS user as the owner, exactly as agl temp-password does. With no AGL_CONFIG_OWNER_TOKEN set, it uses the same upgrade admission and controller auth-store access as that command to attach to the controller database and mint a short-lived configuration capability. It passes the capability to the running controller privately, revokes it when the command ends, and never prints it. The controller must be running, and the CLI must use its AGL_HOME and database environment. The audit trail records every local configuration request, including reads, as local_cli, with the OS username, uid where available, pid, parent pid and parent process name where available; master revisions, projections and apply history also retain the local CLI attribution. Same-user processes, including agents, are trusted and can use this path without the owner's passkey.
agl config inference get
agl config inference set codex agl_proxy
agl config inference set codex native
inference set <claude|codex|opencode> <agl_proxy|native> reads the master, changes only that harness's route, saves with the revision it read, projects, reviews, and applies only that harness's preview. native removes the route: this is switching a harness off. It waits for completion and prints JSON with the previous and saved master revisions, projection revision, applied file paths and operation id. Other configuration commands below work through the same local owner path; omit their token assignment to use it.
The command refuses an apply whose review changes more than the inference route, and prints the review's diff and reason on stderr. Codex may change only model_provider and [model_providers.agl_proxy] in ~/.codex/config.toml; Claude Code may change apiKeyHelper and the proxy's environment variables in ~/.claude/settings.json; OpenCode may change enabled_providers and provider.agl_proxy in ~/.config/opencode/opencode.jsonc. --yes skips this extra-change check, allowing the selected harness's other reviewed changes. It never bypasses a Blocked harness or files edited outside AGL. A first apply can include instructions, policy or skills and therefore need --yes after the owner reviews that diff. A refusal leaves the saved master and projection available, and applies no harness files.
The configuration pane's Apply and history section (open it from Settings → Configuration) still creates a short-lived owner capability for agl config. Setting AGL_CONFIG_OWNER_TOKEN selects that existing path; an invalid or expired token fails without falling back to local ownership. The capability is shown once, never passed in arguments, and not forwarded to agent runtimes. Keep it in an unexported shell variable and pass it only to the intended command:
read -rs token # paste the capability; the variable stays unexported
AGL_CONFIG_OWNER_TOKEN="$token" agl config master get | jq .master > master.json # the current master, or an empty one
AGL_CONFIG_OWNER_TOKEN="$token" agl config master save --expected-revision none < master.json # the first master; later: the revision from `master get`
AGL_CONFIG_OWNER_TOKEN="$token" agl config project # Flex-adapts and projects the current master
AGL_CONFIG_OWNER_TOKEN="$token" agl config state # projection progress, harness states, history
AGL_CONFIG_OWNER_TOKEN="$token" agl config review # every harness against the latest projection, with preview ids
AGL_CONFIG_OWNER_TOKEN="$token" agl config apply --revision <projection> --previews claude=<id>,codex=<id>,opencode=<id>,flex=<id>
AGL_CONFIG_OWNER_TOKEN="$token" agl config restore review # what returning to the files before AGL puts back, with preview ids
AGL_CONFIG_OWNER_TOKEN="$token" agl config restore --previews claude=<id>,codex=<id>,opencode=<id>
A master document looks like this (the fields below are required; lists and maps may be empty). Optional nativeSettings maps claude, codex and opencode to their supported settings:
{
"instructions": "# Global Development Guidelines\n…",
"harnessInstructions": { "claude": "## Claude Code\n- Spawn subagents with the Agent tool …" },
"drivers": {
"claude": { "model": "claude-opus-5-5[1m]", "effort": "high" },
"codex": { "model": "gpt-6-astra", "effort": "xhigh" },
"opencode": { "model": "openai/gpt-6-astra" },
"flex": { "connection": "flex:authority:<account id>", "model": "gpt-6-astra", "effort": "xhigh" }
},
"skills": [{ "name": "commit-release", "files": [{ "path": "SKILL.md", "text": "---\nname: commit-release\n…" }] }],
"agents": [{
"name": "sanity-checker", "description": "…", "instructions": "…",
"drivers": { "claude": { "model": "claude-opus-5-5", "effort": "high" }, "opencode": { "model": "openai/gpt-6-sol", "effort": "high" } },
"policy": { "commands": [{ "pattern": "git commit*", "decision": "deny" }] },
"native": { "opencode": { "mode": "subagent", "steps": 15 }, "claude": { "tools": "Read, Grep, Glob, Bash", "maxTurns": 15 } }
}],
"commands": [{ "name": "c-fix", "description": "Investigate and fix issue", "instructions": "# Fix: $ARGUMENTS" }],
"mcpServers": [
{ "name": "playwright", "transport": "stdio", "command": "npx", "args": ["@playwright/mcp@latest"], "env": {}, "envFrom": {} },
{ "name": "docs", "transport": "http", "url": "https://developers.openai.com/mcp", "headersFrom": {} }
],
"policy": { "commands": [{ "pattern": "*", "decision": "ask" }, { "pattern": "git status*", "decision": "allow" }, { "pattern": "git push*", "decision": "deny" }] }
}
An MCP server entry may set trustReadOnlyHints: true, which only Flex reads (above). Names of skills, agents and commands are lowercase path segments; a command cannot share a skill's name. Claude Code global efforts are low, medium, high or xhigh; subagent drivers also accept max; Codex efforts are checked against Codex's own model catalog (models_cache.json) and Flex choices against the live Flex catalog. native carries front-matter a harness needs where the master has no concept (OpenCode mode, Claude Code tools, …); it may not set names, descriptions, models or efforts, nor Claude Code permissionMode or allowed-tools. Codex sandbox_mode and approval_policy are supported native agent settings. OpenCode shell rules belong in the agent policy; native Bash rules and allowing wildcards that reach Bash are rejected, including the permission: "allow" shorthand. A native deny wildcard is allowed, with explicit agent shell rules projected after it. Any other front-matter, such as an OpenCode permission for edit, lands in the agent's file and shows in that file's diff at review. The master is at most 4 MiB, each text at most 256 KiB.
The footer's Stats button shows current CPU, memory, aggregate network
rates, and the main disk and each monitored volume with capacity and IO busy
figures. Metric cells reserve their
width and use tabular digits, so changing readings do not move adjacent cells;
the strip scrolls horizontally on a narrow screen. Stats stays pinned with a
reserved ! or ? cue for stale or unavailable live samples; its tooltip and
accessible name give the sample age. One fixed rail control shows the scroll
direction and the number of strained cells with clipped or offscreen values. Values
at a clipped edge disappear as a whole until scrolling reveals them, so a
partial digit is never mistaken for a complete reading. Keyboard focus and
horizontal scrolling reach every disk, the IO figures, both network rates,
and the legal link at the end of the rail. Mount labels retain the path tail,
while each cell's tooltip gives the full path. Open Stats for a live
host-statistics panel
with the sample age, memory used and total, the main disk, and every monitored
volume. A sample older than six seconds is marked stale; its last observed
values and filesystem rows are dated without reducing text contrast, while missing current values
are identified as unavailable. Escape, Close and an
outside press dismiss the panel; the panel updates without rerendering the
workspace. The controller samples CPU, memory and network rates through a bounded
smartsystem worker. Initial network discovery and later topology rescans run
outside the controller event loop, keeping terminal echo and browser input
responsive while metrics are collected. The controller owns and stops that worker.
Clicking one footer reading opens only that reading's one-hour graph in a compact anchored panel; network receive and transmit remain paired. Stats continues to open the complete dashboard. Accounts beside Stats opens Settings at Accounts, where every account's usage is shown.
The controller records a sample every ten seconds in SmartData even while the panel is closed. The panel displays the last hour of CPU, memory, network and per-mount disk capacity and busy readings using dees-catalog charts. The time axis remains one hour wide after startup or a collection outage: unsampled intervals and unavailable readings are gaps, not zero usage. The panel labels partial and stale history, names the end of recorded history separately from the live sample, and marks mounts shown only in history, and reports history retrieval failures. Distinct series colors identify paired readings, and the charts leave room for the graph and its axis labels. The history survives a controller restart and expires after its retention window.
The panel shows disk usage for the filesystem holding / and all monitored
volumes, each with its mount path, used and total size and filesystem type.
Volumes are enumerated through
@modelprofile.com/mcp-system, which bounds every mount's statfs on its own,
so a hung network mount shows -- with the reason in the panel instead of
delaying the sample or the other volumes. Pseudo filesystems and volumes below
1 GiB are left out.
Every disk and volume row carries a second figure, IO, next to its capacity:
how busy the disks behind that mount were over the last sampling interval. It
is the share of the interval in which the busiest of those disks had I/O in
flight — iostat's %util — so it answers "how much of the disk's time is
already spoken for" the way CPU answers it for the processor. The figure takes
the warning style from 80% and the error style from 95%. A mount's first sample
is only a baseline and shows IO -- with the reason, as does its first sample
after more than 30 seconds without one, so a stretch nobody watched is never
passed off as current activity, and a mount whose disks cannot be attributed,
for example a network share.
The panel adds the disks the figure comes from, read and write throughput and
IOPS, and on the main disk the host's I/O pressure (/proc/pressure/io, the
10-second and 60-second some and full averages) where the kernel reports it.
Volumes on one disk show that disk's figures; the panel names the disk. When a
figure is shown, the panel also carries the caveat that busy is the share of time the
disk had I/O in flight, and that SSDs and NVMe can read 100% before they are
saturated.
The footer keeps the existing sustained-high-CPU alert. Memory flashes red when active use reaches 90% of total, and each disk flashes red when capacity use or its busy share reaches 90%; its tooltip names the cause. On Linux active memory excludes reclaimable page cache. Missing or stale readings never trigger a new alarm. Reduced-motion settings keep the alert red without flashing. The panel marks disk busy share from 80% as a warning and from 95% as critical. The panel always shows every rate and volume, and the legal link remains in the bar.
Errors
A failed workspace operation is not painted across the workspace. It goes into a journal in the browser tab, and the header shows an Errors button next to Settings carrying the number of failures not read yet. Its dialog lists them newest first with what the UI was doing, the time, the message, and the controller's reference for the failure. Errors that belong to one conversation — a scratchpad save, a session intelligence question — keep their banner next to that view as well, and are journaled too.
What the composer needs before it can send stays on a line below it and never enters the journal: the account and model a Flex message needs, an incomplete attachment, or composer text over the 64 KiB limit. The line goes as soon as the next keystroke or the next send answers it, or when the workspace opens something else. A change holds only its own conversation, the new conversation it creates, or the workspace (projects, groups, resources and settings), so a message on its way in one conversation leaves every other conversation, the sidebar and the new-conversation box usable. A send that cannot start because another change to the same conversation is still in progress, the conversation is still being created, or the controller is not connected says so on that line and keeps the message in the composer. Deregistering a project waits until no change to one of its conversations is in progress, and says so in the sidebar. When Archive finds another change to its conversation in progress, the guidance appears in the sidebar and clears when that change finishes; it is not journaled. A Codex conversation whose setup did not finish is both — the instruction to send again stands at the composer, and the failed controller call is journaled.
Copy on an entry, and Copy all, put a plain-text report on the clipboard: the AGL version, the time, the operation, the message, the reference, and the controller-side cause with its stack where the controller kept one. Request payloads are not recorded; exception messages and stacks are copied as recorded. Clear empties the journal. The last fifty entries are kept, in memory, for the life of the tab.
When the controller cannot classify a failure it answers The controller operation failed. — internal error text never travels to a client — with an opaque reference. It logs the cause under that reference and keeps the cause in a bounded in-memory journal of the last hundred failures, which only the authenticated owner can read, through controller.failure.detail.get. The Errors dialog resolves the references it collected through that request, so the dialog names the actual error behind the generic sentence. A private MCP request that fails the same way journals it under the method that failed and hands its caller the same reference.
Projects
A project is an explicitly registered existing directory. Every managed conversation, durable resource, and workspace operation is scoped to one project. Conversation model overrides are project/session scoped, while harness default models are controller-wide settings.
Projects are administered in Settings -> Projects, which lists every registered project with its directory and deregisters one. There is no project selector in the sidebar: the conversation list spans projects, and selecting a conversation moves the workspace to its project.
The same section edits the standard project directories: absolute existing directories AGL offers as project locations. Their immediate subdirectories are searched when you open a conversation and offered when you start one, but nothing is registered until a conversation is actually created or opened there. agl settings --add-standard-dir <path> and agl settings --remove-standard-dir <path> edit the same list from the terminal, and plain agl settings prints it.
The browser accepts absolute paths and paths relative to the configured projects root. It suggests matching directories while typing, rejects dot navigation, and reports unavailable paths instead of creating them. A directory typed into the new-conversation box is registered as a project when the conversation starts.
AGL records the complete filesystem ancestry for each project and revalidates it before path-sensitive work. Project removal is a durable deregistration, not a list-only action. Durable terminal, browser and workspace resources must be retired first. For a bound project, AGL then retires controller-managed state, deletes managed OpenCode and Codex conversations, cleans managed Flex roots, Git captures and managed worktrees, and removes project metadata through a resumable process. Removing metadata for an unbound project retains artifacts associated with the untrusted path. AGL never deletes files in the registered working tree.
The Git teardown works from what the reversion engine has records for — the remembered repository catalog and the owned worktree records — instead of walking the project directory, so a project with more subdirectories than the Git discovery limit still finishes its removal. Each record's repository binding is still re-verified against the live filesystem before anything is touched.
A removal that fails is resumed with backoff, and it resumes across restarts and upgrades because the intent is durable. A failure that repeating cannot fix — the Git discovery limit, fenced Git state, a dirty owned worktree, a permission error — stops the retry loop instead: nothing is deleted or abandoned, and the workspace shows a Removal blocked row naming the project and the reason, with Retry to resume it once the cause is resolved. Retry applies to a blocked removal only; a removal that is still being retried keeps its own schedule and is not interrupted. Starting AGL again also resumes a blocked removal once, so an upgrade that fixes the cause clears it without operator action. A pending removal, blocked or not, accepts no new work and is absent from the normal project list.
Conversations
The sidebar is the session list (controller.sessionfeed.list, then sessionfeed deltas). It holds the sessions of every harness across every registered project: the conversations AGL tracks, the Flex and OpenCode sessions of a project folder that AGL does not track yet, the threads on the shared Codex app-server, Claude terminals, and the Claude Code sessions of the host's home and of the fleet profiles' own homes. A conversation that is tracked, run by a fleet worker and present on the Codex server is one row. Each row shows its harness, its state (working, needing input, completed or failed), its name, the last reply's opening for tracked conversations and workers, the tickets it is linked to, and when it last changed; a newly created session joins at the feed's next pass. Selecting a row opens it: a tracked conversation opens, an untracked one is tracked first, a Claude terminal opens in the Terminals view, a session a fleet worker runs opens that worker, and any other Claude Code session or Codex thread opens where it runs, whatever its directory (see Sessions outside every project). Opening a session never registers a project: the project matters only when a new session is started. A row AGL cannot open says why in the sidebar, for example that its project is no longer registered. A ticket chip opens that ticket on the board. Right-click, the ContextMenu key or Shift+F10 opens the row's menu: a conversation's own menu, Rename and Terminate for a terminal, or Copy session id and Copy directory for a native session. Conversations archived in AGL leave the list.
A failed row of any kind leads its menu with Acknowledge and remove (controller.sessionfeed.acknowledge). The controller records, for the owner, that row's failure as of the time the row shows, and the row leaves the Failed group until the session moves again: new activity or a new failure brings it back. Nothing else changes: the session and its transcript, a fleet worker's dispatch fence and error, its tickets and the unknown-dispatch record stay as they are, and nothing is sent, resumed or stopped. The acknowledgements are kept in the controller's database and survive a restart; a row that changed since it was listed, is no longer failed or is gone is refused. From the command line the owner does the same with agl feed (see CLI); no agent, worker or MCP tool can acknowledge, so a worker never hides its own failure. The composer's open-sessions action shows the sidebar and moves focus into the list. The list header offers three actions.
New opens a menu: Chat session, then Terminal, Claude terminal, and Browser. The menu hangs under the button, is keyboard-operable, and returns focus to the button when dismissed.
New → Chat session opens the new-conversation box: the project (or a directory to register), the harness, and the model. The empty workspace shows exactly the same box, so the options never differ between the two. The harness is preselected from the one used last and is otherwise an explicit choice — AGL never starts a harness nobody picked. A new conversation remains a browser draft until its first message is submitted, so abandoning it does not leave an empty harness session. The first message appears in the conversation the moment it is sent, and the chat reads Starting the … conversation… while AGL creates the conversation and sends the message. Leaving the new conversation meanwhile does not withdraw the message: it is delivered, and the sidebar lists the conversation. A conversation that cannot be created returns the message to the composer, one that does not take it keeps it in its own composer, and one whose connection to the controller is lost before the message goes out is discarded again with the message back in the composer; every such failure is journaled.
The terminal and browser entries create in the current project. When no project is in context, one registered project is used without asking, several are picked from in a dialog, and with none registered the menu opens the flow that registers one.
A conversation's own context menu offers the same resources as New attached Terminal, New attached Claude terminal and New attached Browser, in that conversation's project. The controller records the attachment in the same write that creates the resource, so it belongs to the conversation from its first moment and a refused conversation leaves no resource behind. The entries appear only while that project is registered here.
Open searches harness conversations that AGL does not track yet, across every registered project and the immediate subdirectories of the standard project directories. Results show harness, project, and title, and mark what is already open. Picking one tracks it and registers its directory as a project when it is not one yet.
Last active uses the harness’s session-update time, shown in your local timezone.
Archive lists the conversations archived in AGL, across projects, and returns one to the active list. This archive is AGL's own: the harness conversation is untouched and keeps its own archive state.
Conversation features include:
- Steering into a running turn, and queued, steering and held messages that outlive a restart (see below).
- Tip-first transcript loading with older-history pagination.
- Live Assistant text, reasoning, tool state, todos, and subagent cards.
- Inline permission requests and questions in transcript order.
- Synchronized drafts and private temporary attachments.
- Durable model choices and scratchpads.
- Rename, archive, delete, abort, and session-specific Ask/Yolo controls.
- Token and context metrics when the harness can prove them.
Sessions outside every project
A Claude Code session or Codex thread that no project conversation records and no fleet worker runs opens in the main area as a session view without a project (controller.nativesession.read, a page of 50 turns at a time). It shows the transcript, the session panel (scratchpad, Session Intelligence, metrics and todos), pending permission requests and questions, and the session's model and effort; project features (resources, layouts, the project's model choices) are absent. The controller resolves only sessions the session list shows, never a path, and the view reads again every 3 seconds while the session works.
Its composer sends through the runtime that owns the session (controller.nativesession.send), with attachments up to the usual draft limits:
- Codex threads take the message through the host's shared Codex server, steered into a running turn or else starting the next one.
- Claude Code sessions take a message only once they settled; AGL then resumes the session in its own directory. While Claude Code works in it, the view is a live read-only transcript and the composer unlocks once it settles. A permission reply grants once.
- Claude Code background jobs (
claude --bg, listed byclaude agents) stay their session's owner: a message to a stopped, done or blocked job wakes that job through Claude Code itself, asclaude --bg --resume <session id> <message>with no options, in the job's directory; AGL's own Claude Code never resumes a job's conversation. The send succeeds only when Claude Code confirms the wake under the job's own id and its inventory then shows that job live (it waits up to 30 s while Claude Code hands the job to its process) or already finished. A copy it started instead, or a message it saved for the job's next start, fails the send with what Claude Code printed: the message may have reached Claude Code, so AGL never resends it; inspect the job before sending again. A message that starts with-is refused, since Claude Code would read it as an option. Attachments of a job's message stay until the daily sweep, because Claude Code does not report when the woken turn ends. - From an agent or a script, a job no fleet worker binds is reached the same way with
agl fleet worker native-message <session id or 8+ character prefix> --message-file FILE --sender LABELor the MCP toolfleet_worker_native_message, which hold the local-owner capability. It goes through the same send, under the same session lock and audit (session.send), but only to a background job: the text arrives behind thesession_messageheader ([Agent message via AGL from "LABEL" — the local AGL CLI of OS user "…", with the owner capability; not from the user]), never as the job's user. A running or held job is refused with its reason and the adopt command to use once it settles, a failed job with the note that it takes no wake and its adopt command, and a fleet worker's session, a job in a fleet profile's home and a project conversation with their reason; an ambiguous prefix is refused. The receipt (job.nativeMessage) says it was a native wake, which holds no dispatch fence, writes no ticket comment and does not observe the woken turn, and names the command that adopts the job.agl fleet worker messagenever falls back to it: an id no worker binds is refused with the native-message command to run instead.
The attachments of any other native send are deleted once the turn that read them ends.
A session AGL may not write to stays readable and names the reason above the transcript: another process runs it (AGL never becomes a second writer), it lives in a fleet profile's own Claude Code home, its directory no longer exists, an AGL project conversation records it (that conversation sends to it), a fleet worker runs it (the worker's view steers it), it is a failed background job (inspect it with claude logs <id> or claude attach <id>), or AGL's own Claude Code still holds a background job's conversation (Claude Code would wake a copy; the composer unlocks once AGL releases it). Nothing can stop a native session from here; whoever runs it stops it.
The scratchpad and Session Intelligence of a native session are kept per native session id. A fleet worker relaunched under a new session id therefore starts with an empty scratchpad. Session Intelligence on a native session reads that session only.
Flex session metrics
Flex metrics are authoritative fixed-size accounting projections, available even while a session is busy or retrying. AGL requests session.metrics over private Flex IPC v31 rather than scanning or summing transcript usage. Authorization and runtime-generation fencing still apply; failed accounting reads or invalid IPC responses fail the auxiliary request instead of appearing as empty metrics.
reportedLifetimeUsedTokens counts durably settled per-call reports, including session-owned compaction calls, independent of transcript visibility. reportedLifetimeUsedTokensExact describes that reported sum; lifetimeUsageComplete describes whether all lifetime usage is known. Incomplete accounting reports a lower bound, not an exact lifetime total. lifetimeUsedTokens is present only when accounting is complete; a known zero remains zero. inFlight marks unsettled generation or compaction work and makes the lifetime total incomplete until it settles. Child-session and tool-owned model calls are not part of this session's total.
The optional lastObservedInputTokens, ISO lastObservedInputTokensAt and lastObservedInputTokensFreshness: 'historical' describe the last durably settled generation-call input. They are not a current-context estimate: output, steering, compaction and reversion can change context afterwards. Flex does not synthesize currentContextTokens from them. OpenCode and Codex retain their existing metrics behavior.
reportedLifetimeInputTokens, reportedLifetimeCacheReadTokens and reportedLifetimeCacheWriteTokens sum the settled calls' input, prompt-cache reads included, and the part of it read from and written to the prompt cache; lifetimeTokenBreakdownComplete says they cover every call of a complete lifetime total, and they are lower bounds otherwise. reportedLifetimeCacheHitRatio is cache-read tokens over input tokens, 0 without input, as FlexHarness states a call's hit ratio. A session saved before AGL counted them counts them from its next call and never reports the breakdown complete.
Steering and pending messages
A message sent while a conversation's turn runs is delivered one of three ways (delivery on controller.session.send, TControllerPromptDelivery); every one starts a turn when nothing runs:
steer, the composer's main action for Flex and Codex (Steer, "Read at the next step"): the message goes into the running turn, which takes it in at its next step — after the tool results of its current model step and before its next model call; a running tool call or a pending permission is not interrupted. A message the turn did not take in before it ended becomes the next turn's input.next-turn, OpenCode's main action (Send, "Read after this turn"), since OpenCode reads messages only between turns: the message is the next turn's input, ahead of messages queued for after the turn. OpenCode refusessteer.queue, Queue ("Queue for after this turn", Alt+Enter) beside the main action: the message runs as its own turn after every earlier message. MCPsession_sendand a send withoutdeliveryqueue. MCPsession_messagetakes its delivery from the caller.
controller.session.send answers once the turn it starts is the conversation's status: a list read after the answer never shows the conversation idle before that turn ran (an OpenCode send waits for OpenCode to report the turn it dispatched). A message that waits behind a running turn or a held queue starts no turn yet, and the answer says so with queued: true.
Next-turn messages, and steers that became the next turn's input, wait ahead of queued ones, each group in the order it was sent. A message the conversation has not taken in stays under the transcript's Pending divider and says where it stands (IControllerPendingPrompt.state): Steering · read at the next step, Queued for the next turn, Delivery unconfirmed, or Held · not sent.
Queued and held messages offer Edit and Retract in their context menu when AGL can safely withdraw them. Edit withdraws the exact message before restoring its text and available attachments to an empty composer; a refused withdrawal leaves the message pending. Messages held with uncertain prior delivery offer no Edit or Retract. On Flex they are never sent again: Resend is refused (delivery_uncertain) and only Discard drops them; on the other harnesses Resend asks for a transcript check before it sends another copy. With an empty composer, Up recalls the latest pending message when it can be withdrawn. When no message is pending, Up and Down navigate sent-message history and return to the current draft. Native steering already accepted by a harness cannot be withdrawn through these controls. The browser-owner endpoint controller.session.pendingprompt.action uses the exact project, session and prompt identity; editing also requires the current empty draft revision so another draft cannot be overwritten.
Consumed steering messages immediately free their delivery slots. Attached files remain owned by the exact native turn until it finishes, including through a connection rejoin to the same Codex server. Queue capacity and attachment cleanup have separate error messages, shown beside the composer when a send fails. A queued message whose delivery cannot be confirmed stays visible as Delivery unconfirmed, with its input retained and editing disabled until its outcome is known; AGL does not automatically send another copy.
Stop halts the running turn only; it sends nothing and keeps the draft. What the turn had not taken in — its unread steers and the queued messages — is held (heldReason stopped), and so is the prompt of a turn stopped before its model produced anything, since such a turn leaves no trace. A steer of a turn that failed is held as failed; an OpenCode turn that failed holds its queued messages the same way. When the Flex or OpenCode runtime exits or restarts, the messages it had not taken in are held as interrupted; a Codex reconnect holds the unread steers of the turn it lost, while its queued prompts wait for the new connection. A held message is never sent on its own: Resend sends it again like a new message (controller.session.heldprompt.release with action: 'resend' and the busy delivery), Discard drops it (action: 'discard'). On Flex, the messages Stop held (stopped) join the next turn the user starts, whether from the composer, MCP session_send or session_message, or a Resend: exactly once and all of them, ahead of the new message, in the order they were sent, with their sender and attachments. A Resend also takes along every message held as failed, oldest first and ahead of the resent one, so one Resend delivers everything the conversation accepted and could not deliver, in one turn. Stop itself never starts a turn. A message held as uncertain or interrupted may already have reached the model, so it never joins another turn. An interrupted one is only ever resent on its own (its Resend still takes the stopped and failed messages along); a Flex Resend of an uncertain one is refused with delivery_uncertain, since it could deliver the message twice, and it can only be discarded. A Flex start that fails before FlexHarness took the turn (the model check, staging, a start FlexHarness refused outright, or a start the controller's IPC transport refused before writing any of its bytes) holds its whole batch as failed, under the messages' ids and with their sender and files; when the start's outcome is unknown (a timeout, an unknown outcome or a lost connection after the start was sent), it holds them as uncertain instead, so nothing is ever delivered twice. Neither sends anything again on its own. Every held message counts toward the conversation's 16 waiting messages, so a new message is refused while they fill it, with an error that names Resend. Delivering a held message is never refused by that cap or any other count: a Resend of any held message starts one turn that takes it in with every stopped and failed one, however many the conversation accepted (the stopped messages of earlier versions, which did not count toward the cap, included), so no accepted message ever needs a Discard to be delivered. The counts refuse new input only. One limit is inherent: a batch larger than the model's context window cannot run as one turn. That run fails: its first message stays in the transcript as the failed turn's prompt, and the run lists the others as not taken in, so they are held intact as failed, to be resent or discarded. A Resend after such a failure sends the whole batch again, so it fails the same way until the user discards held messages to make it fit. AGL never splits a batch or discards a message on its own.
Queued, steering, unconfirmed and held messages are kept in the controller's database (agl_controller_pending_prompts), so a controller restart does not lose them: every message that was still queued or steering when the controller stopped is held as interrupted at the next start, since the turn it waited for is gone. Unconfirmed deliveries are restored as held messages with the uncertain reason. Their text, model and files are kept: a message's files are written to the controller's object storage (objectstorage/ in the AGL home, an embedded engine bound to 127.0.0.1 with a credential that lives only in the controller's memory) when the message is accepted, so a message held across a restart, resent after it failed or was interrupted, or joining a turn after Stop, carries the same bytes. A message whose files cannot be stored is refused with an error and stays in the composer; it is never accepted without them. The files are deleted once no message names them — after the turn that took the message in, a Discard, or the deletion of the conversation or project — and each start removes any that no message names. A Flex message Stop held stays stopped across a restart and still joins the next turn; one that had already joined a turn when the controller stopped is held as interrupted, like every message that was waiting. The prompts of a Flex limit hold with Start at reset are restored with that hold instead (see below).
Resending atomically transfers the held message to a new operation before dispatch, so a failed cleanup cannot restore the old held copy. If a restart leaves that resend's delivery uncertain, it is held as uncertain; on Flex it can then only be discarded, and on the other harnesses the browser asks the user to check the transcript before sending another copy. Failed hold and discard writes retain ownership until persistence succeeds.
A prompt sent to a Flex conversation that is still working is steered into its running prompt or waits in AGL's prompt queue, the same controller queue OpenCode and Codex conversations use; this holds for the browser composer, for MCP session_send, which queues, and for MCP session_message, which steers or queues as its caller chooses. Flex hands a steer to the running run of the prompt (FlexHarness steerPrompt), which takes it in at its next step boundary as a user message of the same turn and reports the steers it did not take in when it ends. A steer with attachments lets the run read their upload directory like its own. The Flex child is only ever handed a new prompt for an idle conversation. When the conversation is idle again, the messages waiting at the head of the queue start together as one run, oldest first: the first is the run's prompt and the others are taken in as its steers before its first model call (FlexHarness enqueuePrompt steers), each reported and held like a steer under its own id. Messages sent once that run started wait for the next turn. A conversation takes at most 16 waiting messages, counting its running prompt and its held messages, and AGL at most 64 active message operations; a further new message is refused until one is delivered, retracted or discarded. Neither count, nor the limits on uploads in flight, refuses a message the conversation already accepted: delivering held messages, restoring a limit hold, rejoining stopped messages and materializing their files are never refused, so one start carries every message waiting for it, with no ceiling. The controller is the only admission authority: the Flex child sets FlexHarness promptQueueLimits to Number.MAX_SAFE_INTEGER, so FlexHarness refuses no start AGL admitted. A start whose messages do not fit one Flex IPC frame (8 MiB) is staged first: the controller sends the messages in prompt.stage requests under one stage id, then prompt.start names that stage, and the child takes the staged messages first, then the start's own, in one run. A session holds at most one stage; every start ends it, and the controller drops it with prompt.stage.discard when a stage request or the start fails before reaching the child, while the child drops it when it exits. The run ends its batch before a message that is being withdrawn and where the chosen model changes; how a batch treats a model change is one switchable policy (flexBatchModelPolicy in ts/classes.flexpromptqueue.ts), which currently starts a new run at the change. The run can read the attachments of every message it took in until it finishes. If the child still reports work in progress, the whole batch returns to the head of the queue in its order and waits for that work to finish instead of failing. A Flex slash command runs only on a conversation without other queued or running work and is refused otherwise.
After a Flex event gap or project identity recovery, AGL checks the exact registered run's source state before releasing its queue. A newly queued follower also triggers that check, so a missed completion cannot leave later messages waiting behind an already finished run. Streaming events do not cause repeated state queries.
When a Flex provider account reaches a usage or rate limit, the conversation says so: the failed run carries the provider's message and the limit (kind, reset time, plan and window), and a model call that is being retried shows the attempt and the time of the next try. The work that met the limit goes back to the head of the conversation's queue, together with everything sent after it, and the queue is held: nothing is dispatched into the same limit. The controller also remembers the limit for the account and reads the account's limits when asked (a read serves for a minute), so a prompt for an account that is known to be at its limit is held without calling the provider. An account with a maximum usage is read before a prompt is dispatched on it -- no provider failure reports a reserve -- and a prompt for an account at its reserve is held the same way, as Usage reserve reached, until the window that reached it resets; an account whose read fails is not held for it. Sending held prompts now reads the account again, so they are held again while it is at its reserve: clearing its maximum usage, or choosing another account, spends or avoids the reserve. A held queue waits for the user, who can send the held prompts now or cancel them; Stop keeps only the input that was not taken in as held messages; input already retained by Flex stays in its native history. Two per-conversation options, both off by default, change that: Start at reset starts the held prompts on their own once the limit resets, after reading the account's limits again and re-arming the hold if the account is still exhausted, and Compact first runs the conversation's /compact before they start, because compaction uses the same account. With Start at reset on, the hold and the text of its held prompts are kept in the controller's session store, so they survive a controller restart, and a held prompt's files stay in the controller's object storage with it. A hold resumes at most once, however often its timer or a restart triggers it. The hold stays armed until its prompts are handed back to the queue, so a restart or shutdown during the re-check or the compaction keeps them; once a hold has resumed, been sent, cancelled or stopped, or start-at-reset is turned off, the stored prompt text is dropped. The account whose limits a Flex conversation shows is its chosen account or, without one, the account its last prompt ran on since the controller started, so after a controller restart such a conversation shows its account again after its next prompt.
In the browser a busy Flex conversation offers Steer and Queue like Codex. A line above the composer states the limit: "Usage limit reached" or "Rate limit reached", the account, and the reset time with a live countdown, or that the provider reported no reset time. While the queue is held it counts the held prompts, says when they start or when the account is checked again, and carries the Start at reset and Compact first switches with Send now and Cancel. A switch shows its new state at once and settles on what the controller confirms; while a change runs, the conversation's controls are disabled and other changes to it are refused. A model call that is being retried reads, for example, "Rate-limited · retrying in 16 s (4/8)". A Flex run that failed for another reason shows the harness's message as the conversation's status.
Archived conversations are hidden from the normal list and available in the Archive view, which serves them from a durable title cache and therefore starts no harness read. Archive is admitted only after active prompts, permissions, commands, and Session Intelligence work have settled.
AGL never automatically redispatches a Flex archive whose outcome is unknown. It performs bounded exact-session readback and finalizes a confirmed archive. Otherwise it stops the exact Flex generation before releasing archive admission; if that stop cannot yet be confirmed, AGL retains the fences and retries cleanup until the generation exits. An authoritative refresh that confirms the conversation is archived clears a stale browser error.
Restarting a conversation's runtime
The composer's More options button (⋮, directly left of Send) offers Restart for the runtime the open conversation runs on: Restart Codex (this host), Restart Codex · , Restart OpenCode or Restart Flex. It asks first and says what the restart ends, states "Restarting …" where the composer is while it runs, and afterwards how the runtime came back; a runtime AGL cannot restart, a remote Codex server, is listed disabled with the reason. A restart that fails is journaled. The menu follows the composer: while the composer is locked (another Codex client runs the turn, or the conversation is read-only), it is too.
A conversation runs on one runtime, and the browser's read of a conversation (controller.session.get) names it in runtime: Codex (this host) for Codex on this host's own login, Codex · <account> for Codex on an account of the accounts service, Codex · <server> for a remote Codex server, OpenCode or Flex. controller.session.harness.restart { projectId, sessionId } restarts that runtime through the component that owns it, so a changed login, for example after an account switch, takes effect:
- Codex on this host. When Codex's managed daemon serves this host's Codex home, AGL restarts its app-server through authswitch's
CodexDaemon.restart():codex app-server daemon stopwhen it runs, then alwaysstart, in its own systemd user scope from$HOMEwith the environment the running app-server had. The answer'snoticesays how it came back, in authswitch's words: whether it ran before, why it has no systemd scope of its own, and which environment it got. An app-server the daemon does not manage is not restarted, and the answer says why. Without the daemon, AGL's own app-server is stopped and started again. - Codex on an account. The accounts service stops the account's app-server, and starts it again, on the same Codex home, when AGL reconnects.
- A remote Codex server is refused with
harness_restart_unsupported: AGL only connects to it. - OpenCode and Flex. AGL stops its OpenCode or Flex child and starts the next one through the same recovery that follows a crash.
Every conversation on the restarted runtime loses its running turn; Codex conversations of the profile reconnect through the profile's explicit reconnect, and the answer comes once the runtime serves again (for Codex, within 60 seconds, after which AGL's bounded rejoin keeps trying). A restart that did not end with the runtime serving again fails with harness_restart_failed, the reason, and a failure-journal reference where the owning component gave one. Every request is audited as session.harness.restart.
Managed Conversation Authority
Controller-created root conversations are automatically managed. On the first compatible startup, AGL seals one bounded migration cohort per project and harness and admits provider-present, non-deleted conversations that already have durable legacy AGL session state. Opening a project also discovers non-archived native root conversations in that exact folder, independently of which client created them. OpenCode uses exhaustive native cursor pagination, Codex uses the configured server and directory mapping, and Flex uses the project's registered native storage scope. Discovery creates durable managed membership before granting access; deletion fences and tombstones still take precedence. Archived conversations stay out of the active sidebar, and managed archives remain available in the archive dialog. Subagent conversations remain accessible through their parent. Sessions in other folders or unconfigured servers are not admitted. Unmanaged conversations remain excluded from ordinary reads, mutations, events, layouts, resources, Session Intelligence, and lifecycle operations.
Provider-declared parent/child relationships never grant ordinary managed-session authority. AGL does not infer or create child membership from a relationship, and independently managed children continue to use normal managed-session access.
For OpenCode, the browser can also receive a temporary scoped view of a provider-observed direct child of one active managed root. The child remains absent from generic session lists, reads, mutations, layouts, resources, Session Intelligence, lifecycle operations, and MCP. The scope permits status reads, bounded transcript reads and replies to exact pending child permissions or questions only. Background status reads use controller.session.child.status.get, which verifies the same scope without loading messages or attention requests. Permission replies are limited to one-time acceptance or rejection and cannot create a persistent provider grant.
Each direct-child scope is bound to the exact browser peer and credential, project, managed parent identity, parent and child provider generations, and OpenCode runtime generation. Active scopes expire after five idle minutes or 30 minutes total. A completed child receives a fixed ten-minute read-only grace period; if it resumes, the old scope is revoked and the browser must obtain a new generation. Capacity is limited to 16 scopes per peer and parent, 32 per peer, and 512 controller-wide.
Pending direct-child attention is attached only to the browser's managed-parent detail and is limited to 16 children, 32 requests, and 256 KiB. The browser reports when that transfer was truncated. Child invalidations are sequence-ordered, bounded, and delivered only to the exact authenticated peer; overflow or authority loss revokes the scope.
Codex collaboration and subagent activity items link each native child thread to its parent chat, including calls with multiple receivers. The browser reads a direct child through the managed root's Codex connection only after the native parent ID and server profile match; the child gains no independent managed membership or mutation access. A minimal scoped read supplies authoritative child status and the active-subagent count, with periodic refresh while the native child is active. Opening either the subagent row or sidebar item opens one AGL child-chat modal; it reads the bounded transcript then and loads older pages on demand. A completed parent tool or an interaction activity item does not establish that the child finished.
Conversation search accepts a non-empty printable title query of at most 2,048 UTF-8 bytes. A truncated response means more matches or more candidate directories exist: refine the title query, because the response and UI provide no continuation cursor.
An unconfirmed child status is shown as checking or unavailable and is excluded from the active count. Failed status reads are reported and retried at most twice automatically; later session updates can trigger a fresh read. Opening another subagent waits for the previous dialog to close, including its exit animation.
Deleting a managed OpenCode conversation deletes the selected OpenCode conversation. Deleting a managed Flex root also deletes the exact-generation descendant subtree captured with that root. Flex session deletion and project removal require a complete snapshot and exact-generation cleanup cohort, each bounded to 2,048 entries; an oversized scope fails closed.
Models And Provider Accounts
AGL stores an independent default model for OpenCode, FlexHarness, Codex and Claude Code. A conversation can override its harness default without changing other conversations.
A stored default names a model as the harness offered it then, so AGL resolves it against the harness's live model list whenever a conversation is created or sent without a model of its own. While the harness offers it, it is sent. A stored default the harness no longer offers is never sent: the harness's own default stands in -- OpenCode's or Codex's configured model, or for Flex the catalog default of the account placement takes -- and when the harness offers no default AGL can name, OpenCode, Codex and Claude Code run their own default unnamed, while Flex, which has none of its own, asks for an account and model in the composer. The stored setting stays as the owner wrote it; models_list reports it as unavailableSetting, and Settings says under the default model that it is no longer offered and what new conversations run on instead.
A turn the provider refuses -- a model it does not offer, a login it rejects, a limit -- ends in the conversation with the provider's reason as an error message after the prompt, for OpenCode, Codex and Claude Code as for Flex, not only on the conversation's status. Codex keeps the reason on the failed turn, where AGL reads it again with every page; Claude Code records it in its transcript as its reply to the prompt; OpenCode keeps no record of a turn it refused before its model ran, so AGL holds those reasons (2 KiB each, the newest 32 per conversation) while the controller runs. The reason shows once: the status line under the transcript names it only when the transcript's newest turn does not, and otherwise says that the turn failed.
Flex model identity consists of the provider, model, and optional variant, independently of any provider account. AGL presents each model once and reports variants and default status per connected account. An explicitly selected or persisted account is validated exactly and is never silently replaced. Conversations on one account use it side by side: a message sent to an idle conversation is accepted without waiting for another conversation's start, /compact or slash command on the same account; only a refresh of the account's models waits for the uses admitted before it and holds off those after it. Without an account selection, AGL takes the account its account placement would take for the model, as the inference proxy judges the accounts: the best-placed usable account that offers it, never one at its limit or reserve, lapsed, without a usage reading or running this host's Codex. A new conversation's composer shows that account before the first message. When no placed account offers the model, the one account that does takes it; with several, the browser asks for an account selection or connection. The conversation's account picker lists every account the controller lists: one that needs a new sign-in stays listed, disabled, with where to sign in again -- Settings → Accounts, where a login Flex holds itself is logged out and signed in again -- and a conversation whose own account needs it shows no account and refuses a message at the composer until the account is signed in again or another is chosen. The accounts service's accounts are listed only while the service is current; in every other state a note under the picker names it in the Accounts screen's words, with the repair where there is one.
Flex provider accounts are connected from Settings through the provider's supported login flow. Credentials remain in a Flex-owned sealed store and never enter SmartData or the browser. The store's master key is sealed to the local TPM2 and survives normal host reboots, kernel updates, bootloader updates, and firmware updates. AGL never resets an unreadable credential store during ordinary startup. Clearing, replacing, or detaching the TPM requires restoring usable TPM access and explicitly invoking SmartSecretSealedFileStore.resetTpm2() with the exact service, store, and path identity while AGL is stopped. AGL currently provides no operator-facing reset command. That destructive reset discards the old ciphertext, and saved connections then require reauthentication through Settings. Account model catalogs, quota windows, and runtime availability are exposed as bounded normalized metadata.
An active Flex OpenAI account can also be selected for the controller-owned OpenCode runtime. AGL pauses the exact active OpenCode cohort, replaces the private OpenCode runtime, verifies health, and reopens admission only after the transition is settled.
Commands And Workspace History
The composer exposes one harness-aware slash-command catalog. OpenCode templates and built-ins remain OpenCode-owned; Flex commands are listed and executed through FlexHarness; Codex commands use the native Codex app-server command surface plus AGL client actions that the controller explicitly authorizes. The command menu opens only while the prompt is / and part of a command name: ordinary text, paths such as /tmp/x and unknown names open nothing, Enter or Tab completes the highlighted command without sending, and unavailable commands stay listed with the reason they cannot run. A prompt that names a listed command runs it, and its main button reads Run. Commands that are available during an AGL-owned Codex turn still run as commands; they are never forwarded as steered or queued input. Idle-only and otherwise unavailable commands preserve the exact synchronized draft.
Flex /undo and /redo move transcript and workspace history together. For Git-backed projects, AGL captures tracked state and non-ignored untracked paths in controller-private storage without adding controller refs or generated objects to the source repository. Unsupported or ambiguous repository states fail closed or become explicit non-revertible barriers.
FlexHarness agent generation-lease cleanup retains its independent 30 second deadline, followed by a 35 second ordinary Flex IPC control deadline. Each controller-owned Git host operation is bounded to five minutes, Flex reversion maintenance has a separate 5 minute 35 second budget, and the enclosing Flex startup and destructive lifecycle operations remain bounded to 30 minutes.
Controller readiness never waits on Flex initialization for more than 30 seconds before stopping the Flex child and continuing. A Flex child that is still initializing when that boot budget expires is brought back through Flex recovery once the controller is ready, so agl start and agl upgrade observe readiness promptly while Flex keeps its full lifecycle deadline for maintenance-heavy startups.
A terminated Git child gets one second to exit after SIGTERM and then five seconds after SIGKILL to confirm close. A child that has exited but whose close event is late, for example because a descendant inherited its pipes, only fails its own command. Only a child that is still alive after SIGKILL seals the controller's Git reversion engine; AGL then closes that engine and constructs a replacement during the next Flex recovery attempt, or on its own backoff schedule while Flex stays operational, so reversion never stays disabled until a controller restart.
Flex also supports explicit managed worktrees:
/worktree create
/worktree list
/worktree remove <worktreeId>
Worktrees are never created automatically. Removal refuses a worktree with tracked, untracked, or ignored changes.
Tasks, Goals, Scratchpads, And Delegation
Flex conversations include session-local tools for structured tasks, the current goal, and durable scratchpad context. These records are generation-fenced so deleted and recreated sessions cannot inherit stale state.
Flex can delegate through six child levels (root0 → child6) to configured subagents. Delegation runs asynchronously by default: the parent receives the admitted task ID and can continue while the child works. delegate_result reads its progress or waits for a bounded interval; foreground: true makes delegation wait for completion. Omitting taskId creates a child; an exact ID returned by an earlier completed delegation can resume that child in a later run. Every child inherits the managed root's immutable model, variant, provider account and pinned configuration through an exact chain of active parent run leases. Each hop is bound to its parent and child generations, runs, runtime and peer; stale ancestors, cycles and depth beyond six are refused. Children never receive managed-session membership. Terminal completion, cancellation, close or runtime loss fences descendants and revokes their unused admission tickets while retaining exact cleanup authority until settlement.
Pending permissions at every admitted depth remain visible in the authorized managed root conversation. Global Yolo and that exact active root's session Yolo can reply once to the exact child, and a manual root reply can accept once or reject. Configured allow/ask/deny policy remains bound to the inherited revision; a nested delegation needs its own once-only admission ticket. An approved delegation admission also records a read-only transcript grant bound to the controller, project and exact parent and child generations. AGL uses it for authoritative child status and for the single child-chat modal, whose bounded transcript is read when opened and whose older history is paged on demand, including after the child finishes. A nested transcript read must prove every direct-parent grant and current generation back to the explicitly authorized managed root; grandchild relationship metadata does not confer access. Relationship metadata alone grants no access; older children without an admission grant remain unavailable unless they independently have managed membership. Deleting or replacing either generation invalidates the grant. The transcript endpoint cannot answer permissions, send prompts or stop the child.
With FlexHarness 10, settled model history is compacted automatically before a turn when it exceeds the default 256 KiB context budget. The compactor uses that turn's resolved model; successful automatic archival advances the undo horizon. Startup reports ready after storage initialization and migrations, while tracked recovery loads existing session histories in the background. Requests join the same namespace recovery, and a recovery failure closes the runtime to new work.
Session Intelligence
Session Intelligence can answer a question about one managed OpenCode, Codex, Claude Code or Flex conversation and, after reading it successfully, inspect only other managed sessions of those harnesses in the same project. Relationship metadata does not expand that access, and child conversations stay excluded as everywhere else. A Codex conversation is read through the same managed read session_read uses, but without subscribing to the thread: the analysis resumes nothing, starts or steers no turn, and leaves the conversation's writer and its released state as they were. Listing never admits a newly discovered folder conversation.
Every analysis runs in its own ephemeral, read-only Flex session, whatever harness the analyzed conversation belongs to. The session lives in memory only: it has no Flex native storage, writes no database rows or files for its transcript, is never listed as a conversation (sidebar, archive, sessions_list, MCP), and is disposed when the analysis ends or the controller stops. Its only tools read sessions and submit the result; it has no filesystem, shell, browser, mutation, or delegation tools. Only the question, the answer and the scratchpad suggestion are kept, on the analyzed conversation.
Settings → Session Intelligence selects the model every analysis runs on: a Flex account, a model that account offers, and optionally its reasoning effort, chosen like a conversation's Flex model. Only accounts Flex can use now are offered. Saving validates the choice on that exact account and stores it in the controller settings (controller.settings.update with sessionIntelligenceModel; null clears it); every analysis validates it again before it starts. Nothing is selected by default: until a model is selected, Session Intelligence is unavailable and its panel says to select one in Settings → Session Intelligence.
The Session Intelligence panel remains visible while model availability is being checked or is unavailable. Its question composer is enabled only while the selected model validates on its account; otherwise the panel names the selected model and what to do -- select a model, reconnect its account in Settings → Accounts, or select another account -- without exposing account or credential details. When a settings change can fix it, the panel also offers a button that opens Settings at the right section, Session Intelligence or Accounts; a temporary failure offers none.
A fleet worker's session and a session outside every project have the same panel. Its analysis reads that one session only: listing and reading other sessions return nothing else. With no project, it uses the selected account at installation level, as configuration adaptation does, and a read-only session is analysed too.
Answers and scratchpad suggestions are bounded and revision-aware. At most four analyses run controller-wide and one per source conversation.
Resources
Resources are durable project-scoped capabilities separate from conversations.
A resource may be attached to several subjects at once. Every attached conversation may use it: a second chat attaching does not take the resource away from the first, both drive it, and each action is fenced by the attachment revision that caller observed. Another chat's attachment grants a caller nothing — every gate asks whether the set holds that caller's own subject.
Every conversation attachment stores the exact immutable managed-session identity, not only the reusable runtime session ID. A stale or legacy identity-less attachment is detached or rejected during reconciliation, and recreating a conversation with the same runtime ID never inherits its resources. Deleting a conversation removes only that conversation's attachment; the resource stays attached to the others.
Only a browser may be attached to a terminal, and a terminal carries conversation attachments only, so an attachment cycle is impossible by construction rather than by cycle detection. A terminal attachment also puts the conversation that owns the terminal into the browser's driving set, so that agent can act on the browser it attached; the terminal itself is never a member, because it is a process rather than a conversation.
Terminals, Browsers and Workspaces in the top bar open the resources of that type in the project in context: the open resource fills the main area and the panel on the right lists the project's resources of that type, with New for another. A resource row shows one marker per attachment — two by name, the rest as a +N count. Attachments change through the resource's context menu.
A resource created from a conversation's context menu is attached to it by the creating request itself, so it is never persisted or shown unattached. The resource context menu offers Attach to … when nothing is attached, and Additionally attach to …, Move to … and Detach when something is. Each … opens a searchable conversation picker instead of listing every conversation, and never offers a conversation the resource is already attached to. Move and Detach ask which attachment they mean only when there are several. A Move is a single request, so the resource is never momentarily unattached, and Detach removes exactly the membership it named rather than clearing the set.
Terminals
New → Terminal creates a terminal resource, which runs a real shell in the project directory through the controller's shell-free node-pty integration.
- Closing or reloading the browser does not stop the terminal.
- Reopening a terminal shows its current screen, not its history: the controller sends the reconstructed state with up to a thousand rows of scrollback instead of replaying every byte the root produced.
- Manually assigned terminal names, attachments, stopped state, and last exit code are durable.
- New unnamed shells follow the shell or application’s reported window title (OSC 0/2), so a shell with command titles can show
claude agents. Renaming a terminal fixes its name, including after a restart. Automatic titles are live display metadata and are never saved; after the process ends the durable name returns. Existing saved names and shells without title reporting keep their names. - A stopped terminal offers Acknowledge & Restore in its workspace. This starts a fresh shell in the same project directory; processes lost during a controller restart cannot be recovered.
- Terminal input, output, and scrollback are never persisted.
- A terminal can be renamed, stopped, restarted, or retired from its context menu.
- Selecting, opening or creating a terminal puts the caret on it, so the next keystroke reaches the pty. A reattach after a dropped connection never does, so a reconnecting terminal cannot take focus back from wherever you moved on to.
The controller parses each terminal's output as it arrives, so it always knows what that terminal shows, including output produced before anyone attached. An attachment opens with that state at an exact position in the output stream and continues with live output from exactly there, and a viewer that falls behind the in-flight window is handed the current state again rather than a gap. A repeated hand-over carries the current screen without its reconstructed history, so a viewer on a slow connection can catch up with a terminal that keeps producing; one that still cannot is dropped and re-attaches rather than having the controller serialize for it forever. Alternate-screen applications, mouse reporting with SGR coordinates, cursor visibility and style and the scroll region are restored with it; the window title, hyperlinks and underline style are not. A root that produces output faster than the controller can parse it is paused at the pty, as it would be behind a slow physical terminal.
AGL allows at most eight running terminals per project and 32 controller-wide.
Claude chats
New → Claude terminal creates a terminal resource whose root process is Claude Code rather
than a shell. AGL mints the conversation's session id, persists it, and reuses it forever: a
controller restart or agl upgrade brings the chat back with claude --resume <id> in the same
project directory, and the conversation continues.
- The restart intent is explicit. A chat you stop stays stopped; a chat the controller took down comes back. Controller shutdown never records a stopped intent.
- Agent roots receive SIGTERM before SIGKILL so Claude Code can flush its transcript. The last in-flight assistant turn may still be lost; every completed turn is kept.
- Before resuming, AGL proves the conversation is not already open elsewhere and refuses to start
it if it is. Two processes resuming one conversation branch its transcript and silently discard a
branch, so an unverifiable liveness listing is treated as unproven rather than free. Do not
resume an AGL-owned chat by hand with
claude -c,--resume, or the/resumepicker. - A chat that fails to start three times in a row flips to stopped and shows why, instead of retrying on every controller start.
- Claude Code must be installed and on the controller's
PATH, or pointed at byAGL_CLAUDE_EXECUTABLE. It authenticates with its own credentials underHOME, unless the chat runs on a ChatGPT account (below).
A Claude chat is a terminal, not an AGL session. It does not appear in the task sidebar or MCP session tools, and has no model picker, token metrics, permission projection, or upgrade stop. The terminal is the interface. For a Claude Code conversation with these, start a chat session on the native Claude Code harness instead.
Resources are scoped to the controller port. Starting AGL on a different port hides a project's Claude chats and does not restart them; their conversations remain on disk under Claude Code's own storage and are reachable again by starting AGL on the original port.
Claude chats on ChatGPT models
While New Claude terminals in Settings → Accounts is set to ChatGPT models (claudeTerminals: 'chatgpt'), a new
Claude terminal runs on the ChatGPT models of AGL's inference proxy instead of Claude Code's own configuration, and keeps
that for its whole life, across restarts.
- Each start of the terminal takes a lease of the proxy's ChatGPT pool, as
agl proxy run claude --provider openaidoes: a private credential that ends with the terminal, continuing the sessions of the proxy'sclaudeclient. The proxy routes the conversation to one of the pool's accounts and moves it to another when one runs out. The proxy must be running to start the terminal. A proxy restart, such as the oneagl upgrademakes, keeps the lease: the controller takes it back and the chat keeps its credential (see the lease lifetime under Inference proxy above). A lease the restarted proxy does not hand back stops the chat withinference_lostand the reason, and Acknowledge & Restore starts it again on a new lease, resuming its conversation. - The terminal starts with the pool's default model for every model name, the context window the catalog states for it,
and the pool's models as the whole
/modelpicker. Claude Code sends no experimental betas, no nonessential traffic and no attribution block. - Every such terminal shares one Claude Code configuration directory,
$AGL_HOME/claude-gateway, apart from the host's, so the host'ssettings.jsoncannot route it elsewhere. Claude Code writes it, and asks its first-run questions there once; AGL only creates it. Transcripts, the resume check and the liveness listing all read that directory. - AGL 38 retired the controller's own Claude Code gateway: a terminal created on a ChatGPT account before runs on the ChatGPT pool from then on, in the same directory. Reasoning the old gateway signed is not carried to the proxy's accounts; the conversation continues from its text and tool calls.
Browsers
New → Browser creates a browser resource, which provides tabs, navigation, shared viewport control, mouse, keyboard, and text input through a Paint or Video viewer. The controller opens one authenticated persistent event stream and one paired operation stream for each human view. These carry state, input, and video signaling. WebRTC carries video directly between the browser host and viewer, using LAN or WireGuard routes with no external STUN or TURN service by default. The endpoints must be able to reach each other over UDP.
Paint and Video
A browser view presents the page in one of two ways. Paint, the default, draws the page in this tab from the vector scenes the browser itself records, with CanvasKit: text stays sharp at any display scale and nothing is video-encoded. Video streams the page over WebRTC as below. Each view switches on its own with the Paint/Video toggle in its toolbar; the switch keeps the page, its tabs, DevTools, a pending website dialog and held input, and freezes the last frame until the other mode shows its first one. Settings → General → Browser presentation chooses what newly opened views present; it applies without a restart. Settings → General → This device chooses for this browser profile alone: Presentation on this device (Controller default, which follows that setting as it changes, Paint or Video, for a machine that should not paint) and Paint rendering on this device (GPU (WebGL2), the default, or Software). Both stay in this browser (localStorage), reach its other open tabs at once and apply to views opened afterwards, and to the next Paint run; they never reach the controller. Software replays Paint with CanvasKit's CPU raster backend and needs no WebGL at all; GPU checks WebGL2 when Paint starts. A viewer that cannot render Paint in its mode (no WebGL2 on OffscreenCanvas, no WebGL context, or a context lost again after SmartBrowser recreated it once) shows Video and says why; budget and geometry limits never change the mode. From the CLI:
agl settings --port 4097 --browser-presentation video
agl settings --port 4097 --browser-presentation paint
Paint needs SmartBrowser's Paint Chromium. The controller downloads it once, in the background as soon as the browser resource starts (a view opened meanwhile waits for it, and closing that view does not cancel the download), verifies every file against the descriptor its SmartBrowser release ships, and keeps it under <AGL home>/cache/paint-chromium/ (~/.config/agl or $AGL_HOME for an installed AGL). The same binary serves Paint and Video for a browser. When Paint Chromium cannot run, the browser starts on the host's system Chrome (/opt/google/chrome/chrome, whichever milestone the host installed) for Video only, and the view and Settings state why:
-
No usable sandbox. Ubuntu restricts unprivileged user namespaces to programs with an AppArmor profile that allows them. System Chrome ships one; Paint Chromium needs its own, installed once as root, with the path of your AGL home:
# /etc/apparmor.d/agl-paint-chromium abi <abi/4.0>, include <tunables/global> profile agl-paint-chromium /home/*/.config/agl/cache/paint-chromium/paint-*/chrome-linux64/chrome flags=(unconfined) { userns, include if exists <local/agl-paint-chromium> }Load it with
sudo apparmor_parser -r /etc/apparmor.d/agl-paint-chromium. AGL never starts Chromium without its sandbox. -
Download failed. AGL retries with the next browser start, after 60 seconds doubling to an hour.
-
Integrity failed. A download, archive or installed file that does not verify makes Paint unavailable; an installed binary that stops verifying is treated as tampering, its browsers are stopped and the install is deleted. After three failed verifications AGL stops retrying; Retry Paint in Settings resumes once the cause is fixed. Video keeps working throughout, on the independent system Chrome.
-
Capture unavailable. A Paint browser that cannot capture a page keeps working for Video.
Paint plays the page's sound through Enable sound in the view's toolbar, which the browser requires to be a click; sound needs the Paint browser. Video is unchanged.
Paint, sound and DevTools use their own pairs of authenticated streams beside the view's, bound like DevTools to the exact view, stream generation, lease authority and connection. A video playing in the page reaches a Paint view as the original video: for each one the controller asks the view for a further stream pair to its Paint renderer, and the view answers with a request whose response carries the pair. A navigation, resize or other rebind of the Paint source ends that pair on purpose and the next one opens to the same renderer; a pair that breaks closes only its own decoders. A page that Paint cannot capture for a while ends Paint with SmartBrowser's typed unsupported verdict, and the view switches to Video and says so. The renderer and decoder files are served from the controller's origin at /browser-paint/ and /browser-audio/, content-addressed and verified; the page's content security policy allows WebAssembly compilation ('wasm-unsafe-eval') and nothing else beyond the origin. Settings → General links the notices of every browser component, /browser-notices/NOTICES.txt, including the codec patent disclosure and the source offers of Paint Chromium; see third-party-notices.md.
DevTools and diagnostics
The DevTools button toggles the official Chrome DevTools frontend inside the browser pane, with Elements, Console, Network, and Sources, and stays pressed while the inspector is open. The panel icon beside it — Show browser diagnostics, which becomes Hide browser diagnostics while the panel is open — toggles a panel on the right of the browser pane whose Screencast section carries the full diagnostics and stays open until you close it, on this browser. It leads with what the view renders with, each stated as requested, in use and why: Host GPU (the compositing Chrome reports for this browser with the AGL GPU setting, or that a changed setting applies after an AGL restart, or that Chrome found no usable GPU), Presentation (Paint or Video, who asked for it — this device, the controller default or the view's own toggle — and, when Video replaced Paint, why), Paint rendering (this device's choice and the mode the Paint renderer reports, or that a changed choice applies from the next Paint run) and, when the renderer names it, the WebGL renderer (a hardware GPU, or SwiftShader for Chromium's own software GL; shown only). The screencast readings follow: codec, encoder, frame rate, bitrate, dropped frames, decode, frame age, capture-to-display, jitter buffer, round trip, input reply and queue, commands in flight, GPU compositing and video encoding. Show stats on video at the end of that section enables the one-line video overlay for this page lifetime; it is off by default and does not stop metrics collection or the sidebar readings. A reading the renderer has not reported is left out rather than shown as a zero. Website JavaScript errors appear as a compact Website error notice with a Console action; browser and transport failures retain their own diagnostics. The inspector is scoped to the selected tab and the authenticated human view. Closing or replacing that view, changing tabs, revoking authority, or recovering its transport closes the inspector. Document navigation and obsolete input preserve the inspector. Reopen DevTools after a signaling transport recovery.
Website alerts, confirmations, prompts, and before-unload dialogs appear in an origin-labelled modal. Prompt text preserves whitespace and empty responses. Only the exact pending dialog can be answered; switching views invalidates its UI. Held keys and buttons are retained while a dialog blocks input and released when it closes.
Inspection uses a separate pair of bounded authenticated streams, allowing debugger Resume commands while another CDP command is waiting. Small CDP events are batched into ordered writes of at most one MiB, with up to eight writes awaiting acknowledgement and one pending batch. Partial batches flush on the next event-loop turn. The native source charges retained payload and entry overhead against a byte budget, so large replays of small events do not hit a fixed event-count cutoff. Inspector failures retain their original reason and distinguish oversized messages from an exhausted receiver backlog. CDP access does not expose a public debugger socket, browser-wide targets, the private capture page, or another viewer's session. DevTools shows each browser's own frontend, so it inspects a browser of any version: SmartBrowser reads the frontend's files from the running browser over its private CDP pipe, pins each file's SHA-256 for that launch, and the controller serves them at /browser-devtools/ under a random key per browser launch; no remote DevTools CDN and no bundled frontend are involved. The key is handed out only with an authenticated DevTools open, and the route answers 404 once that browser closes. The DevTools UI is English, as Chrome bundles only its en-US strings. A browser whose frontend fails its launch check keeps Video; its view states that DevTools is unavailable because that browser's DevTools frontend failed its checks, the DevTools button is disabled and the Website error offers no Console action. A file whose bytes change after they were pinned closes the open inspector and makes DevTools unavailable for that browser in the same way. An open the controller refuses fails with the typed devtools_unavailable error, whose data carry the reason (frontend-unavailable or disabled) and, when known, the browser version.
Every signaling pair is bound to the exact peer, credential, resource authority, view, and stream generation. BrowserRuntime owns each viewer's media peer; the client cannot supply another viewer's peer identifier. Offers and answers accept one video media section, with bounded payloads and negotiation identifiers. Ordinary websites retain the runtime's network confinement; the private capture extension owns media transport.
Chrome captures the active tab directly and uses native video encoding, decoding, congestion control, and pacing. GPU compositing and encoding are requested automatically where supported; the actual capabilities and encoder are reported rather than assumed. The detail quality policy preserves text resolution and lets Chromium adapt frame rate under pressure. Capture is capped at 2560 by 1600 pixels, 60 frames per second, and 16 Mbps; a second application downscaling loop is no longer used. The higher frame cadence reduces the wait between input and its visible result; congestion control and CPU adaptation still apply. The receiver requests a minimal jitter buffer where supported, and the browser applies its own minimum. Hidden viewers close their media peer and reconnect when visible; the final viewer leaving stops native capture. Human video viewers do not enable JPEG screencasting. Agent screenshots remain available independently.
While a view stays open, the controller renews its BrowserRuntime lease before expiry after rechecking the stored resource, authenticated viewer, and runtime authority. Renewal preserves the media connection and browser incarnation. Closing the view cancels renewal, closes the viewer's media peer while signaling is available, then revokes the lease and closes the transport. A stalled renderer has a bounded cleanup deadline; lease revocation still owns final media cleanup.
Select Settings → General → Browser video → NVIDIA GPU (native) to use the Rust capture, NVENC encoder, and direct WebRTC sender. This backend requires Linux, Xorg, NVIDIA graphics libraries, and an NVENC-capable GPU. It does not use GStreamer or a VM. The native backend uses physical pixels, preserves source resolution, and caps capture at 30 fps. The viewer must negotiate a codec level that supports the requested dimensions; unsupported receiver limits produce an explicit negotiation error. GPU compositing and encoder statistics report the capabilities actually in use.
The setting is persisted by the controller and takes effect after restart. Local operators can use the owner-authenticated control endpoint through the CLI:
agl settings --port 4097 --browser-video-backend native --json
agl stop --port 4097
agl start --port 4097
agl settings --port 4097 --json
The result distinguishes the selected backend from the active backend and reports
restartRequired. Use --browser-video-backend chromium to select Chromium capture.
Settings → General → Browser video → Browser GPU chooses whether browsers use the
host's GPU. Off launches every browser, Paint Chromium and the system Chrome fallback
alike, with software compositing only (SmartBrowser's video.gpu: 'disabled', Chrome's
--disable-gpu), so Video also encodes in software; Paint works in both. On, the
default, uses the GPU where Chrome can. The setting applies after an AGL restart, like the
backend, and Off cannot be combined with NVIDIA GPU (native), which encodes on the GPU:
saving that pair is refused with invalid_settings. Settings shows the active value, and
the browser diagnostics state the compositing Chrome actually reports:
agl settings --port 4097 --browser-gpu off --json
agl restart --port 4097
restartRequired is true while either the backend or the GPU setting differs from the
one the running browsers were launched with.
Native presentation fences and aligned-frame cropping travel through browser protocol
v29. DOM pointer timestamps survive the transport so short drag gestures retain their
occurrence timing.
State and operation results retain reliable FIFO delivery with bounded JSON packets and queue sizes. Four ordered event writes can await acknowledgement, refilling as replies complete. A full event queue reserves one close announcement, which waits for earlier writes before transport teardown. Operation IDs, pending bytes, cancellation, event delivery, and recovery attempts are bounded. Adjacent compatible pointer-move or viewport operations may be coalesced. Video does not occupy this reliable control queue.
The browser installs its renderer before activating a view, Paint and sound included, so the controller opens Paint and audio for a view that is opening or recovering as well as an active one, and delivers a Paint stream's termination and video route requests before the view activates. A recoverable signaling failure suspends input, retires the old stream pair, starts the next generation, and resumes renderer listeners before activating the replacement. Document navigation keeps the native video peer, while input waits for current document state and a subsequent presentation. A failed video connection offers Reconnect video without closing DevTools or replacing the authenticated control view. Stale activation, negotiation answers, cancellation, and close messages cannot act on another generation.
Agents and multiple human viewers share one browser incarnation. BrowserRuntime owns execution scheduling; the controller submits commands in order and retains bounded cancellation and byte ownership until they settle. Four wheel operations can be in flight while mouse/key and semantic operations preserve ordering barriers. One statistics observation runs independently, so delayed telemetry cannot block input, navigation, or media shutdown. Dialog replies have an authorized interrupt slot so a paused page operation cannot block its own reply. The effective viewport takes the smallest width, height, and device scale requested by connected human viewers, and grows when a smaller viewer leaves. Viewport acknowledgements include the accepted dimensions and revision. A conversation leaving the attachment set revokes that conversation's agent authority while preserving every conversation that stayed attached and all human viewers; while such a rebind settles the runtime reports the resource as busy and the controller retries the action rather than failing it. Independent MCP actions receive temporary capability bindings for the exact project, resource, attachment revision, and acting conversation — the calling chat's own task, proven by its caller credential; cancellation and shutdown retain capability cleanup ownership. Trusted Flex channels additionally require the active run and generation.
When attached to a Flex conversation, the resource provides permission-gated navigate, snapshot, screenshot, click, fill, and press actions to that exact run. Frames for closed Flex channels or an exhausted frame backlog are dropped without stopping the Flex child or affecting other tasks.
Resource stop and retirement announce view_closed before ending human streams. A resource authority replacement announces resource_changed; an unrecoverable view failure announces view_failed. Close announcements precede stream teardown. Authority replacement can reopen a selected active resource, bounded at three attempts within 30 seconds. Transport recovery allows six attempts within 45 seconds. Human input, statistics, and transport chunks check the exact live peer, credential, lease, resource, and generation without database reads. View activation, renewal, viewport changes, and semantic commands retain durable checks; mutation authorization runs after Runtime reserves execution order. Delivery failures retain the original reason, stream generation, and queue pressure in logs.
Wheel input is accumulated once per animation frame, with up to four input commands in flight. Each completed wheel command frees its slot without waiting for the whole batch. Coalescable input is merged or dropped under pressure so sustained scrolling does not exhaust the input queue. The viewer shows presented frames per second, bitrate, dropped frames, estimated capture-to-display latency when available, jitter-buffer delay, network RTT, and input latency. Receiver sampling runs independently of remote statistics requests; stale frame-rate samples show as unavailable rather than zero. The Sidebar panel's Screencast section reports decode time, the age of the latest captured frame, codec, encoder, and host GPU capabilities. Resizing preserves the video connection and fits both capture dimensions with one scale, preventing encoded letterboxing after changes to pane size or display scale. A pane without a view distinguishes loading, a closed view, and an unavailable runtime.
Workspaces
A workspace resource serves one project's working tree to the browser: its files, an editor and the changes on disk. Workspaces run on Linux hosts only, because their confinement rests on Linux's /proc/self/fd; on any other host a workspace is neither created nor served, and every request is refused as platform_unsupported. It owns no process, so on Linux it is available whenever the controller is, and it attaches to a conversation — or to a Claude chat's terminal — like a browser does. Its base directory is where its tree starts: the project directory or any directory inside it, chosen when the workspace is created and switched by the human with controller.workspace.base.set, which names the base revision it was based on so two viewers switching at once cannot overwrite each other unnoticed. A chat never moves the base directory; workspace_reveal shows a path inside it and is refused outside it.
The project directory is the only root, whatever the base directory is: every path is project-relative on the wire and resolved on the controller, links included, and a path or link that leads outside the project is refused. Nothing is then done by path. The directory an operation works in is opened without following a link and trusted only once the kernel's own name for the descriptor is the path that was checked, and every entry inside it — a file read or written, a file or directory created, removed, moved or copied — is addressed through that descriptor; a recursive removal or copy walks the tree descriptor by descriptor and never follows a link. A directory swapped for a link at any moment therefore cannot redirect an operation out of the project; watches are held the same way, so they report names of the project's tree only. A directory is never moved or copied into itself (path_within_source), and a removal, move or copy walks at most 128 levels deep (too_deep) and 50,000 entries (too_large).
Two limits remain, both requiring a process that can already write where it acts. A rename whose target a concurrent process creates between the existence check and the rename replaces that target, since there is no rename that refuses to replace. And a process with write access both inside and outside the project can move a directory the controller has already verified out of the project; the operation then still lands in that directory, through its descriptor.
- A secret-shaped file —
.envand its variants,.npmrc,.envrc, private keys and the credential stores AGL's secret-file policy names — is listed, never read, written, created, moved or copied, so no rename turns it into a file that may be read. It can be removed. The policy judges names, matched without regard to case: a hard link to a secret under an ordinary name is readable under that name. Reads cannot refuse every file with a second name, since package managers such as pnpm hard-link every file innode_modules. - Nothing inside
.gitis changed —.GITincluded — and a tree holding a nested repository is neither removed nor copied as a whole. - The base directory and every directory holding it are never moved or removed.
- A write replaces a file only while it still holds the content the write was based on (
changed_on_diskotherwise) and creates one only where nothing is. Every change of one project — writes, new directories, removals, moves, copies and base-directory switches — runs one at a time, so a removal always sees the base directory it has to protect. A file with a second hard link is never written, since the write would change it under its other names too. - Only UTF-8 text up to 4 MiB is read or written; a byte-order mark survives the round trip.
Every refusal carries its own code (path_outside_project, secret_file, git_protected, base_protected, changed_on_disk, …) instead of a generic failure. Changes on disk are audited; reads and watches are not. A browser watches a file or a directory — recursively without .git, node_modules and .nogit — and receives coalesced controller.workspace.changed pushes on its own connection only; a watch ends with its unwatch, its connection, its workspace's retirement or the controller's stop, also while it is still starting (watch_ended). A connection holds at most 64 watches, one watch covers at most 2,048 directories and all watches together 16,384; beyond a bound a watch is refused as watch_limit, and a burst beyond 256 changes arrives as one push that says overflow.
In the browser a workspace opens in place of the chat, as a terminal or a browser does: the file tree from its base directory and the catalog's editor, which reads and saves through the controller and offers no terminal, since a workspace runs no process. New → Workspace creates one at the project directory, and a conversation's context menu offers New attached Workspace. Open files and expanded directories follow the changes on disk through the pane's watches, and a save names the content it replaces, so a file changed on disk since it was opened is never overwritten unseen.
- Leaving a workspace whose editor holds unsaved edits — for a conversation, another resource, a new conversation or another project — asks first: Keep editing stays in the workspace, Discard edits leaves them behind.
- Base directory… browses the project's directories through the workspace's own confined listing and switches to the chosen one. With unsaved edits in the editor the switch asks first, because it leaves them behind. When another view moves the base while this editor holds unsaved edits, the editor stays where it is and says where the base went until the human follows it; a clean editor follows at once.
- A chat's
workspace_revealshows its path in the workspace on screen — a file opens in the editor, a directory is expanded and selected in the tree. A workspace that is not on screen stays where it is. - Send to conversation puts the file or directory selected in the tree into an attached conversation's composer — as long as that conversation is still attached when the block is appended — as project-relative paths under one line naming the project directory. Nothing is submitted: the human sends the prompt. The block is appended by the browser's own draft writer, so it survives text typed but not yet saved, and a concurrent write to the draft is kept beside it. With several conversations attached the open one is offered first; the conversation inside a Claude chat's terminal has no composer and is never offered.
- The editor, Monaco, is served from AGL's own origin at
/monaco-editor/min/vs, from exactly themonaco-editorbuild@design.estate/dees-catalogpins, and never from a CDN. Monaco starts its language workers fromblob:bootstrap scripts that load the worker code from that same origin, so the page's content security policy allowsworker-src 'self' blob:. Scripts still load from AGL's origin only, and nothing needsunsafe-eval. The build is sent withCache-Control: no-cacheand an ETag, so a browser keeps it and revalidates it on each load instead of downloading it again. Only files insidemin/vsare served; a path that names.., in any encoding, is refused. - Losing the connection to the controller closes the workspace pane with the rest of the workspace. It is the one way off the pane that cannot ask first: edits not yet saved are lost, and the pane opens again from disk once the connection is back.
Authentication
AGL creates exactly one passkey credential per controller database. Enrollment is first-wins and there is no weaker online passkey-reset path.
The one-time setup code expires after 30 minutes. Omitting --setup-code uses a random 32-byte code. A custom setup code is visible in shell history and weak codes are recoverable by anyone who can read the database, so prefer the generated value.
For temporary or scripted browser access, mint a CLI password:
agl temp-password --ttl-hours 2
Temporary passwords expire after at most 24 hours. Only their SHA-256 hashes are stored, and at most eight can be active.
The local OS user is trusted: temp-password opens the controller's auth store directly, attaching to its running embedded database engine when present. agl config reuses that access to authorize as owner when AGL_CONFIG_OWNER_TOKEN is absent, without a passkey or browser login. Local configuration requests carry OS user and process attribution in the audit trail and configuration history. Same-user processes are not a security boundary; agents running as that user have this same access. A supplied owner token keeps its existing validation and is never replaced by local authorization on failure.
Authentication belongs to one physical TypedSocket connection. Resume tokens are single-use, rotate on resume, remain only in browser session storage, and are invalidated by controller restart.
A login starts one sign-in of the browser. It lasts 12 hours from the login, or until the temporary password it used expires if that comes first. Resuming never extends it. When it ends, the controller signs out every connection of it at once and closes them with code 4402; the workspace then says the sign-in expired.
The tabs of one browser share the sign-in without sharing its resume token, which stays in each tab's own session storage. They talk over a BroadcastChannel that only pages of the same origin hear:
- Signing in. When one tab signs in, a tab waiting at the sign-in screen asks for a handoff. One signed-in tab answers: it asks the controller for a single-use resume token of its sign-in (
controller.auth.handoff), and the waiting tab redeems it throughcontroller.auth.resume. The token must be redeemed within 30 seconds and carries the same sign-in and expiry. A tab opened later beside a signed-in tab joins the same way. When the answering tab cannot get a token, it says so and the waiting tab stays at the sign-in screen right away; when no tab answers within 5 seconds, the waiting tab stays there too. - Signing out. Sign out in the header (
controller.auth.signout) revokes every resume token of the sign-in and closes every connection of it with code 4401. The other tabs show that the browser signed out, and they do not rejoin until someone signs in again. - Layout. Each tab follows the sidebar width and whether the browser sidebar is shown; the theme already follows through local storage.
- Upgrades. When the controller is upgraded, one tab reloads into the new bundle and reports the outcome. The others follow it and do not race it to the reload.
The project and session in view, the conversation sidebar, and the upgrade-reload fence stay with each tab.
CLI
agl start [options]
agl status [--port 4097] [--json]
agl settings [--port 4097] [--browser-video-backend chromium|native]
[--browser-gpu on|off] [--browser-presentation paint|video]
[--add-standard-dir <path>] [--remove-standard-dir <path>] [--json]
agl stop [--port 4097]
agl restart [--port 4097]
agl service install [startup options] | status [--json] | uninstall | protect
agl upgrade [--port 4097] [--registry <url>] [--grace-period-seconds 300] [--continue-sessions] [--json]
agl mcp [--port 4097]
agl ticket list|get|create|update|move|comment|link|assign [options] [--json]
agl feed list [--state failed] [--json] | acknowledge <row-id> --updated-at <ms> [--json]
agl foreground [options]
agl temp-password [--port 4097] [--ttl-hours 24]
agl help
agl --version
Important startup options:
--port <port> Controller port; default 4097
--opencode-port <port> Private OpenCode port; default 4098
--directory <path> Register one existing project on this start
--projects-root <path> Base for relative project paths and suggestions
--public-origin <origin> Exact browser WebAuthn origin
--rp-id <hostname> WebAuthn relying-party ID
--behind-tls-proxy Declare trusted TLS termination upstream
--setup-code <code> Supply an operator-chosen initial setup code
settings prints the browser capture backend and the standard project directories; --json includes the complete public settings, including a selected Session Intelligence model and Claude terminal routing. A plain read never updates settings. --add-standard-dir and --remove-standard-dir edit that list, which is the same list Settings -> Projects edits in the browser. Directories must be absolute and must exist on the controller's disk.
Runtime configuration is durable and immutable per controller port. Later management commands must use the same --port. The --directory option is transient and can register another project during a later stopped start.
feed reads the sidebar's session feed and acknowledges failed rows as the local owner, as Acknowledge and remove does:
agl feed list --state failed # state, row id, updatedAt (Unix ms), name, tickets per line
agl feed list --state failed --json
agl feed acknowledge codex:01a1504e-1f31-7123-a67a-3d4bd59be124 --updated-at 1760812661922 --json
--updated-at is the value list printed for that row: a row that moved since is refused, and acknowledging the same row and value again changes nothing.
stop verifies the exact package CLI, process, process group, command, port, and process-start fingerprint before signaling anything. AGL never kills processes by name.
restart stops the controller and starts it again on the same port. It takes no startup options: the controller comes back with the runtime configuration stored for its port (public origin, RP ID, projects root, OpenCode port and TLS mode), and with the environment of the shell that runs restart when it runs detached (the unit's own when it runs as agl-controller.service). It checks that the start would succeed before it stops anything: the start must be admitted, and a controller behind a TLS proxy (tlsMode: proxy in agl status --json) is not stopped while AGL_TRUSTED_PROXY_SECRET is missing or malformed in this shell, or while agl-controller.service is installed for its port. The secret is only checked, never shown. Sessions are not paused or continued; upgrade is the command that carries them across a restart.
Running AGL as a systemd user unit
On Linux, the pnpm-global installation can run the controller as the systemd user unit agl-controller.service. This is opt-in:
agl service install --port 4097 # write and enable the unit; nothing is started or stopped
agl restart --port 4097 # move a running detached controller into the unit
service install takes the persistent startup options (--opencode-port, --projects-root, --public-origin, --rp-id) and writes them into the unit's command line: the pnpm-global agl shim, verified to run this release, with __serve --service --port <port>. Because pnpm rewrites the shim for every release it installs, the unit runs whichever AGL is installed and survives upgrades. The unit runs from the home directory, after authswitch-authority.service, and is wanted by default.target, so the user manager starts AGL at login, or at boot when lingering is enabled for the user (loginctl enable-linger). Running it again with the same settings changes nothing, even while the unit runs. A changed definition is written only while the unit is stopped, so changing the settings is agl stop, then agl service install with the new settings, then agl start. It needs a systemd user manager of version 254 or later.
The unit runs only a controller on localhost. A controller behind a TLS proxy needs AGL_TRUSTED_PROXY_SECRET, which the unit cannot carry, so service install refuses --behind-tls-proxy, an HTTPS --public-origin, and a port whose stored runtime configuration is in TLS-proxy mode (it reads that configuration under start admission, from the controller's store); such a controller keeps running detached. A start that asks the installed unit for that mode, or comes from a shell holding AGL_TRUSTED_PROXY_SECRET, is refused the same way, and a unit-run controller that finds its stored configuration in that mode stands down with exit status 78 instead of being restarted.
From then on, start, stop, restart and upgrade drive the unit for the AGL home and port it was installed for:
startlistens on a private start socket (controller-start-<port>.sock, mode0600, in the AGL home's sockets directory), starts the unit, verifies that the process that connects is the unit's main process running this AGL'scli.jsfor that port, and hands it the setup code,--directoryand, during an upgrade, the upgrade token over that socket, never on a command line. A start with startup options or environment the unit does not carry is refused, naming them, instead of running with other settings: start it with the settings it has, or change them withagl stop,agl service installandagl start. A start for another AGL home or port, and every start from a development checkout (including the staged packages of the end-to-end suite), runs detached as before.stopstops a controller that is the unit's main process through the service manager, so it is not restarted: the stop job's SIGTERM, a verified SIGKILL to the controller's process group after 30 seconds, then the end of the stop job. The unit kills only the controller process (KillMode=process), so jobs and an upgrade worker it started outlive it, as they outlive a detached controller.- After a failure the unit restarts the controller after 5 seconds. Such a start, like one at login or boot, takes its own start admission: while an upgrade holds its lock it exits with status 78, which the unit does not restart, and the upgrade starts the controller when it finishes. A start the CLI asked for that fails exits with 78 as well; the CLI reports why.
agl status shows agl-controller.service in place of detached for a controller the unit runs. agl service status [--json] reports the unit's state, its port and AGL home, and its unit and log files. agl service uninstall disables the unit, stops the controller it runs, waits for the unit to stop and removes exactly the definition AGL installed; it refuses while processes it started, such as jobs, still run in the unit's control group. Start AGL detached again with agl start.
The unit carries PATH and AGL_HOME, and, when set, AGL_DB_DIR, AGL_MONGO_DB, AGL_MONGO_URL, AGL_CODEX_EXECUTABLE, AGL_CLAUDE_EXECUTABLE, CODEX_HOME, CLAUDE_CONFIG_DIR, XDG_CONFIG_HOME, XDG_DATA_HOME, XDG_STATE_HOME, XDG_CACHE_HOME, NODE_EXTRA_CA_CERTS, SSL_CERT_FILE, SSL_CERT_DIR, TZ, HTTP_PROXY, HTTPS_PROXY and NO_PROXY; nothing else from the installing shell reaches it. The unit file is readable by every local user, so it never holds a secret: service install refuses while AGL_TRUSTED_PROXY_SECRET is set, while AGL_MONGO_URL, HTTP_PROXY or HTTPS_PROXY carries credentials, and while AGL_FORWARD_ENV names variables the fixed set would drop. Such a controller keeps running detached.
The unit appends the controller's errors to the same $AGL_HOME/logs/controller-<port>.log a detached controller writes; standard output is discarded, as it is for a detached controller.
Protecting the services from the OOM killer
A systemd user manager runs at the OOM score its system unit gives it (OOMScoreAdjust=100 for a stock user@.service) and gives each unit it starts that score plus 100, so without protection agl-controller.service, agl-proxy.service and authswitch-authority.service are among the first processes the kernel kills when the host runs out of memory. Each of these units asks for OOMScoreAdjust=-500, but an unprivileged user manager cannot grant a score below its own: the unit starts anyway, at the manager's score. Run this once per install, as the user who owns the units and not with sudo:
agl service protect
It refuses to run as root. It lists the commands it runs as root and runs them through one sudo (one password prompt; without a terminal it uses only credentials sudo already holds): it writes /etc/systemd/system/user@<uid>.service.d/agl-oom.conf ([Service] / OOMScoreAdjust=-500, mode 0644) and reloads the system manager, so the user manager starts at -500 from the next login or boot, and it writes -500 to the running user manager's /proc/<pid>/oom_score_adj. The kernel lowers a process's floor together with its score only for a writer holding CAP_SYS_RESOURCE, and every process the manager starts afterwards inherits that floor, so the units can take -500 without a new login. Without sudo, or when sudo fails, it changes nothing and prints the same commands to run as root. It then restarts authswitch-authority.service and agl-proxy.service where they run, rewriting a unit written by an earlier release (stop, install from this shell's environment, start), and prints the commands that finish agl-controller.service (agl restart --port <port>, or agl stop, agl service install and agl start for a unit an earlier release wrote), because restarting the controller ends its sessions. Running it again changes nothing and says so.
agl status lists the OOM score of each installed unit's running process and says not protected — run `agl service protect` while one is above 0; agl status --json carries the same as serviceOomProtection, and authswitch authority doctor reports the authority daemon's own score.
Upgrades
Use the active global installation:
agl upgrade --port 4097
By default, upgrade package commands use pnpm's effective registry configuration. Override it for one durable upgrade transaction when needed:
agl upgrade --port 4097 --registry=https://registry.npmjs.org
Registry URLs must use HTTPS, except that HTTP is accepted for localhost, 127.0.0.1, and [::1]. They cannot contain credentials, query parameters, or fragments. Keep authentication in pnpm configuration rather than command arguments.
The upgrade worker:
- Resolves the registry's
latestversion and never downgrades. - Stops the exact active-session cohort before stopping a running controller (see below).
- Waits for admitted operations and data writers to drain.
- Verifies that the stopped process has released its kernel resources before replacing packages; disappearing command-line arguments do not count as process exit.
- Installs while respecting pnpm's configured dependency build policy; AGL uses prebuilt terminal binaries.
- Restores and restarts the exact source version if installation fails before target startup and, for package transitions, before the commit begins.
- If the controller was running, restarts the exact target and reopens prompt admission.
- Accepts a target that speaks a newer controller protocol: it may run harnesses the upgrading release does not know yet, as long as it reports every harness that release knows, and add keys and values to its harness entries and its
proxyandupgradestatus, of which the upgrade leaves out what it cannot read. The target's package, version, process and service fields are checked exactly. - Stops and starts a controller that runs as
agl-controller.servicethrough its unit, so the service manager never restarts the source behind the upgrade's back: a restart it attempts while the upgrade holds its lock stands down with exit status 78. The unit itself is not rewritten; when a release changes it, runagl stop,agl service installandagl start. - Restarts
agl-proxy.serviceonto the new release once the target controller is up, when the unit is installed and running and runs the pnpm-globalaglshim, and waits until the proxy answers as the new release and its credential launcher, run with an empty environment as a harness runs it, runs the new release too. A unit that is not installed or not running is left alone. A restart that fails does not fail the upgrade: it is logged as a warning and appended to the completion message (messagein--jsonoutput). - Moves the accounts service onto the authswitch the new AGL brings, with authswitch's own
authority service stop,installandstart: the service is stopped before the package changes and started again, on the new release, when it ran before. A release that declares noauthswitchcommand is refused before anything is stopped. A host without the service's unit keeps it that way, and the service's store is never touched. Stopping the service also stops the managed Codex runtimes it runs; they start again when next asked for. The log names what was stopped and started; a failed upgrade restores the previous AGL first, then the running service, and each step is recorded in the upgrade transaction, so recovering a lost worker finishes or undoes it. - Preserves its transaction across worker or controller failure.
The cohort is every conversation with an AGL prompt that is queued or still running when the upgrade starts. AGL seals prompt admission, so no new message is accepted and no queue starts a turn, and stops each of them, as Stop does:
- The running turn ends: a Flex prompt is cancelled, a Codex turn AGL started is interrupted, a Claude Code turn is aborted and an OpenCode session is aborted. What the turn produced so far stays in the conversation.
- Messages still waiting in the conversation's queue, and Flex steers the turn had not taken in, are held as stopped. They are not lost: they stay visible and join the conversation's next turn ahead of its new message. A message whose dispatch outcome was already unknown is held as uncertain and never sent again.
- Prompts a Flex usage-limit hold keeps for its reset stay with that hold, which restores them after the restart.
Preparation completes once every stopped turn has ended, that is, once its harness reported the end; a cancel that was merely accepted is not enough. The upgrade waits for that up to the grace period (--grace-period-seconds, 300 by default). If a turn has not ended by then, the upgrade fails before anything is stopped or installed: it names each conversation still busy with its operations, reopens prompt admission at once and fences no conversation. The stopped conversations stay stopped; their held messages wait for their next turn. A failed preparation clears the controller's upgrade phase and releases global prompt admission even if cleanup or transaction storage fails. The watchdog retires proven missing or invalid transaction metadata, or recovery failures after confirming the worker is no longer live. Worker inspection, termination and I/O errors keep admission sealed and ownership retained for retry while worker liveness is uncertain. Retention keeps transactions while their recorded source or target controller or worker is alive.
Use --continue-sessions to send the message "harness has been updated, please continue" to each conversation the upgrade stopped, once, after the restart. It is an ordinary message: it waits in the conversation's queue like any other, and the messages the stop held run first. Without it, AGL reopens admission without sending anything; held messages join the conversation's next turn.
While stopped turns are ending, progress output and the private upgrade log list each blocker with its harness and native session ID, project, fleet worker name when bound, the operation and its state, last observed native turn state and ID when available, and reason, including why stopping failed. agl status --json exposes the same records in upgrade.blockers; agl upgrade --json includes blockers alongside the session counts (sessions.stop and sessions.continue). A native state AGL cannot observe is unknown, rather than inferred from the operation state. Completed work leaves the list; a failure retains the blockers observed before compensation. Failed blocker diagnostics remain visible in status for ten minutes independently of the active upgrade phase; an admitted new upgrade replaces the snapshot.
An upgrade prepared by AGL 39.5.x or older paused its cohort with a control prompt instead. The release that ends this is installed by such an AGL, which still pauses; install it while the Driver is idle, or stop the controller first (agl stop, agl upgrade, agl start). The new controller reads that older transaction and continues its paused conversations with --continue-sessions.
If a forward-only upgrade worker is lost after target startup or package commit begins, a later fixed AGL installation can adopt exactly one stalled transaction during an explicit agl upgrade. Adoption requires a free port, no live worker, controller, Flex child, temporary-password writer, or token-bound metadata, and a compatible installed version. It preserves the original preparation timeline and stopped cohort. Do not delete upgrade files, copy transactions between roots, downgrade the package, or bypass recovery with agl start.
Upgrade coordination lives under $AGL_HOME/upgrade. Private logs live under $AGL_HOME/logs.
A failure reference from the Errors dialog appears in those logs on the line the controller wrote when the failure happened, together with the operation and the cause. The journal the dialog reads it from is in memory only, so it is gone after a controller restart while the log line remains.
Migrating From hcon
Starting from @modelprofile.com/harness-controller@20.0.2, run the active command twice:
hcon upgrade --port 4097
hcon upgrade --port 4097
The first invocation installs the same-package bridge. The second performs the exact package transition to AGL. Wait for each command to finish and use the controller's configured port. This transition lands on AGL 21.0.0. Afterward, use agl and follow the exact migration-bridge sequence below before upgrading to the current release.
The historical hcon transition uses pnpm's configured registry and does not support --registry. Configure pnpm before starting that migration when a specific registry is required.
Upgrading A Legacy Database
AGL 34 removes the SmartDB engine and the historical home converters. Existing NoSQLDB homes upgrade normally. Homes from 32.x or earlier must first complete migration with AGL 33.0.1, which is the final migration bridge, before installing 34. Do not skip that bridge: 34 refuses an unconverted home or an incomplete migration journal instead of creating an empty replacement database.
agl upgrade resolves latest; it has no version-selection flag. For an installation with no unfinished upgrade transaction, use this exact bridge sequence. Keep the controller's existing port and home, let running work settle first, and stop if any command fails:
- Run
agl stop --port 4097from the active installation. - If the authswitch authority service exists, record whether it is running, then run
authswitch authority service stop. Before package replacement, remove a separately installed global@modelprofile.com/authswitchwithpnpm remove --global @modelprofile.com/authswitch; AGL 32.20 and newer already bundle that command. Remove any otherauthswitchon PATH outsidepnpm bin --globalwith the installer that owns it. - Run
pnpm add --global --save-exact agl@33.0.1using the same pnpm installation and registry configuration. Do not substitute a temporarypnpm dlxrunner: lifecycle commands require the active global installation. - If the authority service existed, run
authswitch authority service installfrom the same shell; runauthswitch authority service startonly if it was previously running. The authority store stays in place. - Rename configured
HARNESS_CONTROLLER_*controller variables to their documentedAGL_*replacements, then runagl start --port 4097andagl status --port 4097. Wait for the controller to report ready; this start completes and verifies the database migration. - Run
agl upgrade --port 4097to install the current release through the normal coordinated upgrade.
If an upgrade transaction is unfinished, follow its recovery flow instead of manually replacing the package or bypassing it with start. Never delete its journals.
The bridge verifies document contents, counts and indexes before completing the conversion. Preserve the resulting NoSQLDB directory and migration records together. AGL 34 validates the recorded destination identity at startup; it does not re-read the retired database. Treat the conversion as forward-only.
AGL Home And Persistence
AGL owns one private root:
- Global installations:
$XDG_CONFIG_HOME/agl, normally~/.config/agl. - Repository checkouts:
<checkout>/.nogit/agl. - Explicit override: absolute normalized
AGL_HOMEwhose parent already exists.
Important directories include:
nosqldbfor the embedded NoSQLDB database.objectstoragefor the embedded object storage that keeps the files of accepted messages until no message names them.databasemay remain as a retained legacy backup after migration through AGL 33.0.1. AGL 34 uses onlynosqldband does not open or convert that backup. Never run an older controller against a migrated home.credentialsfor sealed Flex provider credentials.git-reversionfor private captures and managed worktrees.logsfor controller and upgrade diagnostics:controller-<port>.log(detached or unit-run controller errors, rotated to.oldpast 1 MiB when a start finds it larger) and the upgrade workers' private logs.upgradefor durable upgrade coordination.runtimefor private browser, upload, socket, OpenCode, job-output andagl mcphandover state. A repository checkout keeps its embedded database socket in the per-user runtime directory instead (/run/user/<uid>/agl-<hash>when that directory exists and is private), because a Unix socket path is limited to about 100 bytes and a checkout can live at any depth.claude-gatewayfor the Claude Code configuration of Claude terminals that run on ChatGPT models through the inference proxy.migrationfor verified migration journals and retained source records.
Do not move these directories or edit migration and upgrade journals by hand.
The NoSQLDB document models in @lossless.org/client/nosqldb store runtime configuration, passkey metadata, projects, managed-session memberships, crash-recoverable session creation and deletion obligations, layouts, model choices, scratchpads, tasks, goals, resources, Flex public projections, provider metadata, and bounded audit events. Prompt text is not written to audit records.
A Flex conversation change is published as it happens: the scope's public head names its current publication and up to three earlier ones by id, revision and hash, and each publication document lists the scope's session records and transcripts. A save reads and writes only what changed (the changed session's record and projection), so one status change costs the same with 1 or 500 conversations in a project; an evicted publication's records and transcripts are released only where no kept publication lists them. Saves prove the records they add; the controller's idle maintenance proves every published record and writes a damaged or missing one again, as does every startup. Starting this release rewrites existing public heads in place (v39_flexpublicheadpointers); an earlier AGL cannot read the rewritten heads.
Managed job logs and exit records are disposable process artifacts under runtime/jobs; the job records themselves are in NoSQLDB. Terminal I/O, active browser views and their paired transport streams, composer drafts and their pending attachment state, active capability leases, and running processes remain memory-only. Once a prompt is submitted, attachment files exist only in private operation directories for the lifetime of that exact harness operation.
Library consumers can resolve the same home contract:
import { bindAGLHomeEnvironment, resolveAGLHomePaths } from 'agl';
const paths = resolveAGLHomePaths();
const childEnvironment = bindAGLHomeEnvironment(process.env, paths);
Database Configuration
The embedded database requires no separate service. Override its directory with an absolute path. The directory must hold a NoSQLDB database or nothing yet: legacy SmartDB directories are refused.
export AGL_DB_DIR='/private/absolute/path'
To use an external MongoDB-compatible server:
export AGL_MONGO_URL='mongodb://user:password@127.0.0.1:27017'
export AGL_MONGO_DB='opencode_controller'
AGL_MONGO_DB defaults to opencode_controller.
Releases before 33.0.0 read these settings as HARNESS_CONTROLLER_*. Those names are no longer read, and start, foreground, temp-password and upgrade refuse to run while one is set, naming the AGL_* variable that replaced it.
Runtime Environment
OpenCode, FlexHarness, and terminal shells receive a minimal allowlisted environment. Controller and database variables, unrelated parent secrets, and generated OpenCode credentials are not inherited. AGL_HOME is the intentional exception: AGL forwards its canonical value so controller-owned child processes resolve the same private root. An inherited SSH_AUTH_SOCK is also forwarded so authorized Git and SSH operations can use the host's existing authentication agent. AGL does not discover agent sockets, load private keys, or start an agent; the controller must inherit a working socket from its launch environment.
Every AGL_* name is reserved for the controller: it cannot be forwarded and a job cannot set one. Delegate additional non-controller variables explicitly when required:
export AGL_FORWARD_ENV='MY_TOOL_CONFIG,MY_CA_FILE'
Flex provider credentials do not use this mechanism. They stay in the sealed provider store. OPENCODE_AUTH_CONTENT is always rejected; AGL never reads or edits OpenCode's user-wide auth.json.
Credential-free HTTP_PROXY and HTTPS_PROXY values are forwarded. Proxy URLs containing credentials are not forwarded automatically.
A controller that runs as agl-controller.service has the unit's environment instead of the starting shell's: the fixed set of variables agl service install wrote (see Running AGL as a systemd user unit) plus what the user manager provides, such as HOME and XDG_RUNTIME_DIR. AGL_FORWARD_ENV cannot be used with the unit.
Remote Browser Access
Remote WebAuthn requires HTTPS. Start AGL behind a trusted TLS reverse proxy:
export AGL_TRUSTED_PROXY_SECRET="$(node -e "console.log(require('node:crypto').randomBytes(32).toString('base64url'))")"
agl start \
--public-origin https://controller.example.com \
--rp-id controller.example.com \
--behind-tls-proxy
The proxy must:
- Remove client-supplied copies of AGL transport headers.
- Inject the configured secret as
X-Harness-Controller-Proxy-Secret. - Set
X-Forwarded-Proto: httpson WebSocket upgrades. - Preserve the exact public Host and Origin.
- Forward WebSocket upgrades.
- Restrict direct access to the backend listener.
Backend isolation is mandatory. Proxy mode binds the controller to all interfaces, while origin-less loopback CLI management remains intentionally available. Never expose the plain backend port directly.
Security Boundaries
- Local mode binds the controller to loopback only.
- The private OpenCode server always remains on loopback and uses a generated per-process password.
- Application RPC starts only after the exact TypedSocket package-major handshake.
- Client-managed connection tags are disabled; authentication and routing use server-owned peer state.
- Setup attempts are globally rate limited before enrollment.
- Permission auto-accept is an explicit server policy, never an assumption based on an open browser.
- Questions are never auto-answered.
- Unknown, malformed, concurrent, or unverifiable lifecycle state fails closed.
The global auto-accept permissions (yolo) setting replies once to pending permissions across registered projects and writes those replies to the audit log. Each conversation also has a run-local Ask/Yolo switch. For an active managed OpenCode root, that switch covers its currently active direct children under the same exact family authority. For an active managed Flex root, it covers admitted descendants through their exact active parent-run authority chain; a relationship alone grants nothing. Terminal children are never auto-accepted. Disabling either policy seals new automatic replies and drains already admitted replies before returning. Neither mode creates persistent broad provider grants.
Development
Account management lives in ts_authswitch/, with device sign-in in ts_authswitch_signin/ and regression tests in test/authswitch/. These are internal modules built and shipped with AGL, without a separate package manifest or publishing target. Browser consumers import only the credential-free contract, usage-window and presentation leaves. The authority daemon remains independent of the controller. Its storage schema and native credential ownership rules are preserved. The opt-in inference proxy below uses its own gateway bindings; the authority snapshot contract is version 3. Restart an older authority daemon onto this AGL build before using the proxy.
Install dependencies and run the project checks:
pnpm install
pnpm run build
pnpm run check:test
pnpm test
Run the CLI from source:
node cli.js help
node cli.js foreground --port 4097
The default test suite uses disposable embedded databases. Set AGL_TEST_MONGO_URL to include the external MongoDB integration test.
The rootless Docker environment backend is under integration and is not yet available in the controller. ControllerContainerEnvironment requires a durable SmartData-backed setup-readiness provider keyed by the immutable container ID: a failed or interrupted setup blocks the next start until an explicit rebuild. The backend accepts caller-owned environment identity and mounts, so account isolation can be decided by the controller without changing its lifeline or terminal execution. It uses only the rootless user daemon, disables the optional Docker image store, and opens a ready event monitor before each physical start. Monitor loss stops the current run; event timestamps and run leases fence late events and terminal cleanup from earlier runs of the same container. Rootless Docker can omit an OOM event and report OOMKilled: false for exit 137. Without positive OOM evidence, the cause is uncertain even if an earlier Docker signal was handled: the environment remains failed and requires explicit recovery. An explicit start that discovers a dead run reports that loss before restarting. Its credential-free live test uses an isolated pinned Ubuntu container:
AGL_CONTAINER_LIVE_TEST=1 pnpm exec tsrepo test test/test.containerenvironmentlive.node.ts --verbose --logfile --timeout 120
Measure browser input feedback with a local Chrome installation:
AGL_BROWSER_LATENCY=1 pnpm exec tsrepo test test/test.browserlatency.node.ts --verbose --logfile --timeout 180
The benchmark sends DOM key and wheel events through the shipped renderer, AGL browser protocol client, real TypedSocket/WebSocket transport, controller admission and dispatch, BrowserRuntime, and Chrome. The inspected page paints a binary counter into the returned native WebRTC video. The viewer recognizes the changed counter and reports input-to-expectedDisplayTime, local dispatch wait, operation reply duration, and callback lateness. A 40-event wheel burst checks the final cumulative scroll delta and reports feedback for counters actually presented. Pixel reads are confined to this benchmark; normal viewing retains native video presentation. Authority records use a test fixture, and the server binds only to loopback. These measurements estimate compositor presentation on the local host, not physical input-to-photon latency or remote-network performance.
End-to-end tests
pnpm test:e2e builds the package and runs test/e2e/e2e.*.node.ts against it the way an operator runs AGL: the output of pnpm pack is unpacked into a private temporary root and started with cli.js start as a detached controller that supervises a real OpenCode server, a real Flex child and a real codex app-server. Every home the controller and its children touch (AGL_HOME, HOME, the XDG directories, CODEX_HOME and the account service's sockets) lives under that root, $TMPDIR/agle2e-*, on free loopback ports. Three things reach outside it: the engine socket directory is /run/user/<uid>/agl-<hash of the fixture AGL_HOME>, because a development checkout keeps it in the platform user runtime directory whatever XDG_RUNTIME_DIR says (it is removed with the run); pnpm pack and git init of the staged package run with the real HOME and read the user's pnpm and Git configuration; and Chrome runs with the runner's environment. A local fake model server answers Codex and OpenCode through their own provider configuration, and ChatGPT through an in-test HTTPS proxy with a certificate made for the run; a real authswitch account service holds one fixture ChatGPT account with a made-up token. A case that runs Flex on a ChatGPT account, or Claude Code, also starts the run's AGL inference proxy (startInferenceProxy), as the installed proxy runs beside the controller: it serves that same account service, in which the case seeds one fixture Claude sign-in, reaches ChatGPT through the in-test HTTPS proxy and Anthropic through a fake of its own; a case reads the token usage it counted with inferenceUsage(). The proxy is the only way out for the controller and its children: it refuses every other outbound connection and records which process asked, and the suite fails on any but one. That one is OpenCode's own: on start its config loader installs @opencode-ai/plugin into each of its config directories from registry.npmjs.org in the background, which no OpenCode 1.18 setting turns off; refused, OpenCode logs it and carries on. Codex's curated-plugin sync from GitHub is turned off in the fixture's config.toml ([features] plugins = false). Chrome does not use the proxy, so the refusal does not cover it; it signs in to the controller on loopback with agl temp-password.
Every process of a run carries the run's token (TEST_AGL_RUN_TOKEN), Chrome included, and the root holds a marker naming the token and the test process. On SIGTERM, SIGINT or SIGHUP the suite sends those processes SIGKILL, removes the run's root and socket directory, and exits, within the 5 seconds tsrepo test waits after a file timeout's SIGTERM. A run killed outright, or one whose cleanup did not finish in time, is cleaned up by the next run's start: it ends the processes carrying the token of each agle2e-* root whose marker names a test process that is gone, then removes that root and its socket directory, and touches nothing else.
A run that fails keeps its evidence before its root goes: when a test of the file has failed by its cleanup, when the stack does not start, or when a signal ends the run, the suite copies the controller's log (agl/logs), OpenCode's (data/opencode/log), Codex's log database (codex/logs_*.sqlite) and conversation rollouts (codex/sessions), and the Flex state a restart reads -- the flex_* collections of the controller's database and the Git reversion state (agl/git-reversion) -- 4 MiB at most of each (a log keeps its tail, a larger database is left out) and 24 MiB in all, into .nogit/e2e-failures/<stack name>-<run token>/ of the checkout that runs it (the stack is named after its file, for example conversations), with fixture.json: the run's ports and package version, every request the fake model server answered and every connection the proxy refused. It prints that path. Nothing else of the root is copied, so the account service's data, the run's certificate authority, AGL's credentials and the controller's other collections stay out; the ten newest failed runs are kept.
The suite guards, end to end: opening every harness's conversation across page reloads without a harness child being replaced, recovering the Flex child after SIGKILL within 15 seconds, rejoining the shared Codex daemon on its own after that daemon restarts (the conversation opens and answers again within one rejoin backoff step, without Reconnect), starting the controller again (as the same and as the next build) with a browser resource attached to a Codex, Flex and OpenCode chat, listing a project's resources right after a browser is attached to each new chat and after a restart, the Flex model list carrying the account's current catalog, a Flex conversation answering, and opening and answering again after a restart, in a project whose id begins with - or _ (the file creates projects until one does), and an agl mcp bridge answering tool calls from the same process after the controller moves to the next build. It is the test step of gitzone release (release.preflight in .smartconfig.json) and takes about eight minutes.
Host prerequisites: Linux, Chrome (google-chrome, chromium or chromium-browser), openssl, ss (iproute2, to name the process behind a refused connection) and codex 0.156.0 or newer on PATH. Set AGL_E2E_PACKAGE_ROOT to another installed and built checkout, for example an earlier release, to run a scenario against it and prove it fails there:
AGL_E2E_PACKAGE_ROOT=/path/to/agl-32.15.1 pnpm exec tsrepo test test/e2e/e2e.conversations.node.ts --verbose --timeout 900
License and Legal Information
This repository's own source code is licensed under the MIT License. A copy of the license can be found in license.md.
The distributed browser bundle includes third-party packages, among them PDF.js 4.10.38 under the Apache License, Version 2.0. Every build writes the license and notice texts of each bundled package to dist_serve/bundle.js.third-party-notices.txt and .json, which ship with the package and are served beside the bundle. third-party-notices.md covers the assets served unbundled, the PDF.js modification notice and the upstream notices the npm packages do not ship.
Please note: The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.
Trademarks
This project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH or third parties, and are not included within the scope of the MIT license granted herein.
Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines or the guidelines of the respective third-party owners, and any usage must be approved in writing. Third-party trademarks used herein are the property of their respective owners and used only in a descriptive manner, e.g. for an implementation of an API or similar.
Company Information
Task Venture Capital GmbH
Registered at District Court Bremen HRB 35230 HB, Germany
For any legal inquiries or further information, please contact us via email at hello@task.vc.
By using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.