On September 28, 2026, EmDash 1.0, Cloudflare's native Astro CMS, was officially released. Since then, version 1.1 followed on October 2, and version 1.2 on October 7, with updates rolling out in rapid succession over less than two weeks.
We wrote about upgrading our internal blog "yuru-log" from 0.22 to 1.2 in this article. This time, we'll take a closer look at the "plugin mechanism," which underwent the biggest changes in version 1.0.
For details on what kind of CMS EmDash is, check out our first article.
Roughly speaking, it's "WordPress rebuilt with Astro + Cloudflare."
How did plugins change in version 1.0?
In the 0.x era, plugins were essentially "npm packages written into Astro's configuration file," so they ran in the same process as the site, giving them the same permissions as the site code. This meant they could access the database and environment variables just like WordPress.
In version 1.0, sandboxing and a decentralized registry were introduced.
This is a major improvement.
- Plugins run in an isolated environment by default and cannot access any of the site's content, media, users, secrets, or network.
- Required permissions (Capability) are declared by the plugin and approved by the site administrator at install time.
- Approved permissions aren't just "written down" — the runtime actively enforces them.
- Plugins can be installed with a single click from the admin panel. The distribution registry is decentralized on the AT Protocol.
Cloudflare's blog explains this in contrast to WordPress's model where "all plugins share the same process."
Anyone familiar with WordPress plugin-related incidents will find this particularly relevant.
Plugins have two "execution modes"
First, it's important to understand that the same plugin code has two execution modes: Trusted and Sandboxed.
Trusted | Sandboxed | |
|---|---|---|
Registration location |
|
|
Execution environment | Same process as the site | Isolated V8 isolate |
Capability | Declaration only (not enforced) | Runtime enforced |
Network | No restrictions | Blocked. Only hosts listed in |
Data access | Full database access | Your own storage / KV and approved scopes only |
Node.js API | Available | Not available (Web APIs only) |
Resource limits | None | CPU 50ms, 10 subrequests, 30 seconds, approximately 128MB |
Ideal use cases | First-party code, reviewed npm packages | Third-party, registry distribution |
"Isolation" means setting up a dedicated small runtime environment for each plugin and running it separately from the main site.
On Cloudflare Workers, Dynamic Workers is a mechanism that launches a Worker for a plugin on request.
From version 1.0 onward, when running on Node.js, the same runtime (workerd) is launched in a separate process to host plugins, so "sandboxing is no longer limited to Cloudflare."
The key point is that plugin code is identical in both modes, which means you can develop locally with Trusted privileges and run in the sandbox in production as a normal workflow.
Plugin structure: separating files between build time and runtime
EmDash plugins are divided into two parts that execute at different times.
- Descriptor: metadata such as
id,version, required capabilities, and storage definitions. It is imported fromastro.config.mjs, so it runs within Vite at build time. It must not have side effects. - Definition: the content of hooks or routes. It runs on the server at request time.
my-plugin/
├── src/
│ ├── index.ts # descriptor。astro.config.mjs から import される
│ └── sandbox-entry.ts # definePlugin({ hooks, routes })。実行時にロードされる
└── package.json # "." と "./sandbox" を exports で分ける
// src/index.ts
import type { PluginDescriptor } from "emdash";
export function myPlugin(): PluginDescriptor {
return {
id: "my-plugin",
version: "1.0.0",
format: "standard",
entrypoint: "@my-org/my-plugin/sandbox",
capabilities: ["content:read", "network:request"],
allowedHosts: ["api.example.com"],
options: {},
};
}
// src/sandbox-entry.ts
import { definePlugin, type PluginContext } from "emdash";
export default definePlugin({
hooks: {
"content:afterSave": async (event, ctx: PluginContext) => {
ctx.log.info(`saved ${event.collection}/${event.content.id}`);
},
},
});
On the descriptor side, write the definition module path in the entrypoint, and EmDash loads it at runtime. You might want to keep everything in the same file, but since Vite's build would include runtime code, it's correct to separate them.
Standard and Native
As another axis, plugins come in two formats: Standard and Native.
- Standard: the structure described above. The management UI is written in Block Kit (JSON) as described later. It works in both Trusted and Sandboxed modes and can be published to the registry.
- Native: bundles a React-based management UI, custom blocks for Portable Text (body editor), and Astro components for site-side rendering. In return, it only runs in Trusted mode and cannot be published to the registry.
The general approach is to write in Standard, and only use Native when you want to add custom blocks to the editor or inject components into the site's HTML.
Yuru Log uses Native plugins to create custom Amazon product cards and video embeds. Both add dedicated blocks to the editor and render with Astro components—something that's not possible in sandbox mode in principle, because runtime-installed plugins cannot bring Astro components needed at build time.
Capability: the vocabulary of permissions
Capability is at the core of the sandbox. Plugins declare "what they want to access" as strings, and admins review and approve them in the install dialog.
In version 1.x, vocabulary has been organized into the form :. Here are some commonly used examples.
Capability | What you can do |
|---|---|
| Read articles / create, update, and delete |
| Publish, unpublish, and schedule |
| Read media / upload and delete |
| HTTP requests to external hosts. Only hosts listed in |
| Read unsaved draft content / propose revisions |
| Review content and block publication |
In addition to taxonomies, comments, redirects, users, and email sending, there are 27 types in total.
storage and KV are always available without declaration. They are scoped per plugin, so data from other plugins is not visible.
The 0.x naming conventions (read:content, network:fetch, etc.) are deprecated but still work as aliases. The runtime normalizes them to the current names, so existing plugins work without changes, with warnings appearing at bundle time.
Hooks: What can you react to?
Runtime 1.2.0 defines 30 types of hooks. Content saving, publishing, scheduling, deletion; media uploads; email sending; comment posting and approval; plugin installation and activation; and periodic execution via cron. This is a significant increase from 0.x, especially the change where pre- and post-publish operations (content:beforePublish / afterPublish) are now separate hooks.
Throwing in content:beforeSave cancels the save, so validation plugins go here. To stop publishing itself, you declare hooks.content-policy:register and use the pre-publish hook. throw to interrupt.
One often-overlooked but important change in version 1.2 is that on first launch after upgrade, existing plugins' plugin:install and plugin:activate are now re-executed. If your plugin seeds initial data in the install hook, you'll need to make sure it won't break if that data is inserted twice.
Block Kit: Admin interface without JavaScript
In the sandbox, plugin JavaScript doesn't run in the browser. Instead, the admin interface is written in Block Kit, a JSON format. Plugins return an array of blocks—headers, tables, forms, toggles—and EmDash renders them.
Here's the flow: when a user opens a plugin page, EmDash sends a request saying the page opened, and the plugin returns an array of blocks. When the user submits a form, a request arrives with input values, the plugin saves it and returns a new array of blocks. UI works through request-response alone, making this design well-suited to sandbox constraints.
In addition to admin pages and dashboard widgets, 1.x allows the panel beside the editor (editorPanels) to be built with Block Kit too. It's for showing state and actions specific to each article in the edit screen—translation status display, SEO and text checks, broken link inspection, excerpt generation from body text—all features targeting 'the article you're writing now' fit here.
Decentralized registry: a design without a central marketplace
The plugin distribution model also changed in 1.0. From the user's perspective, it's just 'find and install with one click from the admin screen,' but behind the scenes it's built on AT Protocol, the foundation of Bluesky, with an unusual structure.
- Plugin information lives in the author's account. Authors log in with a Bluesky-based account, sign package and release information, and store it in their account's data space. It is not deposited on the registry operator's server.
- Cloudflare's registry is just "one of many catalogs."
plugins.emdashcms.comsimply aggregates and indexes information published by individual authors; anyone can create a different catalog from the same information. - Installation verifies the contents. EmDash checks whether the downloaded plugin has the same contents as what the author signed and published. It verifies file hashes, package name and version, and requested Capabilities against the signed information—and if it was built by GitHub Actions, it also validates that build record. Plugins substituted by the catalog or tampered with are rejected at this step.
- Moderation only "hides." Cloudflare's catalog uses an AI model (Clef) to review plugin descriptions, links, and images, and removes problematic content from the catalog display. However, it cannot alter the plugin's code or strip ownership from the author.
In other words, "if the registry operator disappears, distribution continues as long as information remains in the author's account" and "the operator can hide items from the catalog but cannot tamper with them."
What's distinctive is that it's designed without assuming a centralized marketplace like npm or WordPress.org.
Currently only free plugins are supported. Paid plugins will be handled with the same distributed model once AT Protocol advances, according to the team.
Translation mechanisms could work as a plugin.
Yurulog runs a system that generates English draft versions from Japanese articles using an LLM (see "Building a multilingual blog with EmDash"). Currently, a Node script reads the EmDash API token and translation API key from .env, fetches Japanese articles, translates them, and creates English drafts tagged with translationOf.
When applied to the 1.x plugin specification, this becomes almost a straightforward Standard plugin.
- Trigger:
content:afterPublishhook. It runs when a Japanese article is published. - Reading and writing articles:
content:readandcontent:write. Translation results are created as drafts. - Calling the translation API:
network:requestand one API host inallowedHosts. - API key:
settingsSchemasecretfield. Enter from the admin panel and save encrypted. No need for.envor API token issuance. - Translation status: Store in storage which revision of the source article was translated, and display in the article editing screen via
editorPanelsto show "English version exists / Source article updated, retranslation needed".
Scripts that were previously run manually now work simply by having an administrator approve the Capability. Plus, the runtime guarantees that the sandboxed plugin cannot reach hosts other than the translation API. The structural elegance of this approach is that the concern of "where should I place the code that handles API keys" simply disappears.
One design consideration: every time the sandboxed plugin requests something from the host (reading settings, fetching articles, writing to storage), it counts as one subrequest, and the default limit is 10. Translating a single article should fit within this, but processing like "batch translate all untranslated articles" can quickly hit the limit if you call ctx.content.get() in a loop. A design that processes gradually via cron hooks would be necessary.
Also, site-side UI like language switching cannot be output from the sandbox, so that needs to be implemented on the theme side.
Summary
- EmDash 1.0 plugins are sandbox-first. Declare Capabilities, have the administrator approve them, and the runtime enforces them.
- The same code runs in both Trusted and Sandboxed modes. Development is Trusted, production is sandboxed—that is the standard approach.
- If custom editor blocks or Astro components are needed, use Native (Trusted only).
- descriptor (build-time) and definition (runtime) must always be in separate files
- Capability names changed to
content:readformat in 1.x. Old names work as aliases but are deprecated - The registry is decentralized on AT Protocol. Catalogs can be hidden but not tampered with
- In the sandbox, "one host call = one subrequest" with a limit of 10. Design with this frequency in mind
With WordPress, every time you install a plugin, you accept that
the plugin has full permissions over your site. If even one plugin has a vulnerability, your entire site is at risk.
With EmDash 1.0, for sandbox-type plugins, the runtime displays the permissions a plugin needs at install time and only allows what you approve.
For people looking to migrate away from WordPress, plugin security is one reason to choose EmDash.
The plugin ecosystem is still small and nowhere near "find whatever you need" like WordPress, but we're looking forward to seeing more plugins emerge.
We've already built operational experience on our own site, so we're planning to add it to our service offerings.
A "master of technique" who jumped from DTP into the web world and, before he knew it, mastered markup, frontend, direction, and accessibility. Active across multiple domains since Liberogic's early days, he's now a walking encyclopedia within the company. Recently, he's been diving deep into prompt-driven efficiency optimization, wondering "Can we rely more on AI for accessibility compliance?" Both his technology and thinking continue to evolve.
Ayumu Futamata
IAAP Certified Web Accessibility Specialist (WAS) / Markup Engineer / Frontend Engineer / Web Director