claude-worker

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 through createEngineRunner from 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 — session cwd (and job session.cwd, and the dir of /sdk-sessions listings) must resolve inside one of these roots. Strongly recommended.
  • buildRunnerConfig — map/patch every incoming CreateSessionRequest (client sessions and queue jobs alike) into the actual runner config: inject env, strip or override mcpServers, force allowedTools/disallowedTools, pin permissionMode.
  • Your authenticate hook — 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.