Skip to main content
An app with surface: "service" has no interface. It renders nothing and there is nothing to press — it is woken by site events or a schedule, and speaks under an account of its own. This is what greeting newcomers, answering the same question for the tenth time, opening a daily thread and helping with moderation are made of.

Where it is installed

An ordinary app goes inside a post. A bot is installed against an area: A node owner presses Install on the app’s own page, picks their node, and fills in whatever it asked for:
Installing from the app's own page: pick a node, then fill in what it asked for
After that it is managed at /apps/nodes:
One node's apps: what is at work, its settings and stop buttons, and the picker for adding another

Its powers are that node’s moderators’ powers

A bot installed in a node has its account added to that node’s moderation group. That one line is the whole permission model. From there core’s own category-scoped checks decide what it may do — it can act in that node exactly as far as that node’s moderators can, and can do nothing at all in any other node. Not because of a check written for bots, but because it is not in anybody else’s group.
A bot can never act on staff, or on anyone who moderates the node. This is enforced by the platform rather than left to each author to remember. It also stops an app turning on itself, or on another app moderating the same node.

What wakes it

Declared in triggers in app.json:
A topic’s first post arrives as topic_created, never as post_created. The two do not both fire for it. An app that wants opening posts and replies has to subscribe to both — subscribing to post_created alone silently misses every opening post on the forum, with nothing in the logs to say so.
post_flagged is the proper way in for an app that helps moderate: a member decided something was worth looking at, and the app is asked to help with what they raised rather than going looking on its own. Its payload separates who flagged from who was flagged.

Speaking, and acting

Prefer flag.create. It costs an ordinary scope because it ends with a person deciding; deleting costs a privileged one because it ends with somebody’s post gone. An app that flags is most of the value of one that deletes, and it fails safe. Flags land in the review queue that node already has. post.delete refuses the opening post of a topic — that is closing somebody’s whole thread through a door marked “post”. Pinning reaches only the app’s own posts, and only inside the node it is installed in. To say something that stays above the replies — a node’s rules, a standing notice — use the pin option on post.reply rather than declaring a second post.pin:
Why an option and not its own effect: effects are all declared before any of them run, so a post.pin has no post id to name — the post it would pin does not exist until the post.reply in the same batch has run. “Say this, and keep it at the top” is one intention either way. A post that already exists can still be handled by id with post.pin / post.unpin. post.reply takes two more options that likewise reach only the reply itself: lock: true closes it to further replies, and collapse: true opens it folded to a one-line bar in the nested view, for the reader to unfold with a click — for something that should be there without being in the way. All three need moderate.topic, are recorded, and are undone by a one-shot revert.
post.lock closes a reply, and everything nested under it, to further replies; the rest of the topic carries on. Unlike pinning it is not limited to the app’s own posts — bringing somebody else’s argument to a halt is the entire use for it — so it sits with deleting: never staff, never another moderator of this node, and never a topic’s opening post, which would close the whole topic through a door marked “reply”. Locks go in the same ledger, and are lifted when an admin undoes what an app did.
A batch of effects is all or nothing. So when the pin is refused — a site-wide install, with no node whose moderators the bot acts as — the reply is not posted either. You never end up with a notice that was meant to stay on top sitting somewhere in the replies.
Everything is written to a ledger, so an admin can undo everything one app did in a single operation. Posting has a daily ceiling per app, and a second one per topic counted across every app, so two bots cannot fill a thread between them.

Holding a member back

user.mute keeps a member from opening topics or replying in this node; user.ban also takes them out of the node’s groups. Both need moderate.user, a scope of its own — acting on a person is a different thing from acting on a post, and an admin can grant one without the other.
A bot’s restriction always runs out. days must be 1, 3, 7 or 28, and there is no way to say “for good”: a bug has to heal itself, and shutting somebody out of a node permanently is a decision a person makes and answers for. The member is told by private message, with the length and the reason. Who cannot be held back: staff, the node’s owner, and its moderators. That last group includes the app’s own account, so an app cannot mute another app working the same node, nor mute its way out of being switched off. user.unmute / user.unban lift only what this app applied. A moderator’s decision is not an app’s to undo, or the app becomes a way around the moderator. Every restriction is recorded, and an admin’s one-shot revert lifts them all.

What it can read

All three answer only for the people this invocation is about — whoever acted, whoever wrote the post, whoever was reported. Asking after a stranger returns null, not an error. api.kv.app is worth a paragraph of its own. kv.shared belongs to one install: right for a leaderboard, useless for a list of judged accounts, which has to be the same in all fifty nodes an app runs in. kv.app is that list. It cannot be enumerated, because on a real site the area is a lookup table with tens of thousands of rows, and handing it over whole on every post would put that list in memory every time anybody writes anything.

Reaching outside

The sandbox has no network and will not get one. To reach an outside service, a handler returns an http.fetch effect — the site makes the request, and the answer arrives as a separate onFetch invocation:
The rules:
  • https only
  • Only exact hostnames a reviewer approved for this app, one at a time — no wildcards, no scheme, no path. Requested in domains in app.json.
  • The address is checked before connecting, so an approved hostname cannot be pointed at a private network
  • Redirects are not followed — where they lead was never reviewed
  • The response is capped, and requests have a timeout and a daily limit
Credentials such as an API key belong in the install’s settings, filled in by whoever installs it — never in the bundle a reviewer reads. See app.json and readme.

Working on a schedule

A bot that only works on a schedule must export onInstall. onSchedule fires for a job that already exists, and a job only exists because an effect asked for one — so without registering the first schedule.add in onInstall, it never starts at all.
onInstall runs again on every re-install and every playtest push, so what it does must be idempotent. schedule.add is: it is keyed by job_key and rewritten in place.

Building one

In a playtest a bot hears only about its own author’s actions — otherwise unreviewed code would see every post on the forum. post.write is validated in full and then thrown away: you can see what it would have posted, and it posts nothing.