Architecture
Gateway
Gateway
A gateway is the long-running process that mounts the /centraid URL
prefix, owns an apps store on disk, drives chat/builder/automation runs, and
dispatches handler calls. There is one host-agnostic implementation —
@centraid/gateway's buildGateway() — that three hosts mount.
Three ways to run, one core
buildGateway() constructs the whole object graph (stores, conversation
runner, Runtime, the in-process cron scheduler, every route handler) without
binding a socket. serve() wraps it with a loopback bind + bearer auth.
Embedded in desktop
apps/desktop calls serve() inside the Electron main process. The renderer
talks to it directly over HTTP with a Bearer token (the desktop is a
thin client) — the same authenticated fetch it
would issue against a remote gateway, aimed at loopback. This is the default
for personal use; your data never leaves the laptop. Paths derive from
gateway-paths.ts; secrets (the bearer) use Electron safeStorage.
Standalone daemon (centraid-gateway)
The same serve() runs as a CLI daemon (packages/gateway/src/cli/cli.ts).
Paths derive from a --data-dir / config file; the bearer token is persisted
at <dataDir>/token.bin. Desktop and mobile reach it through their existing
remote-gateway flow — no new wire protocol.
OpenClaw plugin
@centraid/openclaw-plugin calls buildGateway() and mounts its
composedHandler (the route chain minus the bearer check) on the OpenClaw
HTTP server, which owns auth itself. It claims the /centraid,
/_centraid-conversations, and /_centraid-user prefixes.
What each host injects
The core is the same; hosts inject only paths/secrets plus a few Plane B
seams (build-gateway.ts options):
| Seam | Default (desktop / daemon) | OpenClaw override |
|---|---|---|
conversationRunner |
gateway's own codex/claude runner | in-process runEmbeddedAgent runner |
runnerStatus |
codex/claude CLI preflight | { kind: 'openclaw', ok: true } |
fireAutomationFactory |
runAutomation (codex/claude puppet) |
in-process fire (ctx.tool/ctx.agent) |
appsStoreRoot |
set (git store) on desktop & OpenClaw; omitted on the daemon | set |
App code: git store vs legacy tarballs
When appsStoreRoot is supplied, the gateway owns app code as a git store
(issue #137): a bare apps.git plus worktrees. The runtime serves handlers +
static from the live main worktree, rotated atomically per publish. App
data (data.sqlite) stays under appsDir, cleanly separated. The desktop
and the OpenClaw plugin both run this backend.
Omitting appsStoreRoot falls back to the legacy tarball-upload backend:
each upload extracts into an immutable versions/v_<ts>_<sha>/ dir and a
current.json pointer flips on publish. The standalone daemon currently runs
this legacy backend — so it has no draft worktree and uses the data-only chat
runner rather than the unified builder chat.
The git store is the steady-state design. The legacy tarball path is what remains for hosts that haven't adopted a store root.
The gateway owns the builder
On the git-store backend the gateway owns the whole authoring loop, so all hosts that run it behave identically:
- Deterministic lifecycle — scaffold a blank app, clone a template,
rename, and automation CRUD are HTTP endpoints (
lifecycle-routes.ts). The desktop states intent; the gateway stages the change into a session worktree (the draft), mints webhook secrets, and (on explicit publish) flips the live version + reconciles the cron scheduler. - Template catalog —
GET /centraid/_templatesresolves bundle-or-cache; the gateway refreshes the cache from a remote manifest URL on startup (best-effort). - Unified chat — one
POST /centraid/<id>/_turnruns the turn in the app's draft worktree with the union of the agent's native file tools and thecentraid_*data dispatcher, so a single turn both edits the app's code and operates its data. Edits stage in the draft (previewable at/centraid/_draft/<sessionId>/<id>/); Publish is the explicit flip.
See IPC vs HTTP for how the thin desktop client reaches all of this.
Host-capability catalogs
The gateway owns two per-runner catalogs, persisted at the host-supplied
model-catalog.json (paths.modelCatalogFile):
- Model catalog — feeds the chat model picker. Filled by live enumeration
(claude
supportedModels(), codexmodel/list). There is no hardcoded seed: a cold catalog reads[]and the picker shows a loading state until the warmer fills it in. - Host-tool catalog — each runner's builtins + MCP tools, used by the builder's grounding block.
A single CatalogWarmer owns enumeration. On every gateway start, a
background probe warms both surfaces for each detected runner
(probeCliAvailability decides which CLIs are present). Models and tools
refresh on independent triggers; an empty result never clobbers a prior
good entry. See Agent runtime for the enumeration
mechanics.
Routes
GET /centraid/_turn/runner-status— the active runner's readiness + cached models;?refresh=1kicks a model re-enumeration.GET /centraid/_agents/status— detection for every agent the gateway host can drive:{ codexAvailable, claudeAvailable, …Version, …Models, …ModelsStatus, …Tools, …ToolsStatus }.?refresh=1re-enumerates models;?refreshTools=1re-probes the (slower) tool surface. Detection is auth-agnostic — it only checks that<bin> --versionruns, never inspecting keychains or env keys.
A remote gateway reports its own host's agents, not the client's.
On-disk layout
Git-store backend (desktop, OpenClaw)
The desktop lays each gateway out under <userData>/gateways/<id>/ (the local
gateway has the fixed id local):
gateways/<id>/ profile.json ← { id, kind, label, url?, createdAt } token.bin ← bearer (safeStorage-encrypted) code-store/ apps.git ← bare repo (pushable to GitHub) worktrees/ main/<sha>/ ← read-only live materialization sessions/<id>/ ← mutable per-session draft worktrees active-main ← symlink to the live main worktree apps/ _registry.json ← which app ids are registered <appId>/ data.sqlite ← persistent app data; survives version swaps runtime.sqlite ← conversations/turns/items + automation state identity.sqlite ← users + per-user prefs analytics.sqlite ← one summary row per run model-catalog.json ← per-runner model + tool catalog templates-cache/ ← downloaded remote-template tarballsApp code lives in the git store; the runtime serves it from the live
main worktree. App data lives under apps/<appId>/, outside any worktree
so it survives version swaps. The standalone daemon uses the same files minus
the gateways/<id>/ segment (it hosts exactly one gateway).
Legacy tarball backend
Without a store root, app code is versioned under apps/<id>/versions/v_<ts>_<sha>/
with a current.json pointer; data.sqlite still lives at the app root.
Re-uploading identical content (same sha256) collapses to one dir, and
retention prunes old versions (the active version is always retained).
Lifecycle of an app (git store)
- Scaffold / clone — a lifecycle endpoint stages a blank app or a template into a session worktree (the draft).
- Edit — chat turns and file edits stage in the draft worktree; the
draft's branched
data.sqliteis seeded from live so the agent can author and exercise a migration without touching live rows. - Publish — merges the session onto
main, materializes a newworktrees/main/<sha>/, flips theactive-mainpointer, applies any migration to live data, and reconciles the cron scheduler. - Roll back — re-flips the pointer to a prior
mainmaterialization.
Security model in one paragraph
App handlers are trusted local code, authored by the same user running the
gateway. Worker-thread isolation gives crash isolation, timeouts, and a
controlled API surface — not a sandbox against hostile code. Defense in
depth already in place: a Bearer check on every request (except where a
fronting host owns auth), path-traversal guards and an extension allowlist on
static serving, reserved filenames/dirs that are never served, a per-response
Content-Security-Policy (default-src 'self', scoped script-src/img-src/
object-src 'none'/frame-ancestors 'self') and X-Content-Type-Options: nosniff, worker resourceLimits capping memory, and per-handler timeouts
(default 10s query / 30s action). Hardening to "hostile code" level
(isolated-vm, child-process + permission flags) is a future swap-in.
Where to go next
- Apps — what's inside an app dir.
- Queries and actions — the handler contract.
- Agent runtime — the runner, model catalog, and tool catalog.
- IPC vs HTTP — how the desktop reaches a local or remote gateway.
- Deploy — how to put a gateway on an OpenClaw host.