Files
captoys/potluck/.agents/skills/creating-plugins/references/api-routes.md
T
dckc 4b4ef8fea6 capsite: potluck, a fresh EmDash site (cloudflare:blog out of the box)
pnpm create emdash@latest potluck --pm pnpm --install --yes, per
https://docs.emdashcms.com/ ("Create your first EmDash site"); the
--yes defaults are cloudflare + blog. Replaces the old madmode/
starter (never committed).

Generated-by: create-emdash 1.2.0, run by Claude Opus 5.5
2026-10-07 19:57:43 -05:00

12 KiB

API routes and MCP tools

Sandboxed plugin routes are mounted at /_emdash/api/plugins/<slug>/<route-name>. Define them in the typed default export from src/plugin.ts.

Define a route

import type { SandboxedPlugin } from "emdash/plugin";
import { z } from "zod";

const submissionInput = z.object({
	formId: z.string().min(1),
	limit: z.coerce.number().int().min(1).max(100).default(50),
});

const plugin: SandboxedPlugin = {
	routes: {
		submissions: {
			permission: "content:read",
			handler: async (routeCtx, ctx) => {
				const parsed = submissionInput.safeParse(routeCtx.input);
				if (!parsed.success) return { ok: false, error: "INVALID_INPUT" };

				const result = await ctx.storage.submissions.query({
					where: { formId: parsed.data.formId },
					limit: parsed.data.limit,
				});
				return { ok: true, ...result };
			},
		},
	},
};

export default plugin;

The handler receives two arguments:

interface SandboxedRouteContext {
	input: unknown;
	request: {
		url: string;
		method: string;
		headers: Record<string, string>;
	};
	requestMeta?: {
		ip: string | null;
		userAgent: string | null;
		referer: string | null;
		geo: { country: string | null; region: string | null; city: string | null } | null;
	};
	user?: UserInfo;
}

routeCtx.request is a serialized record in both sandbox runners, not a WHATWG Request. Header keys are lowercased. Read parsed request data from routeCtx.input; the body has already been consumed by the host.

The published authoring type currently declares requestMeta as unknown, although both runners send the normalized shape shown above. Narrow it before reading fields when TypeScript cannot infer the shape.

Input sources and validation

Undeclared routes keep the original input contract: the host parses JSON for POST, PUT, and PATCH, and parses the query string for GET, HEAD, and DELETE. Repeated query keys become arrays. Validate the resulting unknown value inside the handler.

Declare request.body as none, json, text, bytes, or form-data to select a parser. Bodies are buffered with a 1 MiB default maximum; maxBytes can raise the limit to 8 MiB. Use pluginRoute() from emdash/plugin to infer PluginRouteQuery, string, Uint8Array, or PluginFormData from the mode. JSON remains unknown for validation.

Form data accepts multipart and URL-encoded requests. The multipart limits are 100 parts, 1 MiB per part, and 255 UTF-8 bytes for a safe filename. The total request must also fit the route body limit.

Declare request header names explicitly in request.headers. Only those safe headers reach the handler. Credentials, cookies, Cloudflare Access headers, proxy authorization, Set-Cookie, and the EmDash CSRF header cannot be declared and never cross the sandbox boundary.

The plugin CLI probe currently does not retain a route entry's input schema, so do not rely on route-level Zod validation for a built sandboxed plugin. An MCP tool still requires its own Zod input schema.

Authentication, permission, and CSRF

Routes are private unless they set public: true.

A private route requires:

  • an authenticated session, or a token with the admin scope;
  • the route's declared EmDash RBAC permission, defaulting to plugins:manage;
  • X-EmDash-Request: 1 for cookie-authenticated calls, for every HTTP method.

The host resolves these checks before invoking the plugin. routeCtx.user then contains the authenticated caller for a user-bound request:

interface UserInfo {
	id: string;
	email: string;
	name: string | null;
	role: number;
	createdAt: string;
}

Caller identity is not gated by users:read; it identifies the current authorized caller. ctx.users is a directory lookup and does require users:read. routeCtx.user is absent on public routes and on machine-token calls without a bound user.

A public route skips authentication, permission, and token-scope checks. It is internet-facing, so validate input, check the intended HTTP method, and verify webhook signatures or shared tokens where applicable.

Public exposure is reviewed during installation. Adding a public route or changing a private route to public requires approval again on update.

HTTP methods

The route name selects one handler. Declare methods to have the host reject other methods with 405 Method Not Allowed and an Allow header before the handler runs:

methods: ["POST"],
handler: async (routeCtx, ctx) => {
	// Validate input, then mutate.
},

Routes without methods remain method-agnostic. Check the method inside legacy handlers that mutate state, or add a declaration.

Results and errors

Return a JSON-serializable value. The HTTP endpoint wraps it in EmDash's { success: true, data } envelope.

Return a stable application-level error object for expected validation and domain failures. Throw only for unexpected failures, and keep exception messages free of credentials, personal data, paths, and stack traces.

Declare response: "raw" for unwrapped responses. The handler must return pluginResponse() from emdash/plugin with a text or Uint8Array body. Raw response bodies are buffered up to 8 MiB. The response header allowlist is Accept-Ranges, Content-Disposition, Content-Encoding, Content-Language, Content-Range, Content-Type, ETag, Last-Modified, Location, and Retry-After. The host removes other plugin-supplied headers, adds security headers, and applies the route-owned cache policy.

Raw routes cannot return active same-origin types, including HTML, JavaScript or ECMAScript, XHTML, SVG, XML, CSS, WebAssembly, multipart/related, and multipart/x-mixed-replace. A raw route also cannot back an MCP tool. Do not return or throw a WHATWG Response; it is not the portable response wire.

Public caching

Core accepts cacheControl on a public route:

routes: {
	catalog: {
		public: true,
		cacheControl: "public, max-age=60, stale-while-revalidate=300",
		handler: async () => ({ items: [] }),
	},
},

The value is applied only to successful public GET and HEAD responses. Private responses, errors, and other methods remain private, no-store. The plugin CLI carries cacheControl through the probe, bundle manifest, registry artifact, and generated descriptor.

Request metadata

Both runners send the same normalized metadata:

  • ip: a trusted client address when the platform or operator configured a trusted proxy header, otherwise null;
  • userAgent: the User-Agent value, otherwise null;
  • referer: the Referer value, otherwise null;
  • geo: Cloudflare country, region, and city when available, otherwise null.

Do not treat userAgent, referer, or geographic values as authenticated identity. Use routeCtx.user for the caller.

Content reads

With content:read, both sandbox runners match the trusted read contract. ctx.content.get() and ctx.content.list() return content identity, slug, status, locale, data, created/updated/published/scheduled timestamps, and SEO metadata when enabled.

list() accepts limit, cursor, where, and orderBy. Field filters, status filters, ordering, and cursor pagination reach the host repository on both runners; they are not evaluated inside the plugin isolate. getTranslations() and getPublicUrl() use the same content:read authority. Collection discovery uses ctx.schema with schema:read. Read Content, schema, translations, and publication for the complete contract.

Publication actions

With content:publish, call getVersioned() before publish(), unpublish(), schedule(), or unschedule(), then pass the returned _rev. Each successful mutation returns the item and its next _rev. content:restore separately provides getTrashedVersioned() and restore() without granting ordinary content reads. These actions execute through the host runtime, including policy and after-hooks; a stale revision or recursive action rejects without changing the entry.

Redirects

Declare redirects:read to list redirect rules with cursor pagination and read one rule with its opaque _rev. Declare redirects:write to create, update, or delete rules; it implies read access and authorizes the plugin to change where visitors are sent.

Pass _rev back unchanged for update() and delete(). A stale value fails with CONFLICT; re-read the current rule before retrying. The revision tracks redirect configuration, not visitor hit counting. Redirect writes use the host redirect handlers, including path-pattern, destination-parameter, duplicate-source, and terminal-status validation. Self-loop and multi-hop-loop validation runs when a rule is created or its source or destination changes. An enabled-only update can reactivate a pre-existing loop, which the Redirects page reports. Plugin input cannot set the host-owned auto marker.

External HTTP responses

ctx.http.fetch() returns a buffered WHATWG Response in both sandbox runners. ok, status, statusText, headers, url, redirected, text(), json(), arrayBuffer(), blob(), and clone() use the standard Web API and preserve the same values across runners.

Request and response bodies are each limited to 8 MiB of decoded bytes. The bridge enforces the limit while reading the body rather than trusting Content-Length. Responses are buffered rather than streamed to plugin code.

Expose a route as an MCP tool

MCP exposure is explicit. The following tool calls the private route as <pluginId>__createEvent:

import type { SandboxedPlugin } from "emdash/plugin";
import { z } from "zod";

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) => {
				const parsed = createEventInput.safeParse(routeCtx.input);
				if (!parsed.success) return { ok: false, error: "INVALID_EVENT" };
				return { ok: true, id: crypto.randomUUID() };
			},
		},
	},
	mcp: {
		tools: {
			createEvent: {
				description: "Create a calendar event requested by the user.",
				route: "events/create",
				input: createEventInput,
				output: z.object({ ok: z.boolean(), id: z.string().optional() }),
				destructive: false,
			},
		},
	},
};

export default plugin;

An MCP tool must:

  • use a tool name containing only letters, digits, _, or -;
  • reference an existing private route;
  • reference a route with an explicit valid permission;
  • reference a JSON route, not a route with response: "raw";
  • declare an input Zod schema;
  • set destructive: true for deletion, overwrite, publishing, charging, or another difficult-to-reverse action.

The optional output schema becomes structured MCP output. The bundle converts both schemas to JSON Schema.

Installation and updates show the exact MCP tools for consent. Adding a tool or changing a route from private to public requires fresh approval. After installation, an administrator separately enables plugin MCP tools. A caller then needs the route permission and either the mcp:tools scope or mcp:tools:<pluginId>.

The production core parser, shared plugin-types parser, plugin CLI artifact, and generated descriptor preserve MCP declarations and route permission/cache metadata. @emdash-cms/plugin-test exposes the parsed manifest so tests can assert that transport. Its invokeRoute() still bypasses the HTTP catch-all, so use the host for plugin execution and bridge behavior, not route authorization, response caching, MCP registration, or consent behavior.