ObjectOS
BuildAutomation

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

TypeTriggered byUse for
AutolaunchedA record change (insert/update/delete)"Send welcome email when user registers"
ScheduledCron 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"
ManualUser 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:

whenFires
before_insertInside the write transaction, before INSERT
after_insertAfter commit
before_updateInside the write transaction, before UPDATE
after_updateAfter commit
before_deleteInside the write transaction, before DELETE
after_deleteAfter 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' },
  ],
});
KeyWhat it declares
objectObject (machine name) whose records the sweep queries
dateFielddate / datetime field evaluated relative to today (day-granular)
offsetDaysOffset mode — fire when dateField is exactly today + each listed offset ([60, 30, 7]; negative = past, e.g. [-1] the day after)
withinDaysRange mode — fire every day dateField is within N days of today: positive = upcoming ("expiring soon"), negative = bounded overdue lookback, 0 = due today
filterOptional ObjectQL where-map AND-ed with the computed date window (e.g. { status: 'active' })
maxRecordsCap 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 against today(). Use that for a real "due today" condition; use timeRelative for 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

StepPurpose
queryRead records via ObjectQL
create / update / deleteWrite to objects
actionInvoke a built-in or plugin-registered action (email, webhook, AI call, …)
conditionBranch on a CEL expression
foreachIterate over a collection
parallelRun sub-steps concurrently
waitPause for duration / until timestamp / until condition
subflowCall another flow
approvalBlock 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 wait instead of long-running steps. A flow that sleeps blocks a worker; a wait until returns the worker to the pool.
  • Use parallel for 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

On this page