Guides
Deployment
Hosting realities — no serverless, single-host sessions, mandatory auth hook, and clamping client requests.
No serverless — and why
The Agent SDK spawns the Claude Code CLI as a long-running subprocess with filesystem state. Edge/serverless functions cannot host this. Realistic targets:
- a VM,
- a container with min-instances (so the process and its disk survive between requests),
- any Node ≥ 22 host with a real filesystem.
Node ≥ 22 also matters for the client side of the stack: @claude-worker/client relies on
platform fetch and WebSocket (global in Node ≥ 22), with fetchImpl/WebSocketImpl
injectable for older runtimes.
Single-host sessions and resume
Sessions are single-host in V1. Transcripts live on the server’s local disk (the SDK default),
and resume works across process restarts on the same host: pass resume: sdkSessionId on
CreateSessionRequest, and the server backfills the prior transcript as replay: true events.
GET /v1/sdk-sessions?dir=… lists the SDK’s on-disk sessions so hosts can offer “resume” after
a restart. Note the two ids: SessionInfo.id is the server-assigned id; sdkSessionId is the
Agent SDK’s — the one you feed back as resume.
Multi-host session storage is still on the roadmap: the bundled SessionStore implementations
are both single-process (see below). If you need the queue to span hosts, the QueueAdapter seam
is the supported path — see Job queue.
Restarts, parked sessions, and the deploy guard
A session parked on a deferred execution lives in a SessionStore. The default one is in-memory:
the park survives a client disconnect, not a restart. Since a park can legitimately last days,
a deploy that restarts the process is how parked work actually gets lost — so point it at the
bundled file store:
import { createFileSessionStore, createWorkerServer } from '@claude-worker/server'
createWorkerServer({
authenticate,
parking: {
store: createFileSessionStore({ dir: '/var/lib/claude-worker/parked' }),
// Boot grace for a deadline that lapsed while the process was down (default 60s):
// the watchdog must not fail every parked execution at t=0.
expiredGraceMs: 60_000,
},
})
hydrate() runs inside listen(), so the records are re-indexed and their watchdogs re-armed
before the server accepts a request. Three things to know:
- The directory holds plaintext transcripts. Each record is one parked session’s whole event
log and tool I/O. Protect it like the SDK’s own
~/.claude/projects. - Credentials never reach it. The stored config keeps the wire request only:
env,extraOptions, and injected functions are dropped, and a rebuilt provider session resolves credentials throughcreateEngineRunnerfrom the environment, exactly as the first build did. - One process per directory. Two servers sharing it would both hydrate and both rebuild the same sessions.
Durability doesn’t make a restart free. A turn actually in flight dies with the process, as does
a pending permission request, and a running job is left claimed. claude-worker guard asks a live
worker whether anything would be lost and exits non-zero while the answer is yes. It needs no
checkout and doesn’t care what started the worker:
# 0 = safe to restart, 1 = still busy, 2 = could not tell (bad URL, auth, unexpected response)
npx claude-worker guard --url https://worker.internal/v1 --token "$TOKEN" \
--wait 300 --allow-parked && systemctl restart claude-worker
--token is sent as Authorization: Bearer; for a host whose authenticate hook reads something
else, use --header name=value (repeatable).
It blocks on any session that is starting, running, or awaiting_approval, on any running
job, on parked sessions unless --allow-parked says a durable SessionStore is configured, and
on queued jobs unless --allow-queued says the same of the QueueAdapter. The two are separate
decisions: a durable session store under the bundled in-memory adapter still wakes a parked
session that no job is left waiting on, so the guard says so rather than pretending otherwise.
The auth hook is mandatory
createWorkerServer refuses to start without an authenticate hook unless you explicitly
pass allowUnauthenticated: true — an opt-in for loopback dev only. Never expose an
unauthenticated worker: whoever reaches it can run tool-wielding sessions in your checkouts.
const worker = createWorkerServer({
authenticate: async (req) => verifyMyAppToken(req.headers.authorization),
})
Return a truthy principal to accept, null/undefined to reject with 401. The hook covers every
route, including WebSocket upgrades (sessions WS and queue WS). Browsers cannot set WS headers —
use a ticket query param (buildWsUrl on the client) or cookies for socket auth.
Anthropic credentials are a separate concern entirely: the server implements no Anthropic auth;
the SDK/CLI resolves credentials from the operator’s environment. For services, set
ANTHROPIC_API_KEY and consider requireApiKey: true to fail closed on subscription
credentials — see Auth & Anthropic’s terms.
Clamp what clients may request
The server trusts its host app: CreateSessionRequest accepts mcpServers, tool policy, model,
and more. Three levers keep that safe:
allowedCwdRoots— sessioncwd(and jobsession.cwd, and thedirof/sdk-sessionslistings) must resolve inside one of these roots. Strongly recommended.buildRunnerConfig— map/patch every incomingCreateSessionRequest(client sessions and queue jobs alike) into the actual runner config: injectenv, strip or overridemcpServers, forceallowedTools/disallowedTools, pinpermissionMode.- Your
authenticatehook — decide who may create sessions at all.
const worker = createWorkerServer({
authenticate,
allowedCwdRoots: ['/srv/checkouts'],
buildRunnerConfig: (req) => ({
...req,
mcpServers: undefined, // clients don't get to bring their own MCP servers
permissionMode: 'default', // force interactive approvals
env: { ...process.env },
}),
requireApiKey: true,
})
See the server reference for every option, and Permissions for the approval flow those clamps feed into.