| title | Flow Metadata |
|---|---|
| description | Build visual automation with decision trees, data operations, HTTP requests, and screen interactions |
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.
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: 'role', 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' },
],
};| Property | Type | Required | Description |
|---|---|---|---|
name |
string |
✅ | Machine name (snake_case) |
label |
string |
✅ | Display label |
description |
string |
optional | Flow description |
version |
number |
optional | Version number (defaults to 1) |
status |
enum |
optional | 'draft', 'active', 'obsolete', 'invalid' (defaults to 'draft') |
type |
FlowType |
✅ | Flow trigger type (see below) |
template |
boolean |
optional | Is this a reusable subflow template (defaults to false) |
runAs |
enum |
optional | 'system' or 'user' execution context (defaults to 'user') |
variables |
FlowVariable[] |
optional | Input/output variables |
nodes |
FlowNode[] |
✅ | Flow nodes |
edges |
FlowEdge[] |
✅ | Connections between nodes |
errorHandling |
object |
optional | Error handling strategy |
| Type | Description | Trigger |
|---|---|---|
autolaunched |
Runs automatically without user interaction | Invoked by other flows or API |
record_change |
Triggered by record create/update/delete | Data mutation events |
schedule |
Runs on a schedule (cron) | Time-based |
screen |
Interactive flow with user screens | User clicks a button |
api |
Exposed as an API endpoint | HTTP request |
Each node performs a specific action in the flow.
| Type | Description |
|---|---|
start |
Flow entry point |
end |
Flow termination |
decision |
Conditional branching (if/else) |
assignment |
Set variable values |
loop |
Structured iteration container — runs a body region once per item (ADR-0031) |
parallel |
Structured parallel block — runs N branch regions concurrently, implicit join (ADR-0031) |
try_catch |
Structured try/catch/retry error handling (ADR-0031) |
create_record |
Create a new record |
update_record |
Update existing records |
delete_record |
Delete records |
get_record |
Query records |
http |
Make an HTTP API call (the deprecated alias http_request still resolves to this) |
script |
Run a custom script action (dispatched by config.actionType) |
screen |
Display a user form/screen (durable pause) |
wait |
Pause for a timer or named signal (durable pause; timers auto-resume) |
subflow |
Invoke another flow — a pause inside the child suspends both runs as a linked chain |
map |
Sequential multi-instance — run a per-item subflow for each element, one at a time (each may pause); batch approval (ADR-0039) |
connector_action |
Execute an external connector action |
{
id: 'unique_node_id',
type: 'decision',
label: 'Check Priority',
config: { /* type-specific configuration */ },
position: { x: 200, y: 100 }, // Canvas position (optional)
}| Property | Type | Required | Description |
|---|---|---|---|
id |
string |
✅ | Unique node identifier |
type |
FlowNodeAction |
✅ | Node type |
label |
string |
✅ | Display label |
config |
object |
optional | Type-specific configuration |
connectorConfig |
object |
optional | External connector settings |
position |
{ x, y } |
optional | Visual position on canvas |
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.
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.
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.
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: [] },
],
},
}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_request', 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_eventremain in the protocol as the interop representation and map onto these constructs on import/export — they are not the native authoring model.
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 node | Suspends until… | Resumed by |
|---|---|---|
approval |
a human decision | the 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. |
screen |
a user submits the form | the 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 elapses | automatically — 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 event | any caller invoking resume(runId) |
"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: 'role', value: 'finance' },
{ type: 'role', 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.
"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_signoff → showcase_one_task_signoff.
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/outputVariablemapping 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.
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 connect nodes and define the execution path:
{
id: 'edge_1',
source: 'check_status',
target: 'send_email',
type: 'default',
condition: "status == 'approved'",
label: 'Approved',
}| Property | Type | Required | Description |
|---|---|---|---|
id |
string |
✅ | Unique edge identifier |
source |
string |
✅ | Source node ID |
target |
string |
✅ | Target node ID |
type |
enum |
optional | 'default' (success), 'fault' (error), 'conditional' (expression-guarded), or 'back' (declared back-edge, ADR-0044); defaults to 'default' |
condition |
string |
optional | Boolean expression for branching |
label |
string |
optional | Label displayed on the connector |
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 },
]| Property | Type | Description |
|---|---|---|
name |
string |
Variable name |
type |
string |
'text', 'number', 'boolean', 'object', 'list' |
isInput |
boolean |
Available as input parameter |
isOutput |
boolean |
Available as output parameter |
Configure how errors are handled during flow execution:
errorHandling: {
strategy: 'retry', // 'fail' | 'retry' | 'continue'
maxRetries: 3,
retryDelayMs: 5000,
fallbackNodeId: 'error_handler',
}| Property | Type | Description |
|---|---|---|
strategy |
enum |
'fail' (stop), 'retry' (retry), 'continue' (skip) |
maxRetries |
number |
Maximum retry attempts (0-10) |
retryDelayMs |
number |
Delay between retries (ms) |
fallbackNodeId |
string |
Node to execute on failure |
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.
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:
| Tab | Component | Purpose |
|---|---|---|
| Overview | FlowViewer |
Renders trigger type, variables, nodes, edges, and errorHandling as inspector cards |
| Run | FlowTestRunner |
Auto-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 |
| Runs | FlowRunsPanel |
Lists 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.
- Workflow Metadata — Event-triggered automation rules
- Object Metadata — Objects that flows operate on
- Validation Metadata — Data validation rules