How every page tells Atlas what it is โ with no hand-kept list
Story-1649 built the chat shell. But a shell that doesn't know which page it's sitting on is a polite stranger. This Story gives the agent two things it cannot work without: a complete map of every surface the app has (so "show me my clients" can reach the clients page) and a live read of the page the user is on right now (so "filter these to active" acts on what's on screen). The map is generated from a one-line export each page writes about itself; the live read is a Zustand store that holds executor functions and is therefore never persisted.
๐ฐ๏ธ Satellite โ why a hand-maintained route list is a slow leak
The stakes: the product bet is many agents across many surfaces.
For Atlas to navigate ("take me to firm members") and for the right scoped
agent to mount ("this is a matter page โ the Matter agent"), something
must know every page exists and what each is for. The obvious move โ keep a
routes.ts array by hand โ is a leak with a slow drip: a
developer adds /settings/firm/billing, forgets the array, and
Atlas is now confidently blind to a page that exists. Nothing
fails. The map silently lies, and the agent's flagship power (hop to any
page) quietly rots one route at a time.
The naive fix โ "we'll grep the router at build time" โ detonates
on the second requirement: a route string tells Atlas a page
exists, but not what it is for. /settings/firm/team
is not self-evidently "where you invite people". So the map needs a
natural-language description per page, written for the model
โ and the only place that can't drift from the page is the page
itself. The real design makes the page the source of truth
about itself: each page.tsx exports a co-located
agentSurface descriptor, a codegen statically reads
those exports into a complete manifest, and CI fails the build if a page
forgets. Add a page โ it registers itself โ CI enforces it. The list can no
longer drift, because there is no list โ only the pages.
โ๏ธ Airplane โ two layers, never conflated
One analogy: the registry is the building directory in the lobby โ complete, static, every floor and what's on it. The Current Page Context is the "you are here" dot โ one location, live, useless once you move. Mixing them is the classic mistake; PRAMAAN keeps them apart by their nature: static + describes-everything โ a generated module; live + current-only + holds functions โ Zustand.
Layer 1 โ the static manifest (build-time, complete)
Every workspace page.tsx exports an agentSurface:
a durable key, which agent mounts, an i18n
presenceKey for UI copy, a model-facing
description, capabilities, and a
kind (route / modal /
drawer). A codegen walks the App Router and emits
route-registry.ts. WHY by AST static extraction and
never import: a page module pulls Clerk, env, data
fetchers, and server-only code; importing it to read one constant would
run all of that. The scanner reads the syntax tree โ serializable
data only.
Layer 2 โ the Current Page Context (runtime, the active page only)
A Zustand store holds the active surface, the live view (filters / search /
selection), a digest of what's visible, and the
client-side tool handles Atlas can run on this page
(set_filter, focus_matter). WHY never
persisted: it holds functions, and HR-23 forbids writing
functions or derived state to localStorage. It is ephemeral,
per-tab, rebuilt on every mount โ that is the whole point of a "live" layer.
The seam โ a per-turn provider bag
Each turn the frontend serializes manifest + CPC digest + tool names +
permissions into AgentSessionContext. WHY an open,
versioned bag ({ version, providers }) and not a closed
struct: that closed struct is the single rewrite-class failure the
RFC flagged โ tomorrow's deadlines provider would become a
cross-repo contract rewrite. As a bag, it is a new key.
๐ฌ Microscope โ the parts and the WHY on each
| File | Role | WHY it exists |
|---|---|---|
src/lib/surface-registry/types.ts | The AgentSurface contract both layers share | Pure types, no runtime deps โ an RSC page can import type it for free and the codegen never executes it |
scripts/route-registry/generate.ts | AST codegen + --check freshness gate | Reads each page's agentSurface from the syntax tree; rejects missing exports, duplicate keys, bad enums, and null without a reason |
src/lib/surface-registry/route-registry.ts | generated the complete manifest | route/params are derived from the file path, so they can never drift from the router |
src/lib/surface-registry/navigation.ts | buildSurfacePath / resolveSurfaceForPath | Navigation by durable surface_key + typed params โ never a string-built raw URL |
src/lib/surface-registry/current-context.ts | The CPC digest builder | Ids + counts + labels only, capped โ orientation, never full rows or PII; count stays true even when ids are capped |
src/stores/cpc-store.ts | The live Zustand store | Holds executor functions โ deliberately no persist middleware (HR-23 r2); an ownerId makes a late unmount a stale-safe no-op |
src/lib/surface-registry/session-context.ts | The per-turn provider bag | Strips tool executors to {name, description, schema}; the run function never crosses the wire |
src/lib/surface-registry/use-surface.ts | SurfacePublisher + usePublish* hooks | Mounted ONCE in the persistent workspace layout, the publisher keeps the active surface in sync with the route across navigation |
The page is the source of truth โ one export
// src/app/[locale]/(workspace)/matters/page.tsx
export const agentSurface = {
key: "matters",
agent: "atlas",
presenceKey: "AgentSurfaces.matters",
description:
"The matters list: every matter in the firm. Browse, search, filter " +
"by status or type, open a matter, and start a new matter.",
capabilities: ["browse matters", "filter by status or type", "start a new matter"],
kind: "route",
children: [{ key: "matters.add", agent: "atlas", kind: "modal", /* โฆ */ }],
};
Auth / system / catch-all pages opt out explicitly:
export const agentSurface = null; // no-agent: auth: Clerk sign-in.
CI requires the reason comment so a null is a decision, not a
forgotten export.
The freshness gate โ drift becomes a red build
pnpm gen:routes writes the manifest; pnpm check:routes
re-runs the extractor and fails if the committed file is stale, a key is
duplicated, or a page is unclassified. It runs in CI and as a
vitest test, so pnpm test catches drift locally before a push
(LI-6). WHY both: a generator only prevents drift if its
output is checked โ an uncommitted regeneration is the same silent lie a
hand-list was.
Orientation, not data
// What rides to the brain per turn โ a digest, capped, no rows:
visible: { ids: ["M-0005","M-0006", โฆ], count: 250, labels: ["Acme v State", โฆ] }
The brain learns there are 250 matters and sees the first hundred ids โ then fetches any detail through an authz-checked server tool that re-resolves permission on access. WHY a digest: the visible ids are already what the page authorized this user to see; shipping full rows would duplicate data the brain must re-authorize anyway, and balloon every turn.
Acceptance criteria โ evidence
| Criterion | Where | |
|---|---|---|
Every workspace page exports agentSurface (or null+reason); CI lints presence | 37 page.tsx exports; generate.ts presence/reason check | done |
route-registry.ts via AST static extraction; freshness-diff gate | generate.ts (no page import); check:routes + freshness test | done |
Durable surface_key (CI rejects dups); kind; nav by key + typed params | generate.ts dup check; kind field; buildSurfacePath | done |
| CPC store per HR-23 (no functions persisted); publishes active surface + clientTools; per-turn digest | cpc-store.ts (no persist); SurfacePublisher; current-context.ts | done |
AGENTS.md rule: every new workspace page MUST export agentSurface | AGENTS.md โ "Surface registry" section | done |
Do NOT run the API pnpm codegen; import from src/gen | AgentSessionContext imported from @/gen; only gen:routes added | done |
SurfacePublisher
makes the active surface genuinely live โ navigate the real app and
the CPC tracks it against the real registry. Per-page wiring of
clientTools / view-state (each page registering its own
set_filter, focus_matter) lands as each surface's
agent ships and the Dock consumes them (RFC-APB-003); the store action +
usePublish* hooks + serialization that those pages call are
delivered and unit-tested here.
Verification
pnpm check:routesโ 26 surfaces freshpnpm testโ 695 passed (incl. 32 new: registry freshness, navigation, digest caps, session bag, store ownership)pnpm lintโ 0 errors ยทpnpm exec tsc --noEmitโ clean ยทpnpm buildโ success
One-sentence recall: the page describes itself, codegen makes the description complete, CI makes it honest, and the live store says where "here" is โ the agent never reads a hand-kept list and never sees a full row.
Files changed
58 file(s), +2214 / -0 lines
| Path | Role | + | - |
|---|---|---|---|
| .github/workflows/ci.yml | Workflow | +6 | -0 |
| AGENTS.md | Source | +28 | -0 |
| eslint.config.mjs | Source | +4 | -0 |
| messages/en.json | Config | +28 | -0 |
| messages/hi.json | Config | +28 | -0 |
| package.json | Config | +2 | -0 |
| scripts/route-registry/generate.ts | Script | +406 | -0 |
| src/app/[locale]/(auth)/firm-picker/page.tsx | App route | +3 | -0 |
| src/app/[locale]/(auth)/forgot-password/page.tsx | App route | +3 | -0 |
| src/app/[locale]/(auth)/no-active-firm/page.tsx | App route | +3 | -0 |
| src/app/[locale]/(auth)/not-invited/page.tsx | App route | +3 | -0 |
| src/app/[locale]/(auth)/sign-in/[[...sign-in]]/page.tsx | App route | +3 | -0 |
| src/app/[locale]/(auth)/sign-up/[[...sign-up]]/page.tsx | App route | +3 | -0 |
| src/app/[locale]/(auth)/signed-out/page.tsx | App route | +3 | -0 |
| src/app/[locale]/(workspace)/account/page.tsx | App route | +11 | -0 |
| src/app/[locale]/(workspace)/clients/[clientId]/page.tsx | App route | +11 | -0 |
| src/app/[locale]/(workspace)/clients/new/page.tsx | App route | +11 | -0 |
| src/app/[locale]/(workspace)/clients/page.tsx | App route | +11 | -0 |
| src/app/[locale]/(workspace)/dashboard/[...notFound]/page.tsx | App route | +3 | -0 |
| src/app/[locale]/(workspace)/dashboard/page.tsx | App route | +11 | -0 |
| src/app/[locale]/(workspace)/help/[section]/[article]/page.tsx | App route | +11 | -0 |
| src/app/[locale]/(workspace)/help/[section]/page.tsx | App route | +11 | -0 |
| src/app/[locale]/(workspace)/help/page.tsx | App route | +11 | -0 |
| src/app/[locale]/(workspace)/layout.tsx | App route | +5 | -0 |
| src/app/[locale]/(workspace)/matters/[matterId]/page.tsx | App route | +11 | -0 |
| src/app/[locale]/(workspace)/matters/[matterId]/record/page.tsx | App route | +11 | -0 |
| src/app/[locale]/(workspace)/matters/page.tsx | App route | +21 | -0 |
| src/app/[locale]/(workspace)/settings/audit/agents/page.tsx | App route | +11 | -0 |
| src/app/[locale]/(workspace)/settings/audit/log/page.tsx | App route | +11 | -0 |
| src/app/[locale]/(workspace)/settings/firm/ai-inference/page.tsx | App route | +11 | -0 |
| src/app/[locale]/(workspace)/settings/firm/team-roles/page.tsx | App route | +11 | -0 |
| src/app/[locale]/(workspace)/settings/firm/team/page.tsx | App route | +11 | -0 |
| src/app/[locale]/(workspace)/settings/matter-configuration/document-types/page.tsx | App route | +11 | -0 |
| src/app/[locale]/(workspace)/settings/matter-configuration/filing-types/page.tsx | App route | +11 | -0 |
| src/app/[locale]/(workspace)/settings/matter-configuration/forums/page.tsx | App route | +11 | -0 |
| src/app/[locale]/(workspace)/settings/matter-configuration/matter-templates/new/page.tsx | App route | +11 | -0 |
| src/app/[locale]/(workspace)/settings/matter-configuration/matter-templates/page.tsx | App route | +11 | -0 |
| src/app/[locale]/(workspace)/settings/matter-configuration/matter-types/page.tsx | App route | +11 | -0 |
| src/app/[locale]/(workspace)/settings/matter-configuration/participant-labels/page.tsx | App route | +11 | -0 |
| src/app/[locale]/(workspace)/settings/matter-configuration/proceeding-types/page.tsx | App route | +11 | -0 |
| src/app/[locale]/(workspace)/settings/page.tsx | App route | +11 | -0 |
| src/app/[locale]/[...notFound]/page.tsx | App route | +3 | -0 |
| src/app/[locale]/invite/[token]/page.tsx | App route | +3 | -0 |
| src/app/[locale]/no-firm/page.tsx | App route | +3 | -0 |
| src/app/[locale]/page.tsx | App route | +3 | -0 |
| src/lib/surface-registry/current-context.test.ts | Library | +66 | -0 |
| src/lib/surface-registry/current-context.ts | Library | +85 | -0 |
| src/lib/surface-registry/index.ts | Library | +42 | -0 |
| src/lib/surface-registry/navigation.test.ts | Library | +63 | -0 |
| src/lib/surface-registry/navigation.ts | Library | +96 | -0 |
| src/lib/surface-registry/route-registry.test.ts | Library | +65 | -0 |
| src/lib/surface-registry/route-registry.ts | Library | +278 | -0 |
| src/lib/surface-registry/session-context.test.ts | Library | +109 | -0 |
| src/lib/surface-registry/session-context.ts | Library | +109 | -0 |
| src/lib/surface-registry/types.ts | Library | +97 | -0 |
| src/lib/surface-registry/use-surface.ts | Library | +104 | -0 |
| src/stores/cpc-store.test.ts | Test | +107 | -0 |
| src/stores/cpc-store.ts | Source | +165 | -0 |