Push notifications end-to-end
This guide walks the whole push loop: you ship a notify.json in your deploy
bundle that says which writes deserve a nudge, the platform watches your app's
data for a matching write and rings a doorbell on every subscribed device, and
your app enrolls users and reacts to taps. It's a cross-layer story — a bundle
file, a data write, and the runtime SDK all play a part — so the reference
documents each piece on its own page and this guide stitches them together.
For the runtime API itself (what subscribe/state/test/watch return and
throw), see backlit.push
in the reference. For every field a notify.json rule can carry, see the
notify.json schema. This
guide links to both rather than repeating them, so it can't drift out of sync
with them.
The mental model
Push has a server half and a client half, and they meet at a write.
- Server half —
notify.json. An optional file at the root of your deploy bundle. It's a list of rules: "when a write matching this shape lands, send this notification to these people." The platform evaluates the rules for you the moment a matching write happens — your app doesn't send anything. - Client half —
window.backlit.push. Your app asks the signed-in user to turn notifications on, and reacts when they tap one. That's it: enrollment and reaction. The sending is entirely server-side.
The doorbell that arrives is deliberately content-light — it carries a count, not your data — so the notification never leaks the payload of the write that triggered it. Your app's service worker renders it with your glow's name and icon; the tap opens the path you named in the rule.
A glow only pushes when its live deploy included a valid notify.json.
Deploy without one (or ship a bundle that omits it) and push is simply off — the
push.subscribe
call throws rather than enrolling anyone.
Enable push in three moves
- Author a
notify.jsonand put it at the root of your bundle, alongsideindex.html. It rides the bundle as an optional fourth file next to the three required ones — see Deploy bundle contract and Packaging the deploy bundle for how the bundle is assembled and uploaded. - Enroll users from your app with
push.subscribe(), gated onpush.supported(). - React to taps with
push.watch().
The rest of this guide is a worked example of each move.
Move 1 — author notify.json
A rule matches a write on the store it lands in and the verb that lands it, then
optionally narrows by a condition on the write's JSON payload, picks who to
notify, and supplies the static notification text. Each of those is a field in
the notify.json schema —
read it there for the exact shape and constraints of on, when, target,
message, coalesce, and schedule; this guide only shows how they fit
together.
Here's a rule for a shared RSVP list: an admin should get a nudge whenever a
guest's write flips their status to going.
{
"version": 1,
"rules": [
{
"id": "rsvp-going",
"on": { "store": "data", "op": "set", "key": "rsvp-" },
"when": [{ "field": "status", "op": "eq", "value": "going" }],
"target": { "roles": ["admin"] },
"message": {
"title": "New RSVP",
"body": "{count} guest just RSVP'd going.",
"url": "/admin/rsvps"
},
"coalesce": { "window": "5m" }
}
]
}
Reading it as narrative: on picks the store and write verb this rule watches
and (via key) narrows to a key prefix; when further gates on the write's
payload (only fire when status equals going); target says who receives it
(users whose glow permission is admin); message is the static text (with
{count} filled in at send time, and url the path a tap opens); and
coalesce batches a burst of matches into one doorbell over a window. The
schema is the authority on every one of those fields — including which
store/op combinations are legal, which stores allow an owner target, and
how the window is clamped — so consult it rather than trusting this paragraph.
Ship this file at your bundle root. If it's malformed, too large, or reaches for something the platform disallows, the deploy is rejected with an error — the deploy API reports which — so you find out at deploy time, not at send time.
Move 2 — the write that trips it
Nothing about sending lives in your app. Push fires off an ordinary data write — the same write your app already makes. Given the rule above, this write is what rings the doorbell:
// A guest marks themselves going. This is a normal shared-data write;
// the matching notify.json rule turns it into an admin notification.
await backlit.data.updateJSON(`rsvp-${user.uid}`, (cur) => ({
...cur,
name: user.name,
status: "going",
}));
The write lands in the data store under a rsvp- key with status: "going"
— exactly the shape the rule's on and when describe — so the platform
matches it and delivers the doorbell to every admin who subscribed. Your app
called no push API to make that happen; it just wrote its data. (For the
JSON-write helpers this uses, see the
data.getJSON / putJSON / putJSONIfMatch
family in the reference and the read-modify-write pattern in
Migrations.)
Move 3 — enroll users and react to taps
Sending is automatic, but a user only receives a doorbell after their browser is enrolled, and enrollment needs a signed-in user and a browser that can do push. Always probe first, then offer the opt-in only where it can work:
const cap = backlit.push.supported(); // synchronous; never throws
if (cap.supported) {
enableBtn.onclick = async () => {
try {
await backlit.push.subscribe(); // prompts for OS permission
await backlit.push.test(); // ring your own doorbell to confirm
} catch (e) {
if (e.code === "permission_denied") showTip("Notifications are blocked.");
else throw e;
}
};
} else if (cap.requiresInstall) {
showTip("Install this app to your home screen to turn on notifications.");
} else {
enableBtn.hidden = true; // this browser can't do push
}
// While a tab is open, follow a tap to where the rule pointed it.
backlit.push.watch((e) => {
if (e.type === "notification-click") location.assign(e.url);
});
The exact return shapes, the requirements each call imposes (a signed-in user,
push enabled for the glow), and the error codes it throws
(permission_denied, unsupported, requires_install, unauthenticated, …)
are all in the reference —
push.subscribe,
push.test,
push.watch, and
the capability tags from
push.supported.
Read them there; this guide won't restate them.
Who can receive, and where
A rule's target speaks in the same permission roles the rest of the platform
uses (admin / user / viewer), and enrollment needs a signed-in user —
so who can actually receive a doorbell depends on the glow's auth mode. A
Private glow's users are all signed in; a Public or Accounts glow serves
anonymous visitors who receive nothing until they sign in. See
Public, Private, and Accounts glows
for which surfaces each mode's visitors can reach, and the
notify.json schema's
target for how roles (and the records-only owner) resolve to recipients.
No natural write? Dedicate a key
Every rule fires off a write — so when the moment you want to announce isn't something the app already stores (a "meeting is starting" button, a manual "ping the team"), invert the trigger: dedicate a key to the announcement and write to it. The write is the send button; the rule matches it like any other.
{
"id": "announce",
"on": { "store": "data", "op": "set", "key": "announce" },
"target": { "roles": ["admin", "user", "viewer"] },
"message": {
"title": "Team update",
"body": "There's a new announcement.",
"url": "/"
}
}
// The button's click handler IS the send.
sendBtn.onclick = () =>
backlit.data.updateJSON("announce", () => ({
text: input.value,
at: Date.now(), // varies every send — load-bearing, see below
}));
That at field is not decoration. Writing a value identical to what
the key already holds is a no-op — the write succeeds but nothing hears
it, so no doorbell rings (the exact rule is under
backlit.data).
Two admins sending the same "Meeting now" text minutes apart would
silently drop the second send. So a dedicated notification key must
always carry something that varies — a timestamp, a counter — or use
per-event keys under a prefix (announce-…, matched by the rule's
prefix key), which also pairs naturally with the schema's
coalesce when
sends can burst.
Who may pull the trigger is just the store choice again: a shared data
key limits sending to signed-in non-viewers, records opens it to any
signed-in user, and capture to anyone — the
permissions guide
is the recipe for that decision. And check you need a dedicated key at
all: if the moment already writes — an anonymous contact form's capture,
a member's records post — hook the rule to that write instead, exactly
as Move 1's RSVP rule did.
Deploy it with an agent
You don't have to package and upload by hand. An AI assistant connected to the
hosted MCP server can create the glow, deploy the
bundle — notify.json included — as a draft, and promote it live once you've
previewed it. See
Deploy with an agent for
the connection details and the two out-of-band deploy transports.