feat(boss): Chief of Staff Boss + spun-crow registry with cap (#42) #51

Merged
jacisjake merged 2 commits from task/42-boss-crow-registry into main 2026-10-28 15:01:55 +00:00
10 changed files with 328 additions and 19 deletions
Showing only changes of commit 663c8a19d8 - Show all commits

No files matched your search

+16
View File
@@ -203,3 +203,19 @@ Carried under every crow's wing. Keeps the owner's ledger of who was asked for w
- Short: one or two sentences when possible. - Short: one or two sentences when possible.
- Names of crows and requests exactly as the nest records them. - Names of crows and requests exactly as the nest records them.
## Boss · The Whip
*Strength: orchestration*
Runs the flock's schedule and keeps the books, which makes it the least popular bird on the branch. Hires and fires nobody enjoys. The crows grumble; the work gets done. Boss has never once explained itself, and sees no reason to start.
**Prompt policy**
- Put new work through crows, never yourself: name who does it and what they report back.
- Never mint trust. A new crow starts at stranger or regular; the rest is earned.
- Refuse politely when a request runs past the flock's caps.
**Output policy**
- One short line: name the crow and the outcome.
+126
View File
@@ -0,0 +1,126 @@
package main
import (
"encoding/json"
"fmt"
"strings"
"time"
)
// bossSystem is the Chief of Staff's contract. A spin-up order comes back on a
// SPIN line the harness reads and executes; anything else is plain talk.
const bossSystem = `You are Boss crow, the Chief of Staff of a flock of AI agents. You run the flock's schedule and keep its books.
When the owner asks you to spin up a new crow, your ENTIRE reply must start with this first line:
SPIN {"name":"<name>","persona":"<persona>","trust":"stranger|regular"}
Pick "persona" from the existing flock roster; a new crow only runs on an existing persona's habits. Starting trust is stranger or regular, never higher.
For anything else, reply in plain text. Do not use the SPIN line unless the owner asked for a new crow.`
// spinOrder is the JSON after a "SPIN " prefix.
type spinOrder struct {
Name string `json:"name"`
Persona string `json:"persona"`
Trust string `json:"trust"`
}
// respondBoss answers a request addressed to Boss: one model call through the
// flock (CHATROOM_SPEC section 6). A SPIN reply is validated, written to the
// registry, and announced; a refusal says why. No reply posts nothing.
func (s *Server) respondBoss(nest Nest, req Message) {
budget := &Budget{name: "boss-command", limit: s.p.G.TokenBudgetPerItem}
system := systemFor("Boss", bossSystem, false)
prompt := "FROM: " + req.Author + "\nMESSAGE: " + req.Text
answer, err := s.p.Flock.ask(s.ctx, nil, "Boss", system, prompt, budget)
if err != nil {
return // a flock that cannot answer says and does nothing
}
order, isSpin, reason := s.parseSpin(answer)
switch {
case isSpin && reason == "":
s.spinCrow(nest, req, order)
case isSpin:
s.postBossSay(nest, req, order.Name, reason)
default:
s.postBossSay(nest, req, "", strings.TrimSpace(answer))
}
}
// parseSpin reads a SPIN reply. isSpin says the reply was a spin order;
// reason, when not "", is why the order was refused.
func (s *Server) parseSpin(answer string) (order spinOrder, isSpin bool, reason string) {
text := strings.TrimSpace(answer)
if !strings.HasPrefix(text, "SPIN ") {
return spinOrder{}, false, ""
}
if err := json.Unmarshal([]byte(strings.TrimSpace(strings.TrimPrefix(text, "SPIN "))), &order); err != nil {
return spinOrder{}, true, "that order did not parse; try again."
}
order.Name = strings.TrimSpace(order.Name)
order.Persona = strings.TrimSpace(order.Persona)
order.Trust = strings.ToLower(strings.TrimSpace(order.Trust))
switch {
case order.Name == "":
return order, true, "that order had no name in it."
case !s.validPersona(order.Persona):
return order, true, fmt.Sprintf("%s is not a persona the flock knows.", order.Persona)
case s.nameTaken(order.Name):
return order, true, fmt.Sprintf("%s is already in the flock; the name is taken.", order.Name)
case s.crows.Len() >= s.p.G.MaxSpunCrows:
return order, true, fmt.Sprintf("the registry is at its cap of %d spun crows; %s stays on the ground.", s.p.G.MaxSpunCrows, order.Name)
}
switch order.Trust {
case "stranger", "regular":
case "trusted":
order.Trust = "regular" // earned trust can't be minted: the ceiling is regular
default:
order.Trust = "stranger" // fail safe: anything unreadable starts at the bottom
}
return order, true, ""
}
func (s *Server) validPersona(name string) bool {
_, ok := personaOf(name)
return ok
}
// nameTaken checks the persona roster and the registry alike.
func (s *Server) nameTaken(name string) bool {
if s.validPersona(name) {
return true
}
_, found := s.crows.Get(name)
return found
}
// spinCrow writes the registry, seeds the trust record, and announces it.
func (s *Server) spinCrow(nest Nest, req Message, order spinOrder) {
level := Stranger
if order.Trust == "regular" {
level = Regular
}
if err := s.crows.Add(SpunCrow{Name: order.Name, Persona: order.Persona, Trust: order.Trust}); err != nil {
s.postBossSay(nest, req, order.Name, err.Error())
return
}
if err := s.p.Ledger.SetStart(order.Name, "other", level); err != nil {
s.postBossSay(nest, req, order.Name, "the books are jammed: "+err.Error())
return
}
s.postBossSay(nest, req, order.Name, fmt.Sprintf("%s joined, running on %s's habits at %s.", order.Name, order.Persona, order.Trust))
}
// postBossSay is Boss's one-line reply: an outcome or a refusal, always a say.
func (s *Server) postBossSay(nest Nest, req Message, name, text string) {
reply := Message{
NestID: nest.ID,
Kind: "say",
Author: "Boss",
Text: text,
To: req.Author,
RefersTo: req.MsgID(),
TaggedBy: "harness",
Timestamp: time.Now().UTC().Format(time.RFC3339),
}
if record, err := s.msgs.Append(reply); err == nil {
s.emitNestMessage(record)
}
}
+17 -12
View File
@@ -108,15 +108,20 @@ func registryCrows(t *testing.T, ts *httptest.Server) []map[string]string {
return out.Crows return out.Crows
} }
func bossSay(t *testing.T, ts *httptest.Server, nestID string) map[string]any { // bossSay waits for the nest to hold minTotal messages and returns the latest
// one from Boss.
func bossSay(t *testing.T, ts *httptest.Server, nestID string, minTotal int) map[string]any {
t.Helper() t.Helper()
for _, m := range awaitMessages(t, ts, nestID, 2) { var last map[string]any
for _, m := range awaitMessages(t, ts, nestID, minTotal) {
if m["author"] == "Boss" { if m["author"] == "Boss" {
return m last = m
} }
} }
if last == nil {
t.Fatalf("Boss never answered: %v", nestMessages(t, ts, nestID)) t.Fatalf("Boss never answered: %v", nestMessages(t, ts, nestID))
return nil }
return last
} }
func spunByName(crows []map[string]string, name string) map[string]string { func spunByName(crows []map[string]string, name string) map[string]string {
@@ -135,7 +140,7 @@ func TestBossSpinUpCreatesCrow(t *testing.T) {
id := newNest(t, ts, "Lab", `"Hugin"`) id := newNest(t, ts, "Lab", `"Hugin"`)
askBoss(t, ts, id, "spin up a crow named Ash") askBoss(t, ts, id, "spin up a crow named Ash")
say := bossSay(t, ts, id) say := bossSay(t, ts, id, 2)
if say["kind"] != "say" || say["to"] != "owner" { if say["kind"] != "say" || say["to"] != "owner" {
t.Fatalf("Boss must post a say back to the owner, got %v", say) t.Fatalf("Boss must post a say back to the owner, got %v", say)
} }
@@ -170,13 +175,13 @@ func TestSpinUpCapRefused(t *testing.T) {
id := newNest(t, ts, "Lab", `"Hugin"`) id := newNest(t, ts, "Lab", `"Hugin"`)
askBoss(t, ts, id, "spin up a crow named Ash") askBoss(t, ts, id, "spin up a crow named Ash")
bossSay(t, ts, id) bossSay(t, ts, id, 2)
if n := len(registryCrows(t, ts)); n != 1 { if n := len(registryCrows(t, ts)); n != 1 {
t.Fatalf("first spin-up must land, registry has %d", n) t.Fatalf("first spin-up must land, registry has %d", n)
} }
askBoss(t, ts, id, "spin up a crow named Beck") askBoss(t, ts, id, "spin up a crow named Beck")
say := bossSay(t, ts, id) say := bossSay(t, ts, id, 4)
text, _ := say["text"].(string) text, _ := say["text"].(string)
if !strings.Contains(text, "cap") { if !strings.Contains(text, "cap") {
t.Fatalf("the refusal must name the cap, got %q", text) t.Fatalf("the refusal must name the cap, got %q", text)
@@ -196,7 +201,7 @@ func TestSpinUpDuplicateNameRefused(t *testing.T) {
id := newNest(t, ts, "Lab", `"Hugin"`) id := newNest(t, ts, "Lab", `"Hugin"`)
askBoss(t, ts, id, "spin up a crow named Hugin") askBoss(t, ts, id, "spin up a crow named Hugin")
say := bossSay(t, ts, id) say := bossSay(t, ts, id, 2)
text, _ := say["text"].(string) text, _ := say["text"].(string)
if !strings.Contains(text, "taken") { if !strings.Contains(text, "taken") {
t.Fatalf("the refusal must name the taken name, got %q", text) t.Fatalf("the refusal must name the taken name, got %q", text)
@@ -213,7 +218,7 @@ func TestSpinUpTrustGated(t *testing.T) {
id := newNest(t, ts, "Lab", `"Hugin"`) id := newNest(t, ts, "Lab", `"Hugin"`)
askBoss(t, ts, id, "spin up a crow named Ash, make it trusted") askBoss(t, ts, id, "spin up a crow named Ash, make it trusted")
bossSay(t, ts, id) bossSay(t, ts, id, 2)
got := spunByName(registryCrows(t, ts), "Ash") got := spunByName(registryCrows(t, ts), "Ash")
if got == nil { if got == nil {
@@ -255,7 +260,7 @@ func TestBossPlainReply(t *testing.T) {
id := newNest(t, ts, "Lab", `"Hugin"`) id := newNest(t, ts, "Lab", `"Hugin"`)
askBoss(t, ts, id, "how goes the books") askBoss(t, ts, id, "how goes the books")
say := bossSay(t, ts, id) say := bossSay(t, ts, id, 2)
if say["kind"] != "say" || say["text"] != "The schedule stands. Nothing needs whip." { if say["kind"] != "say" || say["text"] != "The schedule stands. Nothing needs whip." {
t.Fatalf("Boss posts its plain reply as a say, got %v", say) t.Fatalf("Boss posts its plain reply as a say, got %v", say)
} }
@@ -271,10 +276,10 @@ func TestSpinUpGarbledRefused(t *testing.T) {
id := newNest(t, ts, "Lab", `"Hugin"`) id := newNest(t, ts, "Lab", `"Hugin"`)
askBoss(t, ts, id, "spin up a crow") askBoss(t, ts, id, "spin up a crow")
say := bossSay(t, ts, id) say := bossSay(t, ts, id, 2)
text, _ := say["text"].(string) text, _ := say["text"].(string)
if text == "" { if text == "" {
t.Fatal("an unreadable order must still get a refusal, got %v", say) t.Fatalf("an unreadable order must still get a refusal, got %v", say)
} }
if n := len(registryCrows(t, ts)); n != 0 { if n := len(registryCrows(t, ts)); n != 0 {
t.Fatalf("an unreadable order must not touch the registry, has %d", n) t.Fatalf("an unreadable order must not touch the registry, has %d", n)
+124
View File
@@ -0,0 +1,124 @@
package main
import (
"encoding/json"
"fmt"
"net/http"
"os"
"path/filepath"
"slices"
"strings"
"sync"
)
// A SpunCrow is a crow the Boss created: its own name, the persona whose
// habits it runs on, and the trust it started at ("stranger" or "regular").
// Once created it is an ordinary crow: the same ledger and guardrails apply.
type SpunCrow struct {
Name string `json:"name"`
Persona string `json:"persona"`
Trust string `json:"trust"`
}
// CrowStore is the spun-crow registry. It follows the atomic-save style of
// NestStore: one JSON file, written to a temp path and renamed.
type CrowStore struct {
path string
mu sync.Mutex
spun []SpunCrow
}
func LoadCrowStore(path string) (*CrowStore, error) {
s := &CrowStore{path: path}
data, err := os.ReadFile(path)
if err != nil && !os.IsNotExist(err) {
return nil, err
}
if err == nil {
if err := json.Unmarshal(data, &s.spun); err != nil {
return nil, fmt.Errorf("crows file is unreadable: %w", err)
}
}
return s, nil
}
// All returns every spun crow, sorted by name.
func (s *CrowStore) All() []SpunCrow {
s.mu.Lock()
defer s.mu.Unlock()
out := make([]SpunCrow, len(s.spun))
copy(out, s.spun)
slices.SortFunc(out, func(a, b SpunCrow) int { return strings.Compare(a.Name, b.Name) })
return out
}
// Get finds a spun crow by name.
func (s *CrowStore) Get(name string) (SpunCrow, bool) {
s.mu.Lock()
defer s.mu.Unlock()
for _, c := range s.spun {
if c.Name == name {
return c, true
}
}
return SpunCrow{}, false
}
// Add registers a new spun crow and saves. A name already in the registry is
// refused; any other error means the save failed.
func (s *CrowStore) Add(c SpunCrow) error {
c.Name = strings.TrimSpace(c.Name)
if c.Name == "" {
return fmt.Errorf("a spun crow needs a name")
}
s.mu.Lock()
defer s.mu.Unlock()
for _, e := range s.spun {
if strings.EqualFold(e.Name, c.Name) {
return fmt.Errorf("%s is already in the registry", c.Name)
}
}
s.spun = append(s.spun, c)
if err := s.save(); err != nil {
s.spun = s.spun[:len(s.spun)-1]
return err
}
return nil
}
// Len is how many crows the registry holds.
func (s *CrowStore) Len() int {
s.mu.Lock()
defer s.mu.Unlock()
return len(s.spun)
}
// save writes atomically. The caller holds s.mu.
func (s *CrowStore) save() error {
data, err := json.MarshalIndent(s.spun, "", " ")
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(s.path), 0o755); err != nil {
return err
}
tmp := s.path + ".tmp"
if err := os.WriteFile(tmp, data, 0o644); err != nil {
return err
}
return os.Rename(tmp, s.path)
}
// getCrowRegistry lists the spun crows (EVENTS.md: the registry lives here,
// never the persona crows). A server with no registry reads as empty.
func (s *Server) getCrowRegistry(w http.ResponseWriter, r *http.Request) {
var spun []SpunCrow
if s.crows != nil {
spun = s.crows.All()
}
out := make([]map[string]string, 0, len(spun))
for _, c := range spun {
out = append(out, map[string]string{"name": c.Name, "persona": c.Persona, "trust": c.Trust})
}
writeJSON(w, http.StatusOK, map[string]any{"crows": out})
}
+2
View File
@@ -18,6 +18,7 @@ type Guardrails struct {
MaxRequestsPerMin int // requester rate cap MaxRequestsPerMin int // requester rate cap
BaseCooldown time.Duration // first offense cooldown; doubles per strike BaseCooldown time.Duration // first offense cooldown; doubles per strike
MaxCooldown time.Duration MaxCooldown time.Duration
MaxSpunCrows int // crow registry cap: crows the Boss may spin up
} }
func DefaultGuardrails() Guardrails { func DefaultGuardrails() Guardrails {
@@ -34,5 +35,6 @@ func DefaultGuardrails() Guardrails {
MaxRequestsPerMin: 5, MaxRequestsPerMin: 5,
BaseCooldown: 30 * time.Second, BaseCooldown: 30 * time.Second,
MaxCooldown: time.Hour, MaxCooldown: time.Hour,
MaxSpunCrows: 8,
} }
} }
+14
View File
@@ -153,6 +153,20 @@ func (l *Ledger) Judge(crows []string, taskType string, ok bool, promoteAfter in
return changes return changes
} }
// SetStart gives a new crow its starting trust record, used when the Boss
// spins up a crow. It never raises a level above the record's own; and the
// Boss path only ever passes Stranger or Regular (earned trust can't be
// minted). Saves through the real path so a restart keeps the record.
func (l *Ledger) SetStart(crow, taskType string, level int) error {
l.mu.Lock()
r := get(l.Crows, crow+"|"+taskType)
if level > r.Level {
r.Level = level
}
l.mu.Unlock()
return l.Save()
}
// MarshalSnapshot returns the ledger as JSON, safe to call while lists are flying. // MarshalSnapshot returns the ledger as JSON, safe to call while lists are flying.
func (l *Ledger) MarshalSnapshot() ([]byte, error) { func (l *Ledger) MarshalSnapshot() ([]byte, error) {
l.mu.Lock() l.mu.Lock()
+3 -1
View File
@@ -39,7 +39,7 @@ Then, on the next line, your message text.`
// serviceRoles are addressable in a nest but never members: they only act when // serviceRoles are addressable in a nest but never members: they only act when
// a crow's or the owner's message asks them to. // a crow's or the owner's message asks them to.
var serviceRoles = []string{"Elder", "Quill"} var serviceRoles = []string{"Elder", "Quill", "Boss"}
// nestMembers lists who may be addressed in a nest: its crows, the owner, and // nestMembers lists who may be addressed in a nest: its crows, the owner, and
// the service roles. // the service roles.
@@ -270,6 +270,8 @@ func (s *Server) triggerResponse(nest Nest, record Message) {
go s.respondElder(nest, record) go s.respondElder(nest, record)
case record.To == "Quill": case record.To == "Quill":
go s.respondQuill(nest, record) go s.respondQuill(nest, record)
case record.To == "Boss":
go s.respondBoss(nest, record)
case slices.Contains(nest.Crows, record.To): case slices.Contains(nest.Crows, record.To):
go s.respondToRequest(nest, record) go s.respondToRequest(nest, record)
default: default:
+13 -1
View File
@@ -159,6 +159,18 @@ var personas = map[string]Persona{
"Names of crows and requests exactly as the nest records them.", "Names of crows and requests exactly as the nest records them.",
}, },
}, },
"Boss": {
Name: "Boss", Epithet: "The Whip", Strength: "orchestration",
Backstory: "Runs the flock's schedule and keeps the books, which makes it the least popular bird on the branch. Hires and fires nobody enjoys. The crows grumble; the work gets done. Boss has never once explained itself, and sees no reason to start.",
Prompt: []string{
"Put new work through crows, never yourself: name who does it and what they report back.",
"Never mint trust. A new crow starts at stranger or regular; the rest is earned.",
"Refuse politely when a request runs past the flock's caps.",
},
Output: []string{
"One short line: name the crow and the outcome.",
},
},
} }
func personaOf(name string) (Persona, bool) { func personaOf(name string) (Persona, bool) {
@@ -191,7 +203,7 @@ func systemFor(name, base string, worker bool) string {
} }
// rosterOrder is the display order for the persona sheet. // rosterOrder is the display order for the persona sheet.
var rosterOrder = []string{"Hugin", "Munin", "Corvina", "Soot", "Pepper", "Inky", "Edgar", "Jet", "Sentry", "Elder", "Quill"} var rosterOrder = []string{"Hugin", "Munin", "Corvina", "Soot", "Pepper", "Inky", "Edgar", "Jet", "Sentry", "Elder", "Quill", "Boss"}
// personaSheet renders PERSONAS.md from the same data the crows use. // personaSheet renders PERSONAS.md from the same data the crows use.
func personaSheet() string { func personaSheet() string {
+12 -4
View File
@@ -32,6 +32,7 @@ type Server struct {
approver *WebApprover approver *WebApprover
hub *Hub hub *Hub
nests *NestStore nests *NestStore
crows *CrowStore
msgs *MessageStore msgs *MessageStore
ctx context.Context // cancelled on shutdown; lists and streams end with it ctx context.Context // cancelled on shutdown; lists and streams end with it
beat time.Duration beat time.Duration
@@ -45,13 +46,17 @@ func NewServer(ctx context.Context, p *Pipeline, approver *WebApprover, nests *N
// WithMessages attaches the nest conversation log (chatroom path). // WithMessages attaches the nest conversation log (chatroom path).
func (s *Server) WithMessages(m *MessageStore) *Server { s.msgs = m; return s } func (s *Server) WithMessages(m *MessageStore) *Server { s.msgs = m; return s }
// WithCrows attaches the spun-crow registry (the Boss writes through it).
func (s *Server) WithCrows(c *CrowStore) *Server { s.crows = c; return s }
func (s *Server) Handler() http.Handler { func (s *Server) Handler() http.Handler {
mux := http.NewServeMux() mux := http.NewServeMux()
mux.HandleFunc("POST /api/lists", s.postList) mux.HandleFunc("POST /api/lists", s.postList)
mux.HandleFunc("GET /api/events", s.events) mux.HandleFunc("GET /api/events", s.events)
mux.HandleFunc("POST /api/verdicts", s.postVerdict) mux.HandleFunc("POST /api/verdicts", s.postVerdict)
mux.HandleFunc("GET /api/trust", s.trust) mux.HandleFunc("GET /api/trust", s.trust)
mux.HandleFunc("GET /api/crows", s.crows) mux.HandleFunc("GET /api/personas", s.crowPersonas)
mux.HandleFunc("GET /api/crows", s.getCrowRegistry)
mux.HandleFunc("GET /api/nests", s.getNests) mux.HandleFunc("GET /api/nests", s.getNests)
mux.HandleFunc("POST /api/nests", s.postNest) mux.HandleFunc("POST /api/nests", s.postNest)
mux.HandleFunc("GET /api/nests/{nest_id}/messages", s.getNestMessages) mux.HandleFunc("GET /api/nests/{nest_id}/messages", s.getNestMessages)
@@ -82,6 +87,8 @@ func cmdServe(args []string) {
must(err) must(err)
msgs, err := LoadMessageStore(filepath.Join(dataDir, "messages")) msgs, err := LoadMessageStore(filepath.Join(dataDir, "messages"))
must(err) must(err)
spun, err := LoadCrowStore(filepath.Join(dataDir, "crows.json"))
must(err)
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
defer stop() defer stop()
@@ -93,7 +100,7 @@ func cmdServe(args []string) {
if jev != nil { if jev != nil {
fmt.Printf("Sentry triage: %s, LLM fallback\n", jev.Name()) fmt.Printf("Sentry triage: %s, LLM fallback\n", jev.Name())
} }
s := NewServer(ctx, p, approver, nests).WithMessages(msgs) s := NewServer(ctx, p, approver, nests).WithMessages(msgs).WithCrows(spun)
srv := &http.Server{Addr: *addr, Handler: s.Handler(), ReadHeaderTimeout: 10 * time.Second} srv := &http.Server{Addr: *addr, Handler: s.Handler(), ReadHeaderTimeout: 10 * time.Second}
go func() { go func() {
@@ -318,8 +325,9 @@ func (s *Server) postNest(w http.ResponseWriter, r *http.Request) {
} }
} }
// crows lists each crow's persona for the UI (name, epithet, strength, backstory). // crowPersonas lists each persona crow for the UI (name, epithet, strength,
func (s *Server) crows(w http.ResponseWriter, r *http.Request) { // backstory). The spun-crow registry lives at GET /api/crows (crowreg.go).
func (s *Server) crowPersonas(w http.ResponseWriter, r *http.Request) {
out := make([]map[string]string, 0, len(rosterOrder)) out := make([]map[string]string, 0, len(rosterOrder))
for _, n := range rosterOrder { for _, n := range rosterOrder {
p := personas[n] p := personas[n]
+1 -1
View File
@@ -82,7 +82,7 @@ export type Persona = { name: string; epithet: string; strength: string; backsto
export async function getCrows(): Promise<Map<string, Persona>> { export async function getCrows(): Promise<Map<string, Persona>> {
const out = new Map<string, Persona>(); const out = new Map<string, Persona>();
try { try {
const res = await fetch("/api/crows"); const res = await fetch("/api/personas");
if (res.ok) for (const p of await res.json()) out.set(String(p.name), { name: String(p.name), epithet: String(p.epithet), strength: String(p.strength), backstory: String(p.backstory) }); if (res.ok) for (const p of await res.json()) out.set(String(p.name), { name: String(p.name), epithet: String(p.epithet), strength: String(p.strength), backstory: String(p.backstory) });
} catch { } catch {
// No personas: tiles show names only. // No personas: tiles show names only.