{"id":98082,"date":"2026-10-12T03:02:00","date_gmt":"2026-10-12T07:02:00","guid":{"rendered":"https:\/\/overcentral.com\/en\/?p=98082"},"modified":"2026-09-29T08:13:11","modified_gmt":"2026-09-29T12:13:11","slug":"write-emdash-plugin-tutorial-98082","status":"publish","type":"post","link":"https:\/\/overcentral.com\/en\/write-emdash-plugin-tutorial-98082\/","title":{"rendered":"How to Write an EmDash Plugin: A Step-by-Step Tutorial"},"content":{"rendered":"<p>Cloudflare&#8217;s <a href=\"https:\/\/emdash.dev\" target=\"_blank\" rel=\"noopener noreferrer\" data-iacss-external=\"1\">EmDash CMS<\/a> launched on April 1, 2026, and the first question from every WordPress developer was the same: <em>How do I write a plugin for it?<\/em> The answer is radically different from what you know. WordPress plugins get full access to your database, file system, and user sessions\u2014one compromised plugin and your entire site is exposed. In 2025 alone, security researchers disclosed 11,334 new WordPress vulnerabilities, and 96% of all WordPress security issues trace back to plugins. EmDash fixes this architecturally: every plugin runs in its own V8 isolate via Cloudflare&#8217;s dynamic workers, with permissions declared upfront in a capability manifest. A plugin that declares <code>read:content<\/code>codecodecode and <code>email:send<\/code>codecodecode can literally do nothing else\u2014no database queries, no file system access, no unrestricted network calls.<\/p>\n<p>This tutorial walks you through writing your first EmDash plugin from scratch. You&#8217;ll build a &#8220;Notify Admin on New Post&#8221; plugin that sends an email when a post is published. By the end, you&#8217;ll understand the security model, the hook system, and how to deploy your plugin to Cloudflare&#8217;s runtime.<\/p>\n<h2>What Makes EmDash Plugins Different?<\/h2>\n<p>The fundamental difference is isolation. In WordPress, a plugin is a PHP script that sits alongside the core code. It calls <code>global wpdb<\/code>codecodecode and has unrestricted access to every table. In EmDash, plugins run in their own dynamic worker\u2014a lightweight, hardware-isolated V8 isolate that spins up in milliseconds and disappears when done. The plugin cannot touch your database, your theme, or other plugins unless the capability manifest explicitly grants it.<\/p>\n<p>Here&#8217;s what that looks like in practice. A typical WordPress contact form plugin can read, write, and delete anything. An EmDash plugin with the same feature declares exactly two capabilities: <code>read:content<\/code>codecodecode (to read existing pages) and <code>email:send<\/code>codecodecode (to send notifications). The runtime enforces these boundaries. If a compromised update tries to read password hashes or phone home to an external server, the isolate physically blocks it. This is not policy\u2014it&#8217;s architectural enforcement. V8 isolates, Linux namespaces, seccomp filters, and hardware memory protection keys all work together.<\/p>\n<p>The downside? Full sandboxing requires Cloudflare&#8217;s dynamic workers, which are only available on the Workers Paid plan ($5\/month). On the free tier or when self-hosting on a <a href=\"https:\/\/nodejs.org\" target=\"_blank\" rel=\"noopener noreferrer\" data-iacss-external=\"1\">Node.js<\/a> server, plugins run in-process without isolation. EmDash is open source under MIT, but the headline security feature depends on Cloudflare&#8217;s runtime.<\/p>\n<h2>Prerequisites<\/h2>\n<p>Before you start, you need:<\/p>\n<ul>\n<li><strong>Node.js 22.12 or later<\/strong> (EmDash uses Astro 6, which requires this minimum)<\/li>\n<li><strong>An EmDash instance running locally<\/strong> (run <code>npm create md-latest<\/code>codecodecode and follow the setup wizard)<\/li>\n<li><strong>A Cloudflare account<\/strong> (for deploying with sandboxed plugin support; the free tier works for basic testing)<\/li>\n<li><strong>An email service<\/strong> (for the plugin example, we&#8217;ll use a simple SMTP configuration)<\/li>\n<\/ul>\n<p>If you haven&#8217;t installed EmDash yet, spin up a playground at <code>https:\/\/emdashcms.com\/playground<\/code>codecodecode\u2014it generates a temporary working site that lasts an hour.<\/p>\n<h2>Step 1: Scaffold a Plugin Project<\/h2>\n<p>EmDash plugins are JavaScript modules that export a configuration object. There&#8217;s no CLI generator yet (this is v0.1.0), so we&#8217;ll create the files manually.<\/p>\n<p>Create a new directory for your plugin:<\/p>\n<p>&#8220;`bash<\/p>\n<p>mkdir notify-admin-plugin<\/p>\n<p>cd notify-admin-plugin<\/p>\n<p>&#8220;`<\/p>\n<p>Inside, create two files: <code>index.js<\/code>codecodecode (the plugin logic) and <code>manifest.json<\/code>codecodecode (the capability declaration). EmDash uses a <code>definePlugin<\/code>codecodecode function from the <code>@emdash-cms\/plugin-sdk<\/code>codecodecode package. Install it locally:<\/p>\n<p>&#8220;`bash<\/p>\n<p>npm init -y<\/p>\n<p>npm install @emdash-cms\/plugin-sdk<\/p>\n<p>&#8220;`<\/p>\n<h2>Step 2: Define the Plugin Manifest and Capabilities<\/h2>\n<p>The manifest is the core of EmDash&#8217;s security model. It declares exactly what your plugin can access. Open <code>manifest.json<\/code>codecodecode:<\/p>\n<p>&#8220;`json<\/p>\n<p>{<\/p>\n<p>  &#8220;id&#8221;: &#8220;notify-admin&#8221;,<\/p>\n<p>  &#8220;version&#8221;: &#8220;0.1.0&#8221;,<\/p>\n<p>  &#8220;capabilities&#8221;: [<\/p>\n<p>    &#8220;read:content&#8221;,<\/p>\n<p>    &#8220;email:send&#8221;<\/p>\n<p>  ]<\/p>\n<p>}<\/p>\n<p>&#8220;`<\/p>\n<p>Each capability maps to a specific permission. <code>read:content<\/code>codecodecode allows reading posts and pages (but not writing or deleting). <code>email:send<\/code>codecodecode allows sending emails through the configured SMTP provider. The plugin cannot access the database, the file system, or any external network unless you add more capabilities. A plugin that only needs to read content and send email\u2014like this one\u2014declares exactly those two things. No skeleton key.<\/p>\n<h2>Step 3: Write the Hook Logic<\/h2>\n<p>EmDash provides a hook system similar to WordPress actions. The most relevant hook for our plugin is <code>onPublish<\/code>codecodecode, which fires when a post transitions from draft to published. Our plugin will listen for that hook, read the published post&#8217;s title and author, and send an email to the site admin.<\/p>\n<p>Open <code>index.js<\/code>codecodecode:<\/p>\n<p>&#8220;`javascript<\/p>\n<p>import { definePlugin } from &#8216;@emdash-cms\/plugin-sdk&#8217;;<\/p>\n<p>export default definePlugin({<\/p>\n<p>  id: &#8216;notify-admin&#8217;,<\/p>\n<p>  version: &#8216;0.1.0&#8217;,<\/p>\n<p>  capabilities: [&#8216;read:content&#8217;, &#8217;email:send&#8217;],<\/p>\n<p>  hooks: {<\/p>\n<p>    async onPublish({ post, context }) {<\/p>\n<p>      \/\/ Only proceed if the post is newly published<\/p>\n<p>      if (post.status !== &#8216;published&#8217;) return;<\/p>\n<p>      \/\/ Read the post content (we only have read:content capability)<\/p>\n<p>      const title = post.title;<\/p>\n<p>      const author = post.author?.displayName || &#8216;Unknown&#8217;;<\/p>\n<p>      \/\/ Use the email capability from the context<\/p>\n<p>      const email = context.email;<\/p>\n<p>      \/\/ Send notification to the admin<\/p>\n<p>      await email.send({<\/p>\n<p>        to: &#8216;admin@example.com&#8217;,<\/p>\n<p>        subject: <code>New post published: ${title}<\/code>codecodecode,<\/p>\n<p>        body: <code>A new post titled \"${title}\" was published by ${author}.<\/code>codecodecode,<\/p>\n<p>      });<\/p>\n<p>    },<\/p>\n<p>  },<\/p>\n<p>});<\/p>\n<p>&#8220;`<\/p>\n<p>Key details about this code:<\/p>\n<ul>\n<li>The <code>definePlugin<\/code>codecodecode function validates that the declared capabilities match the actual capabilities used in the hooks. If our code tried to call <code>context.db.query()<\/code>codecodecode, the runtime would throw an error because <code>db<\/code>codecodecode is not in the manifest.<\/li>\n<li>The <code>context<\/code>codecodecode object provides scoped access to the capabilities declared. For <code>email:send<\/code>codecodecode, you get <code>context.email.send()<\/code>codecodecode. For <code>read:content<\/code>codecodecode, you get <code>context.content.read()<\/code>codecodecode. You never get raw database or network access.<\/li>\n<li>Hooks run asynchronously. The <code>onPublish<\/code>codecodecode hook receives a <code>post<\/code>codecodecode object with the published content. The dynamic worker spins up only when this hook fires, executes the function, and terminates. No persistent processes, no memory leaks.<\/li>\n<\/ul>\n<h2>Step 4: Test Locally<\/h2>\n<p>With your EmDash instance running locally (on <code>localhost:4321<\/code>codecodecode), copy your plugin directory into the <code>plugins\/<\/code>codecodecode folder of your EmDash project. The exact path depends on your project structure\u2014check the EmDash documentation for the current convention.<\/p>\n<p>Then restart the development server. EmDash will detect the plugin and register it. Publish a test post and check the server logs. If the email send fails, you&#8217;ll see an error in the console. Note that local testing runs plugins <em>in-process<\/em>\u2014the sandbox isolation only activates when deployed to Cloudflare&#8217;s runtime with dynamic workers.<\/p>\n<p>To verify the plugin works end-to-end without actual email, you can add a console log:<\/p>\n<p>&#8220;`javascript<\/p>\n<p>await email.send({ &#8230; });<\/p>\n<p>console.log(&#8216;Email sent successfully&#8217;);<\/p>\n<p>&#8220;`<\/p>\n<p>If you see the log, the hook fired correctly.<\/p>\n<h2>Step 5: Deploy to Cloudflare with Sandboxed Plugins<\/h2>\n<p>To get the full sandbox protection, you need to deploy your EmDash site to Cloudflare using the Workers Paid plan ($5\/month). The deployment process is:<\/p>\n<ol>\n<li>Push your EmDash project (including the plugin) to a GitHub repository.<\/li>\n<li>Go to the Cloudflare Dashboard, click <strong>Workers &amp; Pages<\/strong>, then <strong>Create application<\/strong>.<\/li>\n<li>Choose <strong>EmDash<\/strong> from the templates (or connect your GitHub repo).<\/li>\n<li>Select the <strong>Paid<\/strong> plan tier when prompted\u2014this enables dynamic workers.<\/li>\n<li>Click <strong>Create and deploy<\/strong>.<\/li>\n<\/ol>\n<p>Cloudflare will provision the infrastructure: Workers for compute, D1 for the database, R2 for media storage, and KV for plugin state. Your plugin now runs inside a V8 isolate with full sandboxing. The capability manifest is enforced at the runtime level. If a future update to your plugin tries to access <code>context.db<\/code>codecodecode, the isolate will refuse\u2014even if the code itself is buggy or malicious.<\/p>\n<h2>Best Practices for Plugin Security<\/h2>\n<p>The capability manifest is powerful, but it&#8217;s not a magic shield. Follow these rules when building EmDash plugins:<\/p>\n<ul>\n<li><strong>Declare the minimum capabilities.<\/strong> If your plugin only needs to read content, don&#8217;t add <code>write:content<\/code>codecodecode. If it doesn&#8217;t need external network, leave out <code>network:fetch<\/code>codecodecode.<\/li>\n<li><strong>Never request <code>admin:user<\/code>codecodecode or <code>db:*<\/code>codecodecode unless absolutely required.<\/strong> These capabilities grant broad access. Most plugins don&#8217;t need them.<\/li>\n<li><strong>Pin your plugin&#8217;s version.<\/strong> Users should see exactly which version they&#8217;re installing. Use semantic versioning.<\/li>\n<li><strong>Test with a malicious scenario.<\/strong> Before publishing, try to make your plugin do something outside its declared capabilities. The runtime should block it. If it doesn&#8217;t, file a bug with the EmDash team.<\/li>\n<\/ul>\n<h2>The Plugin Ecosystem Today<\/h2>\n<p>EmDash launched with zero third-party plugins. As of this writing, the marketplace is empty. But the community is already active: a Pantheon product manager submitted a PR to port the Gutenberg editor, and Yoast&#8217;s founder Joost de Valk has praised the architecture. Cloudflare&#8217;s strategy is to let <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> generate plugins using the built-in MCP server and agent skills files. The MIT license removes the GPL friction that kept commercial developers away from WordPress.<\/p>\n<p>Still, this is v0.1.0. Expect breaking changes. The hook API may evolve, the manifest format may gain new fields, and the dynamic worker pricing could shift. If you&#8217;re building plugins for production clients today, budget for maintenance.<\/p>\n<h2>Where This Falls Short<\/h2>\n<p>The sandbox model only works on Cloudflare&#8217;s runtime. Self-host EmDash on a regular Node.js server and plugins run in-process with no isolation. The feature that justifies EmDash&#8217;s existence requires a paid Cloudflare subscription. That&#8217;s a dependency on a single vendor\u2014something WordPress avoids by design. WordPress runs on any server with PHP and MySQL; EmDash&#8217;s security model is architecturally locked to Cloudflare.<\/p>\n<p>For a small blog with a static theme and no plugins, the free tier is fine. But the moment you need a dynamic worker for sandboxing, you&#8217;re on the paid plan. That $5\/month is cheap, but it&#8217;s still a vendor lock-in that WordPress avoids.<\/p>\n<h2>The Bigger Picture<\/h2>\n<p>EmDash&#8217;s plugin architecture solves a problem that has plagued WordPress for 24 years: plugins with full access to everything. By enforcing capabilities at the runtime level, Cloudflare has built a system where one compromised plugin cannot take down your entire site. The median time from vulnerability disclosure to mass exploitation in WordPress is 5 hours. In EmDash, a buggy plugin can only do what its manifest allows\u2014and nothing more.<\/p>\n<p>That&#8217;s the real innovation here. Not TypeScript, not serverless, not Astro. The sandbox. If you&#8217;re building a greenfield content site and security matters, this architecture is worth a serious look. Just don&#8217;t expect a plugin marketplace tomorrow.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>Cloudflare&#8217;s EmDash CMS launched on April 1, 2026, and the first question from every WordPress developer was the same: How do I write a plugin for it? The answer is radically different from what you know. WordPress plugins get full access to your database, file system, and user sessions\u2014one compromised plugin and your entire site [&hellip;]<\/p>\n","protected":false},"author":7,"featured_media":100314,"comment_status":"closed","ping_status":"","sticky":false,"template":"","format":"standard","meta":{"fifu_image_url":"https:\/\/cards.overcentral.com\/cards\/en\/98082.png","fifu_image_alt":"How to Write an EmDash Plugin: A Step-by-Step Tutorial","footnotes":""},"categories":[31],"tags":[],"class_list":["post-98082","post","type-post","status-publish","format-standard","has-post-thumbnail","category-technology"],"fifu_image_url":"https:\/\/cards.overcentral.com\/cards\/en\/98082.png","fifu_image_alt":"How to Write an EmDash Plugin: A Step-by-Step Tutorial","_links":{"self":[{"href":"https:\/\/overcentral.com\/en\/wp-json\/wp\/v2\/posts\/98082","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=98082"}],"version-history":[{"count":1,"href":"https:\/\/overcentral.com\/en\/wp-json\/wp\/v2\/posts\/98082\/revisions"}],"predecessor-version":[{"id":100315,"href":"https:\/\/overcentral.com\/en\/wp-json\/wp\/v2\/posts\/98082\/revisions\/100315"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/overcentral.com\/en\/wp-json\/wp\/v2\/media\/100314"}],"wp:attachment":[{"href":"https:\/\/overcentral.com\/en\/wp-json\/wp\/v2\/media?parent=98082"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/overcentral.com\/en\/wp-json\/wp\/v2\/categories?post=98082"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/overcentral.com\/en\/wp-json\/wp\/v2\/tags?post=98082"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}