---
title: Flow Metadata
description: 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

```typescript
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: 'screen',
      label: 'Manager Approval',
      config: { screenType: 'approval' },
    },
    { 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` | ✅ | Version number |
| `status` | `enum` | ✅ | `'draft'`, `'active'`, `'obsolete'`, `'invalid'` |
| `type` | `FlowType` | ✅ | Flow trigger type (see below) |
| `template` | `boolean` | ✅ | Is this a reusable subflow template |
| `runAs` | `enum` | ✅ | `'system'` or `'user'` execution context |
| `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_request` | Make an HTTP API call |
| `script` | Execute custom JavaScript |
| `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 |

### Node Structure

```typescript
{
  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):**

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

**Create Record:**

```typescript
{
  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:**

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

**Script:**

```typescript
{
  id: 'calculate',
  type: 'script',
  label: 'Calculate Discount',
  config: {
    code: `
      const discount = input.amount > 1000 ? 0.1 : 0.05;
      return { discount_rate: discount };
    `,
  },
}
```

## 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.

```typescript
{
  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.

```typescript
{
  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).

```typescript
{
  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_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 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`.

```typescript
{
  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](https://github.com/objectstack-ai/framework/blob/main/docs/adr/0039-token-scope-tree-execution.md)
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.

```typescript
{
  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](https://github.com/objectstack-ai/framework/blob/main/docs/adr/0039-token-scope-tree-execution.md)
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` / `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:

```typescript
{
  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` | ✅ | `'default'` (success) or `'fault'` (error) |
| `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:

```typescript
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:

```typescript
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:

```typescript
// 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:

```typescript
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.

## Related

- [Workflow Metadata](./workflow) — Event-triggered automation rules
- [Object Metadata](./object) — Objects that flows operate on
- [Validation Metadata](./validation) — Data validation rules
