Skip to content
Runtime

Flows

Server-side automation — a trigger plus a list of operations (log, email, push, webhook, item.create/update, condition, delay, run a function …) that run on the server when an item event fires, on a cron schedule, on a manual run, or on an inbound webhook. Admin-authored; reachable over REST, the SDK, GraphQL, MCP, and the CLI.

Flows are Backlex’s server-side automation engine: a trigger plus an ordered list of operations that run on the server when the trigger fires. Think “if this happens, do that — log it, email someone, write a row, call a webhook, branch on a condition, wait, then continue.”

Flows are admin-authored — every surface that manages or runs them requires the admin role. That’s the trust boundary: because an admin wrote the flow, its item.create / item.update ops bypass the permissions DSL and run with full access. Don’t expose flow authoring to end-users.

A flow row is { id, name, trigger, operations, layout, active }:

  • trigger — a string that decides when the flow runs (see below).
  • operations — a non-empty array of operations executed top to bottom. Each op can carry nested onSuccess / onError branches.
  • layout — a purely presentational snapshot of the visual builder graph; the engine ignores it.
  • active — paused flows (active: false) are skipped by every trigger and return { ok: false, error: "flow is paused" } on a manual run.
Trigger stringFires when
event:<channel>:<event>A matching realtime event publishes. Item events use the channel items:<slug>, so event:items:posts:created fires on a new posts row. * is a wildcard segment: event:items:posts:* catches create/update/delete, event:items:* catches every collection.
cron:<pattern>The cron pattern is due. Standard 5-field crontab (cron:*/5 * * * * = every 5 min). Dispatched by the same cross-runtime scheduler tick that runs cron functions — no extra infrastructure.
schedule:<spec>A row’s date field comes due — “three days before due_date”. Fires once per matching row, with that row as data. See below.
manual:Only on an explicit run (REST …/run, SDK flows.run, GraphQL runFlow, MCP flows.run, CLI flows run).
webhookAn inbound POST /api/webhook/<flowId> arrives. The request body becomes the flow’s data payload. Public endpoint — guard it with a check op or a shared secret in the path.

The matched row that changed (for event triggers) or the run payload (for manual / webhook) lands in data, available to every op via templating.

A cron flow arrives with no row. That makes “remind me three days before an invoice is due” inexpressible with it: the tick knows the time, not who it is about. A schedule trigger is the other shape — it scans a collection and runs the flow once per row whose date has come due, with the row as data.

The trigger string is schedule: followed by the spec as JSON; the admin’s trigger inspector writes it for you.

{
"collection": "invoices",
"field": "due_date",
"offset": { "value": 3, "unit": "days", "direction": "before" },
"at": 540,
"timeZone": "Europe/Istanbul",
"where": { "status": { "_neq": "paid" } }
}

field must be a timestamp column — a schedule counting from a text field is refused when you save it, because it would not misfire, it would never fire at all.

at changes what the offset means. Left null, the flow fires at the same time of day the date field itself carries, and the offset is plain elapsed time (minutes, hours, days, weeks all allowed). Set to a minute-of-day, the offset becomes calendar days and the flow fires at that wall clock in timeZone — so a reminder stays at 09:00 local across a daylight-saving change instead of drifting to 08:00 for half the year. That pairing is why at needs a days or weeks offset: “two hours before, at 09:00” names two different instants, and it is refused rather than silently resolved. A wall clock that does not exist (the spring-forward gap) fires nothing rather than being rounded into the next hour.

Each row fires exactly once per instant. The scan looks back over a two-day catch-up window rather than only since the last tick, because that window’s left edge is per-process state and a serverless tick may start with no memory of one — a restart or a deploy would otherwise drop every reminder in the gap, silently. A flow_schedule_fires ledger with a unique index on (flow, row, instant) is what keeps that from re-sending: the dispatch claims its row before running, so two instances ticking together cannot both win.

Two consequences worth knowing:

  • Moving a date fires again. The instant is part of the key, so a corrected due date is a new claim. A row nobody touched never re-fires.
  • Turning a schedule on does not mail the backlog. The scan never reaches back past the flow’s own creation, so rows that were already overdue when you saved it stay quiet.

Bookings fire on bookingevent:booking:created, booking:confirmed, booking:cancelled, booking:rescheduled, booking:no_show — with the booking, plus resourceKey and resourceName, as data. That is how a reminder, a calendar write-back or a deposit gets attached to somebody picking a time.

Unlike an item event, this one is not on the realtime bus: it reaches flows, webhooks, event functions and extension hooks, and nothing can subscribe to it. A booking carries a customer’s name, address and telephone number, and the realtime plane’s per-subscriber permission filter only applies to row-shaped payloads. Use the mirror collection if you want bookings on a realtime channel — a mirrored row is a row, and gets the filter.

The channel is singular for a reason worth knowing before you name your own: item events publish on items:<slug>, and three of the schema templates own a collection called bookings. Had the system channel been bookings too, a pattern like event:bookings:created would have matched both the template’s rows and the system’s own events, and fired the flow twice.

Any string field in an op is interpolated with {{ … }} placeholders resolved against three roots:

  • {{ data.* }} — the trigger payload (the changed row, the manual input, or the webhook body). E.g. {{ data.id }}, {{ data.author.email }}. A relation column holds a foreign key, so {{ data.author }} renders the id. Reading through it — {{ data.author.email }} — loads the related row for you, and only the relations a flow actually reads through are fetched. Both forms work in the same flow: the expanded value still renders as the id when you write the bare form. relation_many is a list of ids and is not expanded; use a foreach if you need the rows.
  • {{ $user.id | $user.email | $user.roles }} — the auth subject that ran the flow.
  • {{ $last.* }} — the result of the previous operation (e.g. a request op’s parsed response), so ops can chain.

Missing paths render as an empty string. Interpolation recurses into objects and arrays, so a webhook body or item.create data map is templated field by field.

Every op is { type, …fields, onSuccess?, onError? }. onSuccess / onError are nested operation arrays run after the op succeeds / throws.

typeDoesKey fields
logRecords the rendered line on the run — readable in the response and on the flow.run activity row — and also writes [flow] … to the server logmessage
emailSends a templated email (renders an email_templates row when templateKey is set, else uses subject/html/text). attach carries generated documents, ics a calendar invite — see belowto, templateKey?, vars?, subject?, html?, text?, attach?, ics?
notificationDrops a row into the in-app notifications feed; userId: null broadcasts to admins. push: true also fans out to that user’s devicestitle, body?, url?, userId?, push?
pushSends a native push to a user’s registered devices (no-op if none)title, body, userId, url?
smsSends an SMS through the workspace SMS transport. Addressed either by to (a number carried on the row) or by userId (a user’s registered numbers) — exactly one, see belowbody, to?, userId?, from?
ai.generateGenerates text with the workspace’s AI provider and returns { text, usage? } into {{ $last }}. See belowprompt, system?, model?, maxTokens? (≤8192), effort?, timeoutMs? (≤120s)
ai.classifyPicks one of labels for input and returns { label, matched } into {{ $last }} — the value a following condition branches on. An answer outside the set fails the step unless fallback says otherwise. See belowinput, labels (≥2, distinct), instructions?, model?, fallback?, timeoutMs? (≤120s)
payment.checkoutOpens a hosted checkout with a connected payment provider and optionally writes the link onto a row. Returns { url, reference, … } into {{ $last }}amount (minor units), currency, provider? | providerId?, email?, description?, successUrl?, writeBack?
payment.refundGives back some or all of a payment through the provider that took it. Returns { amount, currency, status, … } into {{ $last }}one of paymentRowId | externalId | reference, amount? (minor units; omitted = the whole balance), provider? | providerId?, reason?, description?
document.renderRenders a document template against the row and stores the PDF. Returns { key, filename, size } into {{ $last }}templateKey? | html?, vars?, filename?, writeBack?
document.signFreezes a document and sends it out for signature — one public link per signer, emailed by the op. Returns { id, status, signers } (and deliberately no links) into {{ $last }}templateKey? | html?, signers, title?, message?, ordered?, expiresInDays?, writeBack?
webhookFires an outbound HTTP request, body JSON-encodedurl, method?, headers?, body?
requestLike webhook but captures the parsed response into {{ $last }} for later opsurl, method?, headers?, query?, body?, timeoutMs? (≤60s)
functionInvokes a saved sandbox function by namename, input? (defaults to data)
run-scriptRuns inline code in the same sandboxcode, timeoutMs? (≤30s)
integrationSends a message through a connected provider, addressed by kind. A provider nobody connected is skipped, not failedkind, text, event?, payload?
integration.taskAsks a connected provider to act on ONE row and writes the answer back — books a shipment, notifies a marketplace. Runs at most once per row; a missing connection fails the runkind, task, collection, itemId, settings?, outputMapping?, force?
item.createInserts a row into a collection (permissions bypassed)collection, data (object or a template string parsed at run time)
item.updatePatches a row by id (permissions bypassed)collection, id, data
conditionBranches: runs then / else based on a permissions-DSL condition over datafilter, then?, else?
foreachRuns do once per row of a collection, with the row as {{ $item.* }}. See belowcollection, do, filter?, sort?, limit? (≤500)
transformEvaluates value (templated) and exposes it as {{ $last }}value
delayPauses. ≤30s sleeps inline; longer is persisted to scheduled_tasks and resumed by the scheduler (cap 30 days)durationMs

A run stops at the first op that throws without an onError branch and returns { ok: false, error }. A flow that checkpointed on a long delay still returns { ok: true } — the remainder is queued, not failed.

Where a schedule trigger answers “each row, when its date comes”, foreach answers “each row, right now” — a weekly digest to every active subscriber, a nightly pass over everything still unassigned.

{
"type": "foreach",
"collection": "subscribers",
"filter": { "active": { "_eq": true } },
"sort": "-created_at",
"do": [
{ "type": "email", "to": "{{ $item.email }}", "templateKey": "weekly-digest" }
]
}

The loop row is {{ $item.* }}, not data. data still holds the flow’s own trigger payload, and rebinding it per iteration would quietly change what every {{ data.x }} in the body meant. (A schedule trigger is the opposite case — there the row is the payload, so it arrives as data and a flow rewritten from event: to schedule: keeps every template it already had.)

The body runs inline, which is what these rules follow from — all of them refused when you save, not discovered at run time:

  • No approval.request and no delay over 30s inside a loop. Both suspend the flow, and the continuation machinery parks “everything after this op at the top level” — which is not “the rest of this iteration, then the remaining rows”. Parked, the loop would silently run once and report success.
  • No foreach inside a foreach. The inner loop would shadow {{ $item }} with no way to reach the outer row.
  • A loop needs a body. An empty one is a body attached to the wrong port.

limit caps the walk at 500 rows, which is also the default. The cap is the default on purpose: a loop that quietly stopped at 50 would report success having skipped the rest. When a loop does hit the cap, the server logs it — “every overdue invoice” covering only some of them is worth saying out loud.

One more thing that bites: a filter operator the DSL does not recognise compiles to true, so $ne where you meant _neq matches every row instead of none. Flow filters are checked for that when you save.

An email op with an ics block attaches a calendar invite. This is the write-back that needs nothing connected: no OAuth, no account, and it reaches Google Calendar, Outlook, Apple Calendar and everything else, from the confirmation email the booking was already going to send. (The other direction — backlex creating the event in a specific Google calendar — is a Calendar destination sync.)

{
"type": "email",
"to": "{{ data.email }}",
"subject": "Your appointment is confirmed",
"text": "See you on {{ data.starts_at }}.",
"ics": {
"summary": "{{ data.service }}",
"start": "{{ data.starts_at }}",
"end": "{{ data.ends_at }}",
"location": "{{ data.address }}",
"organizerEmail": "bookings@example.com"
}
}

Every field is interpolated. summary and start are required; the rest have defaults — guests default to the message’s own recipient, and an end that is missing becomes an hour (or, for an all-day date, a day).

The uid is what stops a second booking. A calendar keys an event on it, so re-sending with the same uid updates the entry the recipient already accepted, and a fresh one books the appointment twice. It therefore defaults to the triggering row’s id — the one value stable across every re-run of a row-scoped flow. Set it explicitly when the flow isn’t row-scoped. Raise sequence on each re-send, and use method: "CANCEL" to withdraw.

organizerEmail decides what the recipient sees: with it the file is a REQUEST and mail clients render accept/decline; without it, a plain event to add.

A start that renders empty or unparseable fails the op. The message names the template, never the rendered value — it is persisted on the run’s activity row, and that value is customer data.

Transports. Every transport carries the file — the self-hosted ones (Resend, SendGrid, Mailgun, SES, SMTP, console) and, since v0.4.114, the managed-cloud gateway. The cloud gateway caps a message at 5 attachments and ~2 MB of encoded content, and it refuses past that rather than trimming, so an oversized send fails loudly instead of arriving incomplete.

A transport that genuinely cannot carry the file still sends the mail and returns attachmentsDropped: true, so the run reports that the invite did not travel rather than leaving the recipient to find out. Nothing is silently lost either way — the flag is what the running transport actually does, not what it intends to.

push can only reach a platform user, because a device has to be registered against an account. SMS has the opposite centre of gravity: the message you most want to automate — an appointment reminder, a delivery notice — goes to a customer, who has no account at all. So the op carries two addressing modes and you pick exactly one:

{ "type": "sms", "to": "{{ data.phone }}", "body": "Reminder: {{ data.starts_at }}" }
{ "type": "sms", "userId": "{{ data.assigned_to }}", "body": "New job assigned" }
  • to — a literal or templated number. It must render to E.164 (+14155552671); anything else fails the op rather than handing the provider a number it will silently drop. A template that renders empty (the row has no phone) fails the same way — a reminder that quietly goes nowhere is worse than a visibly failed run.
  • userId — texts every active number that user registered via /api/phone-numbers. A user with none is a silent no-op, matching push: an unreachable recipient shouldn’t take the automation down.

Setting both, or neither, is rejected when the flow is saved. from overrides the transport’s configured sender id where the provider supports it.

Asking the model, and branching on what it said

Section titled “Asking the model, and branching on what it said”

Two AI steps, deliberately separate rather than one step with a mode, because what they promise the next step is different.

ai.generate writes something. Its answer is prose, and prose is not something a condition can branch on:

{
"type": "ai.generate",
"prompt": "Write a one-line summary of this ticket: {{ data.body }}",
"system": "You write for support staff. Plain, no greeting."
}

{{ $last.text }} is the answer, and {{ $last.usage }} is what it cost — tokens on a direct provider key, neurons on managed cloud, and absent when the provider reported nothing (a zero there would read as free).

ai.classify decides. Its answer is one of a set you wrote down, which is exactly what the step after it can switch on:

[
{
"type": "ai.classify",
"input": "{{ data.subject }}\n{{ data.body }}",
"labels": ["billing", "technical", "other"],
"instructions": "A refund request counts as billing.",
"fallback": "other"
},
{
"type": "condition",
"filter": { "$last.label": "billing" },
"then": [{ "type": "notification", "title": "Billing ticket", "userId": null }]
}
]

The executor checks the answer against labels rather than trusting it. A model that answers outside the set fails the step, unless fallback names the label that should mean “none of these” — and fallback must itself be one of labels, so the condition after it only ever sees a value somebody wrote a branch for. {{ $last.matched }} is false when the fallback was used, so a flow can treat “the model was unsure” differently from “the model said other”.

Worth knowing before you build on these:

  • Naming a model is a hint, not a guarantee. Omitted, the workspace’s Settings · AI choice applies. On managed cloud generation runs on the platform gateway, which forwards only Workers AI (@cf/…) ids and uses its own default for anything else — so a Claude id picked here is honoured on self-host and quietly ignored on cloud.
  • Every generation is metered against the workspace under Usage as an AI call, separately from requests — a flow step generates with no request of its own. It is metered but not capped: an AI step inside a foreach generates once per row, up to the loop’s 500-row ceiling, and nothing counts across the loop.
  • There is a deadline, and it is the only one in the engine besides request. timeoutMs defaults to 60s. A flow run has no retry and no dead-letter queue above it, so a generation that never returns would be a run that never ends.
  • Errors name the template, never the rendered value. A failing step is persisted on the flow.run activity row, and a model asked to classify a support ticket can echo the ticket back — so an unmatched classification reports the label set, not the answer.

For a conversation rather than a single call — tools, memory, a transcript — the feature is agents, not a flow op.

payment.checkout is the step that turns “an invoice was created” into “the invoice has a payment link on it”:

{
"type": "payment.checkout",
"provider": "stripe",
"amount": "{{ data.amount_due }}",
"currency": "USD",
"email": "{{ data.email }}",
"writeBack": { "collection": "invoices", "itemId": "{{ data.id }}", "urlField": "pay_url" }
}

amount is in minor units1050 is 10.50 — matching how payments are stored, and it is usually a template. A render that isn’t a positive integer fails the run, as does a writeBack target that renders empty: a live payment link nothing records is worse than a visible failure. The error names the offending template and never the rendered value, which would put a customer’s invoice total on the persisted flow.run activity row.

The link also lands in {{ $last.url }}, so the next step can email or text it. Full provider matrix in Payments.

payment.refund is the mirror, and it pairs with a status changing — an order moving to cancelled, a return being approved:

{
"type": "payment.refund",
"reference": "{{ data.payment_reference }}",
"reason": "requested_by_customer"
}

Which payment is named by one of paymentRowId, externalId or reference. reference is usually the only handle a flow has: the row that was billed knows what it was billed under and nothing else about the payment. Naming none of the three is rejected when the flow is saved, because that op can never do anything.

An omitted amount means the whole remaining balance, which is the opposite of payment.checkout — there an amount is required and an unrenderable one is fatal. Here only a present amount that renders to something other than a positive integer fails the run. A reference that renders empty fails too: refunding whichever payment turns up instead is not a recoverable guess.

The refundable remainder is checked against the ledger before the provider is called, so a refund can never take the total past what was charged. The outcome lands in {{ $last.status }} — Adyen and Paddle both decide asynchronously and answer pending, so a following condition can branch on it. Provider specifics in Payments.

document.sign is the step after document.render for anything somebody has to sign:

{
"type": "document.sign",
"templateKey": "lease",
"title": "Lease {{ data.no }}",
"signers": "{{ data.parties }}",
"ordered": true,
"writeBack": { "collection": "leases", "id": "{{ data.id }}", "field": "signed_doc" }
}

signers is a list or one template that resolves to an array — a lease with two tenants carries its own counterparties, and interpolate builds strings, so a whole-value placeholder resolves to the value itself here rather than to [object Object].

Unlike payment.checkout, {{ $last }} carries no link. Everything on $last is readable by every op after it, and a signing link is a bearer credential for somebody else’s signature — the op sends the invitation itself, and the signature_request email template is where its wording lives. Details in E-signature.

A flow’s log op is the shortest way to answer did my interpolation resolve?, so its output is kept on the run rather than only written to the server log — a managed tenant’s operator cannot open the account’s Worker observability, and that used to be the only place it went.

Terminal window
curl -s -X POST "$URL/api/flows/$ID/run" -H "$AUTH" -d '{"name":"Ege Yapı","tier":"gold"}'
# { "ok": true, "log": ["dealer=Ege Yapı tier=gold"] }

The body is the flow’s data, not a wrapper around it — {"name":…}, not {"data":{"name":…}}. Wrapping it is a quiet way to get an empty render, since every {{ data.name }} then resolves against the wrapper.

The same result is written to the run record, so it stays readable afterwards:

Terminal window
curl -s "$URL/api/activity?action=flow.run&limit=1" -H "$AUTH"
# { "data": [ { "response": { "ok": true, "error": null,
# "log": ["dealer=Ege Yapı tier=gold"] }, … } ] }

log is absent when the flow has no log op. It is capped at 50 lines — a 51st says the rest were truncated — and 500 characters per line, so a log inside a foreach over a large collection cannot turn one run into a write amplifier.

Flows are reachable from every API surface; all are admin-scoped.

GET /api/flows list
GET /api/flows/{id} get
POST /api/flows create { name, trigger, operations, layout?, active? }
PATCH /api/flows/{id} update (partial)
DELETE /api/flows/{id} delete
POST /api/flows/{id}/run run synchronously with an arbitrary JSON body → { ok, error? }
POST /api/webhook/{id} public webhook trigger (only for `trigger: "webhook"` flows)

Run history is in the activity log, not under /api/flows. Every run writes one flow.run row — itemId is the flow’s id, and response carries { ok, error } — so “did it run, and did it work” is a filter away:

Terminal window
curl "$URL/api/activity?action=flow.run&limit=20" --cookie "$C"
curl "$URL/api/activity?action=flow.run&itemId=$FLOW_ID" --cookie "$C"

The admin’s per-flow cards (last run, success rate, failures) read the same rows. A flow that halted mid-way also leaves its error on that row — there is no separate failure store to check.

Terminal window
curl -X POST http://localhost:5173/api/flows \
-H 'Content-Type: application/json' -H 'Origin: http://localhost:5173' \
--cookie "$(your admin session cookie)" \
-d '{
"name": "Notify on new post",
"trigger": "event:items:posts:created",
"active": true,
"operations": [
{ "type": "log", "message": "New post: {{ data.slug }} (id {{ data.id }})" },
{ "type": "notification", "title": "New blog post",
"body": "Draft \"{{ data.slug }}\" was created.", "userId": null }
]
}'

client.flows.* mirrors the REST surface. Use an admin API key or session — end-user (workspace) clients get FORBIDDEN.

const flow = await client.flows.create({
name: "notify",
trigger: "manual:",
operations: [{ type: "log", message: "hi {{ data.who }}" }],
});
await client.flows.list();
await client.flows.get(flow.data.id);
await client.flows.update(flow.data.id, { active: false });
const run = await client.flows.run(flow.data.id, { who: "world" }); // { ok, error? }
await client.flows.delete(flow.data.id);

Static flows / flow(id) queries and createFlow / updateFlow / deleteFlow / runFlow mutations, present on every workspace’s schema.

mutation Run($id: ID!, $input: JSON) {
runFlow(id: $id, input: $input) { ok error }
}

Three tools for AI agents: flows.list, flows.get, and flows.run (run a flow by id with an input payload).

Terminal window
bun backlex flows list
bun backlex flows get <id>
bun backlex flows run <id>
bun backlex flows create --data @flow.json # round-trips with `get`'s JSON output
bun backlex flows delete <id>

The blog-react example walks through a flow that fires on every new post and drops an admin notification — create a post in the app and watch the operations run.