Skip to main content
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.
In a self-hosted environment, Claude Code cloud sessions run on a runner image you build and maintain. Before rolling a new image to your production environment, drive a full session against a test environment from a script: create a session, read Claude’s reply, send a follow-up, and read that reply too. This is the shape of a CI smoke test that verifies your runner image, git access, and any custom tools before you promote a change. This recipe assumes you’ve already set up an environment and a runner, and that your CI job starts the runner process on the same host as the test script, the natural setup for testing a new runner image. A Stop hook you install on the runner writes each turn’s final reply to a local file, and the script reads it from there, so the only calls to the Anthropic API are the two dispatches themselves. If your test runners are on separate infrastructure, see Remote test runners.

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 as last_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.json on the runner host
  • The script: save as ~/.claude/hooks/e2e-stop-hook-capture.sh on 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_DIR to 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.
Install this hook only on runners serving your test environment. It writes every session’s final reply to disk whenever 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:
  1. 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 the origin remote. 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 containing session_id, and exits without waiting for Claude’s reply.
  2. 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.
  3. 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.
  4. Waits for the follow-up’s reply the same way as step 2.

--environment dispatch behavior

In a non-interactive run, with -p or a piped prompt, Claude Code creates the session, prints the session ID and a link to it, and exits. From a terminal, claude --environment <id> "task" starts an attached interactive cloud session on the environment instead. The flag takes precedence over the remote.defaultEnvironmentId setting. It doesn’t support --output-format stream-json, and can’t be combined with flags that resume, attach to, or preconfigure a session, such as --resume, --continue, --teleport, --session-id, or --init-only. --cloud is rejected with a session ID or URL, and in non-interactive runs when it carries a description. A bare --cloud is treated as absent. From a terminal, you can pass the task as the --cloud description instead of a positional prompt.

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.