95 hooks — 50 filters, 45 events — and 6 UI regions. 25 are wired: something in the board fires
them today, and the rest are declared but not yet reached by a call site.
The wired column is derived from the tree by scripts/hook-callsites.mjs, not
maintained by hand — a registry entry with no call site is a promise about code
that never runs, and it fails in the quietest possible way: the plugin installs,
the handler registers, nothing happens. plugins/reference is required by its own
test to handle every wired hook, so a hook cannot join that column without
something proving it fires.
A filter is handed a value and returns a replacement; its result is used, so a
filter that throws or returns nothing leaves the value as it was and the chain
carries on with the next plugin. An event is told what happened and its return
value is discarded — which is why anything that only wants to observe should be one:
it cannot corrupt the thing it is watching even when it is wrong.
Handlers run in (priority, plugin key) order. Both halves are declared, so two
plugins compose the same way on every request and on every instance.
Every handler is called inside the host’s try/catch and is timed. A plugin that
fails repeatedly is switched off for the rest of the process and says so in its
health row. See plugin-api.md for the policy, the lifecycle and
the limits.
| Hook |
Kind |
Wired |
Value |
Context |
markdown.parse.text |
filter |
— |
string |
ViewerRef & { source: 'post' | 'signature' | 'pm' } |
markdown.render.html |
filter |
— |
string |
ViewerRef & { source: 'post' | 'signature' | 'pm' } |
markdown.directives |
filter |
— |
readonly string[] |
ForumRef | Record<string, never> |
post.body.html |
filter |
— |
string |
PostRef & ViewerRef |
signature.html |
filter |
— |
string |
ViewerRef & { authorId: number } |
smilies.list |
filter |
— |
readonly { readonly code: string; readonly imageUrl: string }[] |
ViewerRef |
word-filter.patterns |
filter |
— |
readonly { readonly pattern: string; readonly replacement: string }[] |
Record<string, never> |
markdown.parse.text — The raw Markdown source, before it is parsed. Last chance to rewrite input.
markdown.render.html — Rendered HTML, after the renderer has constructed it. Anything added here is trusted output and nothing escapes it afterwards.
markdown.directives — The declarative directive list, so a plugin can add a :::name block or :name[…] span without core changes.
post.body.html — One post’s rendered body, in the context of the thread it is being read in.
signature.html — A member’s rendered signature, wherever it appears.
smilies.list — The smilie set offered by the editor and substituted at render.
word-filter.patterns — The render-time word filter’s pattern list.
| Hook |
Kind |
Wired |
Value |
Context |
view.header |
filter |
yes |
HeaderModel |
ViewerRef & RequestRef |
view.user-panel |
filter |
yes |
UserPanelModel |
ViewerRef & RequestRef |
view.navigation |
filter |
— |
NavigationModel |
ViewerRef & RequestRef |
view.footer |
filter |
yes |
FooterModel |
ViewerRef & RequestRef |
view.forum-jump |
filter |
yes |
ForumJumpModel |
ViewerRef & RequestRef |
view.announcement |
filter |
yes |
AnnouncementModel |
ViewerRef |
view.board-index |
filter |
yes |
BoardIndexModel |
ViewerRef |
view.forum-row |
filter |
yes |
ForumRowSlotModel |
ViewerRef |
view.thread-row |
filter |
yes |
ThreadRowSlotModel |
ViewerRef & ForumRef |
view.post-bit |
filter |
yes |
PostBitSlotModel |
ViewerRef & ThreadRef |
view.post-actions |
filter |
yes |
PostActionsSlotModel |
ViewerRef & ThreadRef |
view.member-profile |
filter |
yes |
MemberProfileModel |
ViewerRef |
view.board-stats |
filter |
yes |
BoardStatsModel |
ViewerRef |
view.who-is-online |
filter |
yes |
WhoIsOnlineModel |
ViewerRef |
view.latest-threads |
filter |
yes |
LatestThreadsModel |
ViewerRef |
view.latest-posts |
filter |
yes |
LatestPostsModel |
ViewerRef |
view.pagination |
filter |
yes |
PaginationModel |
ViewerRef |
view.search-form |
filter |
yes |
SearchFormModel |
ViewerRef |
view.error-notice |
filter |
yes |
ErrorNoticeModel |
ViewerRef & RequestRef |
view.shell |
filter |
yes |
ShellModel |
ViewerRef & RequestRef |
view.notice |
filter |
— |
NoticeModel |
ViewerRef |
view.category-block |
filter |
— |
CategoryBlockModel |
ViewerRef |
view.subforum-list |
filter |
yes |
SubforumListModel |
ViewerRef & ForumRef |
view.forum-display |
filter |
yes |
ForumDisplayModel |
ViewerRef & ForumRef |
view.thread-view |
filter |
yes |
ThreadViewModel |
ViewerRef & ThreadRef |
view.post-form |
filter |
— |
PostFormModel |
ViewerRef |
view.redirect-notice |
filter |
— |
RedirectNoticeModel |
ViewerRef |
view.header — The header model, before the theme renders it.
view.user-panel — The user panel model: greeting, counts, account links.
view.navigation — The breadcrumb trail.
view.footer — The footer model, including its link list.
view.forum-jump — The jump box model. A plugin adding a destination must give it a real forum id — the route re-authorises whatever is submitted.
view.announcement — One announcement, on its way to the theme. Its body is already rendered HTML from the boardu2019s own renderer, so a plugin replacing it is replacing trusted markup — the one hook where that is true of a body.
view.board-index — The index page model.
view.forum-row — One forum row in a listing. Runs once per row — keep it cheap.
view.thread-row — One thread row in a listing. Runs once per row.
view.post-bit — One post as the theme will receive it. The busiest hook on the board: it runs once per post on every thread page.
view.post-actions — The per-post control links. Adding one here does not create permission to use it.
view.member-profile — A member’s profile model, including its custom fields and action links.
view.board-stats — The board totals block.
view.who-is-online — The online list, already resolved against the reader.
view.latest-threads — The index sidebar’s newest-threads panel. Runs again on every refresh of the live region, not only on the page load — keep it cheap.
view.latest-posts — The index sidebar’s newest-posts panel. Same refresh cost as view.latest-threads.
view.pagination — A resolved page-link window.
view.search-form — The search form model, including its filter options.
view.error-notice — The error page model. Runs on the page that renders when things are broken.
view.shell — The page frame’s model. Runs on every page including the error pages.
view.notice — A board notice or flash message, before the theme renders it.
view.category-block — One category on the index, with its rendered forum rows.
view.subforum-list — The compact child-forum list above a thread listing.
view.forum-display — A forum page’s model, including its rendered regions.
view.thread-view — A thread page’s model, including its rendered post list.
view.post-form — The composer page’s model. The form itself is app-rendered and arrives as a region.
view.redirect-notice — The interstitial shown after a mutation, before the meta refresh fires.
| Hook |
Kind |
Wired |
Value |
Context |
thread.create.validate |
filter |
— |
ValidationMessages |
{ draft: DraftPayload } |
thread.create.before |
filter |
— |
DraftPayload |
ViewerRef |
thread.created |
event |
yes |
ThreadRef & { authorId: number; subject: string } |
ViewerRef |
post.create.validate |
filter |
— |
ValidationMessages |
{ draft: DraftPayload; threadId: number } |
post.create.before |
filter |
— |
DraftPayload |
ViewerRef & { threadId: number } |
post.created |
event |
yes |
PostRef & { authorId: number } |
ViewerRef |
post.edit.before |
filter |
— |
{ readonly body: string; readonly reason: string | null } |
PostRef & ViewerRef |
post.edited |
event |
yes |
PostRef & { editorId: number; revision: number } |
ViewerRef |
post.delete.before |
event |
— |
PostRef |
ModerationRef |
post.deleted |
event |
— |
PostRef |
ModerationRef |
post.restored |
event |
— |
PostRef |
ModerationRef |
thread.moved |
event |
— |
{ readonly threadId: number; readonly fromForumId: number; readonly toForumId: number } |
ModerationRef |
thread.merged |
event |
— |
{ readonly keptThreadId: number; readonly mergedThreadId: number; readonly postCount: number } |
ModerationRef |
thread.split |
event |
— |
{ readonly sourceThreadId: number; readonly newThreadId: number; readonly postCount: number } |
ModerationRef |
thread.locked |
event |
— |
ThreadRef & { isLocked: boolean } |
ModerationRef |
thread.stickied |
event |
— |
ThreadRef & { isSticky: boolean } |
ModerationRef |
attachment.upload.validate |
filter |
— |
ValidationMessages |
{ readonly filename: string readonly bytes: number /** What the *bytes* say it is, not what the name claims. */ readonly detectedMimeType: string readonly uploaderId: number } |
attachment.uploaded |
event |
— |
{ readonly attachmentId: number; readonly postId: number | null; readonly bytes: number } |
ViewerRef |
attachment.deleted |
event |
— |
{ readonly attachmentId: number } |
ViewerRef |
poll.created |
event |
— |
ThreadRef & { pollId: number; optionCount: number } |
ViewerRef |
poll.voted |
event |
— |
{ readonly pollId: number; readonly optionId: number } |
ViewerRef |
rating.recorded |
event |
— |
{ readonly threadId: number; readonly rating: number; readonly average: number } |
ViewerRef |
thread.create.validate — Validation messages for a new thread. Returning a non-empty list refuses the post.
thread.create.before — The thread draft, before it is written. Subject, body, prefix, options.
thread.created — A thread was created and committed.
post.create.validate — Validation messages for a reply.
post.create.before — The reply draft, before it is written.
post.created — A reply was created and committed.
post.edit.before — An edit’s new body and reason, before the revision is written.
post.edited — A post was edited and a revision recorded.
post.delete.before — A post is about to be soft-deleted. Observation only: refusing is a permission.
post.deleted — A post was soft-deleted.
post.restored — A soft-deleted post was restored.
thread.moved — A thread changed forum. Carries both forum ids.
thread.merged — Two threads became one.
thread.split — Posts were split out into a new thread.
thread.locked — A thread was opened or closed.
thread.stickied — A thread was pinned or unpinned.
attachment.upload.validate — Validation messages for an upload, after the magic-byte check. A plugin may refuse a file core would accept; it can never accept one core refused.
attachment.uploaded — A file finished uploading and re-encoding.
attachment.deleted — An attachment was removed, by a member or by the orphan sweep.
poll.created — A poll was attached to a thread.
poll.voted — A vote was cast. Fires once; the database enforces one per member.
rating.recorded — A thread rating was recorded or changed.
| Hook |
Kind |
Wired |
Value |
Context |
report.created |
event |
— |
{ readonly reportId: number readonly target: 'post' | 'thread' | 'user' | 'pm' readonly targetId: number readonly reporterId: number } |
RequestRef |
report.resolved |
event |
— |
{ readonly reportId: number; readonly resolution: 'actioned' | 'rejected' } |
ModerationRef |
approval.queued |
event |
— |
{ readonly kind: 'thread' | 'post' | 'attachment'; readonly id: number } |
ViewerRef |
approval.decided |
event |
— |
{ readonly kind: 'thread' | 'post' | 'attachment' readonly id: number readonly approved: boolean } |
ModerationRef |
warning.issued |
event |
— |
{ readonly warningId: number readonly userId: number readonly points: number readonly expiresAt: string | null } |
ModerationRef |
warning.revoked |
event |
— |
{ readonly warningId: number; readonly userId: number } |
ModerationRef |
moderation.logged |
event |
— |
{ readonly action: string; readonly targetId: number | null } |
ModerationRef |
report.created — Something was reported. The hook a notifier or a webhook wants.
report.resolved — A report was closed, with the resolution.
approval.queued — Content entered the approval queue.
approval.decided — Queued content was approved or rejected.
warning.issued — A warning was issued, with its points and expiry.
warning.revoked — A warning was revoked or expired.
moderation.logged — A moderation action was written to the log.
| Hook |
Kind |
Wired |
Value |
Context |
user.register.validate |
filter |
— |
ValidationMessages |
{ readonly username: string; readonly email: string; readonly ipPrefix: string | null } |
user.registered |
event |
— |
UserRef & { username: string; requiresActivation: boolean } |
RequestRef |
user.activated |
event |
— |
UserRef |
RequestRef |
user.login.attempted |
event |
— |
{ readonly username: string readonly outcome: 'ok' | 'bad-credentials' | 'locked-out' | 'banned' /** Truncated. Never a full address. */ readonly ipPrefix: string | null } |
RequestRef |
user.logged-in |
event |
— |
UserRef |
RequestRef |
user.logged-out |
event |
— |
UserRef & { reason: 'requested' | 'revoked' } |
RequestRef |
user.banned |
event |
— |
UserRef & { expiresAt: string | null } |
ModerationRef |
user.unbanned |
event |
— |
UserRef & { expired: boolean } |
ModerationRef |
user.groups.changed |
event |
— |
UserRef & { primaryGroupId: number; secondaryGroupIds: readonly number[] } |
RequestRef |
user.profile.updated |
event |
— |
UserRef & { fields: readonly string[] } |
RequestRef |
user.merged |
event |
— |
{ readonly keptUserId: number; readonly mergedUserId: number } |
RequestRef |
user.deleted |
event |
— |
UserRef & { reason: 'pruned' | 'deleted' } |
RequestRef |
user.register.validate — Validation messages for a registration. Where a custom question or an external blocklist belongs.
user.registered — An account was created, before or after activation depending on the mode.
user.activated — An account finished activation.
user.login.attempted — A sign-in was attempted, with the outcome. Never carries the password or the session token.
user.logged-in — A session was established.
user.logged-out — A session was ended, by the member or by revocation.
user.banned — A member was banned, with the expiry when there is one.
user.unbanned — A ban was lifted or expired and the prior group restored.
user.groups.changed — Primary or secondary group membership changed.
user.profile.updated — A member saved profile or option changes.
user.merged — Two accounts were merged. Carries the winner and the account that went.
user.deleted — An account was pruned or deleted.
| Hook |
Kind |
Wired |
Value |
Context |
notification.create.before |
filter |
— |
{ readonly userId: number readonly kind: string readonly subjectText: string readonly href: string } | null |
RequestRef |
notification.created |
event |
— |
{ readonly notificationId: number; readonly userId: number } |
RequestRef |
mail.send.before |
filter |
— |
{ readonly to: string readonly subject: string readonly textBody: string readonly htmlBody: string | null } | null |
{ readonly template: string } |
mail.sent |
event |
— |
{ readonly to: string; readonly template: string } |
RequestRef |
pm.send.before |
filter |
— |
{ readonly senderId: number readonly recipientIds: readonly number[] readonly subject: string readonly body: string } | null |
RequestRef |
pm.sent |
event |
— |
{ readonly messageId: number; readonly recipientIds: readonly number[] } |
RequestRef |
subscription.changed |
event |
— |
{ readonly userId: number readonly target: 'thread' | 'forum' readonly targetId: number readonly subscribed: boolean } |
RequestRef |
reputation.changed |
event |
— |
{ readonly userId: number; readonly delta: number; readonly total: number } |
ViewerRef |
notification.create.before — A notification about to be created. Returning null suppresses it.
notification.created — A notification was stored.
mail.send.before — A queued message, before it is handed to the mail driver. Subject, body and recipient; returning null drops it.
mail.sent — A message was accepted by the driver. Not proof of delivery.
pm.send.before — A private message, before it is stored.
pm.sent — A private message was delivered to its recipients’ folders.
subscription.changed — A member subscribed to or unsubscribed from a thread or forum.
reputation.changed — Reputation was given, changed or removed.
| Hook |
Kind |
Wired |
Value |
Context |
search.query.before |
filter |
— |
string |
ViewerRef |
search.results |
filter |
— |
readonly { readonly postId: number; readonly threadId: number; readonly rank: number }[] |
ViewerRef & { terms: string } |
feed.items |
filter |
— |
readonly { readonly title: string readonly href: string readonly publishedAt: string readonly summary: string }[] /** Always a guest: a feed is cached under a shared URL. */ |
{ readonly feed: 'board' | 'forum' | 'thread' } |
sitemap.entries |
filter |
— |
readonly { readonly href: string; readonly lastModified: string | null }[] |
{ readonly chunk: number } |
metadata.page |
filter |
— |
{ readonly title: string readonly description: string | null readonly canonical: string readonly imageUrl: string | null } |
{ readonly route: string } |
search.query.before — The parsed search terms, before the query runs. The scope is not filterable.
search.results — A page of results, already permission-filtered in SQL. A plugin may reorder or drop; adding a row here would add one the viewer may not see.
feed.items — The items of a feed, rendered as a guest. Anything added is public.
sitemap.entries — One chunk of the sitemap.
metadata.page — Title, description and social card for a page.
| Hook |
Kind |
Wired |
Value |
Context |
admin.navigation |
filter |
— |
readonly { readonly label: string; readonly href: string }[] |
ViewerRef |
settings.saved |
event |
— |
{ readonly keys: readonly string[] } |
{ readonly adminId: number } |
task.run.before |
event |
— |
{ readonly taskId: string } |
Record<string, never> |
task.run.after |
event |
— |
{ readonly taskId: string; readonly ok: boolean; readonly durationMs: number } |
Record<string, never> |
cache.invalidated |
event |
— |
{ readonly tag: string } |
Record<string, never> |
plugin.enabled |
event |
— |
{ readonly pluginKey: string } |
Record<string, never> |
plugin.disabled |
event |
— |
{ readonly pluginKey: string; readonly reason: 'operator' | 'failures' } |
Record<string, never> |
admin.navigation — The admin panel’s section links, so a plugin page can be reached.
settings.saved — Board settings changed. Carries the keys, never the values.
task.run.before — A scheduled task is about to run.
task.run.after — A scheduled task finished, with its outcome and duration.
cache.invalidated — A cache tag was invalidated.
plugin.enabled — A plugin was enabled — including this one, which is how it learns it is on.
plugin.disabled — A plugin was disabled, by an operator or by the host after repeated failures. Carries the reason.
Regions are not theme slots. A theme owns its slots; a region is an explicit
"plugins may add something here" point that a theme chooses to render, so the theme
keeps control of where plugin output appears and the plugin keeps control of what it
is. Several plugins contributing to one region compose by concatenation, in the same
deterministic order as hooks.
| Region |
What it is handed |
header.notice |
The viewer. |
index.footer |
The viewer. |
postbit.badges |
The viewer, the post id and the author id. |
postbit.footer |
The viewer, the post id and the author id. |
profile.panel |
The viewer and the profile’s member id. |
admin.dashboard |
The viewer. |
header.notice — Directly below the board header, above the page body. Board-wide notices.
index.footer — The bottom of the board index, below the statistics block.
postbit.badges — Beside a post author’s name. Runs once per post on every thread page — the most expensive region on the board, and the one to keep trivial.
postbit.footer — Below a post body, above its actions.
profile.panel — A panel on a member’s profile, below the standard fields.
admin.dashboard — A card on the admin dashboard. Only rendered for administrators.