How to use
- Copy the installation command and review the source package before installing it in your own agent environment.
- Check the source document for required tools, credentials and supported hosts.
- Describe your input and desired deliverable; review the output against the source material.
npx skills add https://github.com/objectstack-ai/objectstack --skill objectstack-automationBefore you start
- Installation does not include a model subscription, API allowance or permission to access your files.
- Repository stars and installation counts describe activity, not an independent quality or security test.
Rendered from the source repository. Headings, examples, code, tables, links, and referenced images are preserved.
Automation Design — ObjectStack Automation Protocol
When to Use This Skill
- You are building a visual flow (auto-launched, screen, or scheduled).
- You need a state machine or approval process for a business object.
- You are setting up event-driven triggers (record create/update/delete).
- You need scheduled automation (daily reports, data cleanup).
Predicates and conditions are CEL — everycondition/guard/entryCondition/ filtervaluehere is an Expression envelope evaluated by@objectstack/formula. A slot takes a plain CEL string; theP\...\`/cel\...\` tags wrap the same string with author-time validation. Both parse — pick one per file (the example apps use plain strings). See objectstack-formula for the CEL contract, stdlib and legacy → CEL table.
Flows — Visual Logic Orchestration
A Flow is a directed graph of nodes that execute sequentially or in parallel. Flows are the primary automation building block in ObjectStack.
Flow Types
| Type | When to Use |
|---|---|
autolaunched | Runs without user interaction — triggered by events, APIs, or other flows |
screen | Interactive — presents UI screens to the user (wizards, forms) |
schedule | Runs on a cron/interval cadence declared on the start node's `config.schedule` (daily cleanup, weekly reports) — or a per-record date sweep via config.timeRelative, see Time-relative triggers |
record_change | Fires automatically on record create/update/delete (bind via the start node's triggerType). autolaunched + the same record-* binding behaves identically — the engine reads the start node either way; record_change also opts into the trigger-readiness lint |
api | Invoked explicitly via the API / engine.execute(), or bound as an inbound webhook: POST /api/v1/automation/hooks/:flowName/:hookId (see Inbound webhook triggers below) |
Flow Node Types
Flows are built from 20 built-in node types (the FlowNodeAction seed set — plugins register more via registerNodeExecutor, e.g. approval below):
Control Flow
| Node | Purpose |
|---|---|
start | Entry point — every flow has exactly one |
end | Exit point — can have multiple (early exit, error exit) |
decision | Conditional branching — routed by edge `condition` predicates, not node config (see the approval example below) |
loop | Iterate a nested `config.body` region once per item of config.collection; iteratorVariable (default item) and optional indexVariable bind inside it, maxIterations caps it |
parallel | Fan out into config.branches[] (≥ 2 regions) run concurrently, joined implicitly at block end — no split/join pair to mis-wire |
try_catch | Run config.try; on failure run config.catch with the error in errorVariable (default $error); config.retry re-runs try with backoff first. No `finally` — the container's ordinary out-edges are the continuation |
map | Sequential multi-instance — invoke a subflow once per item of a collection; each iteration may pause (batch approvals) |
wait | Pause execution until a timer elapses or a named signal arrives |
subflow | Invoke another flow (reusable composition) |
parallel_gateway / join_gateway / boundary_event | Not author-facing — BPMN-interop forms the mapper lowers a parallel / try_catch container INTO (automation/control-flow.zod.ts). Author the container |
Data Operations
| Node | Purpose |
|---|---|
assignment | Set variable values |
create_record | Insert a new record |
update_record | Modify existing records |
delete_record | Remove records |
get_record | Fetch records with filters — there is no `query_record` node (that name has no executor and throws) |
External Integration
| Node | Purpose |
|---|---|
http | Call an external HTTP API — canonical since protocol 11.0; http_request survives only as a deprecation-window alias |
notify | Send a notification through the messaging service (inbox channel by default) |
connector_action | Invoke a pre-built integration connector |
script | Call a registered function named by config.function (see Valid-but-silently-wrong #3) |
screen | Display a UI form to the user (screen flows only) |
Human Decision
| Node | Purpose |
|---|---|
approval | Route a record for human sign-off — suspends the run until a decision, then continues down the approve / reject branch (contributed by plugin-approvals) |
notify — the most-used node type
NotifyConfigSchema (automation/io-node-config.zod.ts) is strictObject — an undeclared key is a named parse error. RAW keys never interpolate: a {token} in one is forwarded verbatim, never resolved.
{ id: 'tell_owner', type: 'notify', label: 'Notify Owner', config: {
recipients: '{record.assignee}', // REQUIRED — id, CSV, or string[]
title: 'Done: {record.title}', // inline path; XOR `template` (RAW, localizable)
message: 'Closed by {$User.Id}', // body; only with inline `title`
topic: 'task', // RAW; default 'notify'
severity: 'warning', // RAW; CLOSED enum info|warning|critical
channels: ['inbox'], // RAW; default inbox
sourceObject: 'task', // click-through: a PAIR, else dropped
sourceId: '{record.id}', // at execute time
actionUrl: 'https://…/tasks/123', // overrides the synthesized link
} }Flow Variables
Every flow defines input/output variables. variables is an array of { name, type, isInput, isOutput } entries — not a name-keyed map, and there is no label property on a variable:
variables: [
{
name: 'case_id',
type: 'text',
isInput: true, // passed in when flow is invoked
isOutput: false,
},
{
name: 'approval_result',
type: 'boolean',
isInput: false,
isOutput: true, // returned when flow completes
},
],Flow Example — Auto-Escalate Overdue Cases
Nodes connect via `edges`, not a `next` property. The engine traversesflow.edges({ source, target }); a barenext:on a node is refused.update_recordselects rows with `filter` — an ObjectQLwheremap offield → value/field → { $operator: value }, NOT the UI view-filter[{ field, operator, value }]triples — and writes with `fields` (a single call updates every matching row — no per-row loop needed).labelis required on the flow and on every node, and every path through the graph must reach anendnode.
<!-- os:check -->
import { defineFlow } from '@objectstack/spec';
export const EscalateOverdueCasesFlow = defineFlow({
name: 'escalate_overdue_cases',
label: 'Escalate Overdue Cases',
type: 'schedule',
status: 'active',
runAs: 'system', // a scheduled run has no trigger user — elevate explicitly
nodes: [
{
id: 'start',
type: 'start',
label: 'Daily at 09:00',
// The cadence lives HERE, on the start node's config — FlowSchema has NO
// top-level `schedule` key (one there is a named parse error, not a silent
// strip). A bare cron string also works: schedule: '0 9 * * *'. Do NOT use
// the cron`…` tagged template — its envelope is not a recognized shape.
config: { schedule: { type: 'cron', expression: '0 9 * * *' } },
},
{
id: 'escalate_overdue',
type: 'update_record',
label: 'Escalate Overdue Cases',
config: {
objectName: 'support_case',
// which rows to update — `filter` is a `where` map, not filter triples
filter: {
status: { $in: ['new', 'open'] },
due_date: { $lt: '{TODAY()}' }, // template token → today's date at run time
},
// what to write — `fields`, not `values`
fields: { status: 'escalated' },
},
},
{
id: 'notify_manager',
type: 'http',
label: 'Notify Manager',
config: {
url: 'https://hooks.slack.com/services/...',
method: 'POST',
body: { text: 'Escalated overdue support cases.' },
timeoutMs: 10000, // unset = NO timeout at all — always set one
},
},
{ id: 'end', type: 'end', label: 'End' },
],
edges: [
{ id: 'e1', source: 'start', target: 'escalate_overdue' },
{ id: 'e2', source: 'escalate_overdue', target: 'notify_manager' },
{ id: 'e3', source: 'notify_manager', target: 'end' },
],
});Failure routing & runAs
Handling a failed node: a `fault` edge.{ source, target, type: 'fault' }routes a failed node to a handler instead of ending the run. `type: 'fault'` is what routes — a `label: 'error'` alone does nothing: the edge stays ordinary, and every unconditional out-edge traverses on SUCCESS, so the handler would run when the node succeeds and never when it fails (objectstack validatereportsflow-error-label-not-fault). A handled failure does NOT consume a flow-levelerrorHandling.retry, which replays the flow from the start — prefer a fault edge when the failure is local. The handler reads{<nodeId>.error}(or run-wide{$error}). The run then reports success, and the failed step stays in the trace. It is not a way past a guardrail.
| ROUTES (runtime failure) | Does NOT route (fatal either way) | |:--|:--| | 404, rate-limit, rejected write, failed subflow | missing required config key (objectName, url, flowName, connectorId/actionId); filter token that resolved to nothing; graph past the nesting ceiling; unscoped run |
Routing a guard refusal is worse than the failure: a dropped filter condition widens the query, so a routed delete_record empties the object while the run reports success. objectstack validate names the offending template.
Writing a `readonly` field? Set `runAs: 'system'`.readonly: truegoverns the end-user surface: under the defaultrunAs: 'user', the engine strips areadonlyfield from any non-system write —create_recordandupdate_recordalike — the step reports success but the value never lands, and the drop is named in the step's warnings. A flow that maintains areadonlyfield (approval stamps, conversion flags, SLA markers, rollups) must runrunAs: 'system', the trusted-writer channel.os validate/os buildfail arunAs:'user'update_recordthat writes areadonlyfield, so the mismatch surfaces at build time, not as wrong data days later. (readonlyWhenfields are the same story, per record state — flagged as a warning.) Do not work around this by removingreadonly; that loses the field's edit protection.
Elevate the write, not the flow. Ascreenflow staysrunAs: 'user'. When one step in it must write areadonlyfield, move that step into a dedicatedrunAs: 'system'flow and call it from asubflownode — raising the whole flow silently elevates every other write in it. A `runAs: 'system'` sweep must pin its organization. System context has no trigger user, so nothing narrows the query: a scan or rollup with no organization predicate reads and writes across every tenant. The tenant column is platform-injected — filter on it, never re-declare it per object.
A hook elevates itself with `runAs`, never with `sudo`. An object hook (objectstack-data) declares its ownrunAs: 'system' | 'user' | 'inherit'— default'inherit', the context of the write that fired it — scoping that hook'sctx.apidata operations only, on the in-processhandlerand the sandboxedbodyalike. A'user'hook whose trigger resolved no user has nothing to scope to: itsctx.apidata operations are refused (HOOK_UNSCOPED_DATA_ACCESS, 403) rather than run unscoped — declarerunAs: 'system'when the elevation is intended.sudois not a hook key.
Filter tokens (config.filter)
The one slot where two {…} dialects meet, and the one whose failure widens a query instead of narrowing it.
- Precedence — flow variables win, placeholders pass through. The flow
template engine runs first. A whole-string token it resolves is a flow value; one it does not resolve that IS a recognised filter placeholder ({current_user_id}, {current_year_start}) passes through verbatim for the query engine to expand. So a flow variable named after a placeholder shadows it. Only filter gets this hand-off — in title, message, fields and url a bare {current_year_start} is a nonsense reference.
- Static checkability splits by position. A
{record.…}token **inside a
filter naming an unknown field, or hopping a relation the start node does not list in `config.expand`, is an ERROR* at `objectstack validate`: it resolves to nothing, the condition is DROPPED, and the node refuses to execute. The same reference outside a filter (message body, `http` url, write payload) only renders an empty string — a warning. A `{var}` naming a flow variable or node output is not statically checkable at all*.
Valid-but-silently-wrong (passes build, fails at runtime)
These are legal metadata that authors — AI especially — get wrong. Most are now caught by objectstack build (a hard error, or an advisory warning), but write them right the first time:
- Flow node VALUE interpolation uses SINGLE braces. Value fields on a node's
config (fields, inputs, notify message/title, …) interpolate {token}:
{var}/{record.title}— variable / record field{record.tags.0}— array index (e.g. amultiple: truelookup, stored as an array){$User.Id}/{NOW()}/{TODAY() + 30}— current user / date macros{round(x)}{floor(x)}{ceil(x)}{abs(x)}{min(a,b)}{max(a,b)}—
mirror the CEL stdlib 1:1. round is integer-only (no round(x, 2)); for N decimals write {round(x * 100) / 100} (scale 2)
- anything without
{…}is a literal
❌ body: '{{ai_reply}}' — double-brace is the formula / template-field dialect, not flow values ❌ ticket: '$source.id' — a bare $ref is a literal string, not interpolated ✅ body: '{ai_reply}', ticket: '{source.id}' ❌ '{ROUND(x, 2)}' / '{Math.round(x)}' / '{(x).toFixed(2)}' — any other name in call position fails the node with a named error naming the supported set. The build does not catch these (conditions are checked, call-position names are not) and a fault edge cannot route it.
- `create_record`'s `outputVariable` holds the created RECORD, not its id.
Reference a field explicitly. ❌ update_record … fields: { ref: '{newRec}' } → yields the whole record object ✅ fields: { ref: '{newRec.id}' }
- `script` nodes call a registered function — that is all they do. Set
config.function to a function registered via defineStack({ functions: { my_fn: (ctx) => … } }). It is required: an empty script node refuses at execute, and one pointing at an unregistered function fails loudly.
There is no other dispatch form: use `notify` for delivery, a `connector_action` or http webhook for Slack, a function for logic.
A flow `function` is a PURE compute step — it does NOT read/write the database. It receives ctx.input and returns a value; config.outputVariable exposes that value as a flow variable, and a later declarative node persists it. Keep data effects on the flow graph (visible, governed, build-checkable):
// ❌ DON'T: expect the function to update the record itself (it has no data API)
// ✅ DO: function returns values → outputVariable → update_record persists
{ id: 'ai', type: 'script', config: {
function: 'helpdesk.aiTriageStub', // returns { ai_category, ai_sentiment, … }
inputs: { ticketId: '{record.id}' }, // inputs are interpolated
outputVariable: 'ai',
} },
{ id: 'apply', type: 'update_record', config: {
objectName: 'helpdesk_ticket',
filter: { id: '{record.id}' },
fields: { ai_category: '{ai.ai_category}', ai_sentiment: '{ai.ai_sentiment}' },
} },defineStack({ functions: { 'helpdesk.aiTriageStub': (ctx) => ({ ai_category: 'other', … }) } }). If you genuinely need data-lifecycle side effects (read/write other records), that's an L2 hook (objectstack-data) — hooks get ctx.api; flow functions don't.
A function that writes where the platform cannot see declares it, so the run reports "cannot say" rather than acted: 0:
defineStack({ functions: {
'helpdesk.aiTriageStub': (ctx) => ({ ai_category: 'other' }), // pure — the default
'billing.sync': { handler: syncBilling, effect: 'writes' }, // declared writer
} });- Conditions are bare CEL — the stdlib is what you may call bare.
now(),
today(), daysFromNow(n), daysAgo(n), daysBetween(a, b), isBlank(v), coalesce(a, b), abs/round/min/max, upper/lower/contains/matches, plus CEL built-ins (has, size, int, string, …) — see objectstack-formula for that table: it is CEL_STDLIB_FUNCTIONS, the bare-callable public subset, so receiver methods (called on a value, never bare) are not in it. An UNKNOWN function (PRIOR(), a typo'd name) and a {…}-wrapped field ref both fail the build: a brace is a template, not CEL — write record.x, not {record.x}.
- `notify` reports SUCCESS when the `messaging` capability is absent. The
executor logs no messaging service registered and returns success with output: { delivered: 0, failed: 0, skipped: true } and metrics.acted: 0 — a green run that delivered nothing. Declare messaging in requires.
State Machines & Approvals
A record's state machine locks the legal transitions of its status field so that automation — increasingly AI-generated — cannot drive a record into an illegal state.
State Machine — a state_machine validation rule (ADR-0020)
Since ADR-0020 there is no `workflow` metadata type and no object.stateMachines map. A record state machine is one `state_machine` validation rule in the object's validations array: a flat field + { from: [allowedTo] } transition table. It is enforced on the write path — an update whose field moves to a state not listed for the current state is rejected with the rule's message. A from state mapped to [] is a declared dead-end.
{
type: 'state_machine',
name: 'case_lifecycle',
label: 'Case Lifecycle',
field: 'status', // the field that holds the state
message: 'Invalid status transition.',
initialStates: ['new'], // states a record may be CREATED in
transitions: {
new: ['open'],
open: ['escalated', 'resolved'],
escalated: ['open', 'resolved'],
resolved: ['open', 'closed'],
closed: [], // final — no outgoing transitions
},
}Notes:
- One rule per field. Parallel lifecycles (e.g.
status+payment_status)
are N separate state_machine rules, one per field.
- `initialStates` (optional) gates INSERT: a record created with its
state field outside this list is rejected. transitions only governs updates, so without it a record can be born mid-flow (e.g. created already resolved). Omit to keep the legacy no-check-on-insert behavior.
- Conditional transitions / side effects are NOT part of the machine. A
guard is expressed as a sibling script / conditional validation rule; "do something when the state changes" is a record-triggered Flow (ADR-0019) — a record_change flow whose start-node condition gates on the transition, e.g. previous.status != 'escalated' && record.status == 'escalated'.
- Introspection:
GET /api/v1/meta/object/:name/state/:field?from=:state
returns the legal next states so UIs/agents can read the transition table instead of hard-coding it (next: null = no FSM governs the field, or ?from= was omitted — always pass from).
- An unlisted `from` state is NOT guarded. An update whose current state is
not a key of transitions is treated leniently (no lock) — list every state you want guarded rather than relying on an implicit "any → any".
- Predicate conditions in sibling rules evaluate against the merged record in
the `record.<field>` CEL scope (bare field names do not resolve).
Approvals (Flow Nodes)
Since ADR-0019 there is no standalone approval-process type. An approval is authored as an Approval node (type: 'approval') on an ordinary flow — the run suspends when it reaches the node and resumes down the node's approve / reject out-edge once a decision is recorded. Multi-step review is just successive Approval nodes wired together on the canvas, so the whole review is one diagram a reviewer (or AI) can read end-to-end.
There is noapprovals: [...]stack collection — approval flows live in your normalflows: [...]. The approval state (sys_approval_request/sys_approval_action, the record lock, the status mirror, approver resolution) is owned byplugin-approvals.
// A record-triggered flow: high-value opportunities need manager sign-off,
// and director sign-off too when the amount clears 500k.
{
name: 'opportunity_discount_approval',
label: 'Opportunity Discount Approval',
type: 'record_change',
nodes: [
// Record-change flows bind via the START NODE's config — there is no
// separate top-level `trigger`. `triggerType` is one of
// `record-(before|after)-(create|update|delete)`; `condition` (bare CEL)
// gates whether the flow launches.
{
id: 'start',
type: 'start',
label: 'On Opportunity Update',
config: {
objectName: 'opportunity',
triggerType: 'record-after-update',
condition: cel`record.amount > 100000`,
},
},
{
id: 'manager_review',
type: 'approval',
label: 'Sales Manager Review',
config: {
approvers: [{ type: 'position', value: 'sales_manager' }],
behavior: 'first_response', // or 'unanimous' / 'quorum' / 'per_group'
lockRecord: true, // lock the record while pending
approvalStatusField: 'approval_status', // mirror pending|approved|rejected|recalled onto the row
},
},
// Decision routing lives on the OUT-EDGES, not in node config: the engine
// evaluates each out-edge's `condition` and follows every match — and an
// out-edge with NO condition ALWAYS runs (all such edges execute in
// PARALLEL). Guard every branch with a condition — see e4/e5 below.
{ id: 'needs_director', type: 'decision', label: 'Needs Director?' },
{
id: 'director_signoff',
type: 'approval',
label: 'Sales Director Sign-off',
config: {
approvers: [{ type: 'position', value: 'sales_director' }],
behavior: 'unanimous',
approvalStatusField: 'approval_status',
},
},
{ id: 'mark_won', type: 'update_record', label: 'Mark Won',
config: { objectName: 'opportunity', filter: { id: '{record.id}' }, fields: { stage: 'closed_won' } } },
{ id: 'approved', type: 'end', label: 'Approved' },
{ id: 'rejected', type: 'end', label: 'Rejected' },
],
edges: [
{ id: 'e1', source: 'start', target: 'manager_review',
// entry criteria re-homes onto the edge entering the approval node:
condition: cel`record.amount > 100000` },
{ id: 'e2', source: 'manager_review', target: 'needs_director', label: 'approve' },
{ id: 'e3', source: 'manager_review', target: 'rejected', label: 'reject' },
// Decision branches: mutually-exclusive edge `condition` predicates.
// Without them BOTH branches would execute (unguarded edges run in parallel).
{ id: 'e4', source: 'needs_director', target: 'director_signoff', label: 'true',
condition: cel`record.amount > 500000` },
{ id: 'e5', source: 'needs_director', target: 'mark_won', label: 'false',
condition: cel`record.amount <= 500000` },
{ id: 'e6', source: 'director_signoff', target: 'mark_won', label: 'approve' },
{ id: 'e7', source: 'director_signoff', target: 'rejected', label: 'reject' },
{ id: 'e8', source: 'mark_won', target: 'approved' },
],
}Send-back for revision (ADR-0044)
Approval centers also model send back for revision (退回修改) — distinct from reject (terminate) and from a comment thread (which keeps the request pending). Send-back is a flow movement: the request finalizes as returned, the run walks a `revise` out-edge to an `approval_revise` node (the revise window) where the record unlocks and the submitter reworks it, and an explicit resubmit re-enters the approval node over a declared back-edge, opening round N+1 with a fresh approver slate.
approval ──approve──▶ …
──reject───▶ …
──revise───▶ approval_revise (record unlocked, submitter edits)
└──resubmit──[type:'back']──▶ approval (round N+1)Three pieces author it:
- `revise` out-edge — a third branch label alongside
approve/reject,
targeting an `approval_revise` node. It must be that node type: the window is a service-owned pause (resumeAuthority: 'service'), ended only by POST /api/v1/approvals/requests/:id/resubmit; a wait is resumeAuthority: 'any', so a raw run-resume would walk the back-edge unchecked. The node takes no config — there is no signal to wait on.
- `type: 'back'` resubmit edge — the edge from the revise window back into
the approval node MUST be typed 'back'. This is the only thing that legalizes the cycle: registerFlow validates the graph minus `back` edges as a DAG, so an unmarked cycle is rejected — you opt in, edge by edge. At run time a back-edge traverses normally (it just re-enters the node).
- `maxRevisions` on the approval
config(default3) — the budget of
send-backs per run; exceeding it auto-rejects (resumes down the reject edge). maxRevisions: 0 disables send-back, so never pair 0 with a revise edge.
{
id: 'manager_review', type: 'approval', label: 'Manager Review',
config: { approvers: [{ type: 'position', value: 'manager' }], lockRecord: true, maxRevisions: 2 },
},
// No config and no `waitEventConfig`: the window ends on the submitter's
// explicit resubmit, not on a signal or a timer.
{ id: 'wait_revision', type: 'approval_revise', label: 'Awaiting Revision' },
// …among the approval's edges…
{ id: 'rev', source: 'manager_review', target: 'wait_revision', label: 'revise' },
{ id: 'back', source: 'wait_revision', target: 'manager_review', label: 'resubmit', type: 'back' },Three mistakes the compile-time flow lint flags: areviseedge into anything but anapproval_revisenode (an error —sendBackrefuses that metadata, so the branch cannot run;flow-approval-revise-target-not-service-owned), areviseedge whose window never loops back (a dead endregisterFlowaccepts but that leaves the submitter nowhere to resubmit), and a resubmit edge left withouttype: 'back'(an unmarked cycleregisterFlowrejects). Resubmit is an explicit verb (POST /api/v1/approvals/requests/:id/resubmit), never a record-save. See theshowcase_budget_approvalflow in the showcase app in the framework repo for the canonical shape.
Recording a decision
A decision is recorded through ApprovalService.decide() (or the REST routes POST /api/v1/approvals/requests/:id/approve | /reject). That finalizes the sys_approval_request and resumes the suspended run down the matching branch — you never resume the flow by hand, and you cannot: the approval node declares resumeAuthority: 'service', so POST /api/v1/automation/:name/runs/:runId/resume answers 403 for a run parked on one (including via a subflow pause) and changes nothing.
A decision may also carry structured outputs ({ outputs: { … } } in the decide body) when the node declares the keys in decisionOutputs — the author declares keys, approvers only fill values. Accepted outputs resume the run as <nodeId>.<key> flow variables, so a LATER node reads them as vars.<nodeId>.<key> — this is how "the previous approver picks the next step's approvers" works without writing to a record field (see Dynamic approvers below). A decision carrying an undeclared key is rejected; decision / requestId are reserved. A declaration marked required: true must carry a non-blank value to approve (never to reject) — enforced before any write, with no elevation bypass, so the run cannot resume past the node with the key a later expression approver reads still missing.
Approver Types
type | Resolves to | |||
|---|---|---|---|---|
user | A specific user id (value = user id) | |||
position | Holders of a position — value = the position machine name, resolved via sys_user_position (ADR-0090 D3) | |||
org_membership_level | The org-membership tier — value is one of owner/admin/delegated_admin/member. NOT a position: { type: 'org_membership_level', value: 'sales_manager' } matches nobody; use position. Spelled role before ADR-0090 D3 — that spelling is deprecated, still resolves, and is removed in the next major | |||
team | Members of a flat sys_team | |||
department | A department + all descendant departments | |||
manager | The submitter's manager (sys_user.manager_id) | |||
field | User id read from a record field (value = field name). Resolved against the record's live state at node entry, so a field written mid-flow routes correctly; a multi-select user field fans out into one approver per user | |||
queue | ⛔ Declared but never resolved — the slot routes to nobody. Do not author | |||
expression | A CEL expression resolved at node entry (value = the expression) — see Dynamic approvers below. Only current.* / trigger.* / vars.* roots are available; the optional `resolveAs: 'user'(default) \ | 'department' \ | 'position' \ | 'team'` re-expands each resolved id through the graph |
Dynamic approvers (type: 'expression')
An expression approver computes WHO approves at the moment the node is entered. Its CEL source sees exactly three roots — nothing else:
| Root | Meaning | Analog |
|---|---|---|
current.* | The record's live state at node entry — fields written by earlier steps/approvers are visible | ServiceNow current |
trigger.* | The submit-time snapshot (what flow conditions call record) | ServiceNow Flow Designer trigger.record, Power Automate triggerBody() |
vars.* | Flow variables — node outputs (vars.<nodeId>.<key>), get_record results, vars.previous (the pre-update row) | BPMN process variables |
`record` and bare field names are NOT available and fail the node loudly. Everywhere else on this platform record means "the record at event time" (flow conditions: the trigger snapshot; hook conditions: the stored record overlaid with the write's payload) — at an approval node that phrase is ambiguous between two different times, so you must say which one: current.x or trigger.x. Do not carry the record.x habit over from conditions.
Result contract: a user-id string, a CSV string, or an array of ids. An empty result (present-but-empty field/variable) triggers onEmptyApprovers. A missing key (vars.never_written) is a loud error, never a silent empty slate — guard genuinely-optional inputs explicitly, e.g. has(vars.picked) ? vars.picked : [].
// ① Route on a field an EARLIER approver filled in mid-flow (live value):
{ type: 'expression', value: cel`current.co_review_departments`, resolveAs: 'department' }
// ② The previous approval node's decision outputs pick this node's approvers:
{ type: 'expression', value: cel`vars.lead_review.next_reviewers` }
// ③ Dynamic co-sign (会签): expression yields department ids; resolveAs expands
// each into its members, and with behavior: 'per_group' EACH department is
// its own sign-off group:
{
approvers: [{ type: 'expression', value: cel`current.picked_departments`, resolveAs: 'department' }],
behavior: 'per_group',
onEmptyApprovers: 'fail',
}The full "previous approver picks the next step's approvers" loop, end to end (the shipped showcase_dynamic_approval flow in the showcase app is this shape):
<!-- os:check -->
import { defineFlow } from '@objectstack/spec';
export const DynamicApprovalFlow = defineFlow({
name: 'dynamic_approval',
label: 'Dynamic Approval',
type: 'autolaunched',
status: 'active',
nodes: [
{
id: 'start', type: 'start', label: 'On Submit',
config: { objectName: 'expense', triggerType: 'record-after-update', condition: "status == 'submitted'" },
},
{
// Node A declares what a decision may hand to the flow. The TYPED
// declaration renders a multi-select sys_user picker in the decision
// dialog; the lead approves with outputs:
// POST …/approve { outputs: { next_reviewers: ['u2', 'u3'] } }
// `required: true` is enforced by the runtime on APPROVE (never on
// reject) — node B below has nobody to route to without it.
id: 'lead_review', type: 'approval', label: 'Lead Review',
config: {
approvers: [{ type: 'org_membership_level', value: 'owner' }],
decisionOutputs: [{ key: 'next_reviewers', label: 'Next Reviewers', type: 'user', multiple: true, required: true }],
},
},
{
// Node B resolves them at entry from the lead's decision outputs.
id: 'co_sign', type: 'approval', label: 'Co-sign',
config: {
approvers: [{ type: 'expression', value: 'vars.lead_review.next_reviewers' }],
behavior: 'unanimous',
onEmptyApprovers: 'fail',
},
},
{ id: 'approved', type: 'end', label: 'Approved' },
{ id: 'rejected', type: 'end', label: 'Rejected' },
],
edges: [
{ id: 'e1', source: 'start', target: 'lead_review' },
{ id: 'e2', source: 'lead_review', target: 'co_sign', label: 'approve' },
{ id: 'e3', source: 'lead_review', target: 'rejected', label: 'reject' },
{ id: 'e4', source: 'co_sign', target: 'approved', label: 'approve' },
{ id: 'e5', source: 'co_sign', target: 'rejected', label: 'reject' },
],
});Time-word cheat sheet across surfaces (do not mix them up):
| Surface | Event-time record | Pre-event record | Live record |
|---|---|---|---|
Flow condition / {…} template | record (trigger snapshot) | previous | — (use a get_record node) |
Approval expression approver | trigger.* | vars.previous | current.* |
Object-hook ctx is a different vocabulary — see objectstack-data references/data-hooks.md.
Node Config (ApprovalNodeConfigSchema)
| Field | Purpose | ||||
|---|---|---|---|---|---|
approvers | Who may act (≥ 1 — see Approver Types above). Each approver may carry an optional `group` label (e.g. { type: 'position', value: 'auditor', group: 'finance' }) — with behavior: 'per_group', approvers sharing a label form one group; unlabelled approvers each form their own | ||||
behavior | first_response (first approver decides), unanimous (all must approve), quorum (minApprovals of N — M-of-N collective sign-off), or per_group (EACH approver group must reach minApprovals — one-from-each-group sign-off, 会签). In every mode a single rejection finalizes the node as rejected. Default first_response | ||||
minApprovals | Approvals required — total for quorum, per group for per_group. Omitted ⇒ ALL resolvable approvers under quorum, 1 per group; clamped at runtime so a misconfiguration can never deadlock | ||||
lockRecord | Lock the triggering record from edits while pending. Default true | ||||
approvalStatusField | Business-object field to mirror pending/approved/rejected/recalled onto (should be readonly) | ||||
onEmptyApprovers | What an EMPTY resolved slate does: admin_rescue (default — request opens, only a privileged admin can act via Reassign; never waves through, never kills the run), fail (node fails — treat an empty slate as a config bug), auto_approve (skip the request, continue down approve with output.autoApproved = true — opt-in because it silently waves the record through). Declare it explicitly on any node with an expression approver (linted) | ||||
decisionOutputs | Decision outputs a decision may carry (author declares, approvers fill values). Entries are bare keys (free-text input) or typed declarations `{ key, label?, type: 'text'\ | 'user'\ | 'department'\ | 'position'\ | 'team', multiple?, required? } — a typed entry renders the matching record picker in the decision dialog (multiple collects an id array). Accepted outputs resume the run as <nodeId>.<key> variables; undeclared keys reject the decision; decision/requestId` reserved |
escalation | Optional per-node SLA — `{ enabled, timeoutHours, action: reassign\ | auto_approve\ | auto_reject\ | notify, escalateTo?, notifySubmitter }. timeoutHours is **calendar (wall-clock) hours** — nights, weekends and holidays count; the platform ships no business-hours calendar. escalateTo is a **position machine name** (expanded to its holders via sysuserposition, ADR-0090 D3) or a specific user id — never a membership tier. reassign without escalateTo` degrades to notify (linted) | |
maxRevisions | ADR-0044 — max send-backs-for-revision per run before auto-reject. Default 3; 0 disables send-back. Only meaningful when the node has a revise out-edge |
Branching, side-effects & rejection
These are wired on the graph, not in node config:
- Conditional step — put a
decisionnode before the Approval node, or a
condition on the edge entering it (the old per-step entryCriteria).
- On approve / on reject — wire downstream nodes (
update_record,
http, a notify node, …) to the approve / reject out-edge.
- Roll back on reject — route the
rejectedge as a back-edge to an
earlier node so the submitter can revise (the old back_to_previous).
- Send back for revision (ADR-0044) — distinct from a plain reject: a
revise out-edge into an `approval_revise` window, closed by a type: 'back' resubmit edge. See Send-back for revision above.
- Hard reject — route the
rejectedge to anendnode (the old
reject_process).
Approval Best Practices
- Gate entry on the edge (
conditioninto the Approval node) so the flow
only pauses for records that actually need sign-off.
- Set `approvalStatusField` to mirror status onto the row — views and
formulas can then filter on it without joining sys_approval_request.
- Keep `lockRecord: true` unless you have a strong reason to allow
edits while pending — otherwise approvers chase a moving target.
- Model rejection as a visible branch — a back-edge to revise, or an
end
node to terminate. The path is on the diagram, not hidden in config.
- Notify from downstream nodes wired to the
approve/rejectedges
rather than expecting the node to send mail itself.
Triggers — Event-Driven Automation
A record_change flow fires automatically on a data event. There is no standalone trigger object and no top-level `trigger` / `event` key — the binding lives entirely in the flow's `start` node `config`, which the automation engine parses (resolveTriggerBinding) and wires to the matching ObjectQL lifecycle hook.
Prerequisite — declare the capabilities your nodes need
Metadata registers without its token; the surface just never runs — two of them silently:
| requires token | Turns on | Absent ⇒ | |:--|:--|:--| | automation | the flow engine + node executors | flows never execute | | triggers | record-* / schedule / api start bindings | flows register, never fire | | job | cron cadence + the timeRelative sweep | scheduled flows never launch | | queue | the inbound-webhook consumer | inbound POST answers 503 | | approvals | approval / approval_revise (plugin-approvals) | no executor for the node type | | messaging | notify delivery to inbox (sys_inbox_message) | silent: success with skipped: true | | webhooks | defineStack({ webhooks }) outbound registrations | nothing delivered outbound |
defineStack({
// …
requires: ['automation', 'triggers', 'job', 'queue', 'approvals', 'messaging'],
});Inbound webhook (api) triggers (ADR-0041 Tier 1)
An api flow can be bound to an inbound HTTP endpoint: POST /api/v1/automation/hooks/:flowName/:hookId. Configure it on the start node `config` (the start config is a free-form record, so these keys are read at runtime, not Zod-validated):
config key | Purpose |
|---|---|
hookId | URL path token (default 'default'). Rotate it to revoke a leaked endpoint |
secret | HMAC-SHA256 shared secret. Strongly recommended — without it unsigned posts are accepted and a warning is logged |
- Signature: sender sends
x-objectstack-signature: sha256=<hex>(GitHub/Stripe style). - Idempotency:
x-idempotency-keydedupes retries — author the flow to be idempotent (delivery is at-least-once). - Queue-backed: the endpoint ACKs
202and enqueues; the flow runs on the consumer, never in-band. Requires thequeueservice (see prerequisite). - The JSON body surfaces to the flow as the trigger record (
record.*/ bare fields) plusparams.
Trigger Types (start-node config.triggerType)
triggerType | Fires | ObjectQL hook |
|---|---|---|
record-before-create | before insert (can modify/reject) | beforeInsert |
record-after-create | after insert | afterInsert |
record-before-update | before update | beforeUpdate |
record-after-update | after update | afterUpdate |
record-before-delete | before delete | beforeDelete |
record-after-delete | after delete | afterDelete |
record-before-write / record-after-write | create OR update — one flow, both events | both insert + update hooks |
Trigger Configuration — on the start node
The binding is the START NODE — this is that node, not a whole flow (a flow also owns label, type, nodes and the edges FlowSchema requires):
{
id: 'start',
type: 'start',
label: 'On Case Escalated',
config: {
objectName: 'support_case',
triggerType: 'record-after-update',
// bare CEL; gates whether the flow launches on the event
condition: "previous.status != 'escalated' && record.status == 'escalated'",
},
}Prefer `record-after-** unless you must modify or reject the record; **guard** one that writes its own object, or it re-triggers itself. A record-before-*` flow that throws silently blocks the write — give it a user-facing message.`previous` and `record` are the CEL variables available in update triggers —previous.xis the value before the change,record.xis the value after. See objectstack-formula.
Time-relative triggers — scheduled per-record date sweep
Don't express "act N days before/after a date" (renewal reminders, "expiring soon", overdue sweeps) as a record_change flow gated on date-equality (end_date == daysFromNow(60)) — that predicate is only evaluated when the record happens to change, so unattended it almost never fires. Use a declarative time-relative trigger: a schedule-type flow whose start node carries a `timeRelative` descriptor is swept on a schedule (daily by default) and launched once per record whose date field falls in the window. The record is on the context, so the start condition and {record.*} interpolation work exactly as for a record-change flow — and because the window is evaluated every day, a threshold is never missed.
{
name: 'renewal_alert',
label: 'Renewal Alert',
type: 'schedule',
runAs: 'system', // a sweep has no trigger user — elevate explicitly
nodes: [
{
id: 'start', type: 'start', label: 'Daily Sweep',
config: {
timeRelative: {
object: 'contracts',
dateField: 'end_date',
offsetDays: [60, 30, 7], // fire exactly at T-60 / T-30 / T-7
// — or — withinDays: 30 // "expiring within 30 days" (negative = overdue lookback)
filter: { status: 'active' }, // optional, ANDed with the date window
// maxRecords: 1000 // optional per-sweep cap (default 1000)
},
// Optional sweep cadence; omit for daily 08:00 UTC. Plain shape only:
// schedule: { type: 'cron', expression: '0 8 * * *' }
},
},
// …downstream nodes (notify, update_record, …)
],
edges: [ /* start → downstream */ ],
}Date EQUALITY never matches, so a hand-rolled sweep filters windows, not days: a date field carries a time component, so field == daysFromNow(N) (or { $in: [...] }) compares two differently-timed timestamps and silently returns nothing (build warns flow-date-equality-filter). Tier each threshold as a one-day window ($gte/$lt):
filter: { status: 'active', $or: [
{ end_date: { $gte: '{TODAY() + 7}', $lt: '{TODAY() + 8}' } },
{ end_date: { $gte: '{TODAY() + 30}', $lt: '{TODAY() + 31}' } },
{ end_date: { $gte: '{TODAY() + 60}', $lt: '{TODAY() + 61}' } },
] }Abutting windows tile the timeline, so each record matches exactly one tier — fires once, idempotent, no guard field. Use {TODAY() + N} template tokens in CRUD-node filter values; a cel\…\` envelope is not evaluated there and would be compared as a literal object. For "days remaining" in a message, use daysBetween(today(), record.end_date)`.
Exactly one of offsetDays (discrete T-minus days) or withinDays (a range; negative = overdue) is required. Ships in @objectstack/trigger-schedule — needs requires: ['automation', 'triggers'] plus `'job'` (the sweep cadence runs on the job service). Full descriptor schema: node_modules/@objectstack/spec/src/automation/time-relative-trigger.zod.ts.
CRM Automation Blueprint
For enterprise automation design, align with this CRM-style structure:
| Automation Type | Typical Location | Pattern | |:--|:--|:--| | Screen flow | src/flows/*.flow.ts | Use explicit variables, node graph (nodes + edges), and decision branches | | Approval flow | src/flows/*.flow.ts | A flow with approval node(s); set approvers / behavior / lockRecord / approvalStatusField in node config, branch on approve / reject edges | | Flow registry | src/flows/index.ts | Export allFlows: Flow[] and register centrally in defineStack({ flows }) |
Default approach for metadata apps: model business lifecycle in Flow/Approval metadata first; reserve custom code for edge-case integrations.
Verify your work
A bare ref (status == 'open') resolves — the engine flattens the record's fields into scope — so a typo there is only an advisory did-you-mean. record.status stays canonical, and os validate errors on an unknown record.<field>:
os validate # CEL/predicate validation (record.<field> existence) + schema
# or: os build # the same gates, plus emits dist/It runs the ADR-0032 gate over every condition, edge guard, validation and sharing rule, exiting non-zero. In a scaffolded project: npm run validate.
References
The send-back shape has a graded eval at evals/approvals/test-revise-loop.md.
See references/_index.md for the full list of Zod schemas (with one-line descriptions) — pointers into node_modules/@objectstack/spec/src/. Always Read the source for exact field shapes; do not rely on memory of property names.



