athena-claude-coder
The in-guest Agent Protocol server that drives the Claude Agent SDK for Athena coding tasks (ADR 0006).
The Athena deep agent delegates repository work to a claude-coder async
subagent (deepagents AsyncSubAgent). Its calls travel through the agora
coding-worker proxy (/api/coding-worker/{asset_id}/…), which authorizes every
request, wakes the computer, ensures this server is running, mints a router
grant, and forwards to loopback port 46100 inside the tenant's Talos v2
computer. Here, one ClaudeSDKClient per thread works in the checkout at
/workspace/template and answers with a compact structured result.
| Route | Who calls it | What it does |
|---|---|---|
GET /ok |
the ensure command, the proxy | unauthenticated liveness {ok, version}; adds busy, active_thread_id, owner, owner_since, owner_thread_id only when the guest token header matches (never 401) |
POST /threads |
threads.create |
create a task thread (the proxy supplies the id) |
GET /threads/{thread_id} |
threads.get |
the Thread shape; values.messages[-1].content is the result JSON |
GET /threads/{thread_id}/state |
threads.get_state |
{values, next, metadata} (parity) |
POST /threads/{thread_id}/runs |
runs.create |
start a Claude turn; multitask_strategy=interrupt supersedes a live one |
GET /threads/{thread_id}/runs/{run_id} |
runs.get |
the Run shape; error carries the reason on error/interrupted |
POST /threads/{thread_id}/runs/{run_id}/cancel |
runs.cancel |
interrupt + bounded drain → 204; idempotent |
PUT /internal/credential |
the proxy | rotate the task owner key on disk → 204 |
POST /internal/handoff/request |
the athena-claude launcher |
interrupt + drain the live run, lock the session for a human → {thread_id, claude_session_id, cwd, owner} |
POST /internal/handoff/return |
the launcher on exit | release the lock, record the session id the human ended on → {thread_id, owner} |
GET /threads/{thread_id}/transcript?after&limit |
the proxy (task card) | Claude Code's own session transcript for the thread, projected and redacted → {thread_id, claude_session_id, total, entries} |
Nothing else exists: no LangGraph in the guest, no streaming endpoints, no OpenAPI docs.
Install
From a checkout — what the environment bake does today:
uv tool install --python 3.12 /workspace/template/python-sdk/athena-claude-coder
athena-claude-coder --version
athena-claude-coder serve
install -m 0755 /workspace/template/python-sdk/athena-claude-coder/scripts/athena-claude /opt/athena/bin/athena-claude
From PyPI — only after the first human-triggered publish. Nothing publishes
automatically: a human dispatches
.github/workflows/publish-athena-claude-coder.yml from staging with
confirm=publish and the version that equals __version__ in
src/athena_claude_coder/__init__.py (the upload job runs in the pypi GitHub
environment, which holds the token and its approval/branch rules); until that
has happened for a version, athena-claude-coder==<version> does not resolve.
uv tool install --python 3.12 athena-claude-coder==<version>
athena-claude-coder --version # must print that version; the proxy refuses guests below its minimum
The wheel carries the server only. The athena-claude human launcher
(scripts/athena-claude) is not in it — install it from the checkout (the line
above) or from the bake runbook's inline copy.
claude-agent-sdk==0.2.151 is pinned exactly and bundles the Claude Code CLI.
Configuration
Everything comes from the environment; the proxy's ensure command exports it
before pm2 starts the server.
| Variable | Default | Meaning |
|---|---|---|
ATHENA_CODING_WORKER_PORT |
46100 |
loopback port (never public; the router grant targets it) |
ATHENA_CODING_WORKER_STATE_DIR |
/workspace/.claude-state |
SQLite, run logs, credential, settings.json; also CLAUDE_CONFIG_DIR |
ATHENA_CODING_WORKER_REPO_DIR |
/workspace/template |
Claude's cwd |
ATHENA_CODING_WORKER_DRAIN_SECONDS |
30 |
how long an interrupt waits for the turn's terminal message before killing the task |
ATHENA_CODING_WORKER_MAX_RUN_HOURS |
6 |
runs older than this are interrupted by the sweep |
ATHENA_CODING_WORKER_TOKEN |
— | shared secret; required for every route except GET /ok |
The gate
This server is not the security boundary — the microVM, the agora proxy
(per-request get_asset_with_permission_check + ABAC EDIT) and the gateway
budget are. Two things here are still load-bearing:
- Guest token. Every route except
GET /okrequiresX-Athena-Coding-Worker-Tokento equalATHENA_CODING_WORKER_TOKEN(constant-time compare) → otherwise401. If the variable is unset the server fails closed: those routes answer503 {"detail": "guest token not configured"}.GET /oknever 401s, but without a matching token it says only{ok, version}; the proxy's ensure command proves the running server adopted the current token withGET /threads/00000000-0000-4000-8000-000000000000→404(wrong token401, unset503). can_use_tool. Fail-closed defence in depth for Claude's tool calls: read-only tools pass, writes are confined to the checkout, the state dir and/tmp, destructive shell verbs are never allowed, andgit push,gh pr …, publishing and force operations need an explicitAUTHORIZED: <action>line in the task text. The credential files under the state dir are off-limits to every tool (and the search tools may not be pointed at the state dir root); shell references to them or to theathena-claude-keyhelper are refused.- One live task per computer. Every task shares one checkout and one
credential file, so the envelope's
max_concurrent_tasksis clamped to 1 until worktree-per-task lands (plan P2.4); a second thread gets409 computer busy. The slot is reserved before anything is written, so a create that loses a race leaves no transcript entry, run row or credential.
Credentials
The run's config.configurable.athena envelope — injected by the proxy, never
trusted from anywhere else — carries the task owner key. The server writes it
to <state_dir>/credential (mode 0600, atomic temp+rename, raw key only) and
<state_dir>/credential.json (expires_at, budget_user_id,
anthropic_base_url). Claude reads the key through the apiKeyHelper
(/opt/athena/bin/athena-claude-key, installed by the bake) named in the
settings.json that serve writes; the server process never passes it as an
environment variable.
serve also removes ANTHROPIC_API_KEY, ANTHROPIC_AUTH_TOKEN,
CLAUDE_CODE_OAUTH_TOKEN and CLAUDE_CODE_API_KEY_FILE_DESCRIPTOR from its
own environment before any client is built: ClaudeAgentOptions.env is merged
over os.environ and cannot unset a key, and the computer's own gateway key
is in the inherited environment.
Runs, results, durability
- One consumer. Only the run task iterates
client.receive_response(). Interrupts (cancel,multitask_strategy=interrupt, the sweep, shutdown) set a flag and callclient.interrupt(); the same loop drains the interrupted turn'sResultMessageand recordsinterrupted. The drain is bounded byATHENA_CODING_WORKER_DRAIN_SECONDS; past it the task is cancelled and the SDK closes the subprocess. - Result JSON. The last AI message is the compact result
(
status ∈ completed|blocked|failed,summary,branch,commit_sha,changed_files,tests,artifacts,blockers,questions, plusclaude_session_id,cost_usd,num_turns,terminal_reason,run_id). Budget exhaustion isstatus: blockedwithblockers: ["budget exhausted"]. A run issuccesswhenever Claude produced a terminal result — including error results — because the parent only reads the message onsuccess; a run iserroronly when the SDK or process failed. - Same thread = same Claude session. The session id from the
initmessage is persisted immediately; every later run on the thread resumes it. - Suspend-safe. State is SQLite plus files on the persisted tree. At boot
every
pending/runningrow becomesinterrupted(error: restart) with its session id intact; the sweep interrupts runs older thanATHENA_CODING_WORKER_MAX_RUN_HOURS.
Human handoff (P2.1)
A human in the computer terminal (Olympus terminal tab or ssh) can take a
task's Claude session over and give it back, on the same session: both
sides share CLAUDE_CONFIG_DIR and the checkout, so the next SDK run resumes
exactly where the human stopped.
State (sessions.owner) |
Entered by | What the SDK may do |
|---|---|---|
sdk |
a run starting | — (it is the run) |
human |
POST /internal/handoff/request: the live run is interrupted with reason handoff, drained through the single stream consumer, and recorded as a success whose last AI message is {"status": "blocked", "summary": "Handed off to a human in the computer terminal", "blockers": ["human_active"], "claude_session_id", "run_id"} — success so check_async_task reads it |
runs.create/reserve → 409 human holds the session, nothing written |
none |
POST /internal/handoff/return (records the session id the human ended on), a run finishing, or a restart clearing a dead sdk holder |
start a run; it passes resume=<claude_session_id> |
Both routes are idempotent and need the guest token. owner_since is the
moment the lock was taken (null for none) and doubles as the lock's
lease: the request returns it (with acquired: true the first time,
false while a human already holds the session), the return must present
it, and a return with a stale lease — or one arriving while the SDK owns the
thread — is a no-op. A human lock also occupies the computer's one task slot,
so other threads are refused with 409 computer busy while it is held. The
request carries the launcher's launcher_pid; a lock whose launcher process
is gone (closed terminal, suspended computer) is released at startup, before
every reservation and by the sweep. takeover: true re-mints the lease for a
second terminal; the earlier launcher's return then does nothing. The
authenticated GET /ok reports owner and owner_since for the live thread,
else the human-held or most recent session, and names that thread in
owner_thread_id (null when no session exists) — once a handoff has
drained the run and active_thread_id is gone, this is what ties a human
lock to its task.
scripts/athena-claude is the launcher the bake installs at
/opt/athena/bin/athena-claude: it reads the guest token from the running
server's pm2 environment (PM2_HOME=/workspace/.pm2-athena-coder pm2 jlist,
never printed), requests the handoff, runs claude --resume <session> (or a
fresh --session-id it chooses) with the SDK's exact environment and cwd, and
on exit — any exit code, via trap, retried while the worker is unreachable —
posts the return with the lease and the session id it ended on. It refuses
to start a second writer when another terminal holds the session
(ATHENA_CLAUDE_TAKEOVER=1 takes it over). ATHENA_CODING_WORKER_THREAD_ID
picks a thread explicitly; the default is the live one, else the most recent
session.
Concurrency
The run envelope's max_concurrent_tasks (the workspace policy) sets the
number of task slots, capped at HARD_MAX_CONCURRENT_TASKS = 4. With one
slot (the default) a task runs in the checkout itself (ATHENA_CODING_WORKER_REPO_DIR),
exactly as before. With more, every task thread gets its own git worktree:
git worktree add /workspace/worktrees/<thread12> -b athena/<thread12>from the checkout's currentHEADon the thread's first run (thread12= first 12 characters of the thread id); the path is persisted as the thread'scwd, so every later run — and a human handoff — lands in the same worktree. An existing worktree or branch (crash between create and persist, or a garbage-collected worktree) is adopted / re-attached, never recreated.- A thread occupies one slot while it has a live run, a reservation or a human
holder; the (N+1)th concurrent thread is refused with
409 computer busy, a follow-up run on a thread that holds a slot reuses it. Slot indices are stable while a thread holds its slot;GET /okreportsslots: {used, total},active_thread_ids(oldest first) and keepsactive_thread_id(the oldest live one);busymeans every slot is taken. - Each task's system prompt names its dev-server port,
46200 + slot index, and warns that other tasks may be using the neighbouring ports. - Each task reads its own credential:
runs.createwrites the envelope's owner key to<state_dir>/credentials/<thread_id>as well as to the sharedcredentialfile, and the run's--settingsfile (settings-<thread_id>.json) names anapiKeyHelperthatcats that copy, so concurrent tasks never bill each other's key. The shared file and the baked helper stay for the human launcher;PUT /internal/credentialmay carrythread_idto rotate a task's copy too. - A human handoff takes a slot exactly like a run (refused with
409 computer busybeyond the policy) and a never-run thread handed to a human gets its own worktree under a multi-slot policy. - Threads that already work in the checkout (first run under one slot, or any
0.2.0 row) keep it for transcript continuity; when the policy grows, at most
one such thread may be active at a time — a second is refused with
409 computer busyrather than allowed to share the tree. Two thread ids that share their first 12 characters get distinct worktrees (the second takes a longer name). - GC runs on the sweep cadence and removes a worktree only when its thread
is terminal (no live run, no reservation, owner
none), the worktree is clean (git status --porcelainempty and no commit ahead of the checkout'sHEAD) and its session has been idle for more than 24 h; eligibility is re-checked under the runner lock right before the removal, so a thread claimed meanwhile keeps its worktree. A dirty or ahead worktree is kept and logged once. Theathena/<thread12>branch is never deleted — it is the work product — and the checkout itself is never touched.
Transcript route
GET /threads/{thread_id}/transcript?after=<int>&limit=<int> (guest token
required) reads Claude Code's own session transcript for the thread — the
JSONL under CLAUDE_CONFIG_DIR/projects/<project-slug>/<claude_session_id>.jsonl,
where the project slug follows the thread's cwd (its worktree under a
multi-slot policy). Agora reads a task's turns through this route instead of
the guest pushing them into the Athena session: the session bridge has no
credential a guest could hold. The read is never taken under the runner lock
and runs in a worker thread.
404 thread not foundfor an unknown thread;404 no transcript for this thread yetwhile the thread has noclaude_session_idor the file does not exist yet.- One entry per content block of every user/assistant message:
{seq, role: "user"|"assistant", kind: "text"|"tool_use"|"tool_result"|"thinking", text, tool_name?, ts?}.seqis a stable 0-based index over the whole file,textis clipped to 4000 characters (atool_userenders compactly — the Bash command, the Read/Edit/Write path, the Grep pattern — and atool_resultcarries the result text and the name of the tool it answers),tsis the entry timestamp. Summary, system and meta lines are skipped. - The projection follows the live conversation the way the pinned SDK's
session reader finds it: the most recent turn no later record descends
from, and its
parentUuidancestors. Turns of an abandoned branch (a rewind in the terminal during a handoff) and subagentisSidechainrecords are left out. Unlike the SDK, acompact_boundaryis crossed through itslogicalParentUuid, so the turns before an auto-compaction stay in the card and theisCompactSummarymessage appears in their flow — following the SDK here would maketotalcollapse at every compaction.seqis therefore stable while the conversation only grows; should the live leaf move to another branch, earlier seqs can shift andtotalcan shrink — a consumer that seestotalbelow its cursor re-reads fromafter=0. - Two passes over the file: one for the
uuid/parentUuidgraph (a few fields per record), one that projects only live records and redacts and clips only the requested window — memory is the graph plus the page, not the transcript. afterreturns entries withseq >= after(negative → 0);limitis clamped to1..500, default 200;totalalways counts the whole file.- Credential shapes are redacted before anything leaves the guest
(
redact.py, mirroring the dcode mirror's list):sk-…keys, Bearer and Basic authorization,x-api-keyheaders, environment-style assignments whose name says credential (…_API_KEY=,…_ACCESS_KEY=,…_SECRET=,…_TOKEN=,…_PASSWORD=— the guest's ownATHENA_*/ANTHROPIC_*variables,AWS_SESSION_TOKEN,GITHUB_TOKEN, …), GitHub/AWS/Slack/Stripe shapes, PEM blocks and URL userinfo.
Tests
cd python-sdk/athena-claude-coder
pip install -e ".[dev]"
python -m pytest -v --tb=short && ruff check . && ruff format --check .
The suite never spawns a Claude process: a fake ClaudeSDKClient drives the
runner, and the protocol contract is exercised through the real
langgraph_sdk client on an ASGI transport. Tests marked post_merge need a
real Claude and are skipped unless ATHENA_CODING_WORKER_POST_MERGE=1.
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 athena_claude_coder-0.4.2.tar.gz.
File metadata
- Download URL: athena_claude_coder-0.4.2.tar.gz
- Upload date:
- Size: 89.4 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
68e080776833780f16753737d6d52781c3fc64217abab7640a5f53b1a88df880
|
|
| MD5 |
20751634c6d6b8ae9d19df317ffb764c
|
|
| BLAKE2b-256 |
5e3c8dfb60c1572fca3effda32116470dbc56047d8fc7bd493cf791a34db7ad9
|
File details
Details for the file athena_claude_coder-0.4.2-py3-none-any.whl.
File metadata
- Download URL: athena_claude_coder-0.4.2-py3-none-any.whl
- Upload date:
- Size: 57.0 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
0e29c12a992f255557f3ad0fe3ad4b677cc59d98ecf45308ef25eb82a5071474
|
|
| MD5 |
130b71c23e88dc524327313df987bd4e
|
|
| BLAKE2b-256 |
dbb9b3030e940799c63edef31dacadf9be1e3061975d0b31b61b81a30827df88
|