API Routes
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. Native plugins use the same route options, authentication, and URL layout, but their handlers receive one combined context object. See Your first native plugin for that signature.
Defining routes
Section titled “Defining routes”Declare routes in the default export of src/plugin.ts. Add zod as a runtime dependency when a route validates input or is exposed as an MCP tool:
pnpm add zodThe following example validates a submissions request and queries plugin storage:
import type { SandboxedPlugin } from "emdash/plugin";import { z } from "zod";
const submissionsInput = z.object({ formId: z.string().optional(), limit: z.coerce.number().int().min(1).max(100).default(50), cursor: z.string().optional(),});
const plugin: SandboxedPlugin = { routes: { status: { handler: async (_routeCtx, ctx) => { return { ok: true, plugin: ctx.plugin.id }; }, },
submissions: { handler: async (routeCtx, ctx) => { const parsed = submissionsInput.safeParse(routeCtx.input); if (!parsed.success) { return { ok: false, error: { code: "VALIDATION_ERROR" } }; } const { formId, limit, cursor } = parsed.data;
const result = await ctx.storage.submissions.query({ where: formId ? { formId } : undefined, orderBy: { createdAt: "desc" }, limit, cursor, });
return { ok: true, ...result }; }, }, },};
export default plugin;The SandboxedPlugin annotation infers the route and plugin context types, so the parameters need no annotations. Sandboxed route handlers take two arguments: (routeCtx, ctx).
routeCtxcarries request-shaped data:{ input, request, requestMeta }. Itsinputremainsunknown, so validate it before use.ctxis the samePluginContextyou get inside hooks —ctx.storage,ctx.kv,ctx.content,ctx.http,ctx.log, etc.
Filtering indexed content fields
Section titled “Filtering indexed content fields”Plugins with the content:read capability can filter custom fields that a collection marks as
indexed. Filters run in the database and combine with AND semantics:
const result = await ctx.content.list("items", { where: { fieldFilters: { priority: { in: ["urgent", "high"] }, score: { gte: 80 }, resolved: false, }, },});Scalar values use exact matching. Use null for null matching, { in: [...] } for a set of exact
values, or gt, gte, lt, and lte for range comparisons. EmDash rejects filters for fields
that are not indexed, values that do not match the field type, and more than 20 field filters per
query. An in filter accepts at most 50 values, and all exact values, range bounds, and in
members together have a 50-operand budget per query. Null matches do not consume that budget.
Route URLs
Section titled “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
Section titled “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", handler: async (routeCtx, ctx) => { // Validate routeCtx.input, then create content through ctx. }, },},Private routes require their declared permission for every HTTP method. They also require the X-EmDash-Request: 1 CSRF header for cookie-authenticated requests, including GET and HEAD, because a plugin route can run the same handler for any method. The admin UI sends the header automatically. Token-authenticated requests are exempt from the header but still need the admin token scope and the route permission.
To opt a route out of authentication, mark it public: true:
routes: { track: { public: true, handler: async (routeCtx, ctx) => { const parsed = z.object({ event: z.string() }).safeParse(routeCtx.input); if (!parsed.success) return { ok: false, error: "INVALID_EVENT" }; ctx.log.info("Tracked", { event: parsed.data.event }); return { ok: true }; }, },},The authenticated caller
Section titled “The authenticated caller”On private routes, routeCtx.user is the authenticated user making the request — resolved and authorized by EmDash before your handler runs, so you can trust it for per-user logic (per-user API keys, OAuth connections, plugin-managed preferences):
routes: { "connect/start": { handler: async (routeCtx, ctx) => { // Never read the acting user from the request body — any authenticated // session could impersonate another user that way. Use routeCtx.user. const caller = routeCtx.user; if (!caller) throw new Error("No caller bound"); await ctx.kv.set(`user:${caller.id}:connection`, { startedAt: Date.now() }); return { userId: caller.id }; }, },},routeCtx.user is undefined on public routes (they skip auth, so no caller is bound — even when the visitor happens to have an admin session) and for token-authed requests where the token isn’t bound to a user (machine tokens). The shape matches the UserInfo returned by ctx.users: { id, email, name, role, createdAt } — no sensitive fields.
Note that caller identity is separate from the users:read capability: routeCtx.user tells you who is calling and is always available on private routes, while ctx.users is a user-directory lookup that requires the capability.
Exposing a route as an MCP tool
Section titled “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(),});
const plugin: SandboxedPlugin = { routes: { "events/create": { permission: "content:create", handler: async (routeCtx, ctx) => { const parsed = createEventInput.safeParse(routeCtx.input); if (!parsed.success) return { ok: false, error: "INVALID_EVENT" }; const input = parsed.data; return { id: await createEvent(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, }, }, },};
export default plugin;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
Section titled “Input validation”EmDash parses JSON request bodies for POST, PUT, and PATCH. It parses query parameters for GET, HEAD, and DELETE. The parsed value reaches a sandboxed handler as routeCtx.input: unknown, so validate it inside the handler before reading fields or performing side effects.
Use safeParse when invalid input is an expected caller error. This lets the route return a stable JSON result instead of turning invalid input into an internal exception:
const createInput = 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(),});
routes: { create: { handler: async (routeCtx, ctx) => { const parsed = createInput.safeParse(routeCtx.input); if (!parsed.success) { return { ok: false, error: { code: "VALIDATION_ERROR" } }; } const { title, email, priority, tags } = parsed.data;
await ctx.storage.items.put(`item_${Date.now()}`, { title, email, priority, tags: tags ?? [], createdAt: new Date().toISOString(), });
return { ok: true }; }, },},Query-string input (GET/HEAD/DELETE)
Section titled “Query-string input (GET/HEAD/DELETE)”Bodyless methods have no request body, so their input comes from the URL query string. Every value is a string. Repeated keys become arrays, so ?tag=a&tag=b becomes { tag: ["a", "b"] }; a single ?tag=a remains { tag: "a" }. Use z.coerce for numbers and other non-string values:
const listInput = z.object({ status: z.enum(["open", "closed"]).optional(), limit: z.coerce.number().int().min(1).max(100).default(20), tag: z.union([z.string(), z.array(z.string())]).optional(),});
routes: { list: { // GET /_emdash/api/plugins/<slug>/list?status=open&limit=20&tag=a&tag=b handler: async (routeCtx, ctx) => { const parsed = listInput.safeParse(routeCtx.input); if (!parsed.success) return { ok: false, error: "INVALID_QUERY" }; const { status, limit, tag } = parsed.data; // ... }, },},Return values
Section titled “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
Section titled “Errors”Throw when a sandboxed route cannot complete. EmDash logs the exception and returns a ROUTE_ERROR. The thrown message may be included in that response, so never put credentials, personal data, internal paths, or stack traces in an exception message:
handler: async (_routeCtx, ctx) => { try { return await refreshRemoteIndex(ctx); } catch { ctx.log.error("Remote index refresh failed"); throw new Error("Remote index refresh failed"); }},Sandboxed plugin code cannot select an arbitrary HTTP status by throwing a Response; a Response does not cross every sandbox runner’s boundary as a structured error. EmDash assigns statuses to authentication, authorization, CSRF, and missing-route failures before the handler runs. Return a JSON result for expected validation and domain outcomes, and reserve exceptions for unexpected failures.
An expected error returned as JSON still uses the route’s successful HTTP response and appears inside EmDash’s outer { success: true, data: ... } envelope. Include a stable application-level code so clients can distinguish that outcome.
HTTP methods
Section titled “HTTP methods”The route name, not the HTTP method, selects a handler. If a route changes state, accept only the intended method before performing the mutation:
routes: { item: { handler: async (routeCtx, ctx) => { const parsed = z.object({ id: z.string() }).safeParse(routeCtx.input); if (!parsed.success) return { ok: false, error: "INVALID_ID" }; const { id } = parsed.data;
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: return { error: "METHOD_NOT_ALLOWED", allowed: ["GET", "DELETE"] }; } }, },},Accessing the request
Section titled “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") return { error: "POST_REQUIRED" };},Common patterns
Section titled “Common patterns”Settings and paginated data
Section titled “Settings and paginated data”Plugin settings use private routes, Block Kit forms, and ctx.kv. Settings provides the complete loading, validation, form, and secret-handling pattern.
Routes that list plugin data should return the cursor from ctx.storage.<collection>.query(). Storage pagination shows how to pass a cursor and drain multiple pages without exceeding the 100-item page maximum.
External API proxy
Section titled “External API proxy”Proxy a request to an external service through ctx.http (requires network:request capability and an entry in allowedHosts):
routes: { forecast: { handler: async (routeCtx, ctx) => { const parsed = z.object({ city: z.string().min(1) }).safeParse(routeCtx.input); if (!parsed.success) return { ok: false, error: "INVALID_CITY" }; 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=${encodeURIComponent(parsed.data.city)}`, { headers: { "X-API-Key": apiKey } }, );
if (!response.ok) { throw new Error(`Weather API error: ${response.status}`); } return response.json(); }, },},Calling routes from Block Kit
Section titled “Calling routes from Block Kit”Sandboxed plugins do not ship React code to the admin. Declare an admin route and return Block Kit responses. EmDash sends page_load, block_action, and form_submit interactions to that private route with the correct URL and CSRF header. Block Kit shows the interaction contract and a complete route.
Calling routes from queue and scheduled handlers
Section titled “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
Section titled “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 plus X-EmDash-Request: 1, or an API token with the admin scope. The following server-to-server request uses a token:
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
Section titled “Route context reference”The following interfaces summarize the portable values available to a sandboxed route handler:
// 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; // validate inside the handler before use request: SandboxedRequest; requestMeta?: unknown; user?: UserInfo; // authenticated caller on private routes; undefined on public routes}
interface UserInfo { id: string; email: string; name: string | null; role: number; createdAt: string;}
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.