Plugins can expose API routes for their admin UI and external integrations. Routes are mounted under /_emdash/api/plugins/<slug>/<route-name> (the <slug> is the plugin’s slug field from emdash-plugin.jsonc — exposed at runtime as ctx.plugin.id) and run inside the sandbox runtime with the same PluginContext that hooks receive.
This page covers sandboxed plugins. The API surface for native plugins is the same; the only difference is the handler signature — see the note in Native plugins for details.
Defining routes
Declare routes in the default export of src/plugin.ts:
import type { SandboxedPlugin } from "emdash/plugin";
import { z } from "astro/zod";
export default {
routes: {
status: {
handler: async (_routeCtx, ctx) => {
return { ok: true, plugin: ctx.plugin.id };
},
},
submissions: {
input: z.object({
formId: z.string().optional(),
limit: z.number().default(50),
cursor: z.string().optional(),
}),
handler: async (routeCtx, ctx) => {
const { formId, limit, cursor } = routeCtx.input;
const result = await ctx.storage.submissions.query({
where: formId ? { formId } : undefined,
orderBy: { createdAt: "desc" },
limit,
cursor,
});
return result;
},
},
},
} satisfies SandboxedPlugin;
satisfies SandboxedPlugin infers routeCtx and ctx — no parameter annotations needed. Sandboxed route handlers take two arguments: (routeCtx, ctx).
routeCtxcarries request-shaped data:{ input, request, requestMeta }.ctxis the samePluginContextyou get inside hooks —ctx.storage,ctx.kv,ctx.content,ctx.http,ctx.log, etc.
Route URLs
Routes mount at /_emdash/api/plugins/<slug>/<route-name>. Route names can include slashes for nested paths.
| Plugin id | Route name | URL |
|---|---|---|
forms | status | /_emdash/api/plugins/forms/status |
forms | submissions | /_emdash/api/plugins/forms/submissions |
seo | settings/save | /_emdash/api/plugins/seo/settings/save |
analytics | events/recent | /_emdash/api/plugins/analytics/events/recent |
Authentication and CSRF
Plugin routes are authenticated by default. The dispatcher requires a session (or a token with the admin scope) before it’ll call your handler. Private routes default to the plugins:manage permission for backwards compatibility. Set permission to a narrower EmDash RBAC permission when the operation belongs to an existing content, media, schema, or settings capability:
routes: {
create: {
permission: "content:create",
input: z.object({ title: z.string() }),
handler: async (routeCtx, ctx) => {
// ...
},
},
},
Private routes require the X-EmDash-Request: 1 CSRF header for cookie-authenticated requests. The admin UI sends it automatically; token-authenticated requests are exempt.
To opt a route out of auth and CSRF, mark it public: true:
routes: {
track: {
public: true,
input: z.object({ event: z.string() }),
handler: async (routeCtx, ctx) => {
ctx.log.info("Tracked", { event: routeCtx.input.event });
return { ok: true };
},
},
},
Caching public responses
API responses default to Cache-Control: private, no-store. For public routes that serve the same data to everyone — a product catalog, a public search index — that means every page view pays a full round-trip to the origin. Public routes can opt in to CDN/browser caching with cacheControl:
routes: {
catalog: {
public: true,
cacheControl: "public, max-age=60, stale-while-revalidate=300",
handler: async (ctx) => listProducts(ctx),
},
},
The header is applied only to successful GET responses of public routes. Errors are never cached, other methods keep the default, and setting cacheControl on a private route has no effect — authenticated responses always stay private, no-store.
Exposing a route as an MCP tool
Plugins may explicitly expose selected private routes through EmDash’s MCP server. MCP exposure is never inferred from the route list:
const createEventInput = z.object({
title: z.string().min(1),
startsAt: z.string().datetime(),
});
export default {
routes: {
"events/create": {
permission: "content:create",
input: createEventInput,
handler: async (routeCtx, ctx) => {
return { id: await createEvent(routeCtx.input, ctx) };
},
},
},
mcp: {
tools: {
createEvent: {
description: "Create a calendar event when the user asks to add one.",
route: "events/create",
input: createEventInput,
output: z.object({ id: z.string() }),
destructive: false,
},
},
},
} satisfies SandboxedPlugin;
EmDash exposes this as <pluginId>__createEvent. The referenced route must be private and declare permission. Input schemas are required; output schemas are optional. Set destructive: true for tools that delete, overwrite, publish, charge, or otherwise perform a difficult-to-reverse action.
An administrator must separately enable a plugin’s MCP tools after reviewing their names, descriptions, routes, permissions, and destructive flags. Calling the tool then requires both the route permission and either the mcp:tools token scope or mcp:tools:<pluginId>.
Input validation
input accepts a Zod schema. The dispatcher parses the request body (POST/PUT/PATCH) or query string (GET/DELETE), validates it, and passes the typed result to your handler as routeCtx.input. Invalid input returns a 400 before your handler runs.
routes: {
create: {
input: z.object({
title: z.string().min(1).max(200),
email: z.string().email(),
priority: z.enum(["low", "medium", "high"]).default("medium"),
tags: z.array(z.string()).optional(),
}),
handler: async (routeCtx, ctx) => {
const { title, email, priority, tags } = routeCtx.input;
await ctx.storage.items.put(`item_${Date.now()}`, {
title,
email,
priority,
tags: tags ?? [],
createdAt: new Date().toISOString(),
});
return { success: true };
},
},
},
Return values
Return any JSON-serialisable value. The dispatcher wraps it in EmDash’s standard envelope ({ success: true, data: <your value> }) and serves it as application/json.
return { id: "abc", count: 42 }; // wrapped to { success: true, data: { id, count } }
return [1, 2, 3]; // wrapped to { success: true, data: [1, 2, 3] }
Errors
Throw to return an error response. Anything that isn’t a known plugin error returns a generic message — internal exceptions are masked rather than leaking stack traces or database errors:
handler: async (routeCtx, ctx) => {
const item = await ctx.storage.items.get(routeCtx.input.id);
if (!item) {
throw new Error("Item not found");
}
return item;
},
For a specific status code, throw a Response:
handler: async (routeCtx, ctx) => {
const item = await ctx.storage.items.get(routeCtx.input.id);
if (!item) {
throw new Response(JSON.stringify({ error: "Not found" }), {
status: 404,
headers: { "Content-Type": "application/json" },
});
}
return item;
},
HTTP methods
Routes respond to all methods. Branch on routeCtx.request.method if you need per-method behaviour:
routes: {
item: {
input: z.object({ id: z.string() }),
handler: async (routeCtx, ctx) => {
const { id } = routeCtx.input;
switch (routeCtx.request.method) {
case "GET":
return await ctx.storage.items.get(id);
case "DELETE":
await ctx.storage.items.delete(id);
return { deleted: true };
default:
throw new Response("Method not allowed", { status: 405 });
}
},
},
},
Accessing the request
routeCtx.request is a SandboxedRequest: a portable { url, method, headers } record that behaves identically in-process and inside an isolate. headers is a Record<string, string> keyed by lowercased header name — index it by the lowercased name, or iterate with Object.entries. url is a string, so new URL(request.url) parses query params. routeCtx.requestMeta carries IP, user agent, and geo data normalised across platforms when available.
handler: async (routeCtx, ctx) => {
const { request, requestMeta } = routeCtx;
const auth = request.headers["authorization"]; // lowercased key, no .get()
const url = new URL(request.url);
const page = url.searchParams.get("page");
ctx.log.info("Request", { meta: requestMeta });
if (request.method !== "POST") {
throw new Response("POST required", { status: 405 });
}
},
Common patterns
Settings via KV
Sandboxed plugins read and write settings through the KV store, conventionally under a settings: prefix. The auto-generated settingsSchema form is native-only — for sandboxed plugins, expose the read/write through routes and render the form in Block Kit.
routes: {
settings: {
handler: async (_routeCtx, ctx) => {
const settings = await ctx.kv.list("settings:");
const result: Record<string, unknown> = {};
for (const entry of settings) {
result[entry.key.replace("settings:", "")] = entry.value;
}
return result;
},
},
"settings/save": {
input: z.object({
enabled: z.boolean().optional(),
apiKey: z.string().optional(),
maxItems: z.number().optional(),
}),
handler: async (routeCtx, ctx) => {
for (const [key, value] of Object.entries(routeCtx.input)) {
if (value !== undefined) {
await ctx.kv.set(`settings:${key}`, value);
}
}
return { success: true };
},
},
},
Paginated list
Return cursor-based pagination from a storage query — the response shape matches what the rest of EmDash uses:
routes: {
list: {
input: z.object({
limit: z.number().min(1).max(100).default(50),
cursor: z.string().optional(),
status: z.string().optional(),
}),
handler: async (routeCtx, ctx) => {
const { limit, cursor, status } = routeCtx.input;
const result = await ctx.storage.items.query({
where: status ? { status } : undefined,
orderBy: { createdAt: "desc" },
limit,
cursor,
});
return {
items: result.items.map((item) => ({ id: item.id, ...item.data })),
cursor: result.cursor,
hasMore: result.hasMore,
};
},
},
},
External API proxy
Proxy a request to an external service through ctx.http (requires network:request capability and an entry in allowedHosts):
routes: {
forecast: {
input: z.object({ city: z.string() }),
handler: async (routeCtx, ctx) => {
if (!ctx.http) throw new Error("Network capability not granted");
const apiKey = await ctx.kv.get<string>("settings:apiKey");
if (!apiKey) throw new Error("API key not configured");
const response = await ctx.http.fetch(
`https://api.weather.example.com/forecast?city=${routeCtx.input.city}`,
{ headers: { "X-API-Key": apiKey } },
);
if (!response.ok) {
throw new Error(`Weather API error: ${response.status}`);
}
return response.json();
},
},
},
Calling routes from the admin UI
Use usePluginAPI() from the admin package — it adds the X-EmDash-Request CSRF header and the plugin id prefix automatically:
import { usePluginAPI } from "@emdash-cms/admin";
function SettingsPage() {
const api = usePluginAPI();
const handleSave = async (settings) => {
await api.post("settings/save", settings);
};
const loadSettings = async () => {
return api.get("settings");
};
}
Calling routes from queue and scheduled handlers
Platform-event handlers (a Cloudflare Queue consumer, a custom scheduled() handler) have no HTTP request and therefore no locals.emdash. Use withEmDashRuntime() from emdash/middleware to get the runtime directly and invoke a plugin route without a request:
import { withEmDashRuntime } from "emdash/middleware";
export default {
// ... fetch/scheduled from @emdash-cms/cloudflare/worker
async queue(batch: MessageBatch) {
await withEmDashRuntime(async (runtime) => {
for (const message of batch.messages) {
const result = await runtime.handlePluginApiRoute(
"my-plugin",
"POST",
"/finishJob",
new Request("https://internal/", {
method: "POST",
body: JSON.stringify(message.body),
}),
);
if (result.success) message.ack();
else message.retry();
}
});
},
};
This resolves the same cached runtime that request handlers use, so plugin storage, hooks, and media access all behave exactly as they do during a request. On connection-backed database adapters (e.g. Postgres over Hyperdrive) the callback runs under an event-scoped connection that is committed and closed when it returns.
Calling routes externally
Public routes are callable directly:
curl -X POST https://your-site.com/_emdash/api/plugins/forms/track \
-H "Content-Type: application/json" \
-d '{"event": "pageview"}'
Private routes need session credentials or an API token with the admin scope:
curl -X POST https://your-site.com/_emdash/api/plugins/forms/create \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{"title": "Hello", "email": "user@example.com"}'
Route context reference
// What sandboxed route handlers receive as their two arguments
interface SandboxedRequest {
url: string;
method: string;
headers: Record<string, string>; // lowercased keys
}
interface SandboxedRouteContext {
input: unknown; // narrow with the `input` Zod schema at the route level
request: SandboxedRequest;
requestMeta?: unknown;
}
interface PluginContext {
plugin: { id: string; version: string };
storage: PluginStorage;
kv: KVAccess;
log: LogAccess;
site: SiteInfo;
url(path: string): string;
cron?: CronAccess;
content?: ContentAccess; // when content:read or content:write declared
taxonomies?: TaxonomyAccess; // when taxonomies:read declared
media?: MediaAccess; // when media:read or media:write declared
http?: HttpAccess; // when network:request declared
users?: UserAccess; // when users:read declared
email?: EmailAccess; // when email:send declared and provider configured
}
Native plugins receive a single RouteContext argument that combines the two — see Creating native plugins if you’re going that route.