Two people answer the same support thread, and the customer gets two replies. Neither of them did anything wrong. Neither could see the other. The same collision waits on every record more than one person can act on: a user being suspended, a ledger being adjusted, an organization being deleted.
A presence room answers one question about one record: who else has it open, and what are they doing? It runs in your own Worker, as one hibernating Durable Object per record, and it holds nothing but who is there.
Rooms are declared, never invented
A capability declares its room kinds on the presence field of its contract, beside its admin routes. A kind has a name, the scope that reads the record, and the activities a participant may announce.
defineCapability({
name: "support",
presence: {
basePath: "/support",
ticketKey: (env) => readTicketKey(env), // presence-ticket-key, through @pithy-sh/secrets
rooms: [
{
kind: "support.thread",
scope: "support:threads:read",
activities: ["viewing", "replying"],
session: (c, recordId) => (canRead(c, recordId) ? { name: displayName(c) } : null),
},
],
},
// …
});A room is one kind plus one record id. The kind’s name starts with the capability’s own. The first activity is what everyone starts as. session is the capability’s own permission for reading that record, for a signed-in user of your app; leave it out and the kind admits management clients only.
Presence adds no scope. Anyone who may read a record may see who else has it open, so a kind names the scope that already reads it. That scope must be one the capability’s own admin routes require, and only GET routes, so pithy dashboard connect offers it by default and joining a room never needs a write grant.
The control-plane manifest lists every kind under the capability that declares it, as presenceRooms[]: the kind, its scope, its activities, its participant cap, where a signed-in user mints a ticket, and the socket path, all built from that capability’s own basePath. A Worker older than presence rooms sends no presenceRooms at all, which is not the same as an empty list.
Joining takes a ticket
A browser’s WebSocket sets no headers. It can carry neither a control-plane token nor, cross-origin, a session cookie. So you mint a ticket first, from one of two doors, and both answer the same shape.
| Door | Who | Gate |
|---|---|---|
POST /control-plane/presence/tickets | A management client, such as the dashboard | Signed like any control-plane call, with the room kind’s own scope |
POST {basePath}/presence/tickets | A signed-in user of your app | The kind’s session permission for that record |
The control-plane body names the participant. It carries the room (kind, recordId) and who joins: an opaque ref and a display name, both the client’s own. The signed body is what makes them the client’s claims. A token minted for any other scope is refused with controlplane/insufficient_scope and audited like any denial. An undeclared kind is a 404.
The session body names only the room. The participant is the caller. A user the kind’s permission does not admit gets a 403.
A ticket is single use, lasts 60 seconds, and names one room and one participant. The answer carries the ticket, its expiresAt, the room, the socketPath to open with it already filled in, and the protocol to offer.
Then the socket
GET {basePath}/presence/rooms/:kind/:recordId, upgraded, offering pithy.presence.v1 and pithy.ticket.<ticket> in Sec-WebSocket-Protocol. The Worker answers with pithy.presence.v1.
One refusal for every bad ticket. Missing, forged, expired, already spent, or minted for another room: each is core/presence_ticket_invalid (401). A caller holding somebody else’s ticket learns only that it does not work. A full room is core/presence_room_full (409).
The first frame is init, everyone in the room. Then joined, left and activity reach every other socket as they happen. You send one JSON frame, set_activity, and the plain-text ping, which is answered with pong. A management client and a signed-in user in one room see each other, and each participant carries its origin: control-plane or session. How those frames may change between releases is the socket contract.
The room holds nothing but who is here
Each participant (ref, name, origin, activity and joinedAt) lives in its socket’s attachment, and the room reads it back from its sockets when it wakes. That is what lets it hibernate: a room nobody is talking in bills no duration, and it wakes knowing who is in it. The tickets it has redeemed ride in the same attachments, so a room that slept still refuses a replay.
Nothing is written to D1, KV or Durable Object storage. A dropped socket leaves. When the last socket closes, the room is gone. There is no history of who viewed what, and no way to ask for one.
Bounded
- Participants. 25 per room by default. A kind may declare its own cap, up to 100, because every frame fans out to everyone in the room.
- Frames. A client frame over 1,024 bytes is refused.
- Activities. One of the kind’s declared values, never free text, and at most 16 of them per kind.
The ticket key
Tickets are signed with presence-ticket-key, one secret per environment, generated and never typed. The declaring capability lists it in its @pithy-sh/secrets registry as PRESENCE_TICKET_KEY_ENTRY and in its manifest’s devSecrets. pithy add mints the local value, and pithy secrets provision the deployed ones. Every capability that declares rooms in one Worker signs with that one key, and it is rotatable like any other secret.
Wired by the CLI
The first composed capability that declares a room kind makes pithy add and pithy upgrade write the PRESENCE_ROOMS binding, the PresenceRoom class migration and the entry export, through the same Durable Object path every capability uses:
export { PresenceRoom } from "@pithy-sh/core/src/presence/durableObject";The CLI reads the manifest, not your code. A kit capability’s pithy.manifest.json must list the binding in requiredBindings exactly as PRESENCE_ROOMS_BINDING_SPEC gives it, beside presence-ticket-key in devSecrets. One that leaves it out gets no binding, no migration and no export, and the Worker has no room to open. Your own app capability has no manifest, so pithy upgrade wires it from its composition.
Taking it out is a retirement, not a splice. pithy remove takes the room out with the last capability that declares one, and pithy upgrade takes it out of a Worker where nothing does any more. Either way the class is retired with a deleted_classes migration. A Worker that deployed with the room holds its creation tag, and the room stores nothing, so retiring it loses nothing. It uses no Cloudflare API token.
What it does not do
It declares no rooms of its own. This is the seam. Each capability declares the kinds its records need, and support’s support.thread, one room per conversation, is the first.
It does not cross environments. A room is one Worker’s. Somebody looking at the staging copy of a thread is not in the production room.
It does not lock anything. A room says who else is there. Two people can still both reply; now they can see each other first.