JustFlows

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

Observe

Actions

Run after something happened. Return values are ignored. Use them for indexing, notifications, logging, and synchronization.

Decide

Gates

Run before a mutation. Call event.cancel(reason) to stop it with a message suitable for the operator.

Transform

Filters

Receive a value and must return the next value. Handlers form a predictable pipeline ordered by priority.

typescript
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.

typescript
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.

typescript
interface HookContext {
  siteId?: string;
  requestId?: string;
  source?: "http" | "job" | "cli" | "system";
  actor?: { userId?: string; role?: string };
}

Actions

HookPurposeValue / payload
app.starting / app.startedApplication lifecycle{ version }
app.stoppingApplication shutting down{}
content.created / content.updatedContent row created or updated{ contentId, siteId, type?, translationGroupId? }
content.published / content.unpublishedPublishing state changed{ contentId, siteId, type?, translationGroupId? }
content.deletedContent moved to trash{ contentId, siteId, type?, translationGroupId?, lastInTranslationGroup? }
content.revisionSaved / revisionDiscarded / revisionRestoredA working revision was saved, discarded, or restored{ contentId, siteId, revisionId, source?, actorId? }
media.uploadedMedia file stored{ siteId, mediaId, url }
media.deletedMedia file removed{ siteId, mediaId }
user.created / updated / deletedUser account changed{ userId }
user.accessChangedA user's role assignment changed{ userId, roleId }
access.roleCreated / roleUpdated / roleDeletedA custom role changed{ roleId }
auth.login / auth.logoutAuthentication completed{ userId, email }
auth.loginFailedA sign-in attempt failed{ email, reason }
workspace.createdA multisite workspace and its first site are usable (no database password){ tenantId, siteId, hostname, userMode, databaseMode }
workspace.suspended / reactivatedA workspace was suspended or reactivated{ tenantId }
workspace.deletedA workspace was marked deleted{ tenantId, dropDatabase }
workspace.purgedA suspended or deleted workspace was removed for good (notification only){ tenantId }
site.createdA site was added to a workspace{ tenantId, siteId, hostname, databaseChoice }
tenancy.workspaceCreatedworkspace.created plus the first administrator's email (needs platform:tenancy){ tenantId, siteId, hostname, userMode, databaseMode, adminEmail }
quota.updatedA platform operator or plugin saved workspace or site limits (needs platform:tenancy){ scope, scopeId, limits }
domain.added / activated / failed / removedCustom domain lifecycle: connected, verified and served, taken offline, or removed{ siteId, domainId, hostname, mode, provider }
plugin.installed / activated / deactivated / uninstalledPlugin lifecycle{ pluginId, version, siteId? }
plugin.deleteDataFired once a deleted plugin's deleteData() has finished{ pluginId, version, siteId? }
theme.installed / activatedTheme lifecycle{ themeId, version, siteId? }
core.updatedJustflows core was upgraded{ fromVersion, toVersion, source }
webhook.deliveredAn outbound webhook attempt finished{ deliveryId, endpointId, event, attempt, status, responseStatus, responseBody, error }
request.before / request.afterHTTP request lifecycle{ method, path, statusCode?, durationMs? }
page.viewedA 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.viewedA visitor saw the under-construction splash{ siteId }
cache.revalidatedSelective cache revalidation completed{ trigger, objects, siteId? }
staticExport.completedA static / edge export run finished{ ok, mode, outDir, publicUrl, pages, assets, bytes, pruned, durationMs, errors }
staticExport.deployPush the exported directory to object storage or a CDN{ outDir, publicUrl, manifest, summary }
email.receivedAn inbound message was accepted for this site (needs mail:hook){ from, to, subject, text }
email.queued / sent / failedSystem email delivery lifecycle{ messageType, recipient, transport, status, attempt, detail?, templateKey?, locale? }

Gates

HookPurposeValue / payload
content.beforeCreateValidate or cancel content creation{ input: { siteId, type?, title, slug?, excerpt?, fields? } }
content.beforeUpdateValidate the proposed revision{ contentId, siteId, revision?, revisionId? }
content.beforePublishBlock publishing with a human-readable reason{ contentId, siteId, revision?, revisionId? }
content.beforeDeleteValidate deletion{ contentId, siteId }
media.beforeUploadValidate uploads before storage{ siteId, filename, mimeType, sizeBytes }
media.beforeDeleteValidate media deletion{ siteId, mediaId }
workspace.beforeCreateRefuse a new workspace (no database password included){ name, slug, userMode, databaseMode, siteName, hostname }
workspace.beforeSuspend / beforeReactivateRefuse suspending or reactivating a workspace{ tenantId }
workspace.beforeDeleteRefuse deleting a workspace{ tenantId, dropDatabase }
workspace.beforePurgeHold back the permanent removal of a suspended or deleted workspace; records still exist{ tenantId }
site.beforeCreateRefuse adding a site to a workspace{ tenantId, name, hostname, databaseChoice, userMode, databaseMode }
domain.beforeAddRefuse a custom domain before a website adds it (needs platform:tenancy){ siteId, hostname, mode }
email.beforeSendCancel a rendered system email before it queues or sends{ messageType, recipient, transport, templateKey?, locale? }
typescript
ctx.hooks.gate("content.beforePublish", (event) => {
  if (!event.revision?.excerpt?.trim()) {
    event.cancel("Add an excerpt before publishing.");
  }
});

Filters

HookPurposeValue / payload
webhook.eventTypesAdd event names administrators may subscribe tostring[]
webhook.payloadShape JSON-safe event data before the host signs itunknown
content.input / content.outputTransform API contentRecord<string, unknown>
content.blocksTransform the stored block tree before renderingblock tree
content.renderTransform rendered public HTMLstring
comments.renderReplace the public comment thread markupstring
content.revisionCanonical live/working snapshot behind a revision gate or filter{ title, slug, excerpt, blocks, fields }
media.metadataTransform stored media metadataRecord<string, unknown>
navigation.itemsAdd or change navigation items (runs after the host resolves a menu, visibility rules applied)NavigationItem[]
menu.design.presetsContribute one-click menu layout presets to the visual designer (static data, ids "<pluginId>:<slug>")MenuDesignPreset[]
menu.visibility.evaluateAnswer an item-level custom visibility condition your plugin owns (seeded false, fail-closed)boolean
header.templates / header.resolve / header.configContribute, take over, or adjust site headersHeaderTemplate[] / HeaderConfig | null / HeaderConfig
admin.menuContribute admin navigationAdminNavItem[]
plugin.settingsOverlay values on the plugin settings screenRecord<string, unknown>
plugin.settings.writeIntercept a settings save to persist domain rows and drop keysRecord<string, unknown>
openapi.documentExtend the public OpenAPI documentOpenApiDocument
mcp.toolsNo longer listed or run; offer a plugin route to AI agents with the mcp route option insteadMcpToolDefinition[]
http.responseHeadersAdd response headers (must run synchronously)Record<string, string>
html.headContribute safe head markup to public pages (awaited; the context includes the page locale)string
analytics.headTracking 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 prioritystring
admin.bodyAdd HTML at the end of every administration screen (may be async; context has rootSite)string
security.cspSourcesAdd the https:// origins your public pages load to the Content-Security-Policy; 'unsafe-inline', *, paths, and http: are droppedCspSourceContribution
theme.cssAppend plugin CSS to the theme cascadestring
comments.spamBackendScore a comment submission with an external spam service (host thresholds still apply)SpamCheckBackend | null
search.backendTake over indexing and querying for site search with a plugin-owned engineSearchBackend | null
seo.sitemapPathsAdd public sitemap pathsstring[]
staticExport.routesAdd or remove seed paths before the export crawlstring[]
staticExport.formActionOverride the <form action> written for a dynamic endpointstring
staticExport.assetsAdd same-origin asset URLs the scanner cannot discoverstring[]
staticExport.excludeLeave per-visitor paths (cart, checkout, account) to the live app: never crawled, routed to the originStaticExportExclusion[]
theme.layoutScopesAdd a customizer layout section (content and wide width) for a public prefix your plugin ownsThemeLayoutScope[]
widgets.areasAdd widget areas with default widgets and the content types that show themWidgetAreaDefinition[]
account.sectionsAdd server-rendered, request-scoped sections to the /account pageAccountSection[]
quota.effectiveLimitLower a stored workspace or site limit; it can never raise one (needs platform:tenancy)number | null
permalinks.typeBasesDefault permalink bases for your plugin's content types (stored values win)Record<string, string>
media.placeholderReplace or clear the placeholder image for an empty image slot (must run synchronously)PlaceholderImage | null
site.underConstruction.renderReplace the under-construction page markup (must run synchronously)string
email.sender / email.subject / email.html / email.textAdjust final outgoing system-email fieldsEmailSender | string
email.routeChoose the site that files an inbound message; return the context's siteId to claim it (needs mail:hook)string
operatorKey.ensureCalled 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 }
typescript
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.