Ship your plugin to the EmDash plugin registry
The EmDash plugin registry launches with 1.0. Here's how to scaffold a sandboxed plugin, publish it from your own Atmosphere account, and get it listed for EmDash sites.
EmDash 1.0 is out, and the official plugin registry is live at plugins.emdashcms.com. If you've built an EmDash site and have a plugin idea, or a plugin you've been copying between projects, you can publish it today and let other sites install it from their admin.
This post walks through what it takes to get a plugin listed. For everything else about today’s EmDash 1.0 release, read the announcement on the Cloudflare blog.
Two things to know before you start
Registry plugins are always sandboxed. Sites run anything installed from the registry through their sandbox runner, so your code runs in a sandbox of its own and gets only the access it asks for. Native plugins (the ones in plugins: []) run inside the EmDash server process and can't be published to the registry. Keep distributing those through npm.
You also publish as yourself. Every release is a signed record in your own Atmosphere account, the same identity people use for Bluesky and other AT Protocol apps. If you have a Bluesky account, you can publish with it. If you don't, create an Atmosphere account before you scaffold, because the scaffolder writes your account into the manifest.
Scaffold the plugin
The plugin CLI generates a working project:
pnpm dlx @emdash-cms/plugin-cli init my-pluginThe scaffolder asks for your publisher account, author, security contact, and source repository. You get an emdash-plugin.jsonc manifest, a src/plugin.ts entry point, a test setup, an AGENTS.md, and a creating-plugins skill. That means Claude Code, Cursor, or whatever agent you use can build against the plugin APIs as soon as you open the project.
The CLI is installed as a dev dependency of your plugin, so run its commands with pnpm exec emdash-plugin.
Declare what your plugin needs
The manifest is where you tell site owners what your plugin will touch. Trimmed down to the access fields, a plugin that posts to Slack whenever content is saved might declare:
{
"slug": "slack-notify",
"publisher": "did:plc:abc123def456",
// ...plus the license, author, and security fields the scaffolder writes
"capabilities": ["content:read", "network:request"],
"allowedHosts": ["hooks.slack.com"],
"storage": {}
}
Admins review those capabilities and hosts before they install, and the runtime holds your plugin to them. With this manifest, your plugin can read content and reach hooks.slack.com. It can't write content, see users, or call any other host.
Watch for one quiet failure: when a hook needs a capability you didn't declare, EmDash skips the hook and logs a warning. If your content:afterSave hook never fires, check for content:read first. pnpm run validate catches most other manifest mistakes.
Two manifest rules shape every release after your first:
- Pin
publisherto your DID, not your handle. Handles can move to a different account. The CLI refuses to publish when the active session doesn't match the pinned publisher, with no override flag. Runpnpm exec emdash-plugin whoamito see your sessions andswitch <did>to change accounts. - Treat new access as a major release. Every site that installed your plugin has to re-approve an update that adds capabilities or hosts, so ship those as a major version. New hooks or routes are a minor bump, and fixes are a patch. Published versions can't be replaced, so every change ships as a new version.
Run it against a real site
Start the plugin's dev build first, since the site imports its build output:
cd my-plugin
pnpm install
pnpm devpnpm dev rebuilds the plugin whenever its source or manifest changes. From your site's directory, install the plugin with pnpm add file:../my-plugin, then import it and add a sandbox runner in astro.config.mjs:
import emdash from "emdash/astro";
import myPlugin from "my-plugin";
emdash({
sandboxed: [myPlugin],
sandboxRunner: "@emdash-cms/sandbox-workerd/sandbox",
});
The sandboxRunner line matters. Without it, EmDash skips everything under sandboxed and doesn't show the registry catalog in the admin. The runner depends on where the site is deployed:
- Node.js: install
@emdash-cms/sandbox-workerdandworkerd, and use the config above. EmDash runs your plugin in a separateworkerdprocess. - Cloudflare Workers: use
sandboxRunner: sandbox()from@emdash-cms/cloudflare, add aLOADERWorker Loader binding, and exportPluginBridgefrom your Worker entry point. Worker Loader requires the Workers Paid plan. Sandboxed plugins reach site data through the site's D1 database, so Cloudflare sites that use the Hyperdrive database adapter can't run them currently.
Test on Cloudflare before you publish, even if you develop on Node.js (you'll need a Workers Paid account). Both runners stop a call after 30 seconds, but only Cloudflare enforces the 50 ms CPU limit and the cap of 10 subrequests per invocation. A plugin that passes on Node.js can still fail on Workers.
Bundles have hard size limits: 256 KB decompressed in total, 128 KB per file, no more than 20 files, and no Node built-ins like fs or path in your backend code.
The plugin sandbox docs cover setup and troubleshooting for both runners.
Publish
Sign in with your handle, then publish from the plugin directory:
pnpm exec emdash-plugin login alice.bsky.social
pnpm exec emdash-plugin publishUse the full pnpm exec form here. pnpm login and pnpm publish are pnpm's own npm commands and won't reach the registry.
publish builds and validates the bundle, uploads the tarball to your personal data server (PDS), and writes the release record to your account. Your plugin gets a name in the form @your-handle/slug.
Before a new release appears on plugins.emdashcms.com, automated checks review your listing's text, links, and images. Some listings get held for manual review, so a new plugin can take a while to show up. The CLI prints an emdash-plugin info … --watch command so you can follow the checks.
To cut releases from CI, set up the automated release service with pnpm exec emdash-plugin release setup. It builds and publishes from GitHub Actions and requires a public GitHub repository. The generated workflow runs on tags in the slug@version format, such as slack-notify@1.2.0, so a plain v1.2.0 tag won't trigger a release. If your repository uses Changesets, setup can hook into that workflow instead.
Why releases live in your account
Because each release is a record in your account, plugins.emdashcms.com is one index of the registry, not its owner. Anyone can run another catalog over the same records with their own curation and policies, and your plugin shows up there without a separate submission. A hosting platform could offer its customers the full registry, or only the plugins it has approved.
The EmDash catalog screens listing text and images, and it can hide a plugin whose listing gets flagged. Your signed release record stays in your account either way.
Sites also verify what they download. Before installing, EmDash checks a proof that the release record came from a signed commit in your account. It then checks the tarball's checksum, name, version, and requested access against that record. A catalog can list your plugin, but it can't swap in different code.
Only free plugins currently
All registry plugins are free for now. We want plugin authors to be able to sell their work, and we plan to add paid plugins that keep the account-owned model.
Get started
- Your first sandboxed plugin goes from scaffold to a running hook and route.
- Bundling and publishing covers the release flow, listing images, and account setup.
- The manifest reference lists every field and validation rule.
When your plugin is live, post it in the EmDash Discord so other developers can try it.