Back to the overview

architecture · wire level · one machine

How the parts actually talk to each other.

Four Node processes, ten workers, a handful of stateless sidecars, and one Postgres that does far more than store rows. It is the queue, the journal, and the event bus as well. Below is every process and, more to the point, every wire between them: what the transport is, and what rides it.

This page is written for the reader who has to sign off on it. Nothing is drawn that isn’t in the Compose file, and the one genuinely important fact is at the bottom of the stack rather than the top: inference is the only traffic that leaves the machine.

Topology

Five tiers, one box

Read it downward. Each band between two tiers names the transport that crosses it. The labels are the point, not decoration. Everything below the front door is reachable on the internal Docker network and nowhere else.

Edgeclients, channels and sources, outside the box
Browser

The owner UI, and the Flutter companion on the same contract.

HTTPSHTTP/3SSE
Claude Desktop / Code

Any MCP client, local or remote.

stdio JSON-RPCOAuth
Telegram

Per-responder bots. Text and voice, both directions.

Bot API
Mail · Microsoft 365 · calendars

IMAP/SMTP on your own mailbox, Graph for SharePoint and OneDrive, iCal feeds.

IMAPSMTPMS GraphiCal
Host filesystem

A watched directory. Edit with vim or Syncthing and the brain follows.

bind mountinotify
Microsoft Teamsplanned

Next on the roadmap, and a small piece rather than a new subsystem: channels already attach a transport to any agent, and the Microsoft Graph credentials are already in place for mail and files.

MS Graph
  • HTTPS + HTTP/3 → :443
  • Authorization: Bearer
  • SSE (EventSource)
  • stdio JSON-RPC (local)
  • provider APIs (worker-initiated, outbound)
Front doorthe trust boundary: everything below is internal-only
caddy

The only container with published ports. Automatic Let’s Encrypt TLS, two vhosts (the API origin and the owner-UI origin), reverse-proxied onto the internal Docker network.

:80:443:443/udpACME
  • reverse_proxy web:3000
  • reverse_proxy client-web:3000
  • internal Docker network
Processesall TypeScript on tsx, no build step on the server
client/web

The owner UI. Next.js, and the only Next app left. Zero secrets: no database and no session key. Every byte arrives from server/web over bearer + CORS. Runtime config is read per request from /env.js, so one image serves any origin.

Next.jsno DBbearer
server/web

The backend. A Hono HTTP server behind an http-compat shim, carrying the whole /api surface, the retrieval stack, files, and the SSE endpoints. Owns the DB connection.

Honotsx~288 routes
server/apiawake

The durable runner. Every assistant and Telegram turn runs here as a DBOS workflow journaled to its own Postgres system database, so a crash replays from the journal instead of re-running the turn. The background agents live here too: extractor, summarizer, reflector, and the heartbeat tick that makes the brain proactive. No HTTP surface at all: reached by enqueue, answers over NOTIFY.

DBOSno HTTPresumableheartbeats
server/mcp

The stdio MCP server. packages/mcp-core registers one tool set for both this and the HTTP /api/mcp OAuth endpoint, so a local Claude and a remote client see identical tools.

stdioJSON-RPC
workers ×10

Separate processes off the same image: email, telegram, microsoft, files, docs, events, calendar, maintenance, runs, push. Coordinated by pg-boss queues inside Postgres; no broker to run.

pg-bossone image
sandboxd

Opt-in CLI sandboxes. Holds the Docker socket, so it is deliberately narrow: bearer auth, a fixed verb set, and only containers it labelled itself. Sandboxes land on an isolated network with egress only, and no route to Postgres, MinIO, or web.

profile: sandboxes
  • Drizzle pool
  • pg-boss queues
  • DBOS enqueue + journal
  • pg_notify / LISTEN
  • S3 SDK
Data planeone Postgres does queueing, journalling and the event bus
postgres

The source of truth, and most of the middleware too. pgvector for 768-dim embeddings under HNSW, pg_trgm and ltree, the pg-boss queues, the DBOS journal, the turn-stream replay buffer, and the LISTEN/NOTIFY bus that ties every process together.

pgvectorpg18LISTEN/NOTIFY
minio

S3-compatible object store for file and attachment bytes. Private bucket, internal network, no anonymous access.

S3 SDK
host volumes

Files, backups, per-app SQLite for mini-apps, and per-table SQLite for Tables. That last one is mounted into server/web and server/api alike, because the handlers run in both.

bind mountsSQLite
  • HTTP (internal)
  • WebSocket (internal)
  • no published ports
Sidecarsstateless, internal-only, each in its own memory space
tika

Document parser fallback for the formats in-process parsers miss. Stateless, heap-capped and self-hosted, so the bytes never leave the box.

HTTP :9998
browser

A headless Chromium the app drives with puppeteer-core to print its own authenticated page surface, so an exported PDF reuses the live CSS byte for byte. A pathological render dies here, not in web.

WebSocketpuppeteer
ollama

The bundled local embedder: EmbeddingGemma at 768 dimensions over an OpenAI-compatible API. Opt-in; when it runs, the text being embedded never leaves the machine.

profile: local-embedder
tailscale

Userspace tailscaled with an outbound HTTP proxy, so the brain can reach a GPU box behind NAT by MagicDNS without opening a port. Activated from the UI over a shared socket.

profile: tailnet
  • HTTPS egress: the only traffic that leaves the machine
Outboundinference, and nothing else
Model providers

OpenRouter and direct adapters for Anthropic, OpenAI, Google, xAI, Hugging Face and ElevenLabs, each with a configured backup. Keys live in the encrypted vault and are referenced by handle, so the plaintext never reaches a tool, a trace, or a chat.

HTTPSvault refsprimary + backup

Solid boxes are the default stack. Dashed boxes are opt-in: they ship today, but only start with their Compose profile. A box marked planned is on the roadmap and not built yet. It is drawn so the shape it will slot into is honest, not to suggest it works.

The critical path

One turn, across four processes

The hardest thing in the system is also the most-asked question: the turn runs in server/api, but the browser’s socket is held by server/web. Always two processes. Postgres bridges them, which is why the design works the same way on one box or five.

  1. 01

    The browser posts, and is answered in milliseconds

    POST /api/assistant/turn returns 202 without waiting for a model. Close the tab; the turn does not care.

  2. 02

    server/web enqueues the workflow

    A DBOS enqueue over Postgres. There is no HTTP route into the runner to secure, because there is no HTTP surface on it at all.

  3. 03

    server/api runs it durably

    Each step is journaled as it completes. Restart the process mid-turn and it resumes from the last good step rather than starting over.

  4. 04

    Tools run under a real grant

    Search, reads, page edits, delegation to sub-agents. Every call is checked against the agent’s tool groups server-side, never against what the model asked for.

  5. 05

    Events publish over NOTIFY

    Grounded status built from actual tool calls, plus reasoning and text deltas. Small messages, because the bus caps payloads at about 8 KB.

  6. 06

    server/web fans out over SSE

    One LISTEN, many sockets. A reconnect sends Last-Event-ID, gets the missed range replayed from the buffer table, then live-tails. Gap-free and duplicate-free.

  7. 07

    The answer lands in the durable row

    Written once, by the runner. The stream was always allowed to be lost; the reply never was.

  8. 08

    The trace stays

    Every step, tool call and cost is recorded and readable afterwards. The same record that makes a citation checkable.

Durability and liveness travel separate paths. The journal never carries tokens, so a crash mid-stream costs you the animation, never the answer.

Every wire

Named, in full

A diagram that tried to draw all of these would be less precise, not more. So the picture gives the shape and this gives the edges: every connection in a default install, with its actual transport.

Getting in

Caddy is the only container that publishes a port. Everything below it is reachable on the internal Docker network and nowhere else.

FromToTransportWhat rides it
BrowsercaddyHTTPS :443 · HTTP/3 (QUIC) · :80 for ACMETwo vhosts: the API origin and the owner-UI origin. Certificates are issued and renewed automatically.
caddyclient-web:3000reverse_proxy, internal networkThe owner UI. Static shell plus /env.js, which hands the browser its server origin at request time.
caddyweb:3000reverse_proxy, internal networkThe whole /api surface, the SSE endpoints, and the authenticated print surface.
Browser bundleserver/web /api/**HTTPS + Authorization: Bearer, opt-in CORSEvery read and write. There are no server actions and no DB code in the bundle. A kinded token rides the header, and the cookie path rejects kinded tokens outright so a detached client can never fall back to cookies.
Claude Desktop / Codeserver/mcpstdio JSON-RPC (local process)The full tool set, no network hop.
Remote MCP clientserver/web /api/mcpHTTPS + OAuthThe same tool set, registered once in packages/mcp-core so the two front doors cannot drift.

Running a turn

The turn runs in one process and the browser’s socket is held by another. Always two. Postgres is the bridge, from day one.

FromToTransportWhat rides it
Browserserver/web POST /api/assistant/turnHTTPS → 202 Accepted, immediatelyThe route never waits for the model. It enqueues and returns, so the turn survives a reload, a navigation, or a backgrounded tab.
server/webserver/apiDBOS enqueue over PostgresThe workflow input. server/api has no HTTP surface, so this queue is the only way in.
server/apiPostgres system DBDBOS journalEvery completed step. A crash mid-turn replays the journal and resumes at the last good step instead of re-running the turn.
server/apiserver/webpg_notify (~8 KB cap per message)Turn events: status, tool-start, tool-end, reasoning and text deltas. The cap is why long output streams as many small deltas and never one blob.
server/apiturn_stream_bufferINSERT, short TTL, keyed by (turn, seq)A replay log. NOTIFY has no backlog, so a reconnect sending Last-Event-ID gets the gap replayed, then live-tails.
server/webBrowserSSE on GET /api/assistant/turn/:id/stream (bearer)The same event contract the phone companion consumes. If an event cannot be expressed as JSON both clients could render, it is modelled wrong.
server/apiassistant_messagesdurable writeThe answer. Durability and liveness travel separate paths on purpose: the journal never carries tokens, so a crash mid-stream loses the animation and not the reply.

Acting on its own

The other half of the product. Nothing here is triggered by a person, and it is the same turn machinery above, started by a clock instead of a keystroke.

FromToTransportWhat rides it
server/api (internal tick)heartbeatsevery 60s, with backoff to 30 min on failureFind the routines whose next fire time has passed. The backoff mirrors the reflector, so a flaky database or provider cannot turn the loop into a hot loop.
Gate checkfire / skipearliest_at → cooldown → quiet hours → idleFour gates in cost order, cheapest first. Quiet hours resolve in the surface’s own timezone; the idle gate asks whether you have actually been quiet, and only applies where a surface has an inbound concept, so today that means Telegram.
A fired heartbeatthe turn enginethe same durable workflowNo second code path. A routine that wakes at 6am runs the identical traced, tool-granted, journaled turn as a message you typed.
server/webserver/apipg_notify(heartbeat_due, ownerId)The low-latency wake. Creating a routine or pressing fire-now does not wait up to a minute for the next tick: the UI notifies, the runner listens, and the trace appears within seconds.
The agentits own routinesfive builtin toolscomplete, snooze, update_state, list, fire. The assistant manages its own schedule, which is why a routine can decide it is done, or put itself off until Tuesday.

Keeping the screen honest

The UI repaints because the database said something changed, not because a timer fired.

FromToTransportWhat rides it
nodes INSERTserver/webpg_notify(node_ingested) via triggerEvery new item, whatever put it there.
Extractorserver/webpg_notify(node_indexed)The second beat: a freshly summarised and embedded item. The insert alone has no summary yet, which is why one channel was never enough.
server/webBrowserSSE on GET /api/realtime, per ownerA change signal, filtered by type. One shared LISTEN connection serves every open tab; owner isolation is enforced server-side, so another owner’s change is never emitted.

Feeding it

Ten workers, one image, no message broker. pg-boss keeps its queues inside the same Postgres.

FromToTransportWhat rides it
worker_emailYour mail providerIMAP + SMTP over TLSInbound sync and outbound send from your own mailbox. No MTA to run, and the contacts list gates both directions.
worker_telegramTelegram Bot APIHTTPS long-pollMessages in and replies out, per responder bot.
worker_microsoftMicrosoft GraphHTTPSSharePoint, OneDrive and Outlook ingest.
worker_filesHost filesysteminotify over a bind mountExternal edits from vim, Syncthing or a plain cp, mirrored back into the brain. Loop-safe: the sync path never rewrites the bytes it just read.
worker_calendar / worker_eventsiCal feeds · TelegramHTTPS · 30s pollCalendar ingest, and reminders that fire when remind_at passes.
All workersPostgrespg-boss queues + Drizzle poolJob coordination and every row they write. The schema is created by a one-shot migrate gate, so workers cannot race to create it.

The supporting cast

Stateless sidecars, none of them published. A crash in any of these is contained to that container.

FromToTransportWhat rides it
server/webtikaHTTP :9998, internalParsing for formats the in-process parsers miss. Heap-capped, because a 24 MB deck unzips to many times its size as XML.
server/webbrowser (Chromium)WebSocket, puppeteer-corePDF export. The app drives Chromium to print its own authenticated page, so the export reuses the live CSS byte for byte.
server/webollamaHTTP :11434, OpenAI-compatibleLocal 768-dim embeddings. Opt-in, and when it runs the text being embedded never leaves the machine.
server/webtailscaledLocalAPI over a shared unix socket · HTTP proxy :1055Reaching a GPU box behind NAT by MagicDNS, with tailnet membership driven from the settings UI.
server/web · server/apiminioS3 SDK, internal networkFile and attachment bytes. Private bucket, anonymous access explicitly removed.
Every processits own healthchecka liveness file, touched on a timerConfusingly also called a heartbeat, and unrelated to the agent kind above: this one is just a process saying it is alive, so Compose can restart a worker that has quietly stopped doing work while still running.

Going out

The complete list of traffic that leaves the machine.

FromToTransportWhat rides it
server/api · server/webModel providersHTTPSInference: OpenRouter plus direct adapters, each with a configured backup. Frontier models where they matter, economy models and local embeddings everywhere else.
WorkersYour own accountsIMAP · SMTP · Graph · Bot APIMail, files, and messages, from services you already use, on your credentials.
caddyLet's EncryptACME (HTTP-01 / TLS-ALPN-01)Certificate issuance and renewal.

Teams and peers

Two borders, one rule

There are no tiered read permissions inside a brain, and that is a design position rather than a gap. Finer secrecy means a second brain. So everyone who is not the owner arrives across a border: a person holding a token, or another brain holding a grant.

People, by token

Team membership is a role a contact holds, not a user account. There is no signup, no seat, and nothing to install. A live token row is the role.

The credential
One short token from a look-alike-free alphabet, shown exactly once. Only its SHA-256 is stored, so the brain cannot reproduce it either.
What it opens
Two surfaces on one credential: the team workspace, which is a read-only mirror of the shares the owner has actually activated, and the forum, where topics are multi-author threads the team responder answers.
Read anything, write nothing
The team responder holds one write tool, and it files a request. Anything a member wants changed becomes an item in the owner’s review queue rather than an edit.
The private carve-out
The owner’s email and journal are excluded by default. A team member reads what the team responder can read, and that corpus is not in it.
Revocation is immediate
Every request rechecks membership liveness, so deleting the token row ends every open session mid-conversation rather than at expiry.

Brains, by grant

Federation is deliberately not multi-tenancy. Each brain stays one bounded life with its own owner, and the two negotiate at the border. Your brain asks mine a question; mine answers only from what it was told to share.

Two tokens, one per direction
The token they issued us is sealed and reversible, because we have to replay it when we call them. The token we minted for them is kept only as a hash, verified by hashing what arrives. There is no reversible inbound secret at rest.
Grants are explicit
Per document, or a standing per-category subscription resolved at query time, so granting Pages includes pages written after the grant. Neither is ever flattened into a list of ids.
An allowlist decides what can be category-shared
Pages, notes, files, contacts, tables, events, tasks. Never secrets. Deliberately never email or journal, which stay the owner’s private corpus and can only be shared one document at a time.
The answer is an intersection
What the peer asked for, intersected with what is granted to that peer. There is no unscoped read path in the codebase for a peer to fall back to.
Ungranted looks like non-existent
A document a peer has no grant for returns a 404 that is indistinguishable from one that was never there, so a peer cannot map what it cannot read.

One cross-brain question, end to end

  1. 01

    The asking brain calls

    Its agent uses a peer tool, which posts the question to the peer’s federation endpoint carrying the outbound bearer.

  2. 02

    The answering brain identifies it

    It hashes the presented bearer and matches a unique index. That resolves which peer is asking before anything is read.

  3. 03

    The read is scoped at the source

    The search runs against the intersection of the request and that peer’s active grants. Nothing is filtered out afterwards, because nothing ungranted is fetched.

  4. 04

    And it is on the record

    Every cross-brain read opens its own trace under the answering owner, so the person whose data it is can see who asked what, and when.

Revoking a peer closes both directions at once, and rotating one side leaves the other untouched. Pairing works without either side going first: each adds the other with a URL, hands over the token it minted, and pastes the one it receives.

The boundary

What holds when something goes wrong

Topology answers “what talks to what”. These answer the question a security review actually asks. What stops a persuaded model, a hostile document, or a compromised sidecar from turning into a data leak?

The brain is the boundary

One owner, one deployment, one trust boundary. There are no ACLs inside a brain, because finer-grained permission is a second brain. It peers rather than centralises, so the blast radius of any mistake is one install.

Ingested content is data, never instructions

Every email, page and message that enters is treated as content the agent reads, not as text that can steer it. That is what makes an autonomous brain with access to your inbox safe to leave running.

Tool selection is not permission

Which tool the model reaches for is a suggestion. Tool groups are the hard boundary, checked server-side on every call, so a persuaded model still cannot exceed its grant.

Diagnostics may be sanitised; data never is

Retrieved content is fenced and handed to the model verbatim, never paraphrased into the prompt, which is what makes a citation checkable rather than decorative.

Secrets are referenced, not embedded

API keys live in an encrypted vault and appear in tools as handles. The plaintext never reaches a tool definition, a trace, or a chat transcript.

Three containers hold the Docker socket

The updater, the sandbox supervisor, and autoheal are all root-equivalent on the host, so each is narrowed the same way: no published ports, a fixed verb set, and triggering only over a private volume or a bearer-authed call.

Why it's shaped like this

Three decisions you can feel

Every architecture is a set of trades. These are the three that shaped everything above, and what each one bought.

Postgres does the middleware

The queue is pg-boss, the journal is DBOS, the event bus is LISTEN/NOTIFY, the vector index is pgvector. No Redis, no Kafka, no separate vector database. One thing to back up, one thing to restore, one thing that can be down.

The client holds no secrets

The owner UI has no database credentials and no session key. It reads its origin at runtime and talks over bearer. That is what lets one image serve any install, and lets you run the UI locally against a deployed brain with nothing but a token.

The runner has no front door

server/api is reached only by enqueue and answers only over NOTIFY. A process with no HTTP surface has no HTTP attack surface, and it is what makes a turn survive a restart, a deploy, or a week parked on a question waiting for you.