Architecture
Last updated April 7, 2026
Internal components, bootstrap sequence, and request lifecycle.
The gateway is a Fastify 5 application with a handful of internal components that handle routing, auth, host tracking, and WebSocket dispatch. This page documents the internal structure — the code you'd touch if you were modifying the gateway itself.
Source: infra/kb-labs-gateway/apps/gateway-app/src/.
Component layout
apps/gateway-app/src/
├── bootstrap.ts ← startup sequence
├── config.ts ← loadGatewayConfig() — reads kb.config.json
├── server.ts ← Fastify setup, route registration
├── manifest.ts ← ServiceManifest for kb-dev
│
├── auth/
│ ├── middleware.ts ← Bearer token validation
│ ├── routes.ts ← /auth/token, /auth/refresh
│ └── tokens.ts ← JWT minting, verification, token store
│
├── hosts/
│ ├── registry.ts ← HostRegistry — in-cache + persistent store
│ ├── dispatcher.ts ← Dispatch capability calls to connected hosts
│ └── ws-handler.ts ← WebSocket handler for host agent connections
│
├── execute/
│ ├── routes.ts ← /internal/dispatch endpoint
│ ├── execution-registry.ts
│ ├── retry-executor.ts
│ └── errors.ts
│
├── ws/
│ └── gateway-ws.ts ← WebSocket server wiring
│
├── llm/ ← (optional) LLM gateway routes
├── platform/ ← Platform introspection
├── clients/ ← Typed upstream clients
├── telemetry/ ← Correlation + metrics
├── observability/ ← Diagnostic events
└── docs/ ← /openapi.json registrationBootstrap sequence
From bootstrap.ts:
- Init platform.
createServiceBootstrap({ appId: 'gateway', repoRoot })loads.env, initializes the platform singleton (logger, cache, storage, optionally a SQL adapter). - Build a correlated logger with
serviceId: 'gateway'bindings. - Load gateway config via
loadGatewayConfig(repoRoot)— this parses thegatewaysection ofkb.config.jsonthroughGatewayConfigSchema. Missing config or missinggatewaykey means the gateway starts with defaults (port: 4000, empty upstreams). - Create the host store. If
platform.sqlDatabaseis registered, construct aSqliteHostStorefor persistence. Otherwise log a warning and fall back to cache-only — hosts will be lost on restart. - Create the
HostRegistry, combiningplatform.cache(for fast lookup) with the optional SQL store (for durability). - Restore persisted hosts from the store into the cache. If restore fails, log a diagnostic event and abort bootstrap.
- Seed static tokens. For each entry in
gateway.staticTokens, writehost:token:<token>→{ hostId, namespaceId }into the cache so the auth middleware recognizes them. - Build JWT config. Read
GATEWAY_JWT_SECRETfrom env. Required in production; warns and falls back to an insecure default if missing. - Create the Fastify server with the injected registry, JWT config, and upstreams.
- Start listening on the configured port.
- Wire graceful shutdown on
SIGTERM/SIGINT.
Persistent host store
The gateway uses SQLite for persistence when available:
const db = platform.getAdapter<ISQLDatabase>('sqlDatabase');
if (db) {
hostStore = new SqliteHostStore(db);
logger.info('Host store: SQLite (persistent)');
} else {
logger.warn('Host store: none (cache-only, hosts will be lost on restart)');
}The choice is automatic — the gateway inspects the platform's adapter registry at bootstrap time. Configure a SQL adapter (see Configuration → kb.config.json) to enable persistence; leave it out and every gateway restart starts with an empty host registry.
This isn't just about host metadata — it's about whether in-flight WebSocket sessions survive a restart. Without persistence, every host agent has to reconnect after a gateway restart. With persistence, the registry comes back with its previous state and existing connections can be resumed or rejected cleanly.
Fastify setup
The gateway uses Fastify 5 with @fastify/cors for cross-origin handling and @fastify/websocket (via @kb-labs/shared-http) for the WebSocket tunnel.
Body limit: the gateway sets a moderate body limit — large enough for typical API requests (several MB) but bounded to avoid request-smuggling attacks.
Logging: like every other service, Fastify's own logger is disabled and all logging flows through platform.logger.
Host registry
HostRegistry is the data structure that tracks which hosts are currently connected. Two-layer design:
- Cache layer (
platform.cache) — fast lookups and TTL-based expiration. - Store layer (
SqliteHostStoreor none) — durable records that survive restart.
On every write, the registry updates both layers. On reads, it hits the cache first and falls back to the store for misses (e.g. after a restart, before the cache is warm).
Keys:
host:connected:<hostId>— "this host is currently connected" marker with TTL.host:token:<token>— token →{ hostId, namespaceId }lookup for auth.host:by-namespace:<namespaceId>— reverse lookup for dispatch.
The TTL-based approach means stale entries expire automatically even if a host disconnects ungracefully.
Host dispatcher
dispatcher.ts is what the /internal/dispatch endpoint calls when it needs to execute something on a host agent. Given a namespaceId (or a specific hostId), the dispatcher:
- Looks up a connected host in the registry.
- Sends a capability call message over the host's WebSocket.
- Correlates the response by
requestId. - Streams intermediate events (
chunk) and the finalresultback to the caller.
This is the mechanism behind the workspace-agent adapter: when a workflow asks for a workspace, the adapter calls /internal/dispatch, which routes to a connected host agent, which performs the filesystem operation locally and sends the result back.
WebSocket handlers
Two distinct WebSocket surfaces:
/hosts/connect— for host agents connecting inbound. Authenticated via Bearer JWT. Managed byws-handler.ts./api/v1WebSocket upgrades — proxied through to the REST API upstream ifwebsocket: trueis set on the upstream config. The gateway acts as a transparent WebSocket proxy in this case.
The host agent WS is the more interesting one — it's bidirectional, survives for the lifetime of the connection, and is the channel for dispatching capability calls back to the agent.
Internal dispatch endpoint
/internal/dispatch is a gateway-internal endpoint used by the container execution backend. When platform.execution.mode === 'container', the REST API (or workflow daemon) forwards execution requests through this endpoint to a container environment.
It's not exposed publicly — auth is via a separate shared secret (GATEWAY_INTERNAL_SECRET), not via the public token system. The endpoint accepts a handler reference plus inputs and returns the execution result.
This is how container-mode plugin execution works end-to-end:
- CLI invokes
pnpm kb commit:commit. - REST API (or CLI's gateway-executor) looks up the plugin's handler.
- Execution mode is
container→ forward toPOST /internal/dispatchon the gateway. - Gateway mints a short-lived JWT for the container to call back with.
- Gateway tells the environment adapter to spin up a container with the JWT and the handler reference.
- Container starts a runtime server, imports the handler, executes it, and streams results back.
- Gateway forwards the results to the original caller.
See Concepts → Execution Model for the full pipeline.
Request lifecycle
For a typical proxied request (say, POST /api/v1/plugins/commit/generate from Studio):
Studio
│
▼
Gateway :4000
│ 1. CORS preflight (handled by @fastify/cors)
│ 2. Auth middleware: verify Bearer token → { hostId, namespaceId }
│ 3. Correlation middleware: attach traceId/requestId to request
│ 4. Match upstream: prefix '/api/v1' → rest @ http://localhost:5050
│ 5. Apply rewritePrefix: (none) → forward path unchanged
│ 6. Forward headers + body to REST API
│ 7. Stream response back to Studio
▼
REST API :5050
│ handles the request, calls plugin handler, returns result
▼
Gateway → Studio (response)For a host-dispatched request (workflow wants to read a file on a developer's laptop):
Workflow daemon
│
▼
Gateway /internal/dispatch
│ 1. Verify internal secret header
│ 2. Parse request { namespaceId, capability, method, args }
│ 3. Look up connected host in HostRegistry for namespaceId
│ 4. Send WS message to host agent: { requestId, capability, method, args }
│ 5. Wait for response (with timeout)
▼
Host agent (on laptop) ← WSS tunnel
│ executes local filesystem call
▼
Gateway ← WS message: { requestId, result }
▼
Workflow daemon (response)Graceful shutdown
On SIGTERM / SIGINT:
- Stop accepting new HTTP and WS connections.
- Send
disconnectframes to connected host agents (they'll reconnect later with backoff). - Drain in-flight HTTP requests.
- Close the Fastify server.
- Close the host store (commit any pending writes to SQLite).
- Call
platform.shutdown()to flush the logger and close adapters. - Exit.
What to read next
- Overview — high-level purpose and config.
- Authentication — token types, lifetimes, claims.
- Routing — the rules for matching and forwarding requests.
- Services → Host Agent — the other end of the WSS tunnel.