The first time I tried to get an agent to do real exploratory testing against staging, I spent two days building a bridge and about forty minutes testing.

Staging sat behind a VPN. The seeded database was reachable from one subnet. The browsers I actually trusted — pinned versions, matching the CI image — lived in a container image nobody ran locally. The agent lived on my laptop, outside all of it. So I did what everyone does: port-forwards, a sanitized fixture dump, a tunnel that dropped every twenty minutes, and a slowly growing pile of "just for the agent" configuration that was, by then, its own untested system.

The whole time the thing I wanted was obvious and slightly stupid: stop dragging the environment to the agent. Put the agent in the environment.

In v2.1.224, in early August 2026, Claude Code shipped exactly that. claude self-hosted-runner turns your own machines or containers into a place where Claude Code's web, mobile, and desktop sessions execute. It's a public beta on Team and Enterprise plans. For QA this is the more interesting half of the release, and almost nobody is framing it that way, because on paper it reads like a platform-team feature.

What actually changes for testers

A cloud session is any Claude Code session that runs somewhere other than your machine: started from claude.ai, the mobile or desktop app, a scheduled routine, or your terminal with claude --cloud. By default those run on Anthropic's infrastructure — which is why they've been useless for testing anything internal.

With a self-hosted environment, the same sessions run inside your network. Three moving parts:

  • Environment — a named destination your organization creates in claude.ai admin settings. Sessions are routed to an environment, not to a specific machine.
  • Runner — a process you deploy on your hosts. Same mental model as a self-hosted CI runner.
  • Session — one Claude Code task, executed as a child process the runner spawns.

The control plane queues the session, a runner claims it, clones the repo, and starts Claude Code on your host. Every connection is outbound: the runner polls api.anthropic.com, each session holds its own event stream and inference calls there, and git goes to your git host. Anthropic never connects into your network.

For a QA team, that sentence translates into a specific list:

  • staging is reachable because the session is already on the right side of the VPN
  • the browsers are the ones you baked into the image, at the version your CI uses
  • the seeded test database, the mock payment sandbox, the internal package registry — all just reachable
  • repository checkouts, build artifacts, traces, videos, and screenshots stay on hardware you own

The conversation itself still goes to api.anthropic.com for inference. Self-hosting moves execution, not the model. Be clear about that when someone in your org hears "self-hosted" and assumes "air-gapped".

Standing one up

Version check first — on anything older than 2.1.224 the subcommand doesn't exist and you get generic help output instead of an error, which is a fun ten minutes:

claude self-hosted-runner --help

There's a guided setup that creates the environment, starts a local runner, and writes a cheat sheet to ./runner-setup/CHEAT-SHEET.md:

claude self-hosted-runner setup

Manually, it's four steps. An Owner or admin turns on Allow self-hosted environments on the Cloud environments admin page (it's off by default, and the New button doesn't appear until it's on). Create the environment, copy the environment key — shown once, never again, expires after 365 days. Then on the host:

mkdir -p /etc/claude
(umask 077 && cat > /etc/claude/environment-secret)   # paste, Enter, Ctrl-D
mkdir -p /srv/claude-workspace

claude self-hosted-runner \
  --environment-secret-file /etc/claude/environment-secret \
  --base-dir /srv/claude-workspace

--base-dir defaults to /workspace, which only works if that path already exists and is writable, or the runner runs as root. Get this wrong and it doesn't fail at startup — sessions fail immediately after pickup with EACCES. Use the same --base-dir and --capacity on every runner in an environment: a session that gets requeued to a differently-configured runner comes back with its working directory somewhere else, and every absolute path the agent wrote down earlier now points at nothing.

Then start a session at claude.ai/code, pick your environment from the picker, and watch the runner log Picked up session <id>.

Two things about the host: Linux or macOS only (run a Linux container on Windows), and the clock must be within five minutes of real time or authentication fails outright.

The runner image is a QA artifact now

Anthropic doesn't publish a runner image. You build it. Which means the moment you adopt this, your team owns a container image whose contents determine whether agent-driven testing works — and it should be versioned, reviewed, and smoke-tested like any other test infrastructure.

The docs' minimal Dockerfile installs git, curl, ca-certificates, openssh-client and the pinned claude binary. For browser QA, start from a Playwright base image instead so the browser dependencies come pre-solved:

# Pin to the Playwright version in your package.json — mismatched browsers
# are the single most annoying way for this to fail.
FROM mcr.microsoft.com/playwright:v<your-playwright-version>-noble

ARG CLAUDE_CODE_VERSION
RUN curl -fsSL "https://downloads.claude.ai/claude-code-releases/${CLAUDE_CODE_VERSION:?}/linux-x64/claude" \
      -o /usr/local/bin/claude && chmod +x /usr/local/bin/claude

RUN git config --system user.name "Claude" \
 && git config --system user.email "noreply@anthropic.com" \
 && git config --system --add safe.directory '*'

# MCP servers must be added at build time with user scope. The default
# local scope writes under a per-directory key the runner won't seed.
RUN claude mcp add --scope user --transport http internal-api http://mcp-gateway.internal:8080

ENTRYPOINT ["claude"]

Build it with --build-arg CLAUDE_CODE_VERSION=2.1.224 or later. Note that the runner disables auto-update inside the sessions it spawns, so every session runs exactly the binary in your image. That's a feature: your fleet's Claude Code version is now a pinned dependency, and upgrading it is a deliberate act with a diff.

The part that surprised me most: the runner snapshots the host's ~/.claude/ once at startup and seeds it into every sessionsettings.json, CLAUDE.md, hooks, agents, commands, and skills. If you've already built a QA-specific setup around CLAUDE.md and skills (the setup I wrote about here), you bake that directory into the image and every session on the fleet starts with your locator conventions, your test-writing skill, and your gates already loaded. A repository's committed .claude/settings.json layers on top as project settings.

The catch is in the word once. Change ~/.claude/ on a running host and nothing happens until the runner restarts. Treat that directory as part of the image, not as something you tweak in place.

Give the session staging credentials without putting them in the image

This is where most homegrown "agent in the test environment" setups quietly go wrong: a staging token gets baked into the image, and now every session every member of your org runs has it.

The runner starts your wrapper script in place of the Claude binary, once per session, via --exec-path. The wrapper gets the session's JWT in CLAUDE_CODE_SESSION_ACCESS_TOKEN, whose act claim identifies who started the session — and self-hosted-runner decode-token reads it for you:

#!/bin/bash
# Mint a short-lived staging credential scoped to whoever started this
# session, so an agent's writes on staging trace back to a human.
CREATOR=$("$CLAUDE_RUNNER_CLAUDE_BIN" self-hosted-runner decode-token \
  | jq -re '.act.sub // "" | select(startswith("user:"))') \
  || { echo "no human creator on this session token" >&2; exit 1; }

eval "$(your-token-service issue --subject "$CREATOR" --scope staging-api --ttl 30m)"

exec "$CLAUDE_RUNNER_CLAUDE_BIN" "$@"

Use jq -re, not jq -r: with -r an absent claim yields the literal string null and your credential service happily issues a token for a user named "null".

Two rules the docs are emphatic about, both of which I'd have gotten wrong:

Always exec into "$CLAUDE_RUNNER_CLAUDE_BIN". The child's stdin is the runner's control channel — OAuth token rotations arrive on it — and file descriptor 3 carries activity signals. If your wrapper backgrounds the child with a bare &, it severs stdin, and the session looks perfectly healthy for about thirty minutes until the initial token expires and every API call starts returning 401. A test run that dies at minute thirty-one with an auth error, having done real work, is a genuinely awful thing to debug.

Sessions inherit whatever the wrapper leaves in the environment. So this is also where you'd point the session at the right staging base URL, not in a repo-committed settings file.

Don't lose the traces

A session ends, and at --capacity above one the runner deletes the per-session worktree immediately. Every trace, video, HTML report, and JUnit XML the agent produced goes with it. The post-session lifecycle hook is your one chance:

#!/usr/bin/env bash
# ~/.claude-runner-hooks/post-session — pointed at with --hooks-dir.
# Fires on every session end where a child was spawned.
set -u
IFS=':'
for ws in $CLAUDE_RUNNER_WORKSPACE_PATHS; do
  for dir in test-results playwright-report; do
    [ -d "$ws/$dir" ] || continue
    tar -czf - -C "$ws" "$dir" \
      | your-artifact-store put "sessions/$CLAUDE_RUNNER_SESSION_ID/$dir.tgz"
  done
done

CLAUDE_RUNNER_EXIT_REASON tells you which kind of ending you're looking at — completed, failed, interrupted, or abandoned — which is exactly the metadata you want attached to an artifact bundle. The hook's exit status never affects the session outcome, and it gets --post-session-hook-timeout-sec (60 by default) to finish. It cannot fire on an abrupt host death; if you need stronger guarantees, snapshot from inside the session with a PostToolUse hook instead.

Also: the runner doesn't install the Stop hook that Anthropic-hosted sessions use to nudge Claude into committing and pushing. Without it, a session that ends with uncommitted changes leaves the work on a disk you're about to destroy, and the Create PR button in claude.ai/code stays dead. The docs ship a reference implementation — install it, or accept that "the agent wrote the tests and then they vanished" is a thing that will happen to you once.

Smoke-test the image before you promote it

You now maintain an image that decides whether agent-driven testing works. Test it in CI like anything else. claude -p ... --environment dispatches a session to a specific environment and exits without waiting:

create_json=$(claude -p "Run the smoke suite against staging and report the exit code" \
  --environment "$CLAUDE_TEST_ENVIRONMENT_ID" --ref main --output-format json)
SESSION_ID=$(jq -er '.session_id' <<<"$create_json")

Read the replies back through a Stop hook on the test runner that appends last_assistant_message to $E2E_REPLY_DIR/<session_id>.txt, and assert on a sentinel phrase. Install that capture hook on test runners only — it writes every session's final reply to disk whenever the variable is set, which is fine on a throwaway CI runner and not fine anywhere else. And create a fresh environment per CI run via the admin API (anthropic-beta: ccr-byoc-2025-07-29), then delete it, so runs can't contaminate each other.

One sharp edge: both --environment and --cloud authenticate with a claude.ai OAuth token. API keys are not accepted. The refresh grant is capped server-side at 30 days, so a long-lived CI host needs an interactive claude auth login every 30 days, and there is no machine-identity path today.

For monitoring, the runner serves /healthz and /metrics on port 8080. /healthz returns 200 whenever the process is alive, so it detects a dead runner, not a stuck one — alert on claude_code_self_hosted_runner_last_poll_age_seconds for that. And when someone complains sessions take minutes to start, claude_code_self_hosted_runner_session_init_duration_seconds will tell you it's the clone, which you fix by baking a pre-warmed checkout at <base-dir>/<owner>/<repo> into the image.

What this costs, honestly

Self-hosting saves you nothing on tokens. Sessions in a self-hosted environment consume your organization's Claude Code usage exactly like Anthropic-hosted ones — and now you also pay for the compute, and someone maintains the fleet.

I want to be specific about spend control, because there's a plausible-sounding story here that is wrong. Two spend features shipped the same week: v2.1.225 added gateway spend-limit support to Claude Code's usage warning, and on 7 August the API added session budgets with a budget_reached stop reason. Neither one caps a self-hosted Claude Code session. Inference in self-hosted environments can't be routed through an LLM gateway (or Bedrock, Google Cloud's Agent Platform, or Microsoft Foundry) at all — the session authenticates with an Anthropic-issued OAuth token — so there's no gateway to enforce a limit. And session budgets are a Claude Managed Agents feature on the API, a different product. The --max-budget-usd flag is real but documented as print-mode only, so don't plan your controls around appending it in the wrapper.

What you actually get are time and slot limits, and they're the ones to configure on day one:

  • --kill-session-after-min — hard wall-clock backstop on a stuck session
  • --release-idle-session-min — frees a slot after inactivity, but a session holding a never-finishing background task never counts as idle. If your agent leaves playwright test --ui or a dev server running, that session sits there. This is why the docs say to always pair it with the kill flag, and for browser QA that pairing isn't optional.
  • --exit-if-unused-min and --capacity — the scale-down and parallelism knobs

The limitations that will actually bite a QA team

  • One user per runner. The first session locks the runner to that user's account, and it only serves that account afterwards. Your minimum fleet size is the number of testers active at once — --capacity buys parallelism within one person's sessions, not across people. A team of eight means at least eight runners.
  • Dispatch is organization-wide. Any member of your Anthropic organization can send a session to any environment; there's no per-environment access control on dispatch. The runner that can reach staging and holds your test database credentials is reachable by everyone in the org. --lock-to-account bounds which account a given host executes for, but not who can dispatch into the environment.
  • Resumed sessions lose unpushed work. On idle release or a runner restart, the session resumes on a fresh runner that re-clones from the starting branch. --push-outcome-on-release gives you a best-effort push of committed work — not a dirty working tree — and before enabling it you need to restrict who can push to claude/* refs, because the runner fetches that branch on resume without checking who wrote it.
  • You can't add a private repo mid-session. Select every repository the session needs at creation.
  • Connector traffic still leaves your network. GitHub, Slack, Linear and the other claude.ai connectors are called from Anthropic's side, not from your runner. If tool traffic must stay internal, run local MCP servers on the image instead.
  • Not everything routes there yet. Claude Tag, Claude Security, and Code Review sessions don't go to self-hosted environments. Repositories are checked out from GitHub. Organizations with Zero Data Retention can't use this at all.
  • Auto mode needs a network boundary first. The default pre-approved tool set already includes Bash, so shell egress runs without a prompt regardless of permission mode. Default-deny egress, blocking 169.254.169.254 inside the session container, and --confine-repo-settings enforce (so a checked-out repo can't grant itself write access outside its workspace) are what actually bound a model-directed process on your network. Do those before you turn auto mode on, not after.
  • Kubernetes' default 30-second termination grace will kill your runner mid-cleanup. The drain path needs about 80 seconds at defaults; the runner logs its own total at startup. Set terminationGracePeriodSeconds to at least that, or your post-session artifact upload gets SIGKILLed halfway through a tarball.

Takeaways

  • Self-hosted environments invert the setup you've been fighting: the agent goes to the test environment instead of you piping the test environment out to the agent. For QA, that's the whole value.
  • The runner image is now test infrastructure you own. Pin the Claude Code version, pin the browser version, add MCP servers with --scope user at build time, and bake ~/.claude/CLAUDE.md, skills, hooks — into it so every session starts with your conventions.
  • Mint staging credentials per session from the wrapper script using the creator's identity in the session JWT. Never bake them into a shared image. And always exec the runner's own binary, or you'll get a 401 thirty minutes into a run.
  • Save your artifacts in the post-session hook. The workspace is deleted right after it returns.
  • Budget in minutes, not dollars: --kill-session-after-min plus --release-idle-session-min. There is no per-session spend cap for these sessions, whatever the adjacent release notes suggest.
  • Before production: default-deny egress, block the metadata endpoint, --confine-repo-settings enforce, and remember that anyone in your org can dispatch a session onto that host.

Next post: the other half of this problem — letting the agent use a staging API token without ever seeing its value. v2.1.221 added file-based credential masking to the sandbox, and v2.1.224 extended it with extract, JWT-aware masking, and SigV4 re-signing. It pairs directly with the wrapper script above, and it's the difference between "the token is in the transcript" and "the token exists only at egress".

Last Update: August 10, 2026