Action buttons for EmDash fields and dashboards.
emdash-actions is a native EmDash UI plugin for rendering configurable action buttons. The primary target is contextual field actions: a button inside a regular content form that can copy a value, call a provider endpoint, or write a returned value back into the field. The package also includes a dashboard widget for global site actions such as clearing caches, triggering rebuilds, toggling maintenance mode, starting exports, or kicking off serverless jobs.
The UI package owns the trigger surfaces. Provider plugins own the work behind those triggers. That keeps existing standard, native, or sandboxed plugins independent from this package: they can expose normal EmDash API routes, and emdash-actions can render buttons that call those routes.
actions:button: A field widget for content forms. This is the Janitor-like surface and the main reason for the package. It supportsrunmode for backend actions andclipboardmode for browser-native copy buttons.Actionsdashboard widget: A dashboard surface for global/provider actions. It reads provider manifests and renders one button per matching action.
Field buttons become substantially more powerful with entry context. EmDash does not currently expose a stable field-widget context prop or global object, so emdash-actions uses a best-effort fallback: it reads the admin route, optionally fetches the saved entry and current user from EmDash APIs, and includes selected context in action payloads or clipboard values.
vp install @bnomei/emdash-actionsRegister the UI plugin in your EmDash project. This goes in the Astro config file where your emdash() integration is configured:
// astro.config.mjs
import { defineConfig } from "astro/config";
import emdash from "emdash/astro";
import { actionsPlugin } from "@bnomei/emdash-actions";
export default defineConfig({
integrations: [
emdash({
plugins: [actionsPlugin()],
}),
],
});This registers the actions:button field widget and the Actions dashboard widget. Clipboard field buttons work with only this registration.
Configure providers in the same astro.config.mjs plugin list when you want buttons to call backend routes or resolve manifest actions:
// astro.config.mjs
import { actionsPlugin } from "@bnomei/emdash-actions";
emdash({
plugins: [
actionsPlugin({
providers: [
{
pluginId: "site-tools",
label: "Site tools",
},
],
}),
],
});Use actions:button when an action belongs next to a field in a content form. This is the best fit for contextual tools such as copying or generating an ID, syncing the current field value, refreshing derived metadata, or calling a provider endpoint from a specific content template.
Clipboard mode example. This object is a field definition inside the target collection schema, not an Astro integration option. Put it wherever your project creates or seeds EmDash fields, for example in a seed file exported from emdash export-seed or a project-local schema setup script:
// seed/schema.ts or seed.json
// Inside the target collection's fields array:
{
slug: "newsletter_user_uuid",
label: "Newsletter user UUID",
type: "string",
widget: "actions:button",
options: {
mode: "clipboard",
label: "Copy UUID",
clipboardSuccess: "UUID copied."
}
}Clipboard mode can also copy a configured literal value or a nested value from a json field. This is still a field definition in the collection schema:
// seed/schema.ts or seed.json
// Inside the target collection's fields array:
{
slug: "newsletter_profile",
label: "Newsletter profile",
type: "json",
widget: "actions:button",
options: {
mode: "clipboard",
label: "Copy remote ID",
clipboardValueKey: "remote.id"
}
}Run mode direct route example. Add this field definition to the collection that should show the button:
// seed/schema.ts or seed.json
// Inside the target collection's fields array:
{
slug: "newsletter_user_uuid",
label: "Newsletter user UUID",
type: "string",
widget: "actions:button",
options: {
mode: "run",
pluginId: "newsletter-actions",
route: "copy-user-uuid",
method: "POST",
label: "Copy UUID",
valueKey: "currentValue",
contextKey: "entryId",
contextValueKey: "entryId",
resultValueKey: "uuid"
}
}Run mode manifest action example. This is also a collection field definition; the button resolves one action from the provider manifest:
// seed/schema.ts or seed.json
// Inside the target collection's fields array:
{
slug: "newsletter_user_uuid",
label: "Newsletter user UUID",
type: "string",
widget: "actions:button",
options: {
mode: "run",
provider: "newsletter-actions",
action: "newsletter.copyUserUuid",
label: "Copy UUID",
valueKey: "currentValue",
resultValueKey: "uuid"
}
}Field button options:
mode: Eitherrunorclipboard. Defaults torun.pluginIdorprovider: Plugin id to call.route: Direct plugin route to call.action: Action id to resolve from the provider manifest. Ifrouteis omitted inrunmode, this is required.method: HTTP method for direct routes. Defaults toPOST.label: Button label.description: Text shown above the button.confirm: Confirmation prompt before running.payload: Static JSON payload sent with the request.valueKey: Include the current field value in the request payload under this key.contextKey: Include field context in the request payload under this key.contextValueKey: Dot-path to read from field context before writing it tocontextKey. If omitted, the full context object is sent.resultValueKey: Dot-path to read from the final action result and write back into the field.clipboardText: Literal string to copy inclipboardmode.clipboardValueKey: Dot-path to read from the current field value inclipboardmode. Defaults to the whole field value.clipboardContextValueKey: Dot-path to read from field context inclipboardmode.clipboardSuccess: Success notice shown after copying.placement: Manifest action placement to match. Defaults tofield.manifestRoute: Provider manifest route. Defaults to.well-known/actions.allowedTargetPluginIds: Cross-plugin targets allowed when resolving manifest actions.pollIntervalMsandpollTimeoutMs: Async job polling controls.
Clipboard mode uses the browser navigator.clipboard.writeText() API. It requires a secure browser context, usually HTTPS or localhost, and browser permission. It does not call the backend.
The current EmDash admin renders plugin field widgets with value, onChange, label, id, required, options, and minimal. The content editor already has richer context nearby, including collection, entry item, locale/i18n, current user, and full form data, but it does not pass that context into plugin field widgets yet.
emdash-actions is ready for this host shape:
type ActionButtonContext = {
surface: "field" | "dashboard";
collection?: string;
collectionLabel?: string;
fieldName?: string;
fieldKind?: string;
fieldLabel?: string;
fieldRequired?: boolean;
entryId?: string;
entrySlug?: string;
entryStatus?: string;
entryLocale?: string | null;
isNew?: boolean;
fieldValue?: unknown;
entryData?: Record<string, unknown>;
currentUser?: { id: string; role?: number };
i18n?: { defaultLocale?: string; locales?: string[] };
translations?: unknown[];
formData?: Record<string, unknown>;
};Without a host-provided context prop, the field button can still infer:
collection,entryId,isNew, and URLlocalefrom/_emdash/admin/content/:collection/:idand/_emdash/admin/content/:collection/new.fieldNamefrom the host-provided field element id, which currently followsfield-${name}.fieldLabel,fieldRequired, andfieldValuefrom the field widget props.entrySlug,entryStatus,entryLocale, andentryDataby fetching the saved entry from/_emdash/api/content/:collection/:id.currentUserby fetching/_emdash/api/auth/me.
It cannot infer live unsaved values from sibling fields. For those cases, pass the current field value with valueKey, put static data in payload, or design the provider route to read the latest saved entry server-side.
Provider plugins can describe actions with a manifest. The dashboard widget uses the manifest to render buttons, and a field button can resolve a single manifest action by id. Use placement: "dashboard" for dashboard-only actions, placement: "field" for field actions, or placement: "global" for both.
By default, emdash-actions loads a provider manifest from:
GET /_emdash/api/plugins/site-tools/.well-known/actionsA minimal manifest looks like this. This JSON is returned by a provider plugin route, usually /_emdash/api/plugins/<provider>/.well-known/actions; it is not added to the site astro.config.mjs:
// In the provider plugin route handler for ".well-known/actions":
export const manifest = {
actions: [
{
id: "cache.clear",
label: "Clear cache",
route: "clear-cache",
},
],
};With that manifest, a matching surface shows a Clear cache button. Clicking it calls:
POST /_emdash/api/plugins/site-tools/clear-cacheProvider plugins may return plain JSON, or they may use the optional helpers and types from this package in the provider plugin source:
// src/index.ts in a provider plugin package
import { defineActionsManifest, type ActionsManifest } from "@bnomei/emdash-actions";
export const manifest: ActionsManifest = defineActionsManifest({
actions: [
{
id: "cache.clear",
label: "Clear cache",
route: "clear-cache",
method: "POST",
tone: "info",
icon: "bolt",
confirm: "Clear the site cache?",
},
],
});Using these helpers is optional. A provider can stay completely independent from emdash-actions as long as it returns the documented manifest shape.
Each action in the manifest describes one button:
type ActionDescriptor = {
id: string;
label: string;
route: string;
method?: "POST" | "PUT" | "PATCH" | "DELETE";
pluginId?: string;
description?: string;
icon?: string;
tone?: "default" | "positive" | "warning" | "danger" | "info";
confirm?: string;
placement?: string;
resultMode?: "emdash-action-result-v1" | "emdash-action-accepted-v1" | string;
payload?: Record<string, unknown>;
contextKey?: string;
contextValueKey?: string;
disabled?: boolean;
pollIntervalMs?: number;
pollTimeoutMs?: number;
};Required fields:
id: Stable action id, unique inside the provider manifest.label: Button label.route: Relative plugin API route to call when the button is clicked.
Optional fields:
method: HTTP method. Defaults toPOST.pluginId: Target plugin id. Defaults to the provider plugin. Cross-plugin targets must be explicitly allowed.description: Short text shown under the button label.icon: Icon hint. Current built-ins includecopy,clipboard,power,warning,check,x,close,bolt, andlightning.tone: Visual intent for the button and notices.confirm: Confirmation prompt shown before the action runs.placement: Only show this action for a matching widget placement. Useglobalto show it everywhere.resultMode: Result contract hint. Useemdash-action-result-v1for immediate results oremdash-action-accepted-v1for accepted async work.payload: JSON payload sent with the request body for methods that support a body.contextKey: Include widget context in the request payload under this key.contextValueKey: Dot-path to read from widget context before writing it tocontextKey. If omitted, the full context object is sent.disabled: Render the action as unavailable.pollIntervalMs: Default polling interval for async job status routes. Defaults to1500.pollTimeoutMs: Maximum time to wait for an async job before showing a timeout. Defaults to120000.
Routes are validated before use. They must be relative plugin routes such as clear-cache or .well-known/actions. Absolute URLs, query strings, hashes, encoded paths, traversal segments, and backslashes are rejected.
Use the dashboard widget when an action is global to the site or provider rather than contextual to one field.
Dashboard actions use the same manifest contract as field actions. A dashboard action can opt into context with contextKey and contextValueKey. Without host support, dashboard context is intentionally small: surface: "dashboard" plus currentUser when the auth endpoint responds. It does not contain entry data, because the dashboard is not tied to one content item.
Screenshot pending. Capture this from a real EmDash dashboard after wiring the widget into a host project:
docs/actions-dashboard.pngDashboard widget options go into the actionsPlugin() call in astro.config.mjs:
// astro.config.mjs
actionsPlugin({
title: "Actions",
size: "half",
placement: "dashboard",
providers: [
{
pluginId: "site-tools",
label: "Site tools",
manifestRoute: ".well-known/actions",
allowedTargetPluginIds: [],
},
],
});Available options:
title: Dashboard widget title. Defaults toActions.size: Widget size, eitherfull,half, orthird. Defaults tohalf.placement: Which action placement this widget should show. Defaults todashboard. Set tonullto show all actions.providers: Action provider plugins to load.entrypoint: Package entrypoint for the native descriptor. Defaults to@bnomei/emdash-actions.adminEntry: Admin UI entrypoint. Defaults to@bnomei/emdash-actions/admin.
Provider options:
pluginId: Provider plugin id.label: Human-readable provider label.manifestRoute: Provider route that returns the actions manifest. Defaults to.well-known/actions.allowedTargetPluginIds: Plugin ids this provider may target with action descriptors.
The first example provider is @bnomei/emdash-action-maintenance. It exposes maintenance mode actions that the dashboard can render as buttons.
Install both packages:
vp install @bnomei/emdash-actions @bnomei/emdash-action-maintenanceRegister the actions UI and the maintenance provider in the emdash({ plugins: [...] }) list in astro.config.mjs:
// astro.config.mjs
import { defineConfig } from "astro/config";
import emdash from "emdash/astro";
import { actionsPlugin } from "@bnomei/emdash-actions";
import {
PLUGIN_ID as MAINTENANCE_PLUGIN_ID,
actionMaintenance,
} from "@bnomei/emdash-action-maintenance";
export default defineConfig({
integrations: [
emdash({
plugins: [
actionsPlugin({
providers: [
{
pluginId: MAINTENANCE_PLUGIN_ID,
label: "Maintenance",
},
],
}),
actionMaintenance({
defaultMessage: "This site is temporarily unavailable. Please check back soon.",
}),
],
}),
],
});The maintenance provider can expose buttons for toggling, enabling, and disabling maintenance mode. The provider owns those API routes; emdash-actions only renders the buttons and calls the configured endpoints.
For Cloudflare/serverless actions that start longer work, return an accepted result with a statusRoute. The button surface keeps the action loading, polls that route, and updates the notice until the job reaches a terminal state.
Initial action response from the provider route that starts the job:
// Response body from POST /_emdash/api/plugins/<provider>/<route>
{
ok: true,
status: 202,
jobId: "backup-01",
jobStatus: "accepted",
statusRoute: "jobs/backup-01",
message: "Backup accepted.",
pollAfterMs: 1500,
}Status route response while the job is still active:
// Response body from GET /_emdash/api/plugins/<provider>/jobs/backup-01
{
ok: true,
status: 200,
jobId: "backup-01",
jobStatus: "running",
statusRoute: "jobs/backup-01",
progress: 0.42,
message: "Writing archive.",
}Terminal status route response:
// Response body from GET /_emdash/api/plugins/<provider>/jobs/backup-01
{
ok: true,
status: 200,
jobId: "backup-01",
jobStatus: "succeeded",
progress: 1,
message: "Backup complete.",
}Supported job statuses:
accepted,queued,running: The widget keeps polling.succeeded: The widget stops polling and shows a success notice.failed,cancelled: The widget stops polling and shows an error notice.
statusRoute must be a relative plugin route under the action's target plugin. The same route validation rules apply as for action routes.
If a provider returns status: 202 or resultMode: "emdash-action-accepted-v1" without a statusRoute, the widget can only show the accepted result. It cannot infer queued, running, failed, or completed state without a provider-owned status endpoint.
vp install
vp run typecheck
vp run build
vp run pack:check