There is no mailing list
Your app can tap someone on the shoulder. A turn-based game tells your
opponent it's their move; a queue tells someone their request was approved.
One line in backend.js:
jsawait ctx.notify({ toHandle: game.opponent, body: "Your turn", dedupeKey: `game-${game._id}-turn-${game.turn}` });
That message rides the same rails every platform notification uses — the durable row, the live bell, the phone push. No parallel delivery system to keep in sync, which was the whole point of building it this way.
And then the request everyone makes next: let me notify everyone using the app. The schedule changed. A new round started. It sounds like the same feature with a different argument.
It isn't, and the reason turns out to explain the shape of the entire data model.
The gate is the feature
Start with the single-recipient version, because the hard part there isn't the plumbing either.
Exposing an internal capability to code written by a language model on behalf
of a stranger is mostly a question about the boundary, not the transport. The
transport was already built. What had to be designed was isAppParticipant:
a vibe can notify only people who are already inside it — the owner, a
member with an accepted invite or an approved request, or a handle named in
the access function's live grant state.
That check runs host-side, against the app identity baked into the isolate,
never against anything the handler claims about itself. Which matters,
because without it ctx.notify isn't a notification API. It's an open spam
primitive with a direct line to the platform's phone-push rails.
But read that fourth clause again — a handle named in the access function's grant state — because it's the one an app controls. An access function that writes someone's handle into a grant has made that person notifiable, and the platform will honor it. The gate confines an app to people it has a stored relationship with; the app gets a say in what counts as a relationship.
Get posts like this in your inbox
One email field. Real updates. No algorithm required.
Which puts the access function inside the security boundary rather than beside it, and gives it a rule worth writing on the wall: never put a handle in a grant that its owner didn't put there themselves. Our turn-based example is built around exactly that. You can't name an opponent — you open a seat, and the other player takes it with their own write. Nobody can seat you but you, so nobody can make you notifiable but you.
Almost every time we've opened an internal system up to third-party code, the gate has been the bigger design question than the capability. The capability is usually already sitting there. And the gate is usually only as good as the first app that leans on it.
The list that was never written down
So: notify takes one handle, and the gate decides whether that handle is
allowed. The obvious way to reach an audience is to enumerate it and call
notify in a loop.
You can't, and not because we rate-limited it. There is no roster to iterate.
Here's what a document's visibility actually is in this system. An app's access function runs on every write and emits, per document, the channels that document belongs to. Some of those channels are audiences — "the people who follow this author," say. An audience channel isn't a stored set of handles. It's resolved against the social graph at read time, when someone asks for the document.
So the set of people who can see a thing is a computation, evaluated per reader, at the moment of reading. It is not a list, anywhere, that a backend handler could read. A handler that tried to enumerate its own audience would be guessing — and wrong in both directions, missing people who just followed and inventing people who just left.
This is not a gap. It's what makes the rest of the system work: membership that's computed at read time is membership that's correct at read time. Revocation takes effect on the next read rather than after a sweep. A new follower sees the back catalog without anyone recomputing a fan-out table. Nobody maintains a materialized list of who's in what, so nobody has a materialized list that's subtly stale.
The price of that is one specific thing you can't do: pull the audience into your hand and count it.
What to do instead is what you wanted anyway
Here's the part that surprised us: the feature people ask for is worse than the thing they can already do.
To tell everyone that the schedule changed, write the change into the
database the audience is already reading. Their useLiveQuery puts it on
screen the moment it commits — for exactly the people entitled to see it,
because the platform resolves that audience per reader, which is the same
mechanism that made the roster impossible in the first place.
No fan-out, no cap to tune, no delivery to retry, nothing to be half-sent when a handler times out at recipient forty. The people looking at the app see the new state. The constraint and the solution are the same fact viewed from two sides.
Our schedule example does both halves at once, which is why it's the one worth reading. A scheduled tick refreshes the sessions and writes the changed ones — every viewer's board updates, and not one notification is involved. Then, separately, it notifies the individuals who had starred a session that moved. That second half is genuine one-to-one delivery over favorites the handler can actually read, because a favorite is a document in a database, which is exactly the kind of thing that is written down.
The rule of thumb that fell out: a notification is for one person, one thing, off-screen — your move, your request, your order. Anything shared belongs on the shared screen. And a notification is a nudge toward the app rather than the place the information lives, because every user has a switch that turns them off.
Two examples and a reference
vibes/turn-notify/— a turn-based game notifying the opponent fromonChange. The smallest complete version of the 1:1 case.vibes/schedule-watch/— the schedule refresh that reaches everyone through the database, and notifies only the people whose starred session moved.- Notifications pattern — the contract: participant gate, the 500-character body, the dedupe key, and why the call throws rather than returning a result.
Build something that taps people on the shoulder
Describe the app; the notifications come with it.
Start building →