ConstruireAutomatisation
Cette page n'est pas encore traduite. Elle s'affiche en anglais.

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, Studio'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 flow metadata in the shape this page describes, so you can read and edit it.

A flow is a graph: nodes do the work, edges connect them, and the start node says what launches the flow. The shape is ObjectStack's FlowSchema, documented in full in ObjectStack's Flow metadata.

Enable the capability in your stack:

export default defineStack({
  // ...
  requires: ['automation', 'triggers'],  // the flow engine, and the triggers that start flows
});

Both tokens are needed. defineStack refuses a stack with a flow that starts on a record change, a schedule or an API call while requires lacks either one, because such a flow would register and never run.

Flow types

typeStarted byUse for
record_changeA record insert, update or delete, bound on the start node"Email the assignee when a ticket is created"
scheduleA cron schedule on the start node"Mark stale tasks every night at 2am"
schedule, time-relative (16.0)A date field's proximity to today, via a daily sweep"Remind me 60 days before the contract ends"
autolaunchedAn action button, another flow, or an API call"Approve invoice" actions
screenA user, through one or more screensGuided data entry
apiAn HTTP requestAn endpoint for an external system

Record change: react to a record change

// src/flows/ticket_assigned_email.ts
import { defineFlow } from '@objectstack/spec';

export const ticketAssignedEmail = defineFlow({
  name: 'ticket_assigned_email',
  label: 'Email the assignee of a new ticket',
  type: 'record_change',
  status: 'active',
  nodes: [
    {
      id: 'start',
      type: 'start',
      label: 'Ticket created',
      config: { objectName: 'support_ticket', triggerType: 'record-after-create' },
    },
    {
      id: 'email_assignee',
      type: 'notify',
      label: 'Email the assignee',
      config: {
        recipients: '{record.assignee}',
        title: 'New ticket: {record.subject}',
        message: 'You have been assigned {record.subject}, priority {record.priority}.',
        channels: ['email'],
      },
    },
    { id: 'end', type: 'end', label: 'End' },
  ],
  edges: [
    { id: 'e1', source: 'start', target: 'email_assignee' },
    { id: 'e2', source: 'email_assignee', target: 'end' },
  ],
});

Email goes out through a notify node; there is no separate email node type. channels: ['email'] sends the notification on the email channel, through the transport configured on Email. Without channels, a notification goes to the in-app inbox only. To send a stored email template instead of inline text, set template (a sys_email_template name) in place of title and message.

Field values and notification text in a node's config are templates: {record.<field>} reads the triggering record, {<variable>} a flow variable, {$User.Id} the current user, and {NOW()} / {TODAY()} are the date macros. Conditions are bare CEL with no braces — see Conditions and branches.

The start node's triggerType picks the event, and its optional condition narrows it, for example condition: "record.priority == 'high'":

triggerTypeFires
record-after-createAfter an insert
record-after-updateAfter an update
record-after-deleteAfter a delete
record-after-writeAfter an insert or an update
record-before-createBefore an insert
record-before-updateBefore an update
record-before-deleteBefore a delete
record-before-writeBefore an insert or an update

On an update, previous holds the record as it was: condition: 'record.stage != previous.stage'. To change the pending record within the same write, before it is saved, use a before hook rather than a flow; flows are for the side effects of a save — creating records, notifying, calling HTTP, requesting approval (ObjectStack's Hooks vs flows).

Scheduled: run on a clock

export const nightlyCleanup = defineFlow({
  name: 'nightly_cleanup',
  label: 'Mark overdue tasks',
  type: 'schedule',
  status: 'active',
  runAs: 'system',  // a scheduled run has no user, so its data operations need an explicit identity
  nodes: [
    {
      id: 'start',
      type: 'start',
      label: 'Every night at 2am',
      config: {
        triggerType: 'schedule',
        schedule: { type: 'cron', expression: '0 2 * * *', timezone: 'America/New_York' },
      },
    },
    {
      id: 'mark_overdue',
      type: 'update_record',
      label: 'Mark open tasks past due',
      config: {
        objectName: 'task',
        filter: { status: 'open', due_date: { $lt: '{TODAY()}' } },
        fields: { status: 'overdue' },
        multi: true,  // update every match; without it, a filter that is not a single id is refused
      },
    },
    { id: 'end', type: 'end', label: 'End' },
  ],
  edges: [
    { id: 'e1', source: 'start', target: 'mark_overdue' },
    { id: 'e2', source: 'mark_overdue', target: 'end' },
  ],
});

Backed by the @objectstack/service-job capability — see Runtime Capabilities. A time-triggered flow has no caller to inherit an organization from; ObjectStack's Flow metadata says when its start node must declare one with organization.

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',
  label: 'Contract renewal reminder',
  type: 'schedule',
  status: 'active',
  runAs: 'system',  // a sweep has no user to act as
  nodes: [
    {
      id: 'start',
      type: 'start',
      label: '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',
      config: { recipients: '{record.owner}', title: 'Contract {record.name} ends on {record.end_date}' },
    },
    { id: 'end', type: 'end', label: '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:

// Two variants of the start node's config.timeRelative; the rest of the flow is as above.

// "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".

Started by hand: actions and API calls

export const approveInvoice = defineFlow({
  name: 'approve_invoice',
  label: 'Approve invoice',
  type: 'autolaunched',
  status: 'active',
  variables: [
    { name: 'invoice_id', type: 'text', isInput: true },
  ],
  nodes: [
    { id: 'start', type: 'start', label: 'Start' },
    {
      id: 'approve',
      type: 'update_record',
      label: 'Approve the invoice',
      config: {
        objectName: 'invoice',
        filter: { id: '{invoice_id}' },
        fields: { status: 'approved', approved_by: '{$User.Id}' },
      },
    },
    { id: 'end', type: 'end', label: 'End' },
  ],
  edges: [
    { id: 'e1', source: 'start', target: 'approve' },
    { id: 'e2', source: 'approve', target: 'end' },
  ],
});

Surface it as a button on the Invoice view, with an action of type flow, or call it via REST:

curl -X POST https://app.example.com/api/v1/automation/approve_invoice/trigger \
  -H 'Authorization: Bearer <token>' \
  -H 'Content-Type: application/json' \
  -d '{"params": {"invoice_id": "inv_123"}}'

Inputs are flow variables declared with isInput: true, sent in params under the same names; only declared inputs are bound. The caller's identity is forwarded into the run, so the update runs under the caller's permissions.

Node types

Node typePurpose
get_recordRead records via ObjectQL — one, or a list when limit is above 1
create_record / update_record / delete_recordWrite to objects (multi: true to update or delete every match)
notifySend a notification — the in-app inbox by default, email with channels: ['email']
httpCall an HTTP endpoint
connector_actionRun an action on an installed connector (Slack, …)
scriptCall a registered function, named by config.function
decisionBranch on edge conditions
assignmentSet flow variables
loopRun a body region once per item of a collection
parallelRun branch regions concurrently
try_catchCatch, and optionally retry, a failure inside a region
waitPause for a timer or a named signal
screenPause for a user's input
subflowCall another flow
approvalBlock until approvers decide (requires @objectstack/plugin-approvals)

Conditions and branches

export const expenseRouting = defineFlow({
  name: 'expense_routing',
  label: 'Route new expenses',
  type: 'record_change',
  status: 'active',
  nodes: [
    {
      id: 'start',
      type: 'start',
      label: 'Expense submitted',
      config: { objectName: 'expense', triggerType: 'record-after-create' },
    },
    { id: 'check_amount', type: 'decision', label: 'Over 10,000?' },
    {
      id: 'ask_approver',
      type: 'notify',
      label: 'Ask the approver',
      config: { recipients: '{record.approver}', title: 'Expense over 10,000: {record.name}' },
    },
    {
      id: 'auto_approve',
      type: 'update_record',
      label: 'Auto-approve',
      config: { objectName: 'expense', filter: { id: '{record.id}' }, fields: { status: 'auto_approved' } },
    },
    { id: 'end', type: 'end', label: 'End' },
  ],
  edges: [
    { id: 'e1', source: 'start', target: 'check_amount' },
    { id: 'e_large', source: 'check_amount', target: 'ask_approver', condition: 'record.amount > 10000' },
    { id: 'e_other', source: 'check_amount', target: 'auto_approve', isDefault: true },
    { id: 'e2', source: 'ask_approver', target: 'end' },
    { id: 'e3', source: 'auto_approve', target: 'end' },
  ],
});

A decision node branches on its out-edges. They are evaluated in the order edges lists them, the first whose condition holds runs, and the isDefault edge runs when none does. Without isDefault, an edge with no condition runs on every pass, beside whichever branch matched.

Error handling

// Keys of a record_change flow on `invoice`. The nodes are omitted: `charge_card` is an
// `http` node, `mark_paid` an `update_record`, and `flag_for_review` a `notify`.
edges: [
  { id: 'e_ok',    source: 'charge_card', target: 'mark_paid' },
  { id: 'e_fault', source: 'charge_card', target: 'flag_for_review', type: 'fault' },
],
errorHandling: { strategy: 'retry', maxRetries: 3, backoffMs: 1000, backoffMultiplier: 2 },

Without either mechanism, a node failure ends the run. They work at two scopes:

ScopeOn failure
A fault edgeOne nodeThe run continues from the handler, and completes
errorHandling: { strategy: 'retry', maxRetries }The whole flowThe flow re-runs from the start, up to maxRetries more times

strategy is 'fail' (the default), 'retry' or 'continue'. A retry replays every node that already succeeded — a second email, a second created record — so prefer a fault edge where the failure is local. A failure a fault edge handled is not a flow failure and does not consume a retry, and the failed step stays in the run's trace.

Formulas and expressions (CEL)

Conditions — the start node's condition, an edge's condition — are CEL (Common Expression Language), Google's language for safe expression evaluation:

'record.amount > 10000 && record.tier == "enterprise"'
'record.stage != previous.stage'
'!isBlank(record.notes)'

record and previous carry every declared field, and a field without a value reads as null, so has(record.notes) says that the field is declared, not that it has a value. Test for a value with record.notes != null or isBlank(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. It is Studio's Automations pillar.

Testing flows

os test qa/ticket-email.test.json   # run a Quality Protocol test suite against a running server

os test runs Quality Protocol scenarios, written as JSON test suites, against a running server; --tags selects scenarios by tag. See the CLI reference.

Limits & best practices

  • Keep before-hooks small. They block the write transaction.
  • Use a wait node instead of a long-running step. It parks the run durably, and a timer or a named signal resumes it.
  • Use a parallel block for independent work. It runs its branches concurrently and joins them before the flow continues.
  • Idempotency matters. A flow-level retry re-runs nodes that already succeeded; 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