Self-hosted environments are in public beta on Team and Enterprise plans; Availability and limitations covers the enablement path. This page is the CI test recipe; see the quickstart for setup and Deploy to production for the fleet recipes.
Install the capture hook on your test runner
The read-back works through a Claude Code Stop hook: when Claude finishes a turn, the hook receives the final assistant message aslast_assistant_message in its stdin JSON and appends it to $E2E_REPLY_DIR/<session_id>.txt. Install it the same way as the commit-nudge Stop hook, on the runner host’s ~/.claude/, which the runner seeds into every session.
Save the hook files
Save the two files below on the runner host:- The settings block: merge into
~/.claude/settings.jsonon the runner host - The script: save as
~/.claude/hooks/e2e-stop-hook-capture.shon the runner host and make it executable
Before you start the runner
Two things the hook depends on:- Install it before you start the runner. The runner snapshots
~/.claude/once at startup, so a hook added to a running runner takes effect only after a restart. - Export
E2E_REPLY_DIRto the runner process. The hook is a no-op when the variable is unset or the directory doesn’t exist, so set it wherever you start the runner, such as the systemd unit, pod spec, or CI step. The test script below requires it too.
E2E_REPLY_DIR exists, which is harmless on a throwaway CI runner but not something to carry into a production-environment runner image where the variable might be set by accident.
Run the test loop
The--environment and --ref dispatch flags require Claude Code v2.1.224 or later on the machine that runs the script, the same floor as the runner itself. With the hook in place and a runner started on this host, the test script:
- Creates a session on the test environment with
claude -p "<prompt>" --environment <environment-id> --output-format json, run from a git checkout so the CLI can auto-detect the repository from theoriginremote. The optional--ref <branch>bases the session’s checkout on a named ref instead of local HEAD. The command creates the session, prints one line of JSON containingsession_id, and exits without waiting for Claude’s reply. - Waits for the reply to appear in
$E2E_REPLY_DIR/<session_id>.txt, written by the Stop hook on the runner once the turn completes. - Sends a follow-up with
claude -p "<message>" --cloud <session_id> --output-format json(see Send a follow-up message to a running session), which posts a user event to the existing session and exits. - Waits for the follow-up’s reply the same way as step 2.
Example script
The script below runs the full loop against$CLAUDE_TEST_ENVIRONMENT_ID, your test environment’s ccpool_... ID, shown in the environment’s detail dialog on the admin page or returned by the create-environment call, and asserts on a sentinel phrase in each reply. Run it from a git checkout of the repository you want the session to work in, after starting a runner on this host with the capture hook installed and E2E_REPLY_DIR exported.
TURN1/TURN2 prompts and EXPECT1/EXPECT2 sentinels with whatever exercises your setup, such as asking Claude to run one of your custom MCP tools and asserting on its output.
Remote test runners
If your test runners are on separate infrastructure, such as a persistent Kubernetes fleet your CI job can’t share a filesystem with, swap the file write in the Stop hook for a POST to an endpoint your driver listens on:Authenticate from CI
Bothclaude -p ... --environment and claude -p ... --cloud authenticate with a claude.ai OAuth token; API keys, such as sk-ant-xxxxx, aren’t accepted for either call. Two approaches make a token available in CI.
Long-lived CI host
Runclaude auth login once interactively on the machine that executes the script, using a dedicated user account for automation. The token lives in the OS keychain on macOS, or in ~/.claude/.credentials.json on Linux and Windows. The CLI refreshes the short-lived access token automatically on each invocation, but the underlying refresh-token grant is capped at 30 days from the initial login, so re-run claude auth login interactively on that host every 30 days.
Ephemeral CI runners
There is no long-lived CI token for this today. The scope that grants remote-session control,user:sessions:claude_code, is capped server-side at 30 days, so claude setup-token, which mints a one-year inference-only token, doesn’t cover it. The environment secret isn’t accepted either, since it only authorizes a runner to register with the environment, not to create sessions.
To provision a stored login onto an ephemeral runner, set CLAUDE_CODE_OAUTH_REFRESH_TOKEN and CLAUDE_CODE_OAUTH_SCOPES so claude auth login exchanges the token without a browser; the same 30-day cap applies to the refresh grant. Contact your Anthropic account team if you need a machine-identity path that isn’t bound to a human account.
Create a dedicated test environment
Create and delete environments programmatically so each CI run gets a clean one; the runner your CI job starts registers into the fresh environment. The create and delete calls below are the same endpoints that the Cloud environments admin page on claude.ai uses, and they require theanthropic-beta: ccr-byoc-2025-07-29 header.
Mint the admin token
$ADMIN_TOKEN is a claude.ai OAuth access token for an account that holds an Owner or admin role, minted the same way as Authenticate from CI:
- Mint it: run
claude auth loginwith an account that holds an Owner or admin role, then read the current access token from the OS keychain on macOS or~/.claude/.credentials.jsonon Linux and Windows. - Read it fresh each run: the CLI rotates the access token, and the same 30-day refresh-grant cap applies, so don’t store a copy.
- Pass it via stdin: as the example does, so the token never lands in curl’s argument list or your build log.
Create the environment
Capture the response without echoing it:pool_secret is a long-lived credential that can register runners into the environment, so store it as a masked CI secret and print only the environment ID. The -H @- form that keeps the token out of the process list requires curl 7.55 or later; older curl treats @- as a literal header and sends the request without authorization.
403 permission_error reading self-hosted runners are disabled by your organization's policy.
Start a runner on this host with SELF_HOSTED_RUNNER_ENVIRONMENT_SECRET=$ENVIRONMENT_SECRET, plus the capture hook and E2E_REPLY_DIR per Install the capture hook, then run the test script.