sark

Architecture

One Worker, a Durable Object per thread, a forked sandbox, and a token that can address exactly one of them.

The pieces

Path
src/index.tsHono app. Routes: /health, /slack/events, /mcp, /api/*.
src/do/ThreadSession.tsThe state machine: fork → bootstrap → prompt → watchdog → idle-stop.
src/box/client.tsTyped client for the Box API v1.
src/box/bootstrap.tsRegisters this Worker's MCP server inside a box.
src/mcp/server.tsStateless JSON-RPC MCP server over streamable HTTP.
src/mcp/tools.tsThe five Slack tools and their handlers.
src/prompt.tsBuilds the turn: history, per-message blocks, agent instructions.
src/transport.tsTransport interface; MemoryTransport (the testing sink).
src/slack/verify.tsSlack signature verification.
src/slack/events.tsPure event interpretation: what to ignore, what to run.
src/slack/api.tsSlackClient + SlackTransport.
src/auth/token.tsThread-scoped HMAC tokens.
src/config.tsEnv, num(), idList(), isAllowed().

One Durable Object per thread

THREAD_SESSIONS.idFromName(threadId) maps a thread id to a Durable Object. For Slack that id is {team}:{channel}:{thread_ts}; for /api it's any opaque string you choose. Both resolve through the same function, which is why a live Slack thread is inspectable through the control API.

Everything a thread owns lives in that object's storage:

Key
sessionbox id and state, transport kind, Slack coords, MCP registration, last error, statusTs
runthe in-flight prompt: id, status, start time, how many times the agent has posted
queuemessages waiting for the current run to finish (max 20)
messageswhat MemoryTransport recorded (last 500)
seenEventslast 100 Slack event_ids, for dedup

Because a thread is a single object, one run per thread is a structural property rather than a lock: step() sees an active run and goes to the watchdog instead of starting anything. Different threads never contend; each has its own box.

Three entry points, one path

The three public routes are three different triggers for the same machine:

  • /slack/events verifies the signature, drops retries, interprets the event, checks the allowlist, then enqueues. Real work happens in waitUntil() after the 200 has gone out, because Slack demands a 200 within 3s.
  • /api/* is the same enqueue behind API_TOKEN, plus read routes over the same state.
  • /mcp is the return path: the box calling back. The bearer token names the thread, so nothing in the request body can change which thread is addressed.

The transport is what differs, not the pipeline. MemoryTransport and SlackTransport implement the same interface, and the Durable Object only ever talks to that interface, so the Slack-free path exercises the real code instead of a mock of it.

The state machine

Work is driven by Durable Object alarms, never inline in a request:

alarm → advance() → step()
                     ├─ run in flight?      → watchdog()
                     ├─ queue empty?        → maybeStopIdle()
                     └─ else                → ensureBox() → ensureMcp() → startPrompt()

Each of those steps either completes or schedules the next alarm. advance() serializes against itself (an alarm and an inbound request can both land there), and ensureLiveness() re-arms a thread that has outstanding work but no alarm, which heals a step that died before scheduling its successor.

Details in Thread lifecycle.

Why a Worker at all

The box is the thing that runs code; the Worker is the thing that decides which box, for which thread, on whose behalf, and for how long. Keeping that in one stateless Worker plus per-thread Durable Objects means there is no database, no queue, and no server to operate, and the security boundary is a token format rather than a network topology.

On this page