Skip to Content
TechnicalDemo Environment

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.

  1. Create the demo Clerk organisation in the production Clerk instance. Note its organisation ID (org_…) — it becomes DEMO_TENANT_CLERK_ORG_ID.

  2. 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:expert and org:customer. Only org:customer exists by default — hence the CLERK_ORG_CUSTOMER_ROLE override 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 View As never signs anyone in.

  3. Create the admin Clerk user by hand, with the org:admin role, 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 Enter the demo control 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.updated webhook mirrors firstname, lastname, email and avatar onto 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.

    PersonaNameRole in the storyline
    adminAlex RendellPresenter; administers the tenant
    csmSofia AlmeidaCSM — owns customer relationships
    sdmBen HartleySDM — owns delivery
    expertElin BergqvistExpert — implementation lead, Orrery Core
    vendorOrrery SoftwareThe vendor the demo is told from
    customerAnneke VosOperations Director, Havenbrook Retail Group — 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 as demoStoryline from storyline/index.ts). Every address follows one convention, jamie.nisbet+<lastname>.<role>@sustentus.com, and rosterOf(demoStoryline) (storyline/roster.ts) derives the twenty from the storyline’s team, accounts and experts — there is no second list to keep in step.

  4. 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.

  5. Set DEMO_TENANT_CLERK_ORG_ID to 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 in turbo.jsonglobalEnv — 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.

  6. Merge to main. The migration 1786492800000-flag-demo-tenant.ts runs via db-migrate.yaml and sets isDemo: true on 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.

  7. Verify the flag: tenantService.isDemoTenant(tenantId) returns true (or check the tenants collection directly).

  8. Provision the roster, with CLERK_SECRET_KEY pointed 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 # provision

    This 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 no CLERK_SECRET_KEY, stops the run. With more than one demo tenant it asks for --tenant <id>.

    --check also names anyone in the organisation who is not on the roster — which is how the superseded demo+…@apoyar.eu accounts show up. Removing those from Clerk is a manual tidy-up; their app-user rows carry a clerkUserId, so the demo reset keeps them and they linger in the directory until deleted by hand.

  9. 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). If demo:roster --check did 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 host

Both 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, View As 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:

  1. Add demo.sustentus.com in the apps/web project’s Settings > Domains and configure DNS.
  2. Set DEMO_HOSTS=demo.sustentus.com in that project’s Production environment.
  3. Redeploy. DEMO_HOSTS is read when the app is built — the next.config.ts header 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.
  4. 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 a handle and the current six-digit code from an authenticator app. 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 session waiting behind the door is a platform admin nobody signs in to, so what needs protecting is the whole host.

A curated roster — the internal team plus named partners — enrol a TOTP secret once, out of band.

How it is composed

PieceWhat it is
The rosterDEMO_ACCESS_ROSTER — comma-separated handle:BASE32SECRET. Handles are lowercase and unique
The challenge/demo/access, answered by POST /api/demo/access
The checkRFC 6238 TOTP — SHA-1, six digits, a thirty-second step, one step either side for clock skew, compared constant-time
The cookiehandle + expiry, signed HMAC-SHA-256 with DEMO_ACCESS_SECRET; HttpOnly, Secure, SameSite=Lax, twelve hours

The cookie is host-only — set with no Domain attribute, so it is never sent to platform.sustentus.com, which shares the parent domain. Verifying it also re-checks the roster, so taking someone off ends their access rather than waiting out their cookie.

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/enter then re-checks the gate for itself, as a fifth refusal (not_gated) ahead of the four 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.

Guessing is bounded two ways: ten attempts per five minutes per client address, five per five minutes per handle, the second charged before the secret is looked up so a distributed grind against one person is bounded too. An accepted code cannot be replayed inside its window. Every refusal is identical — unknown handle, wrong code and replay all return the same invalid at the same status, in the same time — so the roster cannot be enumerated from the responses.

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 roster entry 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 manages the roster. It refuses the two edits that silently lock people out — minting a fresh secret for someone already enrolled, and init over an existing roster — and always prints the complete replacement value rather than a fragment to splice in.

pnpm demo:roster list # who is enrolled (handles only, never secrets) pnpm demo:roster check # would the app accept the current roster? pnpm demo:roster init <handle...> # a brand-new roster + a new signing secret pnpm demo:roster add <handle> # enrol one person, keeping everyone else's secret pnpm demo:roster remove <handle> # drop one person, keeping everyone else's secret

It 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. Enrolling someone draws their QR code straight into the terminal for them to scan, which needs qrencode on PATH (brew install qrencode, apt install qrencode) — without it the command still works and prints the otpauth:// payload instead. Send each person only their own.

Then redeploy, per the rule above.

Automatic sign-in

On the demo host the entry page offers Enter the demo. It posts to POST /api/demo/enter, which mints a Clerk sign-in token for the demo admin — 60-second expiry, single-use — and returns it with the demo organisation id. The browser finishes the job (signIn.ticket()signIn.finalize()setActive({ organization })) and is then sent to its destination.

The result is a genuine Clerk session on the real demo admin, not a bypass: tenant resolution, route policies, permissions, View As and canned agent mode all behave exactly as they do for a presenter who typed the password. Activating the organisation is load-bearing — without it auth() returns no orgRole and the visitor lands on /no-role.

Entry is always a deliberate act. Nothing redirects on first byte, and a visitor who already holds their own session is told that entering will sign them out and asked to choose.

Five refusals guard the route, the first four before Clerk is called at all:

RefusalWhenResponse
not_demo_hostthe request is not a demo request404
not_gatedthe access gate above has not been passed404
disabledDEMO_AUTO_SIGN_IN is not exactly "1"404
not_configuredDEMO_ADMIN_CLERK_USER_ID or DEMO_TENANT_CLERK_ORG_ID unset503
rate_limitedmore than 5 entries a minute from one client address429

Only POST is exported, so every other method is answered with 405.

The rate limit is an in-memory token bucket held per serverless instance. It resets on a cold start, and a burst spread across instances gets one allowance each — a brake, not a guarantee. That is the accepted trade: every mint targets the same demo admin user, so abuse costs Clerk API calls and session churn rather than per-visitor billing. Move it to a shared store when there is real traffic to measure.

Three variables configure it, all on the apps/web project and all declared in turbo.jsonglobalEnv:

VariablePurpose
DEMO_AUTO_SIGN_INthe kill switch — off unless exactly "1"
DEMO_ADMIN_CLERK_USER_IDthe Clerk user id of the demo admin created in step 3 of the runbook above
DEMO_TENANT_CLERK_ORG_IDthe demo organisation to activate (also read by the flag migrations, in CI)

With the switch off, or either id unset, the entry page falls back to its placeholder and the route refuses — which is exactly how every environment behaves until it is configured.

What keeps this safe

The session this mints 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 demo admin 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 all three there for the duration and unset them afterwards.

Login Credentials

No credentials are published on this page. The demo credentials — the persona logins, the filler customer logins (customer-1customer-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

Demo Login Screen

Presenter persona switching

A demo runs from one login: the admin account signs in — by password, or through the demo host’s Enter the demo control — and drives the existing View As control. The session is a genuine Clerk session on that account either way, so the admin-only gate, the same-org check and the View As audit trail are the platform’s, unchanged.

View As is one floating control fixed to the bottom right of the app shell, rendered by apps/web/app/(app)/layout.tsx on every page including /onboarding/*. There is no sidebar entry and no full-width banner: the control is both the way in and the way out. Idle it is icon-only and reveals its label on hover and on keyboard focus; while emulating it stays expanded, showing the emulated person’s name and role with a switch and an exit beside them.

The menu behind it is the same list on every tenant — the isDemo flag no longer changes it, because a presenter and an admin want the same thing from it. Switching role is the point, so the role comes first:

  • The menu opens on the emulatable roles in canonical order (sales, CSM, SDM, expert, vendor, customer), each showing how many people hold it. Admin is not among them — the presenter already holds it, which is also why the way back is an exit rather than an emulation.
  • Choosing a role replaces the menu with that role’s people, name-sorted, each entry showing the person’s name and email, with All roles to step back. The roster is only ever one role deep, so the list stays short enough to read in front of a room, with no submenu to open.
  • Each role’s list ends with Role template only — the role’s permissions with nobody’s data behind them. It is permission testing rather than a walkthrough (own-scoped views render empty), and it is what keeps a role nobody holds from being a dead end.
  • The same menu serves the idle control and the active one, so starting, switching person and exiting all sit behind the one trigger on every page.
  • What the isDemo flag still changes is the register of an active emulation: on a demo tenant the control reads as neutral presenter chrome saying “Presenting as”, and anywhere else as an amber audit warning that stays expanded, because there it sits over real customer data rather than a demo roster.

Every person is switchable as soon as pnpm demo:roster has run — the roster writes each seat’s app-user row with its clerkUserId, and it is that field the control filters on. A sign-in is no longer what makes someone appear here.

The list is the tenant’s directory, not the roster file. Anyone provisioned into the tenant afterwards — a customer created through the lead-create flow, say — appears among the roster’s people and looks no different, so reset and re-provision a tenant that has been demoed against before presenting from it again.

Canned agent mode

Onboarding and BRD qualification are the two streaming-LLM moments of a walkthrough, and the two most exposed to a latency spike or a provider incident mid-sentence. On a tenant flagged isDemo both chats replay a recorded transcript instead of calling a model, so the same conversation runs the same way every time.

  • Canned is the default. The two chat surfaces show 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’s isDemo flag on every request and returns live before 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 genuine BRD and 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.
  • Skip to an approved BRD. In canned mode the BRD chat offers a skip control that approves the transcript’s own document straight onto the lead through the standard approval path, then lands on the workspace. Because it is the standard path, it also publishes the lead to the bid pool and fires the expert matcher — the same downstream activity a live approval produces.
  • 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.

Two limits worth knowing before a walkthrough:

  • There is no deny control on the BRD approval in canned mode — the replay steps by turn count and cannot branch, so only approve and skip render. Switch to live to demonstrate the deny path.
  • 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

Admin Dashboard

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

Vendor Dashboard

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 suites and modules (orrery core, orrery analytics, orrery connect, orrery field) rather than third-party platforms, and the services mirror the published menu. The world holds ten Northern-European customer companies across four sales regions and six languages, six delivery consultants, twenty-five current records covering all nine journey stages and worth €794k, and thirty-six completed projects worth €1,012k across eighteen months. It is the only storyline — there is no second world behind it and nothing to select between. Every company, person, project and number in it is listed on Demo tenant data.

Via Admin Demo Data

  1. Log in as an admin user
  2. Navigate to Configure > Demo data (/admin/demo-data)
  3. 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, renewal date and licensed platforms); an expert’s position, bio, skills, products, languages and availability — every time, not only the first, so a person provisioned before the seed is still dressed. The licence fields and the expert profiles are written in a second pass after the catalogue, because both resolve against catalogue rows the same run has only just upserted. Identity is never written: firstname, lastname, email and avatar are 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 platform → product → service → skill hierarchy: platforms with their product modules, a shared generic service catalogue linked many-to-many to products, and skills linked many-to-many to services (no skillCategory). Built idempotently (upsert by name, links via the idempotent link service), so re-running never duplicates catalogue rows or links; industry stays a flat taxonomy reused from the tenant.
  • The current pipeline — leads in each lead status (pending, backlog, quotation_process, awaiting_confirmation, work_in_progress, delivered, survey_sent, completed, and qualified_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 a real sequential requestId via tenant.leadRequestSequence.
  • 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.
  • CSAT records for the engagements that reached survey_sent or beyond, bar a few deliberate exceptions that carry paid revenue and no response — that is what makes revenueAwaitingCSAT a 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:

  1. Demo tenant data — the page publishes the world’s totals, and demo-data/storyline/storyline.test.ts pins them, so a value edited alone fails the suite.
  2. The runbook’s persona table above, if team or the persona entries change.
  3. 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 matching engine rather than by convention: languages must line up. filterEligibleExperts treats language as a hard filter, 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 matches nobody.

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

  1. Log in as an admin user
  2. Navigate to Configure > Demo data (/admin/settings/demo-data)
  3. Click Reset demo tenant 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, view-as audit entries — plus the tenant’s catalogue (services, platforms, 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.
  • 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 IDs would climb on every reset and two runs would not produce the same world.

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, an hour before the 02:00 CSM snapshot job, so each morning’s snapshot describes 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. requireDemoTenant runs 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

  1. Sign in as the admin persona and reset the tenant (above). Allow a few minutes.
  2. Check the counts the run reports — a re-seed that created zero leads means the run did not complete.
  3. Open one vendor deep-dive and one persona dashboard to confirm the world renders populated.

After a walkthrough

  1. Reset again, so the next session starts from the storyline rather than from the last demo’s residue.
  2. 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

Last updated on