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:
git add -A, then detect a no-op (no staged diff and no new commits vs. the checkout base) → reportresult.no_changesand open no PR.- Commit any uncommitted edits on
expected_branchwith aFixes <card-ref>message so Spryng's webhook always links it. git pushthe branch using the write-scoped token embedded in the clone URL.- Open (or reuse, idempotently) a PR via the GitHub REST API.
- Return a structured
result(pr_url,pr_number, diffstat, commits) on thecompletedcallback. Spryng folds it intoAgentRun.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, withresult.finalize_errorset so the failure is visible.
Idempotency & restart behaviour
POST /jobsis idempotent onrun_id. Spryng mints a uniquerun_idper real attempt, so a second POST for arun_idalready in the store (active or recently terminal, withinRUNNER_JOB_TTL_SECONDS) is a duplicate at-least-once delivery — it returns the existing job and never starts a secondclauderun 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 inplanning/executingtofailed(wallclock_timeout) once they passmax_wallclock_seconds + 5 mingrace (apps/agent_runs/reaper.py), freeing the card and revoking the run token. Keepmax_wallclock_secondsrealistic so abandoned runs free their card promptly. To survive restarts without the reaper delay, swapJobStorefor 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:
/healthzdoesn't require auth, returns version- All other endpoints reject missing / wrong tokens
POST /jobsaccepts the payload shape and starts the orchestrationGET /jobs/<id>returns the correct state shapePOST /jobs/<id>/canceland/jobs/by-run/<run_id>/cancelboth work- Idempotent cancel on terminal jobs
- Pydantic rejects malformed payloads with 422
Production checklist
- Generate the auth token (32+ bytes random) and store both
ends —
RUNNER_AUTH_TOKENin the runner's env, same value in Spryng'sAgentRuntimeConfig.api_credential. - Provision
ANTHROPIC_API_KEYin the runner's env (Secrets Manager / SSM SecureString — same pattern as Spryng's keys). - Lock the callback allow-list to your Spryng hostnames.
- Set
max_wallclock_secondson the AgentRuntimeConfig to match your tolerance for stuck runs (default 1800s). - Set
max_cost_cents_per_runon the AgentRuntimeConfig — the runner reports cumulative cost and Spryng's dispatcher fails the run when it exceeds this cap. - Run behind a reverse proxy (nginx / ALB) with TLS termination.
The runner trusts
X-Forwarded-Forvia--proxy-headers. - Health checks — point your orchestrator's liveness probe at
GET /healthz. - 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.27
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| spryng_agent-0.1.27.tar.gz | 23.4 MB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| spryng_agent-0.1.27-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 23.5 MB
Release files / spryng_agent-0.1.27.tar.gz
| Download URL | spryng_agent-0.1.27.tar.gz |
|---|---|
| Size | 23.4 MB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
9c3ba84851ae067d8528bdd5cb8650c33afdb6877c2dd1dda9dbed447e12568f
|
|
BLAKE2b-256 checksum How to use checksums |
c5ee6ca689661361538fe6d90b41e17c109583d7175b3d0765901ab43625c6a7
|
| 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 3, 2026.
Transparency logRelease files / spryng_agent-0.1.27-py3-none-any.whl
| Download URL | spryng_agent-0.1.27-py3-none-any.whl |
|---|---|
| Size | 79.5 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
0081f4942b2fbe3f721f441324745331f8190227796a470cc01ebacea812e203
|
|
BLAKE2b-256 checksum How to use checksums |
7f3582281f9dc8f3141207dc3b7e674528d2dfe6bf7cd871189823cc132ac2c8
|
| 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 3, 2026.
Transparency log