Run Claude Code or Codex on your machine against your Spryng board (the Spryng Local Agent / claude-code-runner).
Project description
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": "0.1.0" }.
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.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file spryng_agent-0.1.6.tar.gz.
File metadata
- Download URL: spryng_agent-0.1.6.tar.gz
- Upload date:
- Size: 115.1 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
66d813fcf644ec01df27b12fa0ac705e2c5182f998c3edf0dab96937b07055af
|
|
| MD5 |
cf715da5274839c64d9f625cf6717a70
|
|
| BLAKE2b-256 |
73e468e10e16abef589161a6360493532605271d7adcdd88c6fc83cf79ca90ae
|
Provenance
The following attestation bundles were made for spryng_agent-0.1.6.tar.gz:
Publisher:
publish-pypi.yml on ScrumDoLLC/claude-code-runner
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
spryng_agent-0.1.6.tar.gz -
Subject digest:
66d813fcf644ec01df27b12fa0ac705e2c5182f998c3edf0dab96937b07055af - Sigstore transparency entry: 1837443154
- Sigstore integration time:
-
Permalink:
ScrumDoLLC/claude-code-runner@0ffca7bf3c88c15bb2c4b16615a3691b5777a796 -
Branch / Tag:
refs/tags/v0.1.7 - Owner: https://github.com/ScrumDoLLC
-
Access:
private
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish-pypi.yml@0ffca7bf3c88c15bb2c4b16615a3691b5777a796 -
Trigger Event:
push
-
Statement type:
File details
Details for the file spryng_agent-0.1.6-py3-none-any.whl.
File metadata
- Download URL: spryng_agent-0.1.6-py3-none-any.whl
- Upload date:
- Size: 63.3 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
da48c04172bb6207f57c2bcdc3b33b26855177190c81df942279600d6c0c395d
|
|
| MD5 |
f19bf55d65a501e79ace51c8e5fecf09
|
|
| BLAKE2b-256 |
f5b3f492f330054ea59ad24a856d9332ebeba8039ac39c5993d90161b9e80da5
|
Provenance
The following attestation bundles were made for spryng_agent-0.1.6-py3-none-any.whl:
Publisher:
publish-pypi.yml on ScrumDoLLC/claude-code-runner
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
spryng_agent-0.1.6-py3-none-any.whl -
Subject digest:
da48c04172bb6207f57c2bcdc3b33b26855177190c81df942279600d6c0c395d - Sigstore transparency entry: 1837443304
- Sigstore integration time:
-
Permalink:
ScrumDoLLC/claude-code-runner@0ffca7bf3c88c15bb2c4b16615a3691b5777a796 -
Branch / Tag:
refs/tags/v0.1.7 - Owner: https://github.com/ScrumDoLLC
-
Access:
private
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish-pypi.yml@0ffca7bf3c88c15bb2c4b16615a3691b5777a796 -
Trigger Event:
push
-
Statement type: