Skip to content

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) and step.action_type (actions). No hardcoded switch.
  • 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 through ctx.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, and action_config columns.

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 variantnode (kind: 'human')step.type
Add a step that waits for an external eventnode (kind: 'external')step.type
Add a new action_type for the built-in action nodeactionstep.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 / workflowActionsRef are exported from @odp/extensions-sdk.
  • Do not consume at a hook's top level (before seal) — use an init hook / request handler, or consume throws with a clear message.
  • Duplicate type / action_type throws ("… 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(...) — assume nodes / actions come from the consume(...) 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

OptionEffect
timeoutMsDeadline = now + ms
timeoutAtAbsolute deadline (Date or ISO string)
(neither)Falls back to the step's timeout_minutes, else waits indefinitely
bothRejected (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:

opUse in a node of kind…Effect
advanceApprove()autoFollow the approve/always/condition transition
runAction()autoRun the step's action_type (built-in action node uses this)
completeAtEnd()endComplete + promote/reject inside one transaction
park()humanStatus stays active; notify assignees; shows in inbox
wait(options?)externalStatus → 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 / source are always present (type/kind derive from the handler).
  • source distinguishes builtin vs extension; category is a UI grouping only.
  • configSchema is 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 typeAn unknown type hit at runtime
false (default)Warn, allow activatePark to waiting + warn (never crashes the instance)
trueReject activationFail 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, and action_config columns.
  • active vs waiting. active = a human task (in the inbox). waiting = an async node awaiting an external resume/timeout (NOT in the inbox). Approval steps keep using active — 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

ODP Internal API Documentation