Cloudflare’s EmDash CMS launched with a promise: plugins that can’t touch your database unless you explicitly allow them. The mechanism that makes this possible is the hook system, powered by dynamic workers running in V8 isolates. This isn’t theoretical — every plugin you write for EmDash declares capabilities, registers hooks, and executes in a sandbox that spins up in under 5 milliseconds.
Here’s exactly how it works, with concrete examples you can copy.
The plugin never sees your database or other plugins’ data.
What a Hook Actually Does in EmDash
In WordPress, a hook is a PHP function call that lets plugins intercept actions like save_postcodecodecodecode or wp_logincodecodecodecode. The plugin code runs in the same process as WordPress core, with full access to $wpdbcodecodecodecode and the file system. EmDash replaces that architecture entirely.
An EmDash hook is a TypeScript function that triggers when a specific event occurs — a post being published, a user logging in, a comment being approved. The plugin registers a handler using the definePlugincodecodecodecode function from the @emdash-cms/plugincodecodecodecode package. When the event fires, EmDash spins up a dynamic worker, passes the event context, executes the handler, and tears down the worker. The plugin never sees your database or other plugins’ data.
The critical difference: capabilities are declared upfront in a manifest. A plugin that only declares read:contentcodecodecodecode and email:sendcodecodecodecode can literally do nothing else — no file reads, no network calls, no database writes. The runtime enforces this at the V8 isolate level using Linux namespaces and seccomp filters.
Registering Your First Hook: A Concrete Example
Let’s build a plugin that sends a welcome email when a new user registers. In WordPress, you’d write something like add_action('user_register', 'send_welcome_email')codecodecodecode and the plugin would have unfettered access to your users table. In EmDash, you write:
“`typescript
import { definePlugin } from ‘@emdash-cms/plugin’;
export default definePlugin({
id: ‘welcome-email’,
version: ‘1.0.0’,
capabilities: [‘read:content’, ’email:send’],
hooks: {
‘user:created’: async (context) => {
const { user, email } = context;
await email.send({
to: user.email,
subject: ‘Welcome to our site!’,
template: ‘welcome’,
});
},
},
});
“`
Three things happen here. First, the plugin declares capabilitiescodecodecodecode — it can read content and send emails, nothing else. Second, it registers a handler for the user:createdcodecodecodecode hook. Third, the handler receives a typed context object containing only the data it needs — the new user’s email address, not the password hash or session tokens.
If a compromised version of this plugin tried to read context.user.password_hashcodecodecodecode, the TypeScript compiler would catch it at build time. If it tried to make an outbound HTTP request, the dynamic worker’s seccomp filter would block it at runtime. The plugin physically cannot escalate.
Available Hooks and Their Payloads
EmDash ships with a growing set of hooks. As of version 0.1.0, these include:
content:publishedcodecodecodecode – fires when any content type is published. Payload includes the content item’s ID, slug, and the user ID of the publisher.content:updatedcodecodecodecode – fires on save, even for drafts. Includes previous and current revision IDs.user:createdcodecodecodecode – fires after a new user is created. Payload includes email, role, and a boolean for whether they were invited.comment:submittedcodecodecodecode – fires when a comment is submitted, before moderation. Payload includes comment text, author IP (hashed), and the parent post ID.media:uploadedcodecodecodecode – fires after a file is uploaded to R2 or local storage. Payload includes the file URL, MIME type, and size in bytes.
Each hook’s payload is strictly typed. You can inspect the @emdash-cms/typescodecodecodecode package to see the exact interfaces. For example, MediaUploadedEventcodecodecodecode includes { url: string, mimeType: string, size: number, uploadedBy: string }codecodecodecode — nothing more.
The Security Model in Practice
To understand why this matters, consider a real WordPress vulnerability from 2025. The “WP Scheduled Posts” plugin (active on 70,000+ sites) had a SQL injection in its save_postcodecodecodecode hook handler. An attacker could send a crafted HTTP request that triggered the hook, and the plugin’s PHP code would execute $wpdb->query("SELECT * FROM wp_users WHERE ...")codecodecodecode with unsanitized input. The plugin had full database access because WordPress architecture grants it.
Now simulate the same scenario in EmDash. A plugin registering a content:publishedcodecodecodecode hook with capability read:contentcodecodecodecode can only receive content data — it cannot issue SQL queries. The database connection is never exposed to the plugin. The only way to read or write data is through EmDash’s built-in API, which the plugin accesses via the context object. Even if the plugin’s handler is compromised, the attacker cannot enumerate tables, read password hashes, or delete posts.
This is not a policy — it’s enforced at the hardware level. Each dynamic worker runs in a separate V8 isolate with its own memory heap. The isolate communicates with the EmDash core through a structured JSON bridge. No shared memory, no file descriptors, no raw socket access.
Building a Plugin That Responds to Content Changes
Let’s build something more practical: a plugin that automatically generates an Open Graph image when a blog post is published. You’d register a content:publishedcodecodecodecode hook, check if the content type is postcodecodecodecode, and then call an image generation API.
“`typescript
import { definePlugin } from ‘@emdash-cms/plugin’;
export default definePlugin({
id: ‘og-image-generator’,
version: ‘0.1.0’,
capabilities: [‘read:content’, ‘media:upload’, ‘network:fetch’],
hooks: {
‘content:published’: async (context) => {
const { content } = context;
if (content.type !== ‘post’) return;
const imageUrl = https://og.example.com/generate?title=${encodeURIComponent(content.title)}&theme=darkcodecodecodecode;
const response = await fetch(imageUrl);
const blob = await response.blob();
await context.media.upload({
file: blob,
filename: og-${content.slug}.pngcodecodecodecode,
type: ‘image/png’,
});
},
},
});
“`
Notice the capabilitiescodecodecodecode array now includes network:fetchcodecodecodecode. Without that declaration, the fetchcodecodecodecode call would fail at runtime with a permission denied error. The plugin explicitly asks for external network access — and only for the purpose of fetching the image. It still cannot write to the database or read other content types.
The dynamic worker for this plugin would spin up, execute the handler (which takes roughly 200–400 ms depending on the image API), upload the result, and then shut down. During execution, the worker uses about 10 MB of memory — far less than a Docker container’s baseline of 50–100 MB.
Why This Matters for AI-Generated Plugins
EmDash’s hook system is designed for a world where AI agents write plugins on the fly. The capability manifest gives you a security boundary: an AI agent can generate code, but it cannot exceed the declared permissions. This is the same pattern Cloudflare uses for its dynamic workers in the ai-gatewaycodecodecodecode product — you can let an LLM generate a report from your data without ever exposing the raw data to the model.
In practice, this means a non-technical site owner could prompt an AI agent: “Create a plugin that emails me whenever a new comment is posted.” The agent would generate the handler, declare the email:sendcodecodecodecode capability, and the plugin would run safely. The owner doesn’t need to audit every line of generated code because the runtime enforces the boundaries.
The One Thing Most Tutorials Miss
Every EmDash plugin must be registered in the emdash.config.tscodecodecodecode file of your project. The standard starter templates include a pluginscodecodecodecode array where you add your plugin’s path. If you forget this step, the hooks never fire. The error message is not obvious — the site works, but events silently pass through without triggering your code.
To debug, check the EmDash admin panel under “Plugins” → “Hooks Log” (available in the paid tier). It shows the last 100 hook executions, including start time, duration, and any errors. Without this log, you can waste hours wondering why your content:publishedcodecodecodecode handler isn’t running.
The Practical Limit of This Architecture
The hook system works brilliantly for side-effect operations — sending emails, generating images, logging events. It is not designed for real-time content transformations. If you need to modify the content before it’s displayed (e.g., automatically add affiliate links to every post), you need a different approach: a theme component or a middleware. Hooks fire after the event is committed, not before. You cannot use a content:publishedcodecodecodecode hook to alter the content before it hits the database.
This is a deliberate trade-off. By enforcing post-event execution, EmDash ensures that plugin code never interferes with the core write path. Database writes remain atomic, consistent, and isolated from plugin failures. If a plugin throws an error, the content is already saved — the error only affects the side effect.
- What is an EmDash hook?An EmDash hook is a TypeScript function that triggers when a specific event occurs, such as a post being published or a user logging in.
- How does EmDash enforce plugin security?EmDash enforces security by requiring plugins to declare capabilities upfront in a manifest, then using V8 isolates and seccomp filters to block unauthorized actions at runtime.
- Can EmDash hooks modify content before it is saved?No, EmDash hooks fire after the event is committed, so they cannot alter content before it reaches the database.