Hook Reference
Hooks allow plugins to intercept and modify EmDash behavior at specific points in the content, media, email, comment, and page lifecycle.
Hook overview
Section titled “Hook overview”The following table lists every hook, what triggers it, what it can modify, and whether it is exclusive:
| Hook | Trigger | Can Modify | Exclusive |
|---|---|---|---|
content:beforeSave | Before content is saved | Content data | No |
content:afterSave | After content is saved | Nothing | No |
content:beforeDelete | Before content is deleted | Can cancel | No |
content:afterDelete | After content is deleted | Nothing | No |
content:afterPublish | After content is published | Nothing | No |
content:afterUnpublish | After content is unpublished | Nothing | No |
content:afterRestore | After content is restored | Nothing | No |
content:afterSchedule | After content is scheduled | Nothing | No |
content:afterUnschedule | After content is unscheduled | Nothing | No |
media:beforeUpload | Before file is uploaded | File metadata | No |
media:afterUpload | After file is uploaded | Nothing | No |
cron | Scheduled task fires | Nothing | No |
email:beforeSend | Before email delivery | Message, can cancel | No |
email:deliver | Deliver email via transport | Nothing | Yes |
email:afterSend | After successful email delivery | Nothing | No |
comment:beforeCreate | Before comment is stored | Comment, can cancel | No |
comment:moderate | Decide comment approval status | Status | Yes |
comment:afterCreate | After comment is stored | Nothing | No |
comment:afterModerate | After admin changes comment status | Nothing | No |
page:metadata | Rendering public page head | Contribute tags | No |
page:fragments | Rendering public page body | Inject scripts | No |
plugin:install | When plugin is first installed | Nothing | No |
plugin:activate | When plugin is enabled | Nothing | No |
plugin:deactivate | When plugin is disabled | Nothing | No |
plugin:uninstall | When plugin is removed | Nothing | No |
Content hooks
Section titled “Content hooks”content:beforeSave
Section titled “content:beforeSave”Capability: content:write
Runs before content is saved to the database. Use it to validate, transform, or enrich content. A sandboxed hook rejects a save by returning a version 1 hook result with a SAVE_REJECTED error and a plain-text reason of 1–500 characters. The API responds with SAVE_REJECTED, and the admin identifies the plugin and shows the reason as text. Empty, overlong, malformed, and unknown error results fail the save with a generic CONTENT_HOOK_ERROR response.
From the host process, throw ContentSaveRejectedError (exported from emdash) to reject a save. Any other exception from either execution mode fails the save with a generic response that does not expose the exception message.
import { definePlugin } from "emdash";
export default definePlugin({ id: "my-plugin", version: "1.0.0", hooks: { "content:beforeSave": async (event, ctx) => { const { content, collection, isNew } = event;
// Add timestamps if (isNew) { content.createdBy = "system"; } content.modifiedAt = new Date().toISOString();
// Return modified content return content; }, },});interface ActorInfo { readonly id: string; readonly role: number;}
interface ContentHookEvent { content: Record<string, unknown>; // Content data collection: string; // Collection slug isNew: boolean; // True for creates, false for updates id?: string; // ID of the existing item on updates; absent on creates actor?: ActorInfo; // Authenticated user that initiated the save}On an update, content holds only the submitted field values. Load the stored item with ctx.content.get(event.collection, event.id) when the hook needs to compare against it. Authenticated REST, visual editing, and MCP saves include actor. Internal writes without an authenticated user omit it.
Return value
Section titled “Return value”- Return modified content object to apply changes
- Return a sandbox hook error envelope to reject the save with a bounded plain-text reason
- Return
voidto pass through unchanged
A sandboxed hook returns this complete envelope to reject a save:
return { __emdashSandboxHookResult: true, version: 1, error: { code: "SAVE_REJECTED", reason: "Add a summary before saving.", },};The host trims reason and accepts between 1 and 500 characters.
content:afterSave
Section titled “content:afterSave”Capability: content:read
Runs after content is saved. Use for side effects like notifications, cache invalidation, or external syncing.
hooks: { "content:afterSave": async (event, ctx) => { const { content, collection, isNew } = event;
if (collection === "posts" && content.status === "published") { // Notify external service await ctx.http?.fetch("https://api.example.com/notify", { method: "POST", body: JSON.stringify({ postId: content.id }), }); } },}content:afterSave receives content, collection, isNew, and the optional authenticated actor. content is the complete saved entry, with its database ID in content.id and collection fields under content.data. The separate optional id used by content:beforeSave updates is absent after the save.
Return value
Section titled “Return value”No return value expected.
content:beforeDelete
Section titled “content:beforeDelete”Capability: content:read
Runs before content is deleted. Use to validate deletion or prevent it.
hooks: { "content:beforeDelete": async (event, ctx) => { const { id, collection } = event;
// Prevent deletion of protected content const item = await ctx.content?.get(collection, id); if (item?.data.protected) { return false; // Cancel deletion }
// Allow deletion return true; },}interface ContentDeleteEvent { id: string; // Entry ID collection: string; // Collection slug permanent?: false; // Present for native plugins; omitted in the sandbox runtime}content:beforeDelete only runs when an entry moves to trash. Native plugins receive permanent: false; the sandbox runtime sends only id and collection. Do not use this field to branch inside a sandboxed hook. Permanent deletion bypasses content:beforeDelete, so the hook cannot prevent an administrator from permanently deleting an entry that is already in the trash.
Return value
Section titled “Return value”- Return
falseto cancel deletion - Return
trueorvoidto allow
content:afterDelete
Section titled “content:afterDelete”Capability: content:read
Runs after content is deleted. Use for cleanup tasks.
hooks: { "content:afterDelete": async (event, ctx) => { const { id, collection, permanent } = event;
if (permanent) { await ctx.storage.relatedItems.delete(`${collection}:${id}`); } },}The event contains id, collection, and permanent. permanent is false when the entry moves to trash and true when it is permanently deleted. Check it before removing data that a restored trashed entry would still need. This hook has no return value.
content:afterPublish
Section titled “content:afterPublish”Runs after an entry is published successfully, including an entry that EmDash publishes automatically at its scheduled time. Use it for work that depends on the published entry, such as notifying another service or refreshing an external search index.
hooks: { "content:afterPublish": async (event, ctx) => { ctx.log.info(`Published ${event.collection}/${event.content.id}`); },}The hook requires the content:read capability. EmDash runs it after the publish response, so its return value cannot change the entry or undo the publication. An error is logged; with errorPolicy: "abort", later publish hooks do not run.
content:afterUnpublish
Section titled “content:afterUnpublish”Runs after an entry is unpublished successfully. Use it to remove or update copies of content held by external systems.
hooks: { "content:afterUnpublish": async (event, ctx) => { ctx.log.info(`Unpublished ${event.collection}/${event.content.id}`); },}This hook has the same content:read capability requirement, deferred execution, event shape, and no-return-value contract as content:afterPublish.
content:afterRestore
Section titled “content:afterRestore”Runs after trashed content is restored. Requires content:read capability.
hooks: { "content:afterRestore": async (event, ctx) => { ctx.log.info(`Restored ${event.collection}/${event.content.id}`); },}content:afterSchedule
Section titled “content:afterSchedule”Runs after content is scheduled for future publishing. Requires content:read capability.
hooks: { "content:afterSchedule": async (event, ctx) => { ctx.log.info(`Scheduled ${event.collection}/${event.content.id}`); },}content:afterUnschedule
Section titled “content:afterUnschedule”Runs after scheduled content is unscheduled. Requires content:read capability.
hooks: { "content:afterUnschedule": async (event, ctx) => { ctx.log.info(`Unscheduled ${event.collection}/${event.content.id}`); },}interface ContentStateChangeEvent { content: Record<string, unknown>; collection: string;}This event shape is shared by content:afterPublish, content:afterUnpublish, content:afterRestore, content:afterSchedule, and content:afterUnschedule. content is the complete entry after the state change, including its id, slug, and status; collection fields are under content.data.
Return value
Section titled “Return value”No return value expected.
Media hooks
Section titled “Media hooks”media:beforeUpload
Section titled “media:beforeUpload”Capability: media:write
Runs before a file is uploaded. Use to validate, rename, or reject files.
hooks: { "media:beforeUpload": async (event, ctx) => { const { file } = event;
// Reject files over 10MB if (file.size > 10 * 1024 * 1024) { throw new Error("File too large"); }
// Rename file return { name: `${Date.now()}-${file.name}`, type: file.type, size: file.size, }; },}interface MediaUploadEvent { file: { name: string; // Original filename type: string; // MIME type size: number; // Size in bytes };}Return value
Section titled “Return value”- Return modified file metadata to apply changes
- Return
voidto pass through unchanged - Throw to reject the upload
media:afterUpload
Section titled “media:afterUpload”Capability: media:read
Runs after a file is uploaded. Use for processing, thumbnails, or metadata extraction.
hooks: { "media:afterUpload": async (event, ctx) => { const { media } = event;
if (media.mimeType.startsWith("image/")) { // Store image metadata await ctx.kv.set(`media:${media.id}:analyzed`, { processedAt: new Date().toISOString(), }); } },}interface MediaAfterUploadEvent { media: { id: string; filename: string; mimeType: string; size: number | null; url: string; createdAt: string; };}Return value
Section titled “Return value”No return value expected.
Lifecycle hooks
Section titled “Lifecycle hooks”Lifecycle hooks require no registration capability.
plugin:install
Section titled “plugin:install”Runs when a plugin is first installed. Use for initial setup, creating storage collections, or seeding data.
hooks: { "plugin:install": async (event, ctx) => { // Initialize default settings await ctx.kv.set("settings:enabled", true); await ctx.kv.set("settings:threshold", 100);
ctx.log.info("Plugin installed successfully"); },}plugin:activate
Section titled “plugin:activate”Runs when a plugin is enabled (after install or re-enable).
hooks: { "plugin:activate": async (event, ctx) => { ctx.log.info("Plugin activated"); },}plugin:deactivate
Section titled “plugin:deactivate”Runs when a plugin is disabled.
hooks: { "plugin:deactivate": async (event, ctx) => { ctx.log.info("Plugin deactivated"); },}plugin:install, plugin:activate, and plugin:deactivate receive an empty event object. They have no return value.
plugin:uninstall
Section titled “plugin:uninstall”Runs when a plugin is removed. Use for cleanup.
hooks: { "plugin:uninstall": async (event, ctx) => { const { deleteData } = event;
if (deleteData) { // Clean up all plugin data const items = await ctx.kv.list("settings:"); for (const { key } of items) { await ctx.kv.delete(key); } }
ctx.log.info("Plugin uninstalled"); },}interface UninstallEvent { deleteData: boolean; // User chose to delete data}The uninstall hook has no return value.
Cron hook
Section titled “Cron hook”Capability: None required
Fired when a scheduled task executes. Schedule tasks with ctx.cron.schedule().
hooks: { "cron": async (event, ctx) => { if (event.name === "daily-sync") { const data = await ctx.http?.fetch("https://api.example.com/data"); ctx.log.info("Sync complete"); } },}interface CronEvent { name: string; data?: Record<string, unknown>; scheduledAt: string;}The cron hook has no return value.
Email hooks
Section titled “Email hooks”For messages sent by plugins, email hooks run in order: email:beforeSend, then email:deliver, then email:afterSend. System authentication messages go directly to email:deliver; they do not run through email:beforeSend or email:afterSend.
email:beforeSend
Section titled “email:beforeSend”Capability: hooks.email-events:register
Middleware hook that runs before delivery. Transform messages or cancel delivery.
hooks: { "email:beforeSend": async (event, ctx) => { // Add footer to all emails return { ...event.message, text: event.message.text + "\n\n—Sent from My Site", };
// Or return false to cancel delivery },}interface EmailBeforeSendEvent { message: { to: string; subject: string; text: string; html?: string }; source: string;}Return value
Section titled “Return value”- Return modified message to transform
- Return
falseto cancel delivery
email:deliver
Section titled “email:deliver”Capability: hooks.email-transport:register | Exclusive: Yes
The transport provider. Only one plugin can deliver emails. Responsible for actually sending the message via an email service.
hooks: { "email:deliver": { exclusive: true, handler: async (event, ctx) => { await sendViaSES(event.message); }, },}Event and return value
Section titled “Event and return value”interface EmailDeliverEvent { message: { to: string; subject: string; text: string; html?: string }; source: string;}The hook has no return value. source is "system" for EmDash authentication messages and the plugin ID for messages sent by a plugin.
email:afterSend
Section titled “email:afterSend”Capability: hooks.email-events:register
Fire-and-forget hook after successful delivery. Errors are logged but do not propagate.
hooks: { "email:afterSend": async (event, ctx) => { await ctx.kv.set(`email:log:${Date.now()}`, { to: event.message.to, subject: event.message.subject, }); },}Event and return value
Section titled “Event and return value”email:afterSend receives the same message and source fields as email:deliver. It has no return value.
Comment hooks
Section titled “Comment hooks”Comment hooks run in order: comment:beforeCreate, then comment:moderate, then comment:afterCreate. The comment:afterModerate hook fires separately when an admin changes a comment’s status.
After a comment is stored, hooks receive this record shape:
interface StoredComment { id: string; collection: string; contentId: string; parentId: string | null; authorName: string; authorEmail: string; authorUserId: string | null; body: string; status: string; moderationMetadata: Record<string, unknown> | null; createdAt: string; updatedAt: string;}comment:beforeCreate
Section titled “comment:beforeCreate”Capability: users:read
Middleware hook before a comment is stored. Enrich, validate, or reject comments.
hooks: { "comment:beforeCreate": async (event, ctx) => { // Reject comments with links if (event.comment.body.includes("http")) { return false; } },}interface CommentBeforeCreateEvent { comment: { collection: string; contentId: string; parentId: string | null; authorName: string; authorEmail: string; authorUserId: string | null; body: string; ipHash: string | null; userAgent: string | null; }; metadata: Record<string, unknown>;}Return value
Section titled “Return value”- Return modified event to transform
- Return
falseto reject - Return
voidto pass through
comment:moderate
Section titled “comment:moderate”Capability: users:read | Exclusive: Yes
Decide whether a comment is approved, pending, or spam. Only one moderation provider is active.
hooks: { "comment:moderate": { exclusive: true, handler: async (event, ctx) => { const score = await checkSpam(event.comment); return { status: score > 0.8 ? "spam" : score > 0.5 ? "pending" : "approved", reason: `Spam score: ${score}`, }; }, },}interface CommentModerateEvent { comment: { /* same as beforeCreate */ }; metadata: Record<string, unknown>; collectionSettings: { commentsEnabled: boolean; commentsModeration: "all" | "first_time" | "none"; commentsClosedAfterDays: number; commentsAutoApproveUsers: boolean; }; priorApprovedCount: number;}Return value
Section titled “Return value”{ status: "approved" | "pending" | "spam"; reason?: string }comment:afterCreate
Section titled “comment:afterCreate”Capability: users:read
Fire-and-forget hook after a comment is stored. Use for notifications. Sending email also requires the email:send capability and a configured email:deliver provider; without both, ctx.email is undefined.
hooks: { "comment:afterCreate": async (event, ctx) => { const recipient = event.contentAuthor?.email; if (event.comment.status === "approved" && recipient && ctx.email) { await ctx.email.send({ to: recipient, subject: `New comment on "${event.content.title}"`, text: `${event.comment.authorName} commented: ${event.comment.body}`, }); } },}Event and return value
Section titled “Event and return value”interface CommentAfterCreateEvent { comment: StoredComment; metadata: Record<string, unknown>; content: { id: string; collection: string; slug: string; title?: string }; contentAuthor?: { id: string; name: string | null; email: string };}The hook has no return value.
comment:afterModerate
Section titled “comment:afterModerate”Capability: users:read
Fire-and-forget hook when an admin manually changes a comment’s status.
interface CommentAfterModerateEvent { comment: StoredComment; previousStatus: string; newStatus: string; moderator: { id: string; name: string | null };}The hook has no return value.
Page hooks
Section titled “Page hooks”Page hooks run when rendering public pages. They allow plugins to inject metadata and scripts.
Both page hooks receive the current public page context:
interface PublicPageContext { url: string; path: string; locale: string | null; kind: "content" | "custom"; pageType: string; title: string | null; pageTitle?: string | null; description: string | null; canonical: string | null; image: string | null; content?: { collection: string; id: string; slug: string | null }; seo?: { ogTitle?: string | null; ogDescription?: string | null; ogImage?: string | null; robots?: string | null; }; articleMeta?: { publishedTime?: string | null; modifiedTime?: string | null; author?: string | null; }; siteName?: string; breadcrumbs?: Array<{ name: string; url: string }>; siteUrl?: string;}
interface PageMetadataEvent { page: PublicPageContext }interface PageFragmentEvent { page: PublicPageContext }page:metadata
Section titled “page:metadata”Capability: None required
Contribute meta tags, Open Graph properties, JSON-LD structured data, or link tags to the page head.
hooks: { "page:metadata": async (event, ctx) => { return [ { kind: "meta", name: "generator", content: "EmDash" }, { kind: "property", property: "og:site_name", content: event.page.siteName ?? "My Site" }, { kind: "jsonld", graph: { "@type": "WebSite", name: event.page.siteName } }, ]; },}Contribution types
Section titled “Contribution types”type PageMetadataContribution = | { kind: "meta"; name: string; content: string; key?: string } | { kind: "property"; property: string; content: string; key?: string } | { kind: "link"; rel: "canonical" | "alternate" | "author" | "license" | "nlweb" | "site.standard.document"; href: string; hreflang?: string; key?: string; } | { kind: "jsonld"; id?: string; graph: Record<string, unknown> | Array<Record<string, unknown>>; };The key field deduplicates contributions — only the last contribution with a given key is used.
Return one contribution, an array of contributions, or null when the plugin has nothing to add.
page:fragments
Section titled “page:fragments”Capability: hooks.page-fragments:register
Inject scripts or HTML into pages. Only available to native plugins.
hooks: { "page:fragments": async (event, ctx) => { return [ { kind: "external-script", placement: "body:end", src: "https://analytics.example.com/script.js", async: true, }, { kind: "inline-script", placement: "head", code: `window.siteId = "abc123";`, }, ]; },}Contribution types
Section titled “Contribution types”type PageFragmentContribution = | { kind: "external-script"; placement: "head" | "body:start" | "body:end"; src: string; async?: boolean; defer?: boolean; attributes?: Record<string, string>; key?: string; } | { kind: "inline-script"; placement: "head" | "body:start" | "body:end"; code: string; attributes?: Record<string, string>; key?: string; } | { kind: "html"; placement: "head" | "body:start" | "body:end"; html: string; key?: string; };Return one fragment contribution, an array of contributions, or null when the plugin has nothing to add.
Hook configuration
Section titled “Hook configuration”Hooks accept either a handler function or a configuration object:
hooks: { // Simple handler "content:afterSave": async (event, ctx) => { ... },
// With configuration "content:beforeSave": { priority: 50, // Lower runs first (default: 100) timeout: 10000, // Max execution time in ms (default: 5000) dependencies: [], // Run after these plugins errorPolicy: "abort", // "continue" or "abort" (default) handler: async (event, ctx) => { ... }, },}Configuration options
Section titled “Configuration options”| Option | Type | Default | Description |
|---|---|---|---|
priority | number | 100 | Execution order (lower = earlier) |
timeout | number | 5000 | Max execution time in milliseconds |
dependencies | string[] | [] | Plugin IDs that must run first |
errorPolicy | string | "abort" | "continue" to ignore errors |
exclusive | boolean | false | Only one plugin can be the active provider (for provider-pattern hooks like email:deliver, comment:moderate) |
Plugin context
Section titled “Plugin context”All hooks receive a context object with access to plugin APIs:
interface PluginContext { plugin: { id: string; version: string }; storage: PluginStorage; kv: KVAccess; content?: ContentAccess; media?: MediaAccess; http?: HttpAccess; log: LogAccess; site: { name: string; url: string; locale: string }; url(path: string): string; users?: UserAccess; cron?: CronAccess; email?: EmailAccess;}See Plugin capabilities for the capability required by each context API.
Error handling
Section titled “Error handling”Errors in hooks are logged and handled based on errorPolicy:
"abort"(default) — Stop execution, rollback transaction if applicable"continue"— Log error and continue to next hook
hooks: { "content:beforeSave": { errorPolicy: "continue", // Don't block save if this fails handler: async (event, ctx) => { try { await ctx.http?.fetch("https://api.example.com/validate"); } catch (error) { ctx.log.warn("Validation service unavailable", error); } }, },}Execution order
Section titled “Execution order”Hooks run in this order:
- Sorted by
priority(ascending) - Plugins with
dependenciesrun after their dependencies - Within same priority, order is deterministic but unspecified
// This runs first (priority 10){ priority: 10, handler: ... }
// This runs second (priority 50){ priority: 50, handler: ... }
// This runs last (default priority 100){ handler: ... }