# clawdforge LAN-only HTTP service that runs `claude -p` subprocess calls on behalf of Sulkta apps. One container holds the Claude Code subscription auth; multiple apps consume via bearer tokens + IP allowlist. ## Why - **Auth in one place** — only this container needs to be `claude /login`'d, not every app - **Smaller app images** — apps stay tiny Python/Go containers, no node/npm/claude-cli - **Audit log** — every prompt + response chars + duration in one SQLite db - **Reusable bone** — petalparse, cauldron, johnny5 all consume the same surface ## Surface ``` GET /healthz liveness + claude --version smoke POST /run run a prompt, return parsed result POST /files upload a file, get a file_token to pass to /run POST /sessions create a multi-turn session (v0.2) POST /sessions//turn send a turn to a session (v0.2) GET /sessions/ read session state (v0.2) DELETE /sessions/ soft-close a session (v0.2) GET /sessions list this token's sessions (v0.2) POST /admin/tokens mint a per-app token (admin) GET /admin/tokens list app tokens (admin) DELETE /admin/tokens/ revoke a token (admin) ``` ### `POST /run` ```json { "prompt": "Sterilize this ingredient line: 'about 2 cups of cooked white rice'", "model": "sonnet", "system": "You are a precise recipe parser. Always reply with valid JSON.", "files": ["ff_..."], "timeout_secs": 60 } ``` Returns: ```json { "ok": true, "result": { "qty": 2, "unit": "cup", "food": "rice", "note": "cooked, white", "approx": true }, "duration_ms": 4321, "stop_reason": "end_turn" } ``` `result` is the inner `{"type":"result","result":"..."}` from `claude -p --output-format json`, auto-stripped of code fences and JSON-parsed if possible. If the inner is not valid JSON, it's returned as a string. ### `POST /files` multipart/form-data, field `file`, optional `ttl_secs` (60..86400, default 3600). Returns `{"file_token": "ff_...", "ttl_secs": 3600, "size": 12345}`. Use that token in subsequent `/run` requests to attach the file via `claude -p --files`. ## Auth Two layers: 1. **IP allowlist** — global CIDR list in `ALLOW_CIDRS` env. Loopback always allowed. Per-app allowlist optional on top (mint with `ip_cidrs: [...]`). 2. **Bearer token** — `Authorization: Bearer cf_<...>` for `/run` and `/files`, `Authorization: Bearer ` for `/admin/*`. Tokens are SHA-256 hashed in SQLite. The plaintext is shown ONCE at create time. ## Deploy 1. SSH to Lucy: `ssh lucy` 2. `mkdir -p /mnt/user/appdata/clawdforge/{data,claude-config,claude-alt-config}` 3. Drop `.env` at `/mnt/cache/appdata/secrets/clawdforge.env` (chmod 600, root:root) — see `.env.example` 4. Clone the repo to `/opt/stacks/clawdforge` (Lucy uses Gitea reverse-tunnel pattern) 5. `cd /opt/stacks/clawdforge && docker compose up -d --build` 6. **Auth Claude CLI** (one-time, persists on volume): ``` docker exec -it clawdforge claude /login ``` Walk through the device-auth flow. Credentials persist at `/root/.claude/` inside the container, mapped to `/mnt/user/appdata/clawdforge/claude-config/` on host. 7. Smoke: ``` curl http://192.168.0.5:8800/healthz ``` Should report `claude_present: true` + a version string. 8. Mint a token for the first consumer: ``` curl -sS -X POST http://192.168.0.5:8800/admin/tokens \ -H "Authorization: Bearer $ADMIN_BOOTSTRAP_TOKEN" \ -H "Content-Type: application/json" \ -d '{"name":"cauldron","ip_cidrs":["172.24.0.0/16"]}' ``` Save the returned `token` into the consumer's env. ## Client snippet (Python) ```python import os, requests CF = "http://192.168.0.5:8800" TOKEN = os.environ["CLAWDFORGE_TOKEN"] r = requests.post( f"{CF}/run", headers={"Authorization": f"Bearer {TOKEN}"}, json={ "prompt": 'Reply with JSON: {"hello": "world"}', "model": "sonnet", "timeout_secs": 30, }, timeout=60, ) r.raise_for_status() print(r.json()["result"]) # {'hello': 'world'} ``` ## Multi-turn / Sessions (v0.2) `/run` is one-shot: stateless, fast, returns a single result. When you need multi-turn context (build something step-by-step, debug across iterations, long-running structured tool-call work), use `/sessions/*`. The session surface is backed by [ACPX](https://github.com/openclaw/acpx) — the OpenClaw team's headless Agent Client Protocol CLI. Clawdforge wraps it so apps don't need to manage ACPX subprocesses or session metadata directly. Sessions persist on disk under `/root/.acpx/sessions/` (mounted from the host) so they survive container rebuilds. ### Quickstart — three turns in one session ```bash TOKEN=$CLAWDFORGE_TOKEN CF=http://192.168.0.5:8800 # 1. Create a session SID=$(curl -sS -X POST $CF/sessions \ -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ -d '{"agent":"claude"}' | jq -r .session_id) # 2. Send a turn curl -sS -X POST $CF/sessions/$SID/turn \ -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ -d '{"prompt":"Help me draft a SQL migration. First describe what you need to know."}' # 3. Send a follow-up — the session keeps context curl -sS -X POST $CF/sessions/$SID/turn \ -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ -d '{"prompt":"Postgres 16, table users, add column tier text default \"free\""}' # 4. Close curl -sS -X DELETE $CF/sessions/$SID -H "Authorization: Bearer $TOKEN" ``` ### Turn response shape ```json { "ok": true, "session_id": "abc123...", "turn_index": 2, "events": [ {"jsonrpc":"2.0","method":"session/update","params":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"..."}}}, {"jsonrpc":"2.0","id":"req-1","result":{"stopReason":"end_turn"}} ], "stop_reason": "end_turn", "duration_ms": 12345 } ``` `events` is the raw ACP NDJSON stream from acpx, parsed into objects. Each entry is a JSON-RPC message — `session/update` for streamed agent output, tool calls, plan updates, etc., and a final `result` envelope with the `stopReason`. No streaming/SSE in v0.2; the full event list is returned when the turn ends. ### Per-app isolation Every session is owned by the token that created it. Cross-token access returns `404` (not `403`) so token A can't even probe whether token B's session exists. ### TTL + cleanup A background sweeper runs every `CLAWDFORGE_SWEEP_INTERVAL_SECS` (default 60s): - Sessions idle longer than `CLAWDFORGE_SESSION_TTL_SECS` (default 1h) are soft-closed via `acpx sessions close`. - Sessions whose `closed_at` is older than `CLAWDFORGE_SESSION_HARD_TTL_SECS` (default 24h) are hard-deleted from clawdforge's ledger. - Closed sessions stay queryable via `GET /sessions/` until the hard-TTL fires. ### Container / deploy The container needs both `claude` and `acpx` on PATH plus a host-mounted volume for ACPX's session store: ```yaml # compose.yml (already configured) volumes: - /mnt/user/appdata/clawdforge/acpx-sessions:/root/.acpx/sessions ``` ```dockerfile ENV ACPX_BIN=acpx RUN npm install -g acpx@latest ``` ACPX shares Claude Code auth from the same `/root/.claude/` volume the v0.1 runtime already used, so a single `claude /login` ceremony covers both `/run` and `/sessions/*`. ## Notes - The CLI is `@anthropic-ai/claude-code` (not the Python `anthropic` SDK). - ACPX is the upstream session driver — see https://github.com/openclaw/acpx and `docs/CLI.md` in that repo for protocol semantics. Clawdforge owns the per-app ledger and TTL policy; ACPX owns session content. - Default model is `sonnet`; per-request override via `model` field on `/run`. For sessions, model is fixed at create time (configurable later). - Per-run working directory is staged under `RUNS_DIR` and torn down on exit, so `claude` can't pollute the container's working tree. - Per-session working directory is staged under `ACPX_SESSIONS_CWD` (default `/data/acpx-cwds/`) and torn down on close. - File uploads are scoped to the uploading app — token A can't reference token B's files.