Block Kit

On this page

EmDash’s Block Kit lets sandboxed plugins describe their admin UI as JSON. The host renders the blocks — no plugin-supplied JavaScript ever runs in the browser.

How it works

  1. The user navigates to a plugin’s admin page.
  2. The admin sends a page_load interaction to the plugin’s admin route.
  3. The plugin returns a BlockResponse containing an array of blocks.
  4. The admin renders the blocks using the BlockRenderer component.
  5. When the user interacts (clicks a button, submits a form), the admin sends the interaction back to the plugin.
  6. The plugin returns new blocks, and the cycle repeats.

Add @emdash-cms/blocks and zod to the plugin when it defines a Block Kit page:

pnpm add @emdash-cms/blocks zod

Declare the page in the plugin manifest so the admin has a navigation entry to load:

"admin": {
	"pages": [{ "path": "/settings", "label": "Settings", "icon": "settings" }],
}

The following admin route validates the interaction, renders a form on page load, and stores its values on submit:

import type { SandboxedPlugin } from "emdash/plugin";
import type { BlockResponse } from "@emdash-cms/blocks";
import { z } from "zod";

const interactionSchema = z.discriminatedUnion("type", [
	z.object({ type: z.literal("page_load"), page: z.string() }),
	z.object({
		type: z.literal("block_action"),
		action_id: z.string(),
		block_id: z.string().optional(),
		value: z.unknown().optional(),
	}),
	z.object({
		type: z.literal("form_submit"),
		action_id: z.string(),
		block_id: z.string().optional(),
		values: z.object({ api_url: z.url(), enabled: z.boolean() }),
	}),
]);

function renderSettings(): BlockResponse {
	return {
		blocks: [
			{ type: "header", text: "Save Log settings" },
			{
				type: "form",
				block_id: "settings",
				fields: [
					{ type: "text_input", action_id: "api_url", label: "API URL" },
					{ type: "toggle", action_id: "enabled", label: "Enabled", initial_value: true },
				],
				submit: { label: "Save", action_id: "save" },
			},
		],
	};
}

const plugin: SandboxedPlugin = {
	routes: {
		admin: {
			handler: async (routeCtx, ctx) => {
				const parsed = interactionSchema.safeParse(routeCtx.input);
				if (!parsed.success) return { blocks: [] };
				const interaction = parsed.data;

				if (interaction.type === "page_load") {
					return renderSettings();
				}

				if (interaction.type === "form_submit" && interaction.action_id === "save") {
					await ctx.settings.set("apiUrl", interaction.values.api_url);
					await ctx.settings.set("enabled", interaction.values.enabled);
					return {
						...renderSettings(),
						toast: { message: "Settings saved", type: "success" },
					};
				}

				return { blocks: [] };
			},
		},
	},
};

export default plugin;

The admin route is private by default. EmDash sends the correct CSRF header when the admin calls it. The handler still validates routeCtx.input because its TypeScript type is unknown and a caller can invoke a private plugin route outside the Block Kit page.

EmDash validates every page and widget response before the admin renders it. An invalid block, unsafe URL, link to an undeclared plugin page, or response over the Block Kit limits fails the request instead of reaching the browser. A response can contain up to 256 KiB, 20 nested levels, 2,000 nodes, 1,000 items per array, and 64 KiB per string.

UI locale and direction

Read routeCtx.ui when a page or widget needs to return text for the administrator’s active locale. The host derives this value from the admin locale cookie or request language and verifies the requested page or widget against the plugin manifest.

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

const plugin: SandboxedPlugin = {
	routes: {
		admin: {
			handler: async (routeCtx) => {
				if (!routeCtx.ui) return { blocks: [] };

				const heading = routeCtx.ui.locale === "ar" ? "حالة المحتوى" : "Content status";
				return {
					blocks: [{ type: "header", text: heading }],
				};
			},
		},
	},
};

export default plugin;

routeCtx.ui contains the surface, locale, and text direction. The admin locale is separate from ctx.site.locale, which describes the site’s default content locale. Manifest labels remain static strings.

Use a link element to navigate without dispatching a Block Kit action. EmDash constructs internal URLs from structured targets, so plugins do not need to know admin route paths.

return {
	blocks: [
		{
			type: "actions",
			elements: [
				{
					type: "link",
					label: "Edit article",
					target: { kind: "content", collection: "posts", id: "01K5POSTEXAMPLE", locale: "en" },
					appearance: "primary",
				},
				{
					type: "link",
					label: "Plugin settings",
					target: { kind: "plugin-settings" },
				},
			],
		},
	],
};

The available targets are:

  • content, with a collection, saved entry ID, and optional content locale;
  • plugin-page, with a path declared by the same plugin;
  • plugin-settings; and
  • external, with an absolute HTTP, HTTPS, or mailto: URL.

External links open in a new tab with noopener noreferrer. Link elements do not accept action_id and cannot appear as form fields. Use a button when the interaction must call the plugin route.

Block images use the same browser-resource policy. Root-relative image URLs are allowed. An external image must use HTTPS and its hostname must appear in the plugin’s allowedHosts. A plugin with network:request:unrestricted can load an HTTPS image from any hostname. Other external images cause the complete Block Kit response to be rejected.

Row actions in tables

Set a table column’s format to element to place a button, link, or menu in each row. Each row stores the element under the column’s key; a row without a value leaves the cell empty. Use a menu element when a row offers several choices behind one button:

return {
	blocks: [
		{
			type: "table",
			page_action_id: "missing_page",
			columns: [
				{ key: "title", label: "Entry" },
				{ key: "languages", label: "Missing" },
				{ key: "action", label: "Actions", format: "element" },
			],
			rows: [
				{
					title: "Hello world",
					languages: "French, Italian",
					action: {
						type: "menu",
						action_id: "translate",
						label: "Translate",
						items: [
							{ label: "French", value: "fr:01K5POSTEXAMPLE" },
							{ label: "Italian", value: "it:01K5POSTEXAMPLE" },
						],
					},
				},
			],
		},
	],
};

Choosing a menu item sends a block_action with the menu’s action_id and the item’s value. Item values must be unique within a menu. Element cells accept only button, link, and menu elements. A menu can also appear in an actions block, as a section accessory, or in empty-state actions, but not as a form field. The elements.menu(actionId, label, items, { style }) builder returns the same shape.

Saved-entry panels and actions

Declare an editor panel when a plugin needs to show information beside a saved entry. Panels start collapsed and call their private route only when an editor opens them.

The following manifest adds a panel for posts and a confirmed repair action:

"admin": {
	"editorPanels": [
		{
			"id": "content-health",
			"title": "Content health",
			"route": "editor/content-health",
			"collections": ["posts"],
			"draft": {
				"read": { "translatable": true },
				"patch": { "fields": ["title", "excerpt", "body"] },
			},
		},
	],
	"editorActions": [
		{
			"id": "repair-metadata",
			"label": "Repair metadata",
			"route": "editor/repair-metadata",
			"placement": "overflow",
			"style": "danger",
			"confirm": {
				"title": "Repair metadata?",
				"text": "This changes the saved entry.",
				"confirm": "Repair",
				"deny": "Cancel",
			},
		},
	],
}

Each referenced route must be private. Its permission controls which editors can invoke the extension. The host also reloads the saved entry and checks its owner before calling the plugin.

Editor extension routes receive an attested routeCtx.ui value. For content-editor-panel and content-editor-action surfaces, routeCtx.ui.entry contains the collection, saved entry ID, content locale, and version. routeCtx.ui.extensionId identifies the selected declaration. Use ctx.content with the content:read capability when the plugin needs saved content.

A panel receives { type: "panel_load" } when it opens. Panel load never includes draft data. Its later button and form interactions use the usual block_action and form_submit shapes. When the plugin declares admin.editor-draft:read and the extension narrows draft.read, an explicit interaction also receives routeCtx.input.draft. The snapshot contains only selected current values, sanitized field definitions, saved identity, and the persisted base revision. Use fields for explicit slugs, translatable: true for the collection’s translatable fields, or both. Draft access requires an explicit collections list.

admin.editor-draft:patch is independent of read access. It permits a route to return a whole-field patch after an explicit interaction:

const draft = routeCtx.input.draft;

return {
	blocks: [],
	patch: {
		type: "editor-draft-patch",
		operations: [
			{ op: "set", field: "title", value: translate(draft.fields.title) },
			{ op: "clear", field: "excerpt" },
		],
	},
};

EmDash validates every operation together against the current server schema, capability, collection, field selector, locale, base revision, ownership, count limits, and byte limits. The browser repeats identity, generation, and field checks before showing a host-rendered preview. Applying the preview marks the form dirty and does not save, create a revision, or run hooks. Any edit made while the plugin is working rejects the complete result.

Saved-only editor actions remain disabled while the form has unsaved changes. Draft-aware actions can run against the unsaved form. An action receives { type: "editor_action" } and, when declared, the same bounded draft snapshot. Return an object containing an optional toast and at most one terminal effect:

return {
	toast: { type: "success", message: "Metadata repaired" },
	refresh: true,
};

Use refresh: true to reload the entry, navigate with a structured link target, or patch to propose unsaved field changes. A response cannot combine terminal effects. EmDash rejects unknown commands, unsafe navigation, invalid or stale patches, and responses over the Block Kit limits before applying an effect.

Block types

TypeDescription
headerLarge bold heading
sectionText with optional accessory element
dividerHorizontal rule
fieldsTwo-column label/value grid
tableData table with formatting, sorting, pagination
actionsHorizontal row of buttons and controls
statsDashboard metric cards with trend indicators
formInput fields with conditional visibility and submit
imageBlock-level image with alt text and an optional title
contextSmall muted help text
columns2–3 column layout with nested blocks
emptyEmpty-state title with an optional description, command, and action buttons
accordionCollapsible section wrapping nested blocks
chartLine or bar time series, or a chart with custom options
bannerStatus or alert message with a title or description
meterNumeric value displayed against a minimum and maximum
codeRead-only TypeScript, TSX, JSONC, Bash, or CSS code
tabLabelled panels containing nested blocks

Element types

TypeDescription
buttonAction button with optional confirmation dialog
linkHost-resolved internal or external navigation
menuButton that opens a list of choices; each choice dispatches an action
text_inputSingle-line or multiline text input
number_inputNumeric input with min/max
selectDropdown select
toggleOn/off switch
secret_inputMasked input for API keys and tokens
checkboxSelect several values from a fixed list
comboboxSearchable single-value selection
date_inputDate value
radioSingle choice from a visible option list

The Portable Text field editor also supports repeater and media_picker. They are not form fields for a sandboxed plugin admin page.

Builder helpers

The @emdash-cms/blocks package exports the same shapes through blocks and elements builder objects. Builders reduce property-name mistakes while returning ordinary JSON-compatible objects:

import { blocks, elements } from "@emdash-cms/blocks";

const { header, form } = blocks;
const { textInput, toggle, select, link } = elements;

return {
	blocks: [
		header("SEO Settings"),
		form({
			blockId: "settings",
			fields: [
				textInput("site_title", "Site Title", { initialValue: "My Site" }),
				toggle("generate_sitemap", "Generate Sitemap", { initialValue: true }),
				select("robots", "Default Robots", [
					{ label: "Index, Follow", value: "index,follow" },
					{ label: "No Index", value: "noindex,follow" },
				]),
			],
			submit: { label: "Save", actionId: "save" },
		}),
		blocks.actions([link("Open settings", { kind: "plugin-page", path: "/settings" })]),
	],
};

Conditional fields

Form fields can be conditionally shown based on other field values:

{
	"type": "toggle",
	"action_id": "auth_enabled",
	"label": "Enable Authentication"
}
{
	"type": "secret_input",
	"action_id": "api_key",
	"label": "API Key",
	"condition": { "field": "auth_enabled", "eq": true }
}

The api_key field only appears when auth_enabled is toggled on. Conditions are evaluated client-side with no round-trip.

secret_input uses has_value: true to show that a value already exists; it does not accept or return the stored value on page load. The field masks typing in the browser. Declare the matching key as type: "secret" in admin.settingsSchema and save it through ctx.settings so EmDash encrypts it. Follow Secret settings before storing credentials.

Try it

Use the Block Playground to interactively build and test block layouts.