Architecture
How Studio is wired end to end — edge, cloud cluster, and desktop — and how requests, runs, and sandboxes flow between the tiers.
This page describes how a running Studio deployment is wired together: the tiers, what each one does, and how a request becomes an agent run, a tool call, or a sandbox preview. It’s useful background whether you self-host or use the cloud, and it’s the conceptual companion to the Kubernetes and Docker Compose deploy guides.
The three tiers
Studio spans three trust/locality boundaries:
- Edge — the public internet path: a CDN and an L4 load balancer.
- Cloud cluster — the Kubernetes deployment: web, API, workers, Postgres, NATS, and cloud sandboxes.
- Desktop — the native Studio app on the user’s laptop, where its embedded
local-apiowns native coding harnesses and local sandboxes.
The same flow as text:
EDGE CLOUD CLUSTER DESKTOP
──── ───────────── ───────
Client ─▶ CF ─┬─▶ NLB ─▶ Web(nginx) ─▶ API ──┬─▶ MCP Proxy ─▶ Downstream MCP (ext)
│ ├─▶ Files/Storage ─▶ Object Store (ext)
│ ├─▶ DB (Postgres)
│ ├─▶ NATS
│ └─▶ Worker ─┬─▶ LLM
│ ├─▶ Downstream MCP (in-process bridge)
│ └─▶ AgentSandbox ─┬─ Daemon API (/_sandbox/*)
│ └─ Org FS (sidecar) ─▶ /api/:org/fs ─▶ S3
└─▶ Gateway (k8s) ─▶ Preview (public dev-server)
Desktop app ─▶ local-api ─┬─▶ Studio API (upstream data and MCP)
├─▶ Claude Code / Codex / OpenCode
└─▶ Desktop Sandbox ─▶ Org FS (mount)
Edge
| Component | Role |
|---|---|
| CF (Cloudflare) | TLS termination, static SPA caching, DDoS/bot mitigation. First hop for all traffic. |
| NLB | L4 load balancer fronting the cluster. Routes to the web (front-door) pods; the frontDoorLabels selector decides which pods receive ingress. |
Cloud cluster
Web, API, and Worker scale independently
These three deployments are separate and scale on their own. The web tier was split out from the API (the “front-door split”) so the static front door can deploy and scale on a different cadence than the application server.
| Tier | Deployment | Responsibility |
|---|---|---|
| Web | nginx | Serves the React SPA and reverse-proxies /api , /mcp , /oauth-proxy , /.well-known to the API Service. Port 8080. |
| API | Hono, STUDIO_DISPATCH_ROLE=api | HTTP routes, Better Auth, the MCP proxy, access control. Enqueues Decopilot runs onto DBOS queues and tails NATS to stream output back to the UI. Stateless. Does not run the agent loop. |
| Worker | Hono, STUDIO_DISPATCH_ROLE=worker | Dequeues DBOS queues (via listenQueues ) and runs the agent loop ( streamText : model → tool → repeat). CPU-bound. These are the run executors; scale horizontally. |
The split is by role, not by image — both run the same build. STUDIO_DISPATCH_ROLE decides whether a pod listens on the DBOS queues ( worker ) or only serves HTTP and enqueues ( api ). A single-deployment setup can use all .
The set of queues a worker pod listens on is configured by env ( listenQueues ). Because of that, worker pools can be split per DBOS queue — running different workflows on separate pools with their own resources and scaling.
Datastores
| Component | Role |
|---|---|
| DB (PostgreSQL, via Kysely) | System of record: orgs, connections, credential vault, audit, threads + messages, and sandbox_runner_state . It also holds the DBOS queues and workflow_status journal that make runs durable and recoverable. |
| NATS | Live messaging infrastructure with three jobs: (1) the fenced JetStream run log ( decopilot.stream.<thread> ) used by /stream and the durable projector; (2) cross-pod run-cancellation broadcasts; (3) JetStream KV state shared by replicas for MCP list caches and connection circuit breakers. |
The event bus is dormant. The CloudEvents pub/sub feature ( EVENT_PUBLISH / EVENT_SUBSCRIBE , the durable event queue, ON_EVENTS subscribers) is only consumed by the workflow plugin, which is not in use. NATS itself is not dormant — it serves the live jobs listed above. Don’t conflate the two.
The Decopilot run lifecycle
- A message (
POST /messages) or an automation fire creates a run on a thread. - The API enqueues it onto a DBOS queue in Postgres:
THREAD_GATE_QUEUE— serialized per thread (concurrency 1 perthreadId).AUTOMATIONS_QUEUE— partitioned by org, so a saturated org only blocks its own partition.
- A Worker dequeues the gate workflow and starts a hosted child workflow. In-process Decopilot runs use
HOSTED_HARNESS_QUEUE; sandbox-hosted Claude Code runs useHOSTED_HARNESS_SANDBOXED_QUEUE. The parent gate doesn’t wait on the child — it live-tails the same NATS stream the child publishes to, while the durable projector writes messages and terminal status. - Output chunks are published to NATS and tailed back to the UI over
/stream. - If the pod crashes, DBOS journal replay resumes retriable steps on another pod — recovery is the framework’s job, not hand-rolled.
Both paths are hosted and use Studio’s fixed AgentSandbox provider when they need a sandbox:
- Decopilot runs its model loop in-process on the worker and reaches the managed sandbox for repository, filesystem, Git, and shell operations.
- Claude Code runs its harness loop inside the managed sandbox; the worker proxies its stream into the same NATS and projector pipeline.
Native coding-agent chats are a separate surface. The desktop app runs Claude Code, Codex, or OpenCode locally and synchronizes the thread through its embedded local-api .
MCP: in-process vs. the proxy routes
This distinction matters for reasoning about the system:
- The worker calls MCP tools in-process. The agent loop builds a
PassthroughClientover an in-memory bridge and calls tools directly — no HTTP hop inside the cluster. It still connects outward to downstream MCP servers. - The MCP proxy routes are for external clients.
/mcp/virtual-mcp/:id,/mcp/:connectionId,/oauth-proxy/*, and/.well-knownare served by the API to external IDEs (Cursor, Claude, VS Code). The worker does not use these.
Which routes are called by whom
| Routes | API / external | Worker |
|---|---|---|
MCP proxy ( /mcp/* , /oauth-proxy/* , .well-known ) | ✅ external clients via the API | ❌ (in-process instead) |
File & object-storage ( /api/:org/files/* , presigned GET/PUT, uploads, /api/:org/fs/* ) | ✅ serving clients | ✅ agent tools read/write files & mint presigned URLs |
| Worker-only over HTTP | — | none — tool calls are in-process |
File and object-storage routes are the genuine “called by both” surface, and they are backed by an S3-compatible Object Store. The /api/:org/fs/* routes also back the org-filesystem mount inside sandboxes.
Sandboxes
A hosted AgentSandbox clones the repo, runs the dev server, and exposes an in-pod daemon. Its HTTP surface splits in two:
| Surface | Auth | Purpose | Caller |
|---|---|---|---|
Preview (catch-all * ) | None — the handle (subdomain) is the secret | Reverse-proxies the running dev server (the live app preview); injects HMR. /_sandbox/* is actively rejected here. | The end user’s browser at <handle>.preview.<domain> , through Cloudflare (LB) → a Kubernetes Gateway (Istio Gateway API / HTTPRoute) → the daemon |
Daemon API ( /_sandbox/* ) | Bearer DAEMON_TOKEN | Control surface: fs ops (read/write/edit/bash/grep), git (status/diff/publish), exec scripts, setup (clone → install → start), tasks, SSE events, harness dispatch. | The cluster (worker for agent fs/git/bash tools; API for UI setup + events) |
Cloud vs. desktop sandboxes
| Cloud sandbox | Desktop sandbox | |
|---|---|---|
| Where | agent-sandbox operator + a SandboxClaim pod per (user, projectRef) | The native app’s local-api and SandboxManager , with one local worktree per handle |
| Reached over | k8s port-forward / in-cluster Service (control); ingress or port-forward (preview) | loopback interception; <handle>.localhost:<port> (preview) |
| Routing owner | Studio’s hosted API always uses AgentSandbox and reports agent-sandbox | The native app intercepts the same lifecycle/filesystem calls locally and reports local-api |
Org filesystem (org-fs)
Each sandbox can mount the org filesystem at <appRoot>/org/<volume> , so the agent and dev server read and write org files as ordinary paths. The mount stack is rclone (NFS/FUSE) → the daemon's loopback WebDAV → /api/:org/fs/* → S3 — the same object store as the file routes, surfaced as a mounted volume. This is the same filesystem you browse in the Library.
It is wired on both surfaces, with different mount mechanics:
| Cloud sandbox | Desktop sandbox | |
|---|---|---|
| Who mounts | a privileged sidecar container (the unprivileged daemon can’t mount) | local-api directly on the user’s machine |
| Config delivery | post-bind: Studio POST /_sandbox/orgfs-config ; the daemon relays it to a shared control volume the sidecar watches (warm-pool claims reject spec.env ) | the native runtime resolves the org volumes and maintains the local mount |
| Propagation | rclone with allowOther so the mount propagates to the main container | single client — no propagation needed |
Desktop
The native Studio app embeds the production web UI and an Axum local-api on one authenticated loopback origin. local-api proxies shared data and MCP traffic upstream, but terminates native-only thread, harness, sandbox, filesystem, Git, task, terminal, and preview routes on the laptop. It launches the user’s Claude Code, Codex, or OpenCode CLI in the thread’s local worktree and persists native thread and sandbox state in SQLite.
Provider routing follows the receiving surface rather than a request option: browser lifecycle calls reach the hosted API and AgentSandbox; the same calls from the native webview are intercepted by local-api and operate on the local-api sandbox. That keeps hosted and desktop records distinguishable without exposing a provider selector to callers.
At a glance
- Web / API / Worker are independent deployments; workers are DBOS run executors.
- Runs are durable via DBOS queues + journal in Postgres; recovery is automatic.
- MCP tool calls are in-process on the worker; the
/mcp/*proxy routes are for external clients only. - File/object-storage routes are the shared API+worker surface.
- NATS is live; the CloudEvents event-bus feature is dormant.
- Sandbox routing is surface-owned: hosted Studio uses AgentSandbox; the native app intercepts locally and records
local-api.
Found an error or want to improve this page?
Edit this page