Agentic app
The agentic app is the Sustentus assistant’s own surface: a separate Next.js app that sits beside
the platform in apps/web rather than inside it, so agentic features can change without rebuilding
the platform. Anything that belongs on a classic screen belongs in apps/web.
Overview
Package name: @sustentus/agent
Framework: Next.js 16 (App Router, Turbopack)
Port: 3007
Path: apps/agent/
Vercel project: agentic-interface
It signs in against the same Clerk instance as the platform, with the same keys and no satellite
(multi-domain) configuration: Clerk shares authentication across subdomains of one root domain, so a
signed-in platform user is already signed in here. The app’s own /sign-in exists because that
sharing does not reach a per-commit Vercel preview hostname.
The whole app is signed-in surface with no public audience, so next.config.ts sets
X-Robots-Tag: noindex, nofollow on every path wholesale rather than page by page, and the root
layout repeats it in metadata. app/manifest.ts makes it installable as a standalone PWA, with no
service worker by design — the app is online-only, and a worker’s cache would go stale against Clerk
sessions and per-commit deploys.
Who it is for
Two populations, and only two:
| Population | Holds | Works in |
|---|---|---|
| Tenant user | A Clerk org and a platform role, read from session claims | Their own tenant |
| Sustentus staff, invited partners | No org — the publicMetadata console-access marker | One tenant at a time, chosen from their console scope |
Anyone else is refused. Staff and partners are the same audiences the
console recognises, read through the same
parseConsoleAccess in @sustentus/services/shared.
Their extra privilege is reach, not power: more tenants available, one at a time, and nothing extra inside the one they pick. There is no staff privilege tier.
Access model
proxy.ts — this repo’s Next 16 middleware file — is the deny-by-default gate, and it covers
every route the app will ever serve, pages and route handlers alike. Adding a route adds nothing to
it. Three paths are exempt, and only three:
/sign-in(.*)— how someone without a session gets one./api/health— the probehealth-check.shreads after every merge. Matched exactly, not(.*); it answers only{"status":"ok"}(200) or{"status":"unavailable"}(503) from a MongoDB ping, and it stays inside the proxy so a 200 also proves the gate ran./.well-known/vercel/rate-limit-api/(.*)— the one path@vercel/firewall’scheckRateLimitcalls back into on this host. Without the exemption the gate redirects that call, the SDK sees a 307 it has no branch for and throws, taking down every chat turn. It is Vercel platform surface served by the edge, so exempting it exposes nothing of ours. Scoped to that exact prefix rather than all of/.well-known/vercel/, because other paths under that namespace are app code.
The gate recognises each population differently, and the difference is deliberate:
- A tenant user is decided from session claims alone — an org plus a role in the platform’s enum — so the hot path costs no database read and no Clerk API call.
- An org-less caller’s marker is read live from Clerk, exactly as the console’s gate reads it and for the same reason: reading it live is what makes a revocation deny the next request rather than the next sign-in. That read is reached only on the org-less branch, so a tenant user never pays for it, and it fails closed — a marker that cannot be read is not a marker that may be acted on.
A refused caller is told the truth rather than bounced. /api/** answers 403; a page falls
through to the app’s “No workspace available” state, which is the honest thing to show a signed-in
person who has no workspace yet. A redirect loop back to /sign-in would tell them nothing.
Every route then carries a second, deny-by-default gate of its own — getAgentContext() — for
two reasons: the edge matcher is one config edit away from letting something through, and the edge
deliberately reads no database, so it is the route that resolves which tenant the caller is working
in. Server actions carry the same check, and there it is doing real work: an action posts to a page
route, which the proxy lets through to the no-workspace state rather than 403-ing.
AGENT_AUTHORIZED_PARTIES narrows which origins’ Clerk sessions this deployment accepts. It is
opt-in and empty by default — see Deployment for why, and for where to set
it.
Tenant scoping
One tenant at a time, and never one the conversation supplies. Every AI tool is built by
createTenantContext(tenantId); a tool’s execute never receives a tenant id and no tool input
schema carries one. That construction is what makes “a tool cannot be pointed at another tenant”
true rather than intended, so a tenantId planted anywhere in a conversation cannot reach a tool.
A tenant user’s tenant comes from their org. A console caller chooses theirs from the tenant
picker and it is remembered in an httpOnly cookie (sustentus-agent-tenant, lib/agent-context.ts)
— which is never trusted on its own. resolvePlatformContext re-authorises the id against the
caller’s ConsoleTenantScope on every request, so a forged value, a revoked partner assignment and a
since-deleted tenant all resolve to “no selection” rather than to access, or to an error that
confirms the tenant exists. That is the single authorisation home; selectTenant deliberately does
no authorisation of the tenant beyond refusing a caller the app does not recognise and a value not
shaped like a tenant id.
Switching tenants is one click (/?switch=1) rather than a signed-out round trip, and it changes
which conversations exist: threads belong to a user in a tenant.
Routes
| Route | Who | What it does |
|---|---|---|
/ | Both | A fresh conversation, with the register rail in the left column — opened by the register itself where it declares an opening (see The register speaks first). A console caller with no tenant selected gets the tenant picker instead; ?switch=1 reopens it. A caller whose context resolves to nothing gets “No workspace available” |
/c/:threadId | Both | One saved conversation, reopened with its full history |
/start?flow=&lead=&return= | Tenant users | The hand-off from a platform screen: opens — or reopens — the caller’s conversation about one of their requests and lands on it (see Hand-off from the platform) |
/spend | Tenant admins | The tenant’s AI spend envelope for a month (?period=YYYY-MM) |
/sign-in | Public | Clerk sign-in — the app’s only public page |
GET /api/health | Public | MongoDB ping for the post-merge health read: { status } and nothing else, 200 or 503 |
POST /api/chat | Both | The streaming turn, and the conversation’s only writer |
GET /api/ai/availability | Both | Whether the AI gateway can serve: { status } and nothing else |
GET /api/trace/:threadId/:uiId | Both | The run record behind one assistant turn, for the trace rail |
Three server actions sit beside them: selectTenant (app/select-tenant.ts) records the tenant a
console caller is working in, and renameThread / deleteThread (app/thread-actions.ts) manage
conversations. Each resolves the caller from the session and hands the resulting owner to the
service, which refuses anything that is not theirs.
A thread that is not the caller’s is answered exactly as one that does not exist — on the page, in the chat route and in the trace route alike. The service returns null for both and no surface tells them apart, because any other answer would make these routes a way to test whether a given id is somebody’s conversation. That includes the tenant dimension: a thread held in a tenant the caller has since switched away from is not readable either.
Registers
The surface behaves differently per persona, and a register is the unit of that: its own
prompt-workspace contract and its own tool sets, resolved from the session’s effective platform role.
The catalogue lives in @sustentus/services → the agent registry,
so it is code, not data — publishing markdown changes what a register says, never which tools it
holds.
| Register | Answers for | Asks it can take |
|---|---|---|
csm | CSM | What is blocked across the portfolio |
vendor | Vendor | How the funnel moved, as a decomposition that adds up |
expert | Expert | What is worth bidding on, with the reason for each verdict |
sdm | SDM | Where demand outruns the experts available, how bids split across the price bands, and what sourcing an uncovered pool would require |
admin | Tenant admin | What the assistant is costing this workspace this month, and what is driving it |
customer | Customer | What their requirement document records and what is still open on it, where the request has got to — and writing the document with them, asking with clickable options where a question has a short set of answers, and submitting it once they authorise it |
assistant | Everyone else | The generic assistant |
A persona with no register yet — sales — keeps the generic assistant and is told so.
So does an org-less staff or partner caller, who holds a Clerk org in no tenant and is therefore in no
persona at all: resolveRegisterForContext passes null and the header says “General assistant”
rather than naming a persona they are not in. That is doctrine, not an omission — reach is not a
persona.
lib/ai/register.ts is the one home for the mapping from a resolved PlatformContext to the
registry’s inputs, because three surfaces ask the same question — the chat route, to assemble the
prompt and mount the tools, and both page routes, to name who is answering in the header — and they
must never disagree.
Tenant scoping and permission scoping are different things, and both apply. On top of the tenant binding above, a register’s tool declares the permission set it needs, and a caller missing any one of them is never handed the tool at all.
The register rail
The register is not only what answers — it is the left column, so a user sees which register they are in and what moved while they were away before they ask anything. Each register declares the rail sections it shows, beside its tool sets in the same catalogue:
| Register | Verb | Rail |
|---|---|---|
csm | unblock | The blocker queue as counts, revenue exposed, and a standing note |
vendor | analyse | The standing note only — the funnel is the register’s answer |
expert | bid | The bid pool banded by verdict, and committed capacity |
sdm | cover | Coverage by status, uncovered demand, and a standing note that sourcing is not wired up |
admin | govern | The spend envelope as a meter, and a standing note |
customer | converse | Their own requests banded by stage, and a standing note saying what happens to the document |
assistant | assist | The verb card alone |
Copy and figures come from different places, and the boundary is the point. The verb, the
one-line stance and every note body are markdown in the prompt workspace (L2/rails/<register>.md),
so staff change what the rail says by publishing rather than by deploying. Every figure and its label
come from a typed server-side read that reuses the service already behind that register’s tools, under
the same permission gate — so no figure is model-emitted, and a staff edit cannot relabel one into a
different claim. A test asserts that every figure the rail shows is reproducible by a tool the
declaring register actually mounts.
Those rail files are catalogued, versioned and publishable like every other workspace file, and are composed into no register’s prompt: the rail is a surface the user reads, not context the model answers from, so the assembled prompt is byte-identical to one that never had them.
A section is one of four kinds, and the difference is what it may contain rather than how it looks: a trace is counted rows, a stat is one figure, a meter is one figure against the whole it is part of, and a note is prose and carries no figure at all. That last distinction is the load-bearing one — a note’s body is staff-editable markdown, so a kind that could hold a figure would let a publish relabel one. A meter’s percentage is computed by its reader and drawn as given: the component clamps only the bar’s width, so consumption past the whole it is measured against reads as a full bar beside its real number rather than as a figure quietly capped.
A section whose read needs a permission the caller does not hold is absent — not rendered as zero, which would be a claim about a read that never ran. A rail read that fails degrades to the verb card plus one quiet line, and the register’s own words survive it, because copy resolves through the seed-tolerant body read and only figures need a tenant read to exist.
The rail takes the left column at and above the tablet breakpoint (768px) and is hidden below it, where its verb card is reachable from a header control.
The role rail, and the Screens link
Under the header sits a second, narrower rail: every register in the registry as a pill carrying its label and its verb, with the one that is answering marked as current. It is there because the surface is one product behaving differently per persona, and a rail showing only your own register says nothing about that. A caller in no persona has the fallback’s own pill marked — never one of the personas they are not in.
Every pill but the current one is inert, and says so on hover and on keyboard focus. They are
focusable buttons carrying the reason rather than disabled ones, because disabled takes an element
out of the tab order and the explanation with it. On the demo tenant the pills are live for a
presenter — see register switching below.
The pills’ verbs are the published ones, read from the same ## Verb section the register rail’s
verb card renders, so the pill and the card cannot disagree about what a register is for. One
workspace read covers all six. AgentRegister.verb in the registry is the fallback beneath that.
The header also carries a Conversation / Screens control in place of a plain platform link.
Conversation is the side the reader is already on; Screens opens the classic platform in a new tab at
the caller’s role home (/{role}), where their overview is served, and the whole control is absent when
PLATFORM_APP_URL is unset or unusable. The link carries no tenant of its own — the platform
resolves that from the Clerk session both apps share.
Register switching, on the demo tenant
The concept this surface is built from is told by clicking along the role rail. Doing that needs one person to be able to answer as several personas, which is why the pills are live on the demo tenant and nowhere else. A presenter — a tenant admin there, or a console caller who has selected it — picks a register and gets it in full: that demo person’s tools, their permission set, their prompt contract, their register rail and their composer copy.
The pick is a server-side selection in a second httpOnly cookie
(sustentus-agent-acting-register), and it is trusted exactly as little as the tenant cookie above:
resolvePlatformContext re-authorises it on every request and is the only thing that can put an
acted-as persona on a PlatformContext. The decision itself lives once, in
packages/services/src/auth/acting-persona.ts, and answers “no switch” for
- any tenant that is not the demo tenant — by the tenant service’s own
isDemoTenant, a field nothing outside a migration writes; - any caller who is not a presenter;
- any register the demo roster holds no real Clerk login for (
demo:roster) — the persona acted as is always a person who could have signed in themselves; - any value that is not a catalogued register key.
Every one of those refusals is the same answer, so a forged cookie leaves the caller in their own register rather than revealing which guess was close. A pill the roster cannot seat is inert on the demo tenant too, and says that rather than the non-demo reason. Picking your own register is not a switch, which is what makes your own pill the way back — no second control exists.
This is the one place the platform resolves tools for an identity that is not the caller’s, and the
one deliberate exception to “a console caller is in no register”: bounded by the demo tenant, whose
every row is fabricated. It changes nothing about the platform’s org
roles, session claims or Clerk configuration — the presenter’s session is untouched, and
apps/web calls resolvePlatformContext with no register at all, so switching cannot reach it.
Conversations opened while switched belong to the demo persona, so the drawer, the history a turn is answered against, the run record and the trace all address the identity the tools actually ran as.
What the composer and the answer headers say
A third catalogued file per register, L2/surfaces/<register>.md, holds the copy the shell speaks
in: the name each answer is signed with, its mode note, the scenario chips above the input, the
placeholder, and the footer line under it.
| Register | Signed | Mode |
|---|---|---|
csm | Sustentus | unblock |
vendor | Sustentus | analysis |
expert | Sustentus | bid pool |
sdm | Sustentus | supply |
admin | Sustentus | envelope |
customer | Project Discovery | requirements |
assistant | Sustentus | general help |
The customer register is the one signed by something other than Sustentus, and that is the file saying so rather than a special case in code: the customer is not a job title inside the workspace, and Project Discovery is what the register is for rather than who it answers as.
The chips stay above the input for the whole conversation rather than vanishing with the empty state — the second question is the one worth prompting — and each sends its label verbatim down the ordinary send path, so a chip is an ordinary turn and nothing reads tenant data in the browser. A caller in no register gets the fallback register’s chips, which are the four catalogue starters the app used to hardcode.
Surface files sit on exactly the rail files’ terms: catalogued, seeded, versioned and validated like
every other workspace file, and in no register’s registerPaths. Chips are affordances the user
reads, not instructions the model answers from — “Open SL-2048” composed into a prompt is how a model
invents a record — and keeping them out is also what leaves the assembled prompt byte-identical to
one that never had them. Their publish check measures the fields rather than the prompt’s token
band, which has nothing to say about eighty words that never reach the model: a missing field is
refused by the same required-section rule every file has, and an empty, over-long or too-numerous one
by a surface-field refusal naming the field.
A published body that cannot be read whole falls back to the register’s seeded copy rather than rendering half a surface, and an unreachable store falls back the same way — so the shell can lose its tuning and never its copy.
A stored answer keeps the byline it was written under. The chat route reads the answering
register’s name and mode note when the request starts — the same instant it fixes the prompt version
set — and stores them on the assistant turn as byline { agent, mode }
(packages/services/src/db/models/agent-message.ts). A reopened conversation signs each stored
turn with its own stamp, so a thread held as a CSM, reopened after the role, the tenant or the
published Mode changed, still shows each answer under the lens it was answered through. Only the
first write stamps: a turn resumed after an authorisation, or an approval card closed because the
conversation moved past it, keeps the byline it began with. Turns stored before the stamp existed,
a turn whose surface could not be read, and the turn streaming now fall back to the live register’s
copy. Nothing is backfilled — the run record keeps only the register key, and resolving it today
would sign an old answer with today’s copy.
The register speaks first
A register that declares an opening briefs the caller on / before they type anything. Every
persona register — CSM, vendor, customer, expert, SDM and tenant admin — declares one; the generic
assistant keeps the empty state.
-
The reads are code. The registry declares them beside the register’s rail sections (
AgentRegister.openinginai/registry/registers.ts). The first read is also the emptiness probe: when its output reportstotal: 0, the reads after it are replaced bywhenEmpty.Register Opens on When the first read is empty CSM csm_blocker_queue,csm_revenue_at_riskcsm_portfolio_sizeVendor vendor_service_leads,vendor_funnel,vendor_funnel_losses,vendor_lead_book— all over the last 90 daysvendor_lead_book(the window sourced nothing)Customer customer_requests, thencustomer_requirement_documenton the newest request at the drafting stagenothing further (no requests) Expert expert_bid_triage,expert_bid_pool_size,expert_capacity— at the rail’s pool limit (50)expert_capacity(an empty pool)SDM sdm_coverage,sdm_coverage_gap— at the rail’sminescopenothing further (no active demand) Tenant admin admin_spend_envelope,admin_agent_consumptionno probe — the envelope answers a workspace with no earlier month on its own A read’s input may be a pure function of the reads settled before it (
ai/opening/derive.ts) — the customer’s document card targets a request the requests read found; no target means the read is not run and its panel is absent. A read may alsoomittop-level keys from its stored output: the vendor funnel panel drops its own reading and actions, so the model’s reading is the only one on the board. The page runs the reads server-side through the register’s own tools, built for the caller — a tool the caller may not hold is an absent panel, never a zero — and through the same per-render memo the rail reads through, so the panels and the rail’s counts are one read (the customer’s requests share the rail’scustomer-requests:<userId>key; the expert’s pool, the SDM’s coverage cells and the admin’s month shareexpert-bid-pool:,sdm-coverage:andadmin-spend:keys the tools and the rail take from one home each). The SDM coverage table and both admin panels are stored without their own reading and actions (omit), as the vendor funnel is. The CSM’s queue panel iscsm_blocker_queue’s own table part (buildBlockerQueueTableinai/answers/csm.ts) — one row per blocker, worst first, with its stage, owner, days stuck, escalations, SLA and revenue — returned beside the rawrowsandtotalthe menu and the emptiness probe read; an empty queue keeps its empty table above the portfolio claim. The panels are in the page’s HTML. -
The reading is the model’s, the actions are not. The client then asks
/api/chatfor the reading (opening: true). The model writes a headline and its reasoning through theopening_readingtool and picks the actions by id from a menu the reads composed (ai/opening/menu.ts) — prompts the register’s tools answer and links built by the platform-path helpers (leverPlatformPath;expertBidPlatformPath,expertBidDraftPlatformPathandsdmFigurePlatformPathbeside it inshared/lead-levers.ts; andshared/platform-paths.tsfor the service lead board, creating a lead and raising a request) — so it never writes a button. The CSM menu’s accounts come fromcsm_blocker_queue, which ranks the whole queue worst first — past SLA, then at risk, then on track, the longest stuck first within each — before its row cap, so they are the worst accounts on the book, not the newest blockers’ (the cap is per blocker row, so one account carrying most of the worst rows leaves fewer than five on the menu). The expert menu offers no bid form on a lead already bid on or drafted, and no skip; the SDM menu links revenue exposed to the platform’s figure rather than computing it; the admin menu is prompts only. The tool returns areadinganswer part, and calling it ends the turn. The turn is bounded at three steps of at most 1,000 output tokens each. The register’s tools stay mounted on the first two, so the reading may look into one account, and the third is offered onlyopening_readingand must call it. An opening that finishes without a reading (cut off at the length limit, out of steps, ended in text, or its last reading call refused) has failed: its run record readserrorwith the codeOpeningWithoutReading, its claim is released, and the reader keeps the panels with the one-line “No reading this time” notice. It writes one run record like any other. -
What it says is the prompt workspace’s.
L2/openings/<register>.md(sectionsHeadline,Reading,Actions) is composed into the opening turn only, after the register’s standing prompt, and its version is in that run’s prompt version set. Publishing it checks the required sections and the token ceiling against that opening assembly; the thin-prompt floor does not apply. -
A thread only on reply. The page writes the reads as a pending thread’s first turn (
createPendingOpening); the drawer never lists a pending thread, and a TTL index onpendingUntildeletes it and its turn 24 hours after the render (migration1790900000016-agent-opening-pending-ttl). The first reply adopts it — titled from the reply, no longer pending — and the client moves to/c/<threadId>. The reading is claimed once per thread, so a thread never runs two, and a reading asked for by a register other than the one that opened the thread is refused. The opening’s run record survives an unanswered thread’s expiry. -
It degrades in one line. A read that fails is an absent panel, recorded like a failed rail read. With the assistant switched off, rate-limited or failing, the panels stay and one line says the reading did not arrive; a pending thread that could not be written renders the panels and asks for no reading.
The stage is a board
The conversation region renders as a board, not a chat: no avatars and no bubbles
(components/assistant-chat.tsx, primitives from @sustentus/ui).
- The opening leads the page. Every register turn before the caller’s first message is the
opening (
openingLengthinlib/board.ts). The reading’s headline is the page’sh1(BoardHeading), signed once underneath — the register’s name and mode, “as at” the moment the panels were read — then the panels at the stage’s full width, the reasoning and the actions. The heading’s slot is on screen from the first paint with a shimmer while the reading is owed, so the panels never move when the headline lands; an opening with no reading (a hand-off, a failed or refused reading, one that could not be saved) has no heading. The reading part does not repeat its headline under the panels. - Later turns are a question and a section. A caller’s turn is a large plain question
(
MessageQuestion); a register’s answer is a full-width section (MessageSection) with its signature, its parts, and the trace and copy controls in one footer row. A hairline marks where each exchange begins. A reopened thread renders the same way. - The stage is wide, the prose is not. The stage is
max-w-5xl; headings, questions, answer text and the reasoning keep a70chmeasure inside it, and a wide table scrolls within its own panel. Belowmdthe heading and questions step down one size; the rail stays behind the header drawer. - The empty state is for a register with nothing to open with. “How can I help?” shows only on an empty thread whose register has no opening — the generic assistant, and a persona register until its opening ships — or after a hand-off opening was declined or failed. The six persona registers’ seeded placeholders read as instructions (“Tell me which account to push, or what to check first…”).
Conversations
Conversations are saved per user and per tenant. A drawer opened from the header lists the
caller’s recent threads — at every width, since the register rail took the left column —
/c/<threadId> reopens one with its full history, and each can be renamed or deleted from inside the
drawer. Reading is the author’s alone — there is no staff or cross-tenant view — with one named
exception: a conversation a platform hand-off bound to a request is shown read-only on that request’s
Requirements section (below).
The route is the writer, and the only source of history. The client posts one new user message and
a thread id, never the transcript; the chat route reads the history back out of the store. So the
history a turn is answered against is the history that was actually saved, and a caller cannot rewrite
their own past turns — or the assistant’s — on the way in. Only a user role is accepted on the
way in: convertToModelMessages would map a system message to a real system model message, which
would walk straight around the prompt workspace’s injection rails, and a caller-supplied assistant
turn would be a way to put words in the assistant’s mouth.
Only a bounded window of the transcript is replayed to the model (lib/ai/assistant/replay-window.ts),
so a long-lived thread does not make every turn cost more than the last, while the whole thread stays
stored and rendered.
Persistence degrades on its own. Every store call on the turn path is swallowed: the conversation
outranks its own history, so a database that is briefly unreachable costs the user their history, not
their assistant, and the turn still streams — unsaved. Swallowed is not unrecorded: each failure is
also written to agent_degradation_logs with the operation, the tenant, what it cost
(served-unsaved / history-lost / write-lost) and its cause (failed / refused), which
Sustentus staff read in the console at
Assistant health. A degraded turn costs exactly
one reporting write, flushed after the response, and recording may never cost a turn.
The register rail’s read joins that same vocabulary rather than getting a record of its own — a
failed rail read is written as the rail read operation with the rail-lost outcome. It is the one
entry not attached to a chat turn, so it never claims the firstOfTurn stamp: that field counts
degraded turns, and a rail is read on every page render, so letting one claim it would turn the
console’s headline figure into a count of navigations.
Every assistant turn also writes an agent-run record — the register, the model, the tool calls with the tenant each was bound to and the scope it actually ran at, the token usage, the prompt version set and the outcome. That record is the join key everything else reads: the trace rail below, the spend envelope, and the console’s Agent runs. Exactly one is written per turn, a thread-less turn included — that path is where real gateway spend used to fall out of every view derived from it.
Hand-off from the platform
A platform screen that starts a conversation hands off to
/start?flow=<flow>&lead=<requestId>&return=<platform path> (app/start/page.tsx); the platform
builds the URL from AGENT_APP_URL and both ends parse it through @sustentus/services/shared →
agent-handoff.ts. brd — the customer’s requirements conversation — is the only flow; any other is
a 404.
- The tenant is the session’s and the request is the caller’s.
leadis the tenant’s request number, looked up among the resolved actor’s own requests only, so a request that does not exist and one that is somebody else’s get the same 404.returnis kept only when it is a path on the platform’s origin (resolveSameOriginPath— the rule the platform applies toredirect_url); anything else becomes the request’s/workspace/<id>. A signed-out visitor signs in and lands back on the same/startURL. - One conversation per request and person. The thread carries
flow,leadId,key(<flow>:<requestId>, unique per owner —migrations/1790900000014-agent-thread-flow-binding-indexes.ts) andreturnPath; opening the link again, from any tab, reopens it. - The register speaks first, once, and the opening is stored. The page asks for the opening only
while the thread is empty; the chat route claims it atomically, so two tabs run one opening, and
gives it back if the turn fails. What the opening answers is the customer’s arrival from their
request (
lib/ai/opening.ts, never stored or shown); how to open is the customer register’s workspace contract (L3/customer-requirements.md→ Opening from a request). A fresh conversation someone starts themselves still opens on what they said. - The way back is Back to your request in the header, on a bound thread only, to the platform page the hand-off named.
- The platform shows the thread as the conversation record on the request’s Requirements section
(
readLeadThread): read-only, to the request’s customer and to full-scopebrd.viewholders. It is the store’s one read that is not the author’s; the platform decides who may make it before it does.
Answers, and the evidence behind them
A number the register reports is a claim, not a sentence. A question with a figure in its answer comes back as typed tool output rather than prose, and renders as a component carrying the figure, the question it answers, what it counts, over what period, against which date, what was left out, and the records plus the arithmetic that reproduce it on screen. Where the definition comes from is stated rather than implied — the metric dictionary’s own words, or labelled as defined by that answer — so a figure never wears a definition written for a different population. Money is never converted to make a total look complete: no exchange rate is recorded anywhere, so an amount in another currency is excluded and counted in the exclusions with its reason.
“Break that down” yields a table, a funnel or a share split generated for that question and labelled as such, never dressed as a saved report; asking where an account sits renders its position on the lead journey read from the workflow engine. A share split carries a caption per row — a share on its own says how much and never how well — and a consumption figure renders as a meter.
A document renders as a card of field rows: what each field records, or an explicit “not recorded
yet” where nothing does, over a count of how many are recorded. A row can show a value and still be
open — it then carries an openReason (today only a timing row with an end date and no start date)
and is left out of the count; the render boundary refuses a reason with no value or an empty one. The empty rows are rendered rather
than dropped, because the card answers “what is still missing?” as well as “what does it say?”, and a
row may be marked inferred with the reason it was inferred — a marking the render boundary refuses
without one, and refuses on a row with no value. The count is the server’s and is cross-checked
against the rows, so a card cannot claim a completeness the rows beneath it contradict. On the
customer’s requirement document, what counts as recorded is decided by brdCompleteness
(packages/services/src/shared/brd-completeness.ts) — the one rule the card, the confirm tool and
leadService.approveBrd all read. The card is
inert: it renders a document and never edits or submits one.
An answer is data, then the register’s reading of it, then where to go next, in that order and
visually apart. After the figure a register may add a reasoning block: an ordered argument that
names what it reads into the number and, deliberately, the assumption a reader should attack first.
Emphasis inside it is a typed flag rather than markup, which is why no HTML crosses this boundary
even here. Beneath that sits an actions row — one primary and up to three others, each one of two
kinds. A prompt is a follow-up question: its label is the message, and clicking it sends that
text down the same path the composer uses; every prompt offered is a question that register’s own
tools can answer. A link opens the platform page where the action the answer recommends is
actually taken — in the same tab, the way a lever does — and sends nothing; it carries an outward
arrow so a reader can tell before clicking that it leaves for the platform. Either kind may lead
the row. A link’s destination is a path the answer builder derives from a tool’s result through the
platform-path helpers in @sustentus/services/shared, never text the model wrote. Nothing in an
answer writes: no button chases, escalates, approves or reassigns.
Clickable options
A register can ask a question with its likely answers laid out to click — a single choice (one
pick) or a checklist (tick any). Options are a tool’s output: offer_options returns an
options answer part, validated at the render boundary like every part and rendered by
AnswerOptions from @sustentus/ui. The tool sits in its own tool set, converse-options, which
reads and writes nothing and is gated on no permission, so any register can mount it by naming the
set; the customer register does, and its contract says when to use it.
A pick is a message, and nothing writes. Clicking sends the option’s label as the next user
message down the same path the composer uses — no payload, no navigation, no write. Submitting a
document stays customer_confirm_requirements and its authorisation card; “Submit as is / Add final
note” stays the document card’s actions row.
One answer per turn. Calling offer_options ends the turn — the agent’s tool loop stops on the
step that produced its result, so the model cannot answer its own question (a refused call is a tool
error, and the loop carries on so the model can fix it). Two calls in one step are two questions on
one turn, answered together by one message:
| The turn asked | The answer message |
|---|---|
| One single choice | The label, sent on the click |
| One checklist | Selected: <label> · <label>, sent with Continue |
| Several questions | One <label or question>: <picks> line per question, sent with one Continue once each holds a pick |
Answered, the options stay on screen. Once a later user message exists, the turn’s options
render disabled with the picks marked — read back from that message, so a reopened conversation
shows what was offered and what was chosen without any field of its own. A customer can always type
instead; a typed answer closes the options with nothing marked. The render boundary refuses a group
with no options, a mode it does not know, a single choice of one, more than eight options, a label
that is empty, longer than 80 characters, on two lines, carrying · or repeated, and a reading or
actions row on a question — and a refused group degrades to its summary.
Status inside a table is said in a tone rather than left as a word among words: a cell may carry positive, caution, negative, info or neutral and renders as a brand semantic pill, red reserved for the problem. The tone is read off a classification a service already made — an SLA band, a coverage status — and never invented in the answer layer. A dictionary-backed figure also shows the dictionary’s declared shape, in the dictionary’s own words, so a cohort followed forward and a stock read at this moment stop looking identical.
components/answer-parts.tsx keys on the part contract (part.kind, defined in
@sustentus/services/shared), never on tool names, so a register added later returns the same shapes
and renders with no change here. Every part is a validated typed shape rendered as React — the model
emits no markup, so no HTML crosses this boundary at all — and a part that arrives without the records
that reproduce it degrades to plain text and says so.
Behind every answer that ran a tool is a trace rail: which tools ran, the organisation each was
bound to, the scope it actually ran at after permissions narrowed it, its arguments, its outcome and
its timing — and, on a turn a presenter ran while switched, the demo persona it was acted as.
The register says which lens answered; the acted-as line says which person the tools resolved for.
The run record additionally carries the presenter who drove it (AgentRun.actedBy), which no
surface shows: who presented is an operator’s question, not evidence the conversation displays. It is fetched when opened rather than on render — a conversation holds many turns and
almost none of their traces are ever looked at — and a turn that is still streaming has no run record
yet, so the rail is offered only once the turn is stored. A trace that could not be read says it is
unavailable rather than claiming no tools ran.
Writing, and the one tool that needs permission to run
Every register but one reads. The customer register also writes, and the shape it does that in is the rule for any register that writes later.
Two writes, and they reach different places. customer_draft_requirements writes a draft — its
own tenant-scoped collection (brd_drafts), one document per request, which the platform’s
requirements page does not read — so a half-finished conversation cannot reach the approved document
and a new conversation on the same request finds the same draft. Each drafted section carries where
its words came from, and a section the register worked out rather than being told renders as inferred
with its reason on the row; the render boundary refuses a marking with no reason behind it.
Both sections have a shape (shapeProblem and SECTION_LIMITS in ai/tools/customer.ts). The
overview may not contain a heading or be longer than 1,200 characters of HTML. The requirements must
be one <ol> or <ul> with nothing outside it and no list inside it, no more than 8 <li> items,
no heading, and no more than 2,000 characters. Each failure is a one-sentence refusal that names the
section, and nothing is saved for that section. If the same call writes rows, the rows and any sound
section are still saved, and every refused section’s reason comes back in refused. Word counts and
the three-item minimum appear only in the tool’s description, so a customer with two real
requirements is never pushed to pad the list.
The draft also holds five of the request’s own rows: title (what you need), industry,
location, languages and timing. There is no budget row: the budget is the service’s price,
read from the catalogue and never asked. Each row is replaced whole when written, and none is ever
inferred. ai/tools/customer-rows.ts resolves them before they are saved. It is pure over the
tenant’s lists:
industrymust exactly match one of the tenant’s active industries. Otherwise it saves nothing and returns the closest few asindustryChoices.- A country must be on the standard list, and its single tenant region is filled in.
- Languages must be current ISO 639-1 codes.
- The start date must not be before today, and the end not before the start.
Each refused row comes back in refused, and the rest are saved. Service and product have no field
on the tool. The document tools also return today, because the model has no clock and resolves an
approximate timing pick from it.
“Today” is the customer’s own calendar day, not UTC’s. The chat client sends the browser’s resolved
time zone on every /api/chat body, and the route keeps it only when Intl accepts it
(acceptedTimeZone in shared/period.ts). A rejected value is dropped without failing the turn.
The kept zone reaches the tools on RegisterToolContext.timeZone, never as a tool input, so the model
cannot choose it. With no usable zone, which is always the case for the server-rendered opening, the
customer tools use the tenant’s timezone setting when an admin set it, and UTC otherwise (never the
registry default). A failed settings read also falls back to UTC. The returned today, the
past-start check at draft and the same check at confirm all use that one day.
The requirement document card’s dates use a different zone: the workspace’s. Its timing row is
formatted in the tenant’s timezone setting, or the platform default (PLATFORM_TIME_ZONE) when
none is set or the read fails. That rule has one home, displayTimeZoneOf
(db/services/tenant-setting/index.ts), which tenantSettingService.getDisplayTimeZone applies and
apps/web’s getTimeZoneForTenant calls. This is so the card and the workspace’s BRD rows print the
same day for the same lead. buildRequirementDocument requires the zone, and the demo seed stores
its cards in the same one.
Both zones come from one read of the timezone setting per turn. The customer tool set holds that
read once, and the first tool that needs either zone starts it. Each tool starts its zones beside
its first read after the request is matched, and waits on them only where a date is printed or
checked. A turn that refuses or writes nothing does not wait for the card’s zone.
customer_confirm_requirements is the one tool that reaches tenant data the platform already owns,
and it is needsApproval: true. It takes no document content — only which request it means — and
submits the stored draft through leadService.approveBrd, the same path the requirements page takes.
So the model cannot submit words the customer never saw, and the write happens only after they
authorise that specific call. It refuses first, without calling the service, while any of the
document’s nine rows is open (brdCompleteness in shared/brd-completeness.ts) — judged on the drafted sections and the request’s rows under the
drafted ones — and names the open rows; customer_draft_requirements returns its confirm actions only
on a complete draft. It then checks the drafted rows again, with the same rules
customer_draft_requirements used and against today’s lists: the tenant’s active industries, its
active regions and today’s date (draftedRowsInput → resolveRequestRows in customer-rows.ts). A
row that no longer holds — a start date that has passed, an industry archived or a region renamed or
retired since, a country moved out of the drafted region — refuses the whole confirm. Nothing is
submitted, every such row is named, and those rows only are removed from the draft
(brdDraftService.clearRows), so they read as open again and the assistant asks for them. When every
row holds, the rows as just checked are what approveBrd writes. approveBrd applies the same rule to the document it would record and throws
before any write, on first and repeat approvals alike. The demo seeder approves through
recordBrdApproval, below that gate.
What that path writes is a row in brd_versions — the approved document’s own tenant-scoped
collection, one numbered version per approval, which the lead points at rather than holds — plus the
drafted rows, set on the lead in the same write as the approval (requestRowsWrite in
leads/brd-review.ts). A first approval’s write lands only on a lead not yet approved, so of two
overlapping confirmations of one request — a retried or double-confirmed call — one opens the bid
pool and the other is recorded as a revision of it, with no second status step, hand-off or match
run. The draft
above is discarded once it becomes a version. Neither collection touches the lead’s own
description: that is the customer’s account of their request, and the platform keeps it as they
wrote it.
Nothing in an answer performs it. An actions row holds prompts and platform links only
(AnswerActions carries a label, or a label and a path, and has nowhere to hang a payload), so
“Yes, this looks right” is a follow-up prompt like any other —
it makes the register ask, and the authorisation card is where the decision is made. The question on
that card is the tool’s own title, so the chat surface renders it without learning what tools exist.
The route carries the verdict and nothing else. POST /api/chat accepts approvals: [{id, approved}]
in place of a message; the paused turn is read back from the store, the matching part is resolved
there, and the turn resumes. A caller therefore answers a call the assistant itself minted and cannot
supply the call — an id matching nothing pending resolves nothing and is answered 409. The resumed
turn continues the paused message rather than writing a new one, so the transcript is not stored
twice.
A card nobody answered is closed, not left open. The composer stays usable while a card is
pending, so a customer can type past it — and a tool call with no result followed by a user message
is a transcript the model refuses, which would leave the thread answering nothing until the call aged
out of the replay window. Before replay, every turn that is not itself the answer (and, on a
resumption, every turn before the one being answered) closes any still-pending card as denied, with
a reason that says only that no answer is on record — not “declined”, and not “nothing was
changed”, because after a lost save the call may have run. The closure is written back to the stored
turn once (a lost write is recorded as assistant message write / write-lost and the turn is still
answered), and the card then reads “This wasn’t answered, so the conversation moved on without it.”
with no buttons. The rule lives in lib/ai/assistant/unanswered-approvals.ts.
Spend envelope
/spend is the tenant’s own view of what it is spending on the assistant in a chosen month, by the
agent that spent it, with a six-month trend and a month picker anchored to today. ?period=YYYY-MM
selects the month; months are whole UTC calendar months, because spend is reconciled against the
gateway that way.
Tenant admins, and nobody else. The rule itself is holdsSpendEnvelope
(@sustentus/services/shared), and it is a role rather than a permission on purpose: the
permission registry’s nearest neighbour, tenantConfig.view, is held by the CSM and SDM templates
too, and this page refuses both. It has one home because four surfaces ask it and must never
disagree — the shell decides whether to render the link, the page decides whether to answer, the
admin register’s tool set decides whether to mount, and its rail reader decides whether there is a
meter. A link rendered for someone the page would refuse is a dead end; a rail figure shown to someone
the page would refuse is an access-control gap. canReadSpendEnvelope (lib/spend-access.ts) is the
app’s half: it turns a resolved PlatformContext into the role the rule takes, and narrows the type.
A staff or partner caller working inside a tenant is deliberately refused — reach is not power, and
the cross-tenant view they are entitled to is the console’s estate view. Every refused caller sees the
same screen, which says who the envelope is for rather than confirming anything about this tenant’s
spend.
It was the first admin-register surface, and it is now the full view behind one: the admin
register speaks the same figures in the rail and in an answer, and links here for the breakdown, the
trend and the picker. That is why its numbers were already shaped as claims — the population, the
period and the basis stated out loud — and it is the shape the register inherited.
The register measures the month against the tenant’s own last month, because nothing else exists to
measure it against. No spend limit is recorded anywhere on the platform: the meter’s denominator is
the most recent month in the trend that carries metered spend, and every sentence around it says so
rather than calling it a budget. The forecast is arithmetic on two numbers shown on screen — spend so
far divided by the days elapsed, today included and part-way through — and it states that basis
wherever it appears. Beside it, admin_agent_consumption breaks the month down per register with the
share, the turns, the tool calls per turn and the failed-or-refused count, over the same accumulators
this page’s own breakdown is folded from, so the two cannot disagree.
Availability and the kill switch
GET /api/ai/availability reports whether the gateway can serve — ok, degraded, unknown or
unavailable, and nothing else. The conversation asks once on mount, so a credit outage is visible
before a user spends a turn discovering it. degraded warns and deliberately does not block: the probe
is evidence about a moment ago, and the gateway may well serve the next turn. unavailable is not
evidence at all — staff have switched the assistant off, so every turn would be refused and the
composer is disabled rather than left to waste what the user types. unknown renders nothing.
The response is deliberately coarse. The credit balance, the account tier and any gateway error text are account-internal; which switch is off is withheld on the same principle, because a tenant user has no business knowing whether the platform or their own workspace was singled out. The detail stays in the server log and in the console.
- The kill switch is answered first and short-circuits the probe. Staff can turn the assistant off
for one tenant or platform-wide from the console; the read is live and uncached on every turn, so a
flip binds on the very next request with no deploy. It sits before the rate limiter, the store and
the gateway, because nothing a refused turn would otherwise spend should be spent first. The chat
route answers
503with one honest sentence — a temporary stand-down of a service, not a judgement about the caller. - The probe is cached for 60 seconds and reads the gateway’s credit balance rather than spending a
model call to find out. A balance above zero is the only
ok; a reading that cannot be parsed, and a probe that throws, areunknownand render nothing — not understanding the balance is not evidence that AI is down. - The chat route’s rate limit (
@vercel/firewall, keyed by the signed-in user) and the kill-switch read both fail open. A brake that cannot be consulted must not refuse the request: this is a spend brake, not an access control, and the AI Gateway project budget is the hard backstop. The WAF rule the limiter names is created in the Vercel dashboard, not in code, so the route is correct either way — it is recorded with the platform’s other rules under Deployment → Firewall rules, which also says why the embedded lead form’s rule is enforced at the edge instead of consulted from a route.
See AI Gateway for the account, the balance and the smoke-test procedure.
The services boundary
| Entrypoint | Where it may be imported | Holds |
|---|---|---|
@sustentus/services/ai | Anywhere | The connector: defineAgent, model constants, generateStructured, tenant-tool primitives, turn telemetry |
@sustentus/services/shared | Anywhere, edge included | Pure isomorphic code: the role enum, claim helpers, the console-access marker, the answer-part contract |
@sustentus/services/server | Server-only files | Tenant resolution, the domain services, the server-only tenant tools |
@sustentus/services/db | Nowhere — go through /server |
/server carries Mongoose and process.env reads, so importing it from a client component breaks the
build. Agent logic is this app’s, and lives in lib/ai/ — lib/ai/assistant/ is the worked
example, built on the connector’s defineAgent. The brd/ and onboarding/ agents still in
packages/services are apps/web’s legacy surfaces, frozen there rather than treated as the pattern
to copy.
Prompt text is not code. An agent here takes its instructions as a parameter and the chat route
assembles them per request with promptWorkspaceService.assembleForRegister, from versioned markdown
staff publish in the console’s prompt workspace —
so what the assistant says changes with a publish and not a deploy. The version set behind each turn is
read before the turn runs and stamped on its run record, so a publish landing mid-turn cannot change
what that run is stamped with.
UI comes from @sustentus/ui — the shared AI primitives, the answer-part
components, the trace rail and the spend report included.
Data and configuration
The app reads MongoDB through @sustentus/services/server, so it needs MONGODB_URI and
MONGODB_DATABASE_NAME alongside the Clerk keys and AI_GATEWAY_API_KEY. PLATFORM_APP_URL (the
header’s link back to the platform, and the hand-off’s Back to your request) and
AGENT_AUTHORIZED_PARTIES are optional and both fail quiet when unset — the links are simply absent,
and the allowlist buys nothing until a value exists. The other direction is AGENT_APP_URL on the
web project: the platform’s hand-off to /start is offered only when it names this app’s origin.
These are runtime reads on the agentic-interface Vercel project. Declaring them in turbo.json →
globalEnv governs what turbo passes into build tasks under strict env mode and populates nothing at
request time, so being set on web does nothing here. Read them with vercel env ls rather than
inferring them from a working page: the misconfigurations are quiet by design.
Deployment carries the full list and the per-scope rules.
Running it
pnpm agent:dev # port 3007
pnpm agent:buildpnpm agent:dev is backed by the @sustentus/agent#dev task in turbo.json — a dev script without
that task resolves to an empty graph and silently does nothing. vercel.json carries the standard
turbo-ignore command, so a commit that does not affect @sustentus/agent skips the build; the
project’s commit status is Vercel – agentic-interface.