Demo Environment
The Sustentus demo environment provides a fully functional preview of the platform with pre-populated sample data. This guide covers setup, configuration, access, and usage of the demo environment.
Overview
This page covers how the demo environment is provisioned, accessed, reset and presented. For what is actually in it — every fictional company, person, engagement and number — see Demo tenant data.
The demo environment is a special tenant instance marked with isDemo: true that allows users to explore the platform’s features with realistic sample data. It includes:
- Pre-populated data: Leads, chats, proposals, quotes, invoices, CSAT records, activities, and more
- Multiple user roles: Admin, Manager, CSM, SDM, Expert, and Customer perspectives
- Purge-and-reseed reset: Returns the tenant to the approved storyline between client sessions
- Full feature access: All platform capabilities available for exploration
Setup
Demo Tenant Configuration
Demo tenants are identified by the isDemo: true flag in the Tenant model. This flag enables:
- Demo data seeding
- Purge-and-reseed reset (the flag is what the reset refuses without)
- Presenter persona switching
- Special admin controls
Provisioning runbook
The canonical sequence for standing up the demo tenant in production. Order matters: the tenant document is created lazily on first sign-in, and the flag migration fails loudly if the tenant does not exist yet.
-
Create the demo Clerk organisation in the production Clerk instance. Note its organisation ID (
org_…) — it becomesDEMO_TENANT_CLERK_ORG_ID. -
Check the organisation’s role keys and seat limit.
pnpm demo:roster(step 9) adds twenty members, so before it runs, in this Clerk instance:- Organizations → Roles must define
org:admin,org:csm,org:sdm,org:vendor,org:expertandorg:customer. Onlyorg:customerexists by default — hence theCLERK_ORG_CUSTOMER_ROLEoverride for that one. A missing role key is the first of the two ways Clerk rejects provisioning, and the script names it in the error rather than leaving you a bare 422. - The organisation’s maximum membership count must exceed twenty.
- User & authentication must offer at least one non-password sign-in method (for example email magic link), so accounts can be created without a password. That is the second way Clerk rejects provisioning.
Both Clerk instances need this — preview and production are separate instances, and the roster is provisioned into each independently.
Dormant accounts cost nothing: Clerk bills monthly actives, and opening a seat on a demo host signs nobody in to Clerk (see Seat sessions) — it reads the seat’s membership and adds no monthly active.
- Organizations → Roles must define
-
Create the admin Clerk user by hand, with the
org:adminrole, and note its address. This is the only account created manually — it exists to bootstrap the tenant document, which does not exist until somebody signs in. It is the presenter’s login, and it is also the account the demo host’s entry signs visitors in as (see Automatic sign-in below) — so note its Clerk user id too, not just its address.Fictional identities only — never real prospect or client names. Set the name in Clerk exactly as the active storyline names that seat: Clerk owns identity from here, and the
user.updatedwebhook mirrorsfirstname,lastname,emailandavataronto the app-user row. The roster script sets these on every account it creates, so only this hand-made one has to be typed correctly.Persona Name Role in the storyline admin Alex Rendell Presenter; administers the tenant csm Sofia Almeida CSM — owns customer relationships sdm Ben Hartley SDM — owns delivery expert Anders Lindberg Expert — integration architect, Nordlys Digital (London) vendor Maya Mercer VP Customer Success & Services — the seat reads “Orrery Software · Maya Mercer” customer Hannah Price Head of Sales Operations, Brightline Events — the anchor account The table names the six seats a walkthrough visits; the roster is twenty, the other fourteen being the storyline’s remaining customers and experts.
The exact sign-in addresses are deliberately not published on this page — read them from the storyline (
packages/services/src/db/services/demo-data/storyline/saas-vendor.ts, exported asdemoStorylinefromstoryline/index.ts). Every address follows one convention,jamie.nisbet+<seat>.<role>@sustentus.com, androsterOf(demoStoryline)(storyline/roster.ts) derives the twenty from the storyline’steam,accountsandexperts— there is no second list to keep in step. The addresses predate the people: the twenty seats keep their local parts when the world behind them changes, so the local part is the seat’s name, not the current person’s surname —+vosis Hannah Price’s seat and+lindnerAnders Lindberg’s. When a storyline renames the person on a seat,pnpm demo:rosterrenames the Clerk user and the app-user row to match (it reports every rename under--checkfirst); until it runs, Enter the demo shows the previous names on those seats. -
Sign in once as the admin on
platform.sustentus.com. This is what creates the tenant document (tenantService.upsertFromClerk) — nothing else does, which is why one manual sign-in survives. Verify: no/no-role, no onboarding trap. -
Set
DEMO_TENANT_CLERK_ORG_IDto the org ID from step 1 as a variable on the GitHub Actions production environment — that is where migrations run (db-migrate.yaml), not Vercel; no app-runtime code reads this variable today. It must be set before the flag migration merges: an unset variable makes the migration no-op, and ts-migrate-mongoose still records it as applied, so it will not re-fire on a later deploy. (CI-only, so deliberately not inturbo.json→globalEnv— the migration runner never goes through a turbo task.)The preview environment carries its own copy of this variable, set to the preview instance’s own demo org. The two are different orgs, and each migration job resolves only its own environment’s value.
-
Merge to
main. The migration1786492800000-flag-demo-tenant.tsruns viadb-migrate.yamland setsisDemo: trueon the tenant matching the configured org ID. It is idempotent, and fails loudly if the tenant is missing — re-check step 4 if it does (the sign-in is what creates the tenant), then re-run the job (workflow_dispatch); a failed run is not recorded, so the re-run applies it. Note that while it stays failed, later pending migrations from unrelated merges are held up behind it.A second migration,
1786579200000-reflag-demo-tenant-preview.ts, re-asserts the same write. It exists because the original had already been recorded as applied against the preview database while the variable was still production-only, and an applied migration never re-fires. Configuring a new environment from scratch needs no thought about this: both simply run in order. -
Verify the flag:
tenantService.isDemoTenant(tenantId)returnstrue(or check thetenantscollection directly). -
Provision the roster, with
CLERK_SECRET_KEYpointed at this environment’s Clerk instance. This comes before the first seed: the seeder attaches every seat to a real Clerk-backed user and creates nobody, so it refuses outright on a tenant whose roster is not yet in place.pnpm --filter @sustentus/services demo:roster --check # report, write nothing pnpm --filter @sustentus/services demo:roster # provisionThis replaces what used to be twenty hand-made Clerk accounts and twenty sign-ins per instance. For each of the twenty seats it resolves or creates the Clerk user, ensures organisation membership with the mapped
org:<role>, and writes the app-user row — without anyone signing in, which is the point: before this, a sign-in was the only thing that turned a Clerk account into an app-user row, so fourteen of the twenty could not be viewed as at all.Order no longer matters beyond this. The seeder writes each person’s storyline attributes — a customer’s company, country and industry; an expert’s position, bio, skills and products — on every run rather than only at creation, so a row the roster made first is dressed by the next seed rather than left bare. Identity stays Clerk’s: no run writes anyone’s name, email or avatar.
Re-running is always safe: a second run reports every seat already present and writes nothing. A name corrected in the storyline is pushed to both Clerk and the row on the next run, so the roster stays the one list nothing drifts from.
It refuses rather than half-provisioning: a tenant not flagged
isDemo: true, or noCLERK_SECRET_KEY, stops the run. With more than one demo tenant it asks for--tenant <id>.--checkalso names anyone in the organisation who is not on the roster — which is how the supersededdemo+…@apoyar.euaccounts show up. Removing those from Clerk is a manual tidy-up; their app-user rows carry aclerkUserId, so the demo reset keeps them and they linger in the directory until deleted by hand. -
Run the first seed signed in as the demo admin: Configure > Demo data (
/admin/demo-data) → Populate demo data. This fills the tenant with leads across all nine stages and dresses each roster member with their storyline attributes (see Populating Demo Leads). Ifdemo:roster --checkdid not report clean at step 8, this refuses before writing anything and names the seats it could not fill.
Access
Demo Environment URL
The demo tenant is served by the apps/web deployment at two addresses:
https://platform.sustentus.com # the ordinary platform host
https://demo.sustentus.com # the demo hostBoth serve the same deployment. Only the front door differs: on the demo host an
unauthenticated request lands on the public demo entry page at /demo instead of
/sign-in, and demo-host responses carry X-Robots-Tag: noindex, nofollow so the address
stays out of search results. Once a session exists the two hosts are identical — role
resolution, route policies and the onboarding gates all behave the same, because the
host is read only before the sign-in check.
Which hostnames count as the demo host is configuration rather than a literal: DEMO_HOSTS on
the apps/web project is a comma-separated allowlist. Unset, no host is a demo host and both
addresses behave exactly as the platform host always has.
On a preview deployment — whose URL is minted per commit and can never be in the allowlist —
append ?demo=1 to any path to put the browser on the demo branch, and ?demo=0 to take it
off again. The override is a cookie, and it is hard-off in production: there, allowlist
membership is the only thing that makes a request a demo request.
Standing the demo host up
Once per Vercel/Clerk environment:
- Add
demo.sustentus.comin theapps/webproject’s Settings > Domains and configure DNS. - Set
DEMO_HOSTS=demo.sustentus.comin that project’s Production environment. - Redeploy.
DEMO_HOSTSis read when the app is built — thenext.config.tsheader rule bakes it into the routing manifest — so changing the variable alone does nothing until the next deployment. This is the one way the rollout can look broken while everything is correct. - Confirm the production Clerk instance’s allowed origins include the new host, if that list is restricted. Nothing else in Clerk changes: session cookies are shared across subdomains of one root domain, and satellite domains apply only to a different root domain.
Because that cookie is shared across sustentus.com subdomains, a session started on either host
is visible on the other.
The access gate
The demo host is dark. A request that has not passed the gate sees one thing — a challenge asking for an email address, and then for the six-digit code emailed to it. Not the entry page, not the app, not which paths exist behind it.
That is a stronger control than the Clerk sign-in guarding platform.sustentus.com, and it has to
be: on the demo host there is no password to be wrong. The sessions waiting behind the door are the
demo tenant’s people, its admin among them, so what needs protecting is the whole host.
A curated roster — the internal team plus named partners — is a list of email addresses. What proves identity is possession of the mailbox: enrolling someone is adding their address, and the code that admits them is minted fresh each time they ask. There are no per-person secrets.
How it is composed
| Piece | What it is |
|---|---|
| The roster | DEMO_ACCESS_ROSTER — a comma-separated list of email addresses, normalised to lowercase and unique |
| The request | /demo/access posts the claimed address to POST /api/demo/access/request, which mints a six-digit code and emails it (Resend) |
| The challenge | a host-only cookie holding the address, a nonce and an HMAC of the code — never the code — valid ten minutes, burned after five wrong tries |
| The answer | POST /api/demo/access compares the code constant-time against the challenge and re-checks the roster |
| The cookie | address + expiry, signed HMAC-SHA-256 with DEMO_ACCESS_SECRET; HttpOnly, Secure, SameSite=Lax, twelve hours |
| The hop | one signed token per other gate host, sent by the code screen in the background to POST /api/access-gate/hop — see below |
The gate itself lives in @sustentus/services/shared → access-gate (Web Crypto only), shared
with the console; apps/web/lib/demo-access.ts keeps only the demo host’s paths and its reads of
the environment. Every signed value — the pass, the challenge, the hop token — is signed over its
kind and its payload together, so no kind can be relabelled as another.
Both cookies are host-only — set with no Domain attribute, so they are never sent to
platform.sustentus.com, which shares the parent domain. Verifying the gate cookie also re-checks
the roster, so taking someone off ends their access rather than waiting out their cookie. The gate
keeps no server-side state, which is what lets the same check run on the edge in proxy.ts.
Enforcement happens twice, because a route handler must not trust the layer in front of it.
proxy.ts rewrites every ungated demo-host request to the challenge — a rewrite rather than a
redirect, so the visitor keeps the URL they asked for and the host discloses nothing by the address
bar either — and answers /api/** with a bare 404. /api/demo/seat then re-checks the gate for
itself, as its second refusal (not_gated, below).
The self-authenticating public routes pass the gate untouched: /api/cron/** and
/api/webhooks/clerk/**. demo.sustentus.com and platform.sustentus.com are two domains on one
Vercel project, so a cron invocation can arrive carrying either Host — and the nightly demo reset is
itself a cron. Each of those routes checks its own CRON_SECRET bearer or svix signature, so
letting them past widens nothing. /api/health passes too — the post-merge health probe, which
answers only a status word from a MongoDB ping and so has nothing to protect.
The gate never says who is on the roster. Asking for a code returns the same { ok: true }
and the same challenge cookie whether or not the address is enrolled; only an enrolled address is
actually sent mail, and the send runs after the response has gone, so timing gives nothing away
either. Answering then refuses an unenrolled address, a wrong code and a replayed code with the
same invalid, at the same status, in the same time. expired is the one distinguishable
refusal, and it says only that the challenge ran out.
Guessing is bounded three ways, per warm instance: ten requests per five minutes per client address on each route, three codes mailed per fifteen minutes per address, and five answers per five minutes per address — the last charged against the address the challenge was issued to, so a distributed grind against one person is bounded too. An accepted code cannot be replayed.
One code opens console, demo and explore
The gate hosts are every host in DEMO_HOSTS and the console host in CONSOLE_APP_URL — never
platform.sustentus.com. A code answered on any of them opens the others for the same twelve
hours, and the browser never leaves the host where it was answered:
- The answer route sets that host’s pass and returns one hop token per other gate host — signed, naming that host alone, good for sixty seconds and once, carrying the address and the pass’s expiry.
- The code screen sends each token in a background request (
credentials: "include", the token as a plain-text body — never in a URL) to that host’sPOST /api/access-gate/hop. - The hop route answers only a page on another gate host, over https — its CORS allowance names that origin alone, with credentials — and sets its own host-only pass with the same expiry. A valid pass it already holds for someone else is kept; one for the same person is replaced by a pass that outlives it.
- The screen waits for every request, or three seconds, then goes where the visitor aimed. A host that is down, unconfigured or refuses changes nothing for the visitor.
On a preview or a ?demo=1 override the answering host is not a gate host, so no hop is minted and
the pass stays on that host, as before. The hop route is rate-limited per client address and
refuses a body over 2 KB. Because the platform and console projects both sign with
DEMO_ACCESS_SECRET, they must hold the same value: rotating it is a change to both, and a
mismatch only stops the hop — each host’s own gate keeps working.
It fails closed, and it needs a redeploy
Two properties are worth stating plainly, because both invert what the rest of this page would lead you to expect.
Unset means nobody, not everybody. DEMO_HOSTS and DEMO_AUTO_SIGN_IN are written so an unset
variable behaves as though the feature never existed. Here “as before” would mean the demo host wide
open, so an unset, empty or malformed DEMO_ACCESS_ROSTER or DEMO_ACCESS_SECRET admits nobody
and the challenge says the environment is not available. A single malformed or duplicated address refuses
the whole roster rather than dropping a line — a half-parsed allowlist is what nobody notices until the
wrong person is inside.
Changing the roster takes a redeploy. proxy.ts runs on the edge, where Next inlines
process.env at build time — the same reason step 3 of the runbook above says DEMO_HOSTS needs
one. So enrolling someone, removing someone, and rotating DEMO_ACCESS_SECRET as the emergency stop
are all redeploys, not saves. If you need the host shut now, unsetting DEMO_HOSTS is the faster
lever.
Enrolling and removing people
pnpm demo:roster (from the repo root) manages the gate’s roster. Because one malformed or
duplicated address refuses the whole roster, it validates with the app’s own parser and always
prints the complete replacement value rather than a fragment to splice in.
pnpm demo:roster list # who is on the roster
pnpm demo:roster check # would the app accept the current roster?
pnpm demo:roster add <email...> # add people, keeping everyone already on it
pnpm demo:roster remove <email...> # drop people, keeping everyone else
pnpm demo:roster secret # mint a fresh DEMO_ACCESS_SECRETIt reads the current roster from --roster "<value>" or $DEMO_ACCESS_ROSTER; copy the value out
of the apps/web project in Vercel and paste the printed replacement back in. Sending the codes
needs RESEND_API_KEY and EMAIL_FROM on the same deployment — a roster that parses with no mail
sender tells everyone to check an inbox nothing arrives in, and the request route logs loudly when
the send fails.
This is the gate’s roster of people allowed onto the host. It is not the twenty storyline
seats, which pnpm --filter @sustentus/services demo:roster provisions in Clerk (see the runbook
above).
Then redeploy, per the rule above.
Automatic sign-in
On the demo host the entry page, /demo, opens the vendor seat straight away — the demo
roster’s vendor, the first seat of the walk — and lands them on the vendor’s home. There is no
control to press: /demo redirects to the seat-switch page (/demo/switch?seat=vendor), which runs
the same seat swap every later seat change uses, described next. Passing the access gate lands on
/demo, so a visitor goes from the emailed code straight to the vendor’s dashboard.
The browser’s own Clerk session — a staff member signed in to console — is left alone and never read on a demo host. A visitor already holding a seat on this host is sent to that seat’s home instead.
Any of the twenty roster people can also be opened by link: /demo/switch?seat=<seat name> — for
example /demo/switch?seat=lindner for Anders Lindberg — names the person while it switches, opens
them and lands on their role’s home, or on to when the link carries a path on this origin. The six
persona names keep working on the same page and open the same person they always have. A value
that is neither is sent back to /demo.
Seat sessions
On a demo host the seat — which of the demo’s people the visitor is — is the host’s own record, never a Clerk sign-in. The browser holds one Clerk session for every sustentus.com host, so a seat that signed in to Clerk signed console and the other demo host out on every change; console, demo and explore now open side by side in three tabs without touching each other.
The record names a real roster user in the demo organisation and states them exactly as their Clerk session would: their Clerk user id, the demo organisation, their org role, their extra granted roles and their profile. The platform sees the person exactly as it would if they had signed in: tenant resolution, route policies, permissions and canned agent mode all behave as they do for that seat anywhere. The twenty seat accounts stay in Clerk — they are what the record names and what the data is keyed on.
The seat record
sustentus_demo_seat is a cookie signed with the gate’s secret (DEMO_ACCESS_SECRET) under its
own kind, s1 — the signature covers the kind, so a seat can never be read as a gate pass,
challenge or hop, nor one of those as a seat (signSeatRecord / readSeatRecord in
packages/services/src/shared/access-gate.ts). It is host-only (no Domain), httpOnly,
secure and sameSite=lax, so demo and explore each keep their own seat and neither reaches console
or the platform host.
A request holds a seat only when all of these hold; otherwise it holds none and is treated as signed out, whatever Clerk session the browser carries:
- the signature verifies;
- the record was set on this host (compared without the port);
- the request carries a valid gate pass for the same address, roster re-checked;
- the record has not expired — it expires with the gate pass it was set under, at most 12 hours;
DEMO_AUTO_SIGN_INis"1"— the kill switch ends open seats, not only new ones.
Rotating DEMO_ACCESS_SECRET ends every seat with every pass. getIdentity()
(apps/web/lib/identity.ts) and the proxy read it through apps/web/lib/demo-seat-record.ts on a
demo request and only there; off a demo host both read Clerk exactly as before. On a demo host the
proxy’s “org-less staff → console” redirect never fires.
The seat route
POST /api/demo/seat with { "seat": "<seat>" } opens a seat: it reads the person’s membership of
the demo organisation from Clerk (one call — their org role, extra granted roles and profile), signs
the seat record and sets it, and answers the role of the person it opened. A seat is one of the
twenty people in rosterOf(demoStoryline), named one of two ways:
- A seat name — the person’s own key: the tag of their sign-in address with its
.<role>suffix removed, sojamie.nisbet+vos.customer@sustentus.comisvosandjamie.nisbet+lindner.expert@…islindner(seatNameOfinapps/web/lib/demo-sign-in.ts). It is derived from the roster on every request — there is no second list — and survives a storyline rename because the address does. It matches exactly:Vos,vos.customeror a full address is no seat. - A persona name (
admin,csm,sdm,expert,vendor,customer) — an alias for the first roster entry holding that role, the ruleapps/e2ewalks by, so the customer is Brightline’s contact (vos) and the expert is the one whose bid is selected (lindner). The guided walk, Enter the demo and the persona switcher all change seat this way.
A persona name is checked first; no seat name equals one, which a unit test pins. The vendor
seat’s label reads company first, person after (Orrery Software · Maya Mercer). The roster
email is then looked up inside the tenant DEMO_TENANT_CLERK_ORG_ID names, and the seat is
opened for that row’s clerkUserId. It opens the roster only, in the demo tenant only, on the demo
host only.
DELETE /api/demo/seat leaves the seat — the account menu’s Sign out. It clears this host’s seat
and nothing else; the gate pass stays, and the entry page then opens the vendor seat again.
The route is part of the demo host’s front door (isDemoEntryPath): the proxy lets it through with
no seat and with any seat, and on the platform host it falls through to the ordinary sign-in
redirect and deny-by-default policy table. Only POST and DELETE are exported, so every other
method is answered with 405, and both refuse a request whose Sec-Fetch-Site is not same-origin
— the answer sets a cookie, so another sustentus.com page must not be able to force it. Six
refusals guard the POST, all decided before Clerk is called:
| Refusal | When | Response |
|---|---|---|
not_demo_host | the request is not a demo request, or another site’s page sent it | 404 |
not_gated | the access gate above has not been passed | 404 |
disabled | DEMO_AUTO_SIGN_IN is not exactly "1" | 404 |
not_configured | DEMO_TENANT_CLERK_ORG_ID is unset, or no tenant carries it | 503 |
unknown_seat | no seat in the body, a value that is neither a persona nor a seat name, or no Clerk login for it in the demo tenant | 404 |
rate_limited | more than 30 seat changes a minute from one client address | 429 |
A failed Clerk call, or a person who is not a member of the demo organisation, answers 502 and leaves the seat the visitor held as it was. The rate limit is checked last, so a refused request never spends a visitor’s allowance.
The rate limit is an in-memory token bucket held per serverless instance, thirty a minute per client address. Paul’s walk changes seat eight times end to end (vendor → CSM → customer → SDM → CSM → SDM → customer → vendor), and Previous replays a swap, so a presenter never meets it. It resets on a cold start, and a burst spread across instances gets one allowance each — a brake, not a guarantee. Every seat opened costs one Clerk API read. Move it to a shared store when there is real traffic to measure.
Each seat opened writes one server log line, [api/demo/seat] opened, naming the person by seat
name and role — the seat name even when the visitor asked by persona — and the email address that
answered the gate. It never carries the cookie or a Clerk id.
The swap
swapSeat(seat, to?) (apps/web/lib/demo-seat.ts) is the one client function that changes who the
visitor is. It posts to the seat route — a refusal ends there and the visitor keeps the seat they
had — and then navigates once, a full page load, to to when it is a path on this origin,
otherwise to the home of the role the seat route returned. Nothing in it touches Clerk. The
seat-switch page runs it on mount; a swap that fails logs the cause to the browser console and the
page offers Back to the demo.
The account menu and settings on a demo host
The sidebar’s account control on a demo host is drawn by the app, not Clerk: the seat’s Clerk name
and avatar, a Settings dialog with the theme and notification preferences, and Sign out
(above). The seat’s Clerk account itself is never editable from a demo host. On /settings the
organisation panel shows the demo organisation’s name and logo, read-only, in place of Clerk’s
OrganizationProfile — which reads the browser’s Clerk session and so cannot show the seat’s
organisation. Off a demo host both stay Clerk’s.
What a seat leaves behind
Opening a seat signs nobody in to Clerk, so it adds no Clerk session and no monthly active. Browsing
writes nothing on the platform, but a seat that acts writes what that persona can write: agent
threads owned by the seat, notification read-state, customerLastActiveAt when a customer opens a
hand-off. The nightly reset converges all of it; the pre-demo reset in
the client-session runbook covers a same-day free-rein visit. A visitor
leaves holding the last seat they opened on that host until the gate pass ends; the entry page sends
them back to it.
Until the agent hand-off carries the seat (agent-seat-per-tab), a customer seat that hands off to
the agentic app arrives there as the browser’s own Clerk session — the staff member, or nobody —
not as the seat.
Configuration
Two variables configure it, both on the apps/web project and both declared in turbo.json →
globalEnv:
| Variable | Purpose |
|---|---|
DEMO_AUTO_SIGN_IN | the kill switch — off unless exactly "1" |
DEMO_TENANT_CLERK_ORG_ID | the demo organisation (also read by the flag migrations, in CI) |
DEMO_ADMIN_CLERK_USER_ID is retired: the admin is a roster seat like the others. A value
left on Vercel is inert; unset it at leisure.
With the switch off, or the organisation unset, the entry page shows its placeholder card saying the demo is not open on this deployment, the route refuses and no open seat is honoured — which is exactly how every environment behaves until it is configured.
What keeps this safe
The admin seat is an ordinary platform admin, and admin short-circuits to allow-all. Two of its powers reach outside the demo tenant: it can send Clerk invitations granting permanent organisation membership that the nightly reset does not revoke, and it can switch the AI agent from canned to live and spend real gateway budget.
The answer to that is who reaches the host at all, not what the session may do once it is there.
The access gate is what keeps the door shut: everyone behind it is a named
person on the roster, so the admin seat keeps every affordance it has. DEMO_AUTO_SIGN_IN is a kill
switch, not the lock — enabling it in Production is safe once the gate is configured there.
Enable the two together, gate first. DEMO_AUTO_SIGN_IN on with DEMO_ACCESS_ROSTER unset is
harmless — the gate fails closed, so nobody reaches the entry page — but the reverse ordering is the
one to avoid thinking about at all: there is no ordering that opens the host, because the gate is in
front of everything.
The same reasoning covers Preview. Preview environment variables apply to every preview
deployment and branch aliases are deterministic, so a preview with DEMO_AUTO_SIGN_IN set and no
DEMO_ACCESS_* is dark rather than open — which is the fail-closed rule doing its job. To smoke the
full flow on a preview, set the gate’s variables and these two there for the duration and unset
them afterwards.
Walk mode
A demo host runs one of two modes, and which one is a setting on the deployment — never a per-visitor or per-URL switch, and neither mode has an Exit walk:
- Walkthrough — the guided walk over LEAD-001’s journey; the walk changes seat at each step. The persona switcher does not render.
- Free rein — no walk. A persona switcher at the bottom right of the shell opens any of the twenty roster people as the visitor’s seat.
The setting
DEMO_WALK_MODE on the apps/web project, declared in turbo.json → globalEnv and read by
apps/web/lib/demo-walk-mode.ts alone — the shell asks it whether to render the switcher, and the
walk asks it whether to mount. It is keyed by host, because Vercel sets a variable per
environment rather than per domain and both modes can live on the one project (demo.sustentus.com
for the walk, explore.sustentus.com for free rein). The value is a comma-separated list of:
- a bare mode —
walkthroughorfree-rein— the default for every demo host not named; <hostname>=<mode>— that host’s mode.
DEMO_WALK_MODE | demo.sustentus.com | explore.sustentus.com | a preview (?demo=1) |
|---|---|---|---|
| unset or empty | free rein | free rein | free rein |
walkthrough | walkthrough | walkthrough | walkthrough |
demo.sustentus.com=walkthrough | walkthrough | free rein | free rein |
walkthrough,explore.sustentus.com=free-rein | walkthrough | free rein | walkthrough |
Unset, empty or unrecognised reads as free rein — the DEMO_HOSTS convention, so an
environment that has never heard of the walk shows no bar. Hostnames compare case-insensitively and
without the port; whitespace is ignored; an entry that is neither a mode nor host=mode is skipped;
a named host with an unknown mode reads free rein; on a repeat, the first entry wins. The mode means
something only on a demo request — on the platform host neither the walk nor the switcher renders,
whatever the value.
Like DEMO_HOSTS, the value is read at request time on the server, so a change takes a
redeploy to reach a running deployment.
Production turns the walk on once it has merged. Unset, demo.sustentus.com runs free rein and
has the switcher. With the walk on main, set demo.sustentus.com=walkthrough on the
production environment and redeploy. explore.sustentus.com needs no entry of its own — an unnamed
host reads free rein.
The persona switcher
A floating control fixed to the bottom right of the app shell, in a neutral presenter register
(apps/web/components/demo/persona-switcher.tsx, rendered by
apps/web/app/(app)/layout.tsx). It always names the person signed in, with their persona as a
badge, and opens to all twenty roster people in three groups, each in roster order
(listSeatGroups in apps/web/lib/demo-sign-in.ts):
| Group | People | Each row’s second line |
|---|---|---|
| Orrery Software team | the admin, CSM, SDM and vendor | the role — the vendor row reads “Orrery Software · Maya Mercer” |
| Customers | the customer contacts | the person’s company |
| Experts | the delivery experts | the class — Internal, Partner or Freelance |
The team group is named after the storyline’s vendor. The active row is the person signed in,
found by their own address — the shell’s existing app-user resolution matched against the
roster (resolveSeatByAddress), never their role, since every customer contact shares one. It is
marked and cannot be picked; a signed-in address that is no roster person’s renders no switcher.
A pick is the seat swap by that person’s seat name and lands on
their home. The open menu scrolls when the twenty rows run past the screen.
The list is the storyline in memory: building it adds no database or Clerk read to the page
render, so every roster person is offered whether or not this deployment holds their login. A
person with no login (pnpm demo:roster not run for them) is refused on pick by the seat route.
A refused pick stays on the page and says why in one line (rate limited, not configured, or a generic failure — a person with no login reads as the generic one), and the visitor keeps the seat they had.
It renders only where a pick can work: a demo request, free-rein mode, DEMO_AUTO_SIGN_IN on, and
a visitor signed in as one of the roster people — a session whose active organisation is the demo
tenant’s (DEMO_TENANT_CLERK_ORG_ID), the rule the entry page uses. A visitor who reaches the demo
host with their own workspace’s session sees no switcher, since a pick would sign them out. Otherwise nothing renders and the shell adds no
clearance for it.
What a free-rein visitor can write
Everything the seat they hold can write — it is a real tenant and a real session, and nothing fences writes. What converges the tenant again is the nightly reset at 01:00 and the pre-demo reset in the client-session runbook; the walk reads LEAD-001 as at its own history, which only appends. The one thing a free-rein visitor can do that breaks a same-day walkthrough is delete or qualify out LEAD-001 — which is what the pre-demo reset is for.
The walk
In walkthrough mode the app shell carries Paul’s demo as a guided walk: a bar above the shell
and a spotlight on the page. It mounts only where it can run — a demo request, walkthrough mode,
DEMO_AUTO_SIGN_IN on, and a visitor signed in as one of the six seats in the demo organisation
(shouldMountWalk in apps/web/lib/demo-walk-mode.ts). Anywhere else nothing of it renders, and
every page behaves as it does without it.
- The bar (
apps/web/components/demo/guided-walk.tsx) sits in the shell’s flow, above the mobile header, so it never covers a page’s own header. It shows the eight stages as numbered pills — the current one also named, each clickable to open that stage’s first step — a date pill stating the moment the page below is read as at, to the minute in the tenant’s business timezone (the current time on a step read today;apps/web/components/demo/walk-moment.tsx— each as-at page publishes its moment withWalkMoment), a pink chip naming the role in front of the audience (“Viewing as CSM” — the person is named by the seat-change card), “Stage n of 8”, Previous, Next, a presenter-notes toggle and a spotlight toggle. The left and right arrow keys move as Previous and Next do, and H hides or shows the spotlight’s caption, except while typing in a field. There is no Exit walk. - The spotlight (
Spotlightinpackages/ui/src/compound/spotlight.tsx) rings what the step is about and shows one caption, marked with the stage; with the notes on, the step’s spoken line shows under the caption. A step may also carry a more-info line (moreonWalkStep) — a short text and one link, opening in a new tab — shown between the caption and the spoken line whatever the notes toggle says, and hidden only with the bubble. Expert Match’s first step (5.1) carries the routing note’s title and its link to the Sustentus team, read fromDEMO_ROUTING_NOTE(packages/services/src/shared/demo-routing-note.ts), the same words the Matching card’s info button opens; its caption and spoken line name the matcher’s criteria fromDEMO_MATCHING_CRITERIAin the same file, which the note’s first line is built from too, so the card and the walk never name different criteria. The notes are off on a fresh walk. The ring is the element’s own outline (data-spotlit, styled inpackages/ui/src/globals.css), so it moves with the element and is clipped by the same scroll container; an element with no radius of its own takes the card radius while ringed, so the frame is never square. A container that would cut the ring gives the element room instead: the pipeline board’s scroll row reaches 0.75rem past the board, padded back, so a column’s ring and halo fit inside it while the columns keep their place. When the step changes the spotlight brings its target into view by one rule (spotlightScrollBlock,packages/ui/src/compound/spotlight-scroll.ts): left where it is when it can be seen whole, scrolled to its top when it is taller than the view — the Command Centre at 1.1 — and centred otherwise. Every target carriesdata-spotlight-targetfor its 6rem scroll margin. A step withring: falseis brought into view but not ringed: the conversation’s turns, 4.1 to 4.4b, where the audience already watches the thread, carry the caption alone. The caption is a pink bubble — the--spotlighttoken, a colour nothing else on the platform uses — kept to a corner of the screen, not to its target: beside or under the target it sat over the very content it explained. Its home is the bottom-left; when its box there would cover the ring (halo included), it moves to the bottom-right, then the top-left, measured from the walk bar’s bottom edge so it never sits under the bar (spotlightCaptionCorner, same file). It is re-checked as anything scrolls, the window resizes or the bubble or ringed element changes size, and moves only when its current corner covers the ring — so it never moves on a still page; when every corner would cover it, it takes the one that covers least. A new step starts from the bottom-left. The presenter hides it with its close button, the bar’s spotlight toggle or H, and shows it again from the toggle or H; the ring stays either way. Hidden is a walk-wide mode like the notes — it holds on every following step and across seat swaps until shown again — and a fresh walk starts with it shown. It never blocks the page — no backdrop, no focus trap — so anything on it can be clicked, and Next resumes the script. When the step’s mark is not on the page (an off-script page, or data missing) the same bubble shows in the bottom-left with nothing ringed — the caption has one home — and, like the ring, it waits for the opening veil and the seat-change card to clear. The bar’s second row carries only the error and missing-record alerts. - Next opens the next step. When its seat differs, the walk goes to
/demo/switch— a public page on the demo host, outside the app shell — which runs the seat swap and replaces itself with the step route, so Back never lands on it, and says whose view comes next while it does. When the seat stays, the step opens in place. Previous opens the one before, swapping back where the seat differs. - Whose view it is, and that it is loading.
/demo/switchsays “Switching to the CSM” with the person at presentation size, and leaves a one-shot marker in the tab’s session storage; the page the swap lands on — entering the demo as the vendor included — takes it and centres “Now viewing as the CSM — Sofia Almeida” over the page for about 3 seconds, or until any click or key, before the spotlight appears. A reload finds no marker, so shows no card (apps/web/lib/demo-walk/seat-arrival.ts). A step that navigates veils the page below the bar withOpening stage n · <stage>from the click until the step’s anchor is on the new page or the 2-second anchor wait ends; a step that stays on the page moves at once, unveiled (apps/web/lib/demo-walk/opening.ts). The platform’s ownloading.tsxskeleton is unchanged.
The script is data. packages/services/src/shared/demo-walk-script.ts holds one step per row of
the walk coverage matrix — Paul’s 76 presenter actions with stage 4 expanded to Bernice’s ten
turns, 83 in all — and demo-walk.ts beside it the eight stages, the anchor registry and the types.
A step carries:
| Field | What it is |
|---|---|
id | The coverage-matrix number — 1.4, 4.3d. Stable, so a recorder can name a step. |
stage | 1 to 8. |
seat | The seat it is seen from. |
route | A path template — /workspace/:lead, /proposals/:proposal, /vendor/customers/:customer. |
record | The stable keys that fill the route (below). |
asAt | A moment of LEAD-001’s history in the workspace as-at view’s grammar, or none. |
asAtOf | A page with no :lead read as at a moment of a lead’s history — { lead, status, before? }, status a key in the workspace as-at grammar: a status (the first time the lead entered it), <status>:approved (the BRD’s release) or <status>:accepted (the quote’s acceptance), or the instant before it. Stage 1 reads the Command Centre and its figure pages just before LEAD-001’s arrival, except 1.5–1.7, which read today (LEAD-009 did not exist then). The checkpoints 4.11 (backlog:approved) and 6.11 (work_in_progress) read the Centre at that stage’s moment; 8.9 reads today. |
hash | The section the page scrolls to. |
reveal | How many messages of the conversation record are shown, or none. |
anchor | A registry name, plus a key on a repeated element. |
ring | false brings the anchor into view without ringing it; absent, it is ringed. |
caption | The one line under the spotlight. |
note | The spoken line — never a figure: the screen shows the figures. |
Records by key, resolved when a step opens. The script holds no ObjectId. A lead is named by
its requestId (LEAD-001, LEAD-009), a proposal as the accepted proposal of a lead, a customer by the
storyline account key (its roster email finds the user). GET /api/demo/walk/<step>
(apps/web/app/api/demo/walk/[step]/route.ts) resolves them in the session’s tenant, fills the
route, appends ?asAt= — the step’s key, or the instant its asAtOf resolves to from the lead’s
status history, BRD approval and quote acceptance (apps/web/lib/demo-walk/resolve.ts) — and the section, records the position and redirects — or, when the walk
asks for JSON because the seat stays, answers with the address. A reset between two steps changes
nothing. It answers 404 off the demo host, outside walkthrough mode, without the gate cookie, or to
a visitor who is not a demo seat. A record or moment it cannot find lands on the seat’s home, and the bar says
the demo tenant needs a reset. The step’s id rides in the page’s address (walk=), so Back and
Forward replay the step each entry was opened as, swapping the seat first where it differs. The walk
takes a step as its own only once that step’s page has landed under its seat — never while the step
route answers — so a page left off-script resumes from the last step that landed, the one Back or
Forward reached included, as a reload of it does.
Anchors. A screen marks what a step points at with data-walk-anchor="<name>" — plus
data-walk-key on a repeated element: a milestone’s place in the plan, a pipeline card’s request
number, a column’s status, a message’s position — placed through walkAnchor("<name>")
(apps/web/lib/demo-walk/anchor.ts). The names come from the registry (WALK_ANCHORS), so a
misspelt anchor fails typecheck, and apps/web/lib/demo-walk/anchors.test.ts fails when a
registered name is placed on no screen. Off the walk the attributes are inert.
The conversation. On a step that names a reveal count, the walk holds the conversation sheet
open — below the bar and without its modal layer, so Next stays in reach — and the record shows the
first n messages of the stored thread, with a typing line while more remain; Next raises n. The
conversation steps read LEAD-001 as at backlog:approved, where the thread is complete. Everywhere
else the sheet and the record are unchanged.
The position is a cookie on the demo host beside the gate cookie, sustentus_demo_walk:
host-only, secure, SameSite=Lax, twelve hours like the gate, holding the step id, the notes flag,
the spotlight flag and the missing-record flag. A reload, Back/Forward and a seat swap keep the
step and both toggles. Entering the demo clears it, so the walk starts at stage 1, step 1 — the
Command Centre as the vendor: redeeming an access code (/api/demo/access), or opening / or
/demo on the demo host from outside the demo — typed, a bookmark, a link from an email or another
site (isWalkEntry in apps/web/lib/demo-walk/position.ts: a GET whose Sec-Fetch-Site is not
same-origin). The proxy expires the cookie with a same-address redirect; the app’s own redirects
to / are same-origin and keep the step. A fresh walk adopts step 1 where it stands — walk=
added, no navigation — only when step 1 reads the page as it is now and sits on a literal route
(adoptsInPlace in apps/web/lib/demo-walk/adoption.ts; a templated route such as
/workspace/:lead is always opened through navigation); 1.1 reads the Centre as at LEAD-001’s arrival, so it is
opened through its step route and the address carries asAt=.
Changing a step. Edit its entry in demo-walk-script.ts. A new mark goes into WALK_ANCHORS
and onto its screen with walkAnchor; the services script test (demo-walk.test.ts) and the web
tests (anchors.test.ts, script.test.ts) then say what is missing — an id that repeats, a stage
out of order, a route parameter with no record, an ObjectId, a malformed as-at key, a page the
step’s seat may not open, or a registered name placed nowhere. An as-at key the grammar accepts but
LEAD-001 does not hold opens the step’s page live, with no warning, and the bar’s date pill shows
the current time instead of the step’s moment: check the step against the History menu on
/workspace/<LEAD-001> after a reset.
Login Credentials
No credentials are published on this page. The demo credentials — the persona logins,
the filler customer logins (customer-1 … customer-10), and the shared demo mailbox —
live as environment variables on the Vercel project. That is the single place to read
them; open the Vercel project’s environment variables, or ask a platform admin if you don’t
have Vercel access.
The sign-in addresses are the active storyline’s own — read them from its team and
persona entries (see the provisioning runbook above); the storyline module, not this page,
is their source of truth.
Login Screen

Presenter persona switching
A presenter changes persona on the demo host, where every seat change is the seat swap: in free rein through the persona switcher, in walkthrough through the walk (see Walk mode). The platform host has no persona switching: an admin signed in there sees their own view only.
Register switching in the agentic app
The seat swap above is apps/web’s and drives the classic screens. The agentic app has its own
presenter control and a different mechanism: the role rail under its header, whose pills are live
on the demo tenant. A presenter — the demo tenant’s admin, or a Sustentus staff or partner account
that has selected the demo tenant — picks a register and the whole surface answers as that demo
person: their tools, their permission set, their prompt contract, their context rail and their
composer copy.
The two do not share machinery and neither is a way into the other. A seat swap opens a
person as the demo host’s seat; register switching resolves the agentic app’s
register for a demo roster seat, re-authorised on every request in one home
(packages/services/src/auth/acting-persona.ts) and impossible off the demo tenant by construction
rather than by a flag. Nothing about it touches org roles, session claims or Clerk.
It rests on the same roster this page provisions: only a register the roster holds a real login for
can be acted as, so pnpm demo:roster is what makes a pill live. A register with no seat stays inert
and says so. Picking your own register is the way back.
Conversations opened while switched belong to the demo persona, so a presenter sees that person’s threads while acting as them — which is also why a tenant that has been demoed against should be reset before presenting from it again.
Canned agent mode
The onboarding concierge is the streaming-LLM moment of a walkthrough most exposed to a latency
spike or a provider incident mid-sentence. On a tenant flagged isDemo it replays a recorded
transcript instead of calling a model, so the same conversation runs the same way every time.
Canned mode is the concierge’s only flow: the BRD conversation moved to the agent app’s customer
register (reached from the platform by /start — see
the agent app), and the agent app has no canned mode.
- Canned is the default. The concierge shows a presenter switch (canned ↔ live). The
choice is a session cookie (
sustentus-demo-agent-mode) and a preference, never an authorisation: the server re-reads the tenant’sisDemoflag on every request and returnslivebefore it looks at the cookie, so a real tenant cannot reach canned mode by any cookie, header or body — and the switch does not render for it. - The writes are real. Tools that touch only the caller’s own tenant data execute for real, so a
canned walkthrough leaves a genuinely populated blueprint behind.
analyse_source— the one tool that fetches a website and runs a second extraction call — is served from the transcript, which is what makes the no-external-call guarantee hold end to end. - Editing the script is a PR: the transcripts are versioned JSON at
packages/services/src/ai/canned/transcripts/, schema-checked on first use, and a malformed one fails loudly naming the file rather than streaming a half conversation.
One limit worth knowing before a walkthrough:
- There is no reset yet. A second onboarding run resumes at the end of the script rather than the greeting, and the blueprint completes for real, so don’t promise a back-to-back repeat.
Use Cases
The demo environment supports multiple user roles, each with different capabilities and access levels.
Admin Role
Full system access with administrative capabilities:
- Dashboard: System overview and analytics
- Settings: Tenant configuration and management
- Demo data: Populate the tenant with the storyline, or reset it back to the storyline
- User Management: Create and manage users across all roles
- System Configuration: Configure workflows and statuses

Manager/CSM/SDM Roles
Team and project management capabilities:
- Leads Management: View, create, and manage leads
- User Management: Manage team members and assignments
- Activity Tracking: Monitor team activities and progress
- Finances: View quotes, invoices, and financial reports
- Products: Manage product catalog
- CSAT: View and analyze customer satisfaction scores
- Skills: Manage skills and the services they support
[Screenshot placeholder: Manager dashboard showing leads and team overview]
Expert Role
Project execution and consultation capabilities:
- Bid Pool: View and bid on available projects
- Proposals: Create and manage project proposals
- Projects: Track active projects and milestones
- Invoices: Create and manage invoices
- My Knowledge: Personal knowledge base and resources
- My Team: Manage team members and collaborations
[Screenshot placeholder: Expert dashboard with bid pool and projects]
Customer Role
Self-service and support capabilities:
- Projects: View project status and details
- Finances: View quotes, invoices, and payment history
- Create Request: Submit new project requests
- Help centre: Access support resources
[Screenshot placeholder: Customer dashboard with project requests]
Vendor Role
Vendor management and procurement capabilities:
- Dashboard: Overview of CSAT metrics, customer performance, and regional statistics
- Leads: View, create, and manage leads
- Create Lead: Multi-step lead creation form
- Customers: Manage customer relationships and invitations
- Team View: Manage team members and territories
- Help centre: Access support resources

Demo Data Management
Populating Demo Leads
Admins can fill the current tenant with the demo storyline: one coherent world of customer companies, delivery experts and engagements spread across every stage of the service journey, plus a backdated history, so the platform renders fully populated for demos and visualisation.
The storyline is Orrery Software — a fictional mid-market B2B SaaS vendor whose services arm implements, integrates, migrates, trains and optimizes its own product for the customers who license it. That framing matches the ICP the marketing site addresses; the catalogue is the vendor’s own product — one product, Orrery CRM — rather than third-party products, and the services are the eight a CRM implementation partner sells, in Paul’s names (Rapid implementation, Data migration, Integration build, Compliance review, Custom extension, Ongoing optimisation, Team training, Premium support). The world is Bernice’s, in pounds: English customer companies that bought Orrery CRM seats, each with its licence ARR; her UK-based delivery experts with the industries they know; her current records, covering every stage of the service journey and priced at her figures from the quote stage on, some of them in flight as the vendor’s order book; and a backdated history of completed projects behind them. Every current record carries a stable key and an authored request id, so a reset reproduces the world exactly and the walk’s LEAD-001 is always the same Brightline lead. It is the only storyline — there is no second world behind it and nothing to select between. Its counts and totals — customers, experts, records, values, request ids, the span of the history — are published on one page only, Demo tenant data, with every company, person and project in it; this page restates none of them, so a storyline change has one page to keep true.
Via Admin Demo Data
- Log in as an admin user
- Navigate to Configure > Demo data (
/admin/demo-data) - Click Populate demo data and confirm
The run takes a few minutes and writes, in order:
- The storyline’s people. Every seat is filled from the roster by email, and the run creates nobody: a seat with no Clerk-backed user aborts the run before its first write, naming every missing address at once. What each run does write is the attributes the storyline owns — a customer’s company, country, industry and vendor, plus their licence relationship (ARR and renewal date); an expert’s position, bio, skills, products, languages, seniority, industries and availability — every time, not only the first, so a person provisioned before the seed is still dressed. The storyline’s industry names are topped up on the tenant’s
industrytaxonomy by name (the reset never purges it), so accounts and experts resolve theirs by name. The licence fields are written in a second pass over the accounts, and the expert profiles after the catalogue, because they resolve against catalogue rows the same run has only just upserted. Identity is never written:firstname,lastname,emailandavatarare Clerk’s, re-mirrored on the person’s next sign-in. The run reports which roster member filled which seat, on the same screen that triggered it. - A tiered catalogue, so a fresh demo shows the product → service → skill hierarchy: the storyline’s products, a shared generic service catalogue linked many-to-many to products, and skills linked many-to-many to services (no
skillCategory). Every product gets the universal services and two specialist services in rotation — or every specialist service, when the catalogue has fewer products than specialists, so a one-product catalogue reaches the whole menu. Each service, skill and product carries its authored description, and the product its knowledge profile (overview, key features, integrations, target customers, use cases, pricing model, docs URL — what the AI assistant reads), written on every run. Built idempotently (upsert by name, compared regardless of case; links via the idempotent link service), so re-running never duplicates catalogue rows or links. Names are authored in the case they display in (“Orrery CRM”, “Integration build”), and a catalogue row found under another casing — seeded before names kept their case — is renamed in place to the storyline’s text. A service renamed outright reaches a tenant populated before the rename through the storyline’s alias map (catalogue.serviceAliases, previous name → storyline name), applied before the upsert: a row still under its previous name is renamed in place and made active, keeping its id, so every lead, link and skill on it follows; where the storyline’s row already exists beside it, the previous row is merged into it — leads, product↔service links, lead-form options, skills and users’ remembered lead-list filter repointed (each lead-form config and skill in one atomic write), the storyline’s row made active — and archived through the service archive guard, so a lead, link or skill still on the previous row stops the run with the guard’s error instead of being left on an archived service. A service no alias names is left as the admin set it. Each storyline skill’s service links are rewritten on every run, and the run’s summary counts the services it renamed and merged — both 0 after a reset. Industry stays a flat taxonomy, topped up with the storyline’s names; beyond that re-casing, nothing in it is rewritten. - The current pipeline — leads in each lead status (
pending,backlog,quotation_process,awaiting_confirmation,work_in_progress,delivered,survey_sent,completed, andqualified_out), each with the supporting records its stage implies: proposals, quotes, milestones, invoices, activity, status history, and lead-expert matches. Each lead is seeded along a valid product → service → skill path, so the catalogue and the cascade read coherently, and gets therequestIdits storyline record authors: the seed reserves one block fromtenant.leadRequestSequenceand writes each record’s id at its offset in that block, so after a reset (which zeroes the sequence) LEAD-001 is always LEAD-001, and a populate over a tenant that already holds leads shifts the block instead of colliding. No seeded lead carries a budget: its value is its service’s list price, read live from the catalogue, never a figure a customer gave (approval-budget-write-respects-currency-and-stored-budgets): a record reads £0 until its requirements are approved, the list price from approval until its seeded bids price it, then its accepted quote. Each lead also carries the source, intake channel, drop-off reason and bid deadline its record authors. Its BRD approval goes through the platform’s own approval core, which applies the live rule to that deadline: one that had passed, or was within 48 hours, at the seeded approval becomes the approval plus seven days. The leads in the bid pool keep the deadlines their records author. Every closed-engagement lead in the backdated tail closes bidding at the moment its customer accepted the quote, seven days into its life — accepting a quote is what closes the bid pool — and its approval, three and a half days in, keeps that deadline (seeded-lead-clock.test.tschecks each one). - A backdated tail of closed engagements, dated in 30-day windows back from today — window 1 is the last 30 days. Without it every “vs prior period” comparison in the vendor analytics is flat and the retention cohorts have nothing to retain. Each tail engagement is dated across its own life (
closed-tail.ts): the lead is created 60–120 days before its close and enterswork_in_progressat the quote’s acceptance a week later, and itsdelivered,survey_sentandcompletedsteps fall in the week before the close. A storyline record can pin the close day (closedDay) or the span (spanDays) where the derived one would sit on a window’s edge. - Every record dated no later than what it records. Milestones are written at the acceptance, and a done one completes no earlier than a day per step after it, no earlier than the one before it, and no later than the reset — or, for an engagement that ended after acceptance, the day it ended (
milestone-dating.ts); each milestone’s activity-feed entry carries the instant that milestone completed or moved, from the same ladder (milestoneFeedEntries); quotes with their bid, and bills no later than they were sent or paid and never before the acceptance (invoice-dating.ts). Read as at a past moment (?asAt=), the Command Centre therefore shows the work then in flight, not records created at the reset. - CSAT records for the engagements that reached
survey_sentor beyond, bar a few deliberate exceptions that carry paid revenue and no response — that is what makesrevenueAwaitingCSATa real number rather than zero.
Nothing is random. Every company, person, engagement title, value and score is authored, and the variation that used to come from Math.random() now comes from a hash of each record’s own identity — so two runs against an equivalent empty tenant produce the same world, differing only in ObjectIds and the wall-clock the relative dates hang off.
Changing the storyline
Content and mechanics are separate. The generator (packages/services/src/db/services/demo-data/index.ts) holds no content; every value it writes comes from demoStoryline (demo-data/storyline/saas-vendor.ts), typed by demo-data/storyline/types.ts. Changing the demo world is therefore a data change, not a code change — edit that module.
There is one storyline. The old defaultStoryline / activeStoryline pair, the spare Calderon Technology Partners world, and the optional storyline argument on populateDemoLeads and resetDemoTenant are all gone: a second world nobody seeded was a second world nobody kept true, and the argument only made it possible to fill a tenant with a world the docs do not describe. The seeder and the reset read demoStoryline directly.
What has to move with a content change:
- Demo tenant data — the page publishes the world’s totals, and
demo-data/storyline/storyline.test.tspins them, so a value edited alone fails the suite. - The runbook’s persona table above, if
teamor the persona entries change. - The three narrative account keys at the top of
storyline.test.ts— the anchor, failing-CSAT and churn-risk accounts are named there.
Two rules hold: fictional only — no real prospect or client names, ever — and fully authored, so a reset reproduces the approved world exactly. A third is enforced by the storyline’s tests rather than by convention: languages must line up. The matcher ranks on language through the Location + language tick, so every account’s language has to be spoken by at least one expert’s languages, and a project’s assigned expert must share their account’s — otherwise the seeded world renders correctly but ranks every expert as missing the customer’s language.
The action targets the current tenant, is refused outright on any tenant not flagged isDemo: true (requireDemoTenant, the same gate the reset uses), and appends on each run — it does not clear or de-duplicate existing data, and raises no notifications, emails, or external side effects. There is no undo and no transaction, so a run that is interrupted part-way can only be repeated, not resumed. It is a visualisation aid, not a production data path; to return a tenant to the storyline, use the reset below.
Resetting the demo tenant
Seeding only ever adds. Every walkthrough leaves leads, CSAT responses, notifications and audit entries behind it, and re-seeding on top compounds them — so the demo world drifts further from the storyline with every session. The reset is the way back: it purges the tenant and re-seeds the approved storyline, so the world converges instead of accumulating.
It is hard-gated. resetDemoTenant calls requireDemoTenant before its first delete and refuses any tenant not flagged isDemo: true, so it can never run against a real tenant. It also refuses before purging when the tenant is missing a Clerk-backed admin, or any seat on the roster — otherwise it would empty a tenant the re-seed cannot then repopulate. That refusal is recorded on the tenant, so the missing addresses are readable at Configure > Demo data without going near the logs.
Via Admin Settings
- Log in as an admin user
- Navigate to Configure > Demo data (
/admin/settings/demo-data) - Click Reset demo organisation and confirm
The dialog names the tenant, and the run reports what it purged and what it re-seeded. It takes a few minutes — the purge is fast, the re-seed is the same few minutes as populating. The page also shows the last reset — who ran it and when — read from the tenant on load, so it survives navigation and is visible to anyone who opens the page (see Audit trail).
What gets purged
Everything tenant-scoped except an explicit keep-list. The classification lives in one place — packages/services/src/db/services/demo-reset/policy.ts — and is deny-by-default: a new tenant-scoped collection is purged unless someone deliberately keeps it, and a test fails if a new one is left unclassified.
Purged: leads and their status history, proposals, quotes, invoices, milestones, expert matches, CSAT responses, expert ratings, blockers, change control, action items, project messages, activity, notifications, onboarding blueprints, expert evidence, SDM outreach state — plus the tenant’s catalogue (services, products, skills and their links), which the seeder rebuilds identically.
What survives
- Identity. The reset makes no Clerk API call at all: the organisation, the six persona logins and their memberships are untouched. Their app-user records are kept because they carry a
clerkUserId, so each persona signs back in with the same identity. The storyline’s other people — customer contacts and delivery experts, which have no login — are purged and recreated by the seeder’s upsert-by-email. - Tenant configuration: tenant settings and integrations, role templates, permission overrides, SLA definitions and locations.
- Industry, because the seeder only ever reads it — it reuses whatever the tenant already has, so purging it would leave the re-seed with no industries to attach leads to. This is why industry is treated differently from the rest of the catalogue.
- CSM portfolio snapshots, so the CSM activation table keeps a prior day to compare against.
- SDM coverage snapshots, for the same reason and with more riding on it: the coverage grid is derived fresh on every read and records no history, so this row is the only opening figure the SDM’s coverage drill-downs can reconcile from. Purging it would leave every one of those bridges saying “the record begins today”, permanently. A kept snapshot is matched to today’s grid by the product name it stores, compared regardless of case. So a storyline that re-cases a catalogue name (“orrery crm” → “Orrery CRM”) still meets the rebuilt catalogue, which gets new ids on every reset. A rename that changes the letters is carried into stored outreach and snapshots by
sdmCellRenameService. - Everything global. Collections with no
tenantId— the tenant record itself, action types, metric definitions — are unreachable from a tenant-scoped delete.
Lead request IDs come from tenant.leadRequestSequence, not from a counter collection, so the reset zeroes that field explicitly. Without it the reserved block would start higher on every reset and the storyline’s authored ids — LEAD-001 among them — would not land on their own numbers.
The reset is not atomic: it purges, then re-seeds, with no transaction. An interrupted run leaves the tenant purged or half-seeded — the remedy is to run it again, which converges.
Via cron
GET /api/cron/demo-reset resets every tenant flagged isDemo: true. It requires the CRON_SECRET bearer token (and fails closed if the variable is unset), so it is unreachable from a normal signed-in session. Targets come from the flag rather than from configuration, so no environment variable carries a tenant id.
The route is scheduled at 01:00 daily in apps/web/vercel.json, ahead of both snapshot jobs — the CSM portfolio at 02:00 and the SDM coverage grid at 06:00 — so each morning’s snapshots describe the freshly reset world. Vercel fires crons against production deployments only — a preview deploy never runs it, which is why the first unattended firing is also the first real one.
It is sized for one demo tenant. resetAllDemoTenants resets its targets sequentially inside a single maxDuration budget, and one reset is itself a few minutes of sequential writes. That fits because the flag migration keys on a single DEMO_TENANT_CLERK_ORG_ID, so exactly one tenant carries isDemo: true — but nothing enforces it. Flag a second one and the invocation is killed mid-sweep, which is the one failure the audit trail below cannot show: the function dies before it can record anything, so lastReset still reads as the previous night’s success. Raise maxDuration or bound the sweep before a second demo tenant exists.
The preview environment does carry a tenant flagged isDemo: true (DEMO_TENANT_CLERK_ORG_ID is set there, and the flag migration runs against the preview database), so a manual call to this route against a preview deployment has a real target to purge. It is not a no-op there.
Audit trail
Every reset — from the admin button or the nightly cron — records itself on the tenant document as tenant.lastReset: who ran it (the acting admin, or the cron), when, the outcome, the totals purged and re-seeded, and the storyline that was seeded. A failed reset records the reason instead of the totals.
It lives on the tenant rather than in its own collection for one reason: the purge never touches the Tenant document, so the record survives the very operation it describes. It holds the latest reset only — a second reset replaces the first, and no history is kept.
Two properties are worth knowing when reading it:
- A refused reset writes nothing.
requireDemoTenantruns before the recording path, so a tenant that fails the demo guard receives no audit write either — the record can never become the cross-tenant write the guard exists to prevent. - Recording is best-effort. If the write fails, the reset’s own error is what the caller sees; the audit trail never masks the real failure behind a bookkeeping one.
The admin demo-data page reads it on the server, so /admin/settings/demo-data answers “who reset this, and when” on a cold load — another device, another person, the morning after the cron. Timestamps render in UTC and are labelled as such.
Client-session runbook
Before a walkthrough
- Sign in as the admin persona and reset the tenant (above). Allow a few minutes.
- Check the counts the run reports — a re-seed that created zero leads means the run did not complete.
- Open one vendor deep-dive and one persona dashboard to confirm the world renders populated.
After a walkthrough
- Reset again, so the next session starts from the storyline rather than from the last demo’s residue.
- If the reset errors, read the message — it names the collection or the missing persona — and run it again. Repeating is safe; the reset converges.
Technical Details
Demo Tenant Identification
Demo tenants are identified by the isDemo: true flag in the Tenant model
(packages/services/src/db/models/tenant.ts). The flag is written only by the tracked
migration 1786492800000-flag-demo-tenant.ts, keyed on DEMO_TENANT_CLERK_ORG_ID and run
against both the preview and production databases — never by hand. Standing the preview
world up end to end is the preview database runbook.
Reading and enforcing the flag, from @sustentus/services/server:
// Cheap boolean read (projects only the flag)
const isDemo = await tenantService.isDemoTenant(tenantId);
// Hard gate — throws for non-demo and missing tenants. Destructive demo
// operations (purge/reseed) must call this before touching data.
await requireDemoTenant(tenantId);Next Steps
- Read the Demo tenant data reference — the people, companies and engagements the storyline seeds
- Explore the Applications documentation
- Learn about Development Workflows
- Check out Deployment Guides