Your first sandboxed plugin
This guide walks through building a minimal sandboxed plugin from scratch — a plugin that logs every content save and exposes a single API route. By the end you’ll have a plugin that runs in an isolated runtime via the configured sandbox runner. The same plugin code can also run in-process if the site operator chooses to move it from sandboxed: [] into plugins: [] — for example on platforms without a sandbox runner available.
If you haven’t decided yet whether you want a sandboxed or native plugin, read Choosing a plugin format first.
Two files
Section titled “Two files”Every sandboxed plugin ships two pieces:
- A descriptor — a small object describing the plugin (id, version, capabilities, storage, where to find the runtime entry). Imported by
astro.config.mjsat build time. - A sandbox entry — the runtime code: hooks, routes, storage access. Loaded into the sandbox runtime at request time.
The two files live in the same package and run in completely different environments. The descriptor never sees the runtime context; the entry never sees astro.config.mjs.
my-plugin/├── src/│ ├── index.ts # Descriptor — runs in Vite at build time│ └── sandbox-entry.ts # Hooks, routes, storage — runs in the sandbox runtime├── package.json└── tsconfig.jsonSet up the package
Section titled “Set up the package”-
Create a new directory and initialise it as a TypeScript ES module package.
package.json {"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/sandbox-entry.mjs"},"files": ["dist"],"scripts": {"build": "tsdown src/index.ts src/sandbox-entry.ts --format esm --dts --clean"},"peerDependencies": {"emdash": "*"},"devDependencies": {"emdash": "*","tsdown": "^0.6.0","typescript": "^5.5.0"}}The
"./sandbox"export is what the descriptor’sentrypointwill point at. The bundler builds both files intodist/. -
Add a
tsconfig.json:tsconfig.json {"compilerOptions": {"target": "ES2022","module": "preserve","moduleResolution": "bundler","strict": true,"esModuleInterop": true,"declaration": true,"outDir": "./dist","rootDir": "./src"},"include": ["src/**/*"],"exclude": ["node_modules", "dist"]}
Write the descriptor
Section titled “Write the descriptor”The descriptor is a factory function that returns a PluginDescriptor. It runs in Vite at build time, which means it must be side-effect-free and can’t use any runtime APIs (fetch, the database, environment variables — none of those exist yet).
import type { PluginDescriptor } from "emdash";
export function helloPlugin(): PluginDescriptor { return { id: "plugin-hello", version: "0.1.0", format: "standard", entrypoint: "@my-org/plugin-hello/sandbox",
capabilities: [], storage: { events: { indexes: ["timestamp"] }, }, };}A few details that matter:
format: "standard"is required. Without it, EmDash treats the package as a native plugin and looks for a different shape. Theformatfield defaults to"native".entrypointis a module specifier, not a file path. Use the same string you’d pass toimport— usually"<package-name>/sandbox". The package name can be scoped (@my-org/plugin-hello); the pluginidcannot.idis a URL-safe slug, not the npm package name. It must match/^[a-z][a-z0-9_-]*$/— start with a lowercase letter, then letters, digits, hyphens, or underscores. The id is used both as a single path segment in plugin route URLs (/_emdash/api/plugins/<id>/...) and as part of generated SQL identifiers for plugin storage indexes, so@,/, leading digits, and uppercase letters all fail at runtime. Pair an unscopedidlikeplugin-hellowith a scoped npm package name inentrypoint.- Capabilities, allowedHosts, and storage live on the descriptor. The sandbox entry does not declare them — it can only use what the descriptor permits.
- Don’t put runtime logic here. No top-level
await, no module-levelfetch, no reading files. The descriptor is metadata.
Write the sandbox entry
Section titled “Write the sandbox entry”The runtime side. This file is loaded inside the sandbox runtime at request time, with no access to anything except what ctx provides.
import { definePlugin } from "emdash";import type { PluginContext } from "emdash";
interface ContentSaveEvent { collection: string; content: { id: string }; isNew: boolean;}
export default definePlugin({ hooks: { "content:afterSave": { handler: async (event: ContentSaveEvent, ctx: PluginContext) => { 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: PluginContext) => { const result = await ctx.storage.events.query({ limit: 10 }); return { events: result.items }; }, }, },});Things worth knowing:
definePlugin()in the sandbox entry takes only{ hooks, routes }. Noid, noversion, nocapabilities— those come from the descriptor. EmDash throws at build time if you try to pass them here.- Hook handlers take
(event, ctx). The event shape depends on the hook name; see the Hooks reference. - Route handlers take
(routeCtx, ctx)— two arguments.routeCtxhas{ input, request, requestMeta };ctxis the samePluginContextyou get in hooks. Routes are reachable at/_emdash/api/plugins/<plugin-id>/<route-name>. ctx.storage.eventsworks becauseeventswas declared on the descriptor. Accessing an undeclared collection throws.ctx.kvis always available — a per-plugin key-value store withget,set,delete, andlist(prefix).
Register the plugin
Section titled “Register the plugin”In your site’s astro.config.mjs, import the descriptor factory and pass it into the EmDash integration. Sandboxed plugins go in sandboxed: []; in-process plugins go in plugins: []. A standard-format plugin works in both — start with sandboxed.
import { defineConfig } from "astro/config";import emdash from "emdash/astro";import { sandbox } from "@emdash-cms/cloudflare";import { helloPlugin } from "@my-org/plugin-hello";
export default defineConfig({ integrations: [ emdash({ sandboxed: [helloPlugin()], sandboxRunner: sandbox(), }), ],});sandboxRunner is the pluggable piece. The example above uses sandbox() from @emdash-cms/cloudflare, which is the sandbox runner most sites use today. Runners for other platforms are in development. If no runner is configured (or the configured runner reports as unavailable on the current platform), sandboxed: [] plugins are skipped at startup — to run the same plugin in-process, move it from sandboxed: [] into plugins: [].
Build and run
Section titled “Build and run”From the plugin directory:
pnpm buildIn the site directory, link or install the plugin (pnpm add @my-org/plugin-hello or a workspace link), then start the dev server. You should see [hello] Content saved … in the logs the next time you save a piece of content in the admin, and GET /_emdash/api/plugins/plugin-hello/recent should return the last ten save events.
What to read next
Section titled “What to read next”- Hooks — the full set of events you can react to
- API routes — input validation, public routes, error handling
- Storage and KV — query options, indexes, batch operations
- Capabilities and security — content access, network requests, host allowlists
- Bundling and publishing — when you’re ready to ship to the marketplace