- Session with consume-self close() (compile-time use-after-close)
- AtomicBool flag + Drop best-effort async close via tokio::spawn (logged on failure)
- Client::new_session / list_sessions / get_session
- TurnResult.text() helper, hand-written Debug to avoid bearer leak
- tests/sessions.rs: 12 tests covering new/close/idempotent/drop/list/state/404/text/debug-redaction
- README "Multi-turn / Sessions (v0.2)" section
v0.1 run path unchanged.
Spec: memory/spec-clawdforge-v0.2.md
Server core:
|
||
|---|---|---|
| clawdforge | ||
| clients | ||
| tests | ||
| .env.example | ||
| .gitignore | ||
| compose.yml | ||
| Dockerfile | ||
| LICENSE | ||
| README.md | ||
| requirements.txt | ||
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/<id>/turn send a turn to a session (v0.2)
GET /sessions/<id> read session state (v0.2)
DELETE /sessions/<id> 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/<name> revoke a token (admin)
POST /run
{
"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:
{
"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:
- IP allowlist — global CIDR list in
ALLOW_CIDRSenv. Loopback always allowed. Per-app allowlist optional on top (mint withip_cidrs: [...]). - Bearer token —
Authorization: Bearer cf_<...>for/runand/files,Authorization: Bearer <ADMIN_BOOTSTRAP_TOKEN>for/admin/*.
Tokens are SHA-256 hashed in SQLite. The plaintext is shown ONCE at create time.
Deploy
- SSH to Lucy:
ssh lucy mkdir -p /mnt/user/appdata/clawdforge/{data,claude-config,claude-alt-config}- Drop
.envat/mnt/cache/appdata/secrets/clawdforge.env(chmod 600, root:root) — see.env.example - Clone the repo to
/opt/stacks/clawdforge(Lucy uses Gitea reverse-tunnel pattern) cd /opt/stacks/clawdforge && docker compose up -d --build- Auth Claude CLI (one-time, persists on volume):
Walk through the device-auth flow. Credentials persist atdocker exec -it clawdforge claude /login/root/.claude/inside the container, mapped to/mnt/user/appdata/clawdforge/claude-config/on host. - Smoke:
Should reportcurl http://192.168.0.5:8800/healthzclaude_present: true+ a version string. - Mint a token for the first consumer:
Save the returnedcurl -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"]}'tokeninto the consumer's env.
Client snippet (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 —
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
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
{
"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 viaacpx sessions close. - Sessions whose
closed_atis older thanCLAWDFORGE_SESSION_HARD_TTL_SECS(default 24h) are hard-deleted from clawdforge's ledger. - Closed sessions stay queryable via
GET /sessions/<id>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:
# compose.yml (already configured)
volumes:
- /mnt/user/appdata/clawdforge/acpx-sessions:/root/.acpx/sessions
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 PythonanthropicSDK). - ACPX is the upstream session driver — see https://github.com/openclaw/acpx
and
docs/CLI.mdin 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 viamodelfield on/run. For sessions, model is fixed at create time (configurable later). - Per-run working directory is staged under
RUNS_DIRand torn down on exit, soclaudecan't pollute the container's working tree. - Per-session working directory is staged under
ACPX_SESSIONS_CWD(default/data/acpx-cwds/<session_id>) and torn down on close. - File uploads are scoped to the uploading app — token A can't reference token B's files.