Operation extensions
An operation extension adds a new step type to the Flows system. Built-in operations cover CRUD, conditions, scripts, sleeps, email, notifications, and HTTP requests; an operation extension lets you add a step that does something specific to your project.
Operations are hybrid extensions, meaning they have both an app side and an API side. The app side describes how the operation appears in the flow editor (its icon, configuration form, and tile preview). The API side runs the work when a flow executes the operation.
A single npm package created by the extensions toolchain holds both halves.
Anatomy
Section titled “Anatomy”The package’s source has two entrypoints: one for the app side and one for the API side. The scaffolder writes them as app.js and api.js for JavaScript projects or app.ts and api.ts for TypeScript. Both are built into dist/app.js and dist/api.js. The package’s cairncms:extension field carries both paths and points source at whichever filenames the scaffolder produced:
{ "cairncms:extension": { "type": "operation", "path": { "app": "dist/app.js", "api": "dist/api.js" }, "source": { "app": "src/app.ts", "api": "src/api.ts" }, "host": "^1.0.0" }}The runtime only reads path; source is what the build CLI uses. As long as the manifest points at the right files, the source filenames themselves can be anything.
For local file installs (without packaging), use the parallel folder layout with built .js files:
<EXTENSIONS_PATH>/operations/<name>/├── app.js└── api.jsThe two halves are joined by their id. CairnCMS recognizes them as the same operation when both export configs with matching id values.
App entrypoint
Section titled “App entrypoint”The app side describes the operation’s appearance in the flow editor and what fields the configuration form exposes:
import { defineOperationApp } from '@cairncms/extensions-sdk';
export default defineOperationApp({ id: 'custom', name: 'Custom', icon: 'box', description: 'A custom flow operation.', overview: ({ text }) => [ { label: 'Text', text }, ], options: [ { field: 'text', name: 'Text', type: 'string', meta: { width: 'full', interface: 'input' }, }, ],});Fields available on OperationAppConfig:
id— unique key. Must match the API entrypoint’sid. Scope proprietary operations with an author or organization prefix.name— display name shown in the operation picker.icon— icon name from the Material icon set or one of CairnCMS’s custom icons.description— short description (under 80 characters).overview— what shows on the operation’s tile in the flow grid. Either:- a function
(options, { flow }) => Array<{ label, text, copyable? }>that returns label/text pairs derived from the operation’s configured options, or - a Vue component for fully custom rendering, or
nullfor no overview.
- a function
options— fields shown in the configuration drawer when the operation is added or edited. Either an array of field definitions, a function returning an array, a Vue component for fully custom rendering, ornullfor no options.
API entrypoint
Section titled “API entrypoint”The API side defines the work the operation actually performs:
import { defineOperationApi } from '@cairncms/extensions-sdk';
export default defineOperationApi({ id: 'custom', handler: ({ text }) => { console.log(text); },});Fields available on OperationApiConfig<Options>:
id— unique key, matching the app entrypoint.handler—(options, context) => unknown | Promise<unknown> | void— the function that runs when the operation executes.
defineOperationApi is generic in the Options shape, which gives you typed access to the configured options inside the handler:
import { defineOperationApi } from '@cairncms/extensions-sdk';
type Options = { text: string; uppercase?: boolean };
export default defineOperationApi<Options>({ id: 'shout', handler: ({ text, uppercase }) => { return uppercase ? text.toUpperCase() : text; },});The handler
Section titled “The handler”The handler runs each time a flow reaches the operation. It receives:
options— the operation’s configured options, with any data chain variables already interpolated. If the editor configuredto: '{{ $trigger.payload.email }}', the handler receives the resolved email address, not the template string.context— an object that combines the standard API extension context with two additions specific to operations:services,exceptions,database,env,logger,getSchema— same as endpoint and hook contextsdata— the entire flow data chain, with every prior operation’s output keyed by operation keyaccountability— the accountability object derived from the flow’s trigger (the originating user, role, IP, and so on)
A typical handler:
import { defineOperationApi } from '@cairncms/extensions-sdk';
export default defineOperationApi({ id: 'count-articles', handler: async (options, { services, accountability, getSchema, database }) => { const { ItemsService } = services; const articles = new ItemsService('articles', { schema: await getSchema({ database }), accountability, });
return articles.readByQuery({ aggregate: { count: '*' }, filter: { status: { _eq: options.status } }, }); },});The result of the handler is what gets appended to the data chain under the operation’s key.
Success and failure paths
Section titled “Success and failure paths”Each operation in a flow has two outgoing connectors: success and failure. The handler controls which one runs next:
- Complete without throwing to take the success path. Whatever the handler returns is appended to the data chain under the operation’s key. A
voidorundefinedreturn still resolves successfully and is stored asnullon the data chain. - Throw an error to take the failure path. The thrown value is appended to the data chain.
import { defineOperationApi } from '@cairncms/extensions-sdk';
export default defineOperationApi({ id: 'check-quota', handler: async ({ tenantId }, { services, exceptions, accountability, getSchema, database }) => { const { ItemsService } = services; const { ForbiddenException } = exceptions;
const tenants = new ItemsService('tenants', { schema: await getSchema({ database }), accountability, }); const tenant = await tenants.readOne(tenantId);
if (tenant.usage >= tenant.quota) { throw new ForbiddenException(`Tenant ${tenantId} is over quota.`); }
return { remaining: tenant.quota - tenant.usage }; },});A Condition operation that follows this one can branch on either path, or you can route the failure connector to a notification operation.
Operations vs hooks vs endpoints
Section titled “Operations vs hooks vs endpoints”Operations, hooks, and endpoints are the three server-side extension types, but each fits a different shape of work:
- Operation — a step inside a flow. Use this when the work belongs in a configurable, user-built pipeline.
- Hook — a reaction to a built-in platform event. Use this for logic that should run automatically, every time, without user configuration.
- Endpoint — a custom HTTP route. Use this when an external client needs a way to invoke the work directly.
The same logic is sometimes appropriate as more than one of these. For example, “send a notification when an order ships” can be a hook (filter or action on orders.update) or an operation (configurable step in an order-management flow). The hook is automatic; the operation is composable.
A complete minimal example
Section titled “A complete minimal example”An operation that joins two arrays of items by a shared key is useful for combining results from earlier flow operations.
src/app.js:
import { defineOperationApp } from '@cairncms/extensions-sdk';
export default defineOperationApp({ id: 'join-by-key', name: 'Join by Key', icon: 'merge', description: 'Joins two arrays from the data chain on a shared key.', overview: ({ leftKey, rightKey, joinOn }) => [ { label: 'Left source', text: leftKey }, { label: 'Right source', text: rightKey }, { label: 'Join field', text: joinOn }, ], options: [ { field: 'leftKey', name: 'Left source key', type: 'string', meta: { width: 'half', interface: 'input' }, }, { field: 'rightKey', name: 'Right source key', type: 'string', meta: { width: 'half', interface: 'input' }, }, { field: 'joinOn', name: 'Join on field', type: 'string', meta: { width: 'full', interface: 'input' }, }, ],});src/api.js:
import { defineOperationApi } from '@cairncms/extensions-sdk';
export default defineOperationApi({ id: 'join-by-key', handler: ({ leftKey, rightKey, joinOn }, { data }) => { const left = data[leftKey] ?? []; const right = data[rightKey] ?? [];
const rightByKey = new Map(right.map((row) => [row[joinOn], row]));
return left.map((row) => ({ ...row, ...(rightByKey.get(row[joinOn]) ?? {}), })); },});After build and install, the new operation appears in the operation picker. Editors configure it by selecting which two prior operation outputs to join and which field to match on.
Where to go next
Section titled “Where to go next”- Flows covers flows from a user perspective for understanding where your operation slots in.
- Hooks and Endpoints cover the other two server-side extension types.
- Creating extensions covers the toolchain in full.