This guide builds a minimal sandboxed plugin from scratch. The plugin logs every content save and exposes a single API route. It runs in an isolated runtime provided by the configured sandbox runner. The same code also runs in-process when a site operator moves it from sandboxed: [] into plugins: [], for example on a platform without a sandbox runner.
If you haven’t decided between sandboxed and native, read Choosing a plugin format first.
Two pieces
A sandboxed plugin is:
emdash-plugin.jsonc— a hand-edited manifest: identity, the trust contract (capabilities, hosts, storage), and profile fields. No code.src/plugin.ts— the runtime: hooks and routes. Type-only imports fromemdash/plugin; no runtimeemdashimport.
emdash-plugin build reads both and emits the dist/ artifacts a site consumes.
The following example shows the file layout of a complete plugin:
my-plugin/
├── emdash-plugin.jsonc # Identity + trust contract + profile
├── src/
│ └── plugin.ts # Hooks, routes — runs in the sandbox runtime
├── package.json
└── tsconfig.json
Set up the package
-
Create the directory and a
package.json. The build isemdash-plugin build; there is notsdowninvocation to write.{ "name": "@my-org/plugin-hello", "version": "0.1.0", "type": "module", "main": "dist/index.mjs", "exports": { ".": { "import": "./dist/index.mjs", "types": "./dist/index.d.mts" }, "./sandbox": "./dist/plugin.mjs" }, "files": ["dist", "emdash-plugin.jsonc"], "scripts": { "build": "emdash-plugin build", "dev": "emdash-plugin dev" }, "peerDependencies": { "emdash": ">=0.13.0" }, "devDependencies": { "@emdash-cms/plugin-cli": "0.2.0", "emdash": ">=0.13.0", "typescript": "^5.9.0" } }"."is the generated descriptor a site imports;"./sandbox"is the built runtime file.emdash-plugin buildgenerates both. -
Add a
tsconfig.json:{ "compilerOptions": { "target": "ES2022", "module": "preserve", "moduleResolution": "bundler", "strict": true, "esModuleInterop": true, "verbatimModuleSyntax": true, "skipLibCheck": true, "types": [] }, "include": ["src/**/*"], "exclude": ["node_modules"] }
Write the manifest
emdash-plugin.jsonc carries the plugin’s identity (slug), its trust contract (capabilities, allowedHosts, storage), profile fields, and the publisher pin.
The following example shows a complete manifest for the hello plugin:
{
"$schema": "./node_modules/@emdash-cms/plugin-cli/schemas/emdash-plugin.schema.json",
"slug": "plugin-hello",
"publisher": "did:plc:abc123def456", // your Atmosphere account DID
"license": "MIT",
"author": { "name": "Jane Doe", "url": "https://example.com" },
"security": { "email": "security@example.com" },
"capabilities": [],
"allowedHosts": [],
"storage": { "events": { "indexes": ["timestamp"] } }
}
Notes on this manifest:
slugis a URL-safe id, not the npm package name./^[a-z][a-z0-9_-]*$/, max 64 chars. It’s a single path segment in plugin route URLs (/_emdash/api/plugins/<slug>/...) and part of generated SQL identifiers for storage indexes, so@,/, leading digits, and uppercase all fail. Pair an unscopedslug(plugin-hello) with a scoped npm package name.storagedeclares collections up front.ctx.storage.eventsworks at runtime only becauseeventsis declared here. Accessing an undeclared collection throws.versionis omitted. The build reads it frompackage.jsonso there’s one source of truth. See the manifest reference.- The trust contract is consent. Changing
capabilities,allowedHosts, orstoragelater requires a version bump — installed sites consented to the old contract.
Write the runtime
src/plugin.ts default-exports a bare object annotated with satisfies SandboxedPlugin. emdash/plugin provides only types, so a sandboxed plugin has no runtime dependency on emdash.
The following example logs every content save to plugin storage and exposes a recent route that returns the last ten saves:
import type { SandboxedPlugin } from "emdash/plugin";
export default {
hooks: {
"content:afterSave": {
handler: async (event, ctx) => {
ctx.log.info("Content saved", {
collection: event.collection,
id: event.content.id,
});
await ctx.storage.events.put(`save-${Date.now()}`, {
timestamp: new Date().toISOString(),
collection: event.collection,
contentId: event.content.id,
});
},
},
},
routes: {
recent: {
handler: async (_routeCtx, ctx) => {
const result = await ctx.storage.events.query({ limit: 10 });
return { events: result.items };
},
},
},
} satisfies SandboxedPlugin;
Notes on the runtime file:
satisfies SandboxedPlugintypes everything. It inferseventfrom the hook name (with the full canonical event type) andctxasPluginContext, so handlers need no parameter annotations. A typo’d hook key like"content:afterSav"is a compile error.- Hook handlers take
(event, ctx). The event shape depends on the hook name; see the Hooks guide. - Route handlers take
(routeCtx, ctx)— two arguments.routeCtxis{ input, request, requestMeta? };ctxis the samePluginContext. Routes are reachable at/_emdash/api/plugins/<slug>/<route-name>. ctx.storage.eventsworks becauseeventsis declared in the manifest.ctx.kvis always available — a per-plugin key-value store withget,set,delete,list(prefix).
Register the plugin
In the site’s astro.config.mjs, import the plugin’s default export and pass it in. Sandboxed plugins go in sandboxed: []; in-process plugins go in plugins: []. A sandboxed plugin works in both. The example below uses sandboxed::
import { defineConfig } from "astro/config";
import emdash from "emdash/astro";
import { sandbox } from "@emdash-cms/cloudflare";
import hello from "@my-org/plugin-hello";
export default defineConfig({
integrations: [
emdash({
sandboxed: [hello],
sandboxRunner: sandbox(),
}),
],
});
sandboxRunner is the pluggable piece. The example uses sandbox() from @emdash-cms/cloudflare, the runner most sites use today. If no runner is configured (or the configured runner is unavailable on the current platform), sandboxed: [] plugins are skipped at startup — move the plugin into plugins: [] to run it in-process.
Build and run
From the plugin directory:
emdash-plugin validate # schema-check the manifest first
emdash-plugin build # emit dist/
For an edit loop, run emdash-plugin dev (rebuilds on save, keeps the last good dist/ on a failed build). In the site, install or link the plugin (pnpm add file:../plugin-hello or a workspace link) and start the dev server. Save a piece of content in the admin and you should see Content saved … in the logs; GET /_emdash/api/plugins/plugin-hello/recent returns the last ten save events.
What to read next
- The manifest — every field, the trust contract, publisher pinning
- The
emdash-pluginCLI —build,dev,bundle - Hooks — the full set of events
- API routes — input validation, public routes, errors
- Storage and KV — query options, indexes, batch operations
- Capabilities and security — content access, network, host allowlists
- Bundling and publishing — shipping to the marketplace