SDK reference
Hooks
Hooks let plugins observe completed work, stop operations before they happen, or transform values as they move through Justflows. The public names and payloads are versioned in @justflows/sdk.
Actions, gates, and filters
Actions
Run after something happened. Return values are ignored. Use them for indexing, notifications, logging, and synchronization.
Gates
Run before a mutation. Call event.cancel(reason) to stop it with a message suitable for the operator.
Filters
Receive a value and must return the next value. Handlers form a predictable pipeline ordered by priority.
const unsubscribe = ctx.hooks.action(
"content.published",
async (event, hookContext) => {
await updateSearchIndex(event.contentId);
},
{ priority: 100, id: "acme.search.index" }
);
// Optional manual cleanup. The host also cleans up on deactivate.
unsubscribe();Ordering and cleanup
Lower priorities run first; the default is 100. Handlers at the same priority keep registration order. Set once: true for one dispatch. Registration returns an unsubscribe function, and the runtime automatically removes a plugin’s handlers when it deactivates.
Network-wide handlers
On a multisite installation a handler normally runs only on the sites that activated the plugin. Register it with network: true to run it on every site while the plugin is active on the main site, also on sites that never activated it. That needs the sensitive platform:network permission; without it, registering throws. Routes, jobs, and unmarked handlers stay per site. Inside the handler, ctx.settings and ctx.data still belong to the request’s site, so read the main site’s choices through ctx.network.mainSiteSettings.
ctx.hooks.filter("admin.body", async (html) => {
const tag = await ctx.network?.mainSiteSettings.get<string>("adminTag");
if (!tag || !/^[A-Z0-9-]+$/.test(tag)) return html;
return `${html}<script src="/ext/acme.tags/tag.js" data-tag="${tag}"></script>`;
}, { network: true });Hook context
The second handler argument carries correlation and identity—not secrets or server internals.
interface HookContext {
siteId?: string;
requestId?: string;
source?: "http" | "job" | "cli" | "system";
actor?: { userId?: string; role?: string };
}Actions
| Hook | Purpose | Value / payload |
|---|---|---|
| app.starting / app.started | Application lifecycle | { version } |
| app.stopping | Application shutting down | {} |
| content.created / content.updated | Content row created or updated | { contentId, siteId, type?, translationGroupId? } |
| content.published / content.unpublished | Publishing state changed | { contentId, siteId, type?, translationGroupId? } |
| content.deleted | Content moved to trash | { contentId, siteId, type?, translationGroupId?, lastInTranslationGroup? } |
| content.revisionSaved / revisionDiscarded / revisionRestored | A working revision was saved, discarded, or restored | { contentId, siteId, revisionId, source?, actorId? } |
| media.uploaded | Media file stored | { siteId, mediaId, url } |
| media.deleted | Media file removed | { siteId, mediaId } |
| user.created / updated / deleted | User account changed | { userId } |
| user.accessChanged | A user's role assignment changed | { userId, roleId } |
| access.roleCreated / roleUpdated / roleDeleted | A custom role changed | { roleId } |
| auth.login / auth.logout | Authentication completed | { userId, email } |
| auth.loginFailed | A sign-in attempt failed | { email, reason } |
| workspace.created | A multisite workspace and its first site are usable (no database password) | { tenantId, siteId, hostname, userMode, databaseMode } |
| workspace.suspended / reactivated | A workspace was suspended or reactivated | { tenantId } |
| workspace.deleted | A workspace was marked deleted | { tenantId, dropDatabase } |
| workspace.purged | A suspended or deleted workspace was removed for good (notification only) | { tenantId } |
| site.created | A site was added to a workspace | { tenantId, siteId, hostname, databaseChoice } |
| tenancy.workspaceCreated | workspace.created plus the first administrator's email (needs platform:tenancy) | { tenantId, siteId, hostname, userMode, databaseMode, adminEmail } |
| quota.updated | A platform operator or plugin saved workspace or site limits (needs platform:tenancy) | { scope, scopeId, limits } |
| domain.added / activated / failed / removed | Custom domain lifecycle: connected, verified and served, taken offline, or removed | { siteId, domainId, hostname, mode, provider } |
| plugin.installed / activated / deactivated / uninstalled | Plugin lifecycle | { pluginId, version, siteId? } |
| plugin.deleteData | Fired once a deleted plugin's deleteData() has finished | { pluginId, version, siteId? } |
| theme.installed / activated | Theme lifecycle | { themeId, version, siteId? } |
| core.updated | Justflows core was upgraded | { fromVersion, toVersion, source } |
| webhook.delivered | An outbound webhook attempt finished | { deliveryId, endpointId, event, attempt, status, responseStatus, responseBody, error } |
| request.before / request.after | HTTP request lifecycle | { method, path, statusCode?, durationMs? } |
| page.viewed | A visitor received a public page (not a preview, an error page, or the static exporter's crawl); fired after the response is sent | { siteId, path, locale?, contentId?, userAgent, referrer?, host } |
| site.underConstruction.viewed | A visitor saw the under-construction splash | { siteId } |
| cache.revalidated | Selective cache revalidation completed | { trigger, objects, siteId? } |
| staticExport.completed | A static / edge export run finished | { ok, mode, outDir, publicUrl, pages, assets, bytes, pruned, durationMs, errors } |
| staticExport.deploy | Push the exported directory to object storage or a CDN | { outDir, publicUrl, manifest, summary } |
| email.received | An inbound message was accepted for this site (needs mail:hook) | { from, to, subject, text } |
| email.queued / sent / failed | System email delivery lifecycle | { messageType, recipient, transport, status, attempt, detail?, templateKey?, locale? } |
Gates
| Hook | Purpose | Value / payload |
|---|---|---|
| content.beforeCreate | Validate or cancel content creation | { input: { siteId, type?, title, slug?, excerpt?, fields? } } |
| content.beforeUpdate | Validate the proposed revision | { contentId, siteId, revision?, revisionId? } |
| content.beforePublish | Block publishing with a human-readable reason | { contentId, siteId, revision?, revisionId? } |
| content.beforeDelete | Validate deletion | { contentId, siteId } |
| media.beforeUpload | Validate uploads before storage | { siteId, filename, mimeType, sizeBytes } |
| media.beforeDelete | Validate media deletion | { siteId, mediaId } |
| workspace.beforeCreate | Refuse a new workspace (no database password included) | { name, slug, userMode, databaseMode, siteName, hostname } |
| workspace.beforeSuspend / beforeReactivate | Refuse suspending or reactivating a workspace | { tenantId } |
| workspace.beforeDelete | Refuse deleting a workspace | { tenantId, dropDatabase } |
| workspace.beforePurge | Hold back the permanent removal of a suspended or deleted workspace; records still exist | { tenantId } |
| site.beforeCreate | Refuse adding a site to a workspace | { tenantId, name, hostname, databaseChoice, userMode, databaseMode } |
| domain.beforeAdd | Refuse a custom domain before a website adds it (needs platform:tenancy) | { siteId, hostname, mode } |
| email.beforeSend | Cancel a rendered system email before it queues or sends | { messageType, recipient, transport, templateKey?, locale? } |
ctx.hooks.gate("content.beforePublish", (event) => {
if (!event.revision?.excerpt?.trim()) {
event.cancel("Add an excerpt before publishing.");
}
});Filters
| Hook | Purpose | Value / payload |
|---|---|---|
| webhook.eventTypes | Add event names administrators may subscribe to | string[] |
| webhook.payload | Shape JSON-safe event data before the host signs it | unknown |
| content.input / content.output | Transform API content | Record<string, unknown> |
| content.blocks | Transform the stored block tree before rendering | block tree |
| content.render | Transform rendered public HTML | string |
| comments.render | Replace the public comment thread markup | string |
| content.revision | Canonical live/working snapshot behind a revision gate or filter | { title, slug, excerpt, blocks, fields } |
| media.metadata | Transform stored media metadata | Record<string, unknown> |
| navigation.items | Add or change navigation items (runs after the host resolves a menu, visibility rules applied) | NavigationItem[] |
| menu.design.presets | Contribute one-click menu layout presets to the visual designer (static data, ids "<pluginId>:<slug>") | MenuDesignPreset[] |
| menu.visibility.evaluate | Answer an item-level custom visibility condition your plugin owns (seeded false, fail-closed) | boolean |
| header.templates / header.resolve / header.config | Contribute, take over, or adjust site headers | HeaderTemplate[] / HeaderConfig | null / HeaderConfig |
| admin.menu | Contribute admin navigation | AdminNavItem[] |
| plugin.settings | Overlay values on the plugin settings screen | Record<string, unknown> |
| plugin.settings.write | Intercept a settings save to persist domain rows and drop keys | Record<string, unknown> |
| openapi.document | Extend the public OpenAPI document | OpenApiDocument |
| mcp.tools | No longer listed or run; offer a plugin route to AI agents with the mcp route option instead | McpToolDefinition[] |
| http.responseHeaders | Add response headers (must run synchronously) | Record<string, string> |
| html.head | Contribute safe head markup to public pages (awaited; the context includes the page locale) | string |
| analytics.head | Tracking markup for a public page, seeded with "" (must run synchronously). A tag plugin appends below priority 100; a consent plugin rewrites it at the default priority | string |
| admin.body | Add HTML at the end of every administration screen (may be async; context has rootSite) | string |
| security.cspSources | Add the https:// origins your public pages load to the Content-Security-Policy; 'unsafe-inline', *, paths, and http: are dropped | CspSourceContribution |
| theme.css | Append plugin CSS to the theme cascade | string |
| comments.spamBackend | Score a comment submission with an external spam service (host thresholds still apply) | SpamCheckBackend | null |
| search.backend | Take over indexing and querying for site search with a plugin-owned engine | SearchBackend | null |
| seo.sitemapPaths | Add public sitemap paths | string[] |
| staticExport.routes | Add or remove seed paths before the export crawl | string[] |
| staticExport.formAction | Override the <form action> written for a dynamic endpoint | string |
| staticExport.assets | Add same-origin asset URLs the scanner cannot discover | string[] |
| staticExport.exclude | Leave per-visitor paths (cart, checkout, account) to the live app: never crawled, routed to the origin | StaticExportExclusion[] |
| theme.layoutScopes | Add a customizer layout section (content and wide width) for a public prefix your plugin owns | ThemeLayoutScope[] |
| widgets.areas | Add widget areas with default widgets and the content types that show them | WidgetAreaDefinition[] |
| account.sections | Add server-rendered, request-scoped sections to the /account page | AccountSection[] |
| quota.effectiveLimit | Lower a stored workspace or site limit; it can never raise one (needs platform:tenancy) | number | null |
| permalinks.typeBases | Default permalink bases for your plugin's content types (stored values win) | Record<string, string> |
| media.placeholder | Replace or clear the placeholder image for an empty image slot (must run synchronously) | PlaceholderImage | null |
| site.underConstruction.render | Replace the under-construction page markup (must run synchronously) | string |
| email.sender / email.subject / email.html / email.text | Adjust final outgoing system-email fields | EmailSender | string |
| email.route | Choose the site that files an inbound message; return the context's siteId to claim it (needs mail:hook) | string |
| operatorKey.ensure | Called with hooks.apply: ask the host to create a missing 32-byte key in the installation .env, named with your plugin's prefix (never returned or stored in the database) | { name } |
ctx.hooks.filter(
"content.output",
(content) => ({ ...content, enhancedBy: ctx.pluginId }),
{ priority: 120 }
);Publish plugin-owned hooks
A plugin may emit actions, gates, and filters under its own manifest ID—for example acme.shop.order.created. Core namespaces cannot be spoofed. Export the payload types from your own package so integrators do not have to guess their shape.