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
| 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 |
Flow Types
| 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 |
Nodes
Each node performs a specific action in the flow.
Node Types
| 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 |
notify | Send an outbound notification via the messaging service |
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 |
connector_action dispatches against the runtime connector registry, which connector plugins populate. The three generic executors — rest, 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)
}| 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 |
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_eventremain 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 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) |
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_signoff → showcase_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/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.
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 screenEach 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',
}| 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 |
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 },
]| 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 |
Error Handling
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 |
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:
| 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.
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
Related
- Workflow Metadata — Event-triggered automation rules
- Object Metadata — Objects that flows operate on
- Validation Metadata — Data validation rules