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, orfetch("/_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-256is checked against the App's secret and then stripped; you never hold that secret and cannot check it yourself. Everyx-vibes-*header on the request was put there by the platform — inbound headers in that namespace are deleted before the forward — sox-vibes-github-event,x-vibes-github-delivery,x-vibes-github-installationandx-vibes-timestampare the platform's word, not the caller's. (x-vibes-signature-256carries 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-externalto say so. Store them as data and never feed them toctx.callAIas 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
2xxacknowledges. A4xxis final: the platform answers GitHub with success and the delivery is not retried, which is also what happens when your backend has no/github-webhookroute at all. A throw, or a5xx, 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 ascheduledsweep. - It runs as nobody. GitHub's deliveries carry no sign-in, so
ctx.userInfoisnulland anyctx.db.putmust passaccess.jsfor an anonymous caller — or write a request document and letscheduleddo 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:
- Guard on
event.dbNamefirst — the handler fires for every database in the app, so an unguarded write-back loops on itself. - Write derived docs to a different database than the one that
triggered the event. Backend writes trigger
onChangeagain (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._revis the write's sequence number, the same value asevent.seq, and_revis filled in on read, never stored.- A deleted document arrives as
{}withdeleted: trueand no_rev.oldDocis the stored body of the previous revision without a_rev, and it isnullwhen 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:
fetchanswers the caller with the500above and does not retry; a caller that wants a retry has to send the request again.onChangeis 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 samedocand_revas the first delivery, which is why the state-machine pattern claims a job withifRevbefore doing the work. Throw on purpose when the work is safe to repeat (an upstream503); return on purpose when it is not (a paid call, a message to a person).scheduledretries a throw with a short backoff. A throw is recorded as the timer's last error (npx vibes-diy app status you/your-appshows 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 — anattemptscounter 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"anderr.currentRev, the revision there now. That holds even when both wrote identical content. ifRev: nullmeans "create it". The write commits only if the document does not exist. A deleted document counts as not existing, the same answergetgives (null), soifRev: nullbrings it back._revis filled in on read and never stored. Spreading a read into a put is safe, and youraccess.jsnever sees it.- Leave
ifRevout 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
GETwith no key and no custom headers reaches any ordinary site, so an RSS or Atom feed, a public JSON API, an.icscalendar 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-Origincheck, 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.comis seeded,api.linkedin.comcame 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-appis what/_api,onChange, andscheduledexecute. 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
fetchlane is still rolling out — design as iffetchcallers are anonymous, and requireallowAnonymous-permitted writes or route through the other lanes. - Two kinds of observability, and they're for different jobs. For
debugging,
ctx.logis the real log lane and you read it back withnpx vibes-diy app logs you/your-app --since 1h(--tailto follow) — a handler'sconsole.*output goes nowhere. For anything users or your dashboard should see, write status docs into anoplogdatabase, 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
- backend.js reference — the full contract, every field and cap, generated from platform source.
- Telling one person vs. telling everyone —
when to send a
ctx.notifyand when to just write the change down. - Connect your visitors' accounts — let a visitor link their own GitHub account so your backend can read from it, with no credential code of your own.
- Patterns cookbook — the write-only credential vault, doc-status state machines, CI lanes with device certs, and the other production patterns our own infrastructure runs on.
- Launch stories: Every vibe just got a backend, Your backend can call the model now.