Skip to main content
Self-hosted environments are in public beta on Team and Enterprise plans; an Owner or admin enables them by turning on Allow self-hosted environments on the Cloud environments admin page. This page assumes a working runner; see the quickstart for setup and Deploy to production for the fleet recipes.
A self-hosted environment runs Claude Code cloud sessions on your own infrastructure, executed by a runner process you deploy. With no configuration, that runner clones the session’s repository, spawns Claude Code, and cleans up. This page is for the platform engineer operating the runners: it covers the extension points for when those defaults don’t fit, from per-session credential provisioning to replacing checkout entirely. Wrappers and hooks run as executable files on the runner host, which is Linux or macOS, and the examples on this page assume a POSIX shell. A few hook environment variables on this page still use pool, such as CLAUDE_RUNNER_POOL_ID; the CLI flag and env var names use environment, such as --environment-secret-file.

Wrapper scripts

Use a wrapper script when each session needs setup the runner can’t do on its own: provisioning short-lived credentials scoped to the session creator, exporting environment-specific secrets, preparing language toolchains, or applying resource limits around the child process. The runner starts your wrapper in place of the Claude Code binary, once per session. End the wrapper by exec-ing into $CLAUDE_RUNNER_CLAUDE_BIN, the runner’s own binary, so signals and exit codes propagate correctly. Point --exec-path, or SELF_HOSTED_RUNNER_EXEC_PATH, at the wrapper when you start the runner:
The runner sets the following in the wrapper’s environment: The wrapper also inherits the rest of the child’s managed environment, including any server-provided environment variables. exec propagates all of it automatically; if your wrapper spawns the child another way, forward the full environment.

Keep stdin and file descriptor 3 attached

The child’s stdin is the runner’s control channel. Token rotations and session-end signals arrive on it. The runner also opens a pipe on file descriptor 3 and reads the child’s activity signals from it to drive idle and startup timeouts. A plain exec "$CLAUDE_RUNNER_CLAUDE_BIN" "$@" preserves both automatically. If your wrapper backgrounds the child with a bare &, it severs the child’s stdin: the session looks healthy until the initial OAuth token’s roughly 30-minute lifetime expires, then every API call fails with 401 authentication_error. If your wrapper must background the child, for example to keep a teardown trap alive, save stdin on file descriptor 4 or higher and re-attach it explicitly:
Don’t close or reuse file descriptor 3 in the wrapper. Redirecting the child’s stdout and stderr is fine.

Provision credentials scoped to the session creator

Use the decode-token subcommand to read claims from the session JWT. It reads the token from an argument, from CLAUDE_CODE_SESSION_ACCESS_TOKEN, or from stdin, in that order; see Verify the token inside the session for what it checks. The example below decodes the creator identity, exchanges it for short-lived AWS credentials, and execs into Claude Code:
Use jq -re rather than jq -r when the extracted claim gates an auth decision, so an absent claim exits non-zero instead of passing the literal string null downstream. Sessions created by an organization service identity, such as bot and agent sessions, carry an agent: subject rather than user:, so this example refuses them; if your environment serves those sessions, decide explicitly whether the wrapper falls back to a default credential for them instead of exiting. When your credential exchange needs the SSO subject or email instead, read .act.attested_by.sub or .act.email and handle their absence: the token carries them only when the creating surface recorded them, and a CLI-dispatched session can lack both. For the full claim reference and verification from services outside the runner, see Verify session identity.

Lifecycle hooks

Lifecycle hooks replace stages of the runner’s per-session pipeline with your own scripts. Point the runner at a directory of hooks with --hooks-dir <path>, or SELF_HOSTED_RUNNER_HOOKS_DIR. The runner looks for executable files with well-known names; any hook that isn’t present falls through to the built-in behavior, so you only write the ones you need. Hooks run with the runner’s own privileges, and session children share that UID, so mount the hooks directory read-only, or bake it into the image, so session code can’t modify it; see the hardening section. These hooks are distinct from Claude Code hooks, which run inside the session; lifecycle hooks run on the runner, around the session.

checkout

Runs once per repository, in place of the runner’s built-in clone and fetch. Use the hook to clone from a read-through mirror, seed a working tree from an archive, or apply per-session git auth. The runner sets: The script must leave a working tree at CLAUDE_RUNNER_CHECKOUT_PATH checked out at the requested revision. Detached HEAD is fine; the runner creates the session’s working branch on top. The runner verifies the path contains a .git afterwards; if your hook materializes a non-git source such as Perforce or an unpacked tarball, set CLAUDE_RUNNER_SKIP_GIT_VERIFY=1 in the runner’s environment to skip that check. Git-based flows such as working-branch creation and pushing results require a git checkout, so export outcomes from non-git trees with a post-session hook. The runner doesn’t pass a git credential to the hook. Instead, mint a per-session clone credential from the session’s identity: verify CLAUDE_CODE_SESSION_ACCESS_TOKEN with a standard JWT library against the JWKS endpoint under CLAUDE_RUNNER_API_BASE_URL, as described in Verify the token from your service, then have your credential service issue a short-lived clone credential for the identity in the token’s act claim. CLAUDE_RUNNER_CLAUDE_BIN isn’t set in the checkout-hook environment, so the decode-token subcommand isn’t available here. Falling back to whatever git authentication the host already has, such as an SSH agent, credential helper, or .netrc, is also an option. A non-zero exit fails the session, and the tail of the script’s stderr is surfaced to the user. The runner removes the checkout path after the session ends.

post-session

Runs once per session, after the Claude Code child has exited and before the runner tears the workspace down. This hook is your only chance to save uncommitted work: at --capacity above one, the runner deletes per-session worktrees right after the hook returns, and at --capacity 1 the reused canonical clone is hard-reset when the next session starts, so uncommitted tracked changes don’t survive on either path. Typical uses are pushing a snapshot branch of uncommitted changes, archiving logs, or emitting a session-ended event to your own systems. The hook fires on every session end where a child process was spawned, whatever the cause; the CLAUDE_RUNNER_EXIT_REASON values below enumerate the cases. It can’t fire when the runner terminates abruptly, such as a VM preemption or a power loss; if you need guarantees against abrupt termination, snapshot periodically from inside the session with a Claude Code PostToolUse hook instead. The runner sets: CLAUDE_RUNNER_EXIT_REASON takes one of four values:
  • completed: a clean exit, including a session archived or deleted while the child was still connected.
  • failed: a child crash or a setup failure after spawn.
  • interrupted: an idle release, startup timeout, server deassign, drain, watchdog kill, or the released=false backstop.
  • abandoned: reserved for sessions another runner claimed; the hook doesn’t currently fire in that case.
The session lifecycle counter semantics classify an idle release, a startup timeout, and a server deassign as completed instead: those are clean handoffs from the session’s perspective even though this hook reports them as interrupted. The hook’s exit status never affects the session outcome; a failure is logged and ignored. The runner waits up to --post-session-hook-timeout-sec, 60 seconds by default, on every session end including runner shutdown. This example saves uncommitted work to a rescue branch:
The hook pushes with whatever git credentials are available in its own environment on the runner host. Under the no-credentials-in-the-image posture, including when the built-in clone goes through the Anthropic git proxy, there are none, so mint a short-lived push credential inside the hook before pushing: exchange the session token the hook receives in CLAUDE_CODE_SESSION_ACCESS_TOKEN with your own token service, verifying it as Verify session identity describes. When the hook holds a credential the session didn’t, also pin where it pushes: replace origin with an operator-supplied URL and pass -c credential.helper= plus your own helper, so repo-local config the session wrote can’t redirect the credentialed push.

command

Runs once per session after checkout, in place of the built-in child spawn. The hook receives the same environment as a wrapper script and should exec into "$CLAUDE_RUNNER_CLAUDE_BIN" the same way. Use the command hook to keep all customization in one hooks directory; use --exec-path when the wrapper lives elsewhere. If --exec-path is also set, the flag takes precedence and the command hook is ignored. Always exec the runner’s own binary rather than a PATH-resolved claude; otherwise you defeat version pinning.

On-demand runners

Instead of running a fixed fleet, you can boot one runner per session. The orchestrator is a separate, stateless subcommand that polls Anthropic for spawn requests, one per session that’s queued with no runner available, and runs your spawn-runner hook for each. Your hook submits a workload to your platform: a Kubernetes Job, an EC2 instance, a Nomad dispatch. On-demand runners improve credential hygiene. On a fixed fleet, the environment secret lives on every runner host, which is the same host that runs user sessions. With the orchestrator, the environment secret stays only on the orchestrator host, which never runs user code; each spawned runner receives a single-use work order that registers exactly one runner and then expires. To start the orchestrator, pass the environment secret and a hooks directory containing an executable spawn-runner script:
The orchestrator keeps no state between polls, so you can run two or more replicas against the same environment for availability. Each spawn request is claimed server-side by exactly one replica. All replicas must use the same --expected-spawn-seconds value; see the hook contract.

The spawn-runner hook

The orchestrator runs ${hooks-dir}/spawn-runner once per spawn request. The hook must submit work asynchronously and return within --hook-timeout, 60 seconds by default. It must not wait for the runner to boot. The hook receives: The spawned runner registers with the work order in place of the environment secret:
  • Start it with the work order: point --environment-secret-file at a file containing the work-order JWT, or set SELF_HOSTED_RUNNER_ENVIRONMENT_SECRET to the JWT value.
  • Copy the JWT before the hook exits: the orchestrator deletes the work-order file after the hook exits, so copy the JWT into the workload you submit, such as a Kubernetes Secret on the spawned Job, rather than passing the file path through.
  • Use --capacity 1 on spawned runners: a session-bound work order registers exactly one runner bound to that session, so a higher capacity adds slots that never receive work, and the runner logs a warning at startup.
  • Pre-warming work orders register unbound: the standby runner isn’t bound to a session and claims queued work like a fixed-fleet runner.
The contract has four provisioner-agnostic rules:
  1. Be idempotent on CLAUDE_RUNNER_ORDER_ID. Redelivery of the same request must spawn at most one runner. Derive a deterministic resource name from the ID and let your platform reject the duplicate.
  2. Don’t retry the workload. One order ID means at most one created workload. If the runner never registers, Anthropic re-requests with a fresh order ID after --expected-spawn-seconds.
  3. Use the exit-code contract. Exit 0 means submitted. Exit 1 means retryable failure; the session backs off and is re-offered. Exit 2 or higher means non-retryable; the session is blocked from spawning again until an Owner or admin selects Retry on it in the environment’s Activity tab. On non-zero exit, the tail of the hook’s stderr appears there as the failure reason, so write the actionable error to stderr and never secrets. For a pre-warming request there is no session to fail: the orchestrator logs a non-zero exit locally only, and the server re-requests the spawn after the lease.
  4. Set --expected-spawn-seconds to at least your p99 boot time. This is the server-side lease. All orchestrator replicas must use the same value.
Everything the hook writes to stdout or stderr appears in the orchestrator’s log with credentials automatically redacted. If sessions stay queued, check the orchestrator’s /healthz body for queue counts, then open your environment’s Activity tab on the Cloud environments admin page: expand a failed session there for its spawn error, and select Retry to re-request it.

MCP servers

To make MCP servers available in every session, add them at image build time with the same claude mcp add command used on a desktop install. If your runner is a bare process rather than a container, run the same command as the runner’s user on the host, then restart the runner: it reads host config once at startup. The --scope user flag is required; the default local scope writes under a per-directory key that the runner doesn’t seed into sessions. For example, in your Dockerfile:
The runner snapshots the host’s config once at startup. The snapshot captures the mcpServers key from the host’s .claude.json, which lives next to rather than inside ~/.claude/, and the runner seeds only that key into each session’s isolated config; account state and project history are dropped. To confirm the servers reached sessions, start a session on the environment and ask Claude to list its MCP tools; the runner also logs a startup warning for any captured entry whose type it doesn’t recognize and drops the entry, so the drop is visible instead of the server silently failing to load. When SELF_HOSTED_RUNNER_HOST_CONFIG_DIR is set, the runner reads .claude.json from that directory instead, so pointing the variable at an empty directory disables MCP seeding too. Two other sources work as well:
  • The enterprise-scope managed MCP file at its standard system path: /etc/claude-code/managed-mcp.json on Linux runner hosts, /Library/Application Support/ClaudeCode/managed-mcp.json on macOS hosts. Use it for locked-down fleets where only administrator-listed servers may load; see exclusive control with managed-mcp.json for the precedence rules.
  • <repo>/.mcp.json: project scope. Commit the file to the repository; its servers are auto-approved in cloud sessions.
When connector delivery is enabled for your organization, Anthropic’s control plane delivers the connectors you’ve configured on claude.ai to interactively-created sessions through server-provided MCP configuration, routed through api.anthropic.com. Sessions created programmatically, such as CLI dispatches, don’t receive connector delivery; give them MCP servers through the host snapshot, the managed MCP file, or <repo>/.mcp.json instead. The child’s OAuth token doesn’t carry a scope for fetching connectors directly, so the child doesn’t attempt that fetch itself; delivery is server-driven. settings.json and managed-settings.json don’t carry MCP server definitions; there is no top-level mcpServers field in the settings schema. Sessions inherit the runner’s environment, so set ENABLE_TOOL_SEARCH there to control MCP tool search for every session a runner spawns; the MCP page covers the values.

Prompt sessions to push their work

Anthropic-hosted sessions run a Stop hook, the Claude Code hook that runs when Claude finishes responding, that prompts Claude to commit and push its work. The runner doesn’t install one. Without it, a session that ends with uncommitted changes leaves that work only on the runner’s disk, and the Create PR button in claude.ai/code stays inactive until the branch exists on the remote. The reference implementation below has two parts. Merge the settings block into ~/.claude/settings.json on the runner host, which the runner seeds into every session, and save the script as ~/.claude/hooks/stop-hook-nudge.sh on the runner host and make it executable:
The hook prompts Claude to commit and push before the session ends, and stays silent when the directory isn’t a git repository or has no remote.

Permissions and tool approval

A self-hosted session has no terminal attached, so an unanswered permission prompt stalls the turn until the user responds in the UI. Anthropic’s control plane sends each session’s tool list and permission rules with the work payload; the default configuration pre-approves routine tool calls, including Bash, and cloud sessions pre-approve file edits regardless of mode. A call that nothing pre-approves prompts through the session UI.
Only enable auto mode on an environment whose session containers run with default-deny network egress and the rest of the hardening section in place. Routine tool calls, including Bash network requests, run without a human in the loop on both the default pre-approved tool set and in auto mode, so the network boundary is what limits where those calls can reach.
To keep prompts to a minimum regardless of what the control plane sends, pin auto mode from your wrapper script or command hook. Auto mode lets sessions run without routine permission prompts: a separate classifier model reviews actions before they run and blocks the ones it rejects, and explicit ask rules still force a prompt; the permission modes page covers what the classifier checks. The runner appends server-computed flags before invoking the wrapper, and for single-value flags such as --permission-mode the parser honors the last occurrence, so a flag you append after "$@" overrides the server-sent value:
To pre-approve specific tools instead, append --allowed-tools with your rules, for example --allowed-tools "Bash(bazel *) Bash(yarn *) mcp__internal__*". List flags such as --allowed-tools and --disallowed-tools accumulate across occurrences rather than overriding, so your rules apply on top of any rules the control plane sends. To narrow, append --disallowed-tools, which denies tools even if another rule allows them.

How each session’s config is assembled

The runner gives each session its own config directory, seeded from an in-memory snapshot of the host’s ~/.claude/ that the runner captures once at startup: settings.json, CLAUDE.md, hooks, agents, commands, and skills in your runner image apply to every session as the user-level baseline. Because the snapshot is taken at startup, config changes on a running host take effect only after a runner restart. Set SELF_HOSTED_RUNNER_HOST_CONFIG_DIR to seed from a different path, or point it at an empty directory to disable seeding. Repository-committed .claude/settings.json layers on top as project settings. Sessions also read managed-settings.json from the standard system path in your runner image, but the managed tier uses one source at a time, and server-managed settings are checked first: if your organization delivers any server-managed keys, sessions ignore the runner image’s managed file, except that env blocks merge per key across managed sources. See settings precedence.

Repository-committed permission rules

Don’t put a bare "Edit", "Write", or "NotebookEdit" entry in a repository-committed permissions.allow. A bare file-tool rule matches the tool regardless of path, granting writes anywhere on the host rather than only the workspace, so the runner’s write-scope confine guard flags the session; with --confine-repo-settings enforce it refuses to spawn the session instead of logging and continuing. See the hardening section. A repository needs no file-tool rule at all: cloud sessions pre-approve file edits regardless of mode. If you do commit a rule, scope it to the workspace, such as "Edit(/**)"; a single leading slash is relative to the project root, which is the session’s workspace. Bare file-tool rules are fine in the operator’s host-level settings.json, since that file isn’t repository-committed. A defaultMode of auto is only honored from the image-wide or user-level settings file, so a checked-out repository can’t grant itself auto mode. For which modes cloud sessions accept and the full rule syntax, see permission modes.

What’s next