Skill v1.0.0
currentLLM-judged scan95/100version: "1.0.0" name: objectstack-automation description: > Design ObjectStack automation — Flows (visual logic), Triggers, Approvals, state machines, and the jobs (defineJob) / webhooks (defineWebhook) stack collections. Use when the user is adding *.flow.ts, wiring an event-driven rule, modelling an approval chain, or building an interactive screen flow / wizard (objectstack-ui routes those here). Do not use for data lifecycle hooks at the object layer (see objectstack-data) or for kernel / plugin events (see objectstack-platform). CEL expressions in flow conditions / edge guards: load objectstack-formula alongside. license: Apache-2.0 compatibility: Requires @objectstack/spec 18.x (Zod v4 schemas) metadata: author: objectstack-ai version: "18.0.0" domain: automation tags: flow, workflow, trigger, approval, state-machine, scheduled, webhook
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 evaluatedby@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). Seeobjectstack-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 idtitle: '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|criticalchannels: ['inbox'], // RAW; default inboxsourceObject: 'task', // click-through: a PAIR, else droppedsourceId: { 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 invokedisOutput: 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 `fault` edge.{ 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 staysordinary, and every unconditional out-edge traverses on SUCCESS, so thehandler 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, whichreplays the flow from the start — prefer a fault edge when the failure islocal. The handler reads{{ <nodeId>.error }}(or run-wide{{ $error.message }}; in CEL,vars["$error"]). The run then reportssuccess, 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 `readonly` field? Set `runAs: 'system'`.readonly: truegoverns the end-user surface: under the defaultrunAs: 'user', the enginestrips 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 thatmaintains areadonlyfield (approval stamps, conversion flags, SLAmarkers, rollups) must runrunAs: 'system', the trusted-writer channel.os validate/os buildfail arunAs:'user'update_recordthat writesareadonlyfield, so the mismatch surfaces at build time, not as wrong datadays 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 adedicatedrunAs: 'system'flow and call it from asubflownode — raisingthe whole flow silently elevates every other write in it.A `runAs: 'system'` sweep must pin its organization. System context has notrigger user, so nothing narrows the query: a scan or rollup with noorganization predicate reads and writes across every tenant. The tenant columnis 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 thathook'sctx.apidata operations only, on the in-processhandlerand thesandboxedbodyalike. A'user'hook whose trigger resolved no user hasnothing 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. Only filter gets this hand-off — in a text slot {{ current_year_start }} is a nonsense reference.
- An operand that evaluates to `null` refuses the node (not
fault-routable): a dropped condition does not narrow a query, it widens it, so a routed delete_record would empty the object while the run reports success. Where the value may be absent, route around the node with a decision; to match an empty field, write the literal null. A relation hop (record.owner.email) needs the start node's config.expand to 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.
In assignments.*, fields.*, subflow / map input.*, script inputs.*, loop / map collection, notify recipients / templateData.* / sourceId, http body and every CRUD filter operand, a string is the literal text it spells, and a {…} token is refused — at objectstack validate (expression-invalid), registerFlow and 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 / 100 turns 123.46 into 123
- 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_user holds id, positions, organizationId, isPlatformAdmin only — an email or a name is a sys_user read by that id
Text slots (notify title / message / actionUrl, screen title / description, end message, http url / 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`'s `outputVariable` holds the created RECORD, not its id.
Reference a field explicitly. ❌ fields: { ref: { dialect: 'cel', source: 'newRec' } } → writes the whole record object ✅ source: '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` on the slack connector 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):
``ts // ✅ 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:
``ts 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 — 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 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 eventcondition: "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 auser-facing message.
previous(before the change) vsrecord(after) in an update trigger'scondition: 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.