PR: #400: feat(agents): surface registry + agentSurface codegen + Current Page Context [Story-1656] by @ankitmaheshwari-com
View PR →
Story 1656 ยท Epic-1646 Agentic interaction ยท Wave-1 (surface registry + CPC)

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

FileRoleWHY it exists
src/lib/surface-registry/types.tsThe AgentSurface contract both layers sharePure types, no runtime deps โ†’ an RSC page can import type it for free and the codegen never executes it
scripts/route-registry/generate.tsAST codegen + --check freshness gateReads 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.tsgenerated the complete manifestroute/params are derived from the file path, so they can never drift from the router
src/lib/surface-registry/navigation.tsbuildSurfacePath / resolveSurfaceForPathNavigation by durable surface_key + typed params โ€” never a string-built raw URL
src/lib/surface-registry/current-context.tsThe CPC digest builderIds + counts + labels only, capped โ€” orientation, never full rows or PII; count stays true even when ids are capped
src/stores/cpc-store.tsThe live Zustand storeHolds 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.tsThe per-turn provider bagStrips tool executors to {name, description, schema}; the run function never crosses the wire
src/lib/surface-registry/use-surface.tsSurfacePublisher + usePublish* hooksMounted 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

CriterionWhere
Every workspace page exports agentSurface (or null+reason); CI lints presence37 page.tsx exports; generate.ts presence/reason checkdone
route-registry.ts via AST static extraction; freshness-diff gategenerate.ts (no page import); check:routes + freshness testdone
Durable surface_key (CI rejects dups); kind; nav by key + typed paramsgenerate.ts dup check; kind field; buildSurfacePathdone
CPC store per HR-23 (no functions persisted); publishes active surface + clientTools; per-turn digestcpc-store.ts (no persist); SurfacePublisher; current-context.tsdone
AGENTS.md rule: every new workspace page MUST export agentSurfaceAGENTS.md โ†’ "Surface registry" sectiondone
Do NOT run the API pnpm codegen; import from src/genAgentSessionContext imported from @/gen; only gen:routes addeddone
Scope seam (honest boundary). The 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

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.ymlWorkflow+6-0
AGENTS.mdSource+28-0
eslint.config.mjsSource+4-0
messages/en.jsonConfig+28-0
messages/hi.jsonConfig+28-0
package.jsonConfig+2-0
scripts/route-registry/generate.tsScript+406-0
src/app/[locale]/(auth)/firm-picker/page.tsxApp route+3-0
src/app/[locale]/(auth)/forgot-password/page.tsxApp route+3-0
src/app/[locale]/(auth)/no-active-firm/page.tsxApp route+3-0
src/app/[locale]/(auth)/not-invited/page.tsxApp route+3-0
src/app/[locale]/(auth)/sign-in/[[...sign-in]]/page.tsxApp route+3-0
src/app/[locale]/(auth)/sign-up/[[...sign-up]]/page.tsxApp route+3-0
src/app/[locale]/(auth)/signed-out/page.tsxApp route+3-0
src/app/[locale]/(workspace)/account/page.tsxApp route+11-0
src/app/[locale]/(workspace)/clients/[clientId]/page.tsxApp route+11-0
src/app/[locale]/(workspace)/clients/new/page.tsxApp route+11-0
src/app/[locale]/(workspace)/clients/page.tsxApp route+11-0
src/app/[locale]/(workspace)/dashboard/[...notFound]/page.tsxApp route+3-0
src/app/[locale]/(workspace)/dashboard/page.tsxApp route+11-0
src/app/[locale]/(workspace)/help/[section]/[article]/page.tsxApp route+11-0
src/app/[locale]/(workspace)/help/[section]/page.tsxApp route+11-0
src/app/[locale]/(workspace)/help/page.tsxApp route+11-0
src/app/[locale]/(workspace)/layout.tsxApp route+5-0
src/app/[locale]/(workspace)/matters/[matterId]/page.tsxApp route+11-0
src/app/[locale]/(workspace)/matters/[matterId]/record/page.tsxApp route+11-0
src/app/[locale]/(workspace)/matters/page.tsxApp route+21-0
src/app/[locale]/(workspace)/settings/audit/agents/page.tsxApp route+11-0
src/app/[locale]/(workspace)/settings/audit/log/page.tsxApp route+11-0
src/app/[locale]/(workspace)/settings/firm/ai-inference/page.tsxApp route+11-0
src/app/[locale]/(workspace)/settings/firm/team-roles/page.tsxApp route+11-0
src/app/[locale]/(workspace)/settings/firm/team/page.tsxApp route+11-0
src/app/[locale]/(workspace)/settings/matter-configuration/document-types/page.tsxApp route+11-0
src/app/[locale]/(workspace)/settings/matter-configuration/filing-types/page.tsxApp route+11-0
src/app/[locale]/(workspace)/settings/matter-configuration/forums/page.tsxApp route+11-0
src/app/[locale]/(workspace)/settings/matter-configuration/matter-templates/new/page.tsxApp route+11-0
src/app/[locale]/(workspace)/settings/matter-configuration/matter-templates/page.tsxApp route+11-0
src/app/[locale]/(workspace)/settings/matter-configuration/matter-types/page.tsxApp route+11-0
src/app/[locale]/(workspace)/settings/matter-configuration/participant-labels/page.tsxApp route+11-0
src/app/[locale]/(workspace)/settings/matter-configuration/proceeding-types/page.tsxApp route+11-0
src/app/[locale]/(workspace)/settings/page.tsxApp route+11-0
src/app/[locale]/[...notFound]/page.tsxApp route+3-0
src/app/[locale]/invite/[token]/page.tsxApp route+3-0
src/app/[locale]/no-firm/page.tsxApp route+3-0
src/app/[locale]/page.tsxApp route+3-0
src/lib/surface-registry/current-context.test.tsLibrary+66-0
src/lib/surface-registry/current-context.tsLibrary+85-0
src/lib/surface-registry/index.tsLibrary+42-0
src/lib/surface-registry/navigation.test.tsLibrary+63-0
src/lib/surface-registry/navigation.tsLibrary+96-0
src/lib/surface-registry/route-registry.test.tsLibrary+65-0
src/lib/surface-registry/route-registry.tsLibrary+278-0
src/lib/surface-registry/session-context.test.tsLibrary+109-0
src/lib/surface-registry/session-context.tsLibrary+109-0
src/lib/surface-registry/types.tsLibrary+97-0
src/lib/surface-registry/use-surface.tsLibrary+104-0
src/stores/cpc-store.test.tsTest+107-0
src/stores/cpc-store.tsSource+165-0