Skip to content

Mnemose Architecture

Overview

Mnemose (Mnemosyne OS Environment) is a cloud-native SaaS platform where an AI agent acts as the primary cloud-infrastructure operator. The platform runs entirely on Cloudflare’s edge network — no GCP, no Docker containers, no Node.js runtime dependencies in Worker code.

Repository: Mnemose-ai/mnemose Stack: TypeScript monorepo (pnpm workspaces), Cloudflare Workers/D1/R2/KV/Queues/Durable Objects/Pages/Vectorize/AI Console: React 18 SPA on Cloudflare Pages at console.mnemose.ai API: GraphQL endpoint at api.mnemose.ai/graphql IaC: Pulumi (Cloudflare provider)


CQRS + Queues

Mnemose uses Command-Query Responsibility Segregation driven by Cloudflare Queues:

GraphQL mutation → commands Queue → handler → events Queue → read-model projection

Flow

  1. Client sends GraphQL mutation to Gateway Worker
  2. Worker validates auth (see Authentication), extracts tenant ID
  3. Worker publishes command to mnemose-commands Queue
  4. Queue consumer dispatches command to appropriate handler
  5. Handler executes business logic, returns domain events
  6. Events appended to domain_events table in D1 (append-only)
  7. Events published to mnemose-events Queue
  8. Event projection consumer updates read models in D1

Authentication

The gateway Worker (services/gateway/src/auth/cf-access.ts, wired from context.ts and REST /v1 routes) verifies identity and derives the tenant exclusively from verified claims (never from client headers, except the gated dev paths below). Auth failures are raised as UNAUTHENTICATED GraphQL / REST errors. The Worker’s maskError pass-through preserves that code, and both GraphQL and REST attach a WWW-Authenticate challenge so the console can trigger token refresh flows.

Token sources, in order: Cf-Access-Jwt-Assertion, Authorization: Bearer, then the CF_Authorization cookie.

Two verification paths:

  1. Cloudflare Access JWT (production path). The token is verified with jose (RS256) against the Cloudflare Access JWKS at https://<team-domain>.cloudflareaccess.com/cdn-cgi/access/certs. Tenant comes from the mnemose_tenant_id claim stamped by the Access IdP mapping. Pulumi resources live in infra/access.ts and are enabled with mnemose:provisionAccess. Setup: Cloudflare Access.
  2. Dev shared-secret bypass. When NODE_ENV is not "production" and the DEV_AUTH_TOKEN Worker secret matches the presented token (or DEV_AUTH_BYPASS is enabled and the token still matches), the request is accepted as the fixed dev actor (mnemose-dev-user) with the tenant taken from the schema-validated x-tenant-id header. Production ignores both flags. The console build mirrors this with VITE_DEV_AUTH_BYPASS / VITE_DEV_AUTH_TOKEN (set in the CI console deploy step from the MNEPOSE_DEV_AUTH_TOKEN secret).

Dev-environment first-run seeding lives in scripts/seed-dev-tenant.sh: it calls the registerSelf mutation (creates the “Mnemose System” tenant and the platform’s own cloud project) and then inserts a platform_admin IAM binding for the dev actor so myTenants populates.

Sandbox tenant authorization

Sandbox mutations are tenant-verified at both layers:

  1. GraphQL layer. Every sandbox mutation targeting an existing sandbox id (destroySandbox, execInSandbox, destroyExecSandbox, the updateSandbox* facet mutations, bind/unbindSandboxService, grant/revokeSandboxFacet) resolves the sandbox row first via assertSandboxTenant and rejects with a non-leaking Sandbox not found error when the row is missing or owned by another tenant. Ephemeral exec sandboxes (which may have no D1 row) are verified with SandboxDO.verifyCaller instead.
  2. Durable Object layer (defense-in-depth). SandboxDO.verifyCaller compares the caller’s tenant against the tenant stored in DO state and is invoked by every mutating RPC (terminate, executeCommand, suspend, resume, and all facet/data-source/deception-policy mutators). A leaked or guessed sandbox id cannot operate another tenant’s sandbox.

Key components

  • Commands — Zod schemas in packages/domain/src/commands/. 14 command types.
  • Handlers — Importable functions in services/commands/*/. Each receives a command + Env + DbClient, returns domain events.
  • Events — Zod schemas in packages/domain/src/events/. Appended to D1 domain_events table.
  • Read models — Drizzle-defined SQLite tables in packages/db/src/schema/. Updated by event projections.

Cloudflare Topology

Gateway Worker (mnemose-gateway-dev)

Entry point: services/gateway/src/worker.ts

  • HTTP handler — Hono app with routes:
    • POST /graphql — GraphQL Yoga endpoint
    • GET /health — Health check
    • ALL /agent/* — Proxy to AgentRuntimeDO
    • ALL /api/kernels/* — Proxy to KernelBrokerDO
    • GET /assets/:key — R2 asset proxy
    • GET /reports/:key — R2 report proxy
  • Queue handler — Consumes commands and events from Queues
  • Scheduled handler — Cron triggers for inventory sync, report builder, agent sweep, kernel sweep

CORS middleware allows console.mnemose.ai, mnemose-console.pages.dev, localhost:3200 with credentials.

Durable Objects

  • AgentRuntimeDO (services/gateway/src/do/agent-runtime-do.ts) — Agent execution runtime with hibernation support. SQLite-backed state. One DO per agent ID.
  • KernelBrokerDO (services/gateway/src/do/kernel-broker-do.ts) — Kernel registry and heartbeat management. One DO per tenant ID.

Agent Harness

Evaluation targets accept targetType “harness”, resolved tenant-scoped and executed via AgentRuntimeDO /run with harnessId.

D1 Database (mnemose-dev)

SQLite database. 41 tables. 0.65 MB initial size.

  • Event store: domain_events (append-only)
  • Read models: tenants, users, orgs, workspaces, projects, groups, sandboxes, cloud_projects, bootstrap_states, resource_inventory, scheduled_reports, agent_state, LLM provider config, etc. (the seven SEO read-model tables were dropped in migration 0013_drop_seo_tables; SEO now ships as the seo-rank-tracker app-store artifact)
  • Migrations: packages/db/src/migrations/0000_initial_sqlite_schema.sql
  • ORM: Drizzle (@mnemose/db package, drizzle-orm/d1 driver)

R2 Buckets

  • mnemose-assets-dev — Uploaded assets (images, files)
  • mnemose-reports-dev — Generated reports (JSON, PDF)

KV Namespaces

  • mnemose-config-dev (ID: 8e28cad8dbee460a811f1590ab0bacaa) — Runtime configuration
  • mnemose-sessions-dev (ID: 0c7e8098d02448fcb9a4813b53d4df37) — Session state

Queues

  • mnemose-commands-dev — Command bus (with DLQ mnemose-commands-dlq-dev)
  • mnemose-events-dev — Event bus (with DLQ mnemose-events-dlq-dev)

DNS + Routes

RecordTypeTargetProxied
mnemose.aiA192.0.2.1Yes
api.mnemose.aiCNAMEmnemose.aiYes
console.mnemose.aiCNAMEmnemose-console.pages.devYes

Workers Routes:

  • api.mnemose.ai/* → mnemose-gateway-dev
  • mnemose.ai/* → mnemose-gateway-dev

Cloudflare AI + Vectorize

  • AI binding — Workers AI for LLM inference
  • VECTORIZE_INDEX — Vectorize index mnemose-memory-index-dev for memory search (future)

Console

React 18 SPA on Cloudflare Pages.

  • URL: console.mnemose.ai (custom domain on Pages project mnemose-console)
  • Auth: Cloudflare Access JWT via CF_Authorization cookie. Adapter in apps/console/src/providers/cf-access-adapter.ts. See Authentication for the current dev-environment fallback.
  • GraphQL client: urql with fetchExchange + subscriptionExchange. Endpoint: https://api.mnemose.ai/graphql (production) / /graphql (dev, proxied by Vite).
  • Routing: TanStack Router
  • Styling: Tailwind CSS v4
  • Telemetry: OpenTelemetry (sends traceparent header — CORS-allowed)

UX ethic: in-camera creation

Anywhere the console lets a user select an entity from another domain (provider, model, project, harness, …), the UI must also offer a discrete in-context creation path — typically a small (~0.8em) right-aligned “Add” link in the field’s title row opening a create/edit modal dialog. The user must never be forced to abandon their current page to create a missing referenced entity. Reusable dialogs live in apps/console/src/components/inline-create/ (ProviderCreateDialog, ModelCreateDialog); prefer extracting shared ones there over re-implementing per view. Selection fields fed by dynamic domains must repopulate their options when the parent selection changes (e.g. model options follow the selected provider) and support type-to-search when option lists are large.


Cloud Adapters

Port interfaces in packages/cloud-adapters/src/ports/:

PortCF Implementation
EventBuscf/event-bus.ts (Cloudflare Queues)
BlobStoragecf/blob-storage.ts (R2)
Secretscf/secrets.ts (KV + Workers Secrets)
Schedulercf/scheduler.ts (Cron Triggers)
CloudCredentialscf/cloud-credentials.ts (WIF for customer clouds)
IdentityProvidercf/identity-provider.ts (CF Access JWT)
Workflowcf/workflow.ts (Durable Objects)
IaaScf/iaas.ts (Cloud API proxy)
Kmscf/kms.ts (Workers Secrets)

GCP and AWS implementations have been removed. The createCloudflarePlatform() function in packages/cloud-adapters/src/cf/platform.ts constructs a CloudPlatform from Worker Env bindings.


Deploy Profiles

Defined in infra/profiles.ts via resolveProfile():

ProfilePlanCostLimits
solo (default)Free$0/mo at rest100k req/day, 500MB D1, 10GB R2, 10ms CPU
prodWorkers Paid~$5/mo baseHigher limits, PITR, VPC, min instances

Configured via pulumi config set mnemose:profile solo|prod.


Bootstrap Runtime

Shared across CLI, gateway, and command handlers via @mnemose/bootstrap.

Workers-compatible dist (packages/bootstrap/dist/index.js) exports:

  • coreManifestForEnv(env) — deployment topology constants
  • assessCoreHealth(manifest, kernelRows) — pure health assessment
  • persistBootstrapState(db, input) — D1 bootstrap state upsert

GCP-specific bootstrap functions (organization/project provisioning) are not available on Workers.


Observability

  • Worker logs: console.log output visible in Cloudflare dashboard
  • OTEL traces: traceparent header propagated from console to Worker (CORS-allowed)
  • Cron monitoring: Scheduled handler logs visible in CF dashboard
  • Model-call telemetry: every real model call is persisted per-request in D1 (token_spend_metrics) with real provider-reported token usage (falling back to the character-heuristic estimate only when a provider omits usage), plus latency, status, provider, route id, and usage-source detail. Exactly one row is written per model call: caller-owned recording — AgentRuntimeDO records the agent-loop path (with session id and R2 payload key) and passes skipSpendRecording so the model router does not double-record; the router records for its other callers (REST /v1/chat/completions, evaluation model targets). The model column carries the actually-executed model (route/fallback resolved), not the requested name when they diverge. Full request/response payloads are captured to the R2 bucket mnemose-llm-calls-{env} under calls/<tenant>/<date>/<callId>.json; capture failure never fails the LLM call. The capture pipeline stores payloads unredacted today (redaction insertable later); retention class defaults to default (30 days) with per-tenant overrides via the retention_policies table.
  • Retention policies API: tenants manage retention through GraphQL — retentionPolicies(scope), upsertRetentionPolicy(scope, class, durationDays), deleteRetentionPolicy(scope, class), and modelCall(id) drill-down (row detail plus the R2 payload from LLM_CALLS, null-safe without the binding). Resolution order for an effective window is: exact class policy → tenant default (default class) policy → 30-day global default; duration_days NULL = retain forever. Enforcement is lazy: D1ObservabilityStore prunes token_spend_metrics rows with a per-row cutoff derived from their retention_class, and sweeps expired R2 payloads under calls/ by key date prefix vs the class cutoff (best-effort, never fails the persist/query path).
  • Future: CF Observability + Tail Workers for structured logging

Error Handling & Masking

Yoga maskedErrors in worker.ts passes through safe codes (UNAUTHENTICATED, FORBIDDEN, BAD_USER_INPUT, GRAPHQL_VALIDATION_FAILED) and masks everything else as "Unexpected error." (INTERNAL_SERVER_ERROR).

  • BAD_USER_INPUT is reserved for operator-actionable errors (e.g. unconfigured providers, missing API secrets, vendor API rejections).
  • Resolvers use the userError(message) helper (services/gateway/src/schema/errors.ts) to throw actionable errors.
  • Internal invariants and platform exceptions throw plain Error so they stay masked from clients.
  • Upstream vendor response bodies are never passed into error messages (status code only) to prevent leaking upstream secrets or internal topologies.

Models

Provider configuration and model catalog are decoupled.

  • Provider row (llm_provider_config): one per tenant + providerKind. Holds baseUrl, secretRef, optional oauth (client-credentials: tokenUrl / clientId / clientSecretRef / scopes), or cloudCredentialRef. model is optional leftover, not the catalog.
  • Model rows (llm_provider_model): many per tenant + kind. source is discovered or manual. Generation params live on the model entry (temperature, topP, topK, maxTokens, frequencyPenalty, presencePenalty, stopSequences, extra).
  • Auth modes: GraphQL authMode is derived — oauth when oauth is set, cloud when cloudCredentialRef is set, otherwise token.
  • Discovery: syncLlmProviderModels fetches vendor /models (OpenAI-compat Bearer, Anthropic x-api-key, Cloudflare account models). Azure / Vertex / Bedrock are manual-only. Sync deletes discovered rows not in the vendor payload, then upserts the set.
  • Write path: Direct synchronous writes via Drizzle on ctx.db in GraphQL resolvers (per ADR 0010), retiring the CQRS queue round-trip for tenant CRUD. Transactions are used for multi-table operations. Provider secrets are AES-GCM in the CONFIG KV namespace (enc-secret:<id>), keyed by Worker secret SECRETS_MASTER_KEY.
  • Routing: ModelRouter resolves candidates by consulting routes in strict order: exact alias match on enabled llm_routes entries (match_kind = 'alias'), resolving candidate provider/model targets based on the route strategy (pinned uses the first available candidate, cascade tries candidates in sequence, while auto is reserved for wave M-4 and throws BAD_USER_INPUT). When no route matches the requested model alias, ModelRouter falls back to the zero-config implicit cascade built from enabled provider models and default providers. Entry parameters are merged into the payload and client-credentials tokens are resolved for OAuth-configured providers.

No built-in fallback model: all inference routes through ModelRouter against tenant-configured providers. There is no edge/binding fallback and no implicit system default model: an unconfigured tenant lists zero models and cannot run inference — routing fails loud with No LLM providers configured for tenant <id>. The agent runtime resolves its default model the same way (resolveDefaultModel: default-kind provider models first, then any enabled model row; throws when none exist).

Agent Harness

An Agent Harness is a first-class aggregate: a named, tenant-scoped bundle of provider, model, generation params, autonomy mode, HITL tier, step budget, and tool bindings that Studio and the agent runtime treat as one subject.

  • D1 tables: agent_harnesses and agent_harness_tools (migration 0010).
  • Commands: CreateAgentHarness, UpdateAgentHarness, and DeleteAgentHarness on mnemose.commands; projected into D1 by queue-handler.ts (projectEventOntoDb). Create carries a caller-minted harnessId so the returned id matches the projected row. Temperature is stored ×100 (INTEGER) and converted at the GraphQL boundary (0..1).
  • Gateway: GraphQL module services/gateway/src/schema/harnesses.ts (agentHarness / agentHarnesses, create/update/delete).
  • Runtime resolution: AgentRuntimeDO /run accepts harnessId. Lookup is tenant-scoped (id AND tenant_id); unknown or foreign ids return "Harness not found" (400). When present, the harness model overrides options.model (precedence: harness > model param). /agent/* routes require the same authentication as all other gateway routes.
  • Studio: Agent workbench save/load persists and rehydrates harnesses through the GraphQL harness operations; runs forward the selected harnessId.
  • Vector store: attach point exists on the harness (vectorStoreRefs) but remains deferred pending Cloudflare Vectorize free-tier verification.
  • Known gap: evaluation scorecards persist harness-subject runs as target type "agent" (@mnemose/evaluations schema only models model/agent targets).

Marketplace Harness Artifacts (M-3-A)

Distinct from agent harnesses above, marketplace harness artifacts (kind = 'harness', ADR 0012) are importable JSON configuration presets (HarnessSpecSchema in @mnemose/marketplace) sharing one validation layer across three ingress paths: marketplace install, raw JSON import, and console create (UI lands in a later stripe). Persisted in the existing marketplace_artifacts table (spec in manifest_json; no new tables). GraphQL: direct-write module services/gateway/src/schema/harness.ts (createHarness / importHarness / listHarnesses / getHarness / deleteHarness). See packages/marketplace/docs/architecture.md.

Installable App Lifecycle + Vendored Mounting (ADR 0013)

App-kind artifacts execute end-to-end on the Workers runtime:

  • Lifecycle commands — InstallApp, UninstallApp, EnableApp, DisableApp, UpdateAppConfig publish to mnemose.commands from the gateway apps mutations; services/agent/src/command-subscriber.ts dispatches them to @mnemose/cmd-app-lifecycle (Workers-edge). Install fetches + validates the manifest from the catalog repo over HTTPS, replays the artifact’s dbSchemaModule SQL idempotently (single db.batch()), persists to marketplace_artifacts, and emits AppInstalled / AppConfigUpdated etc.
  • Gateway mounting — vendored app gateway modules (services/gateway/src/app-modules.ts) register their queries/mutations on the shared Pothos builder before toSchema(); resolvers run through a per-request SeoDb-style port built from the request’s D1 binding (ctx.seoDb), never importing cloud internals directly.
  • Console mounting — /apps/:appId/* (InstalledAppShell) lazy-loads the vendored artifact’s default view when installed; the artifact’s stand-in GraphQL/page-shell adapters are overridden at bundle time by the real console modules (Vite exact-file aliases). A dynamic “Applications” sidebar section lists installed vendored apps.
  • Health — assessAppHealth takes host-supplied facts (mounted module ids, vendored console ids, deployed jobs, built images); manifest-declared surfaces require confirmation, undeclared surfaces pass.
  • Catalog index — availableApps is generated at build time from the app-store submodule by scripts/generate-app-catalog.mjs into generated-app-catalog.ts (Workers cannot read the filesystem at boot). Each card surfaces the manifest’s source (repo/ref/path) as sourceRepo/sourceRef/sourcePath so the console install dialog can pre-populate source fields for registered apps.

Reference artifact: seo-rank-tracker (see docs/seo-rank-tracking.md). Adding a new app: one static import + one registry entry per host (services/gateway/src/app-modules.ts, apps/console/src/apps/registry.ts) plus @app-store/* alias entries. See ADR 0013.

Key Files

FilePurpose
services/gateway/src/worker.tsWorker entry (fetch/queue/scheduled)
services/gateway/src/context.tsGraphQL context factory (CF Access JWT + dev-token bypass auth)
services/gateway/src/env.tsEnv interface (D1, R2, KV, Queues, DOs, AI)
services/gateway/src/do/Durable Object classes
services/gateway/src/queue-handler.tsQueue consumer dispatcher
services/gateway/src/event-listener.tsEvent projection processor
packages/cloud-adapters/src/cf/CF adapter implementations
packages/cloud-adapters/src/ports/Port interfaces
packages/db/src/Drizzle SQLite schema + migrations
packages/domain/src/Commands, events, personas, marketplace
infra/index.tsPulumi IaC (D1, R2, KV, Queues, DNS, Routes)
infra/profiles.tsDeploy profile resolver
wrangler.tomlWorker bindings configuration
apps/console/React SPA source
.github/workflows/ci.ymlCI pipeline

Console wiring status (post Wave F-1)

Derived from the verdict matrix in functional-state-audit.md (baseline e6df91e, ten pillar views mocked). Wave F-0 persisted the store, evaluation, observability, and honeypot tables. Wave F-1 wired every mocked view to a live API. The anti-mock ESLint rule (mnemose/no-console-mock-theater) fails CI if SAMPLE_* / MOCK_* constants or multi-line object-array useState seeds return under apps/console/src/views (*.test.* / *.spec.* files are exempt).

Legend: REAL = view issues a live GraphQL/REST/DO call and renders only returned data. PARTIAL = live call, but the product surface is incomplete (read-only, missing fields, or a known proxy bug).

Legacy surface (unchanged since the audit)

AreaViewStateAPI surface
Dashboarddashboard.tsxREALGraphQL DASHBOARD_QUERY
Fleet / KernelsFleet/*REALGraphQL queries/mutations + subscriptions
Tenants / Users / IAMtenants, users, iam-bindings, iam-escalationsREALGraphQL, D1-backed
Cloud projects / Ownershipcloud-projects, ownershipREALGraphQL
Provisioning / Remediation / Reports / Resourcesprovisioning, remediation, reports, resourcesREALGraphQL
Encryption keysencryption-keys.tsxREALGraphQL
SEO suite—removedReturns as the seo-rank-tracker marketplace app (S-3)
Login / Settingslogin, settingsREALAuth flow
Sandboxes — list/table/policysandboxes/index, SandboxTable, NetworkPolicyInspectorREALGraphQL SANDBOXES_QUERY + DO mutations
Studio — session liststudio/indexREALGraphQL chatSessions / chatSession

Wave F-1 pillar views (flipped MOCK → REAL)

AreaViewStateAPI surface
Store — browsestore/StoreBrowseViewREALGraphQL availableApps, listTools, searchTools, installApp
Store — detailstore/StoreAppDetailViewREALGraphQL app, appHealth
Store — installedstore/InstalledAppsViewREALGraphQL installedApps, enableApp, disableApp, uninstallApp
Store — permissionsstore/ToolPermissionMatrixREALGraphQL getToolPermissions, setToolPermission, deleteToolPermission, evaluateToolPermission
Models — routermodels/ModelRouterManagerREALGraphQL llmProviderConfigs (+ models, authMode), llmProviderKindMetadata, setLlmProviderConfig, model add/update/remove/sync
Models — fallbackmodels/FallbackTierListPARTIALGraphQL llmProviderConfigs (read-only implicit cascade; see limitations)
Models — healthmodels/ProviderStatusGridREALGraphQL llmProviderStatus
Models — spendmodels/SpendTelemetryPanelREALREST GET /v1/observability/metrics/tokens
Evaluations — launcherevaluations/EvaluationRunLauncherREALGraphQL runEvaluation
Evaluations — runsevaluations/BenchmarkRunsTableREALGraphQL evaluationRuns (polls 5s while in flight)
Evaluations — leaderboardevaluations/ModelLeaderboardREALGraphQL evaluationRuns (aggregates completed scorecards)
Evaluations — scorecardevaluations/ScorecardAccuracyTableREALGraphQL evaluationRuns + evaluationRun(id)
Evaluations — diffevaluations/RunComparisonDiffREALGraphQL evaluationRuns + evaluationRun(id).caseResults
Evaluations — casesevaluations/LlmAsJudgeResultsPARTIALGraphQL evaluationRun(id).caseResults (no judge/transcript fields; see limitations)
Observability — tracesobservability/OtelTraceTimelineREALREST GET /v1/observability/traces
Observability — tokensobservability/TokenUsageChartsREALREST GET /v1/observability/metrics/tokens
Observability — honeypotobservability/CanaryHoneypotAlertsREALGraphQL honeypotAlerts
Sandboxes — terminalsandboxes/SandboxTerminalREALGraphQL executeCommand (EXEC_IN_SANDBOX_MUTATION)
Sandboxes — files/artifactssandboxes/FilesystemArtifactExplorerREALREST /v1/sandboxes/:id/files, /artifacts, artifact-content download
Studio — workbenchstudio/AgentWorkbench + studio/indexREALGraphQL createChatSession / appendChatMessage + DO POST /agent/:id/run
Studio — live stepsstudio/LiveStepExecutionREALDO GET /agent/:id/status + /transcript (polled)
Studio — transcriptstudio/TranscriptInspectorREALDO transcript, fallback GraphQL chatSession.messages
Studio — HITLstudio/HitlApprovalBannerPARTIALDO GET /status.pendingApproval + POST /approval/resume (singular; see limitations)

Honest limitations

  • FallbackTierList is read-only. llmProviderConfigs has no persistable tier field. The view sorts default-first then providerKind; reorder is not available until the schema grows a tier column.
  • Studio HITL is a single pending approval. AgentRuntimeDO exposes pendingApproval on GET /status and has no approval-list endpoint. The banner can approve/deny the current item only.
  • Eval case results lack judge/transcript fields. EvaluationCaseResult carries pass/score/latency/tokens/cost/model/rawResponse/failureReason. There is no judge rationale or per-case transcript to render.
  • /api/kernels/* proxy prefix bug is still open. Gateway strips /agent/:id and /sandbox/:id before forwarding to the DOs, but /api/kernels/* forwards the raw request. KernelBrokerDO expects /kernels. Vite’s /kernels proxy targets the local broker, so local Fleet may work while the deployed /api/kernels path does not.