ObjectOS
Build

Flows & Automation

Declarative business logic — described to AI or written in TypeScript, the runtime executes the same artifact either way.

Flows & Automation

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, Console's 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'],
});

Three 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"
ManualUser clicks a button in Console, 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.

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 in Console as a button on the Invoice view, or call it via REST:

curl -X POST https://app.example.com/api/v1/data/invoice/actions/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

Console 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