Skip to main content

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:

  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.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

spryng_agent-0.1.10.tar.gz (118.2 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

spryng_agent-0.1.10-py3-none-any.whl (65.3 kB view details)

Uploaded Python 3

File details

Details for the file spryng_agent-0.1.10.tar.gz.

File metadata

  • Download URL: spryng_agent-0.1.10.tar.gz
  • Upload date:
  • Size: 118.2 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for spryng_agent-0.1.10.tar.gz
Algorithm Hash digest
SHA256 7c3f1c008a934b4400bf25f45e24f77a023b9f20739d9245ea7eef8f241aac81
MD5 647aa5245a0bd9d37b8b5b0f7dadf9d6
BLAKE2b-256 90f0786ab753d8cc5e0499e0f2e6585f0511a19991e3918b6f01c92b0db61ca4

See more details on using hashes here.

Provenance

The following attestation bundles were made for spryng_agent-0.1.10.tar.gz:

Publisher: publish-pypi.yml on ScrumDoLLC/claude-code-runner

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file spryng_agent-0.1.10-py3-none-any.whl.

File metadata

  • Download URL: spryng_agent-0.1.10-py3-none-any.whl
  • Upload date:
  • Size: 65.3 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for spryng_agent-0.1.10-py3-none-any.whl
Algorithm Hash digest
SHA256 7c2cbc03475e2e29468e6aeb3865197357255fd0fa421f779c7969d66cb9b0ad
MD5 a1fc3640544ebf45eff43a43795149c7
BLAKE2b-256 f65e995ef3494b644af9f626934513d5f4ff8107e3ac453e7b474b358b8445af

See more details on using hashes here.

Provenance

The following attestation bundles were made for spryng_agent-0.1.10-py3-none-any.whl:

Publisher: publish-pypi.yml on ScrumDoLLC/claude-code-runner

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page