Action buttons for EmDash fields and dashboards.
@bnomei/emdash-actions adds configurable buttons to the EmDash admin. A button
can copy a field value in the browser, call an EmDash plugin route, write a
returned value back into a field, or trigger a dashboard-level provider action.
The core split is simple:
emdash-actionsowns the admin UI trigger surfaces.- Provider plugins own the backend work behind those triggers.
That means a provider can be native, standard trusted, or sandboxed, as long as it exposes normal EmDash plugin API routes.
npm install @bnomei/emdash-actionsThis quick start wires a single dashboard button that runs backend TypeScript in
an EmDash provider route. The example action clears a cache placeholder, writes
plugin-local state with ctx.kv, logs through ctx.log, and returns feedback to
the clicked button.
Create src/emdash/cache-actions.ts:
import { definePlugin, type RouteContext } from "emdash";
import { defineActionsManifest, type ActionsManifest } from "@bnomei/emdash-actions";
export function cacheActions() {
return definePlugin({
id: "cache-actions",
version: "0.1.0",
routes: {
actions: {
handler: cacheActionsManifestRoute,
},
"cache/clear": {
handler: clearCacheRoute,
},
},
});
}
async function cacheActionsManifestRoute(): Promise<ActionsManifest> {
return defineActionsManifest({
actions: [
{
id: "cache.clear",
label: "Clear cache",
route: "cache/clear",
method: "POST",
placement: "dashboard",
icon: "bolt",
tone: "warning",
confirm: "Clear the site cache?",
},
],
});
}
async function clearCacheRoute(ctx: RouteContext) {
const serverTime = new Date().toISOString();
const clearCount = ((await ctx.kv.get<number>("state:clearCount")) ?? 0) + 1;
await clearSiteCache(ctx);
await ctx.kv.set("state:clearCount", clearCount);
ctx.log.info("Cache clear action completed", {
clearCount,
site: ctx.site.name,
serverTime,
});
return {
ok: true,
status: 200,
severity: "success",
message: `Cache cleared on the server at ${serverTime}.`,
toast: {
type: "success",
title: "Cache cleared",
message: `${ctx.site.name} cache has been cleared ${clearCount} time(s).`,
},
};
}
async function clearSiteCache(ctx: RouteContext) {
// Replace this with your cache provider, CDN, build, or deployment logic.
await ctx.kv.set("state:lastClearedAt", new Date().toISOString());
}Add actionsPlugin() and the provider plugin to the EmDash plugin list in
astro.config.mjs:
import { defineConfig } from "astro/config";
import emdash from "emdash/astro";
import { actionsPlugin } from "@bnomei/emdash-actions";
import { cacheActions } from "./src/emdash/cache-actions";
const cacheActionsProvider = {
pluginId: "cache-actions",
label: "Cache actions",
manifestRoute: "actions",
};
export default defineConfig({
integrations: [
emdash({
plugins: [
actionsPlugin({
providers: [cacheActionsProvider],
}),
cacheActions(),
],
}),
],
});Open the EmDash dashboard. The Actions widget loads:
GET /_emdash/api/plugins/cache-actions/actionsIt renders Clear cache. Clicking the button calls:
POST /_emdash/api/plugins/cache-actions/cache/clearThe provider route runs on the server, uses EmDash ctx, and returns the inline
button feedback plus toast.
Note
This quick start uses a dashboard action because it shows the whole provider loop without needing collection schema changes. Field buttons use the same provider route model.
| Goal | Start here |
|---|---|
| Copy a value without backend code | Clipboard Field Button |
| Call one backend route from a field | Direct Route Field Action |
| Use provider-owned action UI and routes | Manifest Field Action |
| Use one safe provider-owned runner route | Runner Field Action |
| Add a runner-backed dashboard action | Runner Dashboard Action |
| Collect a few scalar inputs before submit | Inline Form Action |
| Target a host-provided nested row | Row Target Action |
| Add a dashboard action | Dashboard Action |
| Return clipboard, open, download, or reload effects | Response effect examples |
| Show a Kumo toast | Toast Notification |
| Patch a clicked button after success | Action Patch |
| Poll long-running work | Async Job |
| Run the provider in a sandbox | Sandboxed Provider |
The full recipe index is in examples.
actions:button: field widget for content forms. It supportsclipboardmode for browser-native copying andrunmode for provider-backed actions.Actions: dashboard widget for provider/global actions. It reads configured provider manifests and renders matching action buttons.
Every action must have a human-readable label. The action label is the
user-facing command text for the rendered button. icon is optional decoration
only; it must not be the only visible affordance for the action.
Field labels and action labels describe different things. The collection field
label names the field or slot, while the manifest action label or field
options.label names the command the button runs. They may match, but the
button still renders the resolved action label as visible text.
Idle buttons show the resolved action label. The button title, tooltip text,
and aria-label use that same resolved label. Feedback, progress, success, and
error text may temporarily replace or supplement the label after interaction,
but the idle state should always remain clear.
A provider manifest describes buttons:
{
actions: [
{
id: "cache.clear",
label: "Clear cache",
route: "cache/clear",
method: "POST",
placement: "dashboard",
},
],
}Use clear labels for every action surface:
{
actions: [
{
id: "cache.clear",
label: "Clear cache",
route: "cache/clear",
method: "POST",
placement: "dashboard",
target: { surfaces: ["dashboard"] },
icon: "bolt",
},
{
id: "entry.rebuild",
label: "Rebuild entry",
runner: { route: "actions/run-entry" },
placement: "entry",
target: { surfaces: ["entry"], idFrom: "entryId" },
icon: "refresh",
},
{
id: "field.summarize",
label: "Summarize field",
runner: true,
placement: "field",
target: { surfaces: ["field"], idFrom: "entryId" },
icon: "bolt",
},
{
id: "row.translate",
label: "Translate row",
runner: true,
placement: "field",
target: { surfaces: ["row"], idFrom: "rowId" },
icon: "repeat",
},
],
}emdash-actions loads the manifest from a provider route such as:
GET /_emdash/api/plugins/cache-actions/actionsClicking the action calls the manifest route target:
POST /_emdash/api/plugins/cache-actions/cache/clearField buttons can also skip the manifest and call a direct route from field
options. Dashboard discovery uses actionsPlugin({ providers }).
Manifest caching: the dashboard widget fetches provider manifests once per mount (and again when the target surface changes). Manifests are treated as deployment-static, so adding or changing actions server-side mid-session is picked up after a page reload rather than automatically. This is intentional — background refetching would discard in-memory action patches (toggle labels, updated payloads) and churn the action list. During provider development, reload the admin page to see manifest changes.
Manifest actions can also use runner mode:
{
actions: [
{
id: "field.summarize",
runner: true,
label: "Summarize",
placement: "field",
target: { surfaces: ["field"], idFrom: "entryId" },
payload: { format: "short" },
form: {
mode: "inline",
fields: [{ name: "format", type: "select", options: ["short", "long"] }],
},
},
],
}Runner actions always call the provider-owned runner route, defaulting to:
POST /_emdash/api/plugins/<provider>/.well-known/actions/runwith a normalized invocation:
{
"invocationId": "inv_...",
"actionId": "field.summarize",
"payload": {
"format": "long"
},
"target": {
"type": "field",
"surface": "field",
"collection": "posts",
"entryId": "post-1",
"fieldName": "summary",
"value": "Current field value"
}
}Use direct route mode when the browser should call one explicit provider route
such as field/slugify. Use runner mode when the provider should keep a fixed
server-side action registry and avoid exposing one callable route per button.
Runner providers must treat actionId as an identifier only, look it up in that
registry, authorize it server-side, and re-read target documents before mutating
content or protected state.
Runner actions may use the provider default runner route or override it per action:
{ id: "entry.rebuild", runner: { route: "actions/run-entry" }, label: "Rebuild" }payload is only static action input defaults. Inline form values and field
option payload values are merged into the request payload at submit time, and
user-provided form values win over defaults. context and target stay
top-level in runner invocations.
target.idFrom and target.idKeys can produce client-side missing-target
warnings before a request is sent. They are ergonomics only: provider runners
must re-read and validate the authoritative target server-side.
Important
Field JSON belongs in the target collection schema's fields array. It does
not go inside actionsPlugin() or astro.config.mjs.
Warning
Provider routes must be relative plugin routes such as cache/clear or
.well-known/actions. Absolute URLs, query strings, hashes, encoded paths,
traversal segments, spaces, and backslashes are rejected.
Provider routes return JSON that controls the clicked button and optional browser effects:
message,severity,color,backgroundColor, andborderColordrive temporary inline feedback.actionpatches the stable clicked button descriptor, useful for toggles.effectsor top-level aliases can runclipboard,open,download, andreload.effects.reloadacceptstrueor{ scope, delayMs }, wherescopeisfield,entry,dashboard, orpage. Scoped reloads dispatch the cancelableemdash-actions:reloadbrowser event so hosts can refresh a narrower surface; if unhandled, they fall back to a page reload.toastshows a Kumo toast.status: 202,jobStatus, andstatusRoutestart async polling.- An action result body with
status: 409,severity: "warning", and a reload effect is the canonical stale-target conflict result. Return this as the normal JSON action response body so the widget can run result effects. resultValueKeyon a field button can write a returned value back into the field.
See the focused response recipes for complete payloads: Clipboard Effect, Open Effect, Download Effect, Toast Notification, Feedback And Colors, Action Patch, and Async Job.
Field buttons can use inferred entry context. For direct-route actions,
contextKey and contextValueKey merge that context into the flat request
payload for compatibility. Runner actions keep the same context top-level in the
ActionInvocation body instead of hiding it inside payload.
Without a host-provided field-widget context, the plugin infers what it can from the admin route and saved EmDash APIs: collection, entry id, new/edit state, field name, current field value, saved entry data, and current user when available.
Warning
Entry context is best-effort. It cannot infer live unsaved values from sibling
fields. Pass the current field value with valueKey, use static payload, or
have the provider read the latest saved entry server-side.
emdash-actions is a native EmDash UI plugin and belongs in plugins: [].
Provider plugins can use different formats:
- Native providers use
definePlugin()fromemdashand receive one mergedRouteContextargument. - Standard trusted providers can expose the same manifest and routes in
plugins: []. - Standard sandboxed providers export a
SandboxedPluginroute map, run insandboxed: [], and receive(routeCtx, ctx).
See Sandboxed Provider for the sandboxed shape and caveats.
providers: provider plugins the dashboard widget should discover.placement: dashboard manifest placement to render. Defaults todashboard. Set tonullto show all placements.title: dashboard widget title. Defaults toActions.size: dashboard widget size,full,half, orthird.entrypointandadminEntry: advanced descriptor overrides.
pluginId: provider plugin id.label: human-readable provider label. This names the provider, not an action button.manifestRoute: provider route that returns the manifest. Defaults to.well-known/actions.runnerRoute: provider route forrunneractions. Defaults to.well-known/actions/run.allowedTargetPluginIds: cross-plugin route targets this provider may call.
The most common field options are:
mode:clipboardorrun.providerorpluginId: provider plugin id.route: direct provider route.action: provider manifest action id.label: field-local button command text. Withaction, this overrides the manifest action label for this field. Without it, manifest-backed buttons use the manifest action label.description,icon,tone,confirm: button UI.iconis optional decoration and the text label still renders.payload: static JSON body values.valueKey: include the current field value in the request body.contextKeyandcontextValueKey: include inferred context in direct-route payloads. Runner actions receive context top-level.resultValueKey: write a returned result value back into the field.clipboardText,clipboardValueKey,clipboardContextValueKey,clipboardSuccess: clipboard mode.feedback,buttonStyle,cooldownMs: temporary feedback and styling.resultEffect,pollIntervalMs,pollTimeoutMs: response shortcuts and async polling.
The exported TypeScript contracts live in src/types.ts.
Note
Clipboard mode uses navigator.clipboard.writeText() in the browser. It does
not call the backend, and browsers require HTTPS or localhost plus clipboard
permission.
Caution
Use confirm for destructive dashboard actions such as cache clearing,
maintenance toggles, deploys, exports, or purge operations.
- Direct field routes can specify
options.providerandoptions.routein the field JSON. They do not need to be listed inactionsPlugin({ providers })unless the dashboard should discover them too. - Cross-plugin target routes require
allowedTargetPluginIds; otherwise a provider manifest can only target its own plugin id. DELETEactions do not receive a JSON body, so payload, value, and context keys are useful withPOST,PUT, andPATCH.- Protected downloads can use
effects.download.routeto fetch through the action target plugin with EmDash auth headers. Sandboxed providers should prefer public or signed URLs for binary downloads, or delegate streaming to a trusted native route. - Async jobs need a provider-owned
statusRoute. Without one, the widget can show that work was accepted but cannot infer queued, running, failed, or completed state.
npm ci
npm run check
npm run typecheck
npm run build
npm run pack:check