Block Kit
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
Section titled “How it works”- The user navigates to a plugin’s admin page.
- The admin sends a
page_loadinteraction to the plugin’s admin route. - The plugin returns a
BlockResponsecontaining an array of blocks. - The admin renders the blocks using the
BlockRenderercomponent. - When the user interacts (clicks a button, submits a form), the admin sends the interaction back to the plugin.
- 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 zodDeclare 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
Section titled “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.
Navigation links
Section titled “Navigation links”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; andexternal, with an absolute HTTP, HTTPS, ormailto: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.
Saved-entry panels and actions
Section titled “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
Section titled “Block types”| Type | Description |
|---|---|
header |
Large bold heading |
section |
Text with optional accessory element |
divider |
Horizontal rule |
fields |
Two-column label/value grid |
table |
Data table with formatting, sorting, pagination |
actions |
Horizontal row of buttons and controls |
stats |
Dashboard metric cards with trend indicators |
form |
Input fields with conditional visibility and submit |
image |
Block-level image with alt text and an optional title |
context |
Small muted help text |
columns |
2–3 column layout with nested blocks |
empty |
Empty-state title with an optional description, command, and action buttons |
accordion |
Collapsible section wrapping nested blocks |
chart |
Line or bar time series, or a chart with custom options |
banner |
Status or alert message with a title or description |
meter |
Numeric value displayed against a minimum and maximum |
code |
Read-only TypeScript, TSX, JSONC, Bash, or CSS code |
tab |
Labelled panels containing nested blocks |
Element types
Section titled “Element types”| Type | Description |
|---|---|
button |
Action button with optional confirmation dialog |
link |
Host-resolved internal or external navigation |
text_input |
Single-line or multiline text input |
number_input |
Numeric input with min/max |
select |
Dropdown select |
toggle |
On/off switch |
secret_input |
Masked input for API keys and tokens |
checkbox |
Select several values from a fixed list |
combobox |
Searchable single-value selection |
date_input |
Date value |
radio |
Single 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
Section titled “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
Section titled “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
Section titled “Try it”Use the Block Playground to interactively build and test block layouts.