Skip to main content
Every handler has the signature (ctx, api) and may be async. Each handler returns the { blocks, state, effects } triple:
  • blocks: the component tree; null means the UI doesn’t need to change
  • state: local state passed back to you on the next invocation
  • effects: 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, and onMessage 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.
The JS in the page is injected as a source string, not a closure. Any module-level constants it references must be included in the injected preamble, or you’ll get X is not defined at runtime.

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:
An app with no interface, built entirely around these, is a service app — see Bots.

Permissions and data

Reads (via api, 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.set always lands in their own namespace, which is what makes leaderboards trustworthy.
  • Broadcasts are just signals. A client that receives rt.publish re-runs a permission-checked render; the broadcast payload itself is never treated as state.
  • api.post.get answers only for the post this invocation is about, and null for any other id. Prefetching is what stops an app installed in one corner reading across the site.
  • kv.app cannot 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:
All it does is make the site itself — not your UI — open a confirmation naming the amount, what it’s for, and who gets paid. Only when the member taps “Pay” there do any points move, from their balance to the installer’s (or straight to nothing, on a platform-funded install). Your 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.
You cannot draw this confirmation or click through it. Whatever your buttons say, what the member actually confirms comes from the site’s own record, not your interface. One invocation can only ask one question at a time — a new ask replaces an unanswered one — and both a per-purchase and a per-member-per-day spending cap apply.
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.