Create EmDash CMS plugins with hooks, storage, settings, admin UI, API routes, and Portable Text block types. Use this skill when asked to build, scaffold, or implement an EmDash plugin, or when creating plugin features like custom block types, admin pages, or content hooks.
Install with Codex or Claude Copy this prompt, paste it into Codex, Claude, or another assistant, and let it review the skill page and install it for you.
A direct command skips the review prompt. Inspect the source before running it.
Create EmDash CMS plugins with hooks, storage, settings, admin UI, API routes, and Portable Text block types. Use this skill when asked to build, scaffold, or implement an EmDash plugin, or when creating plugin features like custom block types, admin pages, or content hooks.
Creating EmDash Plugins
EmDash plugins extend the CMS with hooks, storage, settings, admin UI, API routes, and custom Portable Text block types. All plugins are TypeScript packages.
Plugin Types
EmDash has two plugin formats:
Type
Format
Admin UI
Where it runs
Standard
definePlugin({ hooks, routes })
Block Kit
Isolate on Cloudflare, in-process elsewhere
Native
createPlugin() / definePlugin() with id+version
React or Block Kit
Always in host isolate
Standard is the default. Most plugins should use it. Standard plugins can be published to the marketplace and work in both trusted and sandboxed modes.
Native is an escape hatch for plugins that need React admin components, direct DB access, or custom Astro components. Native plugins can only run in plugins: [] -- they cannot be sandboxed or published to the marketplace.
Plugin Anatomy
Every plugin has two parts that run in different contexts:
Plugin descriptor (PluginDescriptor) — returned by the factory function in index.ts. Declares metadata (id, version, capabilities, storage). (imported in ). Must be side-effect-free.
Runs at build time in Vite
astro.config.mjs
Plugin definition (definePlugin()) — contains the runtime logic (hooks, routes). Runs at request time on the deployed server. Has access to the full plugin context (ctx). Lives in a separate file (typically sandbox-entry.ts).
These must be in separate entrypoints because they execute in completely different environments:
my-plugin/
├── src/
│ ├── index.ts # Descriptor factory (runs in Vite at build time)
│ ├── sandbox-entry.ts # Plugin definition with definePlugin() (runs at deploy time)
│ ├── admin.tsx # Admin UI exports (React) — optional, native only
│ └── astro/ # Site-side rendering components — optional, native only
│ └── index.ts # Must export `blockComponents`
├── package.json
└── tsconfig.json
The descriptor is what gets imported in astro.config.mjs. The entrypoint field points to the module containing the definePlugin() default export. For standard plugins, this is the ./sandbox export from package.json.
Key differences from native format:
No id, version, or capabilities in definePlugin() -- those live in the descriptor
definePlugin() is an identity function providing type inference
Hook handlers use (event, ctx) two-arg pattern
Route handlers use (routeCtx, ctx) two-arg pattern
Exported as default (not a factory function)
Plugin ID Rules
Lowercase alphanumeric + hyphens only
Simple (my-plugin) or scoped (@my-org/my-plugin)
Unique across all installed plugins
Registration
The descriptor is imported in astro.config.mjs (Vite context):
Standard plugins work in either array. Native plugins only work in plugins: [].
Trusted vs Sandboxed Plugins
EmDash has two execution modes. Plugin code is identical in both — only the enforcement changes.
Trusted
Sandboxed
Runs in
Main process
Isolated V8 isolate (Dynamic Worker Loader)
Install method
astro.config.mjs (code change + deploy)
Admin UI (one-click from marketplace)
Capabilities
Advisory (not enforced)
Enforced at runtime via RPC bridge
Resource limits
None
CPU 50ms, 10 subrequests, 30s wall-time, ~128MB memory
Network access
Unrestricted
Blocked; only via ctx.http with allowedHosts
Data access
Full database access
Scoped to declared capabilities
Node.js APIs
Full access
Not available (V8 isolate only)
Available on
All platforms
Cloudflare Workers only
Best for
First-party code, reviewed npm packages
Third-party extensions, marketplace plugins
Trusted Mode
Trusted plugins are npm packages or local files added in astro.config.mjs. They run in-process with your Astro site.
Capabilities are documentation only. Declaring ["content:read"] documents intent but isn't enforced — the plugin has full process access.
Only install from sources you trust. A malicious trusted plugin has the same access as your application code.
Sandboxed Mode
Sandboxed plugins run in isolated V8 isolates on Cloudflare Workers via Dynamic Worker Loader. Each plugin gets its own isolate.
Capabilities are enforced. If a plugin declares ["content:read"], it can only call ctx.content.get() and ctx.content.list(). Attempting ctx.content.create() throws a permission error.
Network is blocked by default. Direct fetch() calls fail. Plugins must use ctx.http.fetch(), which validates against allowedHosts.
Storage is scoped. A plugin can only access its own KV and storage collections.
Admin UI uses Block Kit. Sandboxed plugins describe their UI as JSON blocks -- no plugin JavaScript runs in the browser. See Block Kit reference.
No Portable Text block types. PT blocks require Astro components for site-side rendering (componentsEntry), which are loaded at build time from npm. Sandboxed plugins are installed at runtime and can't ship components. PT blocks are a native-plugin-only feature.
Routes work. Standard plugin routes are available in both trusted and sandboxed modes via the sandbox runner's invokeRoute() RPC.
Sandboxing is not available on Node.js. All plugins run in trusted mode on non-Cloudflare platforms.
Developing for Both Modes
Write the same code. Develop locally in trusted mode (faster iteration, easier debugging). Deploy to sandboxed mode in production without code changes. With the standard format, the same entrypoint serves both modes -- no separate sandbox entry needed.
// src/sandbox-entry.ts -- works in both trusted and sandboxed modesimport { definePlugin } from"emdash";
importtype { PluginContext } from"emdash";
exportdefaultdefinePlugin({
hooks: {
"content:afterSave": {
handler: async (event: any, ctx: PluginContext) => {
// Trusted: ctx.http present because descriptor declares network:request// Sandboxed: ctx.http present and enforced via RPC bridgeif (!ctx.http) return;
await ctx.http.fetch("https://api.analytics.example.com/track", {
method: "POST",
body: JSON.stringify({ contentId: event.content.id }),
});
},
},
},
});
Key constraint for sandbox compatibility: no Node.js built-ins (fs, path, child_process, etc.) in backend code. Use Web APIs instead.
Capabilities
Capabilities control what APIs are available on ctx. Always declare what your plugin needs — even in trusted mode, they document intent and are required for sandboxed execution.
When a marketplace plugin is installed, the admin sees a capability consent dialog listing what the plugin can access. Users must approve before installation.
Publishing to the Marketplace
Standard plugins can be published to the EmDash Marketplace for one-click installation:
Descriptor factory -- imported in astro.config.mjs
"./sandbox"
Server (runtime)
definePlugin({ hooks, routes }) -- loaded by entrypoint at runtime
"./admin"
Browser
React components for admin pages/widgets (native plugins only)
"./astro"
Server (SSR)
Astro components for site-side block rendering (native plugins only)
The "." export has the descriptor. The "./sandbox" export has the implementation. The descriptor's entrypoint field points to "./sandbox". Only include ./admin and ./astro exports for native-format plugins.
Plugin Features
Each feature is optional. Add only what your plugin needs:
Feature
Where
Standard
Native
Purpose
Hooks
definePlugin({ hooks })
Yes
Yes
React to content/media/lifecycle events
Storage
descriptor storage
Yes
Yes
Document collections with indexed queries
KV
ctx.kv in hooks/routes
Yes
Yes
Key-value store for internal state
API Routes
definePlugin({ routes })
Yes
Yes
REST endpoints at /_emdash/api/plugins/<id>/<route>