← Design notes

Studio — building a persona

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

1 · What the Studio is

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.

A persona is pure data — 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.

2 · The one machine

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:

switchin codewhat it controls
Referent classbtype = public · supplied · invented; identity kind = person | characterWho 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 poolmetas = ingest_metas + web_metasWeb 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 metercoverage_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.
Imaginationdecide_imagination()faithful · extended · freeSystem-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).
Provenanceinline 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.

The cap and the notch

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.

REFERENT CLASS ← the reachable span (ceiling) · ● recommended notch revered figure faithful is ceiling & floor real person extended is the ceiling ≥ 25 verified items pulls the notch here character · invented its own ground truth faithful extended free
The coral bar is how far the class lets invention reach; the filled dot is where 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.

Provenance — the made-from bar

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.

MADE FROM — example, extended notch web · 40% your sources · 20% AI fills · 40% The solid attested share (web + your sources) grows toward 85% as the notch moves to faithful; the AI-fills remainder shrinks.

3 · The build pipeline

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.

Identify Gather Author Verify Publish
#stage · labelwhat it doesruns on
1Identity lock identitySERP 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
2Ingest your sources ingestOnly 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
3Gather gatherPer-facet loop over five facets (biography · worldview · interviews · manner · positions): PLAN → SEARCH → TRIAGE → FETCH+extract → MINE → VERIFY → GATE, up to three rounds.v4-flash
4Coverage probe coverageOne thinking call — "what famous episodes of this life are missing?" — seeds a targeted gather round for the gaps quota-hunting skipped.v4-pro
5Curate curateWrites sources.md (reliability tiers · conflicts · a knowledge-horizon paragraph); drops lock-mismatched URLs from disk.v4-pro
6Coverage 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
7Author authorWrites profile.md + tags.txt from the kit template and the corpus, with stacked overlays (supplied / character / revered). Code owns the slug + ULID.v4-pro
8Mechanical checks checksFrontmatter card keys · 9 required sections · banned analyst labels · quote oracle (verbatim) · tags rubric · single-source shingle scan.code
9Patch patchRepairs against the findings list — and is never allowed to leave the profile mechanically worse (net-harm patches revert).v4-pro
10Cold audit ×2 auditTwo 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
11Built publishComputes 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
2026-08-15A build is not atomic — so the end of one announces itself. The room app's persona library memoises its slug set on the persona roots' mtimes (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.

The per-step model matrix

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").

stagemodel · thinking (default preset)
identityv4-pro · off
gather · ingestv4-flash · off
curate · coveragev4-pro · high
authorv4-pro · high
patchv4-pro · off
audit A / audit Bv4-pro · high  /  gemini-3.5-flash · high  (hidden in the UI)

4 · The Studio home

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.

The ⋮ menu — status-appropriate

stateactions
built (published-to-you)Built summary (opens the step-4 report) · Delete
parked probeContinue · Delete
interruptedResume · Delete
failed / abortedRestart · Delete
deletedRestore 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.

Where a rebuilt persona goes — the adopt rule (2026-08-17)

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.

The second-order failure is the one worth carrying: that overwrite also defeated 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 slug is minted, never derived (owner's design call, same day)

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.

The file model is not the flaw; using a name-derived string as the identity key is. A database would not fix names — it would force a primary key that isn't the name. That same move is available on disk: an app-built slug is now <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.
casebeforenow
build → delete → rebuild, same namerena 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 namesecond gets -2 — leaking that a private one existsno collision, no leak
same name as a public personaalan-turing-2same — 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.

How it's embedded in the app

5 · The building page

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.

Level-one progress — hiding the internals

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.

REGULAR USER — level one Identity lock Ingest your sources Gather Author Cold audit ×2 Built verdict · built-from bar only ADMIN — studio_details ON Gather v4-flash 5 facets · 41 verified quotes · 12 domains ✓ biography✓ worldview◔ manner (thin) Author v4-pro profile.md + tags.txt · extended notch Cold audit ×2 seat A · seat B — worst-of verdict then: coverage-by-facet · audit.md · sources.md · the 4-prompt live-room test checklist · the cost bill
The same build, two audiences. 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.

You can leave — we'll notify you

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.

The finished report

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.

6 · Visibility & ownership

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.

Two axes × two values — visibility: 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

Studio · the persona factory, as shipped · built & live, native in the app since v804 (Discover › Persona studio). This page folds together four earlier notes (persona-builder v1 · builder-v2 "one machine" · the v2 UI mockup · Persona Studio home) into one current-implementation record. Source of truth: lib/builder.py · lib/builder-ui.html · lib/run_room.py. Related: Persona visibility · Vibes content engine · Current functions.