1> ## Documentation Index
2> Fetch the complete documentation index at: https://code.claude.com/docs/llms.txt
3> Use this file to discover all available pages before exploring further.
4
5# Self-hosted environments reference
6
7> Complete reference for the self-hosted runner and orchestrator: CLI flags, environment variables, and Prometheus metrics.
8
9<Note>
10 Self-hosted environments are in public beta on Team and Enterprise plans; an [Owner or admin](/docs/en/cloud-environments#organization-shared-environments) enables them by turning on **Allow self-hosted environments** on the [**Cloud environments** admin page](https://claude.ai/admin-settings/cloud-environments). This page is the flag and metric reference; see the [quickstart](/docs/en/self-hosted-environments-quickstart) for setup and [Deploy to production](/docs/en/self-hosted-environments-deploy) for the fleet recipes.
11</Note>
12
13This page is the reference for the two processes you run in a [self-hosted environment](/docs/en/self-hosted-environments): the runner, which executes Claude Code [cloud sessions](/docs/en/claude-code-on-the-web) on your hosts, and the optional autoscaling orchestrator, which starts runners as sessions queue. Each has its own flag table. Both run on Linux or macOS hosts, which the defaults such as `/workspace` and `~/.claude` assume. Run `claude self-hosted-runner --help` for the authoritative list on your installed version.
14
15Metric series and a few API fields still use `pool` for what these pages call an environment; both terms name the same thing. The environment ID is the `pool_id` field, with the form `ccpool_...`: wherever these pages show a `pool` identifier, it names the environment. CLI flags and environment variables spell it `environment`, such as `--environment-secret-file`; the deprecated `pool` spellings still work, as the [`--environment-secret-file` row](#runner-cli-flags) describes.
16
17## Runner CLI flags
18
19Most flags have a corresponding environment variable. When both are set, the flag takes precedence. Duration flags take minutes or seconds on the CLI, but the paired environment variable is always in milliseconds, indicated by the `_MS` suffix, and the Default column shows the flag's unit: `--exit-if-unused-min 10` is equivalent to `SELF_HOSTED_RUNNER_IDLE_SHUTDOWN_MS=600000`, and a Helm value like `SELF_HOSTED_RUNNER_STARTUP_TIMEOUT_MS: "15"` means 15 milliseconds, not the 15-minute default.
20
21| Flag | Env var | Default | Description |
22| :------------------------------------ | :------------------------------------------------ | :-------------------------- | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
23| `--api-url <url>` | none | `https://api.anthropic.com` | API base URL. Override only for testing. |
24| `--base-dir <path>` | `SELF_HOSTED_RUNNER_BASE_DIR` | `/workspace` | Directory for repository checkouts and per-session working directories. The runner creates it and its subdirectories on first use, so the user running the runner needs write access to this path or its parent. Use the same value on every runner in an environment; see [Keep the base directory and capacity identical across runners](/docs/en/self-hosted-environments-deploy#keep-the-base-directory-and-capacity-identical-across-runners). |
25| `--capacity <n>` | none | `1` | Maximum concurrent sessions this runner handles. All sessions belong to the same locked account. Use the same value on every runner in an environment; see [Keep the base directory and capacity identical across runners](/docs/en/self-hosted-environments-deploy#keep-the-base-directory-and-capacity-identical-across-runners). |
26| `--configure-git` | `SELF_HOSTED_RUNNER_CONFIGURE_GIT=1` | off | Write global git identity and enable Anthropic commit signing at startup. See [Configure git](/docs/en/self-hosted-environments-deploy#configure-git). |
27| `--confine-repo-settings <mode>` | `SELF_HOSTED_RUNNER_CONFINE_REPO_SETTINGS` | `warn` | Warn without refusing (`warn`, the default), enforce (`enforce`), or disable (`off`) the guard that flags a session when a repository's committed settings try to grant write or read access outside that session's own workspace, set environment variables, or override the operator's sandbox or hooks posture, such as `sandbox.enabled: false` or `disableAllHooks`. See [Harden your deployment](/docs/en/self-hosted-environments-deploy#harden-your-deployment). |
28| `--debug-token-dir <path>` | `SELF_HOSTED_RUNNER_DEBUG_TOKEN_DIR` | unset | Write live tokens to disk for inspection. Debug only; don't use in production. |
29| `--drain-grace-sec <n>` | `SELF_HOSTED_RUNNER_DRAIN_GRACE_MS` | `0` | Controls when the runner exits after its active sessions finish: `0` exits immediately without polling for more, and a positive value keeps the runner alive and re-polling the locked account's queue for that many seconds first, at the cost of the per-session container isolation described in the [hardening section](/docs/en/self-hosted-environments-deploy#harden-your-deployment) |
30| `--drain-wait-sec <n>` | `SELF_HOSTED_RUNNER_DRAIN_WAIT_MS` | `0` | On `SIGTERM`, wait up to N seconds for each session's in-flight turn and background tasks to finish before terminating the child |
31| `--environment-secret-file <path>` | `SELF_HOSTED_RUNNER_ENVIRONMENT_SECRET` | required | Path to a file containing the environment secret, or, for runners spawned by the [orchestrator](/docs/en/self-hosted-environments-configuration#on-demand-runners), the single-use work-order JWT. `SELF_HOSTED_RUNNER_ENVIRONMENT_SECRET` carries the secret value directly, not a file path. The older `--pool-secret-file` flag and `SELF_HOSTED_RUNNER_POOL_SECRET` variable still work and print a deprecation notice to stderr; preview-program runner builds older than 2.1.216 only recognize those older names. |
32| `--exec-path <path>` | `SELF_HOSTED_RUNNER_EXEC_PATH` | own binary | Binary or wrapper script to spawn for each session. See [Wrapper scripts](/docs/en/self-hosted-environments-configuration#wrapper-scripts). |
33| `--exit-if-unused-min <n>` | `SELF_HOSTED_RUNNER_IDLE_SHUTDOWN_MS` | `0` | Exit after N minutes of polling with no work ever assigned, for autoscaler scale-down. `0` disables. |
34| `--git-host-rewrite <from>=<to>` | none | unset | Rewrite `https://<from>/...` source URLs to `https://<to>/...` before cloning, for split-horizon DNS. Repeatable; flag only. |
35| `--git-ssh-rewrite <host>` | none | unset | Rewrite `https://<host>/...` source URLs to `git@<host>:...` before cloning, for SSH-only git hosts. Repeatable; flag only. |
36| `--health-port <port>` | `SELF_HOSTED_RUNNER_HEALTH_PORT` | `8080` | Port for the `/healthz` and `/metrics` listener. Set `0` to disable. |
37| `--hooks-dir <path>` | `SELF_HOSTED_RUNNER_HOOKS_DIR` | unset | Directory of lifecycle hook scripts. See [Lifecycle hooks](/docs/en/self-hosted-environments-configuration#lifecycle-hooks). |
38| `--kill-session-after-min <n>` | `SELF_HOSTED_RUNNER_MAX_LIFETIME_MS` | `0` | Terminate a session child once it has lived N minutes wall-clock, as a safety limit for stuck sessions. A kill that falls mid-turn is deferred until the turn finishes, bounded by a grace window. `0` disables. |
39| `--lock-to-account <id>` | `SELF_HOSTED_RUNNER_LOCK_TO_ACCOUNT` | unset | Pre-lock the runner to a specific account at startup instead of locking on first session. Accepts an email address or `user_...` ID in the environment's organization. |
40| `--log-file <path>` | `SELF_HOSTED_RUNNER_LOG_FILE` | unset | Mirror runner logs to a file in addition to stdout and stderr, created with `0600` permissions. Required for `self-hosted-runner doctor` to tail logs locally. |
41| `--log-level <level>` | none | `info` | `info` or `debug` |
42| `--post-session-hook-timeout-sec <n>` | `SELF_HOSTED_RUNNER_POST_SESSION_HOOK_TIMEOUT_MS` | `60` | Budget for the [`post-session` hook](/docs/en/self-hosted-environments-configuration#post-session) on every session end, including runner shutdown |
43| `--push-outcome-on-release` | `SELF_HOSTED_RUNNER_PUSH_OUTCOME_ON_RELEASE` | off | On a runner-initiated session end such as a drain or idle release, push tracked outcome branches to `origin` before deleting the workspace, so in-flight commits survive a restart. Best-effort; adds 30 seconds to the shutdown budget, and requires git 2.29 or newer to resume from the pushed branch. Restrict push access to `claude/*` refs before enabling; see [Resumed sessions lose unpushed work](/docs/en/self-hosted-environments-deploy#additional-limitations). Repositories checked out via a `checkout` lifecycle hook aren't pushed; snapshot those from the [`post-session` hook](/docs/en/self-hosted-environments-configuration#post-session) instead. |
44| `--release-idle-session-min <n>` | `SELF_HOSTED_RUNNER_SESSION_IDLE_MS` | `0` | Release a session slot after N minutes of inactivity once a turn finishes or the session waits for the user's action. A session that's still mid-turn, including one holding a never-finishing background task or an approval requested from inside a running tool call, doesn't count as idle; pair with `--kill-session-after-min` as the hard backstop. A release that leaves the runner with no active sessions starts the same exit path as a normal drain, governed by `--drain-grace-sec`. `0` disables. |
45| `--retire-at <epoch-seconds>` | `SELF_HOSTED_RUNNER_RETIRE_AT` | unset | Retire the runner at an absolute Unix timestamp in seconds, for infrastructure that kills the runner at a known time; [Runner lifecycle](/docs/en/self-hosted-environments#runner-lifecycle) describes the release sequence and how to size the margin. Values before 2001 or after the year 5138 are rejected by the flag and ignored by the environment variable. |
46| `--session-stop-grace-sec <n>` | `SELF_HOSTED_RUNNER_SESSION_STOP_GRACE_MS` | `5` | How long to wait for the Claude process to exit cleanly after a session ends, before force-killing it. Raise the value if the child's own `SessionEnd` hooks need more time. |
47| `--startup-timeout-min <n>` | `SELF_HOSTED_RUNNER_STARTUP_TIMEOUT_MS` | `15` | Release a session slot if the child hasn't signaled that it initialized within N minutes of spawn. Cleared by the child's init signal on the [activity channel](/docs/en/self-hosted-environments-configuration#keep-stdin-and-file-descriptor-3-attached), not by ordinary output, after which `--release-idle-session-min` takes over. `0` disables. |
48| `--trust-workspace [bool]` | `SELF_HOSTED_RUNNER_TRUST_WORKSPACE` | on | Seed persisted trust for each session's repository paths so repo-committed `permissions.allow` and `additionalDirectories` are honored. Set `false` to drop repo-committed permission grants and configure allow rules in the host config's `settings.json` instead; repository-committed `sandbox.*` settings still apply either way, which is why the [repo-settings guard](/docs/en/self-hosted-environments-deploy#harden-your-deployment) scans them regardless of this flag. |
49| `--use-anthropic-git-proxy` | `CLAUDE_RUNNER_USE_GIT_PROXY=1` | off | Clone via Anthropic's git proxy instead of customer-managed git auth. Requires `--capacity 1` and git 2.32 or newer; the runner refuses to start otherwise. Supersedes the rewrite flags. |
50
51Most duration flags have a maximum, chosen to keep each timeout inside the runtime's 32-bit timer ceiling of roughly 24.85 days. The `--*-min` flags cap at 10080 minutes, 7 days; `--drain-grace-sec` at 604800 seconds, also 7 days; and `--drain-wait-sec` at 86400 seconds, 24 hours. `--session-stop-grace-sec` and `--post-session-hook-timeout-sec` are uncapped. Overrunning a cap behaves differently per surface:
52
53* **Flag**: startup fails with an error.
54* **Environment variable**: the runner clamps the value to the timer ceiling rather than rejecting it.
55
56## Orchestrator CLI flags
57
58The `self-hosted-runner orchestrator` subcommand, which spawns [on-demand runners](/docs/en/self-hosted-environments-configuration#on-demand-runners), accepts `--api-url`, `--environment-secret-file`, `--hooks-dir`, `--health-port`, and `--log-level` with the same defaults as the runner and, where the runner's flag has one, the same environment variable, except that `--hooks-dir` is required and must contain a `spawn-runner` hook. It also takes its own flags:
59
60| Flag | Default | Description |
61| :------------------------------- | :------ | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
62| `--hook-concurrency <n>` | `4` | Maximum `spawn-runner` hooks running in parallel. Also caps how many spawn requests are claimed per poll. |
63| `--hook-timeout <sec>` | `60` | Terminate the hook's process tree after this many seconds. The timeout plus its 5-second kill grace must stay below `--expected-spawn-seconds`; the orchestrator enforces this at startup. |
64| `--expected-spawn-seconds <sec>` | `120` | Expected p99 boot time for spawned runners, in the server-enforced range 10 to 3600. Sent on every poll as the server-side lease; if no runner registers before it elapses, the session is re-offered with a fresh order ID. All replicas must share this value. |
65| `--min-idle <n>` | `0` | Keep at least N idle session slots free by spawning standby runners proactively. `0` disables pre-warming. Pair with the runner's `--exit-if-unused-min` so surplus standby runners reclaim themselves. |
66| `--debug-dir <path>` | unset | Write each spawn request's work order and hook stderr to disk. Debug only; never set in production. |
67
68### SCM connector flags
69
70The orchestrator can hold a standing WebSocket connection to Anthropic's control plane so that hosted pre-session flows, such as the repository picker and the branch or ref resolver, can reach a GitHub Enterprise Server host that's only routable from inside your network. The connector stays off unless you set `--scm-connector-host`.
71
72| Flag | Default | Description |
73| :------------------------------------------------------ | :----------------------------------- | :---------------------------------------------------------------------------------------------------------------------------------------------------------- |
74| `--scm-connector-host <host[:port]>` | unset | GitHub Enterprise Server hostname to forward requests to. Port defaults to `443`. Setting this flag enables the connector. |
75| `--scm-connector-id <n>` | required with `--scm-connector-host` | The numeric ID of your organization's GitHub Enterprise Server connection. Contact your Anthropic account team for the value when you enable the connector. |
76| `--scm-connector-provider <slug>` | `ghe` | Path segment identifying the provider, matching `^[a-z0-9-]{1,32}$`. |
77| `--scm-connector-ca-file <path>` | unset | Extra CA bundle, in PEM format, for TLS connections to the GitHub Enterprise Server host. |
78| `--scm-connector-host-rewrite <from>=<to_host:to_port>` | unset | For end-to-end testing only: redirects the TCP connection while keeping the Host header and TLS SNI as `--scm-connector-host`. |
79
80The connector authenticates with the orchestrator's existing environment secret and reconnects automatically: with exponential backoff on a dropped connection, or a fixed 30-second delay when the control plane closes the connection because another orchestrator replica already holds it.
81
82## Environment-variable-only settings
83
84These runner settings are read from the environment only and cover behavior most deployments leave at the default:
85
86| Env var | Default | Description |
87| :----------------------------------------- | :---------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
88| `SELF_HOSTED_RUNNER_HOST_CONFIG_DIR` | `~/.claude` | Directory captured into the runner's startup snapshot and seeded into each session's `CLAUDE_CONFIG_DIR`; changes on disk apply after a runner restart. Setting the variable also moves where the runner reads `.claude.json` for [MCP seeding](/docs/en/self-hosted-environments-configuration#mcp-servers), so setting it, including to its own default, relocates that lookup; point at an empty directory to disable seeding entirely. |
89| `SELF_HOSTED_RUNNER_MAX_LIFETIME_GRACE_MS` | `900000` | Bounds how long a `--kill-session-after-min` kill is deferred while waiting for an in-flight turn to finish |
90| `SELF_HOSTED_RUNNER_SIGKILL_GRACE_MS` | `30000` | How long the runner waits for the OS to deliver `SIGKILL` to a child stuck in uninterruptible I/O before exiting itself. Floored at `--post-session-hook-timeout-sec` plus 15 seconds, and 30 more when `--push-outcome-on-release` is set, so the effective minimum is 75 seconds at defaults. |
91| `CLAUDE_RUNNER_FETCH_DEPTH` | `50` | Git fetch depth for fresh clones. Set a positive integer, or `full` or `0` for a complete fetch. Repositories already present in the workspace keep their existing depth. |
92| `CLAUDE_RUNNER_SKIP_GIT_VERIFY` | unset | When `1`, skip the `.git` presence check after a `checkout` hook runs. Set this when your hook materializes a non-git source. |
93| `FORCE_AUTOUPDATE_PLUGINS` | unset | When `1`, let plugin marketplaces auto-update even though the binary is pinned |
94| `CLAUDE_CODE_DISABLE_ARTIFACT` | unset | When `1`, disable the Artifact tool in sessions regardless of the organization's admin setting, and drop the `*.frame.claudeusercontent.com` egress requirement |
95
96## Telemetry
97
98Session children send operational telemetry to Anthropic unless you turn it off. No code or repository content is sent. Set telemetry variables on the runner process; the runner re-asserts them after applying server-provided environment variables, so the operator's setting always takes precedence.
99
100One control is specific to self-hosted environments: `CLAUDE_CODE_BYOC_ENABLE_DATADOG=1` opts in to Datadog operational metrics, which are off by default in self-hosted environments. The general Claude Code telemetry controls, `DISABLE_TELEMETRY`, `DO_NOT_TRACK`, `DISABLE_ERROR_REPORTING`, and `CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC`, apply to session children as documented in the [environment variable reference](/docs/en/env-vars). `DISABLE_GROWTHBOOK` is related but different: setting `DISABLE_GROWTHBOOK=1` disables feature-flag fetching, and telemetry stays on unless `DISABLE_TELEMETRY` is also set.
101
102`CLAUDE_CODE_ENABLE_TELEMETRY` is unrelated: it enables OpenTelemetry export to your own collector, as described in [Monitoring](/docs/en/monitoring-usage), and doesn't control Anthropic's analytics.
103
104## Health endpoint
105
106The runner serves `GET /healthz` on the configured health port. The response is `200 OK` whenever the process is alive, whatever state the poll loop is in, so an HTTP probe on this endpoint detects a dead process only. The JSON body describes current state:
107
108```json theme={null}
109{
110 "status": "ok",
111 "runner_id": "ccrunner_...",
112 "active_sessions": 2,
113 "last_poll_at": "2026-03-31T18:04:11.220Z",
114 "last_poll_age_ms": 842
115}
116```
117
118Use `last_poll_age_ms` as a liveness signal in custom probes; a value that grows unbounded indicates the poll loop is stuck. Both `last_poll_at` and `last_poll_age_ms` are `null` until the first poll completes.
119
120The orchestrator serves its own `/healthz` on its health port. Its endpoint always returns `200`, and the body carries a `connected` field reporting whether the most recent poll succeeded, plus per-state spawn-queue counts in `queue_counts`. Gate readiness and alerting on `connected` rather than the status code.
121
122When the [SCM connector](#scm-connector-flags) is configured, the orchestrator's `/healthz` body also carries `scm_connector_connected` and a `scm_connector` object with `connected`, `last_connected_at`, `last_error`, `reconnects`, and `requests_forwarded`. Both fields are `null` when `--scm-connector-host` isn't set.
123
124## Prometheus metrics
125
126Each runner serves Prometheus metrics at `GET /metrics` on the same port as `/healthz`. Key series:
127
128| Series | Notes |
129| :-------------------------------------------------------------------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
130| `claude_code_self_hosted_runner_info{runner_id,version,client_label}` | Always `1`; useful for fleet inventory and version-drift detection |
131| `claude_code_self_hosted_runner_capacity` | Configured `--capacity` |
132| `claude_code_self_hosted_runner_active_sessions` | Sessions currently running |
133| `claude_code_self_hosted_runner_locked_account{email}` | Present once the runner has locked to a user. The label value is the account email, and the runner always emits it once locked; if your metrics store is broadly readable, drop or hash the label at scrape time, for example with Prometheus `metric_relabel_configs`. |
134| `claude_code_self_hosted_runner_last_poll_age_seconds` | Seconds since the last successful poll. Alert if over 60. |
135| `claude_code_self_hosted_runner_poll_errors_total{error_kind}` | Cumulative PollWork failures by kind: `transport`, `timeout`, `5xx`, `429`, or `4xx`. All five series are present from process start; alert on `rate(...[5m]) > 0`. |
136| `claude_code_self_hosted_runner_sessions_started_total{client_platform}` | Session child processes spawned over the runner's lifetime, one series per session origin such as `web_claude_ai`, `ios`, `android`, `desktop_app`, or `claude-code-cli`, or `unknown` when the server didn't send one. The Slack values `claude_in_slack` and `claude-in-slack` are reserved: [Claude Tag](https://claude.com/docs/claude-tag/overview) sessions can't route to self-hosted environments yet, so neither value appears until that support ships. Use `sum()` for the fleet total. |
137| `claude_code_self_hosted_runner_sessions_completed_total{client_platform}` | Sessions that ended cleanly, labeled the same way. Broader than a plain clean exit: see [session lifecycle counter semantics](#session-lifecycle-counter-semantics) for what counts. |
138| `claude_code_self_hosted_runner_sessions_failed_total{client_platform}` | Sessions that ended in failure, labeled the same way. Same caveat: see [session lifecycle counter semantics](#session-lifecycle-counter-semantics). |
139| `claude_code_self_hosted_runner_sessions_interrupted_total{client_platform}` | Sessions the runner terminated for an operational reason rather than a session outcome, labeled the same way. See [session lifecycle counter semantics](#session-lifecycle-counter-semantics). |
140| `claude_code_self_hosted_runner_initializing_sessions` | Sessions currently in the init phase, from assignment until the child's init event |
141| `claude_code_self_hosted_runner_session_init_duration_seconds` | Histogram of session init durations |
142| `claude_code_self_hosted_runner_session_init_errors_total` | Sessions that failed before reaching init: a checkout hook failure, git prep, token issue, or a pre-init child crash |
143| `claude_code_self_hosted_runner_session_start_hook_errors_total` | `SessionStart` hooks that reported an error outcome, one per failing hook execution |
144| `claude_code_self_hosted_runner_session_idle_seconds{session_id,client_platform}` | Per-session gauge of seconds since the session went idle. Useful for terminating sessions stuck on an unanswered permission prompt. |
145
146The orchestrator serves its own series at `GET /metrics` on the same port as its `/healthz`:
147
148| Series | Notes |
149| :-------------------------------------------------------------------------------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
150| `claude_code_self_hosted_orchestrator_info{version,pool_id,orchestrator_uuid,hostname}` | Always `1` |
151| `claude_code_self_hosted_orchestrator_connected` | `1` when the most recent poll succeeded; drops to `0` after any failed poll, whatever the failure kind |
152| `claude_code_self_hosted_orchestrator_last_poll_age_seconds` | Seconds since the last poll attempt, success or failure, unlike the runner's identically-named metric, which measures since the last success; pair with `connected` to catch failing polls. The orchestrator's poll loop waits on hook execution, so alert above `--hook-timeout` plus a margin, around 90 seconds at defaults, rather than a flat 60. |
153| `claude_code_self_hosted_orchestrator_poll_errors_total{error_kind}` | Cumulative PollSpawnHints failures by kind: `transport`, `timeout`, `5xx`, `429`, or `4xx`. All five series are present from process start; alert on `rate(...[5m]) > 0`. |
154| `claude_code_self_hosted_orchestrator_queue_pending_sessions` | Spawn requests claimable right now |
155| `claude_code_self_hosted_orchestrator_queue_backing_off_sessions` | Spawn requests in retry backoff after a retryable hook failure |
156| `claude_code_self_hosted_orchestrator_queue_circuit_broken_sessions` | Spawn requests blocked until an Owner or admin retries them from the environment's **Activity** tab; alert if above zero |
157| `claude_code_self_hosted_orchestrator_pool_pending_sessions` | Total sessions waiting on a runner for this environment. Environment-wide aggregate, identical on every orchestrator instance: use `MAX` rather than `SUM` across instances. |
158| `claude_code_self_hosted_orchestrator_pool_active_sessions` | Sessions currently assigned to an alive runner in this environment. Environment-wide aggregate, identical on every orchestrator instance: use `MAX` rather than `SUM` across instances. |
159| `claude_code_self_hosted_orchestrator_spawn_hooks_total{result}` | Cumulative `spawn-runner` hook outcomes: `ok`, `retryable`, `non_retryable`. Counts orchestrator hook invocations, not session children the runners spawn: not comparable to `sessions_started_total`, since capacity above one, warm pools, and runners spawned again for the same session all diverge the two. |
160| `claude_code_self_hosted_orchestrator_spawn_hook_duration_seconds` | Histogram of hook durations |
161| `claude_code_self_hosted_orchestrator_warm_hints_dispatched_total` | Standby spawn requests dispatched since process start |
162| `claude_code_self_hosted_orchestrator_session_queue_wait_seconds` | Histogram of seconds each session waited in the queue before the orchestrator claimed it for spawn, recorded from the queue-wait timestamp the control plane sends with each session's spawn request. Use for p50/p99 queue-time alerting. Pre-warming spawns aren't sampled. |
163| `claude_code_self_hosted_orchestrator_clock_skew_seconds` | Local-minus-server clock skew; diagnostic, present once measured |
164| `claude_code_self_hosted_orchestrator_scm_connector_connected` | `1` when the [SCM connector](#scm-connector-flags)'s WebSocket is open; `0` while dialing or backing off. Absent when `--scm-connector-host` isn't set. |
165| `claude_code_self_hosted_orchestrator_scm_connector_requests_forwarded_total` | Cumulative HTTP requests proxied to the configured SCM host since process start. Absent when `--scm-connector-host` isn't set. |
166
167For autoscaling, pick the series that matches your scaling style and gate it before it feeds the scaler:
168
169* **Queue-depth scaling**: feed `claude_code_self_hosted_orchestrator_pool_pending_sessions` into your HPA or KEDA scaler, not `queue_pending_sessions`.
170* **Capacity scaling**: scale on the ratio of the runner's `active_sessions` to `capacity`.
171* **Gate on `connected`**: filter the query with `claude_code_self_hosted_orchestrator_connected == 1` per instance, so a disconnected replica's stale value doesn't feed the scaler.
172
173During a full poll outage, every replica disconnected, the gated query returns no data. HPA holds the current replica count on a missing metric, but KEDA's Prometheus scaler at its default `ignoreNullValues: "true"` reads the empty result as zero and scales in; set `ignoreNullValues: "false"` on the ScaledObject, optionally with a `fallback` replica floor.
174
175The following Prometheus Operator `PodMonitor` covers both processes. It selects pods by the `app.kubernetes.io/part-of: claude-code-self-hosted-runner` label and the named `health` port that the [Kubernetes recipe](/docs/en/self-hosted-environments-deploy#kubernetes) sets; adjust the namespaces to match your deployment:
176
177```yaml theme={null}
178# Example Prometheus Operator PodMonitor for the Claude Code self-hosted
179# runner + orchestrator. Adjust the namespace and label selectors to match
180# your deployment. Both the runner and the orchestrator serve /metrics on
181# their --health-port (default 8080).
182apiVersion: monitoring.coreos.com/v1
183kind: PodMonitor
184metadata:
185 name: claude-code-self-hosted-runner
186 namespace: monitoring
187spec:
188 namespaceSelector:
189 matchNames:
190 - claude-runners
191 selector:
192 matchExpressions:
193 # Matches the runner Deployment from the Kubernetes recipe, plus any
194 # on-demand runner Jobs and orchestrator pods you label the same way
195 # and give a named 'health' containerPort.
196 - key: app.kubernetes.io/part-of
197 operator: In
198 values: [claude-code-self-hosted-runner]
199 podMetricsEndpoints:
200 - port: health
201 path: /metrics
202 interval: 30s
203```
204
205These sample alert rules are a starting point; tune the thresholds for your fleet size:
206
207```yaml theme={null}
208# Example Prometheus alert rules for the Claude Code self-hosted runner
209# + orchestrator. Tune thresholds for your fleet size and SLOs.
210groups:
211 - name: claude-code-self-hosted-runner
212 rules:
213 - alert: ClaudeRunnerPollStale
214 expr: claude_code_self_hosted_runner_last_poll_age_seconds > 60
215 for: 2m
216 labels: {severity: warning}
217 annotations:
218 summary: "Runner {{ $labels.pod }} has not polled in >60s"
219 - alert: ClaudeRunnerVersionDrift
220 expr: count(count by (version) (claude_code_self_hosted_runner_info)) > 1
221 for: 30m
222 labels: {severity: info}
223 annotations:
224 summary: "Runners are running mixed versions"
225 - alert: ClaudeRunnerInitErrorsHigh
226 expr: increase(claude_code_self_hosted_runner_session_init_errors_total[10m]) > 3
227 for: 5m
228 labels: {severity: warning}
229 annotations:
230 summary: "Runner {{ $labels.pod }}: >3 session init failures in 10m (checkout hook / git / token / pre-init crash)"
231 - alert: ClaudeRunnerPollErrors
232 expr: sum by (pod) (rate(claude_code_self_hosted_runner_poll_errors_total[5m])) > 0
233 for: 2m
234 labels: {severity: warning}
235 annotations:
236 summary: "Runner {{ $labels.pod }}: PollWork failing ({{ $value | humanize }}/s over 5m)"
237 - alert: ClaudeRunnerSessionStartHookErrors
238 expr: increase(claude_code_self_hosted_runner_session_start_hook_errors_total[10m]) > 3
239 for: 5m
240 labels: {severity: warning}
241 annotations:
242 summary: "Runner {{ $labels.pod }}: >3 SessionStart hook failures in 10m"
243
244 - name: claude-code-self-hosted-orchestrator
245 rules:
246 - alert: ClaudeOrchestratorDisconnected
247 expr: claude_code_self_hosted_orchestrator_connected == 0
248 for: 2m
249 labels: {severity: critical}
250 annotations:
251 summary: "Orchestrator {{ $labels.pod }} cannot reach the Anthropic control plane"
252 - alert: ClaudeOrchestratorPollStale
253 expr: claude_code_self_hosted_orchestrator_last_poll_age_seconds > 90
254 for: 2m
255 labels: {severity: warning}
256 annotations:
257 summary: "Orchestrator {{ $labels.pod }} has not polled in >90s (poll loop waits on hook execution)"
258 - alert: ClaudeOrchestratorCircuitBroken
259 expr: claude_code_self_hosted_orchestrator_queue_circuit_broken_sessions > 0
260 for: 1m
261 labels: {severity: critical}
262 annotations:
263 summary: "{{ $value }} sessions circuit-broken — spawn-runner hook is repeatedly non-retryable; fix infra then retry from the Activity tab"
264 - alert: ClaudeOrchestratorPollErrors
265 expr: sum by (pod) (rate(claude_code_self_hosted_orchestrator_poll_errors_total[5m])) > 0
266 for: 2m
267 labels: {severity: warning}
268 annotations:
269 summary: "Orchestrator {{ $labels.pod }}: PollSpawnHints failing ({{ $value | humanize }}/s over 5m)"
270 - alert: ClaudeOrchestratorSpawnHookFailing
271 expr: sum by (pod) (increase(claude_code_self_hosted_orchestrator_spawn_hooks_total{result!="ok"}[5m])) > 3
272 for: 5m
273 labels: {severity: warning}
274 annotations:
275 summary: "Orchestrator {{ $labels.pod }}: >3 spawn-runner hook failures in 5m"
276```
277
278### Pass through session-child metrics
279
280Each session runs in its own child process with its own OpenTelemetry metrics; at `--capacity` above one, the runner rewrites how those child metrics are exposed. Setting `OTEL_METRICS_EXPORTER=prometheus` on the runner host and `CLAUDE_CODE_ENABLE_TELEMETRY=1` in the session's environment, for example from your [wrapper script](/docs/en/self-hosted-environments-configuration#wrapper-scripts) or the runner's own environment, which sessions inherit, re-exposes each child's counter and gauge instruments on the runner's own `/metrics` endpoint, alongside the runner's series. The runner rewrites the child's exporter to push over OTLP to a loopback-only receiver on the health port, tags each series with `session_id` and `client_platform` labels, and evicts a session's series when that session ends. Histograms don't pass through, and a child metric whose name would collide with the runner's own prefix is dropped.
281
282At the default `--capacity 1`, the rewrite doesn't apply: the session's child binds its own Prometheus endpoint on port 9464 as usual.
283
284### Session lifecycle counter semantics
285
286The `sessions_started_total`, `sessions_completed_total`, `sessions_failed_total`, and `sessions_interrupted_total` counters classify each session by how it ended. Every spawned session child increments `sessions_started_total` at spawn time, and exactly one of the other three increments at exit, so `sessions_started_total` minus the sum of the other three equals the number of session children currently running.
287
288* `completed`: the session ended cleanly. This covers the child exiting on its own with code `0`, the session being archived or deleted while the child was still connected, and the runner releasing the slot as a clean handoff: an idle release, a startup timeout, or a server-side deassign the poll loop noticed before the child exited. Increments `sessions_completed_total`.
289* `failed`: the child exited on its own with a non-zero code, either a crash or a setup failure after spawn. Increments `sessions_failed_total`.
290* `interrupted`: the runner terminated the child for an operational reason that's neither a session success nor a runner fault, such as a drain, for example a Kubernetes rolling restart sending `SIGTERM`, the max-lifetime watchdog `--kill-session-after-min`, or the `released=false` backstop: the runner terminates the child after the control plane declines three consecutive idle-release requests, each because a user message was still waiting to be processed. Increments `sessions_interrupted_total`.
291
292The [`post-session` hook](/docs/en/self-hosted-environments-configuration#post-session)'s `CLAUDE_RUNNER_EXIT_REASON` doesn't use this classification for clean handoffs. The hook reports an idle release, a startup timeout, and a server deassign as `interrupted`, since from the hook's perspective the runner killed the child, while the counters above record those same events as `completed`, since nothing went wrong and the slot was handed back cleanly. If you reconcile hook receipts against `sessions_completed_total` directly, you undercount completions. Use the hook for per-session guarantees and the counters for aggregate rates.
293
294On a one-shot environment, `--capacity 1` with the default `--drain-grace-sec 0`, each runner process exits moments after its one session ends. `sessions_completed_total`, `sessions_failed_total`, and `sessions_interrupted_total` increment only at session end, right before that exit, so a Prometheus scrape every 15 to 60 seconds rarely catches the increment before the runner's series disappears; these three end-of-session counters are the terminal counters the rest of this section refers to. `sessions_started_total` increments at spawn and stays visible for the life of the session, so it reliably shows up, but on a one-shot environment it reads closer to "sessions currently running" than a cumulative count.
295
296Use the series in this table for the corresponding goal instead of the terminal counters:
297
298| Goal | Use |
299| :---------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
300| Throughput | `claude_code_self_hosted_orchestrator_spawn_hooks_total{result="ok"}`, a counter on the long-lived orchestrator that increments once per successful `spawn-runner` hook and stays meaningful under `rate()`. It counts hook invocations rather than sessions, so pre-warming and repeated spawns for the same session diverge it from session counts. |
301| Utilization | `sum(claude_code_self_hosted_runner_active_sessions)` against `sum(claude_code_self_hosted_runner_capacity)`, both gauges valid at every scrape regardless of runner lifetime |
302| Backlog | `claude_code_self_hosted_orchestrator_pool_pending_sessions` for queue depth, and `claude_code_self_hosted_orchestrator_queue_circuit_broken_sessions`, alerting if above zero |
303| Failures | `claude_code_self_hosted_runner_sessions_failed_total`, best effort: real crashes after spawn do increment it, and `rate()` is meaningful on runners that outlive their sessions with `--drain-grace-sec` above `0`. A one-shot environment has the same scrape-window problem as the other terminal counters, so treat any non-zero value you do see as worth investigating. Failures before spawn, such as a checkout hook failure, git preparation, or a token issue, appear only in `session_init_errors_total`. |
304
305The `orchestrator_*` rows exist only on environments running the [on-demand orchestrator](/docs/en/self-hosted-environments-configuration#on-demand-runners). On a fixed fleet whose runners outlive their sessions, with `--drain-grace-sec` above `0`, use `sum(rate(claude_code_self_hosted_runner_sessions_started_total[5m]))` for throughput; on a one-shot fleet that series has the same scrape-window problem as the terminal counters, so rely on the queued-sessions count instead. Check backlog on the environment's **Activity** tab, on the [**Cloud environments** admin page](https://claude.ai/admin-settings/cloud-environments): the runners don't export a queue-depth series.
306
307For per-session outcome reporting, use the [`post-session` hook](/docs/en/self-hosted-environments-configuration#post-session) instead: it fires at every session end where a child process was spawned, apart from abrupt runner termination such as a VM preemption, per the [hook's own contract](/docs/en/self-hosted-environments-configuration#post-session).
308
309## What's next
310
311* [Self-hosted environments](/docs/en/self-hosted-environments): the environment, runner, and session model; the [quickstart](/docs/en/self-hosted-environments-quickstart) and [Deploy to production](/docs/en/self-hosted-environments-deploy) hold setup and operations
312* [Customize sessions](/docs/en/self-hosted-environments-configuration): wrapper scripts, lifecycle hooks, and on-demand runners
313* [Verify session identity](/docs/en/self-hosted-environments-identity): the session token, its claims, and how to verify it