{"id":98026,"date":"2026-10-06T15:02:00","date_gmt":"2026-10-06T19:02:00","guid":{"rendered":"https:\/\/overcentral.com\/en\/?p=98026"},"modified":"2026-09-29T07:55:47","modified_gmt":"2026-09-29T11:55:47","slug":"emdash-hook-system-event-driven-plugins-98026","status":"publish","type":"post","link":"https:\/\/overcentral.com\/en\/emdash-hook-system-event-driven-plugins-98026\/","title":{"rendered":"EmDash Hook System: Build Event-Driven Plugins Without Database Access"},"content":{"rendered":"<p>Cloudflare\u2019s <a href=\"https:\/\/emdashcms.com\" target=\"_blank\" rel=\"noopener noreferrer\" data-iacss-external=\"1\">EmDash CMS<\/a> launched with a promise: plugins that can\u2019t 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\u2019t theoretical \u2014 every plugin you write for EmDash declares capabilities, registers hooks, and executes in a sandbox that spins up in under 5 milliseconds.<\/p>\n<p>Here\u2019s exactly how it works, with concrete examples you can copy.<\/p>\n<h2>What a Hook Actually Does in EmDash<\/h2>\n<p>In WordPress, a hook is a PHP function call that lets plugins intercept actions like <code>save_post<\/code>codecodecodecode or <code>wp_login<\/code>codecodecodecode. The plugin code runs in the same process as WordPress core, with full access to <code>$wpdb<\/code>codecodecodecode and the file system. EmDash replaces that architecture entirely.<\/p>\n<p>An EmDash hook is a TypeScript function that triggers when a specific event occurs \u2014 a post being published, a user logging in, a comment being approved. The plugin registers a handler using the <code>definePlugin<\/code>codecodecodecode function from the <code>@emdash-cms\/plugin<\/code>codecodecodecode 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\u2019 data.<\/p>\n<p>The critical difference: capabilities are declared upfront in a manifest. A plugin that only declares <code>read:content<\/code>codecodecodecode and <code>email:send<\/code>codecodecodecode can literally do nothing else \u2014 no file reads, no network calls, no database writes. The runtime enforces this at the V8 isolate level using Linux namespaces and seccomp filters.<\/p>\n<h2>Registering Your First Hook: A Concrete Example<\/h2>\n<p>Let\u2019s build a plugin that sends a welcome email when a new user registers. In WordPress, you\u2019d write something like <code>add_action('user_register', 'send_welcome_email')<\/code>codecodecodecode and the plugin would have unfettered access to your users table. In EmDash, you write:<\/p>\n<p>&#8220;`typescript<\/p>\n<p>import { definePlugin } from &#8216;@emdash-cms\/plugin&#8217;;<\/p>\n<p>export default definePlugin({<\/p>\n<p>  id: &#8216;welcome-email&#8217;,<\/p>\n<p>  version: &#8216;1.0.0&#8217;,<\/p>\n<p>  capabilities: [&#8216;read:content&#8217;, &#8217;email:send&#8217;],<\/p>\n<p>  hooks: {<\/p>\n<p>    &#8216;user:created&#8217;: async (context) =&gt; {<\/p>\n<p>      const { user, email } = context;<\/p>\n<p>      await email.send({<\/p>\n<p>        to: user.email,<\/p>\n<p>        subject: &#8216;Welcome to our site!&#8217;,<\/p>\n<p>        template: &#8216;welcome&#8217;,<\/p>\n<p>      });<\/p>\n<p>    },<\/p>\n<p>  },<\/p>\n<p>});<\/p>\n<p>&#8220;`<\/p>\n<p>Three things happen here. First, the plugin declares <code>capabilities<\/code>codecodecodecode \u2014 it can read content and send emails, nothing else. Second, it registers a handler for the <code>user:created<\/code>codecodecodecode hook. Third, the handler receives a typed context object containing only the data it needs \u2014 the new user\u2019s email address, not the password hash or session tokens.<\/p>\n<p>If a compromised version of this plugin tried to read <code>context.user.password_hash<\/code>codecodecodecode, the TypeScript compiler would catch it at build time. If it tried to make an outbound HTTP request, the dynamic worker\u2019s seccomp filter would block it at runtime. The plugin physically cannot escalate.<\/p>\n<h2>Available Hooks and Their Payloads<\/h2>\n<p>EmDash ships with a growing set of hooks. As of version 0.1.0, these include:<\/p>\n<ul>\n<li><code>content:published<\/code>codecodecodecode \u2013 fires when any content type is published. Payload includes the content item\u2019s ID, slug, and the user ID of the publisher.<\/li>\n<li><code>content:updated<\/code>codecodecodecode \u2013 fires on save, even for drafts. Includes previous and current revision IDs.<\/li>\n<li><code>user:created<\/code>codecodecodecode \u2013 fires after a new user is created. Payload includes email, role, and a boolean for whether they were invited.<\/li>\n<li><code>comment:submitted<\/code>codecodecodecode \u2013 fires when a comment is submitted, before moderation. Payload includes comment text, author IP (hashed), and the parent post ID.<\/li>\n<li><code>media:uploaded<\/code>codecodecodecode \u2013 fires after a file is uploaded to R2 or local storage. Payload includes the file URL, MIME type, and size in bytes.<\/li>\n<\/ul>\n<p>Each hook\u2019s payload is strictly typed. You can inspect the <code>@emdash-cms\/types<\/code>codecodecodecode package to see the exact interfaces. For example, <code>MediaUploadedEvent<\/code>codecodecodecode includes <code>{ url: string, mimeType: string, size: number, uploadedBy: string }<\/code>codecodecodecode \u2014 nothing more.<\/p>\n<h2>The Security Model in Practice<\/h2>\n<p>To understand why this matters, consider a real WordPress vulnerability from 2025. The \u201cWP Scheduled Posts\u201d plugin (active on 70,000+ sites) had a SQL injection in its <code>save_post<\/code>codecodecodecode hook handler. An attacker could send a crafted HTTP request that triggered the hook, and the plugin\u2019s PHP code would execute <code>$wpdb-&gt;query(\"SELECT * FROM wp_users WHERE ...\")<\/code>codecodecodecode with unsanitized input. The plugin had full database access because WordPress architecture grants it.<\/p>\n<p>Now simulate the same scenario in EmDash. A plugin registering a <code>content:published<\/code>codecodecodecode hook with capability <code>read:content<\/code>codecodecodecode can only receive content data \u2014 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\u2019s built-in API, which the plugin accesses via the context object. Even if the plugin\u2019s handler is compromised, the attacker cannot enumerate tables, read password hashes, or delete posts.<\/p>\n<p>This is not a policy \u2014 it\u2019s 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.<\/p>\n<h2>Building a Plugin That Responds to Content Changes<\/h2>\n<p>Let\u2019s build something more practical: a plugin that automatically generates an Open Graph image when a blog post is published. You\u2019d register a <code>content:published<\/code>codecodecodecode hook, check if the content type is <code>post<\/code>codecodecodecode, and then call an image generation API.<\/p>\n<p>&#8220;`typescript<\/p>\n<p>import { definePlugin } from &#8216;@emdash-cms\/plugin&#8217;;<\/p>\n<p>export default definePlugin({<\/p>\n<p>  id: &#8216;og-image-generator&#8217;,<\/p>\n<p>  version: &#8216;0.1.0&#8217;,<\/p>\n<p>  capabilities: [&#8216;read:content&#8217;, &#8216;media:upload&#8217;, &#8216;network:fetch&#8217;],<\/p>\n<p>  hooks: {<\/p>\n<p>    &#8216;content:published&#8217;: async (context) =&gt; {<\/p>\n<p>      const { content } = context;<\/p>\n<p>      if (content.type !== &#8216;post&#8217;) return;<\/p>\n<p>      const imageUrl = <code>https:\/\/og.example.com\/generate?title=${encodeURIComponent(content.title)}&amp;theme=dark<\/code>codecodecodecode;<\/p>\n<p>      const response = await fetch(imageUrl);<\/p>\n<p>      const blob = await response.blob();<\/p>\n<p>      await context.media.upload({<\/p>\n<p>        file: blob,<\/p>\n<p>        filename: <code>og-${content.slug}.png<\/code>codecodecodecode,<\/p>\n<p>        type: &#8216;image\/png&#8217;,<\/p>\n<p>      });<\/p>\n<p>    },<\/p>\n<p>  },<\/p>\n<p>});<\/p>\n<p>&#8220;`<\/p>\n<p>Notice the <code>capabilities<\/code>codecodecodecode array now includes <code>network:fetch<\/code>codecodecodecode. Without that declaration, the <code>fetch<\/code>codecodecodecode call would fail at runtime with a permission denied error. The plugin explicitly asks for external network access \u2014 and only for the purpose of fetching the image. It still cannot write to the database or read other content types.<\/p>\n<p>The dynamic worker for this plugin would spin up, execute the handler (which takes roughly 200\u2013400 ms depending on the image API), upload the result, and then shut down. During execution, the worker uses about 10 MB of memory \u2014 far less than a Docker container\u2019s baseline of 50\u2013100 MB.<\/p>\n<h2>Why This Matters for AI-Generated Plugins<\/h2>\n<p>EmDash\u2019s hook system is designed for a world where <a href=\"https:\/\/overcentral.com\/en\/rogue-ai-agents-liability-vacuum-97898\/\" title=\"Rogue AI agents expose liability vacuum as OpenAI faces claims\" data-iacss-internal=\"1\">AI agents<\/a> write plugins on the fly. The capability manifest gives you a security boundary: <a href=\"https:\/\/overcentral.com\/en\/ai-avatar-empire-96590\/\" title=\"Build an AI Avatar Empire Without Showing Your Face\" data-iacss-internal=\"1\">an AI<\/a> agent can generate code, but it cannot exceed the declared permissions. This is the same pattern Cloudflare uses for its dynamic workers in the <code>ai-gateway<\/code>codecodecodecode product \u2014 you can let an LLM generate a report from your data without ever exposing the raw data to the model.<\/p>\n<p>In practice, this means a non-technical site owner could prompt an <a href=\"https:\/\/overcentral.com\/en\/meta-muse-ai-agent-80441\/\" title=\"Meta Launches Muse AI Agent, Needs User Trust\" data-iacss-internal=\"1\">AI agent<\/a>: \u201cCreate a plugin that emails me whenever a new comment is posted.\u201d The agent would generate the handler, declare the <code>email:send<\/code>codecodecodecode capability, and the plugin would run safely. The owner doesn\u2019t need to audit every line of generated code because the runtime enforces the boundaries.<\/p>\n<h2>The One Thing Most Tutorials Miss<\/h2>\n<p>Every EmDash plugin must be registered in the <code>emdash.config.ts<\/code>codecodecodecode file of your project. The standard starter templates include a <code>plugins<\/code>codecodecodecode array where you add your plugin\u2019s path. If you forget this step, the hooks never fire. The error message is not obvious \u2014 the site works, but events silently pass through without triggering your code.<\/p>\n<p>To debug, check the EmDash admin panel under \u201cPlugins\u201d \u2192 \u201cHooks Log\u201d (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 <code>content:published<\/code>codecodecodecode handler isn\u2019t running.<\/p>\n<h2>The Practical Limit of This Architecture<\/h2>\n<p>The hook system works brilliantly for side-effect operations \u2014 sending emails, generating images, logging events. It is not designed for real-time content transformations. If you need to modify the content before it\u2019s 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 <code>content:published<\/code>codecodecodecode hook to alter the content before it hits the database.<\/p>\n<p>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 \u2014 the error only affects the side effect.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>Cloudflare\u2019s EmDash CMS launched with a promise: plugins that can\u2019t 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\u2019t theoretical \u2014 every plugin you write for EmDash declares capabilities, registers hooks, and executes in a sandbox [&hellip;]<\/p>\n","protected":false},"author":7,"featured_media":99311,"comment_status":"closed","ping_status":"","sticky":false,"template":"","format":"standard","meta":{"fifu_image_url":"https:\/\/cards.overcentral.com\/cards\/en\/98026.png","fifu_image_alt":"EmDash Hook System: Build Event-Driven Plugins Without Database Access","footnotes":""},"categories":[31],"tags":[],"class_list":["post-98026","post","type-post","status-publish","format-standard","has-post-thumbnail","category-technology"],"fifu_image_url":"https:\/\/cards.overcentral.com\/cards\/en\/98026.png","fifu_image_alt":"EmDash Hook System: Build Event-Driven Plugins Without Database Access","_links":{"self":[{"href":"https:\/\/overcentral.com\/en\/wp-json\/wp\/v2\/posts\/98026","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/overcentral.com\/en\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/overcentral.com\/en\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/overcentral.com\/en\/wp-json\/wp\/v2\/users\/7"}],"replies":[{"embeddable":true,"href":"https:\/\/overcentral.com\/en\/wp-json\/wp\/v2\/comments?post=98026"}],"version-history":[{"count":1,"href":"https:\/\/overcentral.com\/en\/wp-json\/wp\/v2\/posts\/98026\/revisions"}],"predecessor-version":[{"id":99312,"href":"https:\/\/overcentral.com\/en\/wp-json\/wp\/v2\/posts\/98026\/revisions\/99312"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/overcentral.com\/en\/wp-json\/wp\/v2\/media\/99311"}],"wp:attachment":[{"href":"https:\/\/overcentral.com\/en\/wp-json\/wp\/v2\/media?parent=98026"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/overcentral.com\/en\/wp-json\/wp\/v2\/categories?post=98026"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/overcentral.com\/en\/wp-json\/wp\/v2\/tags?post=98026"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}