Vibes DIY
Vibes DIY / Docs / Reference
creator docs · reference · generated

backend.js — the app's server-side backend

This is the same document the Vibes code generator reads when it builds apps — rendered from prompts/pkg/llms/backend.md on every deploy, so it can't drift from what the platform actually does. Read it from your terminal any time with npx vibes-diy skills.

backend.js is an optional server-side file, a sibling of access.js. It runs on the platform's servers — never in the browser — and gives the app three server superpowers, each an exported function:

  • fetch(request, ctx) — answer HTTP requests at the app's /_api/... URL (webhooks from outside, or fetch("/_api/…") from the app itself).
  • onChange(event, ctx) — react after a document write commits (derive, aggregate, moderate, fan out).
  • scheduled(event, ctx) — run periodically (cleanup, digests, timers).

Export only the handlers the app needs. Most apps need no backend.js at all — normal CRUD flows through Fireproof directly. Reach for it only when the app needs a server-side action: accepting a webhook, deriving/aggregating documents in reaction to writes, or periodic work. These docs are present by default so you know the capability exists — their presence is not a request to add a backend. If the user's request doesn't call for one, don't create or mention backend.js.

When to use which handler — signal words

  • fetch — "webhook", "OAuth", "callback", "API endpoint", "receive", "form POST", "Stripe/GitHub/Sonos sends us…". Something external → us.
  • onChange — "when X is created", "after a write", "notify", "send an email", "aggregate", "moderate", "sync to…". An internal write → a side effect: ctx.notify for every person the app emails, notifies or reminds, and a Zapier Catch Hook for a tool the person names (Slack, Google Sheets, a CRM).
  • scheduled — "every N minutes", "poll", "check periodically", "cron", "cleanup", "digest". Timer-driven, no user in the loop.

A reminder that recurs at a wall-clock time — "every morning at 9", "each Friday" — is a scheduled handler; onChange answers a write that just happened.

An app can need more than one — a payments app wants fetch (webhook receipt) and onChange (email on the new order). If none of these signal words fit the request, the app needs no backend.js — don't emit one.

Decision rule: when live external data is the app's substance, use backend.js + cache-into-db, never fetch-per-render; when external data is only launch flavor, take a generation-time snapshot into seed.json instead.

Keep availability separate from a successful empty result. Inspect a successful source response before choosing its parser, then validate the fields the app uses. An empty validated response is data; an unavailable first read leaves the dependent decision pending. For example, a dispatch planner waiting for its closure calendar records that unavailable state and postpones calendar-dependent dispatch. A previously validated cache can support the declared fallback, with its freshness and failure visible in saved status.

Output format

backend.js is a separate file, exactly like access.js: one prose line, the heading naming backend.js, then one complete fenced block below it (the whole file — don't use SEARCH/REPLACE for it; re-emit the full file to change it). Never put backend code inside an App.jsx block, and never import backend.js from App.jsx — the browser can't run it.

Writes go through the access function

Every ctx.db.put/ctx.db.delete is enforced by the app's own access.js — the exact gate user writes pass through — acting as the trigger's identity:

Handler Writes act as…
onChange the user whose write triggered the event
fetch the signed-in caller when verifiable — treat as possibly anonymous
scheduled the app owner, in admin mode

So the permission model stays in access.js, and backend.js writes must be allowed by it for the acting identity. If a fetch handler writes, make sure the access function permits that write for an anonymous caller (or design the write to happen in onChange/scheduled, which carry stronger identities).

The access function can tell where a write came from: user.backend names the handler that issued it — "scheduled", "onChange" or "fetch" — and is absent for every write from a browser or the CLI, the app owner's own included. What it does not change is identity. An onChange write still acts as the triggering user, so anything it may write, that user's own client could write too; onChange stays derivation and convenience, never privilege escalation. For documents only the server should control, use scheduled and gate them on the origin with ctx.requireBackend("scheduled"), which admits the run and turns every ordinary client write away.

scheduled always runs in admin mode, the same override the owner gets from the client's admin toggle: ctx.requireAccess(...), ctx.requireRole(...) and ctx.requireBackend(...) all no-op, and its ctx.db.query reads are unfiltered. The access function still runs on every write — its returned channels and grants still route the doc, so members keep seeing what the cron writes. user.backend is "scheduled", so ctx.requireBackend("scheduled") passes for the run on its own merits. Two consequences worth designing around: a scheduled sweep can read and rewrite every user's documents in the app (access functions protect users from each other, not from the app owner — the owner's automation touches all users' data); and because admin mode skips requireAccess, a bug in a scheduled write fails silently-wrong rather than forbidden — keep scheduled writes small and well-guarded. fetch and onChange never get admin mode: they're user-triggerable, and a handler any visitor can invoke must not run owner-privileged.

Taking on the authorization job: config.fetch.unfilteredReads

Because the fetch lane runs as the calling user, a fetch handler by default reads nothing for a caller with no session, and cannot read an access-fn-bound database without an opt-in. Some routes legitimately need to: a calendar feed at GET /_api/faves.ics?t=<token> is fetched by a calendar app that carries no login, and the docs it serves are private to one user. For those, backend.js can opt in — per database, fetch lane only:

jsexport const config = {
  fetch: {
    unfilteredReads: {
      dbs: ["faves"],
      why: "serves per-user .ics calendar feeds authorized by an unguessable token",
    },
  },
};

This is an obligation, not a convenience. Inside that handler, for those databases, you are taking on the authorization job the platform normally does: both the anonymous refusal and the access-function scoping come off, so ctx.db.query/ctx.db.get return every document in the named databases — all users, all channels — to whoever called the route. The legitimate pattern is a capability: an unguessable, per-user, revocable token in the URL or a header, checked before you read, with the response scoped to exactly that token's owner. "The URL is hard to guess" only holds if you mint the token yourself with real entropy and never print it anywhere else.

Two things to design around. The opt-in is scoped to the lane, not the route: backend.js exports one fetch handler that routes internally, so every path inside it can read those databases — keep the check at the top and name as few databases as the feature needs. And it is a static declaration: dbs must be plain string literals and why a non-empty string literal saying who is authorized and how, or the push is rejected. There is no call-site flag, and no other lane can opt in.

ctx — what a handler gets

jsctx.appInfo; // { ownerHandle, appSlug } — this app's identity
ctx.userInfo; // { userHandle } or null — who the handler is acting as
ctx.secrets; // Record<string,string> — the owner's per-vibe secrets; frozen, {} when none
await ctx.db.put(doc, { db: "notes", id: "optional-id" }); // resolves to the doc id AFTER commit
await ctx.db.put(doc, { db: "jobs", id: doc._id, ifRev: doc._rev }); // commits only if nobody wrote the doc since you read it
await ctx.db.delete(docId, { db: "notes" });
const docs = await ctx.db.query({ db: "notes", field: "type", key: "note", limit: 100 }); // a filtered page
const doc = await ctx.db.get("state:cursor", { db: "notes" }); // one doc by _id, or null
const text = await ctx.callAI("prompt", { model: "openrouter/auto", max_tokens: 500 }); // server-side AI call
const json = JSON.parse(
  await ctx.callAI("rate this", { schema: { properties: { verdict: { type: "string" }, score: { type: "number" } } } })
); // structured output
const res = await ctx.fetch("https://api.example.com/x", { headers }); // outbound HTTP — use this, not bare fetch()
await ctx.notify({ toHandle: "alice", body: "Your turn", dedupeKey: "game-7-turn-3", path: "#game-7" }); // one participant; `path` optional; throws on failure
ctx.log("info", "tick finished", { written: 3 }); // diagnostics — read back with: vibes-diy app logs owner/slug
const gh = await ctx.github.fetch("/repos/acme/site/issues", { method: "POST", body: { title: "Filed from the vibe" } }); // connected GitHub repo
  • ctx.github.fetch(path, init) calls the GitHub REST API as the GitHub App installation the owner connected to this vibe. It is the only GitHub surface a backend has: path is a GitHub API path rooted at / (/repos/…, plus /installation/repositories), init takes method, headers and body (an object is JSON-encoded for you), and it resolves a normal Response — so res.status, await res.json() and res.headers.get("link") all work, and an upstream 404 or 422 comes back as a Response, not an error. There is no token to read. The installation credential is attached by the platform and never enters your code, so you cannot pass it to another library, log it, or store it — and every call the App makes on the owner's behalf is one the platform made. The proxy also enforces the binding: a /repos/{owner}/{repo}/… path must name a repository the owner actually connected to THIS vibe, so a path built from user-supplied text simply gets refused rather than reaching some other repo the installation happens to cover. Do not treat that refusal as your validation — check the repo name yourself before you build the path, and tell the user which repos this vibe is bound to (/installation/repositories). Calls outside the proxied surface reject with an Error carrying a short reason (not_connected — the owner has not connected a repo, unmodeled_endpoint — a path outside /repos/… + /installation/repositories, or one naming a repo this vibe is not bound to, unsupported_method, streaming_unsupported, body_too_large, permission_not_granted — the installation lacks that permission, or upstream_error); branch on err.reason, and tell the user which one it was.

  • { db } names the Fireproof database (same names App.jsx uses with useFireproof). It is required — except inside onChange, where the database that triggered the event is the default.

  • Always await db calls; they resolve only after the write commits (or throw when the access function denies it).

  • Every read carries the doc's revision as _rev. ctx.db.get, each ctx.db.query row and the onChange event's doc include _rev beside _id. It is a reserved field the platform fills in on read and never stores, so spreading a read straight into a put is safe.

  • ifRev makes a write a compare-and-set. ctx.db.put(doc, { db, id, ifRev: doc._rev }) commits only if the doc is still at the revision you read. Pass null to write only if the doc does not exist yet — a deleted doc counts as not existing, just as get returns null for it. If anyone else wrote the doc in between, even with identical content, the put throws with err.code === "conflict" and err.currentRev (the revision there now, null when the doc is gone), so of several racing handlers exactly one succeeds. The access function still runs on every put, so its oldDoc.status check plus ifRev is an exclusive claim. A conflict is an answer, not a failure: someone else has the doc, so return. Leave ifRev out and a put is last-writer-wins, as always. This is backend.js only: useFireproof writes in the browser are local-first and keep last-writer-wins.

    jstry {
      await ctx.db.put({ ...event.doc, status: "running" }, { db: "jobs", id: event.docId, ifRev: event.doc._rev });
    } catch (err) {
      if (err.code === "conflict") return; // another run claimed it first
      throw err;
    }
  • ctx.db.get(id, { db }) resolves the single document with that _id, or null when it is missing or deleted. It is the right read whenever you can name the document — a tick's state doc, a token lookup, a singleton. Never scan a database looking for a doc whose id you already know.

  • ctx.db.query({ db, field, key, keys, range, prefix, limit, after }) reads a page of the database's latest docs. Every filter is optional and they combine: field names the document field to match on, then key (one exact value), keys (any of several), range ([from, to]) or prefix (a string, or an array prefix — charwise-encoded, same semantics the client's queries use) narrows it. Three edges to get right: no field means no filtering (you get a raw page of everything), a bare field with no selector still drops docs that lack that field, and prefix takes the raw value — a plain string or array, never anything pre-encoded (the encoding is internal). limit only clamps the page down; the 2000-doc cap stays. Ask for what the handler actually needs — reading the whole database and filtering in the handler wastes the cap and goes blind as the app grows. Sorting still happens in the handler.

  • The result is a real Array of docs that also destructures — both const docs = await ctx.db.query(...) and const { docs, next } = await ctx.db.query(...) work.

  • Paginate on next, never on emptiness. next is computed from the raw page before filtering, and only when that page came back full — so a narrowly filtered page can be empty and still carry next. Stopping at the first empty page silently truncates the read:

    jslet after;
    do {
      const page = await ctx.db.query({ db: "faves", field: "type", key: "favorite", limit: 500, after });
      for (const doc of page) {
        // …
      }
      after = page.next;
    } while (after);

    next rides on the array itself, so read it before transforming: any array operation (.filter(), .map(), spread) returns a plain array with no next. On the last page the property is absent, not undefined — "next" in page is false.

  • Both reads are read-gated as the acting identity, through the same gate: anonymous fetch callers are denied, and in fetch a database bound to an access function cannot be read at all (keep the fetch lane's read databases unbound, or declare config.fetch.unfilteredReads). In onChange a bound database reads filtered to the triggering user's channels — the same per-reader filter the client's own reads use, so the handler sees exactly what the user who triggered it could see: neighbouring docs for dedup, rank and "don't repeat what's already there" checks, and nothing privileged. In scheduled (admin mode) reads are unfiltered — access-fn-bound databases return every doc, across all users and channels. Made for scheduled sweeps: read, decide, then put/delete.

  • The one exception to that last sentence is the declared opt-in above, config.fetch.unfilteredReads — a fetch handler that names a database there reads it unfiltered, anonymous callers included, and owes the authorization check itself. Nothing changes for writes, or for any lane or database you didn't name.

  • ctx.secrets carries the owner's own per-vibe secrets, set in the vibe's settings page or with vibes-diy secrets set KEY. Use it to verify inbound credentials — a webhook's shared secret or token — or to authenticate outbound calls to a service the owner holds a key for (Authorization: Bearer ${ctx.secrets.KEY} to an admitted API). Never write a value anywhere users can read it (a doc, a response body). Only the owner can set or rotate secrets; handlers see the current values on every invocation. A visitor's own account (their GitHub, Threads, Instagram) is never a secret and never a pasted token — it is a connection: declare it in config.connections, let the platform hold the credential, and read it through @vibes.diy/social or ctx.connections (the connections skill).

  • Make every outbound HTTP call with ctx.fetch(...), never a bare fetch(...). It takes the same arguments as the web fetch and returns a Response. Why not bare fetch: a backend that exports a fetch handler creates a module-scoped fetch binding, so a bare fetch(url) anywhere in the file resolves to your own handler — a fetch handler calling out recurses into itself, and a scheduled/onChange call passes the URL where a Request is expected. ctx.fetch always reaches the real HTTP client. It is policy-gated, and three lanes admit a call.

    • A plain read of a public URL goes through. ctx.fetch(url) with no method (or GET/HEAD), carrying at most accept/accept-language and no credentials, reaches any ordinary https host from fetch, onChange and scheduled alike — so an RSS or Atom feed, a public JSON API, an .ics file or a plain web page is readable today, with no key, no arrangement and nothing to configure. The platform makes the request itself, identified as this app, and serves a repeated read from a cache — for about five minutes by default, or for whatever lifetime the response asks for, held to between a minute and an hour. Nothing is cached for less than a minute, so a poll faster than that re-reads bytes the cache already holds. "5m" is the sound default for a feed that states no lifetime of its own, and slower suits a feed that changes a few times a day; a feed asking to be re-read sooner (Cache-Control: max-age=60) is the one case a faster tick earns.

    • A CORS-open endpoint takes anything a browser could send from the app's own page — same preflight rules, same Access-Control-Allow-Origin check, browser-forbidden headers like Cookie stripped. This is the lane a POST, an Authorization: header, or any custom header name travels on, so a write or a keyed API call needs the host itself to say yes.

    • The platform's curated list carries server-to-server APIs that send no CORS headers (api.github.com is seeded; additions are a PR to the platform repo).

      Emailing, notifying or reminding a person is ctx.notify. ctx.notify reaches a person in the app by handle — the owner, a member, a manager — on their bell, their push and their email, so "email the manager every Monday", "email me a weekly digest" and "send the owner a summary" are each a scheduled handler that calls ctx.notify. A recipient who is not in the app yet joins it through the app's invite: the person picker takes their handle, or takes their email address and mails them an invite, and once they arrive every notification reaches them. Keep the record in the app too, so the app's own screen shows what went out.

      A Catch Hook carries a record to a tool the person names. When the ask names Slack, Google Sheets, a CRM, Airtable, Notion, Clio, HubSpot or Zapier itself, hooks.zapier.com is on the curated list for a POST to a Catch Hook, so the app hands the new record to that tool without holding its credentials. The owner makes a Zap whose trigger is "Webhooks by Zapier → Catch Hook", saves the hook URL as a secret (vibes-diy secrets set ZAPIER_HOOK_URL ...), and the backend posts JSON to ctx.secrets.ZAPIER_HOOK_URL. The URL is the credential, so it stays in the secret, out of every document and response body. Write that path gated on the secret (if (!ctx.secrets.ZAPIER_HOOK_URL) return;), so the app works fully before any Zap exists, and say in your reply that connecting a Zap to the hook turns that delivery on. Treat any 2xx as accepted and mark the record sent; on anything else throw — a handler that returns normally has acknowledged the event, so the platform will not call it again for that write, while a thrown onChange is re-delivered with backoff (a handful of tries over a few minutes) and a run that never succeeds lands in the app's logs.

      jsexport async function onChange(event, ctx) {
        const doc = event.doc;
        if (event.deleted || doc.type !== "intake" || doc.sentToZapier) return;
        if (!ctx.secrets.ZAPIER_HOOK_URL) return; // no Zap connected yet: the record stays in the app
        const res = await ctx.fetch(ctx.secrets.ZAPIER_HOOK_URL, {
          method: "POST",
          headers: { "content-type": "application/json" },
          body: JSON.stringify({ type: doc.type, name: doc.name, email: doc.email }),
        });
        if (!res.ok) {
          ctx.log("warn", "zapier hook refused", { status: res.status });
          throw new Error(`zapier hook ${res.status}`); // re-delivered with backoff
        }
        await ctx.db.put({ ...doc, sentToZapier: true }, { db: event.dbName });
      }

    A call outside all three returns 403 {"vibesEgressDenied":true,"gate":"floor"|"owner-blocked"|"cors"|"read-rate"|"rate-limit","host":...}, and gate says which lane refused: cors is a write or a credentialed call to a host that never opted us in, and read-rate means the read lane's own tighter budget (10 per 10s, 60 per minute) is spent for the moment, which the next tick clears. Check for that shape and surface a useful error. Remedies: read it as a plain GET, use a CORS-open endpoint, request a curated-list addition, or ask about owner blessing. The creator-facing version of this gate — including what to do when a link is reported as taking more power than the app has today — is at https://good.vibes.diy/docs/your-app-has-a-backend, which is the page to send someone to. Limits: https on port 443 only, 15s per request, 10MB responses, per-vibe rate caps (30/10s, 300/min). Make all outbound calls before returning the Response — a ctx.fetch() fired while a streamed body is still being pulled after the handler returned is unsupported and gets denied. Interactive frontend AI calls stay in App.jsx via callAI; server-side AI belongs to ctx.callAI.

  • ctx.callAI(prompt, options?) is the server-side AI call. It resolves to the completion text (a plain string) — no API key needed and none exists in the handler; the platform makes the call and meters the cost against the account of the user who triggered the handler (the writer in onChange, the signed-in caller in fetch), or the app owner when no user is involved (scheduled ticks, anonymous webhooks). Options: model (default "openrouter/auto"), max_tokens, temperature, schema. No streaming. Schema mode works the same way it does in App.jsx: pass { schema: { properties: { … }, required: [ … ] } } and the platform constrains the model the best way that model supports (native JSON schema, tool mode, or a schema system message). The result still comes back as a string — JSON.parse it. When you pass a schema and name no model, the default shifts to a schema-capable model rather than "openrouter/auto". Budget: a handful of calls per invocation (currently 5) and ~64k prompt characters; a denied or failed call throws — catch it and degrade (e.g. write the doc back with an error field) rather than losing the event. Keep interactive/streaming AI in App.jsx via callAI; use ctx.callAI when the result must be server-authoritative (moderation, digests, summaries users shouldn't be able to forge).

  • ctx.notify({ toHandle, body, dedupeKey, path }) sends one person a notification. It takes a single object; toHandle, body and dedupeKey are required and path is optional — match the object form in the example above, which is the calling convention (codegen has been observed passing these positionally). The message lands in that person's bell, inbox, and push notifications.

    • toHandle is one handle, and the platform delivers to people already in this app: the owner, who always qualifies, an accepted invite or approved request, a handle named in the access function's persisted grant state, or somebody whose authenticated write reached the app (anonymous writes don't count). Invite, request, and writer membership resolve the person through their bound handle; the grant-state clause matches the named handle itself. Explicit revocations deny anyone but the owner. A passing check plus valid fields writes the notification into the inbox history of a recipient the platform can resolve — an unresolvable handle fails the call after the participation check. Their switches silence the live bell, the push, and the email — not the history.

    • When the notification is the opt-in kind — your turn, your order shipped — seat the player the way a guest takes their own seat. A turn notification goes to a seat its owner took themselves. A join write where the added handle must equal user.userHandle qualifies the writer by their own act. This is an app-side convention for notifiable seats, not a rule about read grants: chosen-peer grants that share without notifying — inviting a collaborator into a shared list — stay intentional, and a grant-named handle stays eligible whether or not its owner seated it.

    • body is the whole message — plain text, 500 chars max. The platform titles it with the app's name and links it back to the app, so spend the body on what happened and who it concerns.

    • dedupeKey names the happening, so one event delivers once however many times the handler runs: "round-4-started", "order-42-shipped", "game-7-turn-3". The platform keys it per (app, recipient).

    • path says WHERE in your app the notification points, so the bell and the email land on the thing it is about instead of your front page. It must be a # fragment — "#game-7", "#order/42" — of at most 200 characters with no spaces or line breaks, and nothing else: a fragment can only ever address a position inside your own app, which is why it is the only form allowed. Omit it and the notification links to the app itself, exactly as before. A malformed path fails the whole ctx.notify call (the promise rejects) rather than being quietly ignored — so a dead deep link is something you find out about, not something your users find out about.

    • Read it back in App.jsx from your own hash, and listen for changes, because a click while the app is already open moves the hash without reloading:

      jsxconst [target, setTarget] = useState(() => window.location.hash.slice(1));
      useEffect(() => {
        const onHash = () => setTarget(window.location.hash.slice(1));
        window.addEventListener("hashchange", onHash);
        return () => window.removeEventListener("hashchange", onHash);
      }, []);
      // target === "game-7" → open that game
    • await it and let it throw. Success resolves with nothing to inspect; a missing field, an over-long body, or a handle outside the app rejects the promise. Usually the surrounding work should survive an undeliverable message, so catch it and carry on:

      jstry {
        await ctx.notify({
          toHandle: game.opponent,
          body: "Your turn",
          dedupeKey: `game-${game._id}-turn-${game.turn}`,
          path: `#game-${game._id}`, // lands them on THAT game, not the lobby
        });
      } catch (err) {
        ctx.log("warn", "notify failed", { game: game._id, reason: err.message });
      }
    • Reach for it when one person needs to know one thing: your turn, your request was approved, your order shipped. Each user also controls a "messages from a vibe" switch, so treat a notification as a nudge toward the app rather than the place the information lives.

    • When everyone using the app should see something — the schedule changed, a new round started — write that change to a database the audience already reads. 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 at read time. The app's own screen is the source of truth; that's what makes it the right surface for anything shared.

  • ctx.log(level?, ...args) is the handler's diagnostic lane. It is the output that reaches the owner, who reads it back with vibes-diy app logs <owner>/<slug> --since 1h [--tail] — the isolate's console output is collected nowhere, so route anything worth seeing later through ctx.log. The level is optional and comes first ("debug", "info", "warn", "error"); any other first argument is the message and the level defaults to info, so ctx.log("cache miss", { key }) and ctx.log("warn", "feed empty", { url }) both work. Log the shape of a run rather than its contents: document ids, counts, statuses, timings, and an error's message and code. Identifiers make the stream diagnostic while users' document bodies and ctx.secrets values stay in the handler.

Worked external-data pattern — cache into the live database

A public feed reads fine from a handler, and a run can still come back empty — the host is down, the shape changed, the read budget is spent for the minute. Never fetch an external feed from render or a React effect. Poll once per tick, normalize a bounded number of rows into the exact database and document shape the UI queries, and keep the last successful rows when refresh fails. Seed a small honest snapshot before the first request so even a first-run denial has something useful to show.

This complete example makes one curated GitHub API request every 15 minutes (well inside the documented caps), stores at most 20 normalized release docs in nodeReleases, and records refresh state in that same database.

backend.js

jsexport const config = { scheduled: { interval: "15m" } };

const SNAPSHOT = [
  {
    _id: "release:snapshot-v22",
    type: "release",
    title: "Node.js 22 (saved snapshot)",
    url: "https://nodejs.org/en/blog/release/v22.0.0",
    publishedAt: "2024-04-24T00:00:00.000Z",
    source: "saved-snapshot",
  },
];

async function ensureSnapshot(ctx) {
  // Point-read by id — the snapshot has a known `_id`, so there's nothing to scan for.
  if (await ctx.db.get(SNAPSHOT[0]._id, { db: "nodeReleases" })) return;
  for (const doc of SNAPSHOT) await ctx.db.put(doc, { db: "nodeReleases" });
}

// True only when every tracked field of the desired doc already matches what's
// stored — extra stored keys (like `_rev`) are ignored. This is what lets a
// tick that changes nothing write nothing.
function unchanged(existing, next) {
  if (!existing) return false;
  return Object.keys(next).every((k) => existing[k] === next[k]);
}

// The diff source is a POINT-READ per incoming id, not a query over the stored
// set: the work is bounded by what the feed sent (≤20 docs), never by how big
// the database has grown — so a db that outgrows a query page can never
// silently eject rows from the diff and turn this tick into a rewrite loop.
// The database is a parameter: every doc a helper writes has to land in the db
// the UI live-queries, and a helper with one baked-in name is how a status doc
// ends up somewhere nothing reads.
async function putIfChanged(ctx, next, db) {
  const existing = await ctx.db.get(next._id, { db });
  if (unchanged(existing, next)) return;
  await ctx.db.put(next, { db });
}

export async function scheduled(event, ctx) {
  ctx.log("debug", "refresh tick", { at: event.scheduledTime }); // proof of life, before any read
  await ensureSnapshot(ctx);

  // One request, no retry storm; ctx.fetch enforces 15s/10MB and egress caps.
  const response = await ctx.fetch("https://api.github.com/repos/nodejs/node/releases?per_page=20", {
    headers: { accept: "application/vnd.github+json" },
  });

  if (response.status === 403) {
    const denied = await response
      .clone()
      .json()
      .catch(() => null);
    if (denied?.vibesEgressDenied === true) {
      // A real state change — putIfChanged writes once on entering denial, then
      // nothing while it persists. No per-tick timestamp, so no churn.
      await putIfChanged(ctx, {
        _id: "refresh:status",
        type: "refreshStatus",
        state: "egress-denied",
        message: "showing saved data — live refresh unavailable",
      }, "nodeReleases");
      return; // preserve the seeded/last-good release docs
    }
  }

  if (!response.ok) return; // transient failure: keep last-good rows, write nothing

  const rows = await response.json().catch(() => null);
  if (!Array.isArray(rows) || rows.length === 0) return; // empty upstream: abort, no writes

  for (const row of rows.slice(0, 20)) {
    if (!row?.id || !row?.html_url || !row?.published_at) continue;
    // Stable per-item `_id` + diff: a row whose content is identical is skipped.
    await putIfChanged(ctx, {
      _id: `release:github-${row.id}`,
      type: "release",
      title: String(row.name || row.tag_name || "Node.js release"),
      url: String(row.html_url),
      publishedAt: String(row.published_at),
      source: "github-live",
    }, "nodeReleases");
  }

  await putIfChanged(ctx, {
    _id: "refresh:status",
    type: "refreshStatus",
    state: "fresh",
    message: "Live feed refreshed",
  }, "nodeReleases");
}

App.jsx

jsximport React from "react";
import { useFireproof } from "use-fireproof";
import { Badge, Heading, RowCard, Stack, Text } from "@vibes.diy/pouf";

export default function App() {
  const { useLiveQuery } = useFireproof("nodeReleases");
  const { docs: releaseDocs } = useLiveQuery("type", { key: "release" });
  const { docs: statusDocs } = useLiveQuery("type", { key: "refreshStatus" });
  const releases = [...releaseDocs].sort((a, b) => b.publishedAt.localeCompare(a.publishedAt));
  const status = statusDocs.find((doc) => doc._id === "refresh:status");

  return (
    <main className="min-h-screen bg-background text-foreground font-sans mx-auto max-w-2xl p-6">
      <Stack gap={5}>
        <Heading level={1}>Node.js releases</Heading>
        {status?.state === "egress-denied" && (
          <div role="status">
            <Badge tone="info">showing saved data — live refresh unavailable</Badge>
          </div>
        )}
        <Stack gap={3}>
          {releases.map((release) => (
            <RowCard key={release._id}>
              <a className="font-bold underline" href={release.url} target="_blank" rel="noreferrer">
                {release.title}
              </a>
              <Text size="sm" muted>
                {release.publishedAt.slice(0, 10)}
              </Text>
            </RowCard>
          ))}
        </Stack>
      </Stack>
    </main>
  );
}

The vibesEgressDenied body is a policy result, not proof that the upstream is down. Catch that exact 403 shape and show the saved snapshot with honest copy — "showing saved data — live refresh unavailable". Never clear cached rows, render a broken screen, or silently label saved data as fresh.

Zero-churn discipline for scheduled ticks. A cron runs forever, so it must not write forever. Give every item a stable _id and put only the rows whose content actually changed — the putIfChanged diff above, which point-reads each id it is about to write. Diff against point-reads of the incoming ids, so the work is bounded by what arrived this tick, not by how big the database has grown; reach for a keyed ctx.db.query only when the stored set itself is the target (and then walk next to exhaustion before diffing — a one-page diff source re-puts everything the page missed, forever). Never stamp a per-tick checkedAt/updatedAt/run counter onto a doc: it makes every put look new, and each spurious put mints a fresh revision and a sync-feed row that re-tickles every replica on every tick. A failed or empty upstream fetch aborts with no writes, preserving the last-good rows. Only a genuine content change — a new or updated row, a real state transition — earns a write.

A tick's own memory is a document it reads by id. Whatever a scheduled handler needs to know about what it already did — a cursor, a last-synced marker, a per-run state — lives in one doc with a fixed _id, read with ctx.db.get(id, { db }). Never re-derive that answer by scanning the database: a sweep that diffs against its own whole-db read goes blind the moment the database outgrows one page, and then rewrites everything on every tick, forever. Keep a copy of the state in module scope as well, and read a missing or failed state read as "don't know — skip this tick", never as "nothing has been done yet": that second reading is exactly what restarts the endless rewrite. That state doc is owner-only in access.js — it is what the tick believes about itself, so keeping it to the owner is what keeps anyone else from steering the tick.

fetch — the app's HTTP endpoint

Runs for requests to the app's /_api route. The request path is rooted after /_api: a call to https://slug--owner.host/_api/webhooks/pay arrives with pathname /webhooks/pay. Return a standard Response.

backend.js

jsexport async function fetch(request, ctx) {
  const url = new URL(request.url);
  if (url.pathname === "/rsvp" && request.method === "POST") {
    const body = await request.json();
    const id = await ctx.db.put({ kind: "rsvp", name: body.name, at: Date.now() }, { db: "rsvps" });
    return new Response(JSON.stringify({ ok: true, id }), {
      headers: { "content-type": "application/json" },
    });
  }
  return new Response("not found", { status: 404 });
}

From App.jsx, call it with a relative fetch — no host needed:

App.jsx

jsconst res = await fetch("/_api/rsvp", { method: "POST", body: JSON.stringify({ name }) });

The platform's answers are worth recognising. 404 backend.js _api: not found means the app has no fetch handler to run. When a handler throws, the caller gets a 500 whose body names only the error class — backend.js threw: TypeError. Details are in this app's backend log: … — and the full message is written to the app's log, where vibes-diy app logs <owner>/<slug> shows it. A 502 backend.js failed to run means the module never started, most often an error at the top level of backend.js. So a route that expects trouble — an upstream that can be down, input that can be malformed — catches it and answers { ok: false, reason } with a status the app can show, and the platform's 500 stays the signal for a bug to fix.

A connected GitHub repository's deliveries arrive here too, as a POST to /_api/github-webhook, already verified by the platform, with the event name in x-vibes-github-event, GitHub's delivery id in x-vibes-github-delivery (key writes by it — a delivery can arrive twice), and an untrusted third-party body; a 4xx ends the delivery, and a throw marks it failed (GitHub does not retry it).

/_api opens to every caller once the app is published. Before its first publish, and after the owner unpublishes it, the routes answer the app's own people — its owner, its developers, and anyone it is shared with — and everyone else gets 404 backend.js _api: not found without the handler running. The page reaches its own routes in every state through apiFetch (below), which arrives as the signed-in visitor. call_own_api, onChange and scheduled keep running, and a connected GitHub webhook runs until the owner unpublishes.

Who is calling: ctx.userInfo comes from the request, never a cookie

Cookies are not read. ctx.userInfo is filled in three ways:

  • The app's own page, through apiFetch from use-vibes. The platform makes the call for the signed-in visitor, so the handler runs as them: ctx.userInfo is that visitor, their ctx.db writes pass access.js as them, and ctx.connections spends their connection. A visitor who is not signed in arrives as ctx.userInfo === null.
  • A caller outside the app — a script, another agent, a server — sends the person's vibes.diy token as Authorization: Bearer <token>. A missing, expired or unrecognised token is not an error: the handler runs with ctx.userInfo === null, so a route that needs a person answers 401 itself.
  • The builder's own call_own_api check (see the debugging skill) arrives already verified, as the app's owner, with no bearer header.

A plain fetch("/_api/...") from App.jsx carries no visitor, so the handler sees ctx.userInfo === null. That suits routes that should not depend on who is asking — reading a page, calling a service with a secret. When a route needs to know the visitor — their own records, their connection, their permissions — the page calls it with apiFetch. The path is the route under /_api, and the answer reads like a Response:

App.jsx

jsximport { apiFetch } from "use-vibes";

const res = await apiFetch("/my-rsvp", {
  method: "POST",
  headers: { "content-type": "application/json" },
  body: JSON.stringify({ going: true }),
});
const mine = res.ok ? await res.json() : null;

A route's own error status resolves with ok: false; apiFetch rejects only when the route could not be reached. Bodies are strings, and answers are for JSON-sized results rather than exports. Screens that simply list the visitor's own records still read best from the database hooks, which already know who is signed in.

Writes a handler makes with ctx.db.put still pass through access.js as the caller, so the app's rules hold for API writes too.

Bulk transformations run as a route

A change that touches many stored records is written once, as a named /_api route, and run once — not looped from outside the app. Gate the route inside the handler on the caller being the owner, declare config.fetch.unfilteredReads for the collections it reads, page with the cursor ctx.db.query returns, and keep progress in a state document read by id so a second run continues where the first stopped.

backend.js

jsexport const config = { fetch: { unfilteredReads: ["rsvps"] } };

export async function fetch(request, ctx) {
  const url = new URL(request.url);
  if (url.pathname !== "/admin/normalize") return new Response("not found", { status: 404 });
  if (ctx.user?.userHandle !== ctx.ownerHandle) return new Response("forbidden", { status: 403 });
  const page = await ctx.db.query("kind", { key: "rsvp", limit: 100, db: "rsvps" });
  for (const doc of page) {
    if (typeof doc.name === "string" && doc.name !== doc.name.trim()) {
      await ctx.db.put({ ...doc, name: doc.name.trim() }, { db: "rsvps" });
    }
  }
  return new Response(JSON.stringify({ ok: true, more: page.next ?? null }), {
    headers: { "content-type": "application/json" },
  });
}

Reading a page the person names — the /_api proxy

A page the person names is read in backend.js and handed to the app through /_api. A browser reaches its own app and the hosts that publish CORS headers, so an ordinary web page — somebody's homepage, a blog post, a competitor's landing page — is readable from a handler and from nowhere else. This is the shape for every "scan this site", "check this URL", "summarize this page" ask: the handler takes the address, reads it with ctx.fetch, strips the markup to text, and answers with what it measured. Repeat reads of the same address come back from the read cache (about five minutes), so a person re-scanning a site pays for one read.

The handler settles the address before reading it. The platform reads https: hosts, so an address typed without a scheme, or with http:, is normalized to https: and anything else comes back as a stated refusal rather than a thrown handler — a refusal the app can show is worth more than a policy error the app has to guess at.

ok means TEXT CAME BACK. A 200 that strips to nothing — a page that renders itself in the browser, a consent wall, a host answering with an empty body — is a read that produced no page, so it answers ok: false with a reason. That is what makes page.ok a sound thing for the app to branch on: a score or a summary is only ever computed from text the handler actually returned.

backend.js

jsexport async function fetch(request, ctx) {
  const url = new URL(request.url);
  if (url.pathname === "/page") {
    const typed = (url.searchParams.get("url") ?? "").trim();
    let parsed;
    try {
      parsed = new URL(/^[a-z]+:/i.test(typed) ? typed : `https://${typed}`);
    } catch {
      return Response.json({ ok: false, reason: "that does not look like a web address" }, { status: 400 });
    }
    // The platform reads https hosts, so http is normalized rather than refused.
    if (parsed.protocol === "http:") parsed.protocol = "https:";
    if (parsed.protocol !== "https:") {
      return Response.json({ ok: false, reason: "only web pages can be read" }, { status: 400 });
    }
    const res = await ctx.fetch(parsed.href, { headers: { accept: "text/html" } });
    const html = await res.text();
    // Script and style bodies first — their contents are not page text — then the tags themselves.
    const text = html
      .replace(/<(script|style)\b[^>]*>[\s\S]*?<\/\1>/gi, " ")
      .replace(/<[^>]+>/g, " ")
      .replace(/\s+/g, " ")
      .trim()
      .slice(0, 20000);
    // A page that came back with no readable text is a read that produced no page.
    if (res.ok === false || text.length === 0) {
      const reason = res.ok ? "that page returned nothing we could read" : `that page answered ${res.status}`;
      return Response.json({ ok: false, status: res.status, reason });
    }
    const title = /<title[^>]*>([\s\S]*?)<\/title>/i.exec(html)?.[1]?.trim() ?? "";
    return Response.json({ ok: true, status: res.status, title, text });
  }
  return new Response("not found", { status: 404 });
}

From App.jsx, the page asks the handler for the address it was given:

App.jsx

jsconst res = await fetch(`/_api/page?url=${encodeURIComponent(url)}`);
const page = await res.json();
if (page.ok === false) {
  setResult({ read: false, reason: page.reason });
  return; // nothing is scored, summarized or graded from a page that was not read
}
setResult({ read: true, ...(await scorePage(page.title, page.text)) });

The refusal lands where the result would have gone — "we could not read that page: it returned nothing we could read" — so the person sees the read fail instead of a number computed from nothing.

onChange — react to committed writes

When the request says a record is kept on the server, or that the server reacts to something, emit this handler. A person asking for activity to be recorded centrally, for a tally the app maintains, or for something to happen whenever a record lands is naming the server as the place it happens — that ask is the trigger, and the shape is the activity-mirror example at the end of this file: the client writes the document that triggers it, and onChange writes what the server owes in response.

Runs after any document write to the app's databases commits (user writes and backend writes alike). The event:

backend.js

jsexport async function onChange(event, ctx) {
  // event: { dbName, docId, doc, oldDoc, seq, deleted } — doc carries _id and _rev
  if (event.dbName !== "votes" || event.deleted) return;
  // Maintain a server-authoritative tally the UI reads but users can't forge.
  await ctx.db.put({ _id: "tally-" + event.doc.pollId, kind: "tally", bump: event.seq }, { db: "tallies" });
}

Two rules keep change-reactions sane:

  1. Guard on event.dbName first. The handler fires for every database, so an unguarded write-back loops on itself.
  2. Write derived docs to a different database than the one that triggered the event. A backend write triggers onChange again (one generation deeper); the platform stops the chain silently after four generations, but a tight same-database ping-pong is still wasted work. Different db + dbName guard makes loops structurally impossible.

onChange reads what the triggering user could read

A reaction usually needs the set its write joins — the siblings, the existing keys, what's already there. ctx.db.query and ctx.db.get on a bound database return exactly the docs in the triggering user's channels, through the same per-reader filter the client's own reads use. Nothing privileged ever comes back: a doc the triggerer couldn't open stays invisible, and reads page like any other query (walk next to exhaustion).

jsexport async function onChange(event, ctx) {
  if (event.dbName !== "links" || event.deleted) return;
  // What did sibling drafts already say about this link? Only docs the
  // triggering user could read come back — a private sibling stays hidden.
  // Walk every page to exhaustion: a fully-filtered page still carries `next`,
  // so stopping on an empty page would miss a sibling on a later one.
  let after = null;
  do {
    const page = await ctx.db.query({ db: "drafts", field: "linkId", key: event.doc.linkId, limit: 100, after });
    if (page.some((d) => d.angle === event.doc.angle)) return; // already covered
    after = page.next;
  } while (after);
  await ctx.db.put({ _id: "draft-" + event.docId, linkId: event.doc.linkId, angle: event.doc.angle }, { db: "drafts" });
}

Jobs that finish even when a run crashes

A job that survives a crash carries its status, its claim and its lease on the document. When each request is a unit of work ("resize every upload", "a queue of jobs that keeps going if one fails"), give each job a doc with a status, let onChange claim and finish it, and let a scheduled sweep take back expired leases. The status guard is what makes writing back to the same database safe: the handler acts only on pending, so every chain ends after one step.

Every transition is the same move: read the doc (the event's doc, or a sweep's query row), then put the next status with ifRev: doc._rev. A conflict means someone else moved the job first, so step aside.

  • access.js holds the transition table, keyed on oldDoc.status: ctx.requireBackend("onChange") for pending → running and running → done; ctx.requireBackend("scheduled") for running → pending (only once oldDoc.leaseUntil < Date.now()) and → failed; anything else throws.
  • The claim puts running with ifRev: event.doc._rev, so exactly one run claims each job. It also writes a fresh claimId, leaseUntil and attempts + 1.
  • The finish carries the same claimId, and access.js refuses a done whose claimId differs from oldDoc.claimId. A slow run's lease can be taken back and the job claimed again while it works; the second claim's fresh claimId is what makes the first run's finish refuse instead of overwrite.
  • The sweep releases an expired lease with ifRev: job._rev from its query, so it never overwrites a job that finished between the read and the write.
  • A lost claim returns rather than throws: a redelivered event carries the doc as it first fired, so its _rev is stale and the claim conflicts.
  • Key the work's own writes by jobId:attempt, since a job can run twice.
  • Keep each run short; a run past 15 minutes is delivered again while it is still going, so chunk long work into scheduled ticks.

backend.js

jsexport const config = { scheduled: { interval: "30s" } };
const DB = "jobs", LEASE_MS = 30_000, MAX_ATTEMPTS = 3;
const rid = () => Math.random().toString(36).slice(2, 10);

// One transition: commit `doc` only if the job is still at revision `ifRev`.
async function tryPut(ctx, doc, ifRev) {
  try { await ctx.db.put(doc, { db: DB, id: doc._id, ifRev }); return true; }
  catch (err) {
    if (err.code === "conflict") return false; // someone else moved this job first
    ctx.log("info", "transition refused", { id: doc._id, reason: String(err?.message || err) });
    return false;
  }
}

export async function onChange(event, ctx) {
  if (event.dbName !== DB || event.deleted || event.doc?.status !== "pending") return;
  const job = { ...event.doc, _id: event.docId };
  const claimed = { ...job, status: "running", attempts: (job.attempts || 0) + 1,
    claimId: rid(), leaseUntil: Date.now() + LEASE_MS };
  if (!(await tryPut(ctx, claimed, job._rev))) return; // lost the claim — ack, don't retry
  const output = await doTheWork(job, `${job._id}:${claimed.attempts}`); // idempotent per jobId:attempt
  await tryPut(ctx, { ...claimed, status: "done", output }); // same claimId, or access.js refuses it
}

export async function scheduled(event, ctx) {
  ctx.log("debug", "lease sweep", { at: event.scheduledTime });
  let after;
  do {
    const page = await ctx.db.query({ db: DB, field: "status", key: "running", limit: 500, after });
    for (const job of page) {
      if (!(job.leaseUntil < Date.now())) continue;
      await tryPut(ctx, { ...job, status: job.attempts >= MAX_ATTEMPTS ? "failed" : "pending" }, job._rev);
    }
    after = page.next;
  } while (after);
}

The full walkthrough, the matching access.js, and timings measured on production are at https://good.vibes.diy/docs/patterns/doc-state-machines.

scheduled — periodic work

Requires a config export with a static string-literal interval between "5s" and "1h" (e.g. "30s", "5m", "1h" — computed values are rejected):

backend.js

jsexport const config = { scheduled: { interval: "15m" } };

export async function scheduled(event, ctx) {
  // event: { scheduledTime } — ISO timestamp of this tick
  ctx.log("debug", "heartbeat tick", { at: event.scheduledTime }); // proof of life, every tick
  await ctx.db.put({ kind: "heartbeat", at: event.scheduledTime }, { db: "status" });
}

Runs as the app owner in admin mode (see "Writes go through the access function" above): it can read every doc in the app and its writes pass requireAccess/requireRole automatically, while the access function's channels and grants still route what it writes. Use it for cleanup, digests, rotations, and time-based state — not for anything that needs a user in the loop.

Times of day: the zone comes from the browser

A wall-clock time belongs to the person who set it. "Noon" means noon where the owner lives. The app is running in their browser at the moment they choose the hour, and the browser knows their zone exactly and for free — capture it there, store it beside the hour, and have the tick render event.scheduledTime into that zone before comparing. A stored IANA zone name also tracks daylight saving on its own, which a stored hour-offset does not.

App.jsx — when the owner saves the schedule:

App.jsx

jsxawait merge({
  hour: 12, // the hour they picked, in their own day
  timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone, // e.g. "America/Detroit"
});

backend.js — the tick compares in that zone:

backend.js

jsconst hourLocal = Number(
  new Intl.DateTimeFormat("en-US", {
    timeZone: settings.timeZone ?? "UTC",
    hour: "2-digit",
    hourCycle: "h23",
  }).format(new Date(event.scheduledTime))
);
if (hourLocal !== settings.hour) return; // not their noon yet — nothing to do

The owner picks an hour in the day they actually live in, and the app does the arithmetic. Keep the schedule control in those terms too: label it with the zone you captured ("every day at 12:00, America/Detroit"), so the setting reads back the way they set it.

A timestamp you show a person is rendered in their zone, never the server's. The rule above is about an hour the owner chose; this one is about every other moment your app puts in front of them — a date in a notification body, a "next event" label, a log line they read. A backend has no locale and no zone, so toLocaleDateString() there silently means UTC, and a moment at 02:07 UTC is already tomorrow for half the world. Pass the stored zone every time you format:

jsconst label = new Intl.DateTimeFormat("en-US", {
  timeZone: settings.timeZone ?? "UTC",
  month: "long",
  day: "numeric",
}).format(new Date(moment));

And only say a thing you computed. "Tonight", "this morning", "in an hour" are claims about the reader's clock; if the code did not derive them from that reader's zone, name the moment instead and let them place it.

Recurring reminders on somebody's clock

A recurring reminder fires on each person's own clock, never the server's. Once a reminder repeats — "every weekday at 7" — the zone stops being one setting on one schedule document and becomes a property of each person the reminder is for. Everything below is per person, and none of it is the server's day or the server's hour.

Capture the zone as an effect, not as a question. Do not ask them for a timezone: the browser already knows it, so write it once when they first sign in and leave it alone. Store it where the reminder is addressed — on the person's own profile document when the list is shared — and guard the write so it only ever happens on a real change, or the effect re-fires forever.

Derive the weekday and the hour together, in that zone, from one formatToParts call. getUTCDay()/getUTCHours() answer a question about the machine the handler happens to be running on; at 07:00 in Los Angeles it is already a different day in UTC, so a weekday gate on the server's clock silently drops Monday mornings and fires on Saturday ones.

An unrecognised zone string throws. Intl.DateTimeFormat raises a RangeError on a zone it does not know, so in a loop over people the formatting goes in a try and a bad profile is skipped — one stale record must not stop everybody else's reminder.

The unit of work is that person's local date (en-CA formats it as YYYY-MM-DD), keyed per person, per "A tick that repeats is not a run that repeats" above: the interval is only how often the handler wakes up.

App.jsx — write the zone once, when they arrive:

App.jsx

jsxuseEffect(() => {
  if (!me?.userHandle) return;
  const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
  if (!timeZone || myProfile?.timeZone === timeZone) return; // already stored — don't churn
  database.put({ _id: `profile:${me.userHandle}`, type: "profile", userHandle: me.userHandle, timeZone });
}, [me?.userHandle, myProfile?.timeZone]);

backend.js — evaluate each person in their own zone:

backend.js

jsexport const config = { scheduled: { interval: "15m" } };
const WEEKDAYS = new Set(["Mon", "Tue", "Wed", "Thu", "Fri"]);

export async function scheduled(event, ctx) {
  const at = new Date(event.scheduledTime);
  const profiles = await ctx.db.query({ db: DB, field: "type", key: "profile", limit: 500 });
  ctx.log("debug", "reminder tick", { profiles: profiles.length });

  for (const profile of profiles) {
    const { timeZone, userHandle } = profile;
    if (!timeZone || !userHandle) continue;
    let weekday, hour, dateKey;
    try {
      // One formatter, one zone: the weekday and the hour must agree about the day.
      const parts = new Intl.DateTimeFormat("en-US", {
        timeZone,
        weekday: "short",
        hour: "2-digit",
        hourCycle: "h23",
      }).formatToParts(at);
      weekday = parts.find((p) => p.type === "weekday")?.value;
      hour = Number(parts.find((p) => p.type === "hour")?.value);
      dateKey = new Intl.DateTimeFormat("en-CA", { timeZone }).format(at); // "2026-09-18", their day
    } catch {
      continue; // unrecognised zone on one profile — skip the person, not the tick
    }
    if (!WEEKDAYS.has(weekday) || hour !== 7) continue;

    const id = `morningReminder:${userHandle}:${dateKey}`; // the unit of work, per person
    if (await ctx.db.get(id, { db: DB })) continue; // this morning is already done
    await ctx.db.put(
      { _id: id, type: "morningReminder", ownerHandle: userHandle, date: dateKey, checkedAt: at.toISOString() },
      { db: DB }
    );
  }
}

An unattended tick reports what it did

An unattended tick earns its spend. Nobody is watching a scheduled run, so it checks what it read before it spends anything: read the upstream first, confirm the input set is non-empty (and that a 403 is not the vibesEgressDenied policy body), and return before ctx.callAI when there is nothing to work from. A model call on an empty input set produces confident text about nothing, bills the owner for it, and does it again on the next tick, forever.

Every tick leaves a trace, even the ones with nothing to do. Returning early when there is no work is right — it saves a read and a spend — but a handler that returns silently is indistinguishable from one that never ran, and app logs is the only place an owner can tell. For a job that acts twice a month that means weeks of silence that reads exactly like a dead alarm. So log once per tick, before any early return:

backend.js

jsexport async function scheduled(event, ctx) {
  const due = eventsDueNow(event.scheduledTime);
  ctx.log("debug", "tick", { due: due.length }); // proof of life; no reads, no spend
  if (due.length === 0) return;
  …
}

One line, and "is this thing still alive?" becomes answerable on any cadence. The rarer the job, the more it needs this: for an infrequent handler, silence is the only symptom a stopped alarm ever produces.

A tick that repeats is not a run that repeats. The interval is how often the handler is woken, not how often the work should happen: a 15m interval wakes four times inside the owner's noon, so the handler names the unit of work — here the local day — and keeps the key of the last unit it finished in a state document it reads by id, plus a copy in module scope. A missing or failed state read means "don't know — use the copy", never "nothing has run yet"; that second reading is what bills the owner four times for one noon.

A status document that must be seen to change carries the tick's timestamp. A write whose content is byte-identical to what is already stored is absorbed: the platform stores no new revision, and with no new revision there is no live event, so an open page holding that document sees nothing and keeps showing what it already had. A tick that rewrites a fixed-id status doc with the same fields every time is therefore invisible to a watching reader. Include the moment the tick ran — checkedAt: new Date().toISOString() — and the write is a real change, so the page updates. This is the reader's side of the state-document discipline above: state you only ever read back yourself is better off constant, and state somebody is watching has to move.

Firing on a real-world event

An averaged cycle is not a calendar. When the moment comes from nature rather than from a clock the owner set — a moon phase, a tide, sunrise, a planetary conjunction — the tempting shape is a mean period since a known epoch:

jsconst SYNODIC = 29.530588861; // mean lunation, in days
const t = KNOWN_NEW_MOON + k * SYNODIC * 86400000; // ← wrong by hours

The average is exact and the individual events are not. Orbits are elliptical and perturbed, so real events run early or late against their own mean. Measured for the lunar case: that formula is up to 18 hours out, and across the next six months it puts two of six new moons on the wrong calendar day. It also fails asymmetrically — full moons land within a couple of hours while new moons run ~15 hours late — so testing one and shipping both proves nothing.

An app that merely displays a countdown can live with this if it says "approximately". An app that notifies cannot: a notification is a claim that the thing is happening now, and firing a ±20-minute window around a mean instant lands it precisely on a time that is precisely wrong.

So: derive the event from a real algorithm with periodic-correction terms (the standard treatments are short — a dozen sine terms gets a lunar phase to within minutes), or fetch it from an ephemeris and cache it. If you can do neither, widen the claim rather than the window: give the day, say "around", and never attach a time-of-day word to it.

The same rule holds anywhere a natural cycle has a tidy average — the average is a description of the series, never a prediction of the next member of it.

Claim the unit of work before you spend on it, not after. Write the key first, then do the expensive, externally visible part: an isolate that dies between a finished draft and an unwritten claim comes back and buys the same day again. Claiming first means a crashed run is skipped rather than retried. That is the named exception to the lease-and-retry jobs under onChange above: when a duplicate costs more than a gap — a paid call, a post, a message to a person — skip rather than retry, because a missing day the status doc names beats a double charge nobody notices.

Then say what happened. Write the run's outcome to a status doc the app reads, and put the failure detail in ctx.log("error", …) for whoever inspects the logs: the owner's only view of an unattended app is what it renders, so a finished run leaves a visible record of what it did. A notification reports the outcome the run actually had. Every doc a run writes — status included — goes in the database the UI live-queries, or the record is invisible in exactly the way this whole section is about. A timestamp written only when a run genuinely produced something is a real state change and belongs in that doc — unlike a per-tick checkedAt, which is the churn the zero-churn rule above is about.

backend.js

jsexport const config = { scheduled: { interval: "15m" } };

const SHOW_DB = "show"; // the one database App.jsx live-queries

// The isolate's copy of the last day this handler finished. Survives between
// ticks on a warm isolate, and is the fallback when the state read says nothing.
let lastFinishedDay;

export async function scheduled(event, ctx) {
  ctx.log("debug", "digest tick", { at: event.scheduledTime }); // proof of life, before any return
  const settings = await ctx.db.get("schedule", { db: SHOW_DB });
  const zone = settings?.timeZone ?? "UTC"; // captured in the owner's browser
  const at = new Date(event.scheduledTime);

  const hourLocal = Number(
    new Intl.DateTimeFormat("en-US", { timeZone: zone, hour: "2-digit", hourCycle: "h23" }).format(at)
  );
  if (hourLocal !== settings?.hour) return; // not their hour yet — nothing to do

  // "2026-08-27" in the owner's own day, which is the unit of work here.
  const dayKey = new Intl.DateTimeFormat("en-CA", {
    timeZone: zone,
    year: "numeric",
    month: "2-digit",
    day: "2-digit",
  }).format(at);
  const state = await ctx.db.get("run:state", { db: SHOW_DB });
  const finished = state?.dayKey ?? lastFinishedDay; // absent read: trust the copy
  if (finished === dayKey) return; // this day's draft is already written

  const headlines = await readFeed(ctx); // [] when the fetch failed or the feed was empty

  if (headlines.length === 0) {
    ctx.log("error", "[digest] no headlines this tick", { at: event.scheduledTime });
    await putIfChanged(
      ctx,
      {
        _id: "run:status",
        type: "runStatus",
        state: "no-input",
        message: "couldn't read the feed — no draft written this run",
      },
      SHOW_DB
    );
    return; // no model call, and no notification claiming a draft is ready
  }

  // Claim the day BEFORE spending anything, so an isolate that dies mid-draft can
  // never come back and buy the same day again.
  lastFinishedDay = dayKey;
  await ctx.db.put({ _id: "run:state", type: "runState", dayKey }, { db: SHOW_DB });

  try {
    const draft = await ctx.callAI(`Write six one-liners about: ${headlines.join("; ")}`);
    await ctx.db.put({ _id: `draft:${dayKey}`, type: "draft", draft }, { db: SHOW_DB });
    await ctx.db.put(
      {
        _id: "run:status",
        type: "runStatus",
        state: "fresh",
        lastSuccessAt: event.scheduledTime,
        message: "today's draft is ready",
      },
      { db: SHOW_DB }
    );
    await ctx.notify({
      toHandle: ctx.appInfo.ownerHandle,
      body: "Your six jokes are drafted and ready for the show.",
      dedupeKey: `draft-${dayKey}`,
      path: `#draft/${dayKey}`,
    });
  } catch (err) {
    // The day stays claimed: the owner sees what went wrong and is not billed twice for it.
    ctx.log("error", "[digest] draft failed after the day was claimed", { message: err.message });
    await ctx.db.put(
      {
        _id: "run:status",
        type: "runStatus",
        state: "failed",
        checkedAt: event.scheduledTime, // a second failure must still read as a new one
        message: "today's draft didn't finish",
      },
      { db: SHOW_DB }
    );
  }
}

The run's own bookkeeping is the server's, not a user's. ctx.requireBackend is what separates a run's write from a client's: it passes only when the app's own backend.js issued the write, and refuses every browser and CLI write including the owner's own — while still no-opping under admin mode, so a hand repair runs the function and keeps the routing. access.js is where that is said:

access.js

js// access.js
export default function (doc, oldDoc, user, ctx) {
  if (doc.type === "runState" || doc.type === "runStatus") {
    ctx.requireBackend("scheduled");
    return { channels: ["show"] };
  }
  // …the app's own document types…
  throw { forbidden: "unknown document type" };
}

App.jsx writes the schedule document that tick reads — the hour the owner picked and, beside it, the zone their own browser reported — and reads the same database back, rendering the status beside the drafts so a failed run and a good one never look alike:

App.jsx

jsxconst { useLiveQuery, database } = useFireproof("show");

async function saveSchedule(hour) {
  // The hour is theirs and so is the zone: capture it here, store it beside the hour.
  await database.put({
    _id: "schedule",
    type: "schedule",
    hour,
    timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
  });
}

const { docs: statusDocs } = useLiveQuery("type", { key: "runStatus" });
const run = statusDocs.find((doc) => doc._id === "run:status");
// …
{
  run?.state === "no-input" ? (
    <p role="status">{run.message}</p>
  ) : (
    <p role="status">Last draft written {run?.lastSuccessAt?.slice(0, 16)}</p>
  );
}

A complete example — activity feed + owner-only digest

Users write note docs from App.jsx. The backend does two jobs: onChange mirrors each note into a per-author activity entry (acting as that author — same privilege, just automated), and a scheduled sweep maintains one digest doc that access.js restricts to the owner, so no user can forge it.

backend.js

jsexport const config = { scheduled: { interval: "15m" } };

export async function onChange(event, ctx) {
  if (event.dbName !== "notes" || event.deleted) return;
  await ctx.db.put(
    { _id: "act-" + event.docId, kind: "activity", srcId: event.docId, by: ctx.userInfo, at: event.seq },
    { db: "activity" }
  );
}

export async function scheduled(event, ctx) {
  ctx.log("debug", "digest sweep", { at: event.scheduledTime }); // proof of life, before any return
  // Acts as the app owner — access.js allows `digest` docs for the owner only,
  // making this document genuinely server-controlled.
  //
  // What this tick already did is read BY ID, never re-derived from a scan, and
  // the sweep is bounded by what is NEW rather than by how big the app has grown.
  const state = await ctx.db.get("digest:state", { db: "digest" });
  const since = state?.throughSeq ?? 0;
  const fresh = await ctx.db.query({ db: "activity", field: "at", range: [since + 1, Number.MAX_SAFE_INTEGER], limit: 200 });
  if (fresh.length === 0) return; // nothing happened — write nothing at all

  const throughSeq = fresh.reduce((max, doc) => (doc.at > max ? doc.at : max), since);
  const next = { _id: "digest", kind: "digest", headline: `${fresh.length} new notes`, throughSeq };
  const existing = await ctx.db.get("digest", { db: "digest" });
  if (existing?.headline === next.headline && existing?.throughSeq === next.throughSeq) return; // unchanged

  await ctx.db.put(next, { db: "digest" });
  await ctx.db.put({ _id: "digest:state", type: "digestState", throughSeq }, { db: "digest" });
}