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.
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
| Boundary | Status | Evidence |
|---|---|---|
| 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
Agents.askAtlas,Agents.turnResolved,Agents.turnErroradded to bothenandhi.- Header reuses
SettingsPageHeader+PrimaryButton; the newAskAtlasButtongoes through theButtonprimitive (outline) — no hand-rolled button, no color literals. - No
pnpm codegenrun; all contracts imported fromsrc/gen. One repo.
Files changed
15 file(s), +548 / -38 lines
| Path | Role | + | - |
|---|---|---|---|
| messages/en.json | Config | +4 | -1 |
| messages/hi.json | Config | +4 | -1 |
| src/app/[locale]/(workspace)/layout.tsx | App route | +1 | -0 |
| src/app/api/internal/agent/session-turn/route.ts | App route | +69 | -0 |
| src/components/agents/agent-dock-mount.tsx | Component | +57 | -27 |
| src/components/agents/ask-atlas-button.stories.tsx | Component | +24 | -0 |
| src/components/agents/ask-atlas-button.tsx | Component | +52 | -0 |
| src/components/agents/index.ts | Component | +1 | -0 |
| src/components/matters/live-matters-list-page.tsx | Component | +12 | -9 |
| src/lib/agent/__tests__/session-turn-contract.test.ts | Library | +55 | -0 |
| src/lib/agent/gateway.ts | Library | +59 | -0 |
| src/lib/agents/use-agent-live-rail.ts | Library | +119 | -0 |
| src/lib/kubb-client.ts | Library | +8 | -0 |
| src/stores/agent-dock-store.test.ts | Test | +38 | -0 |
| src/stores/agent-dock-store.ts | Source | +45 | -0 |