Files
captoys/potluck/.agents/skills/creating-plugins/references/hooks.md
T
dckc 4b4ef8fea6 capsite: potluck, a fresh EmDash site (cloudflare:blog out of the box)
pnpm create emdash@latest potluck --pm pnpm --install --yes, per
https://docs.emdashcms.com/ ("Create your first EmDash site"); the
--yes defaults are cloudflare + blog. Replaces the old madmode/
starter (never committed).

Generated-by: create-emdash 1.2.0, run by Claude Opus 5.5
2026-10-07 19:57:43 -05:00

22 KiB
Raw Blame History

Hooks

Sandboxed plugins declare hooks in the typed default export from src/plugin.ts. The SandboxedPlugin type infers each event and return type from the hook name.

Signature

async (event: EventType, ctx: PluginContext) => ReturnType;

Configuration

Simple handler or full config:

import type { SandboxedPlugin } from "emdash/plugin";

const plugin: SandboxedPlugin = {
	hooks: {
		"content:afterSave": {
			priority: 100,
			timeout: 5000,
			handler: async (event, ctx) => {
				ctx.log.info("Saved", { id: event.content.id });
			},
		},
	},
};

export default plugin;

The plugin CLI manifest retains priority, timeout, dependencies, errorPolicy, and exclusive. Registry-installed and config-managed sandbox hooks enter the shared host pipeline, so these settings apply consistently with trusted plugins.

Put capabilities in emdash-plugin.jsonc, not in src/plugin.ts. The host pipeline skips sandboxed and trusted hooks whose required capability is absent.

Isolated sandbox support

The current isolated runtime dispatches these hooks to Cloudflare and Node/workerd sandbox instances through the shared host pipeline:

  • plugin:install, plugin:activate, plugin:deactivate, and plugin:uninstall
  • content:beforeSave, content:afterSave, content:beforeDelete, and content:afterDelete
  • content:beforePublish, content:beforeSchedule, content:beforeUnpublish, content:afterPublish, content:afterUnpublish, content:afterRestore, content:afterSchedule, and content:afterUnschedule
  • media:beforeUpload and media:afterUpload
  • email:beforeSend, email:deliver, and email:afterSend
  • comment:beforeCreate, comment:moderate, comment:afterCreate, and comment:afterModerate
  • cron
  • page:metadata

page:fragments is the only declared hook excluded from sandbox registration. The plugin CLI accepts it and emits a trusted-only warning; the sandbox host proxy drops it before the hook pipeline is built.

Lifecycle Hooks

plugin:install

Runs once on first install. Use to seed defaults.

"plugin:install": async (_event, ctx) => {
	await ctx.settings.set("enabled", true);
	await ctx.storage.items!.put("default", { name: "Default" });
}

Event: {} Returns: void

plugin:activate

Runs when plugin is enabled (after install or re-enable).

"plugin:activate": async (_event, ctx) => {
	ctx.log.info("Activated");
}

Event: {} Returns: void

plugin:deactivate

Runs when plugin is disabled (not removed).

"plugin:deactivate": async (_event, ctx) => {
	ctx.log.info("Deactivated");
}

Event: {} Returns: void

plugin:uninstall

Runs when plugin is removed. Only delete data if event.deleteData is true.

"plugin:uninstall": async (event, ctx) => {
	if (event.deleteData) {
		const result = await ctx.storage.items!.query({ limit: 1000 });
		await ctx.storage.items!.deleteMany(result.items.map(i => i.id));
	}
}

Event: { deleteData: boolean } Returns: void

Content Hooks

content:beforeSave

Runs before save. Return modified content, or void to keep it unchanged. To reject from the sandbox, return { __emdashSandboxHookResult: true, version: 1, error: { code: "SAVE_REJECTED", reason } }. The reason must be 1–500 characters of plain text. EmDash identifies the plugin and shows the reason as text; invalid or unknown error results fail with a generic hook error. From the host process, throw ContentSaveRejectedError (exported from emdash) instead. Any other exception from either execution mode fails the save with a generic message.

"content:beforeSave": async (event, ctx) => {
	const { content } = event;
	if (!content.title) {
		return {
			__emdashSandboxHookResult: true,
			version: 1,
			error: { code: "SAVE_REJECTED", reason: "Add a title before saving." },
		};
	}

	// Transform
	if (content.slug) {
		content.slug = content.slug.toLowerCase().replace(/\s+/g, "-");
	}

	return content;
}

Event: { content: Record<string, unknown>, collection: string, isNew: boolean, id?: string, actor?: { id: string, role: number } }. Authenticated REST, visual editing, and MCP saves include a read-only actor snapshot; internal writes may omit it. On updates, id identifies the existing item. The actor snapshot does not identify the request origin, so a hook cannot distinguish REST, visual editing, MCP, or another authenticated path from this field alone. Returns: Record<string, unknown> | SandboxHookErrorEnvelope | void

content:afterSave

Runs after successful save. Side effects only — logging, notifications, syncing.

"content:afterSave": async (event, ctx) => {
	const { content, collection, isNew } = event;
	ctx.log.info(`${isNew ? "Created" : "Updated"} ${collection}/${content.id}`);
}

Event: { content: Record<string, unknown>, collection: string, isNew: boolean, actor?: { id: string, role: number } }. Authenticated saves include a read-only actor snapshot; internal writes may omit it. Returns: void

content:beforeDelete

Runs before delete. Return false to cancel, true or void to allow.

"content:beforeDelete": async (event, ctx) => {
	if (event.collection === "pages" && event.id === "home") {
		ctx.log.warn("Cannot delete home page");
		return false;
	}
	return true;
}

Event: { id: string, collection: string } Returns: boolean | void

content:afterDelete

Runs after successful delete.

"content:afterDelete": async (event, ctx) => {
	ctx.log.info(`Deleted ${event.collection}/${event.id}`);
	await ctx.storage.cache!.delete(`${event.collection}:${event.id}`);
}

Event: { id: string, collection: string } Returns: void

Publication policy hooks

content:beforePublish, content:beforeSchedule, and content:beforeUnpublish require hooks.content-policy:register. This authority is independent of content:read, content:write, and publication actions.

Publish and schedule events expose the effective draft in content.data and the staged slug in content.slug. Unpublish events expose the currently live content that the action would remove.

Return void to allow the action or { cancel: true, reason } to reject it. The reason must contain 1–500 plain-text characters. Invalid decisions and unexpected abort-policy errors stop the action with a generic failure. Explicit cancellations return PUBLISH_REJECTED, SCHEDULE_REJECTED, or UNPUBLISH_REJECTED.

"content:beforePublish": async (event) => {
	const data = event.content.data;
	const approvalStatus =
		typeof data === "object" && data !== null && "approval_status" in data
			? data.approval_status
			: undefined;
	if (approvalStatus !== "approved") {
		return { cancel: true, reason: "Approve this entry before publishing." };
	}
},

Events contain { content, collection, origin, actor? }; content:beforeSchedule also contains scheduledAt. Human origins are api, mcp, or visual-editor and include the same value in actor.source. The visual-editor origin requires the signed short-lived token from an authenticated toolbar render. Other origins are plugin (with pluginId), scheduler, and system.

Scheduled content runs content:beforePublish again when it becomes due. An explicit scheduler rejection unschedules the entry and lists its reason on the dashboard until the entry is rescheduled, published, deleted, or the record is dismissed. There is no content:beforeUnschedule, so an administrator can always cancel a future publication.

content:afterPublish

Runs after content is published (promoted from draft to live). Side effects only.

"content:afterPublish": async (event, ctx) => {
	ctx.log.info(`Published ${event.collection}/${event.content.id}`);
}

Event: { content: Record<string, unknown>, collection: string } Returns: void

content:afterUnpublish

Runs after content is unpublished (reverted to draft). Side effects only.

"content:afterUnpublish": async (event, ctx) => {
	ctx.log.info(`Unpublished ${event.collection}/${event.content.id}`);
}

Event: { content: Record<string, unknown>, collection: string } Returns: void

content:afterRestore

Runs after trashed content is restored. Side effects only.

"content:afterRestore": async (event, ctx) => {
	ctx.log.info(`Restored ${event.collection}/${event.content.id}`);
}

Event: { content: Record<string, unknown>, collection: string } Returns: void

content:afterSchedule

Runs after content is scheduled for future publishing. Side effects only.

"content:afterSchedule": async (event, ctx) => {
	ctx.log.info(`Scheduled ${event.collection}/${event.content.id}`);
}

Event: { content: Record<string, unknown>, collection: string } Returns: void

content:afterUnschedule

Runs after scheduled content is unscheduled. Side effects only.

"content:afterUnschedule": async (event, ctx) => {
	ctx.log.info(`Unscheduled ${event.collection}/${event.content.id}`);
}

Event: { content: Record<string, unknown>, collection: string } Returns: void

Media Hooks

media:beforeUpload

Runs before upload. Return modified file info, void to keep, or throw to cancel.

"media:beforeUpload": async (event, ctx) => {
	const { file } = event;

	if (!file.type.startsWith("image/")) {
		throw new Error("Only images allowed");
	}

	if (file.size > 10 * 1024 * 1024) {
		throw new Error("Max 10MB");
	}

	return { ...file, name: `${Date.now()}-${file.name}` };
}

Event: { file: { name: string, type: string, size: number } } Returns: { name: string, type: string, size: number } | void

media:afterUpload

Runs after successful upload.

"media:afterUpload": async (event, ctx) => {
	ctx.log.info(`Uploaded ${event.media.filename}`, { id: event.media.id });
}

Event: { media: { id: string, filename: string, mimeType: string, size: number | null, url: string, createdAt: string } } Returns: void

Email Hooks

Email hooks require specific capabilities. Without the required capability, hooks are silently skipped.

email:beforeSend

Requires: hooks.email-events:register capability.

Runs before email delivery. Return modified message, or false to cancel delivery. Handlers are chained — each receives the output of the previous one.

// emdash-plugin.jsonc: "capabilities": ["hooks.email-events:register"]
"email:beforeSend": async (event) => {
	return { ...event.message, text: event.message.text + "\n\n-- Sent via EmDash" };
},

Event: { message: EmailMessage, source: string } Returns: EmailMessage | false

email:deliver

Requires: hooks.email-transport:register capability. Exclusive hook — exactly one provider is active.

Implements email transport (e.g. Resend, SMTP, SES). Selected by the admin in Settings > Email.

// emdash-plugin.jsonc declares hooks.email-transport:register and network:request,
// with api.resend.com in allowedHosts.
"email:deliver": {
	exclusive: true,
	handler: async ({ message }, ctx) => {
		const apiKey = await ctx.settings.get("apiKey");
		await ctx.http!.fetch("https://api.resend.com/emails", {
			method: "POST",
			headers: { Authorization: `Bearer ${apiKey}` },
			body: JSON.stringify({
				to: message.to,
				cc: message.cc,
				reply_to: message.replyTo,
				subject: message.subject,
				text: message.text,
			}),
		});
	},
},

Event: { message: EmailMessage, source: string } Returns: void

email:afterSend

Requires: hooks.email-events:register capability.

Runs after successful delivery. Fire-and-forget — errors are logged but don't propagate.

// emdash-plugin.jsonc: "capabilities": ["hooks.email-events:register"]
"email:afterSend": async (event, ctx) => {
	ctx.log.info(`Email sent to ${event.message.to}`, { source: event.source });
},

Event: { message: EmailMessage, source: string } Returns: void

Comment hooks

Comment hooks run for sandboxed and trusted plugins through the shared pipeline. All four require users:read. Their events include author email and request-derived information, so the pipeline skips the hook when the capability is absent.

comment:beforeCreate

Runs before storage. Return a modified event to enrich the comment or its moderator-only metadata, return false to reject the comment, or return nothing to keep it unchanged.

"comment:beforeCreate": async (event) => {
	if (event.comment.body.includes("blocked phrase")) return false;
	return {
		...event,
		metadata: { ...event.metadata, reviewedBy: "rules-v1" },
	};
},

Event:

{
	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>;
}

Returns: the event, false, or void.

comment:moderate

The exclusive moderation provider decides the initial status after the enrichment pipeline.

"comment:moderate": {
	exclusive: true,
	handler: async (event) => ({
		status: event.priorApprovedCount > 0 ? "approved" : "pending",
		reason: "First-time authors require review",
	}),
},

The event contains comment, metadata, collection comment settings, and priorApprovedCount. Return { status: "approved" | "pending" | "spam", reason?: string }.

comment:afterCreate

Runs after the comment is stored. The event contains the stored comment, moderation metadata, the target content summary, and the content author when available. Use it for notifications and other side effects. Returns void.

comment:afterModerate

Runs after an administrator or a plugin changes a comment's status. The event contains the stored comment, previousStatus, newStatus, the moderator's { id, name }, and origin. Administrator changes use { source: "admin", userId }; ctx.comments.setStatus() uses { source: "plugin", pluginId }. A transition runs the hook once. Returns void.

Cron Hook

cron

Runs on a schedule. Configure schedules via ctx.cron.schedule() in plugin:activate.

hooks: {
	"plugin:activate": async (_event, ctx) => {
		await ctx.cron!.schedule("daily-cleanup", { schedule: "0 2 * * *" });
	},
	cron: async (event, ctx) => {
		if (event.name === "daily-cleanup") {
			// ... cleanup logic
		}
	},
},

Event: { name: string, data?: Record<string, unknown> } Returns: void

Public Page Hooks

Public page hooks let plugins contribute to the rendered output of public site pages. Templates opt in to these contributions with <EmDashHead>, <EmDashBodyStart>, and <EmDashBodyEnd> components.

page:metadata

Contributes typed metadata to <head> — meta tags, OG properties, canonical/alternate links, and JSON-LD. Works in both trusted and sandboxed modes.

Returns structured contributions that core validates, dedupes (first-wins), and renders. Plugins never emit raw HTML through this hook.

"page:metadata": async (event, ctx) => {
	if (event.page.kind !== "content") return null;

	return [
		{ kind: "meta", name: "author", content: "My Blog" },
		{
			kind: "jsonld",
			id: `schema:${event.page.content?.collection}:${event.page.content?.id}`,
			graph: {
				"@context": "https://schema.org",
				"@type": "BlogPosting",
				headline: event.page.pageTitle ?? event.page.title,
				description: event.page.description,
			},
		},
	];
}

Event: { page: PublicPageContext } Returns: PageMetadataContribution | PageMetadataContribution[] | null

Contribution types:

  • { kind: "meta", name: string, content: string, key?: string } — <meta name="..." content="...">
  • { kind: "property", property: string, content: string, key?: string } — <meta property="..." content="..."> (OpenGraph)
  • { kind: "link", rel: "canonical" | "alternate", href: string, hreflang?: string, key?: string } — <link> tag (HTTP/HTTPS URLs only)
  • { kind: "jsonld", id?: string, graph: object | object[] } — <script type="application/ld+json">

Dedupe rules: first contribution wins per key. Canonical is singleton.

page:fragments (trusted only)

Contributes raw HTML, scripts, or markup to head, body:start, or body:end. The sandbox authoring type and manifest schema accept the declaration, and the plugin CLI warns that it is trusted-only. The sandbox host proxy excludes the hook, so a registry-installed plugin never contributes these fragments.

"page:fragments": async (event, ctx) => {
	return [
		{
			kind: "external-script",
			placement: "head",
			src: "https://www.googletagmanager.com/gtm.js?id=GTM-XXXXX",
			async: true,
		},
		{
			kind: "html",
			placement: "body:start",
			html: '<noscript><iframe src="https://www.googletagmanager.com/ns.html?id=GTM-XXXXX" height="0" width="0" style="display:none;visibility:hidden"></iframe></noscript>',
		},
	];
}

Event: { page: PublicPageContext } Returns: PageFragmentContribution | PageFragmentContribution[] | null

Contribution types:

  • { kind: "external-script", placement, src, async?, defer?, attributes?, key? }
  • { kind: "inline-script", placement, code, attributes?, key? }
  • { kind: "html", placement, html, key? }

Placements: "head", "body:start", "body:end"

Execution Order

  1. Lower priority values run first
  2. Equal priorities: plugin registration order
  3. dependencies forces a hook to wait for the named plugins regardless of priority

These ordering rules apply to sandboxed and trusted hooks in the shared host pipeline.

Error Handling

  • errorPolicy: "abort" (default) — pipeline stops, operation may fail
  • errorPolicy: "continue" — error logged, remaining hooks still run

These policies apply to sandboxed and trusted hooks in the shared host pipeline.

Quick Reference

Hook Trigger Capability Required Return
plugin:install First install — void
plugin:activate Plugin enabled — void
plugin:deactivate Plugin disabled — void
plugin:uninstall Plugin removed — void
content:beforeSave Before save content:write Modified content, SandboxHookErrorEnvelope, or void
content:afterSave After save content:read void
content:beforeDelete Before delete content:read false to cancel
content:afterDelete After delete content:read void
content:beforePublish Before publish hooks.content-policy:register void or cancellation
content:beforeSchedule Before schedule hooks.content-policy:register void or cancellation
content:beforeUnpublish Before unpublish hooks.content-policy:register void or cancellation
content:afterPublish After publish content:read void
content:afterUnpublish After unpublish content:read void
content:afterRestore After restore content:read void
content:afterSchedule After schedule content:read void
content:afterUnschedule After unschedule content:read void
media:beforeUpload Before upload media:write Modified file info or void
media:afterUpload After upload media:read void
email:beforeSend Before email send hooks.email-events:register Modified message or false
email:deliver Email delivery hooks.email-transport:register void (exclusive)
email:afterSend After email send hooks.email-events:register void
comment:beforeCreate Before comment save users:read Modified event, false, or void
comment:moderate Initial moderation users:read Moderation decision (exclusive)
comment:afterCreate After comment save users:read void
comment:afterModerate After status change users:read void
cron Scheduled task fires — void
page:metadata Page render — Metadata contributions
page:fragments Page render hooks.page-fragments:register (native only) Fragment contributions