Give an AI agent a plugin action.
Publish Mason's Arrange as masonry operation through Editful's live MCP catalog.
Show the bounded request with browser DOM APIs.
Declare agent actions
An agent action gives an AI agent one operation owned by your plugin. The action receives validated JSON, runs inside Editful, and returns validated JSON to the agent.
Use kind.agent(...) when an agent only needs Editful's generic inspect, compose, and edit operations for a node kind. Use context.action(...) when the plugin owns a semantic operation such as arranging Cards with Mason or creating a Card with the choices from a guided workflow.
Add agent-actions to the plugin's capabilities in editful.plugin.ts. The Mason example already declares the capabilities it needs to define Cards and mount its editor, so it adds one entry to the existing list.
capabilities: [
'node-kinds',
'editor-ui',
'agent-actions',
],Editful checks this capability when the plugin registers an action. A plugin without it cannot call context.action(...).
An agent action does not start an MCP server or register an arbitrary MCP method. Editful owns the MCP connection and publishes each enabled contribution as a namespaced tool such as editful_canvas_action_arrange_cards_as_masonry.
Publish Arrange as masonry
The Editors guide defines Mason, a persistent sidebar that arranges selected Cards into columns. Its masonryLayout(...) helper accepts Card snapshots, a column count, and a gap, then returns the position updates for the layout.
Register an agent action beside the existing editor contribution. The action uses the same helper, but receives stable node ids instead of reading the current selection.
context.action({
id: 'example:arrange-cards-as-masonry',
name: 'arrange_cards_as_masonry',
description:
'Arrange at least two existing Cards into masonry columns while preserving their sizes.',
inputSchema: {
type: 'object',
properties: {
node_ids: {
type: 'array',
minItems: 2,
maxItems: 100,
items: {
type: 'string',
minLength: 1,
maxLength: 128,
},
description: 'Stable node ids of the Cards to arrange.',
},
columns: {
type: 'integer',
minimum: 1,
maximum: 12,
},
gap: {
type: 'number',
minimum: 0,
maximum: 64,
},
},
required: ['node_ids', 'columns', 'gap'],
additionalProperties: false,
},
outputSchema: {
type: 'object',
properties: {
node_ids: {
type: 'array',
minItems: 2,
maxItems: 100,
items: { type: 'string' },
},
columns: { type: 'integer' },
gap: { type: 'number' },
},
required: ['node_ids', 'columns', 'gap'],
additionalProperties: false,
},
requiresConfirmation: false,
async run(input, action) {
const { node_ids, columns, gap } = input as {
node_ids: string[];
columns: number;
gap: number;
};
if (new Set(node_ids).size !== node_ids.length) {
throw new Error('Card node ids must be unique');
}
const cards = action.document.inspect(node_ids);
if (
cards.length !== node_ids.length ||
cards.some((node) => node.kind !== 'example:card')
) {
throw new Error('One or more Cards are unavailable');
}
const appliedColumns = Math.min(cards.length, columns);
const transaction = action.document.transaction('Arrange as masonry');
for (const update of masonryLayout(cards, appliedColumns, gap)) {
transaction.update(update);
}
transaction.commit();
return {
node_ids,
columns: appliedColumns,
gap,
};
},
});The input schema is the action's public contract. Editful validates it before run(...), which makes the type assertion inside the callback match the values the host supplies. Editful also validates the returned object against outputSchema before sending it to the agent.
The preview uses the same operation from a small request editor. Choose Run agent tool to apply the displayed bounded input to the selected Cards.
import {
definePlugin,
type PluginActionContext,
type PluginContext,
type PluginJson,
type PluginNodeSnapshot,
} from '@editful/canvas-sdk';
import { registerEditableCard } from './toolbar-editable-text';
export default definePlugin({
register(context) {
registerAgentTool(context);
context.editor({
id: 'example:agent-tool-preview', label: 'Agent request', surface: 'left-sidebar',
mount(container, initialAction) {
let action = initialAction;
const request = document.createElement('pre');
const run = document.createElement('button');
run.textContent = 'Run agent tool';
const sync = () => {
request.textContent = JSON.stringify({
node_ids: action.selection.nodeIds(), columns: 2,
}, null, 2);
run.disabled = action.selection.nodeIds().length < 2;
};
run.addEventListener('click', () => {
arrange(action.selection.nodeIds(), 2, action);
});
container.replaceChildren(request, run);
sync();
return { update(next) { action = next; sync(); },
dispose() { container.replaceChildren(); } };
},
});
},
});
export function registerAgentTool(context: PluginContext): void {
registerEditableCard(context);
context.action({
id: 'example:arrange-cards-as-columns',
name: 'arrange_cards_as_columns',
description: 'Arrange existing Cards into bounded columns.',
inputSchema: {
type: 'object',
properties: {
node_ids: { type: 'array', minItems: 2, maxItems: 20,
items: { type: 'string' } },
columns: { type: 'integer', minimum: 1, maximum: 4 },
},
required: ['node_ids', 'columns'], additionalProperties: false,
},
outputSchema: {
type: 'object', properties: { arranged: { type: 'integer' } },
required: ['arranged'], additionalProperties: false,
},
requiresConfirmation: false,
run(input, action) {
const value = input as { readonly node_ids: string[]; readonly columns: number };
arrange(value.node_ids, value.columns, action);
return Promise.resolve({ arranged: value.node_ids.length });
},
});
}
export function arrange(
nodeIds: readonly string[],
columns: number,
action: PluginActionContext,
): void {
const cards = action.document.inspect(nodeIds)
.filter((node) => node.kind === 'example:card');
if (cards.length < 2) return;
const transaction = action.document.transaction('Arrange cards as columns');
for (const update of columnLayout(cards, columns, 14)) transaction.update(update);
transaction.commit();
}
function columnLayout(cards: readonly PluginNodeSnapshot[], columns: number, gap: number) {
const left = Math.min(...cards.map((card) => card.x - card.width / 2));
const top = Math.min(...cards.map((card) => card.y - card.height / 2));
const cellWidth = Math.max(...cards.map((card) => card.width));
const cellHeight = Math.max(...cards.map((card) => card.height));
return cards.map((card, index) => ({
id: card.id,
x: left + (index % columns) * (cellWidth + gap) + card.width / 2,
y: top + Math.floor(index / columns) * (cellHeight + gap) + card.height / 2,
} satisfies Record<string, PluginJson>));
}JSON Schema bounds the list, column count, and gap. The callback still checks rules the supported schema subset cannot express: node ids must be unique, every id must still exist, and every node must be an example:card.
id- The qualified contribution id Editful uses inside the plugin runtime.
name- The lower snake case operation name published to agents. In this example, the agent chooses
arrange_cards_as_masonry. inputSchema- The bounded JSON shape accepted before the callback runs.
outputSchema- The bounded JSON shape returned to the agent.
run(...)- The trusted plugin callback that reads services and performs the operation.
Commit one canvas change
The action does not receive the canvas store or mutable nodes. It reads immutable snapshots with action.document.inspect(...) and writes the positions returned by masonryLayout(...) through a document transaction.
transaction.commit() records every Card position as one Arrange as masonry change in undo history. If an id is missing or names another kind, the callback stops before it creates a transaction.
Keep the operation at the same level as the action name. arrange_cards_as_masonry owns one layout operation. Creating a Card, changing its title, or choosing its color belongs in a separate action with its own input contract.
Let Editful publish the action
When the plugin is active on a canvas, Editful publishes editful_canvas_action_arrange_cards_as_masonry. The tool has only this action's concrete input schema—there is no action selector or union of unrelated plugin operations.
{
"input": {
"node_ids": ["01JCARDONE", "01JCARDTWO", "01JCARDTHREE"],
"columns": 2,
"gap": 12
},
"request_id": "arrange-project-cards",
"expected_revision": 42
}The MCP bridge owns request_id and expected_revision. The plugin action receives only the object under input. A stale semantic document revision stops the request before the callback changes the document, and a successful response includes the resulting revision.
The live catalog follows the active canvas. Disabling the plugin removes its individual tools; changing an action schema and reloading the plugin replaces the published tool definition as one catalog update.
Require confirmation when needed
Set requiresConfirmation: true when the operation must wait for a person before it runs. Editful shows a host-owned confirmation using the action's name and description, then invokes the callback only after approval.
arrange_cards_as_masonry uses false because it makes one bounded, undoable document change. Confirmation is separate from plugin permissions: an action still needs every capability, network origin, setting, and secret required by the work it performs.
Keep confirmation in the contribution. Do not add a second confirmation field to the input schema; the MCP bridge and Editful host own that decision.
Reuse the same plugin operation
Commands, editors, and agent actions receive the same PluginActionContext. Put shared document work in a function that accepts that context, then call it from each contribution that needs the operation.
Mason's editor and agent action share masonryLayout(...), so the sidebar and the agent calculate the same Card positions. The Workflows guide can use the same pattern: keep its Create card prompts in the command, then move the final Card transaction into a function that a separate create_card agent action can call with schema-validated values.
Agent actions do not need to reproduce interactive prompts. Put required choices in inputSchema, and use requiresConfirmation when the action must wait for approval. If an operation calls an API, use the declared request path from the Network requests guide inside the same action context.