sequenceDiagram
participant Client
participant Worker as Cloudflare Worker
participant DO as SessionAgent DO
participant Modal as Modal Backend
participant Runner as Runner (in Sandbox)
participant Agent as OpenCode Agent
Client->>Worker: POST /api/sessions (create)
Worker->>DO: Initialize session
DO->>Modal: Spawn sandbox
Modal-->>DO: Sandbox URLs + JWT
Client->>Worker: Send message (WebSocket)
Worker->>DO: Forward to DO
DO->>Runner: Forward via WebSocket
Runner->>Agent: Execute prompt
Agent-->>Runner: Streaming response
Runner-->>DO: Stream results
DO-->>Client: Real-time updates (WebSocket/SSE)
sequenceDiagram
participant B as Browser
participant W as Worker (:8787)
participant P as GitHub/Google
B->>W: Click "Sign in"
W->>P: 302 redirect to provider
P->>P: User grants access
P->>W: Callback ?code=xxx
W->>W: Exchange code for token
W->>W: Encrypt + store token
W->>W: Create auth_session
W->>B: 302 to /auth/callback?token=yyy
B->>B: Store token in localStorage
B->>W: GET /api/auth/me
W->>B: { user, providers }
- Session tokens: random 32-byte hex, SHA-256 hashed, 7-day expiry
- GitHub access tokens: AES-256-GCM encrypted in
oauth_tokens - JWT tokens: issued by Worker for sandbox service auth, validated by Runner gateway
Sessions move through a defined set of states. Each transition involves coordination between the Cloudflare Worker, the SessionAgent Durable Object, and the Modal backend.
stateDiagram-v2
[*] --> initializing: POST /api/sessions
initializing --> running: Sandbox spawned
initializing --> error: Spawn failed
running --> hibernating: /hibernate API or idle timeout
running --> terminated: /stop API
hibernating --> hibernated: Snapshot complete
hibernating --> terminated: Sandbox already exited
hibernating --> error: Snapshot failed
hibernated --> restoring: /wake API or new message
hibernated --> terminated: /stop API
restoring --> running: Sandbox restored
restoring --> error: Restore failed
error --> [*]
terminated --> [*]
sequenceDiagram
participant Client
participant Worker
participant DO as SessionAgent DO
participant Modal as Modal Backend
Client->>Worker: POST /api/sessions
Worker->>Worker: Generate sessionId + runnerToken
Worker->>Worker: Insert session in D1 (status=initializing)
Worker->>DO: POST /start (spawn request)
DO->>DO: Store session state in SQLite
DO-->>Worker: 200 OK
Worker-->>Client: { id, status: initializing }
Note over DO,Modal: Async (background)
DO->>Modal: POST create_session
Modal->>Modal: Create sandbox from image
Modal->>Modal: Mount volume, inject secrets
Modal-->>DO: { sandboxId, tunnelUrls }
DO->>DO: Store sandboxId + tunnelUrls
DO->>DO: Set status=running
DO->>DO: Sync status to D1
DO-->>Client: WebSocket: status.updated → running
The Worker returns immediately with status: initializing. Sandbox creation happens asynchronously in the DO. Once the Modal sandbox is running, the DO updates its state and broadcasts the new status to connected clients.
Hibernation snapshots the sandbox filesystem and terminates the container to save resources. It's triggered either explicitly via the API or automatically when the idle timeout expires.
sequenceDiagram
participant Client
participant DO as SessionAgent DO
participant Modal as Modal Backend
participant Runner
DO->>DO: Set status=hibernating
DO-->>Client: WebSocket: status → hibernating
DO->>DO: Flush active seconds + metrics to D1
DO->>Modal: POST hibernate_session
Modal->>Modal: snapshot_filesystem()
Modal->>Modal: terminate sandbox
Modal-->>DO: { snapshotImageId }
DO->>Runner: Send stop signal
DO->>DO: Close runner WebSocket
DO->>DO: Store snapshotImageId
DO->>DO: Clear sandboxId, tunnelUrls
DO->>DO: Set status=hibernated
DO-->>Client: WebSocket: status → hibernated
If the sandbox has already exited (e.g. Modal reclaimed it), the DO receives a 409 and transitions to terminated instead.
Waking a session creates a new sandbox from the stored snapshot. It's triggered explicitly via /wake or automatically when a user sends a message to a hibernated session.
sequenceDiagram
participant Client
participant DO as SessionAgent DO
participant Modal as Modal Backend
participant Runner
Client->>DO: Send message (or POST /wake)
DO->>DO: Queue prompt (if message)
DO->>DO: Set status=restoring
DO-->>Client: WebSocket: status → restoring
DO->>Modal: POST restore_session (snapshotImageId)
Modal->>Modal: Create sandbox from snapshot image
Modal-->>DO: { new sandboxId, new tunnelUrls }
DO->>DO: Store new sandbox info
DO->>DO: Clear snapshotImageId
DO->>DO: Set status=running
DO-->>Client: WebSocket: status → running + tunnelUrls
Note over Runner,DO: Runner auto-reconnects
Runner->>DO: WebSocket upgrade (role=runner)
DO->>Runner: Dequeue prompt → send to runner
The prompt queue ensures messages sent during hibernation or restoration are delivered once the runner reconnects.
sequenceDiagram
participant Client
participant Worker
participant DO as SessionAgent DO
participant Modal as Modal Backend
Client->>Worker: DELETE /api/sessions/:id
Worker->>DO: POST /stop
DO->>DO: Flush active seconds + metrics
DO->>DO: Send stop to runner, close WebSocket
DO->>DO: Cascade terminate child sessions
DO->>Modal: POST terminate_session (if sandbox running)
Modal->>Modal: sandbox.terminate()
DO->>DO: Clear all sandbox state
DO->>DO: Set status=terminated
DO->>DO: Sync to D1
DO-->>Client: WebSocket: status → terminated
DO->>DO: Publish session.completed to EventBus
Sessions track lastUserActivityAt (updated on every user message and runner activity). An alarm fires at lastUserActivityAt + idleTimeoutMs. If the session is still idle when the alarm fires, hibernation begins automatically.
The SessionAgent DO is the central coordination point for each session. It manages WebSocket connections, message history, prompt queuing, and sandbox lifecycle.
The DO maintains a local SQLite database with 5 tables:
| Table | Purpose |
|---|---|
messages |
Full message history (user, assistant, tool, system) |
questions |
Agent questions pending user answers (5-min expiry) |
prompt_queue |
Queued prompts waiting for runner (queued → processing → completed) |
state |
Key-value store for session metadata (status, sandboxId, tunnelUrls, etc.) |
connected_users |
Currently connected client user IDs |
The DO accepts two types of WebSocket connections, differentiated by a role query parameter:
Client connections (role=client):
- Tagged with
client:{userId}for multiplayer user tracking - Receive an
initmessage on connect with full session state, message history, and pending questions - Trigger a
user.joinedbroadcast to other connected clients
Runner connection (role=runner):
- Tagged with
runner - Authenticated via
runnerToken(256-bit hex, generated at session creation) - Only one runner connection allowed at a time — new connections close existing ones
- On disconnect, any
processingprompts revert toqueuedstatus
Client → DO: prompt, answer, abort, revert, diff, review, ping
Runner → DO (27 types): stream, result, tool, question, agentStatus, complete, error, screenshot, create-pr, update-pr, git-state, files-changed, child-session, spawn-child, models, aborted, reverted, and more.
DO → Client: init, message, message.updated, status, question, user.joined, user.left, streaming, tunnelUrls, and more.
The prompt queue is a 3-state FSM that ensures no messages are lost, even across runner disconnections and session hibernation.
queued → processing → completed
↑ |
└──────────┘ (on runner disconnect)
Dispatch logic when a prompt arrives:
- If runner is connected and not busy → send directly, mark
runnerBusy=true - If runner is busy or disconnected → insert into
prompt_queuewith statusqueued - If session is hibernated → queue the prompt, then trigger wake
Queue drain after each prompt completes:
- Mark current prompt as
completed - Check for next
queuedprompt - If found → mark
processing, send to runner - If empty → set
runnerBusy=false
On WebSocket connection, clients receive:
{
type: 'init',
session: {
id, status, workspace, title,
messages: [{ id, role, content, parts, authorId, authorEmail, authorName, authorAvatarUrl, createdAt }]
},
data: {
sandboxRunning: boolean,
runnerConnected: boolean,
runnerBusy: boolean,
promptsQueued: number,
connectedClients: number,
connectedUsers: [{ userId, name, email, avatarUrl }],
availableModels: string[]
}
}Pending questions are sent as separate question messages immediately after init.
- Active seconds: Tracked via
markRunningStarted()/flushActiveSeconds(). Elapsed time is computed and flushed to D1'ssessions.active_secondscolumn on every state transition out ofrunning. - Message and tool-call counts: Incremented locally in DO SQLite, flushed to D1 after each agent turn and on state transitions.
Each sandbox container runs the following services:
| Service | Port | Purpose |
|---|---|---|
| OpenCode agent | 4096 | AI coding agent (HTTP + SSE) |
| VS Code (code-server) | 8765 | Web IDE |
| noVNC | 6080 | Virtual display GUI (Xvfb on :99) |
| TTYD | 7681 | Web terminal |
| Auth gateway | 9000 | JWT proxy that routes to all services above |
| Runner | — | Bridges OpenCode ↔ SessionAgent DO via WebSocket |
The auth gateway on port 9000 is the single entry point. It validates JWTs issued by the Worker and proxies authenticated requests to the internal services.
The Worker uses Cloudflare D1 (SQLite) as its primary database. D1 is the source of truth for session listings, user data, and org config. The SessionAgent DO's local SQLite is the source of truth for real-time state (messages, connected users, prompt queue).
erDiagram
users ||--o{ sessions : creates
users ||--o{ auth_sessions : authenticates
users ||--o{ oauth_tokens : links
users ||--o{ api_tokens : generates
users ||--o{ integrations : configures
users ||--o{ agent_memories : stores
users ||--o{ workflows : authors
sessions ||--o{ session_participants : has
sessions ||--o{ session_share_links : shares
sessions ||--|{ session_git_state : tracks
sessions ||--o{ session_files_changed : modifies
sessions ||--o{ screenshots : captures
sessions ||--o{ sessions : spawns
workflows ||--o{ triggers : fires
workflows ||--o{ workflow_definition_versions : publishes
workflows ||--o{ workflow_executions : runs
workflow_executions ||--o{ workflow_execution_nodes : traces
workflow_executions ||--o{ workflow_approvals : gates
workflow_executions ||--o{ workflow_spawned_sessions : spawns
integrations ||--o{ sync_logs : logs
integrations ||--o{ synced_entities : syncs
Auth (4 tables)
| Table | Key Columns | Notes |
|---|---|---|
users |
id, email, name, avatar_url, github_id, role | Role is admin or member |
auth_sessions |
token_hash, user_id, provider, expires_at | 7-day expiry, SHA-256 hashed tokens |
oauth_tokens |
user_id, provider, encrypted_access_token | AES-256-GCM encrypted, unique per user+provider |
api_tokens |
user_id, token_hash, prefix, scopes | Revocable, optional expiry |
Sessions (4 tables)
| Table | Key Columns | Notes |
|---|---|---|
sessions |
id, user_id, workspace, status, sandbox_id, tunnel_urls | Denormalized metrics: message_count, tool_call_count, active_seconds |
session_git_state |
session_id (unique), source_type, branch, pr_number, pr_state | 1:1 with session, tracks PR lifecycle |
session_files_changed |
session_id, file_path, status, additions, deletions | Unique per session+file_path |
screenshots |
session_id, r2_key, description | R2-backed image storage |
Multiplayer (2 tables)
| Table | Key Columns | Notes |
|---|---|---|
session_participants |
session_id, user_id, role | Roles: owner, collaborator, viewer |
session_share_links |
session_id, token, role, max_uses, use_count | Revocable, optional expiry |
Organization (3 tables)
| Table | Key Columns | Notes |
|---|---|---|
org_settings |
name, allowed_email_domain, default_session_visibility | Singleton row (id=default) |
org_api_keys |
provider (unique), encrypted_key | Org-wide LLM provider keys |
invites |
code, email, role, expires_at | One-time use invite codes |
Integrations (3 tables)
| Table | Key Columns | Notes |
|---|---|---|
integrations |
user_id, service, config, status | Unique per user+service |
sync_logs |
integration_id, status, records_synced | Per-sync operation tracking |
synced_entities |
integration_id, entity_type, external_id, data | Unique per integration+type+external_id |
Workflows (7 tables)
| Table | Key Columns | Notes |
|---|---|---|
workflows |
user_id, slug, draft_definition, published_version_id | Draft + published-version pointer for the dag/v1 lifecycle |
workflow_definition_versions |
workflow_id, version, definition, definition_hash | Append-only history of published dag/v1 definitions |
triggers |
workflow_id, type, config, webhook_token | Types: webhook (one-time-token authed), schedule, manual |
workflow_executions |
workflow_id, trigger_id, status, definition_snapshot, cleanup_completed_at | One row per run; the row id is also the Cloudflare Workflow instance id |
workflow_execution_nodes |
execution_id, node_id, status, approval_id, invocation_id, expires_at | Per-node trace rows with retention TTL |
action_invocations (workflow-attributed) |
workflow_execution_id, node_id, iteration_index, service, action_id, status, expires_at | Unified approval primitive — both tool-policy holds and explicit approval node invocations of workflows.request_approval live here; resolved via the approve/deny API + instance.sendEvent |
workflow_spawned_sessions |
execution_id, session_id | Authoritative link from an execution to sessions spawned by session or orchestrator nodes |
Memory (1 table)
| Table | Key Columns | Notes |
|---|---|---|
agent_memories |
user_id, workspace, content, category | Per-user, optionally scoped to workspace |
- Timestamps as TEXT in ISO format (
datetime('now')) - JSON columns as TEXT for flexible structured data (config, metadata, steps)
- Denormalized metrics on
sessionsfor fast dashboard queries - Self-referential sessions via
parent_session_idfor child session trees - Single-org model with
org_settingsas a singleton row