← Telemachus

Design — beta-onboarding-experience

Beta Onboarding Experience (Skinnable & Admin-Configurable)

Purpose. Turn the beta funnel from a hard-coded, generically-themed form into a plugin-owned experience a team owner can brand and configure at runtime — logo, hero, header/footer, copy, custom fields, and a full theme — without the shape of it being baked into the Telemachus core. Uninstall the plugin and the platform has no funnel: beta onboarding is a capability the platform permits, not a requirement it imposes.

This extends the onboarding provider seam already in the RI (register-onboarding!, plugins/beta-onboarding) from a "config hash" to a full experience, and moves the source of truth from code/env to admin-editable storage. It composes with existing subsystems — RBAC gates the editor, the anti-abuse gate stays core, the LLM judge runs through the scheduler — and adds no new infrastructure.

Related: saas-onboarding.md (provisioning a tenant; this doc is the pre-sales lead capture that precedes it), rbac-and-teams.md (who may edit), ai-queue-and-concurrency.md (the judge job).

The ask

The public landing (betaLandingView()) renders a generic form from a provider config (title, subtitle, fields) in the app's own slate/indigo chrome. A deployer can swap copy, fields, and the judge prompt via register-onboarding! or TELEMACHUS_ONBOARDING — but only in code/env, and the result is always the Telemachus look.

Reference beta pages — EA Playtesting, Total War: Warhammer 40K beta, Ableton's Centercode portal — share a pattern the current model can't express:

Four separable requirements fall out, each a section below: an extensible data model, runtime configurability by the admin, a flexible render contract, and theming.

Principle: core is mechanism, the plugin is presentation

Core ships capabilities true for any beta program — capture a lead, resist abuse, store it, route the root URL, vet with a judge. It ships zero opinion about what the page looks like, which fields exist, or that a beta funnel exists at all.

Core (Telemachus) — mechanism Plugin (beta-onboarding) — policy + presentation
Prospect capture + anti-abuse gate (PoW, challenge, honeypot, velocity) The default experience: fields, copy, judge prompt
Experience storage & resolution (draft/publish, precedence) The theme & brand assets it ships with
Home routing to a pluggable root experience Its own frontend — template or full static bundle
A render contract + a public client SDK Field & validation definitions
The async LLM-judge job kind What "qualified" means for this program
RBAC, quotas, metering

We already have the seam — the onboarding provider registry and the plugins/beta-onboarding example. This design widens it and moves the source of truth to admin-editable storage.

1. Extensible data model

Slice 38 added company_address and phone as typed columns. That doesn't scale — every new program-specific field would need a migration on every deployment. Instead:

-- generic, forward-compatible
ALTER TABLE prospects ADD COLUMN attributes TEXT;  -- {"company_address":"…","platform":"PC"}

-- the experience itself is data, versioned + owned by a team
CREATE TABLE onboarding_experiences (
  id TEXT PRIMARY KEY,
  team_id TEXT NOT NULL,
  key TEXT NOT NULL,                       -- "beta", "founders", "wh40k"
  status TEXT NOT NULL DEFAULT 'draft',    -- draft | published
  config TEXT NOT NULL,                    -- whole experience; judge_system is server-only
  updated_by TEXT, updated_at TEXT
);

Field definitions get richer, so a plugin/admin can express real forms — the judge prompt and the frontend are both generated by iterating these:

{ "key":"platform", "label":"Primary platform", "type":"select",
  "options":["PC","PS5","Xbox"], "required":true,
  "help":"Where you'll test", "width":"half",
  "validate":{ "pattern":"…", "max":120 }, "group":"Your setup" }

Compatibility. The existing typed columns stay and keep working; new fields land in attributes. No backfill, no breakage — row->prospect merges typed columns and parsed attributes into one hash for the judge and the review UI.

2. Configurable by the admin, at runtime

An owner/operator (settings:manage) edits the experience in the console — no redeploy, no env var. Resolution order when serving the root URL:

  1. Published experience in the DB for the team (admin-authored) — wins.
  2. else a plugin-registered provider (the shipped default / template).
  3. else the core default (a minimal name/email form) — or nothing, if no plugin.

Endpoints (admin-gated) alongside the existing public ones:

# public (unchanged contract, richer payload)
GET  /api/beta/config          → published experience, PUBLIC slice (no judge_system)
GET  /api/beta/challenge       → anti-abuse challenge
POST /api/beta/signup          → capture (fields → attributes)

# admin (settings:manage)
GET  /api/beta/experience      → full config incl. draft + judge_system
PUT  /api/beta/experience      → save draft
POST /api/beta/experience/publish
GET  /api/beta/config?draft=1  → live preview of the draft

The console gains an Onboarding editor: brand/logo, hero, header/footer, theme tokens, the field builder, and the judge prompt — with a live preview pane (it renders the same landing component against the draft config).

ENV launch defaults (first-boot)

A deployer must be able to bring the system up with the funnel already configured, before anyone logs in to edit it. So resolution has a base experience layer beneath the DB:

resolve-experience(team) =
    published DB row                                  # admin runtime authority
  else base-experience = merge(registered provider,   # TELEMACHUS_ONBOARDING selects it
                               TELEMACHUS_ONBOARDING_FILE)  # JSON of launch defaults, overrides on top

Seed-then-own semantics. On first boot there is no DB row, so the base (provider

This keeps config-as-a-file, no external service — consistent with the deterministic-deps tenet.

3. The render contract — three tiers

Tier Author supplies Flexibility Safe by default Effort
A · Slots + tokens (built-in) Structured config: logo, hero, header, footer, detail blocks (markdown), fields, theme tokens High — covers the reference pages Yes — no arbitrary HTML/JS Low (admin, no code)
B · Static bundle (plugin) Its own landing/ HTML+CSS+assets; calls our JS SDK Total — pixel-perfect Sandboxed plugin + CSP Medium (plugin author)
C · Custom template (shipped) HTML with {{placeholders}} authored in the console, rendered in a sandboxed iframe Very high Sanitized and sandboxed Low (admin)

Recommendation. Ship Tier A as the default (safe, no-code, live-previewable, and enough for the reference pages), and Tier B for teams that want their own frontend — the plugin drops a static bundle and calls a small public client SDK. Tier C lets an admin author raw HTML with {{placeholders}} in the console (no plugin, no filesystem). It is defended twice: the server sanitizes the markup (strips <script>, on* handlers, javascript: URIs, and structural/active tags) and substitutes escaped values, then it renders inside a sandboxed, opaque-origin iframe (allow-scripts, no allow-same-origin) with our trusted submission bootstrap as the only script — so even a sanitizer bypass runs isolated from our origin (no access to the operator's token, cookies, or the parent DOM) and can only reach the already-public, rate-limited beta API (CORS-enabled for it).

The client SDK is what makes Tier B clean — a custom page never re-implements anti-abuse:

window.Telemachus.beta.config()        // fields, theme, copy
window.Telemachus.beta.challenge()     // PoW + signed challenge
window.Telemachus.beta.submit(values)  // solves PoW, posts, returns {ok}

4. Theming — a token contract, scoped and self-hosted

The experience carries a theme object; the landing renders under a scoped root (.beta-experience) that maps those into CSS custom properties, isolated from the app chrome. Everything downstream reads only these tokens, so reskinning is data:

"theme": {
  "brand":"#ffb200", "brandInk":"#161009",
  "bg":"#0a0a0b", "surface":"#16130d", "ink":"#f7f3e8", "muted":"#b6a98a",
  "radius":"3px", "fontDisplay":"Cinzel", "fontBody":"Georgia",
  "logo":"asset://logo.svg", "heroImage":"asset://keyart.jpg", "mode":"dark"
}

4b. Turning fields off, and adding new ones

The form is the experience document's fields list, and that list is now the whole truth. Two things had to become true for that to hold — both were bugs hiding behind each other:

email is structural(ONB‑9) — the only field the config cannot remove. The whole anti-abuse model is keyed on it (per-email and per-domain velocity caps, the disposable-address check, the free-provider signal the judge weighs) and a prospect is identified by it, so an instance that stopped collecting it would silently lose its dedup and its rate limiting. Everything else, name included, is the operator's call.

Adding a field needs no migration. Any key outside reserved-field-keys lands in the prospect's attributes JSON blob, and Admin › Beta renders prospect details generically from the field definitions — so a new field appears in the review table with no code change. examples/onboarding-jp-corporate.json is a worked example: a Japanese-market funnel that drops revenue and collects 法人番号 instead.

The validation vocabulary is deliberately small

Key Meaning
required must be answered
digits value is ASCII digits only
minlength / maxlength bounds on length

There is no regex, on purpose. A pattern would be admin-authored and then run against attacker-chosen input on an unauthenticated public endpoint — a catastrophic-backtracking foot-gun aimed squarely at the funnel. This is the same call the workflow binding sublanguage makes (frozen, no arithmetic, no eval, "write a tool" as the escape hatch). These four cover the shapes a signup form needs: a 13-digit 法人番号 is digits + minlength/maxlength of 13.

A quoted number ("minlength": "13") is accepted as well as 13. Hand-written JSON will eventually quote one, and silently dropping the constraint is a far worse failure than honouring it.

The refusal names the field using its localized label — 「法人番号を入力して ください。」, not corporate_number — which is why the server localizes the experience before validating rather than after.

The browser gets required, minlength, maxlength and inputmode="numeric" from the same definitions, but only for what the browser can uniquely do: cap typing, pick a numeric keypad, mark a field for assistive tech. The server is the authority on acceptance; duplicating the messages client-side would drift from locales/*.json the first time one changed.

5. Localization — an overlay on the document, not a catalog

The funnel's copy is operator-authored marketing text, so it cannot live in locales/*.json alongside shipped product strings: a deployment replaces it wholesale. It is a per-locale overlay on the same experience document:

{ "title": "Join the Telemachus beta",
  "cta":   "Request access",
  "fields": [{"key":"email","label":"Work email","type":"email","required":true}],
  "i18n": {
    "ja": { "title": "Telemachus ベータ版に参加する",
            "cta":   "アクセスを申請",
            "fields": { "email": {"label": "勤務先メールアドレス"} } } } }

The base document stays exactly what it is today — the default-locale copy — so an experience with no i18n key behaves byte for byte as before. That is what makes this safe to ship over live funnels, and it means a deployment localizes by adding one key to TELEMACHUS_ONBOARDING_FILE.

Translation is presentation only.Decided (ONB‑8). An overlay may replace title, subtitle, eyebrow, cta, footer, logo, nav, details, and — matched by key — a field's label and options. It may not rename a field key, change a type, flip required, or touch judge-system, theme, landing or template. So the submitted body and the anti-abuse configuration are identical in every language by construction, not by review. This is why doBetaSignup() can fetch the field list without a locale at all.

Resolution is ?lang=X-Telemachus-Locale → the instance default (LOC‑7). ?lang= comes first deliberately: a funnel is a page people are linked to, so the switcher has to leave a shareable URL behind, and there is no signed-in console state to carry a preference. A locale with no overlay falls back whole — never a half-translated page — and ja-JP reaches a ja overlay.

The switcher offers locales, which the server derives from the document (default + every overlay that carries content). It can therefore never advertise a language the funnel has no copy for. It disappears entirely when the operator has turned negotiation off instance-wide, or when there is only one language.

Three kinds of string, three homes — worth keeping straight:

String Lives in Localized by
Funnel copy (title, labels, options) the experience document an i18n overlay, per deployment
Funnel chrome ("Thanks — your request is in review") the console's const L shipped catalogs
Server refusals ("a valid email is required") surface/messages.rktlocales/*.json shipped catalogs

The third was bare English until this change, which meant a Japanese applicant who mistyped an address got an English error on an otherwise Japanese page — the most likely message on the form, untranslated. The honeypot's fake success uses the same localized string as the real one, because it has to be indistinguishable.

The public slice ships one language: the resolved locale and the available locales, never the overlay table (and never the judge prompt).

bash test/e2e/funnel-l10n.sh    # 16 browser assertions: switcher, repaint, submit path

Security & the tenets

Slice plan

Each slice ships, tests, and demos on its own — same cadence as the rest of the port.

Slice Title Contents
39 Extensible model attributes JSON on prospects; generic field→attributes capture; judge prompt + review UI iterate definitions. Retires per-field columns as the pattern.
40 Experience storage + admin API onboarding_experiences table, draft/publish, resolution precedence, GET/PUT/publish endpoints, RBAC.
41 Tier-A skinnable shell Scoped token theming; logo/hero/header/footer/detail slots; the console Onboarding editor with live preview.
42 Assets + fonts Local asset store (asset://), size/type limits, font allowlist / @font-face upload.
43 Tier-B bundle + client SDK window.Telemachus.beta SDK; plugin static-bundle landing served through the sandbox + CSP.
44 Tier-C templates (shipped) Console-authored HTML with {{placeholders}}, sanitized server-side and rendered in a sandboxed opaque-origin iframe; CORS on the public beta API so the isolated frame can submit.

Decisions to confirm

Nothing here changes what the platform requires — only what it permits.