Skip to main content

claude-code-runner

Out-of-process sidecar that runs the claude CLI on behalf of Spryng AgentRuns.

Spryng's apps/agent_runs/runtimes/claude_code.py adapter speaks the protocol; this service owns the subprocess and posts progress back. Deploy it once per environment (one container per runtime config), point Spryng at it via Settings → AI → Agent Runtimes, and agents start driving themselves end-to-end.


Quick start

# 1. Provision the auth token both sides will share.
export RUNNER_AUTH_TOKEN=$(python -c "import secrets; print(secrets.token_urlsafe(32))")
echo "Runner token: $RUNNER_AUTH_TOKEN"

# 2. Provision the Anthropic API key the `claude` CLI uses.
export ANTHROPIC_API_KEY=sk-ant-...

# 3. Build + run the container.
docker build -t claude-code-runner .
docker run --rm -p 8088:8088 \
    -e RUNNER_AUTH_TOKEN \
    -e ANTHROPIC_API_KEY \
    -e RUNNER_ALLOWED_CALLBACK_HOSTS=app.spryng.io \
    claude-code-runner

# 4. In Spryng, AI Settings → Agent Runtimes → Add:
#    type:        claude_code
#    endpoint:    http://<runner-host>:8088
#    credential:  $RUNNER_AUTH_TOKEN

Local dev without Docker:

pip install -e .[dev]
cp .env.example .env
# edit RUNNER_AUTH_TOKEN
uvicorn runner.main:app --reload --host 0.0.0.0 --port 8088

Protocol

The runner exposes four endpoints. All except /healthz require Authorization: Bearer $RUNNER_AUTH_TOKEN.

POST /jobs

Spryng's adapter calls this to enqueue a job. Returns 202 with a job_id immediately; the work happens in the background.

Request body (see models.py for the full schema):

{
  "run_id": 4242,
  "card_ref": "ON-914",
  "card_id": 12345,
  "project_slug": "onboarding",
  "org_slug": "spryng-internal",
  "agent": {
    "username": "pilot-claude",
    "display_name": "Pilot Claude"
  },
  "spec": {
    "content": "---\nfeature_identity: …\n---\n\n## Context\n…",
    "format": "md",
    "complexity_score": 12
  },
  "replan_context": null,
  "spryng_callback": {
    "base_url": "https://app.spryng.io",
    "bearer_token": "agent-run-sub-token-xxxxx",
    "progress_path": "/api/scrumdo/organizations/spryng-internal/agent-runs/4242/progress/",
    "agent_run_header_name": "X-Spryng-Agent-Run",
    "agent_run_header_value": "4242"
  },
  "runtime": {
    "model_slug": "claude-sonnet-4-7",
    "max_wallclock_seconds": 1800,
    "max_cost_cents": 500
  }
}

Response:

{ "job_id": "1f8c…", "url": "/jobs/1f8c…" }

GET /jobs/<job_id>

Returns the runner's view of the job — runner-local state, plan text (once generated), outcome (once executed), exit code, cumulative cost in cents, and the live PID if a subprocess is currently running.

POST /jobs/<job_id>/cancel

Cooperative cancel. Sets cancel_requested, cancels the orchestration task, SIGTERMs any live subprocess (then SIGKILL after 10s), and posts state=cancelled back to Spryng.

Idempotent — calling cancel on a terminal job returns ok with note: "already terminal".

POST /jobs/by-run/<run_id>/cancel

Same as above but looked up by Spryng's run_id rather than the runner-local job_id. Used by Spryng's adapter when it doesn't have the runner job id (e.g. when the original start callback failed).

GET /healthz

Liveness probe. No auth required. Returns { "ok": true, "version": "<current version>" }.


Orchestration loop

Per job, the runner walks through these states:

                              ┌──────────────────────┐
queued (Spryng) ──► planning ─┤                      │
                              │   claude `generate   │
                              │   plan` subprocess   │
                              │                      │
                              └────────┬─────────────┘
                                       │ plan text
                                       ▼
                              ┌──────────────────────┐
                              │ POST progress to     │
                              │ Spryng with plan     │
                              └────────┬─────────────┘
                                       │
                                       ▼
                              awaiting_approval ──┐
                                                  │ poll Spryng's
                                                  │ /agent-runs/<id>/
                                                  │ every 15s
                                                  ▼
                                          state=executing observed
                                                  │
                                                  ▼
                              ┌──────────────────────┐
                              │   claude `execute    │
                              │   plan` subprocess   │
                              └────────┬─────────────┘
                                       │ outcome text
                                       ▼
                              ┌──────────────────────┐
                              │ finalize: stage /    │
                              │ commit / push branch │
                              │ / open-or-reuse PR   │
                              └────────┬─────────────┘
                                       │ result {pr_url, diffstat, …}
                                       ▼
                              ┌──────────────────────┐
                              │ POST progress to     │
                              │ Spryng: completed +  │
                              │ outcome + cost +     │
                              │ result              │
                              └──────────────────────┘

Any failure / timeout / cancel along the way posts the appropriate state=failed | cancelled callback so Spryng's audit log stays correct.

Finalize — commit / push / PR (deterministic)

After a successful execute, runner/finalize.py::finalize_execution makes the artifacts a property of the runner, not of the agent's behaviour:

  1. git add -A, then detect a no-op (no staged diff and no new commits vs. the checkout base) → report result.no_changes and open no PR.
  2. Commit any uncommitted edits on expected_branch with a Fixes <card-ref> message so Spryng's webhook always links it.
  3. git push the branch using the write-scoped token embedded in the clone URL.
  4. Open (or reuse, idempotently) a PR via the GitHub REST API.
  5. Return a structured result (pr_url, pr_number, diffstat, commits) on the completed callback. Spryng folds it into AgentRun.evidence['result'] and links the PR immediately — the card shows it without waiting on the webhook. On a push/PR failure the run still completes, with result.finalize_error set so the failure is visible.

Idempotency & restart behaviour

  • POST /jobs is idempotent on run_id. Spryng mints a unique run_id per real attempt, so a second POST for a run_id already in the store (active or recently terminal, within RUNNER_JOB_TTL_SECONDS) is a duplicate at-least-once delivery — it returns the existing job and never starts a second claude run or double-charges.
  • No resume across restart. The job store is in-memory (runner/jobs.py), so a container restart loses in-flight jobs. This is intentional — the monolith reaper is the agreed backstop: it sweeps runs stuck in planning / executing to failed (wallclock_timeout) once they pass max_wallclock_seconds + 5 min grace (apps/agent_runs/reaper.py), freeing the card and revoking the run token. Keep max_wallclock_seconds realistic so abandoned runs free their card promptly. To survive restarts without the reaper delay, swap JobStore for a Redis/SQLite-backed implementation.

What the runner still does NOT do

  • Auto-merge. The human reviews and merges the PR. Spryng's large-diff approval gate still applies.
  • PR discovery from a webhook. Finalize opens the PR itself; the webhook remains the reconciler/source of truth for review + check state.

Configuration

All env vars; defaults shown.

Var Default Purpose
RUNNER_HOST 0.0.0.0 Bind address
RUNNER_PORT 8088 Bind port
RUNNER_AUTH_TOKEN required Bearer token shared with Spryng's AgentRuntimeConfig
CLAUDE_CLI_BINARY claude Path to the Claude Code CLI binary
ANTHROPIC_API_KEY inherited Read by the claude CLI from env
RUNNER_WORKDIR_ROOT /tmp/spryng-runs Per-job working directories
RUNNER_JOB_TTL_SECONDS 3600 How long terminal jobs stay in memory (also the idempotency window)
RUNNER_SPRYNG_POLL_INTERVAL_SECONDS 15 Approval-polling cadence
RUNNER_GITHUB_API_BASE https://api.github.com GitHub REST base for the finalize PR open/reuse (override for GHE)
RUNNER_ALLOWED_CALLBACK_HOSTS * Comma-separated hostname allow-list for callbacks (SSRF guard)
LOG_LEVEL info uvicorn / app logging

The allow-list is the most important security knob:

# Prod
RUNNER_ALLOWED_CALLBACK_HOSTS=app.spryng.io,qa.spryng.io

# Multi-tenant: limit to your own Spryng deployments only.

When set to *, the runner posts callbacks to whatever URL Spryng sends. Fine for dev; dangerous in prod if a malicious payload could ever reach the POST /jobs endpoint.


Cost tracking

The runner pulls token counts off the claude --output-format json metadata and converts to cents using the same price table as Spryng's Anthropic provider adapter. The cost_cents_delta field on the progress callback aggregates planning + execution costs.

Update claude_runner.py _PRICE_TABLE when Anthropic pricing changes; mirror the same edit on Spryng's anthropic_adapter.py to keep the two sides honest.


Tests

pip install -e .[dev]
pytest -v

The test suite mocks both the claude subprocess and Spryng's callbacks so it runs hermetically. Coverage:

  • /healthz doesn't require auth, returns version
  • All other endpoints reject missing / wrong tokens
  • POST /jobs accepts the payload shape and starts the orchestration
  • GET /jobs/<id> returns the correct state shape
  • POST /jobs/<id>/cancel and /jobs/by-run/<run_id>/cancel both work
  • Idempotent cancel on terminal jobs
  • Pydantic rejects malformed payloads with 422

Production checklist

  1. Generate the auth token (32+ bytes random) and store both ends — RUNNER_AUTH_TOKEN in the runner's env, same value in Spryng's AgentRuntimeConfig.api_credential.
  2. Provision ANTHROPIC_API_KEY in the runner's env (Secrets Manager / SSM SecureString — same pattern as Spryng's keys).
  3. Lock the callback allow-list to your Spryng hostnames.
  4. Set max_wallclock_seconds on the AgentRuntimeConfig to match your tolerance for stuck runs (default 1800s).
  5. Set max_cost_cents_per_run on the AgentRuntimeConfig — the runner reports cumulative cost and Spryng's dispatcher fails the run when it exceeds this cap.
  6. Run behind a reverse proxy (nginx / ALB) with TLS termination. The runner trusts X-Forwarded-For via --proxy-headers.
  7. Health checks — point your orchestrator's liveness probe at GET /healthz.
  8. Logs — uvicorn writes structured access logs to stdout; forward them to your usual aggregator.

Spec deviation note

The unified spec at BOARD_AI_AGENTS_UNIFIED_SPEC.md §E.4 calls for a strategy-pattern driver layer in Spryng plus an out-of-process compute that owns the subprocess. The driver layer lives at runtimes/claude_code.py; this repo IS the out-of-process compute. The HTTP protocol is the contract — both sides version it together.

When Spryng's adapter changes (new fields on the payload, new endpoint expectations), update models.py here in lockstep and bump the version in pyproject.toml.

Metadata

Release files for spryng-agent 0.1.26

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for spryng-agent 0.1.26
File Size Uploaded
spryng_agent-0.1.26.tar.gz 23.4 MB Details

Built distribution (wheel)

Table of built distributions (wheels) for spryng-agent 0.1.26
File Interpreter ABI Platform
spryng_agent-0.1.26-py3-none-any.whl Python 3 none any Details

Total release size: 23.5 MB

Release files / spryng_agent-0.1.26.tar.gz

Download URL spryng_agent-0.1.26.tar.gz
Size 23.4 MB
Tags Source
SHA-256 checksum
How to use checksums
f509d0f8ece857834a213a37f4de540a429120daa700828953bdcd550f48fd15
BLAKE2b-256 checksum
How to use checksums
43b3bbd22a4007e038bc0f346690fdf2544f704bbc1ca021100bee4c363b0cc9
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 1, 2026.

Transparency log

Release files / spryng_agent-0.1.26-py3-none-any.whl

Download URL spryng_agent-0.1.26-py3-none-any.whl
Size 79.0 kB
Tags Python 3
SHA-256 checksum
How to use checksums
3f292c896d1fdeb8c6122f62dca80af4cff14ecd1cb16dce76d2805a62e6f4a7
BLAKE2b-256 checksum
How to use checksums
afd0f78123af97eba5c7c02d6bb39d8fd9a977f8b91a945e94a37253e63c0388
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 1, 2026.

Transparency log

Release history Release notifications | RSS feed

This release

0.1.26 This release

2 release files

0.1.22

2 release files

0.1.21

2 release files

0.1.20

2 release files

0.1.8

2 release files

0.1.6

2 release files

0.1.5

2 release files

0.1.2

2 release files

0.1.1

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page