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:


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.What wakes it
Declared intriggers in app.json:
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:
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.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.
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 anhttp.fetch effect — the site makes the request, and the answer arrives as a separate onFetch invocation:
- https only
- Only exact hostnames a reviewer approved for this app, one at a time — no wildcards, no scheme, no path. Requested in
domainsinapp.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 exportonInstall. 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
post.write is validated in full and then thrown away: you can see what it would have posted, and it posts nothing.