Your first sandboxed plugin

On this page

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:

  1. emdash-plugin.jsonc — a hand-edited manifest: identity, the trust contract (capabilities, hosts, storage), and profile fields. No code.
  2. src/plugin.ts — the runtime: hooks and routes. Type-only imports from emdash/plugin; no runtime emdash import.

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

  1. Create the directory and a package.json. The build is emdash-plugin build; there is no tsdown invocation 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 build generates both.

  2. 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:

  • slug is 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 unscoped slug (plugin-hello) with a scoped npm package name.
  • storage declares collections up front. ctx.storage.events works at runtime only because events is declared here. Accessing an undeclared collection throws.
  • version is omitted. The build reads it from package.json so there’s one source of truth. See the manifest reference.
  • The trust contract is consent. Changing capabilities, allowedHosts, or storage later 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 SandboxedPlugin types everything. It infers event from the hook name (with the full canonical event type) and ctx as PluginContext, 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. routeCtx is { input, request, requestMeta? }; ctx is the same PluginContext. Routes are reachable at /_emdash/api/plugins/<slug>/<route-name>.
  • ctx.storage.events works because events is declared in the manifest.
  • ctx.kv is always available — a per-plugin key-value store with get, 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.