ADR-0021: Guest UI lives at /g/* — segregated route tree, distinct app shell

  • Status: Accepted
  • Date: 2026-05-25
  • Deciders: @karasu
  • Supersedes:
  • Superseded by:

Context

Given guests (ADR-0017), grants (ADR-0018), workflows (ADR-0019), and issues (ADR-0020), the remaining question is how the UI exposes all this to a guest.

The instinct is to reuse the operator routes (/projects/:id/...) with role-toggled rendering: hide the audit tab, hide the settings page, gate the DSL viewer. This is dangerous. A single forgotten conditional anywhere in the operator UI — or a future component that someone adds without thinking about role — leaks operator surface to guests. The blast radius of a mistake is "all guest accounts see operator data, immediately, with no easy fix."

The safer pattern is route segregation: distinct trees, distinct shells, distinct sidebar/nav, distinct everything. The auth gate at the app root decides which tree the principal lives in, and there is no path that crosses.

Other constraints:

  • Mobile-first. Guests are highly likely to be on phones. The operator UI is already mobile-first per ADR-0002; the guest UI inherits that and goes further (no desktop-only assumptions; no terminal anywhere).
  • No sidebar exposure. The operator's sidebar shows projects, sessions, audit, config. A guest never sees any of that. Their navigation is a flat "your projects" list and back-out arrows.
  • One identity per cookie scope. A browser can hold both kaged_session (operator) and kaged_guest_session (guest) cookies simultaneously without confusion, but a single page renders for exactly one principal at a time. There is no "switch role" affordance.
  • Branded consistently. Guests see kaged. The brand (the 影 glyph, the typography, the dark palette per the brand guide v0.2) is shared. The information architecture differs; the visual identity is unified.
  • Hard rejection of operator routes for guests, and vice versa. A guest typing /projects/whatever into their browser gets a 404 or a redirect to /g. An operator typing /g/... gets a 404 or redirect to the dashboard. No accidental shared paths.

Decision

The kaged UI exposes two parallel route trees: /projects/... and adjacent operator paths (existing) for operators, and a new /g/... tree for guests. Each tree has its own app shell, its own auth gate, its own navigation chrome. The root auth gate inspects identity by user_id prefix (per ADR-0017) and routes the principal to their tree; the other tree is inaccessible. No route exists that renders for both principal classes.

Route map (guest tree)

/g/login                       → Login (handle + password)
/g/setup?token=<token>          → First-time password creation from invite link
/g/account                      → Password change, active sessions
/g/account/sessions              → Active session list with revoke
/g                              → Project list (one tile per granted project)
/g/:project_id                  → Project landing (workflows + issues tabs)
/g/:project_id/workflows         → Available workflows list
/g/:project_id/workflows/:name   → Workflow invocation form
/g/:project_id/runs              → The guest's own past workflow runs on this project
/g/:project_id/runs/:run_id      → Run detail (streamed output, final result)
/g/:project_id/issues            → Issue list (gated by issues.view_own / view_all)
/g/:project_id/issues/new        → File a new issue (gated by issues.file)
/g/:project_id/issues/:number    → Issue detail with updates
/g/logout                       → Logs out, clears kaged_guest_session, redirects to /g/login

There is no /g/sessions/... analogue to the operator's session view. Guests do not have free-form chat sessions; they have workflow runs (a session-shaped record with kind: "workflow" per ADR-0019). The terminology and the URL match.

Auth gate logic (root)

The app root mounts a gate that resolves identity before any other route renders:

1. If kaged_session cookie present and valid →
   identity = operator (user_id from sidecar header or loopback nonce per ADR-0007)
   → render operator shell (existing AppShell)
   → if path starts with /g/  → redirect to /
2. Else if kaged_guest_session cookie present and valid →
   identity = guest:<ulid>
   → render guest shell (new GuestShell)
   → if path is /, /projects/*, /config/*, /status, /audit  → redirect to /g
3. Else if path is /g/login or /g/setup → render unauthenticated guest auth pages
4. Else if path is /launch → render existing operator launch flow per ADR-0007
5. Else → redirect to / (operator) or /g/login (guest), based on a referrer hint cookie if available, else /

The "if path starts with /g/" check in case 1 prevents an operator from accidentally rendering the guest shell — the operator never has a reason to be there, and if they want to see what guests see they create a guest account in their own deployment (per ADR-0017 "no role-switching" decision).

GuestShell

A separate top-level layout component, distinct from the operator's AppShell. Differences:

Element Operator AppShell Guest GuestShell
Sidebar Yes — projects, sessions, config, status, audit None. Guest IA is too narrow to warrant one.
Top bar (desktop) Operator name, daemon status indicator, warnings Guest handle, "your projects" link, sign out
Top bar (mobile) Project context back-arrow Same — back to project list or project root
Project switcher Sidebar list + dashboard /g is the project list page; navigation is via tile-tap
Audit / config / status surfaces Available None. Guests have no operational surface.
Terminal panel Optional (right pane in sessions) None ever. No PTY in any guest view.
Warning banners (--insecure, --no-sandbox) Always rendered if set Not rendered. Those are operator concerns; guests don't need to know the operator's deployment mode and shouldn't be alarmed.
Brand glyph (影) Yes, in logo header Yes, in logo header — same visual identity

The shell is implemented in packages/ui/src/shells/guest-shell.tsx; the routing tree mounts under it via TanStack Router per the existing convention.

Project landing (/g/:project_id)

The default landing for an active guest in a project. Adapts to the guest's permission_set:

┌─────────────────────────────────────┐
│ 影 kaged                  cara@   ⌄ │  ← top bar: brand, handle, account menu
├─────────────────────────────────────┤
│  ← Your projects                    │  ← back link
│                                     │
│  Photographer Site                  │  ← project label
│  Add testimonials, draft posts      │  ← short description (operator-set, optional)
│                                     │
│  ┌─────────────┐ ┌─────────────┐    │
│  │ Workflows   │ │ Issues      │    │  ← tabs
│  └─────────────┘ └─────────────┘    │
│                                     │
│  ▶ Add a testimonial                │  ← workflow tiles, one per exposed
│  ▶ Draft a blog post                │
│                                     │
│  ─ Or ─                             │
│                                     │
│  [+ File an issue]                  │  ← if issues.file granted
│                                     │
└─────────────────────────────────────┘

If the guest's grant has no workflows exposed and no issues.file, the landing reads "Your operator hasn't given you anything to do here yet" — honest empty state.

Workflow invocation (/g/:project_id/workflows/:name)

A form generated from the workflow's inputs schema (per ADR-0019). Each field renders per its type:

  • string → text input or textarea (based on max_length)
  • integer / number → number input
  • boolean → checkbox
  • file → file picker with drag-and-drop and the accept MIME constraint
  • url → text input with inputmode="url"
  • enum → select

On submit, the form posts to /api/v1/projects/:id/workflows/:name/invoke. The response is a run ID; the UI navigates to /g/:project_id/runs/:run_id and streams the output via the WebSocket per ADR-0016.

If confirm_required is set on the workflow, a confirmation step is interposed between form submit and invocation.

Run view (/g/:project_id/runs/:run_id)

A streaming display of the workflow's agent output. Guest sees:

  • Workflow name and inputs (collapsed by default).
  • Agent's user-facing messages, streamed.
  • A small set of structured progress indicators when the agent calls tools (e.g., "Uploading photo...", "Building site...", "Deploying...") — not the raw tool-call JSON.
  • Final result on completion: success state + any operator-defined result-summary content.

What the guest does not see, even though they're observing a real agent run:

  • The agent's internal reasoning.
  • Raw tool inputs / outputs.
  • Subagent activity.
  • System prompts (workflow or project).
  • The project DSL.
  • Costs, tokens, model name.
  • Audit log entries.
  • Other guests' or the operator's runs.

The guest's view is a progress display, not a debug surface. The operator's parallel view of the same run (at /projects/:id/sessions/:run_id) is the full surface, unchanged.

Issue UX

Per ADR-0020:

  • List (/g/:project_id/issues): filtered by the guest's view permissions. Default sort: most recently updated.
  • Detail (/g/:project_id/issues/:number): title, body (possibly operator-edited; original visible via a toggle if changed), status, the public update stream (excluding operator_only updates). If the guest has issues.comment_own and the issue is theirs, a comment box is rendered at the bottom.
  • File (/g/:project_id/issues/new): minimal form — title, body (markdown), optional file attachments. On submit, the issue lands in open status and the guest is navigated to the detail page.

Account (/g/account)

A small, focused page:

  • Handle (read-only; only the operator can change a handle).
  • Display name (read-only or editable, per the ADR-0017 open question).
  • Password change form (old + new).
  • Active sessions list (each row: device hint from User-Agent, IP suffix, last active time, "revoke" button). Revoking the current session logs the guest out.

Authentication flows

First-time setup (/g/setup?token=<token>):

  1. Token presented, daemon validates (existence, not expired, not consumed).
  2. If valid, form renders: "Set a password for handle <handle>." Min length 12 (configurable per deployment).
  3. On submit, daemon hashes (argon2id), stores, marks guest active, consumes token, sets kaged_guest_session cookie, redirects to /g.
  4. If token invalid, page renders the failure with instructions: "Ask your operator to send a fresh invite link."

Login (/g/login):

  1. Form: handle + password.
  2. On submit, daemon checks rate limits (per-account + per-IP), verifies password, sets kaged_guest_session cookie, redirects to /g (or to a redirect_to query param if it was set by the gate and points within /g/...).
  3. Failure shows generic "Invalid credentials" — does not distinguish "no such handle" from "wrong password." Rate-limit and lockout messages are explicit.

Logout (/g/logout):

  1. Invalidates the kaged_guest_session cookie's row in guest_sessions.
  2. Clears the cookie.
  3. Redirects to /g/login.

Cross-origin and proxy behaviour

Same as the operator UI per ADR-0007:

  • kaged_guest_session cookie has SameSite=Lax, HttpOnly, Secure when over HTTPS.
  • CSRF token on state-changing endpoints. Reused mechanism from the operator UI.
  • KAGED_UI_API_BASE env var applies to the guest UI's /api/v1 calls identically.

Mobile reference devices

Same reference devices as the operator UI per specs/ui/README.md: 375 × 667 (iPhone SE), 411 × 847 (Pixel-class Android), then desktop sizes. Hard no-horizontal-scroll rule applies. The guest UI is more mobile-biased than the operator UI — desktop is supported but not optimised; the guest's "best device" is a phone.

Visual identity

The guest UI uses the same brand tokens, fonts, and colour palette as the operator UI per the brand guide. The 影 glyph is in the logo position. Bracket-lock badges ([OPEN], [RESOLVED], [REJECTED] for issues; [RUNNING], [DONE], [FAILED] for runs) follow brand guide v0.2 conventions (Plex Mono, uppercase, letter-spaced 0.18em).

Differences from the operator UI: no [CAGED]/[UNCAGED] indicators (guests do not see cages); no insecure-mode banners (guests do not need that operational context).

Consequences

What this commits us to

  • A second top-level shell (GuestShell) and a parallel route tree under /g/....
  • A root auth gate that resolves identity once and dispatches by prefix. No path renders for both principal classes.
  • Adaptations of existing components for guest contexts where the data shape is identical but the chrome differs (e.g., a run-view component shared between operator session view and guest run view, but the wrapper UI differs).
  • Mobile-first design treatment for every guest page, with snapshot tests at the reference resolutions.
  • A small but complete set of unauthenticated routes (/g/login, /g/setup) and the auth flows that drive them.
  • No leak paths between the trees — verified via Playwright tests that exercise mis-routed requests (operator → /g/..., guest → /projects/..., both result in redirects and never render restricted content).
  • A modest UI maintenance surface: the guest shell, the guest tree, and the guest-specific components.

What this forecloses

  • No combined operator-and-guest view. An operator who wants to see what a specific guest sees must log in as that guest (using a separate browser, a private window, or by creating themselves a guest account in their own deployment). There is no "view as guest" affordance.
  • No nesting of operator surfaces inside guest pages. A guest's project view does not embed any operator widgets (e.g., no "audit log preview"). The boundary is sharp.
  • No terminal in any guest view. Ever. No PTY, no xterm.js. A workflow may run shell commands as part of its agent run, but the guest sees a structured progress display, not the raw terminal.
  • No insecure-mode warnings shown to guests. Operators chose insecure; guests don't need to know.
  • No DSL viewer for guests. Workflows are invoked; their source is operator-only.
  • No cost/token information to guests. Operator-only.

What becomes easier

  • Safety arguments: a forgotten conditional cannot leak operator surface to a guest because the guest tree has no operator components.
  • Mobile design: the guest tree can lean further into mobile-first without compromising the operator desktop experience.
  • Brand consistency: the visual identity is shared; the IA differs. Both feel like kaged.
  • Onboarding a guest: one URL, one form, no chrome to learn.

What becomes harder

  • Two shells, two route trees, two sets of nav. More UI surface to maintain, though the components for displaying issues/runs/workflows can be shared between trees.
  • Reasoning about shared components: a list-of-issues component used in both trees must not assume operator-shaped data. Type the data shape narrowly at the boundary.
  • Visual regression coverage: snapshot tests now cover both trees. Roughly 2× the screen inventory.

Alternatives considered

Alternative A — Role-toggled rendering inside /projects/:id/...

Why tempting: Reuse the existing project pages, just hide elements based on the principal's role. Single source of truth for project IA.

Why rejected: A single forgotten conditional anywhere — a new dev mode, a future widget, a UI library upgrade that adds a default footer — leaks operator surface. The failure mode of "guest sees the audit log because nobody added a role check to that section" is too easy and too damaging. Route segregation makes the failure structurally impossible: there is no rendering path that serves operator data to a guest, because there is no shared route.

Alternative B — Separate subdomain (guests.kaged.example)

Why tempting: Browser-level separation. Different origin, different cookie scope, very clean.

Why rejected: Requires the operator to provision a second hostname and a second TLS certificate (or a wildcard). The tunnel + sidecar config needs updating. Cross-origin auth flows are real friction in browsers. The route-prefix approach gets 95% of the isolation benefit with 0% of the deployment friction. If a deployment ever wants stronger isolation, subdomains can be added later — the route prefix is forward-compatible.

Alternative C — Native mobile apps for guests

Why tempting: Push notifications, better mobile UX.

Why rejected: Compounds the manifesto problem — the daemon would need to talk to an external mobile-push service (telemetry-shaped per ADR-0007). A mobile-first web UI is sufficient. PWA-style "add to home screen" is plausible v1.x per specs/ui/README.md open question.

Alternative D — Two completely separate Vite builds

Why tempting: Hard separation at the build artifact level. Two binaries, two bundles.

Why rejected: The operator UI and guest UI share substantial components (issue display, run streaming, brand tokens). Two builds means two dependency trees, two CI pipelines, twice the bundle-size budget. Route segregation inside one build, with a top-level shell switch, is the right cost level.

Alternative E — kaged_session cookie carries the role; one shell adapts

Why tempting: Simpler cookie story. Auth gate just reads role from the existing cookie.

Why rejected: Conflates operator and guest sessions. A browser holding only one cookie can be only one principal — which is sometimes what an operator wants (test their guest UI in a private window), but it would force them to log out of the operator account first. Two cookies, two sessions, two scopes is honest and matches how browsers naturally treat them.

Open questions

  1. Brand-glyph treatment in the guest header. Same prominence as the operator UI, or scaled down? Lean same — the brand is unified.
  2. Project description for guests. ADR-0019 has a description on each workflow. Should a project have a guest-facing description set by the operator? Probably yes; an optional guest_description field in the project's local config. v1.x.
  3. Empty-state copy when a grant exposes nothing. "Your operator hasn't given you anything to do here yet" — is that the right tone? Honest, but a little blunt. Worth a copy review with the brand voice.
  4. Account avatar. Should guest accounts have an avatar (uploaded image, or derived letter)? Lean letter-avatar for v1 (same ProjectAvatar pattern reused for guests), upload deferred.
  5. Push to home screen. PWA manifest for the guest UI — useful for repeat guests. Plausible v1.x; not v1.
  6. Notification of issue updates. No SMTP/SMS per ADR-0017. An in-app indicator (badge on the project tile when there's an unread update) is the v1 answer. Confirm in spec.
  7. Browser back-button between operator and guest trees. If an operator logs out and a guest logs in on the same browser, history may contain operator URLs. The auth gate's redirect logic catches this, but the back-button UX is slightly weird. Document and accept.
  8. Account deletion by the guest. Can a guest delete their own account? Lean no — operator-controlled lifecycle. Guest can change password and revoke sessions; the account itself is the operator's record.

References