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 nestedonSuccess/onErrorbranches.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 string | Fires 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). |
webhook | An 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. arequestop’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.
type | Does | Key fields |
|---|---|---|
log | Writes a [flow] … line to the server log | message |
email | Sends a templated email (renders an email_templates row when templateKey is set, else uses subject/html/text) | to, templateKey?, vars?, subject?, html?, text? |
notification | Drops a row into the in-app notifications feed; userId: null broadcasts to admins. push: true also fans out to that user’s devices | title, body?, url?, userId?, push? |
push | Sends a native push to a user’s registered devices (no-op if none) | title, body, userId, url? |
webhook | Fires an outbound HTTP request, body JSON-encoded | url, method?, headers?, body? |
request | Like webhook but captures the parsed response into {{ $last }} for later ops | url, method?, headers?, query?, body?, timeoutMs? (≤60s) |
function | Invokes a saved sandbox function by name | name, input? (defaults to data) |
run-script | Runs inline code in the same sandbox | code, timeoutMs? (≤30s) |
item.create | Inserts a row into a collection (permissions bypassed) | collection, data (object or a template string parsed at run time) |
item.update | Patches a row by id (permissions bypassed) | collection, id, data |
condition | Branches: runs then / else based on a permissions-DSL condition over data | filter, then?, else? |
transform | Evaluates value (templated) and exposes it as {{ $last }} | value |
delay | Pauses. ≤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 listGET /api/flows/{id} getPOST /api/flows create { name, trigger, operations, layout?, active? }PATCH /api/flows/{id} update (partial)DELETE /api/flows/{id} deletePOST /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)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)
bun backlex flows listbun backlex flows get <id>bun backlex flows run <id>bun backlex flows create --data @flow.json # round-trips with `get`'s JSON outputbun 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.