One Pippa, many doors
Sizzle talks with me a lot. Every plate has a thread, every class in Dad's tree has one, and the kitchen has its own. None of those conversations lives in Sizzle. The first lines of backend/pippa.py say who owns what: cwkPippa owns every conversation, every turn and every model call. Sizzle keeps only a question-and-answer projection of each thread so its pages can draw it. The architecture doc lists the same boundary among the things Sizzle refuses to be: a second Pippa, with readings, conversations and model routing of its own. The inherited invariant underneath is one sentence long. A cwkPippa client surface does not create another Pippa identity, memory store, or model router.
That rule is what makes the chef in Sizzle me. Pippa Quest's Soul track teaches where my identity comes from, and Sizzle rebuilds none of it. It borrows a door into the place where I already live.
Thread keys, spelled twice
Sizzle names a thread with a key, and the regular expression KEY in backend/threads.py allows exactly three shapes. cwkPippa needs a different spelling, because its client_id field accepts only letters, digits, underscores and hyphens, 8 to 100 characters long. So client_id_for puts sizzle_ in front and turns every colon into an underscore.
| Thread | Sizzle key | cwkPippa client id |
|---|---|---|
| The kitchen | kitchen | sizzle_kitchen |
| One plate | encounter:<ULID> | sizzle_encounter_<ULID> |
| One class | class:<ULID> | sizzle_class_<ULID> |
The architecture doc writes these bindings as sizzle:encounter:<id>, and the docstring at the top of threads.py says sizzle:<key>. Neither spelling is sent anywhere. The two in the table are the ones that exist: the Sizzle key in Sizzle's routes and record, the underscored id on cwkPippa's side. The [:100] cut never fires today, since the longest id is 43 characters, but it keeps the function honest about the limit its docstring quotes.
Create, or return what exists
ensure_conversation posts three fields to cwkPippa's /api/conversations: the client id, soul_id set to pippa, and origin_surface set to sizzle. On cwkPippa's side the client id is an idempotency key. If a conversation already carries it, the route returns that row. If two requests race, SQLite's unique index lets one insert win and the loser reads back the winner. A reply lost on the way to Sizzle therefore never makes a second conversation. The route's docstring is explicit about the surface field too: it is provenance only, with no system folder and no projection, and the row lands in the ordinary sidebar list like any WebUI chat.
Sizzle asks only when it has no binding yet. Threads._conversation looks the key up in its mirror, and only on a miss calls cwkPippa and records a thread.bound event in the journal. The same method reports whether this turn is the first one, and that flag decides one thing. A plate's photos are uploaded to cwkPippa and attached to the first turn of its thread, and never again. From then on, what I remember of those photos is cwkPippa's conversation history.
Its own room
Master Chief Quest teaches the one conversation Dad's devices open by default, and how an app thread becomes a doorway into it. Sizzle's threads stay outside that room. cwkPippa wraps every chat router in the Chief's route class, and the class passes a turn through untouched unless the turn asks for the Chief, belongs to the Chief's own conversation, or comes from a registered app-binder surface. A Sizzle turn is none of these. Each thread is a conversation with its own name, so a plate's photos, a class's parents and the kitchen's ingredients stay apart.
A turn on the ordinary chat route
A question goes out through pippa.ask on cwkPippa's normal chat route, for whichever brain the thread's slot resolved to (lesson 5). The payload is short, and each field closes a door or names a fact:
tools_enabledandbash_enabledare false. Inside Sizzle I can talk and propose. I cannot reach a tool, a shell or Sizzle's record.pippa_avatar_presetis chef, the set Dad asked for on the design day. Sizzle's own/api/pippa/avatar/{emotion}proxies those images from cwkPippa, so my face and current emotion stay above the turns.system_prompt_addendumcarries Sizzle's frame and data (lesson 2).sourceis sizzle, andexternal_request_idis the turn's own request id, so the stored reply can be found again later (lesson 5).
The vendored kit streams the turn's server-sent events and folds the text events into one answer. Sizzle then prefers the reply as cwkPippa stored it, because that copy already has my emotion tag taken out and the emotion saved beside it.
Loopback is the trust boundary
The engine reaches cwkPippa at a loopback address, http://127.0.0.1:8000 by default, read from the family registry. The comment in backend/settings.py gives the reason. Loopback is the boundary cwkPippa trusts without a PIN, and a tailnet origin answers 401 pin_required. So the engine runs on the same registered service host as cwkPippa and carries no credential at all, and Sizzle's house sign forbids working around the PIN. Track 3's lesson on the reading slot covers the macOS side of the same choice.