| 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.
{/* os:check */}
const approvalFlow = {
name: 'order_approval',
label: 'Order Approval Flow',
type: 'record_change',
version: 1,
status: 'active',
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' },
],
};| 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) |
runAs |
enum |
optional | 'system' (elevated, bypasses RLS) or 'user' (the triggering user, RLS-respecting; the default). A 'user' run that resolved no trigger user has nothing to scope to, so its data operations are refused — declare 'system' for schedule / time-relative / API triggers and for record-change flows fired by a write that carried no user |
variables |
FlowVariable[] |
optional | Input/output variables |
nodes |
FlowNode[] |
✅ | Flow nodes |
edges |
FlowEdge[] |
✅ | Connections between nodes |
errorHandling |
object |
optional | Error handling strategy |
successMessage |
string |
optional | Toast a screen-flow runner shows on terminal success (plain string — {var} is not interpolated) |
errorMessage |
string |
optional | Toast a screen-flow runner shows on failure, instead of the raw error |
| 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 |
notify |
Send an outbound notification via the messaging service |
script |
Call a named callable — a registered function (config.function) or a built-in side-effect marker (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. Connectors is the full authoring guide — provider config contracts, credentialRef auth, and failure modes.
{
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 |
string |
✅ | Node type — a built-in id from the table above or a plugin-registered one. Per ADR-0018 the spec does not gate this with a closed enum; it is checked against the live action registry at registerFlow() |
label |
string |
✅ | Display label |
config |
object |
optional | Type-specific configuration — the registered executor's configSchema owns its shape. Keys that schema does not declare are rejected at registerFlow(), and the built-in executors parse() the value against their Zod contract before running (#4277) |
connectorConfig |
object |
optional | { connectorId, actionId, input } for a connector_action node |
position |
{ x, y } |
optional | Visual position on canvas |
timeoutMs |
number |
optional | Per-node execution timeout |
inputSchema |
object |
optional | Declared input parameter types, for Studio form generation and runtime validation |
waitEventConfig |
object |
optional | wait-node event descriptor (eventType, timerDuration, signalName, timeoutMs, onTimeout) |
boundaryConfig |
object |
optional | BPMN boundary-event descriptor (interop) |
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 never evaluates an arbitrary JavaScript string —
it names a callable. Two forms:
config.function— a function registered throughdefineStack({ functions }).config.inputsis{var}-interpolated and handed to it;config.outputVariablebinds the returned value as a flow variable, so a later declarative node persists it. This is the supported way to run server logic.config.actionType— one of the two built-in side-effect markers,'email'or'slack'. These are logger-backed: they record the intent and succeed, they do not deliver anything — reach for anotifynode when you want real delivery. Any otheractionTypevalue is treated as a registered-function name — except the marker'invoke_function', which means "call the function named inconfig.function" and errors if that key is missing.
Inline config.script (a JS source body) is recognized but not executed —
the built-in runtime has no server-side JS sandbox, so such a node warns and
no-ops. A script node that names neither a built-in action nor a registered
function fails the step loudly rather than passing silently.
{
id: 'score_lead',
type: 'script',
label: 'Score Lead',
config: {
function: 'scoreLead', // registered via defineStack({ functions })
inputs: { rating: '{record.rating}' }, // {var} templates resolve against flow variables
outputVariable: 'leadScore', // later: fields: { score: '{leadScore}' }
},
}It takes its inputs, returns a value, and a later declarative node
persists that value. Data I/O stays on the flow graph — the function itself does
no writes, and the runtime hands it nothing to write with (ctx carries
input / variables / automation / logger, no data engine). The script
action publishes this as handlerContract: 'pure' on its
action descriptor.
The rule is load-bearing, not stylistic. Run summaries report
what a run did to the data, and a script step reports no record metrics
precisely because of it: every write a pure function causes is a downstream
create_record / update_record that counts itself. A function that writes
behind the platform's back makes its run claim it wrote nothing — the durable
sys_automation_run row then reads exactly like a broken sweep, permanently.
If a function genuinely must write (an upstream billing system, a legacy API), declare it so the run stays honest:
defineStack({
functions: {
scoreLead: (ctx) => ({ score: 42 }), // pure — the default
syncBilling: { handler: syncBilling, effect: 'writes' }, // declared writer
},
});A step that calls a declared writer is counted as an effect the platform cannot
measure (unmeasured), never as zero — so the broken-sweep query
selected > 0 AND acted = 0 AND unmeasured = 0 stops firing on that flow, and
keeps working on every other flow that calls a function. Declaring changes what
is reported, not what is allowed: an undeclared writer is still counted as
having written nothing, and no runtime check can catch it — a function is
ordinary host code and may close over a client at module scope.
Screen (flat fields):
The default shape. Each field is collected as a bare flow variable, so a
later node reads {subject}, not {screen_1.subject}.
{
id: 'screen_1',
type: 'screen',
label: 'Conversion Details',
config: {
fields: [
{ name: 'createOpportunity', label: 'Create Opportunity?', type: 'boolean',
required: true, defaultValue: false },
{ name: 'opportunityName', label: 'Opportunity Name', type: 'text',
required: true,
visibleWhen: 'createOpportunity == true' },
],
},
}visibleWhen is bare CEL over the screen's own field names — not the {var}
template dialect the rest of a node's config uses. The client re-evaluates it
as the user types, against the values collected so far, which is why it cannot
be a server-interpolated template. Omit it for a field that is always visible.
A hidden field is not collected, and is not enforced as required — so
required on a visibleWhen field means "required when shown". That is what
lets the example above ask for an opportunity name only when one is being
created.
Two things worth knowing:
- Give a
requiredboolean adefaultValue. An untouched checkbox holds no value at all, which counts as unanswered — withoutdefaultValue: falsethe user cannot express "no" by leaving it clear. - A broken predicate fails open (the field stays visible) rather than hiding
an input the flow may be waiting on.
registerFlow()does check the predicate as bare CEL — a{var}template or a syntax error is a loud registration failure that quotes the source and locates it asconfig.fields[N].visibleWhen— but a predicate that parses and simply names the wrong field still shows up as a field that never hides.
Screen (object form):
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', 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. Decide through the approvals API; the resume route above refuses an approval pause outright (see below). |
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) |
The resume route is generic, so the node the run is parked on decides
whether a raw resume is a legitimate continuation. Every action descriptor
carries a resumeAuthority:
'any'(the default —screen,wait, your own pausing nodes): the caller supplies the continuation and the route is the intended door.'service'(approval): continuing is a side effect of a decision that some service must authorize and record first, so only that service may drive it.ApprovalService.decideenforces the approver slate, writes thesys_approval_actionrow, mirrors the status field — then resumes.
A resume of a 'service' pause through the route answers 403 and changes
nothing: the request stays pending and the run stays parked, so the real
decision can still land. (Before this gate a raw resume walked the approve
edge with no decision recorded, leaving the sys_approval_request row and the
run permanently disagreeing.)
The gate follows a linked-run pause down to the child, so the run id a launcher happens to hold is not a way around it — in both directions it reads the item rather than the loop:
subflow— the signal is delegated to the suspended child, so the child's node is where it lands;map— the signal is not delegated (the node re-runs to start the next item), so continuing here would advance the batch past an item whose decision is still open. Refused while that item is service-gated; the map moves on when the item completes through its owning service.
A second rule on the same seam: a resume signal may not write the engine's
$ namespace ($runId, $record, $flowName, <nodeId>.$mapItemDone, …) —
those are the engine's own handoff variables, and a caller who could set them
could forge a map item's recorded result or re-point the run id an approval /
wait node correlates on. It covers both signal fields: inputs land under
their plain names, and output keys land under the suspended node's id —
which for a map-parked run is the map node itself, i.e. the very same reserved
key. A reserved name answers 400, nothing is applied (not even legitimate
keys sent alongside it), and the run stays parked. Ordinary author variables are
unaffected, $ mid-name (price$) included.
Registering a pausing node of your own? Declare resumeAuthority: 'service' on
its descriptor when the decision to continue belongs to your service rather
than to whoever holds the run id.
"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.
"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. Resuming the map's own run id while an item is
still awaiting a service-gated decision is refused (403) — otherwise the
batch would step past that item as if it had been decided. 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. The resume gate delegates with it: if the child is
parked on an
approval, resuming the parent is refused too.
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 — 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.
A run that reports success: true has not told you it did its job. A scheduled
sweep that selects thirty records and writes none looks identical to one with
nothing to do: same green status, same empty output, same silence. Every
terminal run therefore carries a summary — on the AutomationResult, on the
run in listRuns / getRun, and in the log:
[automation] run flow=stalled_deal_sweep run=run_a1b2 status=completed durationMs=142 selected=30 acted=0 skipped=30 gate=check_stalled->send_nudge:30
| Field | Meaning |
|---|---|
selected |
Records read by the run's data nodes |
acted |
Records created / updated / deleted, plus effects dispatched (notifications delivered) |
skipped |
Node executions a closed gate prevented — one per loop iteration whose conditional edge evaluated false |
unmeasured |
Executions that reached something the platform cannot count — see below |
nodes[] |
Per-node terminal status with runs / failures / skipped and its own selected/acted |
gates[] |
Which gates closed and how often, most-skipped first |
The counts come from the node executors themselves — get_record reports what
it matched, the write nodes report what the data engine actually changed — not
from the engine guessing at a node's output shape. A node that touches no
records (decision, assignment) reports nothing at all, which is different
from reporting zero.
Some nodes reach outside the platform, and for those acted has a third
possible answer. A connector_action dispatches to an external system through a
descriptor that declares nothing about whether the action reads or writes, so
counting it 0 would understate a Salesforce create and counting it 1 would
overstate a lookup — and the overstatement is the worse one, because it makes
the broken-sweep alert never fire. Those executions increment unmeasured
instead:
| Node | Reported |
|---|---|
http, GET/HEAD/OPTIONS |
acted: 0 — a read can never write |
http, mutating method, response OK |
acted: 1 |
http, mutating method, rejected / timed out |
unmeasured — a 500 can arrive after the write landed |
http, durable: true |
acted: 1 — the outbox row is a real, durable effect |
connector_action |
unmeasured |
script, function declared pure (the default) |
nothing — a registered function is contractually pure: data I/O stays on the flow graph, so every write it causes is a downstream node that counts itself |
script, function declared effect: 'writes' |
unmeasured — the function said it writes where the platform cannot see, so the run says the count is incomplete |
The script row is a contract, not a measurement: nothing stops a registered
function from writing, so an undeclared writer still makes its run report
acted: 0. That is why the declaration exists and why it is worth using — see
the purity callout. It is also why the reverse fix was
rejected: marking every script step unmeasured would blind the detector on
every flow that calls any function, to cover the few that break the rule.
unmeasured propagates through subflow and map roll-ups, so a parent whose
child dispatched an uncountable effect knows its own acted is incomplete.
The same counts land on sys_automation_run as queryable columns
(selected_count, acted_count, skipped_count, unmeasured_count, plus a
summary_json breakdown), so a broken sweep is something you can alert on
rather than notice:
// Runs that selected work and did none of it, newest first.
const suspect = await engine.find('sys_automation_run', {
where: {
status: 'completed',
selected_count: { $gt: 0 },
acted_count: 0,
// Without this clause the alert fires on every healthy connector-driven
// flow: those runs report acted 0 because the count is INCOMPLETE, not zero.
unmeasured_count: 0,
started_at: { $gte: since },
},
orderBy: [{ field: 'started_at', order: 'desc' }],
});selected > 0 && acted == 0 && unmeasured == 0 over several consecutive runs is
a near-perfect broken-sweep detector — the case that is otherwise invisible,
because nobody is watching automation until it has already been dead for a
month. A single such run is not proof of anything: a sweep whose work is all
already done reports the same thing legitimately, which is why the signal is
consecutive runs, and why the platform reports the counts rather than raising
the alarm itself.
Rows written before summaries existed carry null counts, not 0 — "not
measured" must not read as "measured zero". The log line defaults to info;
AutomationServicePlugin's runSummaryLog: 'debug' | 'off' turns the volume
down on a very high-frequency flow without turning the measurement off.
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 CEL predicate for branching (a bare string is stored as { dialect: 'cel', source }) |
label |
string |
optional | Label displayed on the connector — cosmetic only. It does not select a path except on a branching node (decision / approval), which picks its out-edge by label. |
isDefault |
boolean |
optional | BPMN default-flow marker (interop). Accepted by the schema, but the engine does not read it — traversal selects by condition and by label. On a decision node, the fallback is the out-edge labelled default: when no conditions[] entry matches, the node emits branchLabel: 'default' |
A fault edge routes a failed node to a handler instead of aborting the
run. Without one, any node failure ends the run.
edges: [
{ id: 'e_ok', source: 'charge_card', target: 'mark_paid' },
{ id: 'e_fault', source: 'charge_card', target: 'flag_for_review', type: 'fault' },
]The handler reads what went wrong from two variables:
| Variable | Scope | Use |
|---|---|---|
{<nodeId>.error} |
the failing node | {charge_card.error} — addressable by name, so one handler shared by several fault edges can tell which node it is handling |
{$error} |
run-wide | {$error.nodeId} / {$error.message} — the most recent failure only |
A run that takes a fault branch and completes reports success, but the
failed step stays in the run trace with status: 'failure' and its message —
recovery never erases the record of what failed.
- Runtime — the world did not cooperate, and a later run could succeed: an
httpnode got a 404, a connector rate-limited or is degraded, the data engine rejected a write, a collection arrived the wrong shape, a subflow failed on its own. Routed to the fault edge. - Guard — a refuse-to-execute check found the metadata wrong. Never
routed: these stay fatal whether or not a
faultedge exists, and the run fails with the guard's own message.
A failure is a guard when both hold: re-running the flow unchanged could never
succeed, and the fix is to edit metadata. In practice that is a missing
required config key (a data node with no objectName, an http node with no
url, a subflow or map with no flowName, a connector_action with no
connectorId/actionId), a filter token that resolved to nothing so the
condition was dropped from the query, a graph that recurses past the nesting
ceiling, or a run that would execute unscoped.
The split is deliberate. A dropped filter condition does not narrow a query, it
widens it — so if guards were routable, one fault edge on a delete_record
would turn off the protection against emptying the object while the run still
reported success. Re-running changes nothing either: the fix is to correct the
metadata, which os validate will point at.
The two recovery mechanisms operate at different scopes and do not compound:
| Scope | On failure | |
|---|---|---|
fault edge |
one node | traversal continues from the handler; the run completes |
errorHandling: { strategy: 'retry', maxRetries: n } |
the whole flow | the flow re-runs from the start, up to n more times |
A failure a fault edge handled is not a flow failure, so it does not consume
a retry. That matters because flow-level retry replays every node that already
succeeded — a second notification, a second created record. Prefer a fault edge
where the failure is local; reach for retry only when replaying the whole flow
is genuinely safe.
Flows use variables to pass data between nodes and to/from callers:
{/* os:check */}
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,
}| Property | Type | Description |
|---|---|---|
strategy |
enum |
'fail' (stop) or 'retry' (re-run the whole flow). 'continue' parses but the engine branches only on 'retry', so it behaves exactly like 'fail' — use a fault edge to keep going past a failed node (default 'fail') |
maxRetries |
number |
Retry attempts after the initial one, 0–10. Under strategy: 'retry' it must be at least 1 and there is no default — see below (default 0, i.e. no retries, for the strategies that never retry) |
retryDelayMs |
number |
Delay between retries (ms) (default 1000) |
backoffMultiplier |
number |
Exponential backoff multiplier (default 1) |
maxRetryDelayMs |
number |
Ceiling on the backed-off delay (default 30000) |
jitter |
boolean |
Randomize the delay to avoid a thundering herd (default false) |
maxRetries counts the re-runs, not the total attempts: maxRetries: 2
runs the flow up to three times.
There is no default retry count. strategy: 'retry' without maxRetries — or
with maxRetries: 0 — is refused when the flow is registered:
// ❌ rejected: "retry" that retries zero times is just "fail"
errorHandling: { strategy: 'retry' }
// ✅ state the attempts
errorHandling: { strategy: 'retry', maxRetries: 3, retryDelayMs: 5000 }A retry re-runs the whole flow from the start, so every node that already
succeeded runs again — records get created again, callouts fire again. That is
too consequential a number to pick on the author's behalf, and picking 0
would make opting into 'retry' do nothing at all. The knobs above are read
only under 'retry'; a fully spelled-out block under 'fail' or 'continue'
is fine and simply ignored.
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.
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.
**A boolean gotcha:** SQLite/libSQL store booleans as `0`/`1`, and CEL's `1 != true`. Compare against the stored shape (`record.done == 1`) or normalize the value before the condition.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.
Every flow surfaces in the Console metadata browser under /_console/, where
ObjectUI's app-shell registers FlowPreview as the flow metadata preview
(registerMetadataPreview('flow', FlowPreview)) in place of the default JSON
inspector. It renders the graph on a canvas — editable (add a node, patch a
selection) only in the designer's edit mode, read-only otherwise. Opening a flow
shows the full-width canvas with no side panel; the toolbar toggles one
collapsible panel at a time:
| Side panel | Component | Purpose |
|---|---|---|
| Variables | — | The flow's declared variables with their types and input/output flags. Opt-in — it is the fallback panel, not one that opens on its own |
| Problems | ProblemsPanel |
Validation findings; selecting one reveals the offending node or edge on the canvas |
| Debug | FlowSimulatorPanel |
Steps the graph client-side, highlighting the active node, the visited nodes, and the traversed edges |
| Runs | FlowRunsPanel |
Real run history from GET /api/v1/automation/:name/runs, each run's step log nested by loop iteration / parallel branch / try-catch handler |
Actually running a flow is a separate Console page — Developer › Flow Runs
(developer/flow-runs). Its test runner auto-generates a form for every
isInput: true variable (with type-aware coercion for number / boolean /
object / list) and calls client.automation.execute(name, { params }) —
the same POST /api/v1/automation/:name/trigger
endpoint — then renders the returned result. A screen flow that comes back
paused is handed to the same FlowRunner the record and list surfaces use,
so multi-step wizards (and object-form steps) can be driven to completion
from here instead of orphaning a suspended run. A run-history panel beside it
lists recent runs and expands one for its step detail. The Console is not
project-scoped: the client comes from the app adapter, with no projectId in
the route.
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.
{/* os:check */}
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' },
],
};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' },
],
};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.
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' },
],
},
],A record_change start node binds to lifecycle events through triggerType.
The single-event tokens map one-to-one:
triggerType |
Fires on |
|---|---|
record-after-create |
insert (after) |
record-after-update |
update (after) |
record-after-delete |
delete (after) |
record-after-write |
insert or update (after) |
record-before-create |
insert (before) |
record-before-update |
update (before) |
record-before-delete |
delete (before) |
record-before-write |
insert or update (before) |
For a rule that must run on both create and update — "recompute the SLA
whenever a case is created or its priority changes" — use record-after-write
instead of duplicating the flow. write is the create-OR-update union (delete
is excluded: a write persists field data, a delete removes the row). One start
node binds both lifecycle events; exactly one fires per mutation, so it is not a
double run.
{
id: 'start',
type: 'start',
label: 'Start',
config: {
objectName: 'crm_case',
triggerType: 'record-after-write', // created OR updated
},
}To branch on which event fired, test previous — it is empty on create
(there was no prior row) and populated on update:
// Edge conditions are bare CEL (ADR-0032) — no {…} braces.
{ id: 'e_created', source: 'start', target: 'on_create', condition: 'previous == null' },
{ id: 'e_updated', source: 'start', target: 'on_update', condition: 'previous != null' },✅ DO:
- Keep flows simple and focused
- Prefer one
record-after-writeflow over two near-identical create/update copies - 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
- Hooks — data-layer interception below the node graph; Hooks vs flows covers when to drop down to one
- Workflow Metadata — why there is no standalone Workflow Rule type, and what replaces it
- Object Metadata — Objects that flows operate on
- Validation Metadata — Data validation rules