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 — every
condition/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 — the primary automation building block.
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 |
Inbound webhook — needs a start-node secret (see Inbound webhook triggers below); a flow only ever started explicitly is autolaunched |
Flow Node Types
Flows are built from 20 built-in node types (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 — a CEL value envelope ({ dialect: 'cel', source: 'rows' }) or an inline list, never a string; 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 config.collection (shaped as loop's); 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 — always set timeoutMs (unset = no timeout). url and headers are served as written with the definition to every member who can read flows — only signingSecret and a start node's secret are withheld — so never a credential there. Route by shape — a header credential → a declarative connector (bearer / basic / api-key) with auth.credentialRef; a query-string key → api-key with paramName; a path-secret webhook url → a token-authenticated connector such as slack — called via connector_action |
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 are read as written: a
{token} in one is refused.
{ id: 'tell_owner', type: 'notify', label: 'Notify Owner', config: {
recipients: { dialect: 'cel', source: 'record.assignee' }, // REQUIRED — id, list, or envelope; a CSV is ONE id
title: 'Done: {{ record.title }}', // inline; XOR `template` (RAW, localizable)
message: 'Closed {{ record.closed_at | date:long }}', // 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: { dialect: 'cel', source: 'record.id' }, // (a value slot)
actionUrl: '/tasks/{{ record.id }}', // text slot; 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
},
],
→ Moved to references/examples-flows.md § Flow Example — Auto-Escalate Overdue Cases.
Failure routing & runAs
Handling a failed node: a
faultedge.{ source, target, type: 'fault' }routes a failed node to a handler instead of ending the run.type: 'fault'is what routes — alabel: '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.message }}; in CEL,vars["$error"]). The run then reports success, and the failed step stays in the trace.
| 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 operand that evaluated to null; graph past the nesting ceiling; unscoped run |
Writing a
readonlyfield? SetrunAs: '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. A
screenflow 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 withsudo. 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 operands (config.filter)
- Each operand is a value slot — a literal, or a CEL value envelope
evaluated before the query (
filter: { id: { dialect: 'cel', source: 'record.id' } }). A whole-string query placeholder ({current_user_id},{today},{current_year_start}) passes through verbatim for the query engine to expand; every other{…}token is refused (expression-invalid) with its envelope, and a flow variable never shadows a placeholder. Onlyfiltergets this hand-off — in a text slot{{ current_year_start }}is a nonsense reference. - An operand that evaluates to
nullrefuses the node (notfault-routable): a dropped condition does not narrow a query, it widens it, so a routeddelete_recordwould empty the object while the run reports success. Where the value may be absent, route around the node with adecision; to match an empty field, write the literalnull. A relation hop (record.owner.email) needs the start node'sconfig.expandto list the field — else the fault names the missing hop, not the remedy.
Valid-but-silently-wrong (passes build, fails at runtime)
Legal metadata that authors — AI especially — get wrong; most are caught by
objectstack build, but write them right the first time:
-
A value slot takes a literal or a CEL envelope — never a
{…}token. Inassignments.*,fields.*,subflow/mapinput.*,scriptinputs.*,loop/mapcollection, notifyrecipients/templateData.*/sourceId,httpbodyand every CRUDfilteroperand, a string is the literal text it spells, and a{…}token is refused — atobjectstack validate(expression-invalid),registerFlowand the executor — naming its CEL spelling. Compute with{ dialect: 'cel', source: '…' }:'{record.owner}'→source: 'has(record.owner) ? record.owner : null'— CEL refuses an absent key where the template wrote nothing, so nothing /null/ a default is now YOUR call;'{record.tags.0}'→'record.tags[0]'; a deep path guards each step:has(vars.source) && has(vars.source.id) ? vars.source.id : null- money:
source: 'round(x * 100) / 100.0'— CEL divides two integers as integers, so/ 100turns123.46into123 - dates:
{TODAY()}→isoDate(today()),{TODAY() ± N}→isoDate(daysFromNow(N))/isoDate(daysAgo(N)),{NOW()}→isoDatetime(now()),{NOW() ± N}→isoDatetime(addDays(now(), N)) - run user:
{$User.Id}→current_user.id; a flow that can run without a user guards it,current_user != null ? current_user.id : null.current_userholdsid,positions,organizationId,isPlatformAdminonly — an email or a name is asys_userread by that id
Text slots (notify
title/message/actionUrl, screentitle/description, endmessage,httpurl/headers) render{{ }}holes — a variable path, optional formatter, no logic; a{…}token there is refused with its{{ }}spelling. A date or the run user has no hole: assign it first (assignments: { v: { dialect: 'cel', source: 'current_user.id' } }), then write{{ v }}. -
create_record'soutputVariableholds the created RECORD, not its id. Reference a field explicitly. ❌fields: { ref: { dialect: 'cel', source: 'newRec' } }→ writes the whole record object ✅source: 'newRec.id' -
scriptnodes call a registered function — that is all they do. Setconfig.functionto a function registered viadefineStack({ functions: { my_fn: (ctx) => … } }). It is required: an emptyscriptnode refuses at execute, and one pointing at an unregistered function fails loudly.There is no other dispatch form: use
notifyfor delivery, aconnector_actionon theslackconnector for Slack, a function for logic.A flow
functionis a PURE compute step — it does NOT read/write the database. It receivesctx.inputand returns a value;config.outputVariableexposes that value as a flow variable, and a later declarative node persists it. Keep data effects on the flow graph (visible, governed, build-checkable):// ✅ function returns values → outputVariable → update_record persists { id: 'ai', type: 'script', config: { function: 'helpdesk.aiTriageStub', // returns { ai_category, ai_sentiment, … } inputs: { ticketId: { dialect: 'cel', source: 'record.id' } }, // CEL values or literals outputVariable: 'ai', } }, { id: 'apply', type: 'update_record', config: { objectName: 'helpdesk_ticket', filter: { id: { dialect: 'cel', source: 'record.id' } }, fields: { ai_category: { dialect: 'cel', source: 'ai.ai_category' } }, } },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. The table is objectstack-formula's
CEL_STDLIB_FUNCTIONS, the bare-callable public subset; 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 — writerecord.x, not{record.x}. -
notifyreports SUCCESS when themessagingcapability is absent. The executor logsno messaging service registeredand returns success withoutput: { delivered: 0, failed: 0, skipped: true }andmetrics.acted: 0— a green run that delivered nothing. Declaremessaginginrequires.
→ State Machines & Approvals moved to references/state-machines-and-approvals.md — its state-introspection route is GET /api/v1/meta/object/:name/state/:field?from=:state.
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.
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 is 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 with no Zod shape):
config key |
Purpose |
|---|---|
hookId |
URL path token (default 'default'). Rotate it to revoke a leaked endpoint |
secret |
HMAC-SHA256 shared secret. Required — without a non-blank one the flow is refused at registration (os validate too) and never armed at boot; the signature goes in x-objectstack-signature |
- Signature:
t=<unix seconds>,v1=<hex>—v1= HMAC-SHA256 of<t>.<raw body>undersecret; refused whentis over 300 s from the server clock. - 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. - 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. Arecord-before-*flow that throws silently blocks the write — give it a user-facing message.
previous(before the change) vsrecord(after) in an update trigger's condition: see objectstack-formula §5.
→ Moved to references/examples-flows.md § Time-relative triggers — scheduled per-record date sweep.
CRM Automation Blueprint
Default approach for metadata apps — model the business lifecycle in Flow/Approval metadata first, reserve custom code for edge-case integrations — in 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 }) |
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. 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 Zod schemas (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.