Vibes DIY
Vibes DIY / Docs
creator docs · guide · backend.js

Your app has a backend

Every vibe can ship a third file. App.jsx is what people see, access.js is who can do what — and backend.js is the part that runs when nobody's browser is open. It runs on the platform's servers, next to your data, with three lanes and no infrastructure to provision:

  • fetch(request, ctx) — your app gets its own HTTP endpoint at /_api/.... Webhooks from the outside world, or fetch("/_api/…") calls from your own UI.
  • onChange(event, ctx) — runs after any document write commits. Derive, aggregate, moderate, fan out.
  • scheduled(event, ctx) — runs on a timer, from every 5 seconds to every hour. Cleanup, digests, token rotation.

Export only what you need. Most apps need no backend.js at all — normal reads and writes flow through Fireproof directly. Reach for it when something must happen server-side: accepting a webhook, maintaining a tally users shouldn't be able to forge, or doing periodic work.

This guide is the narrative tour. The full contract — every field, every cap — is the backend.js reference, the same document the code generator reads. You can also just describe what you want ("a backend that summarizes new entries every night") and the generator wires it for you.

Ship one in five minutes

Pull your app, add a file, push:

shnpx vibes-diy pull you/your-app --dir ./your-app
cd your-app
# create backend.js (below), then:
npx vibes-diy push
js// backend.js — an RSVP endpoint anyone can POST to
export 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 });
}

The path is rooted after /_api: a request to https://your-app--you.host/_api/rsvp arrives with pathname /rsvp. From your own App.jsx, call it relative — fetch("/_api/rsvp", { method: "POST", … }).

When something goes wrong, the status tells you which kind of wrong it is:

Response What it means
404 backend.js _api: not found the app has no fetch handler to run, or it isn't published yet
500 backend.js threw: TypeError. … your handler threw; the full message is in your app's backend log
502 backend.js failed to run. … the module never started — usually an error at the top of backend.js

The public response names only the kind of error, so nothing in your error message reaches whoever called the URL. Read the whole message with npx vibes-diy app logs you/your-app. For failures you expect — an upstream that can be down, input that can be malformed — catch them in the handler and answer with your own status and a reason your app can show.

You can also prompt for it in the editor: backend.js is a normal part of the vibe's source, editable in the Code tab like everything else.

Your /_api opens to the world when you publish. Before the first publish, and after you unpublish, the routes answer your app's own people — you, your developers, and anyone you shared it with — and everyone else gets the 404 above without your handler running. Your page reaches its own routes in every state through apiFetch from use-vibes, which calls as the signed-in visitor; a script calls as you with your token in Authorization: Bearer …. onChange and scheduled keep running either way, and a connected GitHub webhook runs until you unpublish. Apps built in the editor publish as they're built, and npx vibes-diy push publishes by default, so most apps are already open.

GitHub webhooks — a connected repository calls your fetch

Besides webhooks you arrange yourself, a vibe can receive a GitHub repository's events without holding a GitHub secret. The repository is bound to the vibe when the vibes.diy GitHub App is installed on it with your vibe named in the install link's state (you/your-app); the install flow lands back on vibes.diy, where you confirm as the signed-in owner of that vibe. From then on, every delivery for that repository arrives at your ordinary fetch handler as a POST to /_api/github-webhook:

jsexport async function fetch(request, ctx) {
  const url = new URL(request.url);
  if (url.pathname === "/github-webhook" && request.method === "POST") {
    const event = request.headers.get("x-vibes-github-event");      // "issues", "push", …
    const delivery = request.headers.get("x-vibes-github-delivery"); // GitHub's delivery id
    const payload = await request.json();                            // GitHub's parsed payload
    if (event === "issues" && payload.action === "opened") {
      await ctx.db.put(
        { _id: "gh-" + delivery, kind: "issue", title: payload.issue.title },
        { db: "inbox" }
      );
    }
    return new Response(null, { status: 204 });
  }
  return new Response("not found", { status: 404 });
}

What the platform has already done for you, and what is still yours:

  • The signature is verified before you see the request. GitHub's own x-hub-signature-256 is checked against the App's secret and then stripped; you never hold that secret and cannot check it yourself. Every x-vibes-* header on the request was put there by the platform — inbound headers in that namespace are deleted before the forward — so x-vibes-github-event, x-vibes-github-delivery, x-vibes-github-installation and x-vibes-timestamp are the platform's word, not the caller's. (x-vibes-signature-256 carries a per-vibe provenance signature over those fields; the key to verify it is not yet handed to vibes, so treat it as reserved.)
  • The body is untrusted text. Issue titles, pull-request descriptions and commit messages are written by strangers; the request carries x-vibes-content: untrusted-external to say so. Store them as data and never feed them to ctx.callAI as instructions.
  • A delivery can arrive more than once. Key what you write by the delivery id, as above, so a redelivery lands on the same document.
  • Your status decides the retry. A 2xx acknowledges. A 4xx is final: the platform answers GitHub with success and the delivery is not retried, which is also what happens when your backend has no /github-webhook route at all. A throw, or a 5xx, marks the delivery as failed on GitHub's side. GitHub does not retry failed deliveries on its own: the repository owner can redeliver one from the webhook's Recent Deliveries page, so work that must not be lost should also be reachable by a scheduled sweep.
  • It runs as nobody. GitHub's deliveries carry no sign-in, so ctx.userInfo is null and any ctx.db.put must pass access.js for an anonymous caller — or write a request document and let scheduled do the privileged part.
  • It keeps running until you unpublish. A never-published draft receives deliveries; a vibe you have taken down does not, until you publish again.

The identity model — one permission system, not two

There is no service key. Every ctx.db.put and ctx.db.delete a handler makes goes through your app's own access.js, acting as whoever triggered the handler:

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

Two consequences worth internalizing:

onChange is for derivation, never privilege escalation. Its writes act as the triggering user, so anything it may write, that user's own client could write too. Its reads see the same boundary: an access-function-bound database comes back filtered to the triggering user's channels, so a reaction can read its neighbours but never anything privileged. If a document should be something only the server controls, don't write it from onChange.

scheduled is the owner, with the owner's override. It runs in admin mode: requireAccess/requireRole no-op, and its reads are unfiltered — 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's owner. That makes scheduled the right lane for genuinely server-controlled documents (gate them to the owner in access.js and no user can forge them), and also the lane to keep small and well-guarded: because admin mode skips the access checks, a buggy scheduled write fails silently-wrong instead of forbidden.

If your fetch handler writes, the access function must allow that write for an anonymous caller — or move the write to onChange/scheduled, which carry stronger identities.

onChange without loops

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;
  await ctx.db.put(
    { _id: "tally-" + event.doc.pollId, kind: "tally", bump: event.seq },
    { db: "tallies" }
  );
}

Two rules keep reactions sane, both visible above:

  1. Guard on event.dbName first — the handler fires for every database in the app, so an unguarded write-back loops on itself.
  2. Write derived docs to a different database than the one that triggered the event. Backend writes trigger onChange again (the platform caps runaway chains, but a same-database ping-pong is still wasted work). Different db + dbName guard makes loops structurally impossible.

A few details of the event worth knowing before you lean on them:

  • The chain cap is four generations deep. A person's write is generation one; a write your handler makes in response is generation two, and so on. Nothing fires past generation four, and the suppressed event leaves no trace in your app's logs — a reaction that quietly never runs is the sign to look for.
  • doc._rev is the write's sequence number, the same value as event.seq, and _rev is filled in on read, never stored.
  • A deleted document arrives as {} with deleted: true and no _rev. oldDoc is the stored body of the previous revision without a _rev, and it is null when there was no previous revision or that revision was itself a delete.

When a handler throws — retries, and the end of the line

Each lane fails differently: fetch does not retry, while onChange and scheduled both retry on their own:

  • fetch answers the caller with the 500 above and does not retry; a caller that wants a retry has to send the request again.
  • onChange is retried with backoff. A throw puts the same event back on the queue after 10 seconds, then 30, 90, 270 and 300 seconds — six deliveries over about twelve minutes. A handler that keeps throwing gets the event five more times with the same doc and _rev as the first delivery, which is why the state-machine pattern claims a job with ifRev before doing the work. Throw on purpose when the work is safe to repeat (an upstream 503); return on purpose when it is not (a paid call, a message to a person).
  • scheduled retries a throw with a short backoff. A throw is recorded as the timer's last error (npx vibes-diy app status you/your-app shows it) and the handler runs again after 5 seconds, then 10, 20 and 40 (never longer than the timer's own interval). After the fifth failure in a row the timer goes back to its normal cadence. Those extra runs repeat whatever the handler did before it threw, so make the work safe to repeat, and carry longer-lived retry state on a document — an attempts counter and a cap — as scheduled self-maintenance shows.

After the sixth onChange delivery fails, the platform stops and writes one error-level line into your app's log, prefixed platform: so you can tell the platform giving up from your own code throwing:

platform: onChange delivery gave up after 6 attempts — <reason>. db=<db> doc=<id> seq=<n>
shnpx vibes-diy app logs you/your-app --since 24h   # look for level error, body "platform:"

That line is the only trace a lost event leaves. The same shape covers a schedule that could not be re-armed after a push (platform: schedule re-arm delivery gave up …); npx vibes-diy app status you/your-app shows the timer's health and re-arms a dead one.

scheduled — periodic work

The interval is a static string literal between "5s" and "1h", validated at push time:

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

export async function scheduled(event, ctx) {
  // event: { scheduledTime } — ISO timestamp of this tick
  const docs = await ctx.db.query({ db: "notes" }); // unfiltered, admin mode
  // read, decide, then put/delete
}

ctx.db.query({ db }) returns the database's latest docs (capped at 2000) — filter and sort in the handler. A plain ACL database can be queried from a user-triggered fetch or onChange when the acting identity may read it; those reads are ACL-gated and anonymous callers are denied. A database bound to an access function still rejects fetch-lane reads, but onChange reads it filtered to the triggering user's channels — the same per-reader filter the frontend applies, so the handler sees exactly what the user who triggered it could see. Page with limit/after and walk next to exhaustion: a page whose docs all filter out still advances the cursor.

The owner's scheduled/admin override may query an access-function-bound database, but that read is deliberately unfiltered. For viewer-filtered access-function reads, query from the frontend instead; if the backend needs to query the data itself, use a plain ACL database.

Revisions — write only if nothing changed since you read

Every document a handler reads carries its current revision as _rev, beside _id: the doc ctx.db.get returns, each ctx.db.query row, and the onChange event's doc. Pass it back when you write, and the write commits only if nobody has written that document since your read:

jsconst job = await ctx.db.get("job-42", { db: "jobs" });
try {
  await ctx.db.put({ ...job, status: "running" }, { db: "jobs", ifRev: job._rev });
} catch (err) {
  if (err.code === "conflict") return; // someone else moved it first
  throw err;
}
  • One of several racers wins. When two runs write from the same read, one commits and the other throws with err.code === "conflict" and err.currentRev, the revision there now. That holds even when both wrote identical content.
  • ifRev: null means "create it". The write commits only if the document does not exist. A deleted document counts as not existing, the same answer get gives (null), so ifRev: null brings it back.
  • _rev is filled in on read and never stored. Spreading a read into a put is safe, and your access.js never sees it.
  • Leave ifRev out and a write is last-writer-wins, exactly as before. Existing apps change nothing.

Revisions are a backend.js feature. Writes from the browser through useFireproof save to the local copy first and sync afterwards, so they stay last-writer-wins. The doc-status state machines pattern uses revisions to hand each job to exactly one worker.

Secrets — set by the owner, seen only by handlers

shnpx vibes-diy secrets set STRIPE_KEY --vibe you/your-app   # prompts for the value, hidden; or the vibe's Settings page
npx vibes-diy secrets ls --vibe you/your-app                # names + last4 only
npx vibes-diy secrets rm STRIPE_KEY --vibe you/your-app

Handlers see them as ctx.secrets.STRIPE_KEY — frozen, {} when none. The value never appears in a document, a release, or any browser (not even yours); it exists only inside a handler invocation. Use secrets to verify inbound webhooks (compare a shared secret) or authenticate outbound calls (Authorization: Bearer ${ctx.secrets.KEY}). Keeping your own code from leaking one is on you: never write a secret anywhere users can read — a doc, a response body, an error message. Write back a status code, not the response.

Outbound fetch — what your backend may call

Handlers can fetch() external HTTPS APIs through a policy gate with three lanes:

  • Public reads: a plain GET with no key and no custom headers reaches any ordinary site, so an RSS or Atom feed, a public JSON API, an .ics calendar or a web page just works — nothing to arrange. The platform makes the request itself and serves the answer from a cache for about five minutes, so polling one feed faster than that re-reads the same bytes.
  • CORS-parity: if a browser on your app's own page could call it, your backend can too. Same preflight rules, same Access-Control-Allow-Origin check, browser-forbidden headers stripped. Most public JSON APIs just work — Bluesky's entire XRPC API, for example, is CORS-open, which is why a vibe can run a Bluesky bot with no special arrangement.
  • Curated platform list: server-to-server APIs that send no CORS headers (api.github.com is seeded, api.linkedin.com came via meta-hub). Adding a host is a reviewed change to the platform's curated list — ask in Discord or open an issue at VibesDIY/use-vibes.

Sending a record to another tool goes through Zapier. hooks.zapier.com is on the curated list for a POST to a Webhooks by Zapier → Catch Hook, so an app can hand a new record to any tool Zapier reaches (Clio, Slack, Google Sheets, an inbox) without holding that tool's credentials. Make the Zap, save the hook URL as a secret (npx vibes-diy secrets set ZAPIER_HOOK_URL --vibe you/your-app, or the Settings page), and ask the builder to post each new record to ctx.secrets.ZAPIER_HOOK_URL. The URL is the credential: never write it into a document or a response. Treat any 2xx as accepted and mark the record sent; on anything else throw, so the event is re-delivered with backoff and a run that never succeeds lands in the app's logs. Walkthrough: Send your vibe's data anywhere you want.

A link you paste into the builder rides the same gate. When the builder tells you that reading a source takes more power than this app has today, that source sits outside the three lanes above — ask the vibes.diy team in Discord or open an issue at VibesDIY/use-vibes, and they can open it for you.

A denied call returns 403 {"vibesEgressDenied": true, "gate": "floor" | "owner-blocked" | "cors" | "read-rate" | "rate-limit", "host": …} — check for that shape and surface a useful error instead of a mystery failure. gate says which lane refused: cors is a write or a keyed call to a host that never opted you in, and read-rate means the public-read budget is spent for the moment and the next tick clears it. Limits: HTTPS on port 443 only, 15s per request, 10MB responses, per-vibe rate caps (30 per 10s, 300 per minute). Public reads from a host that has not opted your app in are budgeted more tightly — 10 per 10s and 60 per minute — and a body over 512KB is served but not cached. Make outbound calls before returning your Response — a fetch fired after the handler returns is denied. The design story behind the gate is "Trust the target, bless the person".

ctx.callAI — server-side AI with no key anywhere

jsconst text = await ctx.callAI(
  "Summarize these entries as one paragraph: " + JSON.stringify(entries),
  { model: "openrouter/auto", max_tokens: 500 }
);

Resolves to the completion text. No API key exists in your code, your secrets, or anywhere in your tenant world — the platform makes the call and meters the cost to the account of whoever triggered the handler (the writer in onChange, the signed-in caller in fetch, the owner for scheduled ticks and anonymous webhooks — same ai_credits ledger as the chat).

Options: model (default "openrouter/auto"), max_tokens, temperature. No streaming, no schema mode — ask for JSON in the prompt and JSON.parse defensively. Budget: a handful of calls per invocation (currently 5) and ~64k prompt chars. A failed or denied call throws: catch it and write an error field back onto the doc 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. The canonical shape is button → request doc → onChange → ctx.callAI → write-back: the database is the request/response channel, so results survive reloads and sync to every viewer.

ctx.notify — tell one participant

jsawait ctx.notify({
  toHandle: game.opponent,
  body: "Your turn",
  dedupeKey: `game-${game._id}-turn-${game.turn}`,
});

One object, all three fields required; an optional path (a hash path inside your app, like #/game/42) makes the notification open there. The message lands in that person's bell, inbox, and push notifications. toHandle must already be a participant of this app — the owner (always), an accepted invite, an approved request, a handle in the access function's grant state, or somebody whose authenticated write reached the app (revocations deny anyone but the owner); the platform checks, not your handler. Membership resolves the person through their bound handle, except the grant-state clause, which matches the named handle itself. A passing check plus valid fields writes to the inbox history of a recipient the platform can resolve (an unresolvable handle fails after the participation check); their switches silence the live bell, push, and email — not the history. Note the grant-state clause is yours to write with care: naming a handle there makes it notifiable, so for opt-in turn messages seat handles their owners seated themselves — the full rule and the guest-list convention live in Telling one person vs. telling everyone. body is the whole message, plain text and 500 characters max (the platform supplies the title and the link back to your app). dedupeKey names the happening, keyed per (app, recipient), so a retried handler delivers once.

It throws on failure and resolves with nothing on success — there's no result to inspect, so await it inside a try/catch and let the surrounding work survive an undeliverable message.

Use it when one person needs to know one thing. When everyone should see something, there is no broadcast call: write the change into a database the audience already reads, and their useLiveQuery shows it to exactly the entitled readers. Both halves, with worked examples: Telling one person vs. telling everyone.

ctx.log — the diagnostics your handler leaves behind

jsctx.log("info", "tick finished", { written: 3, skipped: 12 });
ctx.log("cache miss", { key }); // level optional, defaults to info
shnpx vibes-diy app logs you/your-app --since 1h        # or --tail to follow

That's the lane. A handler's console.* output is collected nowhere — if you want to see it later, route it through ctx.log. The level comes first and is optional ("debug", "info", "warn", "error"); any other first argument is treated as the message with the level defaulting to info.

Log the shape of a run, not its contents: document ids, counts, statuses, timings, and an error's message and code. Identifiers keep the stream diagnostic while users' document bodies and ctx.secrets values stay inside the handler.

Honest edges

  • Backends always run the live release. There is no version-pinned backend serving: whatever you pushed last under you/your-app is what /_api, onChange, and scheduled execute. To test backend or access changes without touching production, push under a secondary slug (npx vibes-diy push --vibe you/your-app-staging) and promote by pushing to the real slug when it works.
  • Verified signed-in identity on the fetch lane is still rolling out — design as if fetch callers are anonymous, and require allowAnonymous-permitted writes or route through the other lanes.
  • Two kinds of observability, and they're for different jobs. For debugging, ctx.log is the real log lane and you read it back with npx vibes-diy app logs you/your-app --since 1h (--tail to follow) — a handler's console.* output goes nowhere. For anything users or your dashboard should see, write status docs into an oplog database, which doubles as the dashboard's data source (see the operator console pattern). The log stream is for you; the documents are the app's own memory.

Where to go next