ADR-0020: Canvas hosted mode and spec selection¶
- Status: Accepted
- Date: 2026-09-15
- Deciders: Jian (product owner)
Context¶
Through V1–V4 the canvas (apps/canvas) only ever talked to the local server
kampong dev starts (packages/cli): one implicit spec at /api/spec, no
authentication, a file-watcher SSE stream, and single-process runs (ADR-0005,
ADR-0007). V5 built the entire hosted backend (packages/server, PRs #40–#45):
Better Auth sessions at /api/auth/*, workspace-scoped multi-spec CRUD at
/api/specs/:id under Postgres RLS (ADR-0019), masked BYOK key management at
/api/byok (ADR-0016), and durable server-side runs. But the canvas still spoke
only the local single-spec API and had no auth, so the hosted flow was not
usable in a browser. KAN-1228 (SLICES.md V5, build-plan step 8) closes that gap.
Two things had to be decided to do it without forking the app.
Decision¶
1. One bundle, two modes, chosen behind the createApiClient seam¶
The same built canvas is served from one origin by both servers (ADR-0005's
reusable web app, carried to hosted by ADR-0013). Rather than build or ship two
apps, the bundle probes which server is answering at startup
(detectServerMode, apps/canvas/src/api.ts): it requests
GET /api/auth/get-session, which the hosted server answers 2xx (body null
when signed out) and the local server — having no such route — 404s. Any network
failure falls back to local, the mode that needs no hosted infrastructure.
main.tsx then mounts the local single-spec App or the auth-gated HostedApp.
The editor itself (App, RunPanel, the forms) is unchanged between modes.
Both a local client and each hosted per-spec client implement one ApiClient
interface (api.ts); hosted mode passes App a client bound to the chosen spec
(HostedClient.specClient(id)), which addresses /api/specs/:id, starts runs at
/api/specs/:id/runs, and makes subscribeToEvents a no-op (hosted specs live
in Postgres, not on disk, so there is no file watcher). This is the payoff of the
createApiClient(baseUrl) seam the earlier slices deliberately left in place:
the mode difference lives entirely in which ApiClient is constructed, never in
the editor UI.
2. Hosted mode lands on an explicit spec-list view, not an implicit "current" spec¶
The local canvas edits one implicit spec because kampong dev is a
one-spec-per-process tool (ADR-0011). A hosted workspace holds many specs
(/api/specs is a collection, ADR-0019), so hosted mode needs a "which spec?"
step the local canvas never had. The decision is an explicit spec-list landing
view (SpecList.tsx): after auth and workspace selection, the user always
lands on a list of the workspace's specs (GET /api/specs) plus a "new agent"
action (POST /api/specs from a minimal starter template), and picking one
routes the editor at that spec's id.
The rejected alternative was auto-opening some server-designated "current"/most- recent spec and hiding the list behind a switcher. That was rejected because a new or empty workspace has no current spec (so the empty state would be a dead end, which is exactly the "didn't know what to do" problem the product is trying to fix), and because "which spec am I editing?" being implicit is precisely the ambiguity multi-spec introduces — making it explicit is the honest design.
3. Auth progression and the 401 gate¶
HostedApp is a small explicit state machine (no router dependency for four
views): loading → auth → workspace → home{ specs | editor | byok }. It reads
the session once (getSession) and routes on it — no session → login/sign-up
(AuthScreen, email+password and an optional GitHub button); a session with no
activeOrganizationId → workspace create/select (WorkspaceScreen, which calls
Better Auth's organization/create + organization/set-active, the same path
KAN-1226/ADR-0019 rely on); otherwise the workspace home.
Because RLS returns 401 for an expired or revoked session on any route, the
hosted client is wrapped once (guardUnauthorized) so a 401 from anywhere —
control-plane call or the editor's own loadSpec/applyPatch — drops the shell
back to the login screen rather than surfacing a dead-end error. getSession and
the sign-in/out methods are passed through unwrapped (getSession returns null,
never throws, when signed out; the auth methods are how a session is obtained).
4. BYOK key management is write-only in the UI (ADR-0016 made concrete)¶
The BYOK screen (ByokScreen.tsx) is the UI half deferred from KAN-1229. It
adds/replaces (PUT /api/byok/:provider), lists (GET /api/byok), and deletes
keys, but a key value is write-only: once submitted it is shown only as its
masked last four characters (····ab12), because the server never returns a
decrypted or encrypted key (ADR-0016). A run finds the key by matching the
provider named in the spec's model block to a stored key
(resolveWorkspaceModelClient), which is why the "new agent" starter template
includes a model block with a placeholder api_key (present only to satisfy
validation — the stored BYOK key is substituted at run time).
Consequences¶
- The hosted demo is clickable end to end in a browser: sign up → create a
workspace → add a BYOK key → create/edit a spec on the canvas → run it →
approve a guardrail pause → see the trace, all against
packages/server. kampong devis untouched: the local single-spec flow, its file-watcher auto-reload (ADR-0008), and every existing App/api test still pass unchanged.- Full end-to-end verification in a real browser (against a real Postgres) is a separate concern — a Playwright browser layer is its own follow-up, tracked apart from this UI slice.
- Mode detection is a runtime probe, not a build flag, specifically so one artifact serves both servers. If a future deployment ever serves the canvas from an origin without the API, the probe fails closed to local; that is acceptable because no such deployment exists (both servers serve the bundle and the API from one origin, ADR-0005/ADR-0013).