Architecture

Gateway

Edit source

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 catalogGET /centraid/_templates resolves bundle-or-cache; the gateway refreshes the cache from a remote manifest URL on startup (best-effort).
  • Unified chat — one POST /centraid/<id>/_turn runs the turn in the app's draft worktree with the union of the agent's native file tools and the centraid_* 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(), codex model/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=1 kicks 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=1 re-enumerates models; ?refreshTools=1 re-probes the (slower) tool surface. Detection is auth-agnostic — it only checks that <bin> --version runs, 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):

Code
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 tarballs

App 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)

  1. Scaffold / clone — a lifecycle endpoint stages a blank app or a template into a session worktree (the draft).
  2. Edit — chat turns and file edits stage in the draft worktree; the draft's branched data.sqlite is seeded from live so the agent can author and exercise a migration without touching live rows.
  3. Publish — merges the session onto main, materializes a new worktrees/main/<sha>/, flips the active-main pointer, applies any migration to live data, and reconciles the cron scheduler.
  4. Roll back — re-flips the pointer to a prior main materialization.

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.
Was this useful?