Flow Metadata
July 21, 2026 · View on GitHub
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: {
// decision expressions use {braces} around variables — see Expressions in flows
conditions: [
{ label: 'High Value', expression: '{order_amount} > 10000' },
{ label: 'Standard', expression: '{order_amount} <= 10000' },
],
},
},
{
id: 'auto_approve',
type: 'update_record',
label: 'Auto Approve',
config: { objectName: '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). Scaffolded projects (npm create objectstack) ship all three executors by default — paired with the automation capability that performs the materialization — and the showcase wires them too. 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: {
objectName: 'task',
fields: {
title: 'Follow up on {record.name}',
assignee: '{record.owner}',
due_date: '{TODAY() + 7}', // braces required — without them this writes the literal text
},
},
}
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 screen
Each run's steps[] records every executed node — including loop iterations,
parallel branch bodies, and try/catch region steps — which the Studio flow
designer surfaces, nested by iteration / branch / handler, in its Runs side
panel. Recent runs are held in an in-memory ring buffer; terminal runs
(completed / failed) are also mirrored to sys_automation_run as durable
history with a bounded step log, so listRuns / getRun still report a run's
status, steps, and failure reason after a restart or ring-buffer eviction.
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.
Expressions in flows
A flow mixes three expression dialects, and using the wrong one is the single most common way a flow silently misbehaves. Which dialect applies is decided by where the expression sits — not by what it looks like:
| Where | Dialect | Write it like | Bindings |
|---|---|---|---|
Start-node condition | CEL (bare, no braces) | record.amount > 500 | record.*, previous.*, bare field names, vars.* |
Edge condition | CEL (bare, no braces) | record.status == 'open' | same as above |
Decision-node conditions[].expression | Template compare (braces required) | {order_amount} > 10000 | flow variables by name, in {…} |
Field values in create_record / update_record | Interpolation (braces required) | 'Follow up on {record.name}', '{TODAY() + 7}' | {var}, {var.path}, {$User.Id}, {$User.Email}, {NOW()}, {TODAY()}, {TODAY() + 90} (whole days) |
- Braces missing in a decision expression —
'order_amount > 10000'isn't evaluated as a variable at all. It compares the string"order_amount"against"10000", which is always true, so the flow always takes the first branch and never tells you. Write'{order_amount} > 10000'. - Braces missing in a field value —
due_date: 'TODAY() + 7'writes the literal textTODAY() + 7into the field. Write'{TODAY() + 7}'.
The mirror mistake is putting braces into a CEL condition
('{record.amount} > 500') — CEL conditions fail loudly rather than silently,
with an error that tells you to drop the braces.
CEL conditions that fail to evaluate raise an error and stop the run — they never quietly pass. See Formulas for the CEL language itself.
Run a flow via API
Flows of any type can be launched over HTTP — this is what an external system, a scheduled job outside the platform, or the Console's test runner uses:
curl -b cookies.txt -X POST \
https://your-app.example.com/api/v1/automation/order_approval/trigger \
-H "Content-Type: application/json" \
-d '{ "recordId": "ord_123", "objectName": "order", "params": { "priority": "high" } }'
# → { "success": true, "data": { ... }, "meta": { ... } }
| Endpoint | Purpose |
|---|---|
POST /api/v1/automation/:name/trigger | Start a flow (canonical) |
GET /api/v1/automation/:name/runs | List runs (?limit, ?cursor) |
GET /api/v1/automation/:name/runs/:runId | One run's detail (404 Execution not found) |
POST /api/v1/automation/:name/runs/:runId/resume | Resume a paused run — body { inputs, output, branchLabel } |
GET /api/v1/automation/:name/runs/:runId/screen | The pending screen of a screen-flow run |
Passing inputs. Declare variables with isInput: true, then send them in
params under the same names — only declared inputs are bound. recordId and
objectName are lifted into params for you (plus an <objectName>Id alias),
and event defaults to 'manual'.
Identity. The caller's identity (user, positions, permissions, tenant) is
forwarded into the run, so a runAs: 'user' flow executes under that caller's
row-level security. See API Authentication for
credentials.
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 same
POST /api/v1/automation/:name/trigger 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: {
objectName: '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' },
],
};
Time-relative flow (declarative date sweep)
Instead of hand-writing the "find records, then act" query above — and far more
robust than a record_change flow gated on date-equality (end_date == daysFromNow(60)), which only fires if the record is edited on the exact day — a
schedule flow whose start node declares a timeRelative descriptor is swept
on a schedule (daily by default) and runs once per matching record:
export const renewalReminder: Flow = {
name: 'renewal_reminder',
label: 'Renewal Reminder',
type: 'schedule',
status: 'active',
runAs: 'system', // a sweep has no trigger user — elevate explicitly
nodes: [
{
id: 'start',
type: 'start',
label: 'Start',
config: {
timeRelative: {
object: 'contract',
dateField: 'end_date',
offsetDays: [60, 30, 7], // — or — withinDays: 30 (negative = overdue lookback)
filter: { status: 'active' }, // optional, ANDed with the date window
},
// schedule: { type: 'cron', expression: '0 8 * * *' } // optional; defaults to daily 08:00 UTC
},
},
{ id: 'notify_owner', type: 'notify', label: 'Notify Owner' },
{ id: 'end', type: 'end', label: 'End' },
],
edges: [
{ id: 'e1', source: 'start', target: 'notify_owner' },
{ id: 'e2', source: 'notify_owner', target: 'end' },
],
};
Exactly one of offsetDays (discrete T-minus days) or withinDays (a range) is
required. Requires the triggers and job capabilities. The record is on
the flow context (record.*), so the start condition and {record.*}
interpolation work as in a record-change flow.
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