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 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.
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.
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:
email (velocity/dedup), name,
status, judge, created_epoch,
signals.attributes — a
JSON blob keyed by field key. Company, phone, address, and
any custom field all live here (JSONB on Postgres, TEXT on SQLite — same
dialect-neutral pattern as signals).-- 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.
An owner/operator (settings:manage) edits the experience
in the console — no redeploy, no env var. Resolution order when serving
the root URL:
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).
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
TELEMACHUS_ONBOARDING — name of the registered provider
to use as the base.TELEMACHUS_ONBOARDING_FILE — path to a JSON file
describing the experience (title, subtitle, fields,
judge_system, and later theme/hero/header/footer). Its keys
override the provider's. Human-authored JSON may use
judge_system; it is normalized to the internal key.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.
| 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}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"
}@font-face upload.onboarding_assets store (or the existing documents store)
with size/type limits, referenced as asset://….mode; a branded page is usually committed to one look,
independent of the viewer's app theme.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:
required is enforced from the config.
It used to render a * and nothing checked it, so a field an
operator marked mandatory was optional in practice.name no matter what the form
actually showed, so removing name produced a funnel nobody
could submit — 400 name is required against a form with no
name box. Turning a field off did not work at all.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.
| 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.
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.rkt → locales/*.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 pathsettings:manage, self-hosted). The
audience is untrusted prospects. Config-driven content renders
as data (Tier A escapes/sanitizes markdown — no inline script from
config); Tier B runs in the existing sandboxed-plugin
mechanism under a strict CSP; Tier C would require an iframe sandbox +
sanitizer before it ships.judge_system is never in the public
/api/beta/config (already true; preserved)./api/beta/signup gate, so a custom
frontend can't opt out of PoW/velocity/honeypot.attributes blob capped.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. |
/beta/wh40k, /beta/founders)?
The storage model supports many; only routing is open.
Recommendation: allow many, route by key, default
one at root.@font-face upload, or both? Recommendation: both —
a small bundled allowlist now, upload in slice 42.Nothing here changes what the platform requires — only what it permits.