---
title: "Sessions and harnesses"
description: "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."
---

> Documentation Index
> Fetch the complete documentation index at: https://agenthold.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Sessions and harnesses

agenthold does not implement an agent loop. A **harness** (Claude Code, ...) runs inside each sandbox and speaks
the [Agent Client Protocol](https://agentclientprotocol.com) (ACP) on stdio. The server is the ACP client.

## How a turn works

1. A user prompts a session (operator role). If the agent sleeps, the prompt wakes it.
2. The server makes sure the agent's harness is running. sandboxd starts it as the `agent` user, 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.
3. If the harness doesn't know the session yet (new process after a sleep or restart), the server uses
   `session/load` when the harness supports it, and `session/resume` otherwise. 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.
4. `session/prompt` runs. Every `session/update` the 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](https://www.npmjs.com/package/@agentclientprotocol/claude-agent-acp) 0.107.0, which bundles the Claude Agent SDK and Claude Code) | yes |
| `opencode` | `opencode acp` (native ACP, [opencode-ai](https://www.npmjs.com/package/opencode-ai) 1.19.5) | yes |
| `codex` | `codex-acp` ([@agentclientprotocol/codex-acp](https://www.npmjs.com/package/@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 as `ANTHROPIC_API_KEY`, or `claude /login` in the agent's terminal.
- **OpenCode:** works without a login with OpenCode's free models. `opencode auth login` in the terminal, or
  provider API keys as secrets (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `OPENROUTER_API_KEY`). The model is
  `provider/model`, for example `anthropic/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.

Source: https://agenthold.dev/sessions/index.mdx
