(ctx, api) and may be async. Each handler returns the { blocks, state, effects } triple:
blocks: the component tree;nullmeans the UI doesn’t need to changestate: local state passed back to you on the next invocationeffects: operations such as writing data or sending notifications, declared so the site can validate each one and commit them in a single transaction
ctx: context
This list is the privacy boundary — it is deliberately narrow because it is visible to every mini app author.
Blocks: render and onAction
A blocks mini app returns a component tree, which the site renders natively:Webview: webview and onMessage
A webview mini app draws its own UI (HTML + CSS + JS) and gets a real client-side loop:Making scores trustworthy: seed replay
Scores from the page are untrusted. To make the leaderboard, the server must be able to verify them: the server issues a seed, the page sends back the sequence of moves, andonMessage replays them with the same simulation logic to compute the real score. A cheater who wants on the board has to actually play the game well — that’s how the site’s snake mini app works: its scores are computed by the server replaying the moves.
Background handlers
All four run as the mini app’s own bot account and cannot read any member’s private data. Their reads and writes address the app’s shared region, so
api.kv.get in a background run reads back what a background run stored.
A background ctx looks like this:
Permissions and data
Reads (viaapi, all require await; data is prefetched by the platform before the call, so the sandbox never actually queries the database):
Writes (return
effects; each is validated and committed in a single transaction):
Four things to remember:
- Only handlers can write to the shared region. A member’s
kv.setalways lands in their own namespace, which is what makes leaderboards trustworthy. - Broadcasts are just signals. A client that receives
rt.publishre-runs a permission-checked render; the broadcast payload itself is never treated as state. api.post.getanswers only for the post this invocation is about, andnullfor any other id. Prefetching is what stops an app installed in one corner reading across the site.kv.appcannot be enumerated. It answers only for the people this invocation is about, because on a real site the area is a lookup table with tens of thousands of rows — handing it over whole would put that list in memory on every post anybody writes.
context is granted implicitly — no need to request it. post.write, moderate.post, moderate.topic, webview and points.spend are privileged: only an admin grants them (points.spend also has a site setting that has to be on). The rules for posting and acting on content are in Bots.Points: an account between the installer and the member, not money the platform hands out
points.award is not minted from nothing. It’s a transfer: points come out of the balance of whoever installed the app and go to the member. If the installer’s balance can’t cover the batch, the whole thing is refused (E_POINTS_FUNDS); if the site has no points system, refused the same way (E_POINTS_UNAVAILABLE). The one exception is an install an admin has explicitly marked “platform-funded” — only there does the site mint the points outright. Whoever is paying, the same three ceilings still apply: per-award, per-app-per-day, per-member-per-day.
In a call a member triggered, that member is who gets paid. A background run (onTrigger and friends) acts as the bot, so the effect names the recipient with user_id — and it may only be the person the event is about, ctx.data.user_id, such as the inviter in member_invited; anyone else is refused (E_POINTS_DENIED). That is the whole recipe for “X points per person brought in”: a bot installed on the node listens for member_invited and sends points.award to data.inviter_id, funded by the node owner who installed it.
The other direction exists too: selling something. points.spend (a privileged scope, granted separately at review) doesn’t charge anyone — it asks:
onSpend(ctx, api) is then called, with ctx.spend carrying { request_id, amount, status: "paid" } — that’s where you deliver the goods (a kv.set, say). If the member taps “No thanks” or lets it expire (5 minutes), nothing happens.
A crashed onSpend doesn’t undo the payment — reconcile with api.points.spends() and deliver anything paid for but never granted; make delivery logic idempotent on request_id, since a retry must not deliver twice. A webview app hears the same outcome through community.onSpend = fn.
Once an admin marks an install “platform-funded” from the app’s admin page, that install’s points.award is minted by the site and its points.spend simply retires the points — neither passes through anyone’s balance. This is mainly for official, site-run mini apps that shouldn’t be charging whoever installed them.
Saying it in the reader’s language
ctx.locale is the forum’s own language. post.locale, on a post you read, is that member’s. Which to use follows from who reads what you write:
Fallback runs exact, then the same language written for anywhere else, then English. An app that wrote
zh_CN should not leave a zh_TW reader with English — Simplified is much closer to what they read than that.