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 asCLAUDE_RUNNER_POOL_ID. Flags and the newer environment variables sayenvironment, such as--environment-secret-file. They refer to the same thing.
Choosing an extension point
| You want to | Use |
|---|---|
| Give each session short-lived credentials, toolchain setup or resource limits | A wrapper script via --exec-path, or the command hook |
| Clone from a mirror, seed from an archive, or use a non-git source | The checkout hook |
| Save uncommitted work or emit an event when a session ends | The post-session hook |
| Start one runner per session instead of a fixed fleet | The orchestrator and spawn-runner hook |
| Bill model usage to your own AWS or Google Cloud account | Bedrock or Agent Platform |
| Give every session the same MCP servers | Seed MCP config in the image |
| Reduce permission prompts | Pin 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
| Variable | What it holds and how to use it |
|---|---|
CLAUDE_RUNNER_CLAUDE_BIN | Absolute 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_TOKEN | The 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_EMAIL | Creator'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_PLATFORM | Where 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_ID | Session 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_UUID | The same ID as a plain UUID |
CLAUDE_SESSION_INGRESS_TOKEN_FILE | Path 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_DIR | The 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_URL | The 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_TOKEN | Short-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, notjq -r, whenever a claim gates access. A missing claim then fails the command instead of passing the stringnullalong. - Key on
act.sub, the stable user ID. Bot and agent sessions carry anagent:subject, so this script refuses them; decide deliberately whether those get a fallback credential. - If you need the email, read
.act.emailand 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 file | Runs | Replaces |
|---|---|---|
checkout | Once per repository, before the session starts | The built-in clone and fetch |
command | Once per session, after checkout | The built-in child spawn |
post-session | Once per session, after the child exits | Nothing (there is no default) |
spawn-runner | On the orchestrator, once per spawn request | Not applicable to ordinary runners |
The checkout hook
Use it to clone from a read-through mirror, unpack a tarball, or authenticate per session. Environment:
| Variable | Meaning |
|---|---|
CLAUDE_RUNNER_REPO_URL | URL to clone, after --git-host-rewrite and --git-ssh-rewrite |
CLAUDE_RUNNER_REPO_REF | Branch, tag or SHA requested. Empty means the default branch |
CLAUDE_RUNNER_CHECKOUT_PATH | Where the working tree must end up |
CLAUDE_RUNNER_SESSION_ID | Session ID as session_... |
CLAUDE_RUNNER_SESSION_UUID | Same, as a UUID |
CLAUDE_RUNNER_API_BASE_URL | API base URL for session-scoped calls |
CLAUDE_RUNNER_CLIENT_PLATFORM | Starting surface; may be unset |
CLAUDE_CODE_SESSION_ACCESS_TOKEN | The session JWT |
GIT_CONFIG_COUNT, GIT_CONFIG_KEY_n, GIT_CONFIG_VALUE_n | Git 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 aSkippedstep, 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.
| Variable | Meaning |
|---|---|
CLAUDE_RUNNER_SESSION_ID / CLAUDE_RUNNER_SESSION_UUID | Session identifiers |
CLAUDE_RUNNER_EXIT_REASON | How it ended (see below) |
CLAUDE_RUNNER_WORKSPACE_PATHS | Colon-separated working tree paths. Empty when there were no repositories |
CLAUDE_RUNNER_DEBUG_LOG_PATH | The session's debug log, still on disk |
CLAUDE_RUNNER_API_BASE_URL | API base URL |
CLAUDE_RUNNER_CLIENT_PLATFORM | Starting surface (v2.1.229+), may be unset |
CLAUDE_CODE_SESSION_ACCESS_TOKEN | The session JWT |
GIT_CONFIG_COUNT and pairs | Pinned 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.
| Setting | Value in hooks | How to change it |
|---|---|---|
core.hooksPath | /dev/null, so repository and ~/.gitconfig hook directories are ignored | Export 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.fsmonitor | Empty | git -c inside the hook |
GIT_ALLOW_PROTOCOL | https: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.askPass | Ignored from config files | Set 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.program | Paths the runner sets | Not from config files |
| Commit signing | Signed as the session with --configure-git; otherwise commit.gpgsign and tag.gpgsign are false | git -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).
| Variable | Meaning |
|---|---|
CLAUDE_RUNNER_WORK_ORDER_FILE | Temp file with the signed work-order JWT. Deleted when the hook exits. Never log it |
CLAUDE_RUNNER_ORDER_ID | Unique per spawn request and safe in Kubernetes names. Your only dedup key |
CLAUDE_RUNNER_SESSION_ID / CLAUDE_RUNNER_SESSION_UUID | The session. Repeats across re-requests, so use for logging and routing only. Empty for pre-warming requests (--min-idle) |
CLAUDE_RUNNER_ATTEMPT | Spawn requests so far for this session; 0 for pre-warming |
CLAUDE_RUNNER_ORDER_SERVER_TIME | Server 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_ID | Environment ID (ccpool_...) |
CLAUDE_RUNNER_ACCOUNT_ID | Tagged 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_EMAIL | That account's email; personal data, do not log |
CLAUDE_RUNNER_PRIMARY_REPO_URL / CLAUDE_RUNNER_PRIMARY_REPO_REVISION | First git source and its revision, handy for routing to a pre-warmed runner |
CLAUDE_RUNNER_REPO_SOURCES | JSON array of {url, revision} for every git source |
CLAUDE_RUNNER_CORRELATION_ID | Correlation ID supplied when the session was created, if any |
CLAUDE_RUNNER_CLIENT_PLATFORM | Starting 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:
- 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. - Never retry the workload yourself. If the runner does not register within
--expected-spawn-seconds, Anthropic re-requests with a new order ID. - Exit codes matter.
0submitted;1retryable, the session backs off and is re-offered;2or 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. - Set
--expected-spawn-secondsto 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:
- Prepare the account and egress. For Bedrock, submit the use case details and create a policy limiting
bedrock:InvokeModelandbedrock:InvokeModelWithResponseStreamto the inference profiles you use and their underlying models. For Agent Platform, enable the API, request model access, and create a custom role with onlyaiplatform.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. - Give sessions narrowly scoped credentials. Attach the policy or role to an identity that can do nothing else.
- Set one provider's variables on the runner (container spec, service unit, or the workload your
spawn-runnerhook creates), then restart. - 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
SendUserFiletool 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_MODELandANTHROPIC_DEFAULT_MODELfrom 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 asopusresolve 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.jsonon Linux,/Library/Application Support/ClaudeCode/managed-mcp.jsonon 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 atdebuglevel). Before v2.1.229 such sessions exited withYou cannot dynamically configure MCP servers when an enterprise MCP config is present. managedMcpServersin 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
Bashnetwork 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_DIRpicks a different source; an empty directory disables seeding. Theprojects/directory is not included. - Project level: the repository's committed
.claude/settings.jsonlayers on top. - Managed: sessions read
managed-settings.jsonfrom 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 asenv, the sandbox locks, the sandbox binary paths andforceRemoteSettingsRefresh). 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), sodisableAllHooksdisables them andallowManagedHooksOnlydoes 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.mdfor durable instructions; memory files placed underprojects/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.