Appearance
Workflow — Custom Nodes & Actions (Extensions)
How to add your own workflow node types and action types from an API extension. The engine dispatches every node — built-in and custom — through one registry, so a custom node is a first-class citizen, not a special case.
Prereqs: read Extension Development and Extension Guardrails first. For the engine model (steps, transitions, approval, versioning) see Workflow Engine.
TLDR
- The engine looks up a handler in a registry keyed by
step.type(nodes) andstep.action_type(actions). No hardcodedswitch. - Register from an extension by consuming the registries off the service registry —
context.registry.consume(workflowNodesRef)/workflowActionsRef— in an init hook. - A node handler's
onActivate(ctx)decides what happens next throughctx.ops(auto-advance, complete, park for a human, or wait for an async event). - Discover everything (built-in + extension) via
GET /workflow-node-types. - No DB migration: node/action config lives in the existing
type,options, andaction_configcolumns.
Two extension surfaces
| You want to… | Register a… | Keyed by |
|---|---|---|
| Add an automatic step (transform, call an API, compute) | node (kind: 'auto') | step.type |
| Add a human-review variant | node (kind: 'human') | step.type |
| Add a step that waits for an external event | node (kind: 'external') | step.type |
Add a new action_type for the built-in action node | action | step.action_type |
If all you need is a new side effect for the existing action node (like a new send_slack alongside send_notification), register an action — it's simpler. Register a node when you need your own step box on the canvas or an async wait.
Where to register
The registries are not fields on the extension context — that would bloat the generic context and couple core to the workflow module. Instead they are exposed through the standard service registry (context.registry, see Guardrails §6). The workflow module provides them at boot; your extension consumes them via a typed ref and registers into them.
ts
import { defineHook, workflowNodesRef, workflowActionsRef } from '@odp/extensions-sdk';
export default defineHook((hook, context) => {
// Consume in an init hook that runs AFTER the registry is sealed. `app.before`
// is a safe point — the registries are provided at boot, and workflows only run
// at request time, so nodes are registered well before any instance executes.
hook.init('app.before', () => {
const nodes = context.registry.consume(workflowNodesRef);
const actions = context.registry.consume(workflowActionsRef);
actions.register({ /* … */ });
nodes.register({ /* … */ });
});
});workflowNodesRef/workflowActionsRefare exported from@odp/extensions-sdk.- Do not consume at a hook's top level (before seal) — use an init hook / request handler, or
consumethrows with a clear message. - Duplicate
type/action_typethrows ("… is already registered") — you cannot shadow a built-in or another extension's node. - Reload is a full process restart, so registrations rebuild cleanly every boot.
The recipes below show the handler object passed to
nodes.register(...)/actions.register(...)— assumenodes/actionscome from theconsume(...)calls above.
Hard dependency vs optional integration
consume(ref) throws if no provider is registered — correct when your extension requires the workflow module (e.g. a helpdesk extension). If the integration is optional (register nodes only when workflow is present), probe first so a disabled workflow module doesn't crash your extension's boot:
ts
const nodes = context.registry.tryConsume(workflowNodesRef); // undefined if absent
if (nodes) nodes.register({ /* … */ });
// or: if (context.registry.has(workflowNodesRef.id)) { … }Recipe 1 — a custom action type
An action step runs its action_type side effect, then auto-advances. Add a new one:
ts
actions.register({
actionType: 'acme:webhook',
metadata: { label: 'Call Webhook', category: 'integration', source: 'extension' },
async run({ instance, config, knex }) {
// `config` is the step's action_config (already parsed JSON).
await fetch(config.url as string, {
method: 'POST',
body: JSON.stringify({ item: instance.item_id }),
});
},
});Use it in a workflow by adding an action step with action_type: "acme:webhook" and action_config: { "url": "https://…" }.
The run context: { instance, step, config, knex, promote(versionId) }. Prefer services from the extension context over raw knex for anything beyond a trivial scalar write (see guardrails).
Recipe 2 — a custom auto node
An auto node runs and advances in one go. Its onActivate returns the result of an ops call:
ts
nodes.register({
type: 'acme:score',
kind: 'auto',
metadata: { label: 'AI Score', category: 'ai', source: 'extension' },
async onActivate(ctx) {
const score = await scoreItem(ctx.instance.item_id);
// persist output for downstream transitions / audit
// (see "Passing data" below), then continue:
return ctx.ops.advanceApprove();
},
});ops.advanceApprove() follows the approve/always/condition transition out of the node, exactly like a built-in start/action step.
Recipe 3 — an async / external node
This is the headline capability: a node that starts external work, parks, and is resumed when the outside world calls back.
ts
nodes.register({
type: 'acme:esign',
kind: 'external',
metadata: { label: 'E-Signature', category: 'integration', source: 'extension', isAsync: true },
async onActivate(ctx) {
await docusign.createEnvelope(ctx.instance.item_id, ctx.step.options);
// Park the step: status -> `waiting`, excluded from the human inbox.
// Optionally declare a deadline (see below).
return ctx.ops.wait({ timeoutMs: 24 * 60 * 60 * 1000 });
},
// Optional: what to do if the deadline passes before anyone resumes.
async onTimeout() {
return { action: 'fail', reason: 'signature timed out' };
},
});ops.wait(options?) — declaring a deadline
| Option | Effect |
|---|---|
timeoutMs | Deadline = now + ms |
timeoutAt | Absolute deadline (Date or ISO string) |
| (neither) | Falls back to the step's timeout_minutes, else waits indefinitely |
| both | Rejected (ambiguous) |
The scheduler polls waiting steps whose timeout_at has passed on a branch that is separate from the approval-timeout loop, then calls your onTimeout (or fails safe: step timed_out, instance error, version released).
Resuming a parked node
When the external event arrives (e.g. your extension's DocuSign webhook fires), resume the step through the engine surface exposed on the service registry:
ts
import { workflowEngineRef } from '@odp/extensions-sdk';
// inside your extension's webhook endpoint handler:
const engine = context.registry.consume(workflowEngineRef);
await engine.resolveNode(instanceStepId, { trigger: 'approve', data: { envelopeId } });resolveNode is idempotent and atomic — only a waiting step is claimed, so a duplicate webhook (at-least-once delivery) is a safe no-op, and a resume that races the timeout never double-advances. The payload is persisted to instance_step.result. It returns { status: 'resolved' | 'ignored' | 'failed', … } — never throws for a duplicate. You'll need to map your external reference to the instanceStepId (e.g. store it when the node parks in onActivate).
Handler reference
ts
interface WorkflowNodeHandler {
type: string; // step.type discriminator
kind: 'start' | 'end' | 'auto' | 'human' | 'branch' | 'external'; // descriptive
onActivate(ctx): Promise<WorkflowStepResult>;
validateConfig?(step): void; // runs at workflow activation (throw to reject)
onTimeout?(ctx): Promise<{ action: 'advance'; trigger } | { action: 'fail'; reason? }>;
metadata?: WorkflowNodeMetadata; // label / category / icon / isAsync / configSchema
}onActivate receives ctx = { instance, step, instanceStepId, assignedUsers, ops }. ctx.ops:
| op | Use in a node of kind… | Effect |
|---|---|---|
advanceApprove() | auto | Follow the approve/always/condition transition |
runAction() | auto | Run the step's action_type (built-in action node uses this) |
completeAtEnd() | end | Complete + promote/reject inside one transaction |
park() | human | Status stays active; notify assignees; shows in inbox |
wait(options?) | external | Status → waiting; excluded from inbox; awaits resume/timeout |
kind is descriptive only — the engine dispatches on onActivate, not on kind. Pick the kind that matches behaviour so the console renders your node correctly.
Discovery — GET /workflow-node-types
The console builds its palette from this endpoint (auth + workflow.view). It returns built-in and extension-registered types together:
json
{
"data": {
"nodes": [
{ "type": "approval", "kind": "human", "source": "builtin", "label": "Approval", "category": "human-task", "isAsync": false },
{ "type": "acme:esign","kind": "external", "source": "extension", "label": "E-Signature", "category": "integration", "isAsync": true, "configSchema": {} }
],
"actions": [
{ "actionType": "send_notification", "source": "builtin", "label": "Send Notification", "category": "notification" },
{ "actionType": "acme:webhook", "source": "extension", "label": "Call Webhook", "category": "integration" }
]
}
}type/kind/sourceare always present (type/kindderive from the handler).sourcedistinguishesbuiltinvsextension;categoryis a UI grouping only.configSchemais pass-through JSON for the console to render a config form.
Safety: unknown handlers & strict mode
The env flag WORKFLOW_STRICT_NODES (default false) controls what happens when a node/action type has no handler:
| Activation of a workflow with an unknown type | An unknown type hit at runtime | |
|---|---|---|
false (default) | Warn, allow activate | Park to waiting + warn (never crashes the instance) |
true | Reject activation | Fail safe |
Recommended: false in production (an extension being temporarily absent parks the instance for an admin rather than erroring a batch), true in CI/staging to catch wiring mistakes early. Activation also runs each handler's validateConfig.
Data & state notes
- No migration. Node config uses the existing
odp_workflow_steps.type,options, andaction_configcolumns. activevswaiting.active= a human task (in the inbox).waiting= an async node awaiting an external resume/timeout (NOT in the inbox). Approval steps keep usingactive— no inbox regression.- Passing data. A node's output is persisted to
instance_step.result. A full accumulator +templating across steps is planned (see the ADR) — not yet available.
See also
- Workflow Engine — the engine model (steps, transitions, approval, versioning)
- workflow-node-extension.md — the ADR / design rationale (D1–D6)
- Extension Development · Guardrails