Process Roles
The single container image, the roles it splits into, and the reasons behind that split.
The platform is one container image. Every process on the trusted cluster runs that image with a different --role flag, and each role is its own Kubernetes Deployment. This chapter names the roles, describes what each one does, and explains why the split falls where it does.
The image is built from a single TypeScript codebase. The domain lives in src/common: the DAOs, the workflow runner, the model catalog, data sources, billing, auth, the queue contract. Each role is a folder under src/. Its entry file is the process; the folder holds only that role's transport code, and no other role imports from it. In development, the same image runs every role in one process.
#The Roles
| Role | What it does |
|---|---|
| HTTP | The REST API, app serving, method dispatch, hook endpoints, admin |
| WebSocket | Realtime app events and editor sockets |
| General worker | Drains the step, ingest, file-scan and email queues |
| Bulk worker | Drains the bulk-ingest queue for corpus loads |
| Git | Smart HTTP git server: each app's remote, mirrored to S3 |
| App database | Owns and serves each app's SQLite databases |
| Mail inbound | SMTP listener that turns inbound mail into method invocations |
| Sandbox proxy | The only public path to a dev box: preview, control, language server |
| Provisioner | Reconciles dedicated resources; hosts the platform's batch crons |
| Sandbox orchestrator | Release pods, job runners and dev boxes on the untrusted cluster |
The voice worker is a separate image, in services/voice-worker, because its LiveKit bindings need a different base. It registers with the HTTP role and deploys alongside it.
#Why the Split Falls Where It Does
Each role exists for one of three reasons: it holds long-lived connections, it carries rights no request pod should hold, or its lifecycle cannot ride the API's rollout cadence.
Long-lived connections. The WebSocket role and the sandbox proxy role hold hour-long sockets: an editor's control channel, a language server session, a realtime event stream. A rolling deploy of the API tier must not cut these off, and a pod holding one of these sockets must not block the request tier from scaling down. Both roles roll on their own cadence, with their own grace periods.
Rights that request pods must not carry. Two roles talk to a Kubernetes API. The provisioner creates StatefulSets for dedicated retrieval instances on the trusted cluster; the sandbox orchestrator creates pods on the untrusted cluster. Each runs under its own ServiceAccount, scoped to exactly those rights, and the HTTP pods carry none of them. The HTTP role reaches the orchestrator over an internal route with a server token, never with cluster credentials.
Lifecycle. The app database role holds open SQLite connections and flushes them on a debounce, so it drains carefully and hands ownership to a peer on shutdown. The git role holds repositories on local NVMe. The mail inbound role listens on SMTP behind a Network Load Balancer. The provisioner is a singleton, which also makes it the right home for the batch crons that must run exactly once.
Worker pools. The general worker drains queues whose jobs run seconds to minutes: steps, document ingest, file scans, email sends. The bulk worker drains corpus loads, where one batch can saturate a JavaScript thread for minutes at a time. They run on separate node groups, so a bulk load never takes a core from interactive work.
#How Roles Find Each Other
Roles do not import each other. They meet through the data plane and through internal routes.
- Postgres is the shared state. Every role opens the same three pools: writer, reader, and a report reader with a longer timeout.
- Valkey (Redis) carries cache namespaces, pub/sub events for cache invalidation, ownership records for app databases, the warm-pool registry, and the live audit tail.
- SQS (Queue) carries work from the HTTP role to the workers. The message contract lives in
src/common/Queue: one module per message type, with its shape, its producer helper, and the handler the worker registers. - Internal HTTP routes under
/_internalconnect the HTTP role to the app database, git, and sandbox orchestrator roles. Each is guarded by a server token that never leaves the cluster. - The callback origin is the one hostname a sandbox reaches back on. Every managed capability an app uses arrives there as a request with a hook token.
#Workload Identity
Every pod runs under an EKS Pod Identity that maps its ServiceAccount to an IAM role with explicit resource ARNs. None of the roles that touch data carry a wildcard.
| Used by | Grants |
|---|---|
| HTTP, WebSocket, workers, git, app database, mail inbound, sandbox proxy | S3 in every region, SQS, Cognito, Bedrock, KMS, Secrets Manager, Firehose, SES |
| Provisioner | Enqueue rebuilds; a namespaced Role for the dedicated namespace |
| Sandbox orchestrator | Describe the untrusted cluster; an access entry on that cluster bound to a pod-CRUD Role |
| Model trainer | The training queue and its artifacts |
| Model serving | Tuned-model artifacts |
Worker pods disable the Kubernetes ServiceAccount token mount. Their only identity is the AWS one.
Figure 02, "Process Roles": a vertical internals figure. The image is a left vertical artifact spine, the platform image launched with a different --role; its ten role peers sit in a 2×5 grid inside one dashed "ONE IMAGE" zone, surrounded by the three planes they touch. Front-facing roles (top row): HTTP (REST, :3000), WebSocket (realtime, sockets), Sandbox proxy (HTTP + WS, dev box), Mail inbound (SMTP to methods), Provisioner (crons, singleton). Internal and worker roles (bottom row): App database (internal, SQLite), Git (smart HTTP, NVMe), General worker (SQS), Bulk worker (SQS, corpus loads), Sandbox orchestrator (untrusted pods). Above the front-facing roles, four dashed off-figure endpoint chips mark the external parties that reach them: web and API clients, browser sockets, the editor and its dev box, and inbound SMTP. Each points down into its role, crossing the top of the image zone. To the right, a dashed "CLUSTER APIS" zone holds the two Kubernetes APIs only two roles may call: Provisioner reaches the Trusted K8s API and Sandbox orchestrator reaches the Untrusted K8s API ("rights no request pod holds"). The Voice worker sits above the zone as a labeled exception: a separate image on a LiveKit base, deployed alongside HTTP. Along the bottom, a "SHARED DATA PLANE" floor of Postgres, Valkey, SQS, and S3. Roles do not import each other and are not wired individually to these stores; a note reads "every role opens the same pools". The one drawn data-plane path is the queue: HTTP produces into SQS and the General and Bulk workers drain it. Inside the image, HTTP is the hub of the guarded internal routes ("/_internal · server token"), reaching the App database, Git, and Sandbox orchestrator roles. Every connector is grey and orthogonal with one arrowhead style; there is no trust boundary in this figure, so there is no crimson.