Files
openclaw-studio/ARCHITECTURE.md
T

28 KiB

Architecture

High-level overview & goals

OpenClaw Studio is a gateway-first, single-user Next.js App Router UI for managing OpenClaw agents. It provides:

  • A focused UI with fleet list, primary agent panel, and inspect sidebar.
  • Local persistence for gateway connection + focused-view preferences via a JSON settings file.
  • A same-origin WebSocket bridge (/api/gateway/ws) from browser to the upstream OpenClaw gateway.
  • Gateway-backed edits for agent config and agent files.

Primary goals:

  • Gateway-first: agents, sessions, and config live in the gateway; Studio stores only UI settings.
  • Remote-friendly: tailnet/remote gateways are first-class.
  • Clear boundaries: client UI vs server routes vs external gateway/config.
  • Predictable state: gateway is source of truth; local settings only for focused preferences + connection.
  • Maintainability: feature-focused modules, minimal abstractions.

Non-goals:

  • Multi-tenant or multi-user concurrency.
  • Server-side rendering of data from external services.

Architectural style

Layered + vertical slice (feature-first) within Next.js App Router:

  • UI components + feature state in src/features.
  • Shared utilities and adapters in src/lib.
  • Server-side route handlers under src/app/api.

This keeps feature cohesion high while preserving a clear client/server boundary.

Main modules / bounded contexts

  • Focused agent UI (src/features/agents): focused agent panel, fleet sidebar, inspect panel, and local in-memory state + actions. The fleet sidebar keeps the same New Agent entry point, now implemented as a one-step AgentCreateModal flow (name + avatar + launch). Creation is create-only: src/app/page.tsx calls runCreateAgentMutationLifecycle and createGatewayAgent, then reloads fleet state and focuses chat for the new agent; no guided setup compilation, deferred setup persistence, or pending retry UI remains in runtime flows. Agents render a status-first summary and latest-update preview driven by gateway events. Per-agent runtime controls (model, thinking) live in the chat header (AgentChatPanel), active runs can be stopped from the chat composer via chat.abort, and pending exec approvals render in-chat action cards (Allow once, Always allow, Deny) while fleet rows show Needs approval. Settings sidebar actions remain focused on rename, display toggles, permission updates (updateAgentPermissionsViaStudio), new session, cron list/run/delete/create, and delete (AgentSettingsPanel). The Skills section in AgentSettingsPanel is split into Access (per-agent allowlist mode + toggles) and Library (gateway-wide setup actions in modal flow). Cron creation continues to use a guided modal scoped to the selected settings agent. Gateway event classification (presence/heartbeat summary refresh and chat/agent runtime streams) is centralized in bridge helpers (src/features/agents/state/runtimeEventBridge.ts), while runtime flow decisions are emitted from pure policy helpers (src/features/agents/state/runtimeEventPolicy.ts) and executed by src/features/agents/state/gatewayRuntimeEventHandler.ts; both are consumed from one gateway subscription path in src/app/page.tsx, where exec approval events are handled in parallel. Higher-level orchestration is factored into operations under src/features/agents/operations/ (fleet hydration snapshots in agentFleetHydration.ts, pure fleet hydration derivation in agentFleetHydrationDerivation.ts, chat send in chatSendOperation.ts, cron create in cronCreateOperation.ts, mutation lifecycle policy (create/rename/delete) in mutationLifecycleWorkflow.ts, latest-update policy in latestUpdateWorkflow.ts, fleet summary/reconcile policy in fleetLifecycleWorkflow.ts, reconcile operation adapter in agentReconcileOperation.ts, history request/disposition policy in historyLifecycleWorkflow.ts, history sync operation adapter in historySyncOperation.ts, approval lifecycle policy in src/features/agents/approvals/execApprovalLifecycleWorkflow.ts, manual exec approval resolve operation in src/features/agents/approvals/execApprovalResolveOperation.ts, and execution primitives in useConfigMutationQueue.ts and useGatewayRestartBlock.ts). Rename/delete post-run UI side effects are emitted as typed mutation commands from mutationLifecycleWorkflow.ts and executed in src/app/page.tsx. Session setting mutations (model/thinking) are centralized in src/features/agents/state/sessionSettingsMutations.ts so optimistic state updates and sync/error behavior stay aligned. Transcript ownership is split intentionally: optimistic send appends local user transcript entries while canonical timestamps and final ordering come from chat.history sync via the history workflow boundary (historyLifecycleWorkflow.ts) and operation adapter (historySyncOperation.ts); replayed terminal chat events and late deltas from recently closed runs are ignored in gatewayRuntimeEventHandler, which requests recovery history only through the requestHistoryRefresh boundary command. Studio fetches a capped amount of chat history by default (currently 200 messages) and exposes a “Load more” affordance when the transcript may be truncated. Disconnected startup now uses a status-first GatewayConnectScreen with a local command copy affordance and a collapsible remote form.
  • Studio settings (src/lib/studio, src/app/api/studio): local settings store for gateway URL/token and focused preferences (src/lib/studio/settings.ts, src/lib/studio/settings-store.ts, src/app/api/studio/route.ts). src/lib/studio/coordinator.ts now owns both the /api/studio transport helpers and shared client-side load/patch scheduling for gateway and focused settings. The /api/studio envelope also carries domainApiModeEnabled, and the UI treats that server-reported flag as the authoritative domain-mode source.
  • Gateway (src/lib/gateway): WebSocket client for agent runtime (frames, connect, request/response). Session settings sync transport (sessions.patch) is centralized in src/lib/gateway/GatewayClient.ts. Connect failures surfaced through the Studio WS proxy are preserved as GatewayResponseError codes (parsed from connect failed: <CODE> ...) so useGatewayConnection can gate auto-retry via resolveGatewayAutoRetryDelayMs. The OpenClaw control UI client is vendored in src/lib/gateway/openclaw/GatewayBrowserClient.ts with a sync script at scripts/sync-openclaw-gateway-client.ts.
  • Studio gateway proxy server (server/index.js, server/gateway-proxy.js, server/studio-settings.js): custom Next server that terminates browser WS at /api/gateway/ws, loads upstream gateway URL/token server-side, injects auth token when needed, and forwards frames to the upstream gateway.
  • Gateway SSH helpers (src/lib/ssh/gateway-host.ts): shared SSH target resolution and JSON-over-SSH execution for server routes. runSshJson centralizes ssh -o BatchMode=yes invocation, JSON parsing, and actionable error extraction; callers with large payloads (for example base64 media reads) can opt into a higher maxBuffer rather than duplicating spawnSync calls.
  • Gateway-backed config + agent-file edits (src/lib/gateway/agentConfig.ts, src/lib/gateway/agentFiles.ts, src/lib/gateway/execApprovals.ts, src/features/agents/components/AgentInspectPanels.tsx): agent create/rename/heartbeat/delete and per-agent overrides via config.get + config.patch, agent file read/write via agents.files.get and agents.files.set, and per-agent exec approvals via exec.approvals.get + exec.approvals.set.
  • Heartbeat helpers (src/lib/gateway/agentConfig.ts): resolves per-agent heartbeat state (enabled + schedule) by combining gateway config (config.get) and status (status) for the settings panel, triggers wake for “run now”, and owns the heartbeat type shapes and gateway config mutation helpers.
  • Session lifecycle actions (src/features/agents/state/store.tsx, src/app/page.tsx): per-agent “New session” calls gateway sessions.reset on the current session key and resets local runtime transcript state.
  • Local OpenClaw config + paths (src/lib/clawdbot): state/config path resolution with OPENCLAW_* env overrides (src/lib/clawdbot/paths.ts). Gateway URL/token in Studio are sourced from studio settings.
  • Shared agent config-list helpers (src/lib/gateway/agentConfig.ts): pure agents.list read/write/upsert helpers used by gateway config patching to keep list-shape semantics aligned.
  • Shared utilities (src/lib/*): env, ids, names, avatars, message parsing/normalization (including tool-line formatting) in src/lib/text/message-extract.ts, cron types + selector helpers + gateway call helpers in src/lib/cron/types.ts, logging, filesystem helpers.

Directory layout (top-level)

  • src/app: Next.js App Router pages, layouts, global styles, and API routes.
  • src/features: feature-first UI modules (currently focused agent-management components under features/agents).
  • src/lib: domain utilities, adapters, API clients, and shared logic.
  • src/components: shared UI components (minimal use today).
  • src/styles: shared styling assets.
  • server: custom Node server and WS proxy for gateway bridging + access gate.
  • public: static assets.
  • tests, playwright.config.ts, vitest.config.ts: automated testing.

Data flow & key boundaries

1) Studio settings + focused preferences

  • Source of truth: JSON settings file at ~/.openclaw/openclaw-studio/settings.json (resolved via resolveStateDir in src/lib/clawdbot/paths.ts). Settings store the gateway URL/token plus per-gateway focused preferences.
  • Server boundary: src/app/api/studio/route.ts loads/saves settings by reading and writing openclaw-studio/settings.json under the resolved state dir.
  • Client boundary: useGatewayConnection and focused/session flows in src/app/page.tsx use a shared StudioSettingsCoordinator to load settings and coalesce debounced /api/studio patch writes.

Flow:

  1. UI loads settings from /api/studio.
  2. Gateway URL/token seed the connection panel and auto-connect.
  3. Focused filter + selected agent are loaded for the current gateway.
  4. UI schedules focused and gateway patches through the coordinator; both paths converge on /api/studio.

2) Agent runtime (gateway)

  • Client-side boundary: GatewayClient connects to Studio-origin /api/gateway/ws via resolveStudioProxyGatewayUrl() and wraps the vendored GatewayBrowserClient.
  • Server-side boundary: custom server proxy (server/gateway-proxy.js) is in the middle for upstream URL/token resolution and connect-frame token injection.

Flow:

  1. UI loads gateway URL/token from /api/studio (defaulting to NEXT_PUBLIC_GATEWAY_URL, or ws://localhost:18789 when that env var is unset).
  2. Browser opens WS to Studio /api/gateway/ws (ws:// on http, wss:// on https).
  3. Proxy loads upstream URL/token from Studio settings on the server and opens upstream WS.
  4. Proxy forwards connect and subsequent frames; it injects auth token server-side if the connect frame has none.
  5. If upstream connect fails, the proxy sends an error response for the connect request (with a studio.* error code when possible) and closes the Studio-origin WS. The browser-side gateway client converts a failed connect response into a WS close with code 4008 and a reason like connect failed: <CODE> ...; GatewayClient.connect() parses this into GatewayResponseError, and useGatewayConnection decides whether/when to auto-retry based on connectErrorCode (through resolveGatewayAutoRetryDelayMs).
  6. UI requests agents.list and builds session keys via buildAgentMainSessionKey(agentId, mainKey).
  7. A single gateway listener in src/app/page.tsx classifies presence/heartbeat/chat/agent events through classifyGatewayEventKind in src/features/agents/state/runtimeEventBridge.ts, then routes runtime payloads through src/features/agents/state/runtimeEventPolicy.ts and executes intents via src/features/agents/state/gatewayRuntimeEventHandler.ts; it also independently tracks exec.approval.requested / exec.approval.resolved for in-chat approval cards.
  8. Agent store updates agent output/state.
  9. Pending approval queues are pruned by expiry timestamp (with a short grace window), so stale cards and stale awaitingUserInput badges self-clear even when no resolved event arrives.

2b) Control-plane domain API mode + replay/history

  • Authoritative mode source: src/app/page.tsx derives useDomainApiMode only from /api/studio (domainApiModeEnabled === true). Client env checks are not used for runtime request routing in the main app flow.
  • Legacy WS suppression in domain mode: useGatewayConnection in src/lib/gateway/GatewayClient.ts does not auto-open or auto-retry /api/gateway/ws when domainApiModeEnabled === true; legacy browser WS remains available for explicit legacy-mode/diagnostic use.
  • Live stream replay contract (/api/runtime/stream): with Last-Event-ID > 0, replay starts after that id; without Last-Event-ID, replay starts from the recent outbox tail (outboxHead - REPLAY_LIMIT) to avoid stale full-history startup replays.
  • Gap-free stream bootstrap sequencing (/api/runtime/stream): stream startup subscribes first, buffers startup live rows, fetches replay from the effective cursor/floor, drains buffered rows in ascending outbox id order, and emits all rows through one monotonic id guard (entry.id > lastDeliveredId). This prevents replay/subscribe boundary drops and replay/live overlap duplicates in reconnect and fresh-connect paths.
  • History pagination contract (/api/runtime/agents/[agentId]/history): accepts limit and optional beforeOutboxId (exclusive upper bound), returns entries in ascending outbox order plus hasMore and nextBeforeOutboxId. Initial reads fetch the newest window; “load more” requests pass the returned cursor.
  • Domain history ingestion: domain history responses are ingested into the same event pipelines used by live stream handling (gatewayRuntimeEventHandler and exec-approval ingress) with outbox-id dedupe in useRuntimeSyncController.

3) Agent create + per-agent setup

  • Agent files: AGENTS.md, SOUL.md, IDENTITY.md, USER.md, TOOLS.md, HEARTBEAT.md, MEMORY.md.
  • Create + heartbeat + rename + per-agent overrides: stored in gateway config and updated via config.get + config.patch.
  • Exec approvals policy: managed after creation through settings/runtime approval flows.

Flow:

  1. AgentCreateModal captures only create payload (name, optional avatarSeed) in a one-step launch flow (src/features/agents/components/AgentCreateModal.tsx).
  2. runCreateAgentMutationLifecycle enqueues one create mutation, applies queue guardrails, and drives create block status (src/features/agents/operations/mutationLifecycleWorkflow.ts).
  3. createGatewayAgent calls config.get to derive workspace, then calls agents.create with { name, workspace } (src/lib/gateway/agentConfig.ts).
  4. Studio reloads fleet state and focuses the created agent chat in src/app/page.tsx.
  5. Any authority/runtime adjustments happen after creation through settings operations such as updateAgentPermissionsViaStudio (src/features/agents/operations/agentPermissionsOperation.ts).

4) Cron summaries + settings controls

  • Cron: the UI calls gateway cron methods directly (cron.list, cron.add, cron.run, cron.remove) for latest-update previews and agent settings controls.
  • Create flow: AgentSettingsPanel collects a CronCreateDraft in a modal wizard, buildCronJobCreateInput maps it to a gateway-safe payload (src/lib/cron/createPayloadBuilder.ts), and performCronCreateFlow executes create + scoped refresh (src/features/agents/operations/cronCreateOperation.ts).

5) Session settings synchronization

  • UI boundary: AgentChatPanel emits model/thinking callbacks from the agent header; src/app/page.tsx delegates both through one mutation helper.
  • Mutation boundary: applySessionSettingMutation in src/features/agents/state/sessionSettingsMutations.ts owns optimistic store updates, sessionCreated guard logic, sync success updates, and user-facing failure lines.
  • Transport boundary: syncGatewaySessionSettings in src/lib/gateway/GatewayClient.ts is the only client-side builder/invoker for sessions.patch payloads.

Cross-cutting concerns

  • Configuration: environment variables are read directly from process.env. The browser uses NEXT_PUBLIC_GATEWAY_URL only as a default upstream URL when Studio settings are missing; the Studio server persists upstream URL/token in <state dir>/openclaw-studio/settings.json and the WS proxy loads them via server/studio-settings.js. State path resolution lives in lib/clawdbot/paths.ts, honoring OPENCLAW_STATE_DIR. When Studio token is missing, settings loaders can fall back to token/port from <state dir>/openclaw.json. Loopback-IP gateway URLs are normalized to localhost in Studio settings, and the WS proxy rewrites loopback upstream origins to localhost for control-UI secure-context compatibility. The optional Studio access gate is enabled by STUDIO_ACCESS_TOKEN (server/access-gate.js).
  • Runtime durability + startup guard: domain-mode runtime projection/outbox persistence is stored in ${resolveStateDir()}/openclaw-studio/runtime.db via better-sqlite3 (src/lib/controlplane/projection-store.ts). Startup scripts (verify:native-runtime:repair for dev and verify:native-runtime:check for start) verify native addon compatibility before server boot.
  • Testing: Playwright e2e runs Studio with an isolated OPENCLAW_STATE_DIR so the Studio WS proxy does not read real upstream gateway settings from the developer machine.
  • Logging: API routes and the gateway client use built-in console.* logging.
  • Error handling:
    • API routes return JSON { error } with appropriate status.
    • fetchJson throws when !res.ok, surfaces errors to UI state.
    • StudioSettingsCoordinator logs failed async persistence writes (debounced flush or queued patch failures) so settings-save errors are observable.
    • Gateway connect failures with INVALID_REQUEST: invalid config surface a doctor hint in Studio (npx openclaw doctor --fix / pnpm openclaw doctor --fix).
    • Gateway connect failures that close with connect failed: <CODE> ... are preserved as GatewayResponseError codes so auto-retry gating can be code-driven (instead of message-driven).
    • Gateway browser client truncates close reasons to WebSocket protocol limits (123 UTF-8 bytes) to avoid client-side close exceptions on long error messages.
  • Filesystem helpers: server-only filesystem operations live at the API route boundaries. These helpers are used for local settings and gateway-adjacent file operations, not for agent file edits.
  • Remote gateway tools over SSH: some server routes execute small scripts on the gateway host (for example agent-state operations and remote media reads). Shared helpers in src/lib/ssh/gateway-host.ts own SSH invocation and JSON parsing so routes do not hand-roll spawnSync error handling.
  • Tracing: src/instrumentation.ts registers @vercel/otel for telemetry.
  • Validation: request payload validation in API routes and typed client/server helpers in src/lib/*.

Major design decisions & trade-offs

  • Local settings file over DB: fast, local-first persistence for gateway connection + focused preferences; trade-off is no concurrency or multi-user support.
  • Same-origin WS proxy instead of direct browser->gateway WS: allows server-side token custody/injection and easier local/remote switching; trade-off is one extra hop and custom-server ownership.
  • Gateway-first agent records: records map 1:1 to agents.list entries with main sessions; trade-off is no local-only agent concept.
  • Gateway-backed config + agent-file edits: create/rename/heartbeat/delete and per-agent overrides via config.patch, agent files via agents.files.get/agents.files.set, and per-agent exec approvals via exec.approvals.set; trade-off is reliance on gateway availability.
  • Fleet hydration snapshot/derive split: hydrateAgentFleetFromGateway loads gateway/settings snapshots (I/O) and delegates all derived decisions (seeds, exec policy resolution, summary selection) to a pure derivation helper; trade-off is one extra module and a more explicit snapshot input, but the derivation becomes independently testable.
  • Extract page-level workflows into operations: keep src/app/page.tsx as wiring by moving workflow policy into operation modules (for example reconcile in src/features/agents/operations/agentReconcileOperation.ts and manual exec approval resolve in src/features/agents/approvals/execApprovalResolveOperation.ts); trade-off is more modules, but the workflows become independently unit testable without React rendering.
  • Structured connect errors + retry policy helper: GatewayClient.connect() preserves gateway connect-failure codes and useGatewayConnection gates auto-retry via resolveGatewayAutoRetryDelayMs; trade-off is one more helper plus extra state (connectErrorCode), but the behavior is less brittle than string matching.
  • Narrow local config mutation boundary: Studio does not write openclaw.json directly today; if a local-only integration is introduced, keep any local writes narrowly scoped to that integration and reuse shared list helpers instead of ad-hoc mutation paths; trade-off is less flexibility for local-only experimentation, but clearer ownership and lower drift risk.
  • Shared agents.list helper layer: gateway and local config paths now consume one pure helper module for list parsing/writing/upsert behavior; trade-off is one more shared dependency, but it reduces semantic drift and duplicate bug surface.
  • Single gateway settings endpoint: /api/studio is the sole Studio gateway URL/token source; trade-off is migration pressure on any older local-config-based callers, but it removes ambiguous ownership and dead paths.
  • Shared client settings coordinator module: src/lib/studio/coordinator.ts now owns /api/studio transport plus load/schedule/flush behavior for gateway + focused state; trade-off is introducing a central client singleton, but it removes wrapper indirection and duplicate timers/fetch paths.
  • Single shared JSON-over-SSH helper: server routes that need to run a gateway-side script over SSH should use runSshJson in src/lib/ssh/gateway-host.ts (and opt into a larger maxBuffer when expecting large payloads) rather than duplicating spawnSync + JSON parsing; trade-off is one shared dependency, but it reduces drift risk and keeps error surfacing consistent.
  • Vendored gateway client + sync script: reduces drift from upstream OpenClaw UI; trade-off is maintaining a sync path and local copies of upstream helpers.
  • Feature-first organization: increases cohesion in UI; trade-off is more discipline to keep shared logic in lib.
  • Node runtime for API routes: required for filesystem access and tool proxying; trade-off is Node-only server runtime.
  • Event-driven summaries + on-demand history: keeps the dashboard lightweight; trade-off is history not being available until requested.
  • Runtime policy/executor split for event handling: one listener path in src/app/page.tsx classifies frames through src/features/agents/state/runtimeEventBridge.ts, derives side-effect-free decisions in src/features/agents/state/runtimeEventPolicy.ts, and executes those intents in src/features/agents/state/gatewayRuntimeEventHandler.ts; trade-off is additional intent-shape maintenance, but lower coupling between lifecycle policy and side effects.
  • Single gateway event intake subscription: one client.onEvent path now handles both summary-refresh events (presence/heartbeat) and runtime stream events (chat/agent) using bridge classification helpers; trade-off is a larger callback surface, but fewer lifecycle and cleanup divergence points.
  • Shared session-setting mutation path: model and thinking-level updates now pass through one UI mutation helper plus one gateway sync helper (src/features/agents/state/sessionSettingsMutations.ts + src/lib/gateway/GatewayClient.ts), reducing divergence between optimistic state and remote patch flows.
  • Server-reported domain mode authority: app-level runtime routing now uses /api/studio domainApiModeEnabled as the only authoritative toggle; trade-off is explicit parameter threading through operations/hooks, but it removes module-level routing divergence caused by client env fallback checks.
  • Gap-free SSE startup over outbox replay: /api/runtime/stream now uses early subscribe + startup buffering + a single monotonic outbox-id emission guard to preserve ordered delivery across replay and live handoff; trade-off is slightly more route complexity, but it removes boundary-loss and duplicate-delivery failure modes under reconnect/fresh-connect race conditions.

Mermaid diagrams

C4 Level 1 (System Context)

C4Context
  title OpenClaw Studio - System Context
  Person(user, "User", "Operates agents locally")
  System(ui, "OpenClaw Studio", "Next.js App Router UI")
  System(proxy, "Studio WS Proxy", "Custom server /api/gateway/ws")
  System_Ext(gateway, "OpenClaw Gateway", "WebSocket runtime")
  System_Ext(fs, "Local Filesystem", "settings.json and other local reads (e.g. path suggestions)")

  Rel(user, ui, "Uses")
  Rel(ui, proxy, "WebSocket frames")
  Rel(proxy, gateway, "WebSocket frames")
  Rel(ui, fs, "HTTP to API routes -> fs read/write")

C4 Level 2 (Containers/Components)

C4Container
  title OpenClaw Studio - Containers
  Person(user, "User")

  Container_Boundary(app, "Next.js App") {
    Container(client, "Client UI", "React", "Focused agent-management UI, state, gateway client")
    Container(api, "API Routes", "Next.js route handlers", "Studio settings and gateway-host state tools")
    Container(proxy, "WS Proxy", "Custom Node server", "Bridges /api/gateway/ws to upstream gateway with token injection")
  }

  Container_Ext(gateway, "Gateway", "WebSocket", "Agent runtime")
  Container_Ext(fs, "Filesystem", "Local", "settings.json and other local reads")

  Rel(user, client, "Uses")
  Rel(client, api, "HTTP JSON")
  Rel(client, proxy, "WebSocket /api/gateway/ws")
  Rel(proxy, gateway, "WebSocket")
  Rel(api, fs, "Read/Write")
  Rel(proxy, fs, "Read settings/token")

Explicit forbidden patterns

  • Do not read/write local files directly from client components.
  • Do not reintroduce local projects/workspaces as a source of truth for agent records.
  • Do not write agent rename/heartbeat/override data directly to openclaw.json; use gateway config.patch.
  • Do not read/write agent files on the local filesystem; use the gateway tools proxy.
  • Do not add parallel gateway settings endpoints; /api/studio is the only supported Studio gateway URL/token path.
  • Do not branch main app runtime request routing on client env helpers; use /api/studio domainApiModeEnabled.
  • Do not add new generic local openclaw.json mutation wrappers for runtime agent-management flows; if a local-only integration is introduced, keep any local writes narrowly scoped and well tested.
  • Do not store gateway tokens or secrets in client-side persistent storage.
  • Do not add new global mutable state outside AgentStoreProvider for agent UI data.
  • Do not silently swallow errors in API routes; always return actionable errors.
  • Do not add heavy abstractions or frameworks unless there is clear evidence of need.

Future-proofing notes

  • If multi-user support becomes a goal, replace the settings file with a DB-backed service and introduce authentication at the API boundary.
  • If gateway protocol evolves, isolate changes within src/lib/gateway and keep UI call sites stable.