Flows & Automation
Declarative business logic — described to AI or written in TypeScript, the runtime executes the same artifact either way.
Flows are how you express business logic without writing a server.
Every flow is declarative metadata that the runtime executes — same
as objects and views. That means flows show up in os diff, the
audit log, the flow builder, and the AI Builder
all at once.
Most customers create flows by asking the AI:
"When a high-priority ticket sits in 'new' for 30 minutes, notify the manager on Slack."
The AI generates the flow below. This page describes the shape so you can read and edit it.
Enable the capability in your stack:
export default defineStack({
// ...
requires: ['automation'],
});Flow types
| Type | Triggered by | Use for |
|---|---|---|
| Autolaunched | A record change (insert/update/delete) | "Send welcome email when user registers" |
| Scheduled | Cron expression or interval | "Mark stale tasks every night at 2am" |
| Time-relative (16.0) | A date field's proximity to today, via a daily sweep | "Remind me 60 days before the contract ends" |
| Manual | User clicks a button, or an API call | "Approve invoice" actions |
Autolaunched: react to a record change
// src/flows/welcome_email.ts
import { defineFlow } from '@objectstack/spec';
export const welcomeEmail = defineFlow({
name: 'welcome_email',
type: 'autolaunched',
trigger: {
object: 'sys_user',
when: 'after_insert',
},
steps: [
{
type: 'action',
action: 'send_email',
inputs: {
to: '{!trigger.record.email}',
subject: 'Welcome to {!org.name}',
body: 'Hi {!trigger.record.name}, welcome aboard.',
},
},
],
});Variable interpolation: {!trigger.record.<field>}, {!org.<field>},
{!user.<field>}, {!step.<step-name>.output}. Use CEL expressions in
condition: blocks.
Trigger timing:
when | Fires |
|---|---|
before_insert | Inside the write transaction, before INSERT |
after_insert | After commit |
before_update | Inside the write transaction, before UPDATE |
after_update | After commit |
before_delete | Inside the write transaction, before DELETE |
after_delete | After commit |
before_* flows can mutate the record being written (compute fields,
normalize data). after_* flows run async and can call slow external
services.
Scheduled: run on a clock
export const nightlyCleanup = defineFlow({
name: 'nightly_cleanup',
type: 'scheduled',
schedule: { cron: '0 2 * * *', timezone: 'America/New_York' },
steps: [
{
type: 'query',
query: { object: 'task', filter: 'status:open AND due_lt:now()' },
output: 'stale',
},
{
type: 'foreach',
items: '{!step.stale}',
do: [
{ type: 'update', record: '{!item.id}', fields: { status: 'overdue' } },
],
},
],
});Backed by the @objectstack/service-job capability — see
Runtime Capabilities.
Time-relative: fire relative to a date field (16.0)
"Remind me 60 days before the contract ends" used to be authored as a
record-change flow gated on record.end_date == daysFromNow(60) — a
predicate that is only evaluated when the record happens to change, so
unattended it fires almost never. Don't write that. Declare a
time-relative trigger instead: the flow's start node carries a
config.timeRelative descriptor, and the runtime sweeps the object on a
schedule and launches the flow once per matching record:
export const renewalReminder = defineFlow({
name: 'contract_renewal_reminder',
type: 'schedule',
status: 'active',
nodes: [
{
id: 'start',
type: 'start',
config: {
timeRelative: {
object: 'contracts',
dateField: 'end_date',
offsetDays: [60, 30, 7], // T-minus thresholds
filter: { status: 'active' }, // AND-ed with the date window
},
// Optional sweep cadence — defaults to daily at 08:00 UTC.
schedule: { type: 'cron', expression: '0 8 * * *' },
},
},
{ id: 'notify_owner', type: 'notify', label: 'Notify Owner' },
{ id: 'end', type: 'end' },
],
edges: [
{ id: 'e1', source: 'start', target: 'notify_owner' },
{ id: 'e2', source: 'notify_owner', target: 'end' },
],
});| Key | What it declares |
|---|---|
object | Object (machine name) whose records the sweep queries |
dateField | date / datetime field evaluated relative to today (day-granular) |
offsetDays | Offset mode — fire when dateField is exactly today + each listed offset ([60, 30, 7]; negative = past, e.g. [-1] the day after) |
withinDays | Range mode — fire every day dateField is within N days of today: positive = upcoming ("expiring soon"), negative = bounded overdue lookback, 0 = due today |
filter | Optional ObjectQL where-map AND-ed with the computed date window (e.g. { status: 'active' }) |
maxRecords | Cap on records launched per sweep (default 1000; the sweep logs when it clamps) |
Exactly one of offsetDays or withinDays must be set. The two other
common shapes:
// "Expiring soon" — fires every day a document is within 30 days of expiry.
timeRelative: { object: 'hr_document', dateField: 'expires_on', withinDays: 30 }
// Overdue sweep — fires for POs up to 14 days past due (bounded lookback).
timeRelative: { object: 'purchase_order', dateField: 'due_date',
withinDays: -14, filter: { status: 'open' } }Each launch puts the matching record on the automation context, so the
start-node condition and {record.<field>} interpolation work exactly
as they do for record-change flows. The discovery query runs as system,
with per-record failure isolation. os validate gains readiness checks —
it warns when timeRelative.object names an object the stack doesn't
define, and when an auto-triggered flow is left in draft status.
Genuine same-day checks are fine now (#3183). In 16.0
record.due_date == today()matches — the engine coerces temporal==/!=comparisons so a date field compares correctly againsttoday(). Use that for a real "due today" condition; usetimeRelativefor anything of the form "N days before/after a date".
Manual: actions and approvals
export const approveInvoice = defineFlow({
name: 'approve_invoice',
type: 'manual',
inputs: {
invoice_id: { type: 'lookup', reference: 'invoice', required: true },
note: { type: 'textarea' },
},
steps: [
{
type: 'update',
record: '{!inputs.invoice_id}',
fields: { status: 'approved', approved_by: '{!user.id}' },
},
],
});Surface it as a button on the Invoice view, or call it via REST:
curl -X POST https://app.example.com/api/v1/actions/invoice/approve_invoice \
-H 'Authorization: Bearer <token>' \
-d '{"inputs": {"invoice_id": "inv_123", "note": "OK"}}'Step types
| Step | Purpose |
|---|---|
query | Read records via ObjectQL |
create / update / delete | Write to objects |
action | Invoke a built-in or plugin-registered action (email, webhook, AI call, …) |
condition | Branch on a CEL expression |
foreach | Iterate over a collection |
parallel | Run sub-steps concurrently |
wait | Pause for duration / until timestamp / until condition |
subflow | Call another flow |
approval | Block until a user approves (requires @objectstack/plugin-approvals) |
Conditions and branches
{
type: 'condition',
when: 'trigger.record.amount > 10000',
then: [
{ type: 'action', action: 'send_slack', inputs: { /* ... */ } },
],
else: [
{ type: 'update', record: '{!trigger.record.id}', fields: { status: 'auto_approved' } },
],
}Error handling
Each step accepts:
{
type: 'action',
action: 'send_email',
inputs: { /* ... */ },
retry: { attempts: 3, backoffMs: 1000, multiplier: 2 },
onError: 'continue' | 'fail' | 'rollback',
}For autolaunched before_* flows, onError: 'fail' (default) aborts
the originating write transaction. For after_* flows, the originating
write is already committed; failed flow runs land in the job retry
queue.
Formulas and expressions (CEL)
Conditions, dynamic field values, and filter expressions all accept CEL (Common Expression Language) — Google's language for safe expression evaluation:
'amount > 10000 && account.tier == "enterprise"'
'duration(now() - created_at) > duration("30d")'
'has(record.notes) && record.notes != ""'CEL is sandboxed (no side effects, no I/O), evaluated server-side, and auditable in the flow builder.
Visual builder
ObjectOS ships a visual flow builder that round-trips with the declarative metadata — non-engineers can edit a flow, and it serializes back to the same shape as the TypeScript you'd hand-author.
Testing flows
os test --scenario "welcome email fires on signup"Limits & best practices
- Keep before-hooks small. They block the write transaction.
- Use
waitinstead of long-running steps. A flow that sleeps blocks a worker; await untilreturns the worker to the pool. - Use
parallelfor independent steps. Sequential execution is the default. - Idempotency matters. Retries can run the same step twice; external side effects should dedupe (use the flow run id as the key).
- Audit-sensitive actions. Flows that change permissions or
delete records should themselves log to
sys_audit_log.
Where to go next
- Webhooks — outbound notifications, often triggered from flows
- Email — the
send_emailaction's transport - AI Service —
ai_callaction for LLM steps - API Access — invoke manual flows from external systems
- @objectstack/service-automation — source for the execution engine