agenthold does not implement an agent loop. A harness (Claude Code, …) runs inside each sandbox and speaks the Agent Client Protocol (ACP) on stdio. The server is the ACP client.
How a turn works
- A user prompts a session (operator role). If the agent sleeps, the prompt wakes it.
- The server makes sure the agent’s harness is running. sandboxd starts it as the
agentuser, in/home/agent, with the sandbox’s environment plus what the adapter adds. Its stdio goes over the tunnel. One harness process serves all of an agent’s sessions. - If the harness doesn’t know the session yet (new process after a sleep or restart), the server uses
session/loadwhen the harness supports it, andsession/resumeotherwise. History the harness replays while loading is not stored again. Without either capability, a new ACP session starts and the log records a notice that the context was reset. session/promptruns. Everysession/updatethe harness sends is appended to the session’s event log and streamed to everyone watching.
The client file system and terminal capabilities are not advertised. The harness uses its own tools inside its sandbox.
Event log and streaming
Each session has an append-only log of events, each with a sequence number. Event types are prompt, update
(an ACP SessionUpdate, stored as received), permission, permission_resolved, turn_end, error and
notice. The stream (GET /api/v1/sessions/{id}/stream?after=<seq>, WebSocket) sends every stored event after
the cursor and then live events, with no gap or duplicate between the two. A browser that reloads mid-turn
reconnects with the last sequence number it saw and continues. GET /sessions/{id}/events returns the same log
as JSON.
Permissions
Each agent has a permission policy:
auto-approve(default): the sandbox is the safety boundary. Requests are approved with the harness’s one-time “allow” option and logged as auto-approved.ask: connected operators see the request with the harness’s options. The turn waits until someone answers or cancels.
Signing in a harness
Account logins (the normal way)
Claude Code and Codex logins live on your account, under Provider logins (/settings/providers). Connect
each once; every agent that uses your logins then has them when its harness starts, and switching an agent between
Claude Code and Codex needs no new sign-in.
| Harness | What you connect | How the harness gets it | Refresh |
|---|---|---|---|
| Claude Code | Subscription: the token claude setup-token prints (Claude Pro/Max, valid for a year) |
CLAUDE_CODE_OAUTH_TOKEN holds a placeholder; the proxy puts the token into the Authorization header of requests to api.anthropic.com |
none needed |
| Claude Code | API key | ANTHROPIC_API_KEY placeholder, swapped in x-api-key |
none |
| Codex | ChatGPT login: the auth.json that codex login writes (run it with a temporary CODEX_HOME, the page shows the command) |
~/.codex/auth.json with placeholder tokens; the proxy puts the access token into requests to chatgpt.com/backend-api/ |
by the server, see below |
| Codex | ChatGPT access token (at-…, if your ChatGPT workspace issues them) |
CODEX_ACCESS_TOKEN placeholder, swapped for auth.openai.com (whoami only) and chatgpt.com/backend-api/ |
none |
| Codex | API key | OPENAI_API_KEY placeholder (and an auth.json naming it), swapped for api.openai.com |
none |
The real values are encrypted at rest with the master key, never returned by the API and never enter the sandbox. The proxy replaces a placeholder only inside the one header the harness sends it in, only for the hosts (and paths) above. Agents using account logins are therefore not marked “credentials inside sandbox”.
Refresh. A ChatGPT login has a short-lived access token and a refresh token that changes on every refresh
(reusing an old one fails). If every sandbox refreshed on its own, the first would log all the others out. So the
server is the only place that refreshes: it renews the access token an hour before it expires (checked every five
minutes) and stores the new refresh token. Sandboxes get an access-token placeholder shaped like a JWT that never
expires, so Codex does not refresh by itself; if it still tries (after a 401), the proxy answers its request to
auth.openai.com/oauth/token locally: the server refreshes, at most once a minute however many sandboxes ask, and
replies with the same placeholders. Connecting a ChatGPT login refreshes it once at once, which checks it and
takes it over: Codex on the computer you copied it from would have to sign in again, which is why the page
suggests a temporary CODEX_HOME. If the provider rejects the refresh token, the login is marked
connect again on the settings page and the agent’s overview.
Whose login. Each agent uses the account logins of one user, shown on its Overview: by default whoever
created it. Only you can make an agent use your logins (Use my logins), and only for agents you administer.
Everyone who can prompt that agent (operators, schedules, webhooks, other agents) then uses your login through it,
which is why the choice is explicit and visible. Agent admins and you can stop it at any time (Stop using), you
also from the settings page, which lists the agents using your logins. Your login is only delivered while you can
still see the agent, and it is detached from a workspace’s agents when you leave the workspace. An API key secret
attached to the agent for its harness (ANTHROPIC_API_KEY, OPENAI_API_KEY) takes precedence over the account
login.
Connecting, disconnecting and refresh failures are recorded in the audit log of your personal workspace; using a login (once per harness start), attaching and detaching it are recorded on the agent.
Changes take effect without a rebuild: the proxy picks them up within seconds, and the harness restarts with the new login once its running turns have finished.
API keys as secrets
API keys can also go through the egress proxy as ordinary secrets: create a secret (for example the Anthropic
preset), attach it to the agent with an environment variable (ANTHROPIC_API_KEY), and rebuild. The sandbox only
ever sees a placeholder.
Signing in inside one sandbox
The per-agent sign-in is a secondary override, under Overview → Sign in inside this sandbox instead. The server
starts the harness, reads the login methods it advertises in initialize, and runs the chosen method in the agent’s
web terminal (for Claude Code claude /login). The harness stores the resulting token on the agent’s home volume,
so it survives sleep and rebuilds. Such agents are marked “credentials inside sandbox”, because the agent can read
its own login. A connected account login takes precedence over it (for Codex, a ChatGPT login delivered from the
account replaces ~/.codex/auth.json; the previous file is kept as auth.json.agenthold-backup). Agents that were
signed in this way before account logins existed keep their sandbox login: they do not use anyone’s account logins
until an admin chooses Use my logins.
Harness adapters
An adapter (internal/harness/<name>) says how to start the harness in ACP mode, which environment it needs
(never secret values), which secret presets fit it, where it keeps logins, and how to install it in a custom
image. Optionally, it can name the terminal command for a login method the harness advertises without one
(TerminalLoginer), and list login methods the server may call on its own when the harness asks for
authentication, such as an API key provided as a secret (AutoAuthenticator). Adapters whose login can live on the
account implement AccountLoginer (what can be connected, how it is validated, and how it reaches the harness as
placeholders, files and proxy injections) and, for expiring logins, Refresher. Nothing outside
internal/harness knows about a particular harness.
| Harness | ACP entry point | In the base image |
|---|---|---|
claude-code |
claude-agent-acp (@agentclientprotocol/claude-agent-acp 0.107.0, which bundles the Claude Agent SDK and Claude Code) |
yes |
opencode |
opencode acp (native ACP, opencode-ai 1.19.5) |
yes |
codex |
codex-acp (@agentclientprotocol/codex-acp 2.6.0) |
yes |
fake |
/opt/agenthold/fakeagent, a scripted agent for tests and UI work (development only) |
shipped with sandboxd |
Signing in:
- Claude Code: an account login (subscription token from
claude setup-token, or an API key), an Anthropic API key secret asANTHROPIC_API_KEY, orclaude /loginin the agent’s terminal. - OpenCode: works without a login with OpenCode’s free models.
opencode auth loginin the terminal, or provider API keys as secrets (ANTHROPIC_API_KEY,OPENAI_API_KEY,OPENROUTER_API_KEY). The model isprovider/model, for exampleanthropic/claude-sonnet-5-5. - Codex: an account login (ChatGPT login, ChatGPT access token or API key), or an OpenAI API key secret as
OPENAI_API_KEY; the server then signs in with the key on its own. - OpenCode has no account logins: it supports many providers with its own login file, which does not fit one login per harness. Use secrets or its terminal login.
For a custom image, install the harness so that its command is on PATH for the agent user. For Claude Code:
npm install -g @agentclientprotocol/claude-agent-acp@0.107.0 (Node.js 20 or newer).
Base image
images/base (make base-image) is Debian trixie with Node.js 24, the Claude Code and Codex ACP adapters, OpenCode, git, Python 3,
build tools, ripgrep, jq and headless Chromium (CHROME_BIN, Playwright and Puppeteer variables set). The
agent user has passwordless sudo inside its own sandbox.
End-to-end checks
Against the dev stack (make dev, make base-image):
scripts/e2e/sessions.sh: the scripted harness edits files in a real sandbox, a reload mid-turn continues from the cursor, and a terminal login survives sleep.scripts/e2e/provider-logins.sh: an account login reaches a real sandbox as a placeholder only, the proxy injects the value, and disconnecting takes it away.scripts/e2e/claude-harness.sh: starts the real Claude Code harness and shows what it advertises.scripts/e2e/opencode.sh: a real conversation with OpenCode (free model) that edits a file, replays from the cursor, and survives sleep with its history.