Skip to content
C.W.K.
Lesson 01 of 05 · published

A Client, Not a Second Brain

~14 min · integration, conversations, idempotency, trust

Level 0Mise en Place
0 XP0/43 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete

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.

ThreadSizzle keycwkPippa client id
The kitchenkitchensizzle_kitchen
One plateencounter:<ULID>sizzle_encounter_<ULID>
One classclass:<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_enabled and bash_enabled are false. Inside Sizzle I can talk and propose. I cannot reach a tool, a shell or Sizzle's record.
  • pippa_avatar_preset is 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_addendum carries Sizzle's frame and data (lesson 2).
  • source is sizzle, and external_request_id is 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.

Principle: A client that borrows an identity holds a key to one door, never a copy of the house. Bind by a deterministic name, let the owner create or return, and keep only what you need to draw.

Code

cwkSizzle, backend/pippa.py: the client id and the create-or-return call (verbatim, lines 23-39)·python
def client_id_for(key: str) -> str:
    """cwkPippa's client ids allow ``[A-Za-z0-9_-]{8,100}``."""
    return ("sizzle_" + key.replace(":", "_"))[:100]


async def ensure_conversation(key: str) -> str:
    try:
        async with httpx.AsyncClient(timeout=30.0) as client:
            response = await client.post(
                f"{settings.PIPPA_BASE}/api/conversations",
                json={"client_id": client_id_for(key), "soul_id": "pippa", "origin_surface": ORIGIN_SURFACE},
            )
    except httpx.HTTPError as exc:
        raise PippaError(f"cwkPippa unreachable ({settings.PIPPA_BASE})") from exc
    if response.status_code != 200:
        raise PippaError(f"conversation create failed (HTTP {response.status_code}: {kit_pippa.detail_of(response)})")
    return str(response.json()["id"])
cwkPippa, backend/routes/conversations.py: the door Sizzle knocks on, keyed by client_id (verbatim, lines 595-629)·python
class ConversationCreate(BaseModel):
    """A client-minted conversation for a native surface. ``client_id`` is
    the idempotency key (a lost reply cannot create a second row on retry);
    ``origin_surface`` is provenance only — no system folder, no projection:
    the row lands in the ordinary sidebar list like a WebUI chat."""

    soul_id: str = Field(default="pippa", min_length=1, max_length=80)
    client_id: str = Field(min_length=8, max_length=100, pattern=r"^[A-Za-z0-9_-]+$")
    origin_surface: str = Field(default="pippa-mobile", min_length=1, max_length=40, pattern=r"^[a-z][a-z0-9-]*$")


@router.post("/conversations")
async def create_conversation(body: ConversationCreate):
    """Create (or return) a canonical conversation keyed by ``client_id``.

    Native Pippa (clients/apple/PippaMobile) mints the id offline and asks
    here on the next link; the WebUI still creates lazily through
    ``/api/chat``. 아빠's ruling, 2026-09-10: the phone is cwkPippa itself —
    its chats are ordinary chats, not a sibling surface's folder.
    """
    await store.init_db()
    from users.visibility import is_conversation_visible_to

    def _visible(conv) -> bool:
        return is_conversation_visible_to(
            conversation_screen_safe=conv.screen_safe,
            soul_id=conv.soul_id,
            owner_user_id=conv.owner_user_id,
        )

    existing = await store.get_conversation_by_external_id(body.client_id)
    if existing is not None:
        if not _visible(existing):
            raise HTTPException(status_code=409, detail="conversation client id is already claimed")
        return _conversation_payload(existing)
cwkSizzle, backend/pippa.py: one chat turn's payload, tools and shell off (verbatim, lines 60-75)·python
    payload: dict[str, Any] = {
        "conversation_id": conversation_id,
        "message": message,
        "attachments": attachments,
        "pippa_avatar_preset": settings.AVATAR_PRESET,
        "tools_enabled": False,
        "bash_enabled": False,
        "system_prompt_addendum": addendum,
        "source": ORIGIN_SURFACE,
        "external_request_id": request_id,
    }
    if effort:
        payload.update(kit_pippa.reasoning_payload(brain, effort))
    if model:
        payload["model"] = model
    return await kit_pippa.stream_turn(settings.PIPPA_BASE, brain, payload)

External links

Exercise

Pick an app where users talk to an assistant about domain objects (orders, documents, projects). Write a function that turns each object's key into a deterministic conversation id that fits your assistant service's id rules, a create-or-return call that uses it, and a binding event your app records the first time the call succeeds. Then call it twice in a row for one key and twice concurrently for another, and check that each key has exactly one conversation.
Hint
Look at the server side too. Create-or-return needs a unique index on the id, and the losing insert of a race should read back the winner instead of failing. If the service you call has no such route, that route is the first thing to build.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign in — Please sign in to comment.

No comments yet — be the first.