# 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`](https://sdk.backlit.run/sdk.md#backlitpush--notifications)
in the reference. For every field a `notify.json` rule can carry, see the
[notify.json schema](https://sdk.backlit.run/schema/notify.schema.json). 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`](https://sdk.backlit.run/sdk.md#backlitpush--notifications)
call throws rather than enrolling anyone.

## Enable push in three moves

1. **Author a `notify.json`** and put it at the root of your bundle, alongside
   `index.html`. It rides the bundle as an optional fourth file next to the
   three required ones — see
   [Deploy bundle contract](https://sdk.backlit.run/sdk.md#deploy-bundle-contract)
   and [Packaging the deploy bundle](https://sdk.backlit.run/sdk.md#packaging-the-deploy-bundle)
   for how the bundle is assembled and
   [uploaded](https://glow.backlit.run/openapi.yaml).
2. **Enroll users** from your app with
   [`push.subscribe()`](https://sdk.backlit.run/sdk.md#backlitpush--notifications),
   gated on
   [`push.supported()`](https://sdk.backlit.run/sdk.md#pushsupported--supported-reason-requiresinstall-installed).
3. **React to taps** with
   [`push.watch()`](https://sdk.backlit.run/sdk.md#backlitpush--notifications).

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](https://sdk.backlit.run/schema/notify.schema.json) —
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`.

```json
{
  "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](https://glow.backlit.run/openapi.yaml) 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:

```js
// 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`](https://sdk.backlit.run/sdk.md#datagetjson--dataputjson--dataputjsonifmatch)
family in the reference and the read-modify-write pattern in
[Migrations](https://sdk.backlit.run/sdk.md#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:

```js
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 `code`s it throws
(`permission_denied`, `unsupported`, `requires_install`, `unauthenticated`, …)
are all in the reference —
[`push.subscribe`](https://sdk.backlit.run/sdk.md#backlitpush--notifications),
[`push.test`](https://sdk.backlit.run/sdk.md#backlitpush--notifications),
[`push.watch`](https://sdk.backlit.run/sdk.md#backlitpush--notifications), and
the capability tags from
[`push.supported`](https://sdk.backlit.run/sdk.md#pushsupported--supported-reason-requiresinstall-installed).
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](https://sdk.backlit.run/sdk.md#public-private-and-accounts-glows)
for which surfaces each mode's visitors can reach, and the
[notify.json schema](https://sdk.backlit.run/schema/notify.schema.json)'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.

```json
{
  "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": "/"
  }
}
```

```js
// 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`](https://sdk.backlit.run/sdk.md#backlitdata--glow-shared-keyvalue)).
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`](https://sdk.backlit.run/schema/notify.schema.json) 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](https://sdk.backlit.run/guides/permissions-and-write-gating.md)
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](https://mcp.backlit.run/mcp) 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](https://sdk.backlit.run/sdk.md#deploy-with-an-agent) for
the connection details and the two out-of-band deploy transports.
