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
type | Started by | Use for |
|---|---|---|
record_change | A record insert, update or delete, bound on the start node | "Email the assignee when a ticket is created" |
schedule | A 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" |
autolaunched | An action button, another flow, or an API call | "Approve invoice" actions |
screen | A user, through one or more screens | Guided data entry |
api | An HTTP request, signed with the start node's config.secret | An 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'":
triggerType | Fires |
|---|---|
record-after-create | After an insert |
record-after-update | After an update |
record-after-delete | After a delete |
record-after-write | After an insert or an update |
record-before-create | Before an insert |
record-before-update | Before an update |
record-before-delete | Before a delete |
record-before-write | Before 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
schedulecadence, or the time-relative sweep below) and a packageddefineJobcron job arm only when the deployment setsOS_AUTOMATION_SCHEDULED_WORK_ENABLEDtotrue(or1,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 withos doctor, which prints the effective value — see Environment Variables. The platform's own scheduled work (approval escalation, retention, message dispatch) is not affected. Under theisolatedtenancy posture, a scheduled flow that does not declareorganizationon 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' },
],
});| 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:
// 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 againsttoday(). Use that for a real "due today" condition; usetimeRelativefor 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 type | Purpose |
|---|---|
get_record | Read records via ObjectQL — one, or a list when limit is above 1 |
create_record / update_record / delete_record | Write to objects (multi: true to update or delete every match) |
notify | Send a notification — the in-app inbox by default, email with channels: ['email'] |
http | Call an HTTP endpoint |
connector_action | Run an action on an installed connector (Slack, …) |
script | Call a registered function, named by config.function |
decision | Branch on edge conditions |
assignment | Set flow variables |
loop | Run a body region once per item of a collection |
parallel | Run branch regions concurrently |
try_catch | Catch, and optionally retry, a failure inside a region |
wait | Pause for a timer or a named signal |
screen | Pause for a user's input |
subflow | Call another flow |
approval | Block until approvers decide (requires @objectstack/plugin-approvals) |
end | End 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:
| Scope | On failure | |
|---|---|---|
A fault edge | One node | The run continues from the handler, and completes |
errorHandling: { strategy: 'retry', maxRetries } | The whole flow | The 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 withstatus: 'obsolete', and back on withstatus: 'active'.
Testing flows
os test qa/ticket-email.test.json # run a Quality Protocol test suite against a running serveros 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
waitnode instead of a long-running step. It parks the run durably, and a timer or a named signal resumes it. - Use a
parallelblock 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
- Webhooks — outbound notifications, often triggered from flows
- Email — the transport behind a
notifynode'semailchannel - Notifications — how recipients, channels and templates resolve
- API Access — invoke flows from external systems
- @objectstack/service-automation — source for the execution engine