ErstellenAutomatisierung
Diese Seite ist noch nicht übersetzt; angezeigt wird die englische Fassung.

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 request, signed with the start node's config.secretAn 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.

Scheduled work is off until the deployment turns it on. Since ObjectStack 17.5, a time-triggered flow (a schedule cadence, or the time-relative sweep below) and a packaged defineJob cron job arm only when the deployment sets OS_AUTOMATION_SCHEDULED_WORK_ENABLED to true (or 1, on, yes). Unset, nothing arms, and each such flow is reported as disabled by deployment policy, not as an error. Off is the ObjectStack runtime's default; whether your ObjectOS image or its deploy-bundle template sets the variable for you is not something this page can promise, so set it explicitly when your app depends on scheduled flows, and confirm it with os doctor, which prints the effective value — see Environment Variables. The platform's own scheduled work (approval escalation, retention, message dispatch) is not affected. Under the isolated tenancy posture, a scheduled flow that does not declare organization on its start node is not armed even with the switch on.

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.

The route answers with an HTTP status that says what happened, so branch on the status rather than on a success flag in the body: 200 for a run that dispatched (completed, paused, or refused), 404 for an unknown flow, 409 FLOW_DISABLED for a flow that is switched off, 422 FLOW_NO_START_NODE for a definition with no start node, and 400 FLOW_FAILED for a run that dispatched and failed. An action of type flow answers the same way — see REST API → Flow status table.

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)
endEnd the run — completed by default, or refused with a reason (below)

Ending a run: completed or refused

Since ObjectStack 17.4 an end node declares the run's outcome. A flow that has to say no — a duplicate, an over-limit amount — ends on a refusing end instead of failing or showing a screen that only says so:

// One flow node; the flow around it is omitted.
{
  id: 'refuse_duplicate',
  type: 'end',
  label: 'Refused: duplicate',
  config: {
    outcome: 'refused',  // 'completed' (the default) | 'refused'
    message: 'Refused: {record.name} is already registered.',
  },
}

A refused run is not a failed one: nothing threw. It ends with the run status refused, the trigger route answers 200 with that status, and the rendered message comes back as refusalMessage, with no successMessage beside it. message is required when outcome is refused, and refused on a completed end, where nothing would ever show it. Reach the refusing end from a decision branch, and keep every write on the branches the refusal does not take.

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. To run every branch whose condition holds, declare config: { mode: 'inclusive' } on the decision. First-match is the rule since ObjectStack 17.5; before it, every true branch ran, and a decision stored in the database (one saved from Studio) took the new meaning on upgrade without being rewritten.

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.

A flow saved from Studio is stored in the deployment, beside the flows your installed packages ship. Two rules follow, both since ObjectStack 17.6:

  • A packaged flow wins its name. A stored flow that shares its name with a packaged flow is shadowed and does not run; the startup log warns about it. To customize a packaged flow, clone it under a new name.
  • Switch a stored flow with its status. The on/off toggle switches packaged flows only. A flow authored in the deployment is switched off by saving it with status: 'obsolete', and back on with status: 'active'.

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