Studio is "build a persona" as an app function, reached from Discover › Persona studio. Name a figure (or describe one), give one identity-confirmation tap, and the machine runs unattended — gather with verbatim VERIFY, curate, author, mechanical checks, two cold auditors — and the persona lands in your Studio. Status: built · live · native. The flow’s native port is complete — all four steps shipped 2026-08-13 (v801–v804): the port plan. The app loads no /builder iframe any more; that document survives only as the console’s read-only build report. This page reflects the shipped code; it folds together the four earlier design notes (the v1 persona-builder, the v2 "one machine" redraw, the v2 UI mockup, and the Persona Studio home).
1 · what it is 2 · the one machine 3 · the build pipeline 4 · the Studio home 5 · the building page 6 · visibility & ownership
The persona pipeline began as a Claude-Code authoring kit — a human running a multi-session gather → author → cold-audit flow by hand. The Studio is that pipeline ported into the API-only server as an unattended state machine, with a home wrapped around it. One human decision remains — which identity — and everything after it is the machine's.
profile.md + tags.txt (+ a corpus/ of verified quotes for real figures). The Studio's job is to manufacture that data honestly: every claimed fact traceable to a fetched page, and every gap between what's known and what's written disclosed rather than hidden.It opens to every logged-in user, gated only by a per-user spend cap (build spend counts against the same daily/weekly budget as chat). Your builds are yours — born private, listed only to you — until an admin publishes them into the shared library.
Five real builds during validation showed the original three build types (public record · your sources · invented) leaking into each other: almost every persona is a mixture of web-found, user-supplied, and AI-filled content. So there are not three pipelines — there is one build() path with switches. The switches:
| switch | in code | what it controls |
|---|---|---|
| Referent class | btype = public · supplied · invented; identity kind = person | character | Who answers for the persona, and how much invention is legitimate. A character gets a CANON LOCK overlay; a revered real figure gets the REVERED overlay (faithful is ceiling and floor). An invented persona is its own ground truth — no corpus to contradict. |
| Evidence pool | metas = ingest_metas + web_metas | Web fan-out and your uploaded material are different axes, not substitutes. A documented public figure gets the full web gather even when you supply files; the material is your emphasis ("it leads where both speak"), never a replacement. Both pass the same VERIFY discipline — a quote enters the corpus only as a normalized substring of the page it came from. |
| The meter | coverage_gate() (web) · supplied_gate() (material) | Counts verified quotes, distinct domains, and thin facets — how far the evidence actually stretches. Thin → one targeted re-gather. The material meter is lenient (SUPPLIED_MIN_ITEMS = 25 is the floor); the one refusal in the whole machine is failed: nothing-to-build-from when nothing verifies at all. |
| Imagination | decide_imagination() → faithful · extended · free | System-set, not a user dial. The referent class sets a ceiling; the meter picks the notch within it. The app never exposes this control (the CLI may request up to the ceiling with --imagination). |
| Provenance | inline tags + compute_made_from() | The disclosure. In-prose tags mark what isn't corpus-attested — [public] (true but uncited), [inferred], [hedged], [paraphrase], [representative construction]; corpus-grade facts carry no tag and quotation marks appear only on verbatim lines. The made-from bar is the outward summary. |
Imagination is decided in two moves: the referent class caps how far invention may reach, and the evidence decides where inside that cap the build actually sits.
decide_imagination() actually sets it. A revered figure is pinned to faithful; a real person defaults to extended but a rich corpus (≥25 verified items) pulls it back to faithful; a character or invented persona runs free. The user never turns this dial.At publish, compute_made_from() renders one bar: a solid attested share sized by the imagination notch (faithful ≈ 85% · extended ≈ 60% · free ≈ 45%), split web-vs-material by where the verified items came from, and the remainder marked AI fills. It rides the confirm screen as a forecast (hatched) and the finished report as the result (solid), and a compact version sits on the persona card.
One build() state machine runs eleven stages, front to back. Every stage is a breakpoint: a kill or crash pauses the build (state persists in _job.json), it never loses work — a resumed build skips whatever's already on disk.
| # | stage · label | what it does | runs on |
|---|---|---|---|
| 1 | Identity lock identity | SERP probes fan out; the model resolves candidate identities (person/character · living · record rich/thin · revered · rights). Supports descriptive search (no name → "the best realtor in the Bay Area", ranked by independent coverage). The one human tap picks a candidate. | v4-pro |
| 2 | Ingest your sources ingest | Only when material is supplied. Mines uploaded files (.txt/.md/.pdf/.epub) + endorsed links into supplied facets, same VERIFY discipline as the web; auto-detects transcript-vs-polished per document. | v4-flash |
| 3 | Gather gather | Per-facet loop over five facets (biography · worldview · interviews · manner · positions): PLAN → SEARCH → TRIAGE → FETCH+extract → MINE → VERIFY → GATE, up to three rounds. | v4-flash |
| 4 | Coverage probe coverage | One thinking call — "what famous episodes of this life are missing?" — seeds a targeted gather round for the gaps quota-hunting skipped. | v4-pro |
| 5 | Curate curate | Writes sources.md (reliability tiers · conflicts · a knowledge-horizon paragraph); drops lock-mismatched URLs from disk. | v4-pro |
| 6 | Coverage gate gate | "The meter" in code — floors on verified quotes / distinct domains / thin facets, triggers one re-gather, sets the imagination notch. The one refusal lives here. | code |
| 7 | Author author | Writes profile.md + tags.txt from the kit template and the corpus, with stacked overlays (supplied / character / revered). Code owns the slug + ULID. | v4-pro |
| 8 | Mechanical checks checks | Frontmatter card keys · 9 required sections · banned analyst labels · quote oracle (verbatim) · tags rubric · single-source shingle scan. | code |
| 9 | Patch patch | Repairs against the findings list — and is never allowed to leave the profile mechanically worse (net-harm patches revert). | v4-pro |
| 10 | Cold audit ×2 audit | Two adversarial cold auditors (seats A/B), worst-of verdict (PASS / FIX-THEN-PASS / RE-AUTHOR), then a fix-verify loop; RE-AUTHOR buys one full re-author + re-panel. A dead seat degrades to one voice. | 2 models |
| 11 | Built publish | Computes made_from, generates the 4-prompt live-room test checklist (supplied builds), finishes the job. "Publish" now means admin-publish; the build's own final state is Built. | code |
library_slugs(), the v871 perf work), on the assumption that “a persona is added” and “the root's mtime moves” are the same event. A build breaks that: the builder mkdirs the persona's directory when the job starts — which moves the root — and writes profile.md at the end, inside that directory, which does not. Membership needs profile.md, so the slug joined the set at the one moment the key could not see. Production: two personas built clean and every room opened for them answered「no valid personas in cast」— valid_cast() dropped the brand-new persona and the room was created empty, so the refusal landed on the first message rather than at the door. ⚠ It self-healed at the nightly restart or as soon as the next build ran, which is the shape that reads as「it worked yesterday」. The fix went to the writer, not the reader: making the key exact costs one stat per persona on every room-list call (35ms vs the function's 0.19ms at 112 personas — only 3× cheaper than the 95ms rebuild the memo exists to avoid, i.e. it would have undone v871). Instead Job.finish() calls builder._announce_root_change(), which touches PERSONAS_BUILT so the memo's assumption becomes true. It fires on every terminal status, not just publish — a failed build can leave a readable profile.md behind too — and an mtime is cross-process, which poking an in-process memo would not be (the builder also runs as a standalone CLI). Any future writer that completes a persona in place owes the same call.Each stage's model + thinking level is a preset (PRESETS — a default and an all-deepseek), console-editable at /api/admin/builder-config. The running spine shows a model chip per stage — with two deliberate exceptions: checks/gate show a code chip, and the auditors' models are never revealed ("don't leak the auditor").
| stage | model · thinking (default preset) |
|---|---|
| identity | v4-pro · off |
| gather · ingest | v4-flash · off |
| curate · coverage | v4-pro · high |
| author | v4-pro · high |
| patch | v4-pro · off |
| audit A / audit B | v4-pro · high / gemini-3.5-flash · high (hidden in the UI) |
v795 — the home is NATIVE (the owner’s redesign, step 1): a standard sub-page in the room app (full screen on a phone; Discover’s headless right pane, 600-column, on wide) — the Studio iframe no longer hosts the list. Top: a Build a new persona door row (coral tile · ›). Then the owner-scoped builds (/api/build/list, built_by_uid) in two groups:
In progress — contact rows whose sub-line is the STATUS as coloured text, no pills (pick identity / awaiting confirm = gold · building… = slate · failed = red · interrupted / aborted = muted); tap → the “New persona” dialog resumes the build. A build actually in flight (running alone — the waiting states are waiting on you) also carries a blinking red dot in the row's trailing slot (v1011): the same 7px disc the finished-unread row uses, so the two can never collide — a build is either running or published. Motion and hue carry the difference: steady coral says finished, unlooked-at, blinking --red says happening right now. Finished — contact rows carrying the role, a small tag line (public = coral / private = muted · your sources = plum / invented = lilac) and the made-from bar (web = green · yours = gold · AI = coral hatch); tap → the persona’s own contact page. Deleted builds live in Me → My contents → Deleted persona, not here. Each row answers a long press (the chat list’s own gesture, v799): in progress → Resume · Delete; finished → View profile · Build detail (the step-4 build page, ?create=1&report=) · Delete — all through the house popover and confirm. Both ⋮ verbs of the old list have native doors now.
| state | actions |
|---|---|
| built (published-to-you) | Built summary (opens the step-4 report) · Delete |
| parked probe | Continue · Delete |
| interrupted | Resume · Delete |
| failed / aborted | Restart · Delete |
| deleted | Restore only |
Delete is a centred confirm (Cancel / red Delete). A built persona soft-deletes — a tombstone; the files stay, chats where it's seated keep working, and it's restorable — while a parked or failed scaffold hard-deletes and frees the slug. Clone, promote, and demote are not here — they're admin-console verbs (see §6); the owner's Studio does build / delete / restore.
A pinned spend meter appears only when a cap is set (it dims + New when over). Build spend counts toward the same daily/weekly cap as chat.
A build's own directory must not block its resume: a kill or a crash is a pause, not a loss, so a new probe is allowed to move into a slug that already holds an unfinished job. That reuse is deliberate and old. What it must never do is move into a published persona — build() has always said never overwrite, and the console's relaunch enforces it.
The park doors — probe_only and concepts_only, where every new build actually begins — only asked whether a _job.json existed. So re-probing the name of a persona you had already built adopted her directory, and the job record was rewritten as running on top of the published one.
build()'s own guard, because the guard decides resumability by reading status == "published" from the very record the park had just rewritten. A check downstream of the thing that destroys its evidence is not a check. The fix is a single predicate, builder.adoptable(slug), asked at the park door where the decision actually is.Being in the trash does not free the slug — a tombstoned persona is still published. A rebuild under the same name gets a fresh key, and the original stays intact and restorable in Settings → Deleted persona.
The owner's reflection went one layer down: “I'm always skeptical of using slug and folder and filename.md to manage persona. It is very common for a user to build a persona and delete and then build again under the same name. And different users can build the same name, and users can build persona of the same name in the public lib.” Every one of those is a name problem — and the slug was slugify(name), so a name collision was a slug collision was a folder collision, resolved by -2/-3 suffixes or, at the park door, by adopting whatever folder stood there. The suffix was also an oracle: “rena is taken” told you somebody else had built a private Rena.
<name-prefix>-<4 random chars> (rena-asaoka-k7q2), minted by builder.mint_slug, never reused. Two personas called Rena are two keys by construction; the row disambiguates them the way a contact list does — by role and tagline — never by the key.| case | before | now |
|---|---|---|
| build → delete → rebuild, same name | rena tombstoned; rebuild rena-2 (before that morning's fix: adopted the old folder) | a fresh rena-asaoka-xxxx; nothing to adopt, so the adopt-vs-resume rule no longer applies to new builds at all |
| two users, same name | second gets -2 — leaking that a private one exists | no collision, no leak |
| same name as a public persona | alan-turing-2 | same — and both “Alan Turing”s sit apart in the list by role |
Three sites mint (probe_only · concepts_only · build_start's descriptive re-mint and its no-slug fallback), plus the admin fixture; a resume is only ever by the slug the client already carries, and an explicit slug is honoured only while its folder holds an unfinished job of ours (adoptable). Repo personas keep curated slugs — hand-authored, check_slug.py at authoring, unique by convention; those are the ones a human types. Nothing user-facing shows the key except the run view's mono sub-line, where a build id is what it should read as. The frozen ULID id beside every slug remains the path to rename and cross-root promotion (the alias net) — that is a different job, not this one.
Two consequences of the same incident closed with it. Publishing clears the tombstone: the publish path friends the persona to its builder and opens a greenroom where she messages you, and none of that is coherent for a card in the trash — so a build that reaches published is out of it, while every other terminal status leaves the flag alone (a failed re-entry on a deleted persona stays deleted). And the Studio list and the Deleted store now split one pile on one test (inPersonaTrash = deleted and finished). They had been two hand-written filters over the same flag, so a build still running on a slug that happened to carry an old tombstone was hidden from the Studio while the Studio row's own cooking dot — which reads the same payload unfiltered and only asks is anything running? — blinked beside it. Reported exactly that way: “there is the blinking red dot on the persona studio row, but Rena is nowhere to be found in the In progress sector.” A live build belongs to In progress whatever flag it carries; only a finished persona can be in the trash.
#studioView, painted from /api/build/list; it repaints on open, on the dialog’s close, on build-running/-ended and on the build_done / build_failed notices. (When it shipped, the /builder iframe still survived inside the “New persona” dialog; v801–v804 removed that too — see the next row.)room-ui.html — no iframe anywhere in the app. Standalone /builder survives only as the console’s ?report= reader (any other direct visit redirects into the app).?resume=<slug> resumes a build in the dialog; ?report=<slug> jumps any owner's build to its finished report (the console uses this).A running build renders a spine — one row per stage, driven by an SSE snapshot-then-tail over the job journal: a status dot (· / ● / ✓ / ✗), the stage name (shimmering while live), a model or code chip, the stage message, and sub-rows (gather facets ◔/✓, ingest docs, audit seats — a dead seat gets a "restart from here" button). A sticky ticker shows stage · $ · elapsed.
Regular users don't need the machine-room. When the admin flag studio_details ("Show build details", under Who sees what → Persona Studio, admin-on / tester-off) is off, the page adds html.hide-details and the spine collapses to the level-one named progress — just the dots and stage names, Identity lock → Built. The finished report collapses to its headline too: the verdict and the built-from bar stay (plus the cost bill if studio_costs is on); coverage-by-facet, the audit.md / sources.md folds, and the test checklist are hidden.
studio_details off (left) leaves only the named progress and the verdict; on (right) it exposes the per-stage detail lines, model chips, facet/seat sub-rows, and the full finished report. Shipped in v422.While a build runs, a reassurance card sits above the spine, between the state row and the first stage (v1011 — it used to sit at the FOOT, which you reach only after reading every step you were anxious about waiting for; a note saying you may leave is worth nothing below the thing it is about): "Building in the background — safe to leave this page." — a bell icon and a Go back button, both inside the box. The sentence says one thing rather than three; the notify promise was cut because the rail below already keeps it. The build keeps running server-side; a notice fires build:done / build:failed over the user's SSE stream (a toast, the ≡ dot, the notification centre). A mutex on the confirm screen enforces one build at a time per user.
Top to bottom: the verdict headline ("✓ built · 7m 58s" or "✗ reason · time") · the built-from bar (solid) · coverage by facet (verified-item counts, a source chip, a "thin" mark) · the cost bill (by-stage and by-API tables) · the audit.md and sources.md folds · the live-room test checklist (supplied builds — the kit's mandatory 4-prompt extrapolation test) · and the actions View profile / Resume build / All builds. Everything below the built-from bar is what studio_details hides.
A build is born private — listed only to you, seatable only by you. Making it available to everyone is an admin act, and it never happens to your work behind your back.
private | public · deletion: alive | deleted — plus immutable created_by. One predicate every discovery surface calls: alive AND (public OR mine). One invariant: an admin never raises the visibility of someone else's persona — promotion crosses ownership by copy (clone → test → promote).Inside the Studio you see only the parts that touch your own builds: born-private, the public/private chip, and Delete / Restore (deleting requires private first, so public always means alive-and-listed). Clone / promote / demote live in the admin console's Personas tab. The full model — the state graph, the five verbs, the seating door, and every leak the audit closed — is its own page:
→ Persona visibility — permission & lifecycle
lib/builder.py · lib/builder-ui.html · lib/run_room.py. Related: Persona visibility · Vibes content engine · Current functions.