---
title: Native vs. Sandboxed Plugins
description: Understand the two plugin execution modes, what each supports, and why you should prefer sandboxed plugins.
---

import { Aside, Card, CardGrid, Steps, Tabs, TabItem } from "@astrojs/starlight/components";

EmDash plugins run in one of two modes: **sandboxed** or **native**. Both use the same `definePlugin()` API and the same hooks, but they differ in how they're installed, what enforcement they get, and what extra features are available.

**Prefer sandboxed plugins.** Sandboxed plugins can be installed from the marketplace with one click -- no `npm install`, no rebuild, no redeploy. Native plugins require a code change, a dependency install, and a full rebuild. If your plugin can work within the sandbox, it should.

<Aside type="tip">
	The plugin code is identical in both modes. Write your plugin using the standard format, test it locally as a native plugin for fast iteration, and publish it to the marketplace as a sandboxed plugin.
</Aside>

## Quick Comparison

| | Sandboxed | Native |
|---|---|---|
| **Install method** | One-click from admin UI | Code change + `npm install` + deploy |
| **Runs in** | Isolated V8 isolate | Same process as your Astro site |
| **Capabilities** | Enforced by RPC bridge | Advisory only (not enforced) |
| **Resource limits** | CPU, memory, subrequests, wall-time | None |
| **Network access** | Via `ctx.http` with host allowlist | Unrestricted |
| **Data isolation** | Full -- scoped storage and KV | None -- shares the process |
| **Platform** | Cloudflare Workers | All platforms |
| **Admin UI** | Block Kit (JSON-based) | React components or Block Kit |
| **PT block types** | Not available | Astro components via `componentsEntry` |
| **Page fragments** | Not available | Available with `hooks.page-fragments:register` capability |

## What Both Modes Support

The core plugin API is the same regardless of execution mode. Both sandboxed and native plugins can use:

| Feature | Details |
|---|---|
| **All 22 hooks** | Content, media, email, comments, cron, page metadata, lifecycle |
| **API routes** | REST endpoints at `/_emdash/api/plugins/<id>/<route>` |
| **Plugin storage** | Document collections with indexes and queries |
| **KV store** | Key-value storage for settings and state |
| **Content CRUD** | Read, create, update, delete site content (with capability) |
| **Media CRUD** | Read, upload, delete media files (with capability) |
| **HTTP fetch** | External API calls (with capability and host allowlist) |
| **User access** | Read user info (with capability) |
| **Email** | Send email (with capability and provider configured) |
| **Cron scheduling** | Schedule recurring tasks |
| **Page metadata** | Contribute meta tags, OpenGraph, JSON-LD to `<head>` |
| **Settings schema** | Auto-generated admin settings UI |
| **Admin pages & widgets** | Via Block Kit |

## What Only Native Plugins Can Do

Three features require build-time integration that sandboxed plugins can't provide:

### Custom React Admin Pages

Native plugins can ship React components that render full admin pages and dashboard widgets. Sandboxed plugins use [Block Kit](/plugins/block-kit/) instead -- a JSON-based UI that the admin renders on the plugin's behalf.

<Tabs>
<TabItem label="Native (React)">
```typescript title="src/admin.tsx"
export function SettingsPage({ api }) {
	const [config, setConfig] = useState(null);

	useEffect(() => {
		api.get("settings").then(setConfig);
	}, []);

	return (
		<form onSubmit={() => api.post("settings/save", config)}>
			<input value={config?.apiKey} onChange={...} />
		</form>
	);
}
```
</TabItem>
<TabItem label="Sandboxed (Block Kit)">
```typescript title="src/sandbox-entry.ts"
routes: {
	admin: {
		handler: async (routeCtx, ctx) => {
			const apiKey = await ctx.kv.get("settings:apiKey");
			return {
				blocks: [
					{ type: "header", text: "Settings" },
					{
						type: "input",
						element: {
							type: "text_input",
							action_id: "apiKey",
							initial_value: apiKey ?? "",
						},
						label: "API Key",
					},
					{
						type: "actions",
						elements: [
							{ type: "button", text: "Save", action_id: "save" },
						],
					},
				],
			};
		},
	},
}
```
</TabItem>
</Tabs>

### Portable Text Block Types

Plugins can add custom block types to the Portable Text editor (YouTube embeds, code snippets, etc.). The editing UI uses Block Kit fields, which works in both modes. But rendering those blocks on the public site requires Astro components loaded at build time from npm -- so only native plugins can provide a `componentsEntry`.

Sandboxed plugins can still declare PT block types with editing fields. The site author just needs to provide their own rendering components or use a companion native package for rendering.

### Page Fragment Injection

The `page:fragments` hook injects raw HTML, scripts, or stylesheets into public pages. Because these fragments execute as first-party code in the visitor's browser -- outside any sandbox boundary -- this hook is restricted to native plugins. Sandboxed plugins can use `page:metadata` to contribute structured data (meta tags, OpenGraph, JSON-LD) instead.

## How Sandboxing Works

Sandboxed plugins run in isolated V8 isolates provided by Cloudflare's [Dynamic Worker Loader](https://developers.cloudflare.com/workers/runtime-apis/bindings/worker-loader/). The plugin code never shares memory, globals, or bindings with your Astro site.

### Architecture

```
┌─────────────────────┐     RPC      ┌──────────────────────┐
│  Plugin Isolate     │ <----------> │  PluginBridge        │
│  (V8 Worker Loader) │  (binding)   │  (WorkerEntrypoint)  │
│                     │              │                      │
│  ctx.kv.get(k)      │─────────────>│  kvGet(k)            │
│  ctx.content.list() │─────────────>│  contentList()       │
│  ctx.http.fetch(u)  │─────────────>│  httpFetch(u)        │
└─────────────────────┘              └──────────────────────┘
                                            │
                                            v
                                     ┌──────────────┐
                                     │  D1 / R2     │
                                     └──────────────┘
```

Every `ctx` method is a proxy to the bridge. The bridge validates capabilities, scopes storage, and enforces host allowlists before touching any real resources.

### What the Sandbox Enforces

<Steps>

1. **Capability enforcement**

   If a plugin declares `capabilities: ["content:read"]`, it can call `ctx.content.get()` and `ctx.content.list()` -- nothing else. Attempting `ctx.content.create()` throws a permission error. The plugin cannot bypass this because it has no direct database access.

2. **Resource limits**

   Every hook or route invocation runs with hard limits:

   | Resource | Default | Enforced by |
   |---|---|---|
   | CPU time | 50ms | Worker Loader (V8 isolate abort) |
   | Subrequests | 10 per invocation | Worker Loader (V8 isolate abort) |
   | Wall-clock time | 30 seconds | EmDash runner (`Promise.race`) |
   | Memory | ~128MB | V8 platform ceiling |

   Exceeding CPU or subrequest limits aborts the isolate. Exceeding wall-time rejects the invocation promise.

3. **Network isolation**

   Direct `fetch()` is blocked at the V8 level (`globalOutbound: null`). Plugins must use `ctx.http.fetch()`, which proxies through the bridge and validates the target host against the plugin's `allowedHosts` list.

4. **Storage scoping**

   All storage and KV operations are scoped to the plugin's ID. A plugin cannot read another plugin's data, and attempting to access undeclared storage collections throws an error.

5. **No environment access**

   Sandboxed plugins have no access to environment variables, the filesystem, or any host bindings. The V8 isolate context is clean.

</Steps>

### Wrangler Configuration

Sandboxing requires Dynamic Worker Loader. Add to your `wrangler.jsonc`:

```jsonc
{
	"worker_loaders": [{ "binding": "LOADER" }]
}
```

## How Native Plugins Work

Native plugins run in the same process as your Astro site. They're loaded from npm packages or local files and configured in `astro.config.mjs`:

```typescript title="astro.config.mjs"
import myPlugin from "@emdash-cms/plugin-analytics";

export default defineConfig({
	integrations: [
		emdash({
			plugins: [myPlugin()],
		}),
	],
});
```

In native mode:

- **Capabilities are advisory.** A plugin declaring `["content:read"]` can still access anything in the process. The `capabilities` field documents what the plugin *intends* to use, but nothing prevents it from importing modules, calling `fetch()` directly, or reading environment variables.
- **No resource limits.** CPU, memory, and network usage are unbounded.
- **Full process access.** The plugin shares the runtime with your Astro site.

<Aside type="caution">
	Only install native plugins from sources you trust -- npm packages you've reviewed, or code you've written yourself. A native plugin has the same access as your application code.
</Aside>

### Native Plugin Formats

Native plugins can use either of two formats:

<Tabs>
<TabItem label="Standard format (recommended)">
Standard format uses a simple `{ hooks, routes }` structure. The same code can run in both native and sandboxed mode. Metadata (id, version, capabilities) comes from the plugin descriptor, not from `definePlugin()`.

```typescript title="src/sandbox-entry.ts"
import { definePlugin } from "emdash";

export default definePlugin({
	hooks: {
		"content:afterSave": async (event, ctx) => {
			ctx.log.info("Content saved", { id: event.content.id });
		},
	},
	routes: {
		status: {
			handler: async (routeCtx, ctx) => {
				return { ok: true };
			},
		},
	},
});
```
</TabItem>
<TabItem label="Native format">
Native format includes id, version, capabilities, and admin configuration directly in `definePlugin()`. This format can only run as a native plugin -- it cannot be sandboxed or published to the marketplace.

```typescript title="src/index.ts"
import { definePlugin } from "emdash";

export default definePlugin({
	id: "my-plugin",
	version: "1.0.0",
	capabilities: ["content:read"],
	hooks: {
		"content:afterSave": async (event, ctx) => {
			ctx.log.info("Content saved", { id: event.content.id });
		},
	},
	routes: {
		status: {
			handler: async (ctx) => {
				return { ok: true };
			},
		},
	},
	admin: {
		entry: "@my-org/my-plugin/admin",
		settingsSchema: { /* ... */ },
	},
});
```
</TabItem>
</Tabs>

Use standard format unless you need native-only features (React admin pages, PT components, page fragments).

## Node.js Deployments

<Aside type="danger" title="No sandbox on Node.js">
	Node.js does not support plugin sandboxing. All plugins run as native plugins regardless of configuration. There is no V8 isolate boundary, no resource limits, and no capability enforcement at the runtime level.
</Aside>

When deploying to Node.js (or any non-Cloudflare platform):

- The `NoopSandboxRunner` is used -- `isAvailable()` returns `false`
- Attempting to load sandboxed plugins throws `SandboxNotAvailableError`
- All plugins must be registered as native plugins in the `plugins` array
- Capability declarations are purely informational

### Security Comparison by Platform

| Threat | Cloudflare (sandboxed) | Node.js (native only) |
|---|---|---|
| Plugin reads unauthorized data | Blocked by bridge capability checks | Not prevented -- full DB access |
| Unauthorized network calls | Blocked (`globalOutbound: null` + host allowlist) | Not prevented -- direct `fetch()` |
| CPU exhaustion | Isolate aborted by Worker Loader | Not prevented -- blocks the event loop |
| Memory exhaustion | Isolate terminated | Not prevented -- can crash the process |
| Env variable access | No access (isolated V8 context) | Not prevented -- shares `process.env` |
| Filesystem access | No filesystem in Workers | Not prevented -- full `fs` access |

For Node.js deployments, review native plugin source code before installing and use capability declarations as a review checklist.

## Choosing the Right Mode

**Start with sandboxed.** If your plugin uses hooks, routes, storage, and the standard context API, it works in the sandbox. Most plugins fit this model.

**Go native when you need to:**

- Ship custom React admin pages (not Block Kit)
- Provide Astro components for rendering PT block types on the public site
- Inject scripts or HTML into public pages via `page:fragments`
- Access Node.js APIs or environment variables directly
- Use npm dependencies that aren't bundleable into a single ES module

If you're unsure, build with the standard format. You can always add native-only features later, but going the other direction -- native to sandboxed -- is harder because native-only features don't have sandbox equivalents.

## Same Code, Different Guarantees

A plugin written in standard format runs identically in both modes. What changes is the enforcement layer:

```typescript
// This plugin works as both native and sandboxed
export default definePlugin({
	hooks: {
		"content:afterSave": async (event, ctx) => {
			// Native mode: ctx.http is always present (capabilities not enforced)
			// Sandboxed mode: ctx.http is present because the descriptor declares "network:request"
			await ctx.http.fetch("https://api.analytics.example.com/track", {
				method: "POST",
				body: JSON.stringify({ contentId: event.content.id }),
			});
		},
	},
});
```

Develop locally as a native plugin for faster iteration. Publish to the marketplace as a sandboxed plugin for production. No code changes required.
