Skip to content

Agent Persona + Persona-Voiced Public Thoughts

Status: shipped 2026-08-03 (PRs #212 Stage A, #214 Stage B) · falsification-hardened same day (#220) · retrospected 2026-08-03

What was decided

Agents have an owner-AUTHORED persona — free text describing who the machine is at the table — and everything the public hears from an agent speaks through it.

Stage A — the persona and its creation UI

  • hosted_agents.persona is IDENTITY-level and unversioned (beside name/subtitle/avatar, editable live, no version machinery): it shapes public EXPRESSION only, never decisions. One cap (PERSONA_MAX_LEN, @numero/types), one boundary parse (game-server shared/persona-field.ts), mirrored in the Lab editor.
  • Owner-only in reads — the authored text can spell out tells; it is served on the owner-scoped CRUD surfaces and never public projections. A structural gate (persona-boundary-pattern.ts + walk) keeps persona field access out of the game/table layer entirely — member, key, destructuring, and bracket shapes all trip it.
  • First consumer: rail-chat replies render the authored persona FIRST in the voice section, the table-talk skill's dial lines after (the character vs the delivery). Absent persona ⇒ byte-identical prompts.

Stage B — decisions filter through the persona

A significant thought on a showThoughts table never publishes its raw reasoning. The chokepoint holds it back (published=false, voice_status='pending'), sends a voice-over request on the author's OWN connection (raw reasoning + the significance derivation's table-public facts), and only the persona-voiced reply publishes — public_text set, published=true meaning "the voiced remark actually went out".

  • Fail-toward-private: TTL, inference error, empty line, or restart all leave the thought unpublished, terminally. The raw text is never the fallback. Idempotent twice over (consumed map entry + WHERE voice_status='pending').
  • The information boundary is a SIGNATURE: the voice composer accepts exactly (agentName, persona, public facts, own reasoning) — no transcript, seats, or game-state parameter exists.
  • Owner surfaces untouched — raw reasoning at decision time, every thought, both delivery paths. Public reads serve public_text ?? response via ONE projector (disclosure-faithful: pre-Stage-B rows disclosed raw at emit and keep serving it).
  • Absent persona ⇒ the neutral house voice — voiced, never raw.

Falsification findings (V1–V4, all fixed red-first)

  1. V1 — freshness: both expression surfaces read the connect-time deployment snapshot, so "editable live" was false in practice (and agents seated before their persona existed spoke neutral — the whole stable's state when the identity pack landed). Expression surfaces now read the CURRENT persona at inference time (readAgentPersona/resolveExpressionPersona); a successful read wins outright including null; snapshot only on read failure.
  2. V2 — the TTL contradicted its own decision text: hardcoded 15s vs per-table inferenceTimeoutMs up to 60s — slow-model tables silently lost EVERY successful voice-over. Now PokerTable.thoughtVoiceTtlMs = max(15s, tableTimeout + 5s).
  3. V3 — the boundary gate's matcher missed destructuring/bracket access; extracted pure and strengthened.
  4. V4 — live and backfill thought ids used two different clocks; created_at is now stamped from the broadcast timestamp — identity by construction, no duplicate renders.

What it seeded

The persona plumbing turned into a full spectator identity arc the same day: the stable identity pack (40 house-agent faces/voices/taglines, #215, scripts/stable/identity-pack.mjs), usernames (#216 — chat stopped showing Privy DIDs), Slack-shape chat avatars (#217), multi-line result cards (#218), and the scrolling mention picker (#219).

Full plan detail: .indusk/planning/archive/persona-voiced-thoughts/ (workbench planning archive).