SpyBara
Go Premium

self-hosted-environments-reference.md 2026-08-06 15:02 UTC to 2026-08-07 23:57 UTC

313 added, 0 removed.

2026
Sat 8 04:59 Fri 7 23:57 Thu 6 15:02 Wed 5 22:02 Tue 4 22:59 Mon 3 20:02 Sun 2 19:00

Self-hosted environments reference

Complete reference for the self-hosted runner and orchestrator: CLI flags, environment variables, and Prometheus metrics.

This page is the reference for the two processes you run in a self-hosted environment: the runner, which executes Claude Code cloud sessions 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.

Metric 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 describes.

Runner CLI flags

Most 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.

Flag Env var Default Description
--api-url <url> none https://api.anthropic.com API base URL. Override only for testing.
--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.
--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.
--configure-git SELF_HOSTED_RUNNER_CONFIGURE_GIT=1 off Write global git identity and enable Anthropic commit signing at startup. See Configure git.
--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.
--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.
--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
--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
--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, 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.
--exec-path <path> SELF_HOSTED_RUNNER_EXEC_PATH own binary Binary or wrapper script to spawn for each session. See Wrapper scripts.
--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.
--git-host-rewrite <from>=<to> none unset Rewrite https://<from>/... source URLs to https://<to>/... before cloning, for split-horizon DNS. Repeatable; flag only.
--git-ssh-rewrite <host> none unset Rewrite https://<host>/... source URLs to git@<host>:... before cloning, for SSH-only git hosts. Repeatable; flag only.
--health-port <port> SELF_HOSTED_RUNNER_HEALTH_PORT 8080 Port for the /healthz and /metrics listener. Set 0 to disable.
--hooks-dir <path> SELF_HOSTED_RUNNER_HOOKS_DIR unset Directory of lifecycle hook scripts. See Lifecycle hooks.
--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.
--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.
--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.
--log-level <level> none info info or debug
--post-session-hook-timeout-sec <n> SELF_HOSTED_RUNNER_POST_SESSION_HOOK_TIMEOUT_MS 60 Budget for the post-session hook on every session end, including runner shutdown
--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. Repositories checked out via a checkout lifecycle hook aren't pushed; snapshot those from the post-session hook instead.
--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.
--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 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.
--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.
--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, not by ordinary output, after which --release-idle-session-min takes over. 0 disables.
--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 scans them regardless of this flag.
--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.

Most 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:

  • Flag: startup fails with an error.
  • Environment variable: the runner clamps the value to the timer ceiling rather than rejecting it.

Orchestrator CLI flags

The self-hosted-runner orchestrator subcommand, which spawns 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:

Flag Default Description
--hook-concurrency <n> 4 Maximum spawn-runner hooks running in parallel. Also caps how many spawn requests are claimed per poll.
--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.
--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.
--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.
--debug-dir <path> unset Write each spawn request's work order and hook stderr to disk. Debug only; never set in production.

SCM connector flags

The 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.

Flag Default Description
--scm-connector-host <host[:port]> unset GitHub Enterprise Server hostname to forward requests to. Port defaults to 443. Setting this flag enables the connector.
--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.
--scm-connector-provider <slug> ghe Path segment identifying the provider, matching ^[a-z0-9-]{1,32}$.
--scm-connector-ca-file <path> unset Extra CA bundle, in PEM format, for TLS connections to the GitHub Enterprise Server host.
--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.

The 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.

Environment-variable-only settings

These runner settings are read from the environment only and cover behavior most deployments leave at the default:

Env var Default Description
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, so setting it, including to its own default, relocates that lookup; point at an empty directory to disable seeding entirely.
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
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.
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.
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.
FORCE_AUTOUPDATE_PLUGINS unset When 1, let plugin marketplaces auto-update even though the binary is pinned
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

Telemetry

Session 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.

One 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. DISABLE_GROWTHBOOK is related but different: setting DISABLE_GROWTHBOOK=1 disables feature-flag fetching, and telemetry stays on unless DISABLE_TELEMETRY is also set.

CLAUDE_CODE_ENABLE_TELEMETRY is unrelated: it enables OpenTelemetry export to your own collector, as described in Monitoring, and doesn't control Anthropic's analytics.

Health endpoint

The 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:

{
  "status": "ok",
  "runner_id": "ccrunner_...",
  "active_sessions": 2,
  "last_poll_at": "2026-03-31T18:04:11.220Z",
  "last_poll_age_ms": 842
}

Use 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.

The 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.

When the SCM connector 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.

Prometheus metrics

Each runner serves Prometheus metrics at GET /metrics on the same port as /healthz. Key series:

Series Notes
claude_code_self_hosted_runner_info{runner_id,version,client_label} Always 1; useful for fleet inventory and version-drift detection
claude_code_self_hosted_runner_capacity Configured --capacity
claude_code_self_hosted_runner_active_sessions Sessions currently running
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.
claude_code_self_hosted_runner_last_poll_age_seconds Seconds since the last successful poll. Alert if over 60.
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.
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 sessions can't route to self-hosted environments yet, so neither value appears until that support ships. Use sum() for the fleet total.
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 for what counts.
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.
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.
claude_code_self_hosted_runner_initializing_sessions Sessions currently in the init phase, from assignment until the child's init event
claude_code_self_hosted_runner_session_init_duration_seconds Histogram of session init durations
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
claude_code_self_hosted_runner_session_start_hook_errors_total SessionStart hooks that reported an error outcome, one per failing hook execution
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.

The orchestrator serves its own series at GET /metrics on the same port as its /healthz:

Series Notes
claude_code_self_hosted_orchestrator_info{version,pool_id,orchestrator_uuid,hostname} Always 1
claude_code_self_hosted_orchestrator_connected 1 when the most recent poll succeeded; drops to 0 after any failed poll, whatever the failure kind
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.
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.
claude_code_self_hosted_orchestrator_queue_pending_sessions Spawn requests claimable right now
claude_code_self_hosted_orchestrator_queue_backing_off_sessions Spawn requests in retry backoff after a retryable hook failure
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
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.
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.
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.
claude_code_self_hosted_orchestrator_spawn_hook_duration_seconds Histogram of hook durations
claude_code_self_hosted_orchestrator_warm_hints_dispatched_total Standby spawn requests dispatched since process start
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.
claude_code_self_hosted_orchestrator_clock_skew_seconds Local-minus-server clock skew; diagnostic, present once measured
claude_code_self_hosted_orchestrator_scm_connector_connected 1 when the SCM connector's WebSocket is open; 0 while dialing or backing off. Absent when --scm-connector-host isn't set.
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.

For autoscaling, pick the series that matches your scaling style and gate it before it feeds the scaler:

  • Queue-depth scaling: feed claude_code_self_hosted_orchestrator_pool_pending_sessions into your HPA or KEDA scaler, not queue_pending_sessions.
  • Capacity scaling: scale on the ratio of the runner's active_sessions to capacity.
  • 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.

During 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.

The 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 sets; adjust the namespaces to match your deployment:

# Example Prometheus Operator PodMonitor for the Claude Code self-hosted
# runner + orchestrator. Adjust the namespace and label selectors to match
# your deployment. Both the runner and the orchestrator serve /metrics on
# their --health-port (default 8080).
apiVersion: monitoring.coreos.com/v1
kind: PodMonitor
metadata:
  name: claude-code-self-hosted-runner
  namespace: monitoring
spec:
  namespaceSelector:
    matchNames:
      - claude-runners
  selector:
    matchExpressions:
      # Matches the runner Deployment from the Kubernetes recipe, plus any
      # on-demand runner Jobs and orchestrator pods you label the same way
      # and give a named 'health' containerPort.
      - key: app.kubernetes.io/part-of
        operator: In
        values: [claude-code-self-hosted-runner]
  podMetricsEndpoints:
    - port: health
      path: /metrics
      interval: 30s

These sample alert rules are a starting point; tune the thresholds for your fleet size:

# Example Prometheus alert rules for the Claude Code self-hosted runner
# + orchestrator. Tune thresholds for your fleet size and SLOs.
groups:
  - name: claude-code-self-hosted-runner
    rules:
      - alert: ClaudeRunnerPollStale
        expr: claude_code_self_hosted_runner_last_poll_age_seconds > 60
        for: 2m
        labels: {severity: warning}
        annotations:
          summary: "Runner {{ $labels.pod }} has not polled in >60s"
      - alert: ClaudeRunnerVersionDrift
        expr: count(count by (version) (claude_code_self_hosted_runner_info)) > 1
        for: 30m
        labels: {severity: info}
        annotations:
          summary: "Runners are running mixed versions"
      - alert: ClaudeRunnerInitErrorsHigh
        expr: increase(claude_code_self_hosted_runner_session_init_errors_total[10m]) > 3
        for: 5m
        labels: {severity: warning}
        annotations:
          summary: "Runner {{ $labels.pod }}: >3 session init failures in 10m (checkout hook / git / token / pre-init crash)"
      - alert: ClaudeRunnerPollErrors
        expr: sum by (pod) (rate(claude_code_self_hosted_runner_poll_errors_total[5m])) > 0
        for: 2m
        labels: {severity: warning}
        annotations:
          summary: "Runner {{ $labels.pod }}: PollWork failing ({{ $value | humanize }}/s over 5m)"
      - alert: ClaudeRunnerSessionStartHookErrors
        expr: increase(claude_code_self_hosted_runner_session_start_hook_errors_total[10m]) > 3
        for: 5m
        labels: {severity: warning}
        annotations:
          summary: "Runner {{ $labels.pod }}: >3 SessionStart hook failures in 10m"

  - name: claude-code-self-hosted-orchestrator
    rules:
      - alert: ClaudeOrchestratorDisconnected
        expr: claude_code_self_hosted_orchestrator_connected == 0
        for: 2m
        labels: {severity: critical}
        annotations:
          summary: "Orchestrator {{ $labels.pod }} cannot reach the Anthropic control plane"
      - alert: ClaudeOrchestratorPollStale
        expr: claude_code_self_hosted_orchestrator_last_poll_age_seconds > 90
        for: 2m
        labels: {severity: warning}
        annotations:
          summary: "Orchestrator {{ $labels.pod }} has not polled in >90s (poll loop waits on hook execution)"
      - alert: ClaudeOrchestratorCircuitBroken
        expr: claude_code_self_hosted_orchestrator_queue_circuit_broken_sessions > 0
        for: 1m
        labels: {severity: critical}
        annotations:
          summary: "{{ $value }} sessions circuit-broken — spawn-runner hook is repeatedly non-retryable; fix infra then retry from the Activity tab"
      - alert: ClaudeOrchestratorPollErrors
        expr: sum by (pod) (rate(claude_code_self_hosted_orchestrator_poll_errors_total[5m])) > 0
        for: 2m
        labels: {severity: warning}
        annotations:
          summary: "Orchestrator {{ $labels.pod }}: PollSpawnHints failing ({{ $value | humanize }}/s over 5m)"
      - alert: ClaudeOrchestratorSpawnHookFailing
        expr: sum by (pod) (increase(claude_code_self_hosted_orchestrator_spawn_hooks_total{result!="ok"}[5m])) > 3
        for: 5m
        labels: {severity: warning}
        annotations:
          summary: "Orchestrator {{ $labels.pod }}: >3 spawn-runner hook failures in 5m"

Pass through session-child metrics

Each 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 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.

At the default --capacity 1, the rewrite doesn't apply: the session's child binds its own Prometheus endpoint on port 9464 as usual.

Session lifecycle counter semantics

The 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.

  • 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.
  • 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.
  • 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.

The post-session hook'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.

On 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.

Use the series in this table for the corresponding goal instead of the terminal counters:

Goal Use
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.
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
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
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.

The orchestrator_* rows exist only on environments running the on-demand orchestrator. 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: the runners don't export a queue-depth series.

For per-session outcome reporting, use the post-session hook 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.

What's next