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.

Anatomy

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.

Triggers

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.
manual:Only on an explicit run (REST …/run, SDK flows.run, GraphQL runFlow, MCP flows.invoke, 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.

Templating

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 }}.
  • {{ $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.

Operations

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

typeDoesKey fields
logWrites a [flow] … line to the server logmessage
emailSends a templated email (renders an email_templates row when templateKey is set, else uses subject/html/text)to, templateKey?, vars?, subject?, html?, text?
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?
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)
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?
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.

Surfaces

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

REST

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)
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 }
]
}'

SDK (backlex)

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);

GraphQL (reference)

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 }
}

MCP (reference)

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

CLI (reference)

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>

Worked example

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.