ObjectStackObjectStack

Flow Metadata

Build visual automation with decision trees, data operations, HTTP requests, and screen interactions

Flow Metadata

A Flow is a visual automation that orchestrates business logic through connected nodes. Flows support decision branching, data operations (CRUD), HTTP integrations, script execution, user screens, and subflow composition.

Basic Structure

const approvalFlow = {
  name: 'order_approval',
  label: 'Order Approval Flow',
  type: 'record_change',
  version: 1,
  status: 'active',
  template: false,
  runAs: 'system',

  variables: [
    { name: 'order_amount', type: 'number', isInput: true, isOutput: false },
    { name: 'approved', type: 'boolean', isInput: false, isOutput: true },
  ],

  nodes: [
    { id: 'start', type: 'start', label: 'Start' },
    {
      id: 'check_amount',
      type: 'decision',
      label: 'Check Amount',
      config: {
        conditions: [
          { label: 'High Value', expression: 'order_amount > 10000' },
          { label: 'Standard', expression: 'order_amount <= 10000' },
        ],
      },
    },
    {
      id: 'auto_approve',
      type: 'update_record',
      label: 'Auto Approve',
      config: { object: 'order', fields: { status: 'approved' } },
    },
    {
      id: 'request_approval',
      type: 'approval',
      label: 'Manager Approval',
      config: { approvers: [{ type: 'position', value: 'manager' }] },
    },
    { id: 'end', type: 'end', label: 'End' },
  ],

  edges: [
    { id: 'e1', source: 'start', target: 'check_amount', type: 'default' },
    { id: 'e2', source: 'check_amount', target: 'auto_approve', type: 'default', condition: 'order_amount <= 10000' },
    { id: 'e3', source: 'check_amount', target: 'request_approval', type: 'default', condition: 'order_amount > 10000' },
    { id: 'e4', source: 'auto_approve', target: 'end', type: 'default' },
    { id: 'e5', source: 'request_approval', target: 'end', type: 'default' },
  ],
};

Flow Properties

PropertyTypeRequiredDescription
namestringMachine name (snake_case)
labelstringDisplay label
descriptionstringoptionalFlow description
versionnumberoptionalVersion number (defaults to 1)
statusenumoptional'draft', 'active', 'obsolete', 'invalid' (defaults to 'draft')
typeFlowTypeFlow trigger type (see below)
templatebooleanoptionalIs this a reusable subflow template (defaults to false)
runAsenumoptional'system' or 'user' execution context (defaults to 'user')
variablesFlowVariable[]optionalInput/output variables
nodesFlowNode[]Flow nodes
edgesFlowEdge[]Connections between nodes
errorHandlingobjectoptionalError handling strategy

Flow Types

TypeDescriptionTrigger
autolaunchedRuns automatically without user interactionInvoked by other flows or API
record_changeTriggered by record create/update/deleteData mutation events
scheduleRuns on a schedule (cron)Time-based
screenInteractive flow with user screensUser clicks a button
apiExposed as an API endpointHTTP request

Nodes

Each node performs a specific action in the flow.

Node Types

TypeDescription
startFlow entry point
endFlow termination
decisionConditional branching (if/else)
assignmentSet variable values
loopStructured iteration container — runs a body region once per item (ADR-0031)
parallelStructured parallel block — runs N branch regions concurrently, implicit join (ADR-0031)
try_catchStructured try/catch/retry error handling (ADR-0031)
create_recordCreate a new record
update_recordUpdate existing records
delete_recordDelete records
get_recordQuery records
httpMake an HTTP API call
notifySend an outbound notification via the messaging service
scriptRun a custom script action (dispatched by config.actionType)
screenDisplay a user form/screen (durable pause)
waitPause for a timer or named signal (durable pause; timers auto-resume)
subflowInvoke another flow — a pause inside the child suspends both runs as a linked chain
mapSequential multi-instance — run a per-item subflow for each element, one at a time (each may pause); batch approval (ADR-0039)
connector_actionExecute an external connector action

connector_action dispatches against the runtime connector registry, which connector plugins populate. The three generic executorsrest, openapi, and mcp — double as provider factories: with them in your app's plugins:, a declarative connectors: entry that names a provider is materialized into a live, dispatchable connector at boot with no plugin code (ADR-0097; the showcase example wires all three). Brand connectors (Slack, …) are installed separately. One security note: a declarative mcp instance using a stdio transport spawns a local process from metadata and is therefore denied by default — the host opts in with new ConnectorMcpPlugin({ declarativeStdio: ['<trusted-command>'] }); http transports need no opt-in.

Node Structure

{
  id: 'unique_node_id',
  type: 'decision',
  label: 'Check Priority',
  config: { /* type-specific configuration */ },
  position: { x: 200, y: 100 },    // Canvas position (optional)
}
PropertyTypeRequiredDescription
idstringUnique node identifier
typeFlowNodeActionNode type
labelstringDisplay label
configobjectoptionalType-specific configuration
connectorConfigobjectoptionalExternal connector settings
position{ x, y }optionalVisual position on canvas

Node Examples

Decision (branching):

{
  id: 'check_status',
  type: 'decision',
  label: 'Check Status',
  config: {
    conditions: [
      { label: 'Approved', expression: "status == 'approved'" },
      { label: 'Rejected', expression: "status == 'rejected'" },
    ],
  },
}

Create Record:

{
  id: 'create_task',
  type: 'create_record',
  label: 'Create Follow-up Task',
  config: {
    object: 'task',
    fields: {
      title: 'Follow up on {record.name}',
      assignee: '{record.owner}',
      due_date: 'TODAY() + 7',
    },
  },
}

HTTP Request:

{
  id: 'notify_slack',
  type: 'http',
  label: 'Send Slack Notification',
  config: {
    url: 'https://hooks.slack.com/services/...',
    method: 'POST',
    body: { text: 'New order: {record.name}' },
  },
}

Script:

The built-in script executor dispatches on config.actionType (e.g. email) rather than evaluating an arbitrary JavaScript string:

{
  id: 'send_welcome',
  type: 'script',
  label: 'Send Welcome Email',
  config: {
    actionType: 'email',
    template: 'welcome',
    recipients: '{record.email}',
  },
}

Screen (object form):

A screen node normally renders a flat fields list. Set config.objectName to instead render an object's entire create/edit form — including any master-detail child grids (inlineEdit) — as one wizard step. The client renders the object form and, on save, persists the record itself (parent + inline children atomically); the flow then resumes with the new record's id bound to config.idVariable so a later step can reference it.

{
  id: 'new_opportunity',
  type: 'screen',
  label: 'Opportunity Details',
  config: {
    objectName: 'opportunity',         // render this object's full form
    mode: 'create',                    // 'create' (default) | 'edit'
    // recordId: '{account_id}',        // required for mode: 'edit'
    defaults: {                        // pre-filled values (interpolated)
      account: '{account_id}',
      stage: 'prospecting',
    },
    idVariable: 'opportunity_id',      // bind the saved record's id for later steps
  },
}

This is how a single flow walks the user through several full object forms in sequence (e.g. lead → account → opportunity), each step saving its own record.

Structured control flow (ADR-0031)

loop, parallel, and try_catch are structured control-flow constructs — the native, AI-authored model for iteration, concurrency, and error handling. Unlike free-form BPMN gateways (kept in the protocol for import/export interop only), these constructs are well-formed by construction: each owns its body as a self-contained, single-entry/single-exit region carried in config (representation B — a nested sub-graph). Ordinary step-to-step edges stay acyclic — iteration and concurrency are scoped containers, not back-edges — so the DAG invariant is preserved and termination stays analyzable. Regions are validated at registerFlow() (single-entry/single-exit, acyclic, bounded loop); a malformed construct is rejected before the flow can run.

A region runs in the enclosing variable scope (the iterator value and any body mutations are visible to the surrounding flow) — it is not a separate subflow invocation. The container node's ordinary out-edges are the "after-loop / after-block" continuation.

Loop container

Runs its body region once per item of a collection, binding the current item (and optionally its index) as flow variables, under a hard max-iteration guard.

{
  id: 'notify_each',
  type: 'loop',
  label: 'For each task',
  config: {
    collection: '{tasks}',        // template/variable resolving to an array
    iteratorVariable: 'task',     // current item, visible inside the body
    indexVariable: 'i',           // optional zero-based index
    maxIterations: 500,           // hard cap (clamped to the engine ceiling)
    body: {                       // single-entry/single-exit region
      nodes: [
        { id: 'send', type: 'script', label: 'Notify', config: { /* … */ } },
      ],
      edges: [],
    },
  },
}

A loop node with no body keeps the legacy flat-graph behavior — the container is additive.

Parallel block

Declares N branch regions that run concurrently and join implicitly when all complete — there is no author-visible split/join gateway. Branches should write distinct variables (last-writer-wins on collision); a failing branch fails the block.

{
  id: 'fan_out',
  type: 'parallel',
  label: 'Notify in parallel',
  config: {
    branches: [                   // ≥ 2 regions
      { name: 'Email', nodes: [{ id: 'email', type: 'script', label: 'Email', config: { /* … */ } }], edges: [] },
      { name: 'Slack', nodes: [{ id: 'slack', type: 'script', label: 'Slack', config: { /* … */ } }], edges: [] },
    ],
  },
}

Try / catch / retry

Wraps a protected try region; on failure runs the optional catch region (with the caught error bound to errorVariable), optionally retrying the try region first with exponential backoff. This surfaces the engine's existing fault + retry semantics as a structured construct (rather than BPMN boundary events).

{
  id: 'guarded',
  type: 'try_catch',
  label: 'Charge with fallback',
  config: {
    try: { nodes: [{ id: 'charge', type: 'http', label: 'Charge', config: { /* … */ } }], edges: [] },
    catch: { nodes: [{ id: 'flag', type: 'update_record', label: 'Flag failure', config: { /* … */ } }], edges: [] },
    errorVariable: '$error',
    retry: { maxRetries: 3, retryDelayMs: 1000, backoffMultiplier: 2 },
  },
}

BPMN parallel_gateway / join_gateway / boundary_event remain in the protocol as the interop representation and map onto these constructs on import/export — they are not the native authoring model.

Durable pause & resume (ADR-0019)

Some nodes don't finish in one pass — they suspend the run and wait for the outside world. The engine snapshots the run (variables, step log, position) to the sys_automation_run table and returns { status: 'paused', runId }; the pause survives a process restart and is continued later through a single entry point:

POST /api/v1/automation/{flow}/runs/{runId}/resume
{ "inputs": { … }, "branchLabel": "approve", "output": { … } }
Pausing nodeSuspends until…Resumed by
approvala human decisionthe approvals service (POST /api/v1/approvals/requests/:id/approve|reject) — resumes down the matching approve / reject edge. Always decide through the approvals API, never by calling resume directly: a direct resume strands the pending sys_approval_request, so the record stays locked and later approvals on the same record hit DUPLICATE_REQUEST.
screena user submits the formthe UI runner posting the collected inputs; a paused response carrying the next screen chains multi-step wizards under one stable runId
wait (timer)an ISO-8601 duration elapsesautomatically — a one-shot job resumes the run; after a cold boot the engine re-arms pending timers from the durable store (overdue timers resume immediately)
wait (signal)a named external eventany caller invoking resume(runId)

Parallel approvals — one aggregating node, not two pauses

"Finance and legal must both sign off, concurrently" is one approval node with two approver groups and behavior: 'unanimous' — not two parallel pauses. On entry the node opens a single sys_approval_request whose pending_approvers holds both groups (notified concurrently); it stays suspended until every group has approved (the aggregation / AND), then resumes down approve. Any one rejection finalizes immediately down reject.

{
  id: 'dual_signoff',
  type: 'approval',
  label: 'Finance + Legal Sign-off',
  config: {
    approvers: [
      { type: 'position', value: 'finance' },
      { type: 'position', value: 'legal' },
    ],
    behavior: 'unanimous',   // 'first_response' = any one decides
    lockRecord: false,
  },
}

This is the aggregating-node pattern (Camunda multi-instance / Step Functions Map): the run keeps a single program counter and pauses once, so it needs no concurrent-token machinery. Durable pause inside a hand-drawn parallel branch or loop iteration — where two unrelated positions pause independently — is a separate, larger capability (ADR-0039 Track B); the aggregating node covers the common parallel/batch-approval demand without it. Worked example: showcase_invoice_signoff in the showcase app.

Batch approval — a map node, one item at a time

"Sign off every task in the release, in turn" is a map node: it runs a per-item subflow for each element of a collection, sequentially. Unlike loop (whose body runs synchronously and cannot pause), each item is a full child run, so the per-item subflow may durably pause on its own approval — the map waits for that item's decision, then moves to the next.

{
  id: 'signoffs',
  type: 'map',
  label: 'Sign off each task',
  config: {
    collection: '{items}',          // array (e.g. the release's tasks)
    iteratorVariable: 'task',
    flowName: 'one_task_signoff',    // the per-item subflow (it may pause)
    itemObject: 'showcase_task',     // when an item is a record, it becomes the child's `$record`
    outputVariable: 'signoffResults',// each item's subflow output, collected in order
  },
}

The run holds a single program counter the whole time: only one item's approval is open at any moment; when it is decided the engine re-enters the map node to start the next item. v1 is sequential and fail-fast (the first item whose subflow fails fails the map). Concurrent fan-out (all items at once) is the larger ADR-0039 Track B work. Worked example: showcase_release_signoffshowcase_one_task_signoff.

Nested pause — pausing inside a subflow

A pausing node inside a subflow suspends the whole chain as linked runs: the child run pauses at its node, and the parent pauses at the subflow node (correlation: 'subflow:<childRunId>'). Both continuations are durable, and the chain resolves from either end:

  • deciding the child's approval (or its timer firing) completes the child and bubbles its output back into the parent, which continues with the same ${nodeId}.output / outputVariable mapping as a synchronous subflow;
  • resuming the parent run id (what a UI holds from the original launch) delegates down to the suspended child — multi-screen child wizards keep the parent run id stable.

A child that fails terminally after the pause fails every waiting ancestor, so no run is stranded as resumable-forever. See the worked example pair in the showcase app: showcase_project_closure invokes the reusable showcase_closure_signoff approval subflow and notifies the owner with the bubbled decision.

Observing runs

GET /api/v1/automation/{flow}/runs                 # run history (status, steps, error)
GET /api/v1/automation/{flow}/runs/{runId}         # one run
GET /api/v1/automation/{flow}/runs/{runId}/screen  # re-fetch a paused screen

Each run's steps[] records every executed node — including loop iterations, parallel branch bodies, and try/catch region steps — and the Studio flow designer surfaces the same data in its Runs side panel. Run history is an in-memory ring buffer (the durable rows in sys_automation_run hold only live pauses), so history starts fresh on each boot.

Edges

Edges connect nodes and define the execution path:

{
  id: 'edge_1',
  source: 'check_status',
  target: 'send_email',
  type: 'default',
  condition: "status == 'approved'",
  label: 'Approved',
}
PropertyTypeRequiredDescription
idstringUnique edge identifier
sourcestringSource node ID
targetstringTarget node ID
typeenumoptional'default' (success), 'fault' (error), 'conditional' (expression-guarded), or 'back' (declared back-edge, ADR-0044); defaults to 'default'
conditionstringoptionalBoolean expression for branching
labelstringoptionalLabel displayed on the connector

Variables

Flows use variables to pass data between nodes and to/from callers:

variables: [
  { name: 'input_id', type: 'text', isInput: true, isOutput: false },
  { name: 'result', type: 'object', isInput: false, isOutput: true },
  { name: 'counter', type: 'number', isInput: false, isOutput: false },
]
PropertyTypeDescription
namestringVariable name
typestring'text', 'number', 'boolean', 'object', 'list'
isInputbooleanAvailable as input parameter
isOutputbooleanAvailable as output parameter

Error Handling

Configure how errors are handled during flow execution:

errorHandling: {
  strategy: 'retry',           // 'fail' | 'retry' | 'continue'
  maxRetries: 3,
  retryDelayMs: 5000,
  fallbackNodeId: 'error_handler',
}
PropertyTypeDescription
strategyenum'fail' (stop), 'retry' (retry), 'continue' (skip)
maxRetriesnumberMaximum retry attempts (0-10)
retryDelayMsnumberDelay between retries (ms)
fallbackNodeIdstringNode to execute on failure

Discovery & Registration

You almost never call engine.registerFlow() directly. The AutomationServicePlugin (@objectstack/service-automation) auto-discovers every inline flow definition during its start() phase by reading the ObjectQL schema registry:

// inside AutomationServicePlugin.start()
const ql = ctx.getService('objectql');
const flows = ql.registry.listItems('flow');
for (const flow of flows) {
  engine.registerFlow(flow.name, flow);
}

Any flow attached via the defineStack / manifest.register() pipeline is picked up automatically:

import { defineStack } from '@objectstack/spec';
import { approvalFlow } from './flows/approval';

export default defineStack({
  manifest: { id: 'com.example.crm', version: '1.0.0', type: 'app', name: 'CRM' },
  objects: [...],
  flows: [approvalFlow],   // ← registered with the engine on boot
});

The plugin is a soft dependency on metadata — it tolerates running without MetadataPlugin and it logs (not throws) on per-flow registration failures so one broken flow does not abort startup.

Console Flow Viewer & Test Runner

Every flow can surface in the Console metadata browser under /_console/. ObjectUI's flow viewer replaces the default JSON inspector with rich tabs when the flow viewer plugin is installed:

TabComponentPurpose
OverviewFlowViewerRenders trigger type, variables, nodes, edges, and errorHandling as inspector cards
RunFlowTestRunnerAuto-generates a form for every isInput: true variable (with type-aware coercion for number / boolean / object / list), executes the flow against the per-project kernel, and shows the returned outputs + run id
RunsFlowRunsPanelLists historical executions for the selected flow with status, duration, and a deep-link to the run record

The runner posts to the standard automation execute endpoint, scoped to the active project. All three components participate in the Studio authentication / project-scope context, so they work identically in single-project mode and in cloud mode.

Flows in practice

Flows automate business processes as explicit node graphs. Use them for field updates, notifications, record creation, HTTP calls, decisions, waits, screens, subflows, and approval pauses. The old standalone Workflow Rule authoring model is retired; model the same logic as a Flow.

Record-change flow

import type { Flow } from '@objectstack/spec/automation';

export const hotLeadFollowUp: Flow = {
  name: 'hot_lead_follow_up',
  label: 'Hot Lead Follow Up',
  type: 'record_change',
  status: 'active',
  nodes: [
    {
      id: 'start',
      type: 'start',
      label: 'Start',
      config: {
        triggerType: 'record-after-create',
        objectName: 'lead',
        condition: "record.rating == 'hot'",
      },
    },
    {
      id: 'create_task',
      type: 'create_record',
      label: 'Create Follow-up Task',
      config: {
        object: 'task',
        fields: {
          subject: 'Follow up on hot lead',
          related_to: '{record.id}',
          priority: 'high',
        },
      },
    },
    {
      id: 'notify_owner',
      type: 'notify',
      label: 'Notify Owner',
      config: {
        recipients: '{record.owner}',
        title: 'New hot lead',
        message: 'Hot lead created: {record.name}',
        channels: ['inbox'],
      },
    },
    { id: 'end', type: 'end', label: 'End' },
  ],
  edges: [
    { id: 'e1', source: 'start', target: 'create_task' },
    { id: 'e2', source: 'create_task', target: 'notify_owner' },
    { id: 'e3', source: 'notify_owner', target: 'end' },
  ],
};

Scheduled flow

export const contractExpirationCheck: Flow = {
  name: 'contract_expiration_check',
  label: 'Contract Expiration Check',
  type: 'schedule',
  status: 'active',
  nodes: [
    {
      id: 'start',
      type: 'start',
      label: 'Start',
      config: {
        triggerType: 'schedule',
        schedule: { type: 'cron', expression: '0 0 * * *', timezone: 'UTC' },
      },
    },
    { id: 'find_expiring', type: 'get_record', label: 'Find Expiring Contracts' },
    { id: 'notify_owners', type: 'notify', label: 'Notify Owners' },
    { id: 'end', type: 'end', label: 'End' },
  ],
  edges: [
    { id: 'e1', source: 'start', target: 'find_expiring' },
    { id: 'e2', source: 'find_expiring', target: 'notify_owners' },
    { id: 'e3', source: 'notify_owners', target: 'end' },
  ],
};

Update-triggered flow

Trigger on a record update and compare against the previous value:

// Flow: Update probability
flows: [
  {
    name: 'update_probability',
    label: 'Update Probability',
    type: 'record_change',
    status: 'active',
    nodes: [
      {
        id: 'start',
        type: 'start',
        label: 'Start',
        config: {
          triggerType: 'record-after-update',
          objectName: 'opportunity',
          condition: 'record.stage != previous.stage',
        },
      },
      { id: 'update_probability', type: 'update_record', label: 'Update Probability' },
      { id: 'end', type: 'end', label: 'End' },
    ],
    edges: [
      { id: 'e1', source: 'start', target: 'update_probability' },
      { id: 'e2', source: 'update_probability', target: 'end' },
    ],
  },
],

Best practices

DO:

  • Keep flows simple and focused
  • Document the business logic
  • Test recursion and retry behavior
  • Use scheduled flows for batch updates

DON'T:

  • Create uncontrolled loops
  • Update too many fields in one node
  • Use triggers when a declarative flow is enough
  • Mix unrelated concerns in one flow

On this page