Overview
Architecture
Durable Objects
Gateway
Permissions
Transport
Stack
Deploy
Glossary + FAQ
GitHub App
CLAUDE CODE · SESSION ORCHESTRATOR

Duraclaw

A Durable-Object-backed orchestrator that runs Claude Code sessions on a VPS and lets any browser watch, control, and approve from anywhere — even after you close the tab.

One sentence: Duraclaw is a remote control panel for Claude Code — it decouples where the AI work happens (a server) from where you watch and interact with it (any browser).
Why this exists

Claude Code is fantastic — but it lives on one machine, in one terminal.

Start a session on your laptop, close the lid, and the conversation dies. Want to check progress from your phone on the subway? Not possible. Want three people on a team to watch the same agent edit a codebase? Everyone crowds around one monitor. The AI work is powerful; the harness around it is single-user, single-device, single-session.

Duraclaw splits the problem in half. The execution — Claude thinking, running shell commands, editing files — happens on a server you own. The interaction — watching tokens stream in, approving tool calls, asking follow-up questions — happens in any browser that can log in. Close the tab, open it again on your phone at midnight, approve the pending edit: same session, same state, same progress.

Who it's for: anyone running Claude Code on remote infrastructure who wants multi-device continuity, team visibility, or capability-without-autonomy for agent actions.

In plain terms
Durable Object (DO) = a Cloudflare primitive. Imagine a normal serverless function, but this one has a name (e.g. “session-abc-123”) and a private database, and asking for it by name always routes you to the same global instance. The rest of this guide is about what you do with that.

Worker = Cloudflare's serverless function. Runs at the edge (close to the user), very fast startup, no persistent disk. Duraclaw's Worker serves the web UI, handles login, and decides which DO your session lives in.

VPS = Virtual Private Server. A regular cloud Linux box you rent. Needed because Cloudflare Workers can't run arbitrary shell commands or persist files to disk — and Claude Code needs both.

WebSocket = a two-way connection that stays open. Unlike a normal web request (ask → answer → hang up), WebSockets let server and client both send messages whenever. Why Claude's streaming tokens can appear live in the browser.

Cloudflare Tunnel = a private connection dug from the VPS outward to Cloudflare. No open ports on the VPS. The tunnel lets the Worker reach the VPS without anyone on the internet being able to.
Cloudflare Workers
Durable Objects + SQLite
Agents SDK
Claude Agent SDK
WebSocket
CF Tunnel

The Hotel Analogy

Before diving in, build a mental model. Duraclaw is a hotel that runs AI sessions for guests.

HotelDuraclawWhy it matters
The guestYou, in your browserCan leave and come back. Nothing's stored on your device.
Front deskCloudflare WorkerAuth, routing, assigns you a "room" (SessionDO).
Your roomSessionDO (Durable Object)Private, persistent. Exists when you're not in it.
Room servicecc-gateway on VPSThe hands that actually run Claude Code.
IntercomWebSocketTwo-way real-time conversation.
Staff hallwayCloudflare TunnelPrivate, secure passage. Guests never see it.
Room logbookSQLite inside the DOEvery message written down forever.

Explore the Guide

Architecture
Three layers: browser, Cloudflare, VPS. Plus a click-to-response trace.
Durable Objects
The core primitive. Identity + memory + per-object SQLite.
Gateway
cc-gateway runs Claude. Localhost-only. Worktree-aware.
Permissions
Human-in-the-loop approval for every tool call.
Transport
Two WebSockets chained through a DO. Plus CF Tunnel.
Stack
Monorepo, Better Auth + D1, tech choices, Kata CLI.
Deploy
Ship Worker + VPS + Tunnel. Plus 6 patterns to steal.

Quick Links

GitHub
codevibesmatter/duraclaw
Durable Objects
Cloudflare docs
Agents SDK
useAgent() hook
Claude Agent SDK
Anthropic docs
02 · ARCHITECTURE

Three Layers

Each layer runs on different infrastructure, has different responsibilities, and talks to its neighbors through specific channels.

TOPOLOGY YOU (Phone / Laptop / Desktop) │ │ HTTPS + WebSocket (public internet)┌─────────────────────────────────┐ │ CLOUDFLARE Worker + Durable Objects │ └─────────────────────────────────┘ │ │ WebSocket over CF Tunnel (private, encrypted)VPS cc-gateway + Claude Agent SDK
1 · Browser (your device)React + TanStack Start — dumb viewport, stores nothing
2 · Cloudflare Worker + DOServes the site, auth (D1), manages SessionDO instances
3 · VPS (cc-gateway)Bun server running Claude, real filesystem, real shell
Key decision: the browser stores nothing. All state lives on the server. Close the tab, switch devices, come back — everything is still there.
In plain terms
Edge network = Cloudflare runs servers in 300+ cities. Workers execute at whichever one is closest to the user — login latency is measured in milliseconds, not seconds.

TanStack Start = a React “meta-framework” (like Next.js or Remix). Provides routing, server-side rendering, and API endpoints in one stack. Duraclaw's browser-side UI runs on it.

useAgent() = a React hook (shipped by Cloudflare's Agents SDK) that opens and auto-reconnects a WebSocket to a named DO. One line of code on the browser; the SDK handles pings, drops, retries.

Three layers aren't three services running in parallel — they're a chain. Each layer's only job is to forward messages between its two neighbors and add what only it can provide.

Cloudflare Workers can't do what Claude Code needs:

Real filesystem

Workers have no persistent disk. Claude needs to read and write files.

Shell commands

git, npm, python, pytest — Workers can't exec processes.

Long runtime

Workers cap at ~30s. Claude sessions run minutes to hours.

Project codebases

Real repositories, with history, on real disk with real worktrees.

Data Flow: Click to Response

What happens when you type "fix the login bug" and hit send:

1 · BrowseruseAgent() sends { type: "session.message", content } over WS
2 · SessionDOWrites to SQLite, forwards to VPS on WS #2
3 · cc-gatewayParses GatewayCommand, hands content to Claude SDK
4 · Claude SDKThinks, streams tokens, may request tool use
5 · cc-gatewayEmits { type: "session.message.delta", content } per token
6 · SessionDOStores + relays to browser on WS #1
7 · BrowserReact renders deltas — the "typing" effect

If Claude wants to edit a file, the chain pauses at step 5 and inserts an approval prompt (see Permissions).

Walk-through

“Fix the login bug” — one request, three continents, 3.2 seconds

  1. t+0.00s You type “fix the login bug” in the browser and hit Enter. useAgent() packs it into { type: "session.message", content } and shoots it over WebSocket #1.
  2. t+0.04s Cloudflare's edge routes the WS frame to the Worker in, say, Frankfurt. The Worker looks up your session cookie, locates your SessionDO (say, hosted in Amsterdam), and hands the message through.
  3. t+0.08s SessionDO in Amsterdam writes the user message to its private SQLite. Then forwards on WS #2 — through Cloudflare Tunnel — to your VPS in Frankfurt.
  4. t+0.15s cc-gateway on the VPS receives a GatewayCommand. It hands the prompt to the Claude Agent SDK running in the same process.
  5. t+0.4s First token of Claude's reply emerges. cc-gateway emits { type: "session.message.delta", content }. Streams back through the tunnel.
  6. t+0.42s SessionDO appends the delta to SQLite, then relays on WS #1 to your browser. React re-renders with a new character visible.
  7. t+1.1s Claude decides it needs to read login.ts. Emits a PreToolUse event. Gateway pauses Claude, forwards the request as a user_question.
  8. t+1.3s Your browser shows “Claude wants to read login.ts” with Approve/Deny buttons. You close the laptop.
  9. t+8m On the train, you open your phone. Session resumes — DO replays history from SQLite. The pending approval is still there. You tap Approve.
  10. t+8m 0.3s Approval travels browser → DO → gateway → Claude. File read runs; result flows back as token deltas; Claude proposes a fix. You approve the edit. Bug gone.
03 · DURABLE OBJECTS

SessionDO

The single most important class in Duraclaw. One instance per AI session, persists for its entire lifetime.

A regular Worker is a cashier — serves whoever's next, remembers nothing. A Durable Object is a personal banker — you're assigned to one, they know your history, you always talk to the same one.
Why Durable Objects are load-bearing

Every other pattern in Duraclaw falls out of one choice: put the session in a Durable Object.

Think about what a Claude Code session is: it's a long-running stateful conversation that belongs to you, keeps growing, and needs to survive you closing the browser. Classic web architectures handle this with a backend database + a stateless API + a frontend that polls. Lots of moving parts, lots of race conditions, lots of “did my message get through?”

A DO is a single global instance identified by name, with its own private SQLite. Asking for DO session-abc-123 always routes you to the exact same one — wherever it's currently running on Cloudflare's edge. That single-instance guarantee means no race conditions. The private SQLite means no external database. The long-lived WebSocket means no polling. One primitive replaces three layers of traditional backend infrastructure.

Once you have this session lives in this one named thing, everything else — the two-WebSocket chain, the replay-on-reconnect, the human-in-the-loop approvals — becomes implementable in a surprisingly small amount of code.

In plain terms
Single-threaded = exactly one instance is alive at any time, and requests are processed one at a time in arrival order. No two users can accidentally corrupt each other's data because there are no two copies to corrupt.

Eviction = Cloudflare shuts down an idle DO after a period of inactivity to free resources. The DO's code stops running, but its SQLite is preserved. Next request spins up a fresh instance, which reads state back from SQLite. The DO doesn't “know” it was evicted.

Alarms API = a DO can schedule “wake me up at timestamp X” and get called back even when no request arrives. Used for timeouts, retry logic, scheduled maintenance.

Migration (in the DO sense) = a SQL script (e.g., ALTER TABLE messages ADD COLUMN tool_results TEXT) paired with a version number. The DO keeps PRAGMA user_version and runs only newer migrations on every wake-up. Schema evolves across deploys; existing sessions upgrade automatically.

Idempotent = running twice gives the same result as running once. Migrations must be idempotent because DOs wake up many times over their lifetime; each wake-up runs the migration loop.

What DOs Give You

Identity

Ask for DO session-abc-123 and you always get the same global instance.

Single-threaded

One copy worldwide. Requests processed in order. No race conditions.

Private SQLite

Each DO has its own SQL database. Survives restarts and data-center moves.

WebSocket native

DOs hold open sockets — the foundation for real-time messaging.

Alarms API

Schedule "wake me up at X" callbacks. Used for reconnection retries.

Eviction-safe

Idle DOs evict; on next request they spin back up and read state from SQLite.

What SessionDO Owns

Session stateinitializing → active → paused → terminated
Message history (SQLite)Every message, tool call, tool result — forever
WebSocket relayHolds WS #1 (browser) and WS #2 (VPS) simultaneously
Reconnect alarmsIf VPS drops, schedules retry via Alarms API

The Agents SDK Base Class

SessionDO doesn't extend raw DurableObject. It extends Agent from the agents package, which gives browser-side the useAgent() React hook — typed, auto-reconnecting WebSocket with no manual management.

// browser side — the entire connection in one line const { messages, send } = useAgent({ name: "session-abc-123" })

DO Migrations

Need to add a column to session SQLite? Migrations are versioned and idempotent — run every wake-up, only new ones execute.

const migrations = [ { version: 1, sql: `CREATE TABLE messages (...)` }, { version: 2, sql: `ALTER TABLE messages ADD COLUMN tool_results TEXT` }, ]; export function runMigrations(sql: SqlStorage) { const current = sql.exec("PRAGMA user_version").one()?.user_version ?? 0; for (const m of migrations) { if (m.version > current) { sql.exec(m.sql); sql.exec(`PRAGMA user_version = ${m.version}`); } } }
Idempotent = running twice gives the same result as running once. No duplicate "Phone" columns, no matter how many times the DO wakes up.
Walk-through

SessionDO session-abc-123 over 48 hours

  1. mon 10:00 You start a new session from your laptop. Worker creates SessionDO named “session-abc-123.” Cloudflare boots an instance in Amsterdam (closest healthy colo). DO runs migrations (version 0 → 4), creates the messages table, accepts WS #1.
  2. mon 10:05 A flurry of messages. DO writes each to SQLite, relays tokens to the browser. Active.
  3. mon 10:45 You close the laptop. WS #1 dies. DO keeps WS #2 alive while Claude finishes a response; logs it to SQLite; WS #2 goes idle.
  4. mon 11:45 60 minutes of no traffic. Cloudflare evicts the DO instance to free memory. The Amsterdam box drops it; SQLite file is preserved.
  5. tue 09:00 You open the app from your phone. Worker asks for DO “session-abc-123.” Cloudflare boots a new instance — maybe in Dublin this time, different physical box. It reads SQLite; runs the migration loop (nothing new to apply, version matches); accepts the WS #1 from the phone.
  6. tue 09:00 DO replays history to the phone: every message, every tool call, every delta from yesterday in the order they happened. The phone UI renders the full transcript.
  7. tue 09:05 You type a follow-up. DO opens WS #2 to cc-gateway using the saved sessionId. Claude resumes — same workspace, same git worktree, same working memory as yesterday.
  8. tue 14:00 A new schema migration (version 5: add a latency_ms column) ships. Every DO wake-up from now on runs the migration loop; sessions upgrade individually the next time each is used.
Common mistakes
  • Treating a DO's in-memory state as persistent. Instance variables vanish on eviction. Only what you wrote to SQLite survives. If it matters beyond the next request, it goes to SQLite.
  • Forgetting to index by the actual query pattern. SessionDO's SQLite holds potentially thousands of messages. Queries without indexes get slow fast. Add an index on whatever you WHERE-filter by.
  • Non-idempotent migrations. INSERT INTO settings VALUES (1, 'default') in a migration is a bomb — every wake-up re-inserts. Use INSERT OR IGNORE or check existence first.
  • Holding references to DOs across requests. A Worker cannot “keep a handle” to a DO between invocations. Every request re-acquires by name (env.SessionDO.idFromName(...)). Cheap; not a real concern, but expect to see this pattern everywhere.
  • Assuming DOs never fail. They're remarkably reliable, but deploys, cold boots, and rare infra issues can briefly 500. Write code that retries rather than assumes.
  • Putting too much in one DO. A single DO is single-threaded globally. If every user's data goes into one “GlobalStateDO,” you've just reinvented a database with a single writer. Use one DO per entity.
04 · GATEWAY

cc-gateway

A Bun server on your VPS. The SessionDO is the brain; cc-gateway is the hands.

Bun runtime
127.0.0.1:9877
systemd service
Zero public ports
In plain terms
Bun = a JavaScript/TypeScript runtime, like Node but faster and with native TypeScript support. Runs cc-gateway on the VPS. Chosen for startup speed and built-in WS server.

systemd service = Linux's way of saying “this process should always be running.” If cc-gateway crashes, systemd restarts it. On reboot, it auto-starts. Logs flow to journalctl.

127.0.0.1 (a.k.a. loopback) = “this machine only.” A process bound here literally cannot be reached from the network. Perfect for a service that has root-level power inside the VPS.

shared-types package = a separate TypeScript package containing only type definitions (no runtime code). Both the Worker and the VPS depend on it; the compiler enforces that message shapes stay in sync across the WS boundary.

Worktree = a git feature where the same repo can have multiple checkouts on disk, each on a different branch. Duraclaw spins up one worktree per session so parallel agents don't trample each other's files.

bypassPermissions mode = a Claude Agent SDK flag that tells the SDK not to block tool calls itself. Duraclaw uses it because Duraclaw's own permission layer is better suited to the use case — gatekeeping in the UI rather than inside the SDK.

Why 127.0.0.1 Only

The gateway binds to localhost — no one on the internet can connect to it directly. Your VPS firewall can block all inbound traffic. The only way in is via Cloudflare Tunnel (outbound from VPS).

Why this matters: the gateway has the power to run arbitrary code via Claude. Localhost-only binding + outbound-only tunnel is defense in depth — you can't reach it even if you try.

The Protocol

Two message families flow over the DO ↔ gateway WebSocket, defined in shared-types.

Commands (Worker → VPS)

CommandMeaning
executeStart a new Claude session in this worktree
resumeResume a previous session using its SDK session ID
abortCancel the current session
answerReply to a question Claude asked

Events (VPS → Worker)

EventMeaning
session.initClaude ready, here's the model and available tools
assistantClaude said something (streaming text)
tool_resultA tool call finished, here's the result
user_questionClaude is asking the user, need an answer
resultSession complete. Duration and cost included.
errorSomething broke

shared-types: The Contract

Orchestrator and cc-gateway never import from each other. They share only the shared-types package — a no-runtime-code, types-only package that both sides depend on. Change a type; if either side is out of sync, the TypeScript compiler refuses to build.

// shared-types/src/index.ts (simplified) export type GatewayCommand = | { type: "session.create"; config: SessionConfig } | { type: "session.message"; content: string } | { type: "session.tool.approve"; toolCallId: string } | { type: "session.tool.deny"; toolCallId: string; reason: string } | { type: "session.terminate" }; export type GatewayEvent = | { type: "session.created"; sessionId: string } | { type: "session.message.delta"; content: string } | { type: "session.tool.request"; toolCall: ToolCall } | { type: "session.complete"; summary: string };

Worktrees

The gateway scans /data/projects/baseplane* for git worktrees (configurable via WORKTREE_PATTERNS). Each worktree is an independent working copy of the repo — six sessions can edit six branches at once without stepping on each other.

Running as a Service

cc-gateway runs under systemd as duraclaw-cc-gateway.service. Auto-starts on boot, auto-restarts on crash.

# install ./packages/cc-gateway/systemd/install.sh # manage systemctl start duraclaw-cc-gateway systemctl status duraclaw-cc-gateway journalctl -u duraclaw-cc-gateway -f
Common mistakes
  • Running cc-gateway in a terminal and closing the terminal. Process dies with the shell. Install the systemd service — that's what it's for.
  • Skipping CLAUDECODE* env-var stripping. If Claude launches inside a shell that still has those vars, nested sessions can spawn recursively. The gateway strips them; don't remove that code.
  • Sharing worktrees across sessions. Two sessions editing the same branch at the same time = git conflicts at best, corrupted state at worst. One worktree per session; use WORKTREE_PATTERNS to control the pool.
  • Comparing CC_GATEWAY_SECRET with ===. String-equality checks leak timing information. The gateway uses timingSafeEqual on WS upgrade — don't regress to ordinary compare.
  • Forgetting that bypassPermissions is load-bearing. Turn it off and the SDK blocks tools with its own (less flexible) prompts. The gateway's permission layer stops working as intended.
  • Editing GatewayCommand in one package without updating shared-types. TypeScript catches the mismatch at build time — but only if the build actually runs. Run pnpm build before pushing changes across the boundary.
05 · PERMISSIONS

Human-in-the-Loop

Claude runs with full power on your VPS. Before any tool fires, a human approves or denies it from the browser.

AI proposes, human disposes. The gateway runs Claude in bypassPermissions mode — the SDK itself doesn't block anything. The gateway adds its own approval layer on top, giving you capability without autonomy.
Why a human gate instead of an allowlist

Allowlists break. Humans generalize.

The obvious alternative is: give Claude a fixed list of safe commands, deny everything else. That works until the agent legitimately needs to do something you didn't anticipate. You either expand the allowlist (eroding safety) or give up on the task. Maintaining a growing allowlist is a full-time job; bugs in it are quiet and dangerous.

Human-in-the-loop sidesteps this. Claude is allowed to request any tool call. A human — the actual operator, on the actual session — reads what Claude wants to do and makes a case-by-case judgment. The human's context (what they asked for, what the agent said before, whether the repo is a production codebase) is richer than any allowlist can encode. The trade-off: you pay the latency cost of waiting for approvals. Duraclaw mitigates that by making approvals pausable and resumable across devices.

In plain terms
PreToolUse = a hook the Claude Agent SDK fires before a tool actually executes. Duraclaw intercepts this event; until the human decides, the tool call is queued, not run.

Timing-safe compare = a string-equality check that always takes the same amount of time regardless of where the mismatch is. Prevents attackers from using response timing to guess bytes of a secret one at a time. Used for CC_GATEWAY_SECRET.

Queued vs lost: when Claude asks for approval, the request lives in DO SQLite as a row with status: pending. Closing the browser doesn't discard it. Any later reconnection sees the pending row and re-surfaces the prompt. Nothing “expires” silently.

Audit trail = every approval, denial, and tool result is written to SQLite forever. If a bad edit ships, you can replay exactly who approved what and when.
Common mistakes
  • Adding an “auto-approve trusted commands” shortcut. The moment you do, an attacker prompt-injects Claude into requesting a “trusted” command with dangerous parameters. If you want speed, reduce what gets proposed (via system prompt) — don't skip the approval gate.
  • Not logging denials. You learn nothing from a session where every deny is silent. Write the denial reason to SQLite and display it in Claude's transcript so it stops re-requesting.
  • Treating “no pending approvals” as “everything's fine.” A session with no pending prompts can still be mid-hallucination. The gate catches tool use; it doesn't catch wrong thinking.
  • Letting a single user approve across multiple active sessions without context. Clicking through prompts on five sessions at once is a recipe for misclicks. Show the session name + worktree prominently on every prompt.

The Flow

TOOL APPROVAL ROUND-TRIP Browser ──▶ SessionDO ──▶ cc-gateway ──▶ Claude Agent SDKPreToolUse eventcc-gateway ◀── Claude Agent SDK │ ▼ Browser ◀── SessionDO ◀── cc-gateway (tool approval prompt: "Claude wants to edit login.ts") │ ▼ user clicks Approve or Deny Browser ──▶ SessionDO ──▶ cc-gateway ──▶ Claude Agent SDK (tool runs or aborts)

Step-by-step

1. Claude emits PreToolUseIntercepted by cc-gateway before execution
2. Gateway pauses the sessionTool call is queued, nothing runs yet
3. user_question event firesSent to SessionDO over WS #2
4. DO relays to browserStored in SQLite, forwarded on WS #1 — shows as prompt
5. User approves or denies{ type: "session.tool.approve" | "deny", toolCallId }
6. Gateway resolves the gateTool executes — or returns a denial to Claude

Safe by Default

No nested sessions

Gateway strips CLAUDECODE* env vars before spawn — Claude can't launch Claude.

Timing-safe bearer auth

CC_GATEWAY_SECRET checked on WS upgrade using constant-time compare.

Paused ≠ lost

Tool call lives in DO state. User can walk away; approve from phone later.

Full audit trail

Approvals, denials, and results all written to DO SQLite.

When to Steal This Pattern

Deploy any AI agent that takes actions (not just generates text) — financial transactions, infrastructure changes, code edits, database modifications. The pattern is capability-without-autonomy: give the model everything, hold the trigger in a human hand.

06 · TRANSPORT

WebSockets + CF Tunnel

Two chained WebSockets and one outbound-only tunnel. The whole pipeline, end to end.

HTTP vs WebSocket

HTTPWebSocket
Ask, answer, closeStays open indefinitely
Client initiates every exchangeEither side sends at any time
Like sending lettersLike a phone call
Good for: page loads, form postsGood for: chat, streaming, real-time

Claude streams tokens. The browser must receive them as they're generated — no polling, no "any new tokens?" loop. That's WebSocket.

The Chain

TWO WEBSOCKETS, ONE DO IN THE MIDDLE Browser ═══WS 1═══▶ SessionDO ═══WS 2═══▶ cc-gateway (VPS) WS 1: public internet, via Cloudflare WS 2: over Cloudflare Tunnel — private, encrypted
Why put the DO in the middle? If WS 1 drops (browser closes), the DO still owns WS 2 — Claude keeps working. When the browser reconnects, the DO replays missed messages from SQLite. The middleman is what makes "close the tab and come back" possible.
In plain terms
WS upgrade = a WebSocket doesn't exist until a normal HTTP request is “upgraded” by mutual agreement. The browser asks politely (Upgrade: websocket), the server agrees, and the TCP connection is then used as a bidirectional pipe instead of a request/response pair.

Streaming = sending a response piece-by-piece as it's generated, not waiting for the whole thing. Why replies appear word-by-word. Over HTTP, streaming is tricky at the edge; over WebSocket, it's just a series of messages.

Chained WebSockets = two separate WS connections glued together by the DO. WS #1 is browser ↔ DO. WS #2 is DO ↔ VPS gateway. The DO's job is to pump bytes between them, write to SQLite as it goes, and handle reconnection on either side independently.

cloudflared = Cloudflare's daemon you install on the VPS. Opens an outbound connection to Cloudflare's edge and advertises “I can reach localhost:9877.” Cloudflare assigns a hostname; traffic flows back down the tunnel. Firewall never has to allow inbound traffic.
Walk-through

Closing the laptop mid-session — what the two WebSockets actually do

  1. t+0s Claude is generating a 30-second response. Tokens are streaming from VPS → DO → browser. Both WS #1 and WS #2 are alive.
  2. t+3s You close the laptop lid. The browser's WS #1 dies. The DO notices (socket close event), logs “client disconnected” in SQLite.
  3. t+3s The DO does not stop WS #2. Claude keeps generating on the VPS. The gateway keeps sending deltas over WS #2. The DO keeps receiving them, keeps writing to SQLite.
  4. t+15s Claude finishes the response. Last delta + session.complete written to SQLite. WS #2 stays open, idle.
  5. t+8min You open your phone. useAgent() on the new browser fires a WS upgrade to the same DO (same name = same instance).
  6. t+8min 0.1s DO sees a new WS #1 connect. It replays history: reads every SQLite row since the last known cursor, emits each as a WebSocket message.
  7. t+8min 0.5s The phone renders the full response that was generated while you were away, then settles into streaming mode for the next exchange.
  8. t+8min 1s You type a follow-up. New message flows through the still-alive WS #2 — Claude on the VPS never noticed anything.

Cloudflare Tunnel

The gateway binds to 127.0.0.1. The Worker runs on Cloudflare. How do they meet?

1. Install cloudflared on VPSConnects outward to Cloudflare — no inbound ports
2. Cloudflare assigns a hostnamee.g. gateway.yourdomain.com
3. Worker connects to wss://gateway.yourdomain.comCloudflare routes through tunnel to 127.0.0.1:9877
Analogy: your VPS is a house with no doors. The tunnel is dug from inside the house to a Cloudflare relay station. People can't walk up to your house, but they can visit the relay, which sends messages through the tunnel. The house initiated it, so the house is in control.

What You Get

Zero open ports

VPS firewall can block all inbound. Tunnel is outbound only.

Free TLS

Cloudflare handles certs. wss:// encrypted end to end.

DDoS protection

Traffic flows through Cloudflare's network, which mitigates automatically.

Clean separation

VPS handles compute. Cloudflare handles networking and security.

In the Worker config, the tunnel URL is stored as the CC_GATEWAY_URL secret.

Common mistakes
  • Binding the gateway to 0.0.0.0 “just during testing.” Exposes the gateway to the entire internet; anyone who finds the port can drive Claude. Use 127.0.0.1 always; use the tunnel for external access.
  • Forgetting to rotate CC_GATEWAY_SECRET after sharing a test environment. The bearer token gates WS #2 — if someone sees it, they can impersonate the Worker. Rotate after anyone's had access.
  • Not handling WS #1 reconnect on the browser side. useAgent() reconnects automatically, but if you bypass it with raw WebSocket code, stale connections appear alive while sending nothing. Use the SDK.
  • Running multiple gateways against one tunnel hostname. Cloudflare rotates between them, so WS #2 messages split across gateways — state lives in whichever one happened to receive the frame. One tunnel hostname per gateway instance.
07 · STACK

Monorepo, Auth, Tools

Everything outside the core runtime: how the code is organized, how users log in, and how the team ships features.

Monorepo Layout

duraclaw/ apps/ orchestrator/ ← Cloudflare Worker + React UI src/server.ts ← Main entry: DO classes + TanStack handler wrangler.toml ← Worker configuration packages/ cc-gateway/ ← The VPS executor src/server.ts ← Bun WebSocket server systemd/ ← Linux service files shared-types/ ← Types shared by both sides src/index.ts ← GatewayCommand, GatewayEvent kata/ ← Development workflow CLI ai-elements/ ← Shared AI UI components planning/ ← Specs, roadmap, progress scripts/verify/ ← Verification scripts .kata/verification-evidence/ ← Proof things actually work

pnpm manages workspace linking ("@duraclaw/shared-types": "workspace:*" resolves to the local folder). turborepo builds packages in dependency order and caches aggressively.

Auth: Better Auth + D1

Better Auth is a TypeScript auth library (login, cookies, sessions). D1 is Cloudflare's serverless SQLite. Together they handle user accounts, login sessions, and auth tokens.

D1 vs DO SQLite — what's the difference? D1 is a shared database for the whole app. One database, all users. Used for auth. DO SQLite is per-Durable-Object. Each SessionDO has its own private SQLite — used for that session's message history. D1 is the hotel's guest registry; DO SQLite is the diary inside each room.

When to use which storage

DataStorageWhy
User accounts, passwordsD1Global lookup by email. One source of truth. Needs to survive any individual DO going away.
Session messages & tool historyDO SQLiteScoped to that session. Private. Deletes cleanly when session is torn down. No global queries needed.
App-wide config / feature flagsD1 or Workers KVAll sessions need to read it. Put it where everyone can see.
Cross-session user state (prefs, quota)UserDO with its own SQLiteSame per-entity pattern, one DO per user instead of per session.
Large binary filesR2 (object storage)DO SQLite and D1 are row-stores — not for megabytes of blobs.
In plain terms
Better Auth = a TypeScript library that handles the mechanics of login (sessions, cookies, CSRF, OAuth). Duraclaw wires it to D1 as the backing store.

Drizzle ORM = a lightweight object-relational mapper for TypeScript. Lets you write db.select().from(users).where(...) instead of raw SQL strings. Generates migrations from type definitions.

Hono = a tiny web framework for Workers. Think Express-like routing, but built for edge runtimes. Duraclaw's API routes live here.

Zustand = a minimal React state-management library. Used for browser-side UI state that doesn't belong in the DO (sidebar open/closed, theme toggle, etc.).

Radix UI = unstyled but accessible React primitives (dropdowns, dialogs, tabs). Duraclaw styles them with Tailwind; the accessibility heavy-lifting is already done.

Biome = a single binary that replaces ESLint + Prettier. Faster; opinionated. Run it in CI to reject unformatted code.

Turborepo = orchestrates builds across the monorepo's packages in the right order, caching results. The shared-types package builds once; every consumer reuses it.

pnpm workspace = a package manager with native monorepo support. "workspace:*" tells pnpm “link the sibling package, not a version from npm.”
1. Visit /loginEmail + password form
2. Worker checks D1Better Auth handler validates credentials
3. Cookie issuedSession cookie sent to browser
4. Every request validatedCookie checked on each Worker invocation

Routes live at /api/auth/*. The auth instance is created per-request because D1 is only available within a request context. Gateway auth is separate — a bearer token (CC_GATEWAY_SECRET) checked with timing-safe compare during WS upgrade.

Tech Stack

TechWhat it isWhere used
TypeScript 5.8JS with typesEverywhere
React 19UI libraryBrowser
TanStack StartReact meta-framework, file-based routingBrowser
Vite 7Build tool + dev serverBrowser
Cloudflare WorkersServerless edge functionsOrchestrator
Durable ObjectsStateful Workers + SQLiteSession management
Agents SDKReal-time DO + React hookSessionDO + browser
Better AuthTS auth libraryUser login
Drizzle ORMTS ORM for SQLD1 auth DB
D1Cloudflare's SQLiteUser accounts
HonoLightweight Worker frameworkAPI routes
BunJS runtime, faster than NodeVPS gateway
Claude Agent SDKAnthropic's programmatic ClaudeVPS gateway
Vercel AI SDKFramework-agnostic AI toolkitChat transport
ZustandLightweight state managementBrowser UI
Radix UIUnstyled accessible primitivesUI components
Tailwind 4Utility CSSStyling
BiomeLinter + formatterCode quality
TurborepoMonorepo build orchestrationBuild pipeline
pnpmWorkspace-aware package managerDependencies
WranglerCloudflare CLIDev + deploy

Kata: The Dev Workflow CLI

Not part of the runtime — a CLI for the team building Duraclaw. It choreographs the dev process into 8 modes, each with completion gates and verification evidence.

planning

Design features, write specs, break work into phases.

implementation

Write the actual code.

research

Investigate APIs, explore options, read docs.

task

Execute a specific, bounded task.

debug

Fix a specific bug.

verify

Run verification against the real system.

freeform

Open-ended work that doesn't fit other modes.

onboard

Learn the codebase — like this guide.

Verification-Driven Development

The rule: prove things work by testing the real system, not mocks. Every phase produces verification evidence in .kata/verification-evidence/.

CommandWhat it verifies
pnpm verify:dev:upStarts local gateway + orchestrator
pnpm verify:preflightcurl, jq, agent-browser installed
pnpm verify:authSigns in a test user, saves cookies
pnpm verify:gatewayReal API calls to gateway endpoints
pnpm verify:sessionCreates a real session, polls completion
pnpm verify:browserOpens real browser, logs in via /login
pnpm verify:browser:sessionOpens a session page, checks live output
pnpm verify:smokeRuns all of the above in sequence
Common mistakes
  • Running verifications against mocked services. The whole point of the Kata verification-driven approach is hitting real endpoints. Mocking defeats it; bugs in the real glue stay hidden.
  • Hand-editing wrangler.toml after wrangler d1 create. The command prints a database_id you need to paste in. Miss the paste → Worker talks to the wrong D1 → auth fails in a very confusing way.
  • Updating shared-types in one consumer's branch without rebuilding siblings. Turborepo caches per-package; a stale cached build can ship with an out-of-date type. Run pnpm -w build from the root after any shared-types change.
  • Importing from @duraclaw/cc-gateway inside the Worker. The two sides must stay decoupled. Only shared-types is allowed to cross the boundary. Enforce via lint rules.
  • Skipping Drizzle migrations in dev because “it worked last time.” D1 schema drifts silently. Run wrangler d1 migrations apply whenever you pull.
08 · DEPLOY

Ship It

Deployment is two separate things — Orchestrator to Cloudflare, Gateway to VPS. Then six patterns you can steal for your own projects.

Orchestrator → Cloudflare

# 1. Create the D1 auth database wrangler d1 create duraclaw-auth # update database_id in wrangler.toml # 2. Set secrets (encrypted, runtime-only) wrangler secret put CC_GATEWAY_URL wrangler secret put CC_GATEWAY_SECRET wrangler secret put BETTER_AUTH_SECRET wrangler secret put BETTER_AUTH_URL # 3. Run D1 migrations wrangler d1 migrations apply duraclaw-auth # 4. Deploy cd apps/orchestrator && pnpm deploy

Gateway → VPS

1. Install Buncurl -fsSL https://bun.sh/install | bash
2. Clone repo + install depspnpm install in repo root
3. Install systemd service./packages/cc-gateway/systemd/install.sh
4. Configure CF Tunnelcloudflared tunnel → 127.0.0.1:9877 → hostname
5. Set CC_GATEWAY_URL secretwrangler secret put with tunnel hostname
6. Verifypnpm verify:smoke from project root

The Six Patterns You Can Steal

1 · One DO per entity

Per-session, per-user, per-room DOs. Self-contained state machines with private SQLite. No race conditions, built-in persistence.

2 · Agents SDK + useAgent()

Typed, auto-reconnecting WebSocket between browser and DO. No manual connection management.

3 · WebSocket ChatTransport

Custom AI SDK transport over WebSocket instead of HTTP streaming. Works around Worker streaming limitations.

4 · Localhost + CF Tunnel

Bind to 127.0.0.1 only. Use tunnel for external access. Zero open ports. Defense in depth for privileged services.

5 · Human-in-the-loop tools

Intercept PreToolUse, relay to UI, gate on user decision. AI proposes, human disposes.

6 · Verification-driven dev

Real HTTP calls, real browser, real API responses. Not mocks. Especially valuable when AI writes code.

In plain terms
Wrangler = Cloudflare's CLI. Deploys Workers, creates D1 databases, sets encrypted secrets, runs local dev. Everything you can do in the dashboard you can do in Wrangler.

Wrangler secret = an environment value Cloudflare encrypts and injects into your Worker at runtime. Unlike vars in wrangler.toml, secrets never appear in your git history.

D1 migrations = versioned SQL files applied in order to bring the auth database schema to the expected state. Run locally and in production via wrangler d1 migrations apply.

cloudflared = the CLI/daemon that creates the tunnel from VPS → Cloudflare edge. Different binary from wrangler; lives on the VPS, not your laptop.

Tunnel token = a credential cloudflared uses to authenticate itself to Cloudflare. Store in the systemd unit's environment; do not commit.

Threat Models — What Could Actually Go Wrong

Deploying a system that can run arbitrary code on a VPS invites specific attacks. Each scenario below pairs with the layer that stops it.

Someone port-scans my VPS and finds :9877.
Attacker looks for open ports on cloud IPs and tries to connect to anything they find.
Gateway binds 127.0.0.1 only. The port exists but is unreachable from the network. Pair with a firewall rule denying all inbound except SSH.
An attacker finds my Cloudflare Tunnel hostname and tries to connect.
Tunnel hostnames are discoverable (DNS, TLS certificate logs). Random traffic arrives.
WS upgrade requires Authorization: Bearer CC_GATEWAY_SECRET with timing-safe compare. Unauthenticated traffic gets 401.
A user's login cookie is stolen (XSS on another app on the same domain).
Attacker replays the cookie and has full session access as that user.
Better Auth issues HttpOnly + SameSite=Lax cookies — not readable by JavaScript. Rotate secrets periodically; use device-binding where session sensitivity warrants.
A malicious prompt convinces Claude to run rm -rf ~.
Prompt injection via a compromised file Claude is asked to read.
Every tool call requires human approval — the human sees “Claude wants to run rm -rf ~” and denies. Approval gate is the final line.
I accidentally commit CC_GATEWAY_SECRET to GitHub.
Secret scanners index the repo within minutes; bots try it against any discoverable tunnel.
Rotate immediately: wrangler secret put CC_GATEWAY_SECRET with a new value, restart duraclaw-cc-gateway. Old secret becomes useless. Use git-secrets as a pre-commit hook going forward.
A shared tunnel hostname is enumerated and probed.
Traffic from unknown IPs hits the WS upgrade endpoint thousands of times per hour.
Cloudflare's Zero Trust rules can limit the tunnel to specific IPs or identities. Combine with rate limiting at the Worker (which is the only legitimate caller) for defense in depth.
Common mistakes
  • Deploying the Worker before D1 migrations run. First login hits an empty database, Better Auth 500s. Always wrangler d1 migrations apply before pnpm deploy.
  • Setting secrets in wrangler.toml under vars. vars are committed. Use wrangler secret put — different mechanism, encrypted at rest.
  • Forgetting CC_GATEWAY_URL after rotating tunnel hostname. Worker connects to the old hostname, fails silently with timeouts. Run wrangler secret put CC_GATEWAY_URL and redeploy.
  • Not setting a firewall on the VPS. Cloud providers' default networks are more open than people expect. Enable UFW or security groups allowing only SSH inbound.
  • Running cc-gateway as root. Unnecessary privilege elevation. Create a dedicated user; systemd unit specifies User=duraclaw.
  • Skipping pnpm verify:smoke after a deploy. The full test suite catches misconfigured secrets, broken migrations, tunnel issues. Takes 90 seconds; saves hours.

References

GitHub Repo
codevibesmatter/duraclaw
Durable Objects
Cloudflare docs
Agents SDK
developers.cloudflare.com
Claude Agent SDK
docs.anthropic.com
Cloudflare Tunnel
developers.cloudflare.com
TanStack Start
tanstack.com/start
Vercel AI SDK
sdk.vercel.ai
09 · REFERENCE

Glossary + FAQ

Every piece of jargon in Duraclaw, explained in plain English. Jump to a letter or scroll.

A
Agents SDK Cloudflare package
Cloudflare's library that pairs a Durable Object base class (Agent) with a React hook (useAgent). Gives you a typed, auto-reconnecting WebSocket between browser and DO with almost no boilerplate.
AGENTS.md
Markdown file at the repo root with instructions Claude reads before every turn. Project conventions, style rules, don't-dos.
Alarms API
Durable Object feature: state.storage.setAlarm(timestamp) schedules a wake-up callback even when no request arrives. Used in Duraclaw for reconnect retry logic.
B
Better Auth
TypeScript auth library. Handles login forms, session cookies, CSRF, OAuth providers. Backed by D1 in Duraclaw.
Biome
Single binary that replaces ESLint + Prettier for linting and formatting. Faster; opinionated. Used in CI.
Bun
JavaScript/TypeScript runtime, faster than Node, with native TS support. Runs cc-gateway on the VPS.
bypassPermissions mode
Claude Agent SDK flag that disables the SDK's own tool-approval prompts. Duraclaw uses it because the gateway layer (not the SDK) is where approvals happen.
C
cc-gateway
Bun server running on the VPS at 127.0.0.1:9877. The executor: runs Claude, exposes shell + filesystem, relays messages over WS #2.
CF Tunnel Cloudflare Tunnel
Encrypted outbound tunnel from the VPS to Cloudflare's edge. No inbound ports needed. Lets the Worker reach cc-gateway privately.
ChatTransport
Vercel AI SDK abstraction for “how messages move between client and server.” Duraclaw implements a custom WebSocket transport instead of the default HTTP streaming.
Claude Agent SDK
Anthropic's programmatic Claude interface. Exposes tool-use hooks (like PreToolUse) and streaming events. Lets cc-gateway drive Claude Code-style sessions from code.
cloudflared
The daemon that creates the Cloudflare Tunnel. Runs on the VPS; connects outward to Cloudflare; doesn't accept inbound traffic on its own.
Cookie (HttpOnly, SameSite)
Auth cookie flags. HttpOnly = not readable by JS (defeats XSS exfiltration). SameSite=Lax = not sent on cross-site requests (defeats CSRF).
D
D1
Cloudflare's serverless SQLite. A shared database queried via the Workers runtime. Duraclaw uses it for user accounts and auth sessions.
DO Durable Object
Cloudflare primitive: a stateful, globally-addressable, single-instance Worker with its own private SQLite. Asking for a DO by name always routes to the same instance.
Drizzle ORM
TypeScript ORM for SQL databases. Type-safe query builder; generates migrations from schema declarations. Used with D1.
E
Edge network
Cloudflare's 300+ global data centers. Workers run at whichever edge is closest to the user; DOs run wherever is optimal for that instance.
Eviction
Cloudflare shutting down an idle DO instance to free resources. SQLite state is preserved; a later request spins up a new instance that reads state back.
G
GatewayCommand
Message type flowing from Worker → VPS (“do this”). Defined in shared-types. Examples: session.message, session.tool.approve.
GatewayEvent
Message type flowing VPS → Worker (“this happened”). Examples: session.message.delta, session.tool.request, session.complete.
H
Hono
Tiny web framework built for edge runtimes (Workers, Bun, Deno). Express-like routing. Duraclaw's API endpoints live here.
Human-in-the-loop
Design pattern where an AI proposes actions but a human approves each one before execution. Duraclaw's core safety property.
I
Idempotent
Running twice produces the same result as running once. Critical for DO migrations — which run on every wake-up.
K
Kata
A development-workflow CLI internal to the Duraclaw team. Organizes work into modes (planning, implementation, debug, verify) with completion gates. Not part of the runtime.
M
Migration (DO)
Versioned SQL script applied to a DO's SQLite on wake-up. PRAGMA user_version tracks what's already run. Idempotent.
Monorepo
One git repo holding multiple related packages. Duraclaw uses pnpm workspaces + Turborepo to coordinate builds and type-check across them.
P
pnpm workspace
pnpm's native monorepo support. "workspace:*" in a package.json resolves to the sibling package in the same repo.
PreToolUse
Claude Agent SDK event fired before a tool actually executes. Duraclaw intercepts this to queue the call and ask the user for approval.
R
Radix UI
Library of unstyled, accessible React primitives (menus, dialogs, tabs). Used with Tailwind to ship accessible UI quickly.
Replay (on reconnect)
When a browser reconnects, the DO reads its message history from SQLite and emits each message as a WS frame so the UI can render the full transcript.
S
SessionDO
The DO class that represents one Claude Code session. One instance per session, identified by session ID. Owns WS #1, WS #2, message history, state machine.
shared-types
Monorepo package with only TypeScript type definitions (no runtime code). Both Worker and VPS depend on it; compiler enforces WS protocol consistency.
State machine
Fixed set of states with defined transitions. SessionDO moves through initializing → active → paused → terminated.
Streaming
Emitting a response piece-by-piece as it's generated. Claude's tokens stream from VPS → DO → browser as they come out of the model.
systemd
Linux init system. duraclaw-cc-gateway.service tells systemd “run this command, restart on crash, start on boot.”
T
TanStack Start
React meta-framework: file-based routing, server-side rendering, typed RPC. Duraclaw's browser UI runs on it.
Timing-safe compare
String equality that takes the same time regardless of where the mismatch is. Prevents timing-based secret extraction. Used for gateway bearer auth.
Turborepo
Build orchestrator for monorepos. Caches per-package builds; knows dependency order.
U
useAgent()
React hook from Cloudflare's Agents SDK. Opens an auto-reconnecting WebSocket to a named DO. One line of code = a live connection.
V
Vercel AI SDK
Framework-agnostic toolkit for building AI chat UIs. Duraclaw uses parts of it (message format, transport abstraction) but swaps in a custom WebSocket transport.
Vite
Fast build tool + dev server for frontend code. The browser-side Duraclaw bundle builds through Vite 7.
VPS Virtual Private Server
A regular rented Linux box on any cloud. Duraclaw runs cc-gateway + Claude here. Needed for real shell, real filesystem, long-running processes.
W
WebSocket
A persistent two-way connection. Browser ↔ DO is WS #1; DO ↔ cc-gateway is WS #2. Streaming tokens, approval prompts, and live state updates all ride WebSockets.
Worker
Cloudflare's serverless function. Runs at the edge. No persistent disk; ~30s max runtime. Duraclaw's Worker serves the UI, handles auth, and manages DOs.
Workers KV
Cloudflare's eventually-consistent global key-value store. Not used heavily by Duraclaw but common alongside Workers for feature flags and cached config.
Worktree
Git feature: multiple working directories for the same repo, each on its own branch. Duraclaw uses one worktree per session so parallel agents don't interfere.
Wrangler
Cloudflare's CLI. Deploys Workers, creates D1 DBs, runs migrations, manages secrets, spins up local dev.
WS upgrade
The HTTP handshake that promotes a request to a WebSocket. Headers: Upgrade: websocket, Connection: Upgrade. Auth checks happen here in Duraclaw.
Z
Zustand
Minimalist React state-management library. Holds browser-only UI state (sidebars, modals, theme) that doesn't belong in a DO.

Frequently Asked Questions

Can I run Duraclaw without a VPS?

No — the VPS is load-bearing. Cloudflare Workers can't run arbitrary shell commands, can't persist to a real filesystem, and have a ~30s execution cap. Claude Code needs all three. The VPS provides them. Cheapest reasonable VPS works; you're not after raw CPU, you're after the ability to exec git, npm, pytest.

How many simultaneous sessions can one VPS handle?

Depends on what the sessions do. Idle sessions cost near-nothing. Active sessions running a Claude turn each consume bandwidth and whatever the tool calls demand (a running test suite, a npm install, etc.). For personal use, a small VPS can comfortably hold dozens of idle sessions with 1–3 active at a time. For team use, scale the VPS or shard across multiple gateways (each with its own tunnel hostname).

Is this different from Claude Code's web UI at claude.ai?

Yes. claude.ai runs Claude on Anthropic's infrastructure with Anthropic's choice of tools. Duraclaw runs Claude Code on your VPS, in your repos, with your own shell access and full tool control. The “web UI” part looks similar; the execution environment is entirely different.

What happens to a DO if Cloudflare has an outage?

During a regional outage, the DO becomes temporarily unreachable. State is preserved (SQLite is durable across Cloudflare's infrastructure); you just can't route to it until the region recovers. When it does, the DO spins up, migrations run, and sessions resume exactly where they were. DO outages are rare; Cloudflare has published SLAs.

Can I self-host without Cloudflare?

Not easily. The Worker + DO stack is deeply tied to Cloudflare's runtime. You could reimplement the Worker as a Node/Bun server and the DO as an in-process actor with its own SQLite, but you'd lose edge distribution, automatic eviction, and the Agents SDK. The cc-gateway side is reusable; the orchestrator side assumes Cloudflare.

How much does it cost to run?

Cloudflare Workers + DOs + D1 have generous free tiers that cover personal use. Beyond that: Workers at ~$5/month for steady usage; D1 billed per-row-read with a large free quota; DOs billed by duration. VPS costs $5–$20/month for a small box. The biggest bill is Anthropic API usage for Claude itself — that scales with how much you use it.

Can multiple users share one DO session?

Technically yes — multiple browsers can each open WS #1 to the same DO. The DO broadcasts messages to all connected sockets. For collaborative sessions (two humans watching one agent edit code) this is exactly the primitive you want. Authorization — who's allowed to open WS #1 on which sessions — is handled by the Worker via cookies before the WS upgrade.

Does Duraclaw work with local LLMs?

Not out of the box. cc-gateway uses the Claude Agent SDK, which specifically targets Anthropic's API. Swapping in a local model would mean replacing that layer with a local-model equivalent that still emits PreToolUse events, streaming deltas, and the rest of the gateway's protocol. Doable but nontrivial.

How do I back up a session?

Sessions live in DO SQLite. Expose a Worker endpoint that calls the DO's internal exportSession() method (reads every row, returns JSON). Store the dump in R2 or your own storage. Restore is the inverse: a DO importSession() that writes rows back.

Is it safe to approve tools from my phone?

The network path is safe (TLS end-to-end, HttpOnly cookies). The UX risk is screen size — phone screens show less context, so it's easier to misclick. Show the session name, worktree, and full proposed command with generous touch targets; add an optional “require typed confirmation” mode for dangerous tools.

duraclaw
9 sections
MIT
duraclaw-guide.pages.dev/#home