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.
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 } |
| 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? } |
| 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.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 } |
| 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 | Add tools for AI agents (MCP) and the admin assistant; the host checks each tool's capability on every call | McpToolDefinition[] |
| http.responseHeaders | Add response headers (must run synchronously) | Record<string, string> |
| html.head / analytics.head | Contribute safe head markup (sync; html.head context includes the page locale) | string |
| 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 |
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.