PR: #403: feat(agents): mount Atlas Dock on /matters + wire live per-turn rail [Story-1659] by @ankitmaheshwari-com
View PR →
Story 1659 · Epic-1646 Agentic interaction · RFC-APB-002 §C/§D · RFC-APB-003 §A · ADR-110

The composer finally talks to the backend

Five Stories built Atlas a body that could not yet speak to anyone. Story-1649 gave the substrate primitives (APB pill, ACP panel, composer). Story-1655 mounted the one Dock at the workspace layout so the chat survives navigation. Story-1656 gave it a map (the surface registry) and a live read of the page (Current Page Context). Story-1657 wired the reasoning pill. Story-1658 gave it hands (the client-tool dispatcher). Every one of those ended at the same stub: send did not cross a wire — it appended the counsel's own line and stopped. This Story is the last child: it joins the composer to the backend authority resolver, and lights up the matters page with two triggers for one Dock.

🛰️ Satellite — why "send" is the boundary that fakes itself

The stakes: an agent UI that renders beautifully against a fixture is the most convincing lie in this codebase. A mock always carries the happy reply, so the one screen nobody ever sees is the real first run — the empty thread, the new firm, the turn that actually has to leave the browser and come back. Wire the transport wrong and every demo still looks perfect.

The naive fix — "just fetch() the brain and print what comes back" — detonates three ways. (1) Trust. A browser that POSTs straight to the gateway leaks the service key and lets a forged client claim any permission or any reachable surface. (2) Authority. The page's idea of "what I can do" is orientation, not law; if the backend doesn't re-resolve it, the agent's reach is whatever the client asserts. (3) Honesty. The per-turn resolve is an authority handshake, not the brain's spoken answer — conflating them means a green handshake masquerades as a working agent.

The real fix keeps the browser talking only to its own origin, lets the backend overwrite authority, and is scrupulous about which boundary it actually proved.

✈️ Airplane — the shape of one turn

Think of the composer like a customs desk. The counsel writes a declaration (their message + the live page context + the posture they chose). The desk does not wave it straight to the country (the gateway); it hands it to a local clerk at the same building (our BFF proxy), who carries it across and brings back a stamped, re-verified manifest — the customs officer ignores what you wrote about yourself and stamps your real clearances.

composer send
  → useAgentLiveRail: assemble AgentSessionContext
       providers: surface · cpc digest · availableSurfaces · permissions · posture(thinking_level)
  → generated client POST /app/agents/session-context/resolve
       toBrowserRoute → /api/internal/agent/session-turn      (browser only ever hits same-origin)
  → BFF proxy route: parse request (HR-21) → agentGateway.resolveSessionTurn
  → gateway: x-api-key + Clerk bearer → functions #1647
  → ResolvedTurnContext: SERVER-resolved available_surfaces + granted_permissions + resolved_intents
  → assertContract(resolvedTurnContextSchema)  (HR-21 upstream parse)
  → thread: honest acknowledgement of the resolved turn

Two triggers, one Dock: the floating APB pill (above the doctrine strip) and the header "Ask Atlas" button both flip ONE shared flag in useAgentDockStore. The Dock is mounted once at the layout and is out of the matters page's React tree, so the open state cannot be local — it must be shared client state both subtrees read.

🔬 Microscopic — every piece, titled by its WHY

The shared open-state — why a store, not useState

src/stores/agent-dock-store.ts. The Dock lives in the layout; the "Ask Atlas" button lives in the matters header. Two subtrees, one truth. A thin, non-persisted zustand slice (HR-23): open/closed is ephemeral per-tab chrome — nothing to persist, version, or migrate. Mirrors cpc-store.ts's deliberately-unpersisted shape.

The live rail hook — why context assembly lives off the component

src/lib/agents/use-agent-live-rail.ts. It reads the live CPC slices, builds the full per-turn AgentSessionContext via the existing buildAgentSessionContext (surface + cpc + availableSurfaces + permissions) and folds in the composer's thinking_level as the posture provider, then POSTs through the generated mutation. The manifest is sent unfiltered on purpose: the backend permission-filters available_surfaces server-side, so a forged client list cannot widen reach.

The BFF proxy + gateway — why the browser never sees the gateway

src/app/api/internal/agent/session-turn/route.ts + src/lib/agent/gateway.ts + a one-line toBrowserRoute mapping. The browser's generated client targets the absolute /app/agents/... path; the mapping rewrites it onto /api/internal/* so the service key and Clerk bearer stay server-side. Two HR-21 / ADR-061 parses: the browser JSON is validated against agentSessionTurnRequestSchema before forwarding, and the upstream ResolvedTurnContext is run through assertContract before the app trusts it.

The Dock wiring — why an honest acknowledgement, not faked prose

agent-dock-mount.tsx now: shared open-state, a permissions prop from /app/me, live status on the APB/ACP (thinking while the resolve is in flight), and — on a resolved turn — an honest line in the thread. It does not fabricate a brain answer, because this REST rail is the authority handshake, not the spoken reply.

The boundary I wired vs the boundaries I did not (ADR-110). This Story wires the per-turn authority resolve rail (POST /app/agents/session-context/resolve, #1647). The brain's streamed natural-language reply — rich markdown, run-id lifecycle — travels over the SEPARATE ADR-110 broker WebSocket (NEXT_PUBLIC_AGENTS_WS_URL, atlas-rail.ts), a distinct runtime boundary NOT touched here. A green resolve does not prove the broker stream.

System-boundary proof

BoundaryStatusEvidence
Live seam deployed (functions #1647) live POST https://stg.gateway.pramaan.io/app/agents/session-context/resolve → HTTP 401 {"message":"Unauthorized"} (API Gateway JWT authorizer). 401, not 404 ⇒ route exists & is reachable. Gateway /health → 200.
App proxy registered + mapped pass pnpm build registers ƒ /api/internal/agent/session-turn; toBrowserRoute maps the generated client onto it.
Contract round-trip incl. empty/first-run (HR-21) pass session-turn-contract.test.ts: full + empty ResolvedTurnContext ({version:1}, the new-firm zero-grant turn) parse unchanged.
Shared open-state (APB pill + Ask-Atlas) pass agent-dock-store.test.ts: two triggers, one flag.
lint · tsc · vitest green eslint 0 · tsc --noEmit 0 · 117 files / 746 tests pass.
Authed 200 round-trip through the BFF manual seam Requires an interactive staging Production-Clerk session (the JWT a headless worktree cannot mint). Seam is wired; deployed endpoint confirmed above. Final check: sign in on localapp.pramaan.io, open /matters, Ask Atlas → send → observe the /api/internal/agent/session-turn POST resolve.
Brain streamed reply (broker WS) out of scope Separate ADR-110 boundary (atlas-rail.ts). Named, not wired.

i18n & reuse

Files changed

15 file(s), +548 / -38 lines

Path Role + -
messages/en.jsonConfig+4-1
messages/hi.jsonConfig+4-1
src/app/[locale]/(workspace)/layout.tsxApp route+1-0
src/app/api/internal/agent/session-turn/route.tsApp route+69-0
src/components/agents/agent-dock-mount.tsxComponent+57-27
src/components/agents/ask-atlas-button.stories.tsxComponent+24-0
src/components/agents/ask-atlas-button.tsxComponent+52-0
src/components/agents/index.tsComponent+1-0
src/components/matters/live-matters-list-page.tsxComponent+12-9
src/lib/agent/__tests__/session-turn-contract.test.tsLibrary+55-0
src/lib/agent/gateway.tsLibrary+59-0
src/lib/agents/use-agent-live-rail.tsLibrary+119-0
src/lib/kubb-client.tsLibrary+8-0
src/stores/agent-dock-store.test.tsTest+38-0
src/stores/agent-dock-store.tsSource+45-0