Skip to content

Customise self-hosted sessions

Wrapper scripts, lifecycle hooks, on-demand runners, Bedrock or Agent Platform inference, MCP servers and permissions for self-hosted runners.

Out of the box a self-hosted runner does three things per session: clone the repository, start Claude Code, clean up. That is enough to prove the plumbing works, but most real deployments need to hook into that pipeline somewhere, usually to hand each session credentials that belong to the person who started it. This page covers every extension point, roughly in the order you are likely to need them.

It assumes a working runner (see the quickstart) and that you have read the hardening advice in deploying to production. Wrappers and hooks are executables on the runner host (Linux or macOS), and the examples use a POSIX shell.

Note: A few hook variables still say pool, such as CLAUDE_RUNNER_POOL_ID. Flags and the newer environment variables say environment, such as --environment-secret-file. They refer to the same thing.

Choosing an extension point

You want toUse
Give each session short-lived credentials, toolchain setup or resource limitsA wrapper script via --exec-path, or the command hook
Clone from a mirror, seed from an archive, or use a non-git sourceThe checkout hook
Save uncommitted work or emit an event when a session endsThe post-session hook
Start one runner per session instead of a fixed fleetThe orchestrator and spawn-runner hook
Bill model usage to your own AWS or Google Cloud accountBedrock or Agent Platform
Give every session the same MCP serversSeed MCP config in the image
Reduce permission promptsPin a permission mode or tool rules

Wrapper scripts

A wrapper is a script the runner starts in place of the Claude Code binary, once per session. It does its setup and then execs into the real binary so signals and exit codes pass straight through. Register it with --exec-path (or SELF_HOSTED_RUNNER_EXEC_PATH):

claude self-hosted-runner \
  --environment-secret-file /etc/claude/environment-secret \
  --exec-path /opt/claude/session-wrapper.sh

The smallest useful wrapper exports something and hands over:

#!/bin/bash
set -euo pipefail
export PIP_INDEX_URL=https://pypi.internal.acme.example/simple
export NODE_EXTRA_CA_CERTS=/etc/ssl/acme-root.pem
exec "$CLAUDE_RUNNER_CLAUDE_BIN" "$@"

What the wrapper's environment contains

VariableWhat it holds and how to use it
CLAUDE_RUNNER_CLAUDE_BINAbsolute path to the runner's own Claude Code binary. Always exec this rather than a claude on PATH, or you lose version pinning
CLAUDE_CODE_SESSION_ACCESS_TOKENThe session JWT (prefix sk-ant-cc-). Its act claim names the creator, plus their email when the starting surface recorded one. This is the value at spawn time; later refreshes go over stdin. See verify session identity
CCR_SESSION_ACCOUNT_EMAILCreator's email, lifted from act.email without verifying the signature. Fine for labels such as commit trailers, not for gating credentials. Unset when the token has no email. Treat as personal data
CLAUDE_RUNNER_CLIENT_PLATFORMWhere the session was started, for example web_claude_ai, desktop_app, ios, claude_code_cli or scheduled_trigger. Same value for the wrapper and all hooks. Analytics only, never authorisation. May be unset, so use ${CLAUDE_RUNNER_CLIENT_PLATFORM:-} under set -u. v2.1.229+
CLAUDE_CODE_REMOTE_SESSION_IDSession ID as cse_.... Swap the prefix for session_ to get the form in the session URL and in hooks' CLAUDE_RUNNER_SESSION_ID
CLAUDE_CODE_REMOTE_SESSION_UUIDThe same ID as a plain UUID
CLAUDE_SESSION_INGRESS_TOKEN_FILEPath to a file that always holds the current session JWT. Shell subprocesses use it to download files the user attached. exec keeps it; if you rebuild the child's environment, carry it over or attachment downloads quietly break
CLAUDE_CONFIG_DIRThe session's private config directory, seeded from the host snapshot (see how config is assembled). Stays under <base-dir>/_sessions/ afterwards unless the runner has --remove-session-state
ANTHROPIC_BASE_URLThe API base URL for this session, normally https://api.anthropic.com. Do not change it; the inference credential only works against Anthropic
CLAUDE_CODE_OAUTH_TOKENShort-lived (about 30 minutes) token scoped to inference and file upload. Rotated over stdin. Treat it as a bearer credential: never log it, write it to disk or send it outside the container. Your IP allowlist does not constrain it

The wrapper also inherits the rest of the child's managed environment, including server-provided variables. exec passes all of it on; if you launch the child any other way, forward everything.

Keep stdin and file descriptor 3 attached

Two channels between runner and child are easy to break by accident:

  • stdin carries token rotations and the end-of-session signal.
  • fd 3 is a pipe the runner reads activity signals from, which drive the idle and startup timeouts.

A plain exec "$CLAUDE_RUNNER_CLAUDE_BIN" "$@" keeps both. Backgrounding the child with a bare & cuts stdin, and the failure is delayed: the session works until the first token expires about 30 minutes in, then every call returns 401 authentication_error.

If you genuinely need the child in the background, for example so a cleanup trap can run after it, stash stdin on a spare descriptor and give it back explicitly:

#!/bin/bash
cleanup() { rm -rf "/tmp/session-scratch-${CLAUDE_CODE_REMOTE_SESSION_UUID}"; }
trap cleanup EXIT
exec 5<&0
"$CLAUDE_RUNNER_CLAUDE_BIN" "$@" <&5 5<&- &
pid=$!
wait "$pid"

Never close or reuse fd 3. Redirecting stdout and stderr is fine.

Do not touch the system prompt flags

The control plane's system prompt and appended system prompt reach the wrapper as file paths in its arguments: --system-prompt-file <path> and --append-system-prompt-file <path>, written into CLAUDE_CONFIG_DIR (v2.1.281+; earlier runners passed --system-prompt and --append-system-prompt with inline text). Pass "$@" through untouched, or the session loses its instructions.

Because each of these flags takes one value and the last one wins, appending your own --append-system-prompt-file after "$@" replaces the server's instructions rather than adding to them. If you want extra standing instructions, put them in the image's CLAUDE.md instead; the runner seeds that into every session.

Mint credentials for the person who started the session

claude self-hosted-runner decode-token reads the session JWT (from an argument, then CLAUDE_CODE_SESSION_ACCESS_TOKEN, then stdin) and prints its claims; the identity page explains what it verifies. This wrapper exchanges the creator's identity for a scoped GitHub token from an internal broker:

#!/bin/bash
set -uo pipefail

subject=$("$CLAUDE_RUNNER_CLAUDE_BIN" self-hosted-runner decode-token \
  | jq -re '.act.sub // "" | select(startswith("user:"))') || {
  echo "refusing: token failed verification or was not created by a person" >&2
  exit 1
}

GH_TOKEN=$(curl -fsS --data-urlencode "sub=$subject" \
  https://token-broker.internal.acme.example/github/short-lived) || {
  echo "token broker refused $subject" >&2
  exit 1
}
export GH_TOKEN

exec "$CLAUDE_RUNNER_CLAUDE_BIN" "$@"

Points worth copying:

  • Use jq -re, not jq -r, whenever a claim gates access. A missing claim then fails the command instead of passing the string null along.
  • Key on act.sub, the stable user ID. Bot and agent sessions carry an agent: subject, so this script refuses them; decide deliberately whether those get a fallback credential.
  • If you need the email, read .act.email and handle it being absent. It is only there when the starting surface recorded it, and CLI-dispatched sessions may not have it.

Lifecycle hooks

Lifecycle hooks swap out stages of the runner's own per-session pipeline. Point the runner at a directory with --hooks-dir <path> (or SELF_HOSTED_RUNNER_HOOKS_DIR). The runner looks for executables with fixed names; anything missing falls back to built-in behaviour.

Hooks run with the runner's privileges, and session children run as the same UID, so bake the directory into the image or mount it read-only. Otherwise a session could rewrite the hook that sets up the next one.

Note: These are not the same as Claude Code hooks. Claude Code hooks run inside a session; lifecycle hooks run on the runner, around it.

Hook fileRunsReplaces
checkoutOnce per repository, before the session startsThe built-in clone and fetch
commandOnce per session, after checkoutThe built-in child spawn
post-sessionOnce per session, after the child exitsNothing (there is no default)
spawn-runnerOn the orchestrator, once per spawn requestNot applicable to ordinary runners

The checkout hook

Use it to clone from a read-through mirror, unpack a tarball, or authenticate per session. Environment:

VariableMeaning
CLAUDE_RUNNER_REPO_URLURL to clone, after --git-host-rewrite and --git-ssh-rewrite
CLAUDE_RUNNER_REPO_REFBranch, tag or SHA requested. Empty means the default branch
CLAUDE_RUNNER_CHECKOUT_PATHWhere the working tree must end up
CLAUDE_RUNNER_SESSION_IDSession ID as session_...
CLAUDE_RUNNER_SESSION_UUIDSame, as a UUID
CLAUDE_RUNNER_API_BASE_URLAPI base URL for session-scoped calls
CLAUDE_RUNNER_CLIENT_PLATFORMStarting surface; may be unset
CLAUDE_CODE_SESSION_ACCESS_TOKENThe session JWT
GIT_CONFIG_COUNT, GIT_CONFIG_KEY_n, GIT_CONFIG_VALUE_nGit settings the runner pins for your hook (v2.1.280+); see git configuration inside hooks

Other CLAUDE_RUNNER_ variables may appear too.

The contract: leave a working tree at CLAUDE_RUNNER_CHECKOUT_PATH at the requested revision. Detached HEAD is fine; the runner creates the session's working branch afterwards. The runner checks for a .git directory; if you materialise something that is not git (Perforce, an archive), set CLAUDE_RUNNER_SKIP_GIT_VERIFY=1 on the runner, and export results with a post-session hook, because branch creation and pushing need git.

No git credential is handed to the hook, and CLAUDE_RUNNER_CLAUDE_BIN is not set here, so decode-token is unavailable. Either verify the JWT yourself against the JWKS under CLAUDE_RUNNER_API_BASE_URL (see verify the token from your service) and ask your credential service for a clone token for the act identity, or fall back on whatever the host already has (SSH agent, credential helper, .netrc).

Here is a mirror-first checkout that falls back to the origin:

#!/bin/bash
set -euo pipefail
repo_path=${CLAUDE_RUNNER_REPO_URL#https://*/}
mirror="https://git-mirror.internal.acme.example/${repo_path}"
ref=${CLAUDE_RUNNER_REPO_REF:-HEAD}

git init -q "$CLAUDE_RUNNER_CHECKOUT_PATH"
cd "$CLAUDE_RUNNER_CHECKOUT_PATH"
git remote add origin "$CLAUDE_RUNNER_REPO_URL"
git fetch -q --depth 100 "$mirror" "$ref" 2>/dev/null \
  || git fetch -q --depth 100 origin "$ref"
git checkout -q --detach FETCH_HEAD

When the hook fails (non-zero exit, or exit 0 with no usable checkout):

  • For the repository the session pushes to, the session fails, and a non-zero exit shows the tail of stderr to the user.
  • For a read-only repository (for example one added mid-session), the runner logs [runner:warn], posts a Skipped step, removes whatever the hook left behind (retrying at session end if needed) and carries on. If that leaves no repository at all, the session fails anyway. Before v2.1.228 any failure failed the session.

The runner deletes the checkout path after the session.

The post-session hook

This runs after the child exits and before the workspace is torn down, and it is your only chance to rescue uncommitted work. Above capacity 1, per-session worktrees are deleted as soon as it returns; at capacity 1 the canonical clone is hard-reset when the next session starts.

It runs for every session end where a child was spawned, but not when the host dies abruptly (preemption, power loss). For protection against that, snapshot from inside the session with a Claude Code PostToolUse hook.

VariableMeaning
CLAUDE_RUNNER_SESSION_ID / CLAUDE_RUNNER_SESSION_UUIDSession identifiers
CLAUDE_RUNNER_EXIT_REASONHow it ended (see below)
CLAUDE_RUNNER_WORKSPACE_PATHSColon-separated working tree paths. Empty when there were no repositories
CLAUDE_RUNNER_DEBUG_LOG_PATHThe session's debug log, still on disk
CLAUDE_RUNNER_API_BASE_URLAPI base URL
CLAUDE_RUNNER_CLIENT_PLATFORMStarting surface (v2.1.229+), may be unset
CLAUDE_CODE_SESSION_ACCESS_TOKENThe session JWT
GIT_CONFIG_COUNT and pairsPinned git settings (v2.1.280+)

Exit reasons:

  • completed: Claude Code exited normally, or the session was archived or deleted while running.
  • failed: Claude Code crashed, or setup failed after it started.
  • interrupted: the runner stopped it, because of a release, a startup timeout, the server moving it, a drain, or --kill-session-after-min.
  • abandoned: reserved for a session another runner claimed. The hook does not currently fire for it.

The session lifecycle metrics count a release, startup timeout and server move as completed, because the slot was handed back cleanly, so do not expect hook counts and metrics to match exactly.

The hook's exit status never changes the session outcome. The runner gives it --post-session-hook-timeout-sec (60 by default), including during shutdown.

Here is my version of a rescue hook. It overrides the repo-local settings a session could have planted, so session-written config cannot run code with the hook's privileges, and pushes to an operator-chosen remote with an operator-chosen credential:

#!/usr/bin/env bash
set -u
RESCUE_REMOTE="https://git.internal.acme.example/rescue/${CLAUDE_RUNNER_SESSION_UUID}.git"

safe_git() {
  git -c core.fsmonitor=false -c core.hooksPath=/dev/null \
      -c commit.gpgsign=false -c credential.helper= \
      -c credential.helper='!/opt/claude/rescue-cred-helper' "$@"
}

IFS=':' read -r -a trees <<< "${CLAUDE_RUNNER_WORKSPACE_PATHS:-}"
for tree in "${trees[@]}"; do
  [ -d "$tree" ] || continue
  cd "$tree" || continue
  [ -n "$(safe_git status --porcelain 2>/dev/null)" ] || continue
  safe_git add -A
  safe_git commit -qm "rescue ${CLAUDE_RUNNER_SESSION_ID} (${CLAUDE_RUNNER_EXIT_REASON})" || continue
  safe_git push -q "$RESCUE_REMOTE" "HEAD:refs/heads/$(basename "$tree")" || true
done

Under the "no credentials in the image" posture, including when cloning via the Anthropic git proxy, the hook has nothing to push with by default. Exchange CLAUDE_CODE_SESSION_ACCESS_TOKEN for a short-lived push token with your own service (verifying it as the identity page describes). If the hook holds a credential the session did not, push to a URL you control rather than origin, and reset credential.helper as above.

When the hook runs relative to a release (v2.1.236+). If the session was idle after a turn (or holding only background tasks), or timed out at startup, the runner stops the child, runs the hook to completion and only then releases, so a new message cannot resume the session elsewhere mid-hook. If the session was waiting for the user to answer a prompt, the runner releases first and then runs the hook, so it can resume elsewhere while the hook is still going. This applies at the idle timeout, at --retire-at, and (v2.1.260+) at --kill-session-after-min. During a SIGTERM drain the runner holds the lease until the hook finishes.

Git configuration inside lifecycle hooks

Hooks hold the session's token yet run git that reads files sessions can write (~/.gitconfig, .git/config). From v2.1.280 the runner pins some settings for the hook's git via GIT_CONFIG_* pairs and git environment variables, which outrank every config file and do not affect the session's own git. A [runner:git] lifecycle hooks: line at startup shows what is in force.

SettingValue in hooksHow to change it
core.hooksPath/dev/null, so repository and ~/.gitconfig hook directories are ignoredExport your own GIT_CONFIG_KEY_n/VALUE_n pair on the runner. A system-level core.hooksPath is honoured only if the runner's user cannot write the file, the directory or the hooks; otherwise a [runner:warn] explains why it was ignored
core.fsmonitorEmptygit -c inside the hook
GIT_ALLOW_PROTOCOLhttps:http:ssh, so local paths, file:// and git:// fail with fatal: transport 'file' not allowed (or 'git')Set the variable on the runner
core.sshCommand, core.askPassIgnored from config filesSet GIT_SSH_COMMAND or GIT_ASKPASS on the runner (sessions inherit them too, so never put a credential in them)
gpg.program, gpg.openpgp.program, gpg.x509.program, gpg.ssh.programPaths the runner setsNot from config files
Commit signingSigned as the session with --configure-git; otherwise commit.gpgsign and tag.gpgsign are falsegit -c

Precedence rules: your exported pairs replace the runner's for the same key (number from 0 and set GIT_CONFIG_COUNT; if the last announced pair is missing, all of yours are ignored with a warning). git -c inside the hook beats any pair but cannot override GIT_ALLOW_PROTOCOL, GIT_SSH_COMMAND or GIT_ASKPASS.

Everything the runner does not pin, such as credential helpers, url.*.insteadOf rewrites and filter drivers, is still read from session-writable files. A helper or filter driver named there runs with the hook's privileges, and an insteadOf can redirect even a push to an explicit URL. That is why the rescue hook above resets credential.helper. Before v2.1.280 none of this was pinned, and commits from hooks under --configure-git failed unless the hook passed -c commit.gpgsign=false.

The command hook

command runs once per session after checkout, in place of the built-in spawn. It gets exactly the wrapper environment and should end the same way, with exec "$CLAUDE_RUNNER_CLAUDE_BIN" "$@". Use it when you want everything in one hooks directory; use --exec-path when the wrapper lives elsewhere. If both are set, --exec-path wins and the hook is ignored.

On-demand runners

Instead of keeping a fixed fleet warm, the orchestrator boots one runner per queued session. It is a stateless subcommand that polls Anthropic for spawn requests and calls your spawn-runner hook for each, which submits a Kubernetes Job, an EC2 instance, a Nomad dispatch or whatever suits you.

The security win is that the environment secret lives only on the orchestrator host, which never runs user code. Each spawned runner gets a single-use work order that registers exactly one runner and then expires.

claude self-hosted-runner orchestrator \
  --environment-secret-file /etc/claude/environment-secret \
  --hooks-dir /opt/claude/orchestrator-hooks

Run two or more replicas for availability; the server hands each spawn request to exactly one of them. All replicas must share the same --expected-spawn-seconds. Full flags are in the orchestrator flag reference.

The spawn-runner hook

The hook must submit work asynchronously (not wait for the runner to boot) and return within --hook-timeout (60 seconds by default).

VariableMeaning
CLAUDE_RUNNER_WORK_ORDER_FILETemp file with the signed work-order JWT. Deleted when the hook exits. Never log it
CLAUDE_RUNNER_ORDER_IDUnique per spawn request and safe in Kubernetes names. Your only dedup key
CLAUDE_RUNNER_SESSION_ID / CLAUDE_RUNNER_SESSION_UUIDThe session. Repeats across re-requests, so use for logging and routing only. Empty for pre-warming requests (--min-idle)
CLAUDE_RUNNER_ATTEMPTSpawn requests so far for this session; 0 for pre-warming
CLAUDE_RUNNER_ORDER_SERVER_TIMEServer time from the poll response's Date header, for checking the JWT's exp without trusting the local clock. May be empty
CLAUDE_RUNNER_POOL_IDEnvironment ID (ccpool_...)
CLAUDE_RUNNER_ACCOUNT_IDTagged ID of the account that queued the session, for routing, quota or chargeback. Empty if unavailable and always empty for Claude Tag channel sessions
CLAUDE_RUNNER_ACCOUNT_EMAILThat account's email; personal data, do not log
CLAUDE_RUNNER_PRIMARY_REPO_URL / CLAUDE_RUNNER_PRIMARY_REPO_REVISIONFirst git source and its revision, handy for routing to a pre-warmed runner
CLAUDE_RUNNER_REPO_SOURCESJSON array of {url, revision} for every git source
CLAUDE_RUNNER_CORRELATION_IDCorrelation ID supplied when the session was created, if any
CLAUDE_RUNNER_CLIENT_PLATFORMStarting surface; unset for pre-warming. Test with [ -n "${CLAUDE_RUNNER_CLIENT_PLATFORM:-}" ]

The spawned runner registers using the work order instead of the environment secret, either via --environment-secret-file pointing at a file with the JWT or by setting SELF_HOSTED_RUNNER_ENVIRONMENT_SECRET to it. Copy the JWT into the workload (for example a Kubernetes Secret) before the hook exits, because the file is deleted. Use --capacity 1: a session-bound order registers one runner for one session, and extra slots would never get work (the runner warns). Pre-warming orders register an unbound standby runner that claims queued work like a fleet runner.

The four rules of the contract:

  1. Idempotent on CLAUDE_RUNNER_ORDER_ID. Derive the resource name from it and let your platform reject duplicates. Never key on the session ID: every re-request for a session has the same session ID and a new order ID, so a session-keyed workload is created once and never again.
  2. Never retry the workload yourself. If the runner does not register within --expected-spawn-seconds, Anthropic re-requests with a new order ID.
  3. Exit codes matter. 0 submitted; 1 retryable, the session backs off and is re-offered; 2 or above non-retryable, the session is blocked until an Owner selects Retry in the environment's Activity tab. The tail of stderr is shown there, so make it actionable and secret-free. Pre-warming failures are only logged locally.
  4. Set --expected-spawn-seconds to at least your p99 boot time, identically on every replica.

A Kubernetes version:

#!/bin/bash
set -euo pipefail
name="claude-run-${CLAUDE_RUNNER_ORDER_ID}"
ns=claude-runners

kubectl -n "$ns" create secret generic "$name" \
  --from-file=work-order="$CLAUDE_RUNNER_WORK_ORDER_FILE" \
  --dry-run=client -o yaml | kubectl apply -f - >/dev/null

if ! kubectl -n "$ns" create -f - <<EOF >/dev/null 2>err.txt
apiVersion: batch/v1
kind: Job
metadata: { name: ${name} }
spec:
  backoffLimit: 0
  ttlSecondsAfterFinished: 600
  template:
    spec:
      restartPolicy: Never
      containers:
        - name: runner
          image: registry.acme.example/claude-runner:2.1.290
          args: ["self-hosted-runner", "--environment-secret-file", "/wo/work-order", "--capacity", "1"]
          resources: { requests: { cpu: "2", memory: 4Gi }, limits: { cpu: "4", memory: 4Gi } }
          volumeMounts: [{ name: wo, mountPath: /wo, readOnly: true }]
      volumes: [{ name: wo, secret: { secretName: ${name} } }]
EOF
then
  grep -q AlreadyExists err.txt && exit 0
  echo "could not create Job ${name}: $(tail -n 3 err.txt)" >&2
  exit 1
fi

backoffLimit: 0 and restartPolicy: Never honour rule 2, and treating AlreadyExists as success honours rule 1.

Hook output goes to the orchestrator log with credentials redacted. If sessions stay queued, check the orchestrator's /healthz body for queue counts, then the environment's Activity tab on the Cloud environments admin page, where failed sessions show their spawn error and a Retry button. A session stuck in the queue with no error there often means the hook is keyed on the session ID: look for a workload from the first request and none from later ones.

Send model requests to Bedrock or Agent Platform

If model traffic must go through your own cloud account, configure runners for Amazon Bedrock or Google Cloud's Agent Platform (formerly Vertex AI). The runner still polls Anthropic and each session still streams prompts, responses and tool results to api.anthropic.com; only inference moves. The plan requirements and Zero Data Retention exclusion still apply. Sessions can resume on any runner, so configure every runner in the environment the same way.

Steps:

  1. Prepare the account and egress. For Bedrock, submit the use case details and create a policy limiting bedrock:InvokeModel and bedrock:InvokeModelWithResponseStream to the inference profiles you use and their underlying models. For Agent Platform, enable the API, request model access, and create a custom role with only aiplatform.endpoints.predict. Open your provider's endpoints in your egress rules; if sessions cannot reach them, Claude Code may retry for hours before showing an error.
  2. Give sessions narrowly scoped credentials. Attach the policy or role to an identity that can do nothing else.
  3. Set one provider's variables on the runner (container spec, service unit, or the workload your spawn-runner hook creates), then restart.
  4. Check from inside a session, not from your shell on the host.

Warning: Anyone who can get code running in a session, prompt injection included, can spend on these credentials while they are valid. Claude Code needs them inside the session, so shell commands, Claude Code hooks and stdio MCP servers (same user, same environment) can read them. Hardening keeps host credentials out but cannot keep this one out, so give it nothing beyond model invocation.

Credential method checks:

  • If you block the metadata endpoint for sessions (you should), instance-profile credentials will not reach Claude Code. File-based web identity (IRSA on EKS, a Workload Identity Federation credential file) does not need it.
  • Sessions can outlive credentials, so prefer a method that renews itself.
  • Credentials exported by your wrapper are set once per session and never renewed, though Claude Code will use AWS credentials it finds in its environment.

Start these runners with --confine-repo-settings enforce; run in warn first and clear what it reports.

Bedrock (use your own region):

export CLAUDE_CODE_USE_BEDROCK=1
export AWS_REGION=eu-west-2

Agent Platform (use your own project):

export CLAUDE_CODE_USE_VERTEX=1
export CLOUD_ML_REGION=global
export ANTHROPIC_VERTEX_PROJECT_ID=acme-claude-prod

To check, ask Claude in a session to run env | grep -E 'CLAUDE_CODE_USE_(BEDROCK|VERTEX)'. A 1 means the setting arrived (if both are set, Bedrock wins); no output means neither did. That proves configuration, not traffic, so also look for the requests in your cloud account's logs or metrics.

How these sessions differ

  • No claude.ai policy. Server-managed settings and Owner policies from admin settings do not reach the session. Put rules you rely on in the image's managed settings file.
  • No attachments. Files attached in claude.ai, mobile or desktop do not arrive, and the SendUserFile tool cannot return files. Put inputs in the repo or on the runner.
  • Model choice. The control plane sends each session's model; without one, Claude Code uses the provider default. The runner strips ANTHROPIC_MODEL and ANTHROPIC_DEFAULT_MODEL from session environments, so setting them on the runner does nothing. The per-family pinning variables from the Bedrock and Agent Platform pages do reach sessions and control what aliases such as opus resolve to.
  • Unserved models fail. Enable every model developers can pick, the background model, and the classifier model auto mode uses, and allow them all in your Bedrock policy.
  • Missing features. Web search is unavailable on Bedrock and fast mode on both. See feature availability for the rest.

MCP servers

The simplest way to give every session an MCP server is to add it at user scope when you build the image. On a bare host, run the same command as the runner's user and restart the runner, which reads host config once at startup. --scope user is required; the default local scope is keyed per directory and is not seeded into sessions.

RUN claude mcp add --scope user jira-internal -- /opt/mcp/jira-server --base-url https://jira.internal.acme.example
RUN claude mcp add --scope user --transport http design-tokens http://design-tokens.mcp.svc.cluster.local:8080/mcp

At startup the runner snapshots the mcpServers key from the host's .claude.json (next to ~/.claude/, not inside it) and seeds only that key into each session; account state and project history are dropped. Entries with an unrecognised type are dropped with a startup warning. Setting SELF_HOSTED_RUNNER_HOST_CONFIG_DIR makes the runner read .claude.json from there instead, so an empty directory disables MCP seeding.

Other sources sessions load:

  • Managed MCP file: /etc/claude-code/managed-mcp.json on Linux, /Library/Application Support/ClaudeCode/managed-mcp.json on macOS. For locked-down fleets; it takes exclusive control (see managed MCP). When present, sessions skip MCP servers the control plane delivers, claude.ai connectors included, and note them on stderr (recorded at debug level). Before v2.1.229 such sessions exited with You cannot dynamically configure MCP servers when an enterprise MCP config is present.
  • managedMcpServers in managed settings (v2.1.259+): HTTP and SSE servers without exclusive control. See the settings reference.
  • <repo>/.mcp.json: project scope; auto-approved in cloud sessions.

When connector delivery is on for your organisation, claude.ai connectors reach interactively created sessions through api.anthropic.com. Programmatically created sessions, such as CLI dispatches, do not get them. settings.json has no mcpServers field; in managed settings use managedMcpServers. Set ENABLE_TOOL_SEARCH on the runner to control MCP tool search for all its sessions (values on the MCP page).

Turning off the built-in Claude Code Remote server

Cloud sessions get an MCP server called Claude Code Remote that lets Claude schedule routines, start and steer other cloud sessions, attach repositories and follow pull request activity. It registers under one of three names, and deny rules match names exactly, so deny all three:

{
  "permissions": {
    "deny": [
      "mcp__Claude_Code_Remote",
      "mcp__claude-code-remote",
      "mcp__bf7c680d-5fdc-5ef4-b4a0-abadb619bf0a"
    ]
  }
}

A server-level rule covers tools added later. To block just one tool, append __ and its name, for example mcp__Claude_Code_Remote__add_repo. To stop the server connecting at all, list the three names (without mcp__) as serverName entries under deniedMcpServers.

Put the rules in server-managed settings to apply them without touching runners, or in ~/.claude/settings.json on the runner (required for Bedrock and Agent Platform runners). Confirm by asking Claude in a session to list its MCP tools; denied ones vanish.

Prompt sessions to push their work

Anthropic-hosted sessions have a Stop hook that nudges Claude to commit and push; self-hosted runners do not install one. Without it, unfinished work stays on the runner's disk and the Create PR button in claude.ai/code stays greyed out until the branch exists remotely.

You can add your own via the host's ~/.claude/, which is seeded into every session. Register it in ~/.claude/settings.json:

{
  "hooks": {
    "Stop": [
      {
        "hooks": [
          { "type": "command", "timeout": 10, "command": "\"$CLAUDE_CONFIG_DIR/hooks/nudge-push.sh\"" }
        ]
      }
    ]
  }
}

And save this as ~/.claude/hooks/nudge-push.sh (executable):

#!/bin/sh
payload=$(cat)
# Only nudge once per turn.
printf '%s' "$payload" | jq -e '.stop_hook_active == true' >/dev/null 2>&1 && exit 0

dir="$CLAUDE_PROJECT_DIR"
git -C "$dir" rev-parse --is-inside-work-tree >/dev/null 2>&1 || exit 0
[ -n "$(git -C "$dir" remote)" ] || exit 0

if [ -n "$(git -C "$dir" status --porcelain -- . ':(exclude).claude/')" ]; then
  jq -nc '{decision:"block", reason:"You have uncommitted changes. Commit them and push the branch before finishing."}'
  exit 0
fi

ref=""
git -C "$dir" rev-parse -q --verify FETCH_HEAD >/dev/null && ref=FETCH_HEAD
[ -z "$ref" ] && [ -z "$(git -C "$dir" for-each-ref --count=1 refs/remotes/origin)" ] && exit 0
ahead=$(git -C "$dir" rev-list --count HEAD --not $ref --remotes=origin 2>/dev/null || echo 0)
if [ "$ahead" -gt 0 ]; then
  jq -nc --arg n "$ahead" '{decision:"block", reason:("There are " + $n + " unpushed commits. Push them to the remote before finishing.")}'
fi
exit 0

It exits quietly when stop_hook_active is set (so it nudges once), when the directory is not a repository or has no remote, and when there is no fetch reference to compare against. It ignores .claude/ because seeded settings and runtime state live there. Building the JSON with jq avoids injection through branch names. It needs jq in the image.

Permissions and tool approval

A self-hosted session has no terminal, so an unanswered permission prompt stalls the turn until someone responds in the UI. The control plane sends each session's tool list and rules; the default pre-approves routine calls including Bash, and cloud sessions pre-approve file edits in every mode (see permission modes).

Warning: Only pin auto mode in an environment with default-deny egress and the rest of the hardening checklist in place. Routine calls, including Bash network requests, run without a human either way, so the network boundary is your real limit.

To minimise prompts regardless of what the server sends, pin auto mode from the wrapper or command hook. The runner puts server flags in "$@", and single-value flags take the last occurrence, so anything after "$@" wins:

#!/bin/bash
exec "$CLAUDE_RUNNER_CLAUDE_BIN" "$@" --permission-mode auto

In auto mode a classifier model reviews actions and blocks the ones it rejects, while explicit ask rules still prompt. To approve specific tools instead, append --allowed-tools, for example --allowed-tools "Bash(make *) Bash(pnpm *) mcp__jira-internal__*". List flags such as --allowed-tools and --disallowed-tools accumulate, so they add to the server's rules; use --disallowed-tools to narrow, since it denies even where something else allows.

How each session's config is assembled

  • User level: at startup the runner snapshots the host's ~/.claude/ (settings.json, CLAUDE.md, hooks, agents, commands, skills) and seeds it into each session's config directory. Changes take effect only after a runner restart. SELF_HOSTED_RUNNER_HOST_CONFIG_DIR picks a different source; an empty directory disables seeding. The projects/ directory is not included.
  • Project level: the repository's committed .claude/settings.json layers on top.
  • Managed: sessions read managed-settings.json from the standard system path in the image. If your organisation delivers any server-managed keys, sessions ignore the image's file except for the keys read from every admin source (such as env, the sandbox locks, the sandbox binary paths and forceRemoteSettingsRefresh). See managed settings and settings precedence.
  • Control-plane hooks (v2.1.229+): Claude Code hooks supplied by the control plane are written to a reserved hooks/.ccr-launcher/ subdirectory, recreated per session, and registered through a separate file passed with --settings, so your seeded settings and scripts are untouched. Their content comes from fixed constants in Anthropic's deployment. They join the normal merged hook configuration (not the managed tier), so disableAllHooks disables them and allowManagedHooksOnly does not keep them. Host content at ~/.claude/hooks/.ccr-launcher/ is not seeded.
  • Auto memory is off by default in self-hosted sessions other than Claude Tag sessions. Use the image's or repository's CLAUDE.md for durable instructions; memory files placed under projects/ are not seeded and do not switch it on.

Rules committed to a repository

Do not commit a bare "Edit", "Write" or "NotebookEdit" in permissions.allow. Without a path it matches anywhere on the host, so the repo-settings guard flags it, and with --confine-repo-settings enforce the session is refused. Repositories need no file rule anyway, because cloud sessions pre-approve edits. If you must have one, scope it to the workspace with "Edit(/**)" (a single leading slash is relative to the project root). Bare rules are fine in the operator's host-level settings.json.

A defaultMode of auto is honoured only from image-wide or user-level settings, so a repository cannot grant itself auto mode. Rule syntax is on the permissions page.