<< All versions

Skill v1.0.0

currentLLM-judged scan95/100
objectstack-ai/objectstack/objectstack-automation
──Details
PublishedOctober 23, 2026 at 06:33 PM
Content Hashsha256:66532ba396a37f65...
Git SHA06cab4849a8a
──Files
Files (1 file, 22.5 KB)
SKILL.md22.5 KBactive
SKILL.md · 440 lines · 22.5 KB

version: "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 — every condition / guard /
entryCondition / filter value here is an Expression envelope evaluated
by @objectstack/formula. A slot takes a plain CEL string; the
P\...\` / 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

TypeWhen to Use
autolaunchedRuns without user interaction — triggered by events, APIs, or other flows
screenInteractive — presents UI screens to the user (wizards, forms)
scheduleRuns 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_changeFires 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
apiInbound 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

NodePurpose
startEntry point — every flow has exactly one
endExit point — can have multiple (early exit, error exit)
decisionConditional branching — routed by edge `condition` predicates, not node config (see the approval example below)
loopIterate 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
parallelFan out into config.branches[] (≥ 2 regions) run concurrently, joined implicitly at block end — no split/join pair to mis-wire
try_catchRun 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
mapSequential multi-instance — invoke a subflow once per item of config.collection (shaped as loop's); each iteration may pause (batch approvals)
waitPause execution until a timer elapses or a named signal arrives
subflowInvoke another flow (reusable composition)
parallel_gateway / join_gateway / boundary_eventNot author-facing — BPMN-interop forms the mapper lowers a parallel / try_catch container INTO (automation/control-flow.zod.ts). Author the container

Data Operations

NodePurpose
assignmentSet variable values
create_recordInsert a new record
update_recordModify existing records
delete_recordRemove records
get_recordFetch records with filters — there is no `query_record` node (that name has no executor and throws)

External Integration

NodePurpose
httpCall 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
notifySend a notification through the messaging service (inbox channel by default)
connector_actionInvoke a pre-built integration connector
scriptCall a registered function named by config.function (see Valid-but-silently-wrong #3)
screenDisplay a UI form to the user (screen flows only)

Human Decision

NodePurpose
approvalRoute 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.

ts
{ 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:

typescript
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 `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 validate reports flow-error-label-not-fault).
A handled failure does NOT consume a flow-level errorHandling.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 subflowmissing 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: true
governs the end-user surface: under the default runAs: 'user', the engine
strips a readonly field from any non-system write — create_record and
update_record alike — the step reports success but the value never lands,
and the drop is named in the step's warnings. A flow that
maintains a readonly field (approval stamps, conversion flags, SLA
markers, rollups) must run runAs: 'system', the trusted-writer channel.
os validate / os build fail a runAs:'user' update_record that writes
a readonly field, so the mismatch surfaces at build time, not as wrong data
days later. (readonlyWhen fields are the same story, per record state —
flagged as a warning.) Do not work around this by removing readonly;
that loses the field's edit protection.
Elevate the write, not the flow. A screen flow stays runAs: 'user'.
When one step in it must write a readonly field, move that step into a
dedicated runAs: 'system' flow and call it from a subflow node — 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 own runAs: 'system' | 'user' | 'inherit' —
default 'inherit', the context of the write that fired it — scoping that
hook's ctx.api data operations only, on the in-process handler and the
sandboxed body alike. A 'user' hook whose trigger resolved no user has
nothing to scope to: its ctx.api data operations are refused
(HOOK_UNSCOPED_DATA_ACCESS, 403) rather than run unscoped — declare
runAs: 'system' when the elevation is intended. sudo is 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:

  1. 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 }}.

  1. `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'

  1. `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 } }); ``

  1. 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}.

  1. `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 tokenTurns onAbsent ⇒
automationthe flow engine + node executorsflows never execute
triggersrecord-* / schedule / api start bindingsflows register, never fire
jobcron cadence + the timeRelative sweepscheduled flows never launch
queuethe inbound-webhook consumerinbound POST answers 503
approvalsapproval / approval_revise (plugin-approvals)no executor for the node type
messagingnotify delivery to inbox (sys_inbox_message)silent: success with skipped: true
webhooksdefineStack({ webhooks }) outbound registrationsnothing delivered outbound
typescript
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 keyPurpose
hookIdURL path token (default 'default'). Rotate it to revoke a leaked endpoint
secretHMAC-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> under secret; refused when t is over 300 s from the server clock.
  • Idempotency: x-idempotency-key dedupes retries — author the flow to be idempotent (delivery is at-least-once).
  • Queue-backed: the endpoint ACKs 202 and enqueues; the flow runs on the consumer, never in-band.
  • The JSON body surfaces to the flow as the trigger record (record.* / bare fields) plus params.

Trigger Types (start-node config.triggerType)

triggerTypeFiresObjectQL hook
record-before-createbefore insert (can modify/reject)beforeInsert
record-after-createafter insertafterInsert
record-before-updatebefore updatebeforeUpdate
record-after-updateafter updateafterUpdate
record-before-deletebefore deletebeforeDelete
record-after-deleteafter deleteafterDelete
record-before-write / record-after-writecreate OR update — one flow, both eventsboth 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):

typescript
{
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 (before the change) vs record (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 TypeTypical LocationPattern
Screen flowsrc/flows/*.flow.tsUse explicit variables, node graph (nodes + edges), and decision branches
Approval flowsrc/flows/*.flow.tsA flow with approval node(s); set approvers / behavior / lockRecord / approvalStatusField in node config, branch on approve / reject edges
Flow registrysrc/flows/index.tsExport 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>:

bash
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.

All versions