Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
107 changes: 107 additions & 0 deletions content/docs/automation/approvals.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,113 @@ export const opportunityApproval: Flow = {
For multi-step approvals, chain multiple `approval` nodes. For parallel
approvals, see the aggregating-node pattern in [Flow Metadata](/docs/automation/flows).

## The full lifecycle — submit to field change

What actually happens between "the flow hits the approval node" and "the record
says approved":

<Steps>

### The request opens, the run pauses

The node writes a `sys_approval_request` row: `status: 'pending'`,
`pending_approvers` (the resolved approver list), and — if you declared
`approvalStatusField` — it mirrors `'pending'` onto your record's field. The
record is **locked** against edits while pending (`lockRecord`, default `true`),
and the flow run parks until a decision arrives.

Only `approvers` is required on the node; everything else has a default
(`behavior: 'first_response'`, `lockRecord: true`, `maxRevisions: 3`).

<Callout type="warn">
**`type: 'role'` is not a position.** In an approver entry, `role` means the
better-auth membership tier (`owner` / `admin` / `member`). Naming a business
role like `sales_manager` there matches nobody and the request silently has no
approvers — use `{ type: 'position', value: 'sales_manager' }`.
</Callout>

### The approver finds it in their queue

Requests land in **Setup → Approvals → Requests**, whose `my_pending` view
filters to `status = pending` and `pending_approvers` containing the current
user. Programmatically:

```bash
curl -b cookies.txt \
"https://your-app.example.com/api/v1/approvals/requests?status=pending&approverId=usr_123"
```

`approverId` accepts a user id, an email, or `role:<r>` — and takes several
values (comma-separated or repeated) to cover a person's identities in one call.
Other filters: `status`, `object`, `recordId`, `submitterId`, `q`, `limit`,
`offset`.

<Callout type="warn">
**Opening a request notifies nobody.** There is no built-in "you have an
approval waiting" message today — an approver only discovers it by looking at
the queue. If people need to be told, do one of: add a `notify` node next to
the approval node in the flow (the practical answer), or install a messaging
service and drive `remind()`. Reminder, escalation, return, and reassignment
*do* publish notification topics — the initial request doesn't.
</Callout>

### The decision

```bash
curl -b cookies.txt -X POST \
https://your-app.example.com/api/v1/approvals/requests/req_456/approve \
-H "Content-Type: application/json" \
-d '{ "comment": "Within budget." }'
# POST .../reject for the other direction; body: { actorId?, comment? }
```

`actorId` defaults to the caller. The actor **must** be in `pending_approvers`
or the call returns 403 (`FORBIDDEN: actor '…' is not a pending approver`); a
request that isn't pending returns 409 (`INVALID_STATE`). Always go through
these endpoints — never resume the flow run directly.

<Callout type="info">
**With `behavior: 'unanimous'`, an approve is not the end.** Every approver but
the last only trims `pending_approvers`; the request stays `pending`
(`finalized: false`) and the flow stays parked. Only the final approval
finalizes it.
</Callout>

### The record changes and the flow resumes

On finalization the request row gets `status: 'approved'` (or `'rejected'`),
`pending_approvers: null`, and `completed_at`; your `approvalStatusField` is
mirrored to the same value, and the record unlocks. The parked run resumes down
the `approve` or `reject` branch.

Statuses in full: `pending`, `approved`, `rejected`, `recalled`, `returned`.

</Steps>

### Timeouts and escalation

`escalation` is real, not decorative: set `enabled: true` and `timeoutHours`,
and pick an `action` — `notify` (default), `reassign`, `auto_approve`, or
`auto_reject`. Auto decisions run through the normal decide path, so the flow
resumes exactly as if a human had clicked. Every escalation writes an audit row.

<Callout type="warn">
**Escalation needs the job service.** The plugin sweeps pending requests on an
internal timer (~5 min). Without a `job` service registered, SLA timers are
**display-only** and no escalation ever fires.
</Callout>

### Error codes

| Code | HTTP | Meaning |
|:---|:---|:---|
| `VALIDATION_FAILED` | 400 | Malformed request |
| `FORBIDDEN` | 403 | Actor isn't a pending approver |
| `REQUEST_NOT_FOUND` | 404 | No such request |
| `DUPLICATE_REQUEST` | 409 | A pending request already exists for this record |
| `INVALID_STATE` | 409 | The request is no longer pending |
| `THROTTLED` | 429 | Rate limited |

### Best practices

✅ **DO:**
Expand Down
94 changes: 87 additions & 7 deletions content/docs/automation/flows.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -31,17 +31,18 @@ const approvalFlow = {
type: 'decision',
label: 'Check Amount',
config: {
// decision expressions use {braces} around variables — see Expressions in flows
conditions: [
{ label: 'High Value', expression: 'order_amount > 10000' },
{ label: 'Standard', expression: 'order_amount <= 10000' },
{ label: 'High Value', expression: '{order_amount} > 10000' },
{ label: 'Standard', expression: '{order_amount} <= 10000' },
],
},
},
{
id: 'auto_approve',
type: 'update_record',
label: 'Auto Approve',
config: { object: 'order', fields: { status: 'approved' } },
config: { objectName: 'order', fields: { status: 'approved' } },
},
{
id: 'request_approval',
Expand Down Expand Up @@ -166,11 +167,11 @@ Each node performs a specific action in the flow.
type: 'create_record',
label: 'Create Follow-up Task',
config: {
object: 'task',
objectName: 'task',
fields: {
title: 'Follow up on {record.name}',
assignee: '{record.owner}',
due_date: 'TODAY() + 7',
due_date: '{TODAY() + 7}', // braces required — without them this writes the literal text
},
},
}
Expand Down Expand Up @@ -546,6 +547,84 @@ The plugin is a **soft dependency** on `metadata` — it tolerates running
without `MetadataPlugin` and it logs (not throws) on per-flow registration
failures so one broken flow does not abort startup.

## Expressions in flows

A flow mixes **three expression dialects**, and using the wrong one is the
single most common way a flow silently misbehaves. Which dialect applies is
decided by *where* the expression sits — not by what it looks like:

| Where | Dialect | Write it like | Bindings |
|:---|:---|:---|:---|
| Start-node `condition` | **CEL** (bare, no braces) | `record.amount > 500` | `record.*`, `previous.*`, bare field names, `vars.*` |
| Edge `condition` | **CEL** (bare, no braces) | `record.status == 'open'` | same as above |
| Decision-node `conditions[].expression` | **Template compare** (braces required) | `{order_amount} > 10000` | flow variables by name, in `{…}` |
| Field values in `create_record` / `update_record` | **Interpolation** (braces required) | `'Follow up on {record.name}'`, `'{TODAY() + 7}'` | `{var}`, `{var.path}`, `{$User.Id}`, `{$User.Email}`, `{NOW()}`, `{TODAY()}`, `{TODAY() + 90}` (whole days) |

<Callout type="warn">
**The two failure modes to memorize:**

1. **Braces missing in a decision expression** — `'order_amount > 10000'` isn't
evaluated as a variable at all. It compares the *string* `"order_amount"`
against `"10000"`, which is **always true**, so the flow always takes the
first branch and never tells you. Write `'{order_amount} > 10000'`.
2. **Braces missing in a field value** — `due_date: 'TODAY() + 7'` writes the
literal text `TODAY() + 7` into the field. Write `'{TODAY() + 7}'`.

The mirror mistake is putting braces *into* a CEL condition
(`'{record.amount} > 500'`) — CEL conditions fail loudly rather than silently,
with an error that tells you to drop the braces.
</Callout>

CEL conditions that fail to evaluate raise an error and stop the run — they
never quietly pass. See [Formulas](/docs/data-modeling/formulas) for the CEL
language itself.

<Callout type="info">
**A boolean gotcha:** SQLite/libSQL store booleans as `0`/`1`, and CEL's `1 !=
true`. Compare against the stored shape (`record.done == 1`) or normalize the
value before the condition.
</Callout>

## Run a flow via API

Flows of any type can be launched over HTTP — this is what an external system,
a scheduled job outside the platform, or the Console's test runner uses:

```bash
curl -b cookies.txt -X POST \
https://your-app.example.com/api/v1/automation/order_approval/trigger \
-H "Content-Type: application/json" \
-d '{ "recordId": "ord_123", "objectName": "order", "params": { "priority": "high" } }'
# → { "success": true, "data": { ... }, "meta": { ... } }
```

| Endpoint | Purpose |
|:---|:---|
| `POST /api/v1/automation/:name/trigger` | Start a flow (canonical) |
| `GET /api/v1/automation/:name/runs` | List runs (`?limit`, `?cursor`) |
| `GET /api/v1/automation/:name/runs/:runId` | One run's detail (404 `Execution not found`) |
| `POST /api/v1/automation/:name/runs/:runId/resume` | Resume a paused run — body `{ inputs, output, branchLabel }` |
| `GET /api/v1/automation/:name/runs/:runId/screen` | The pending screen of a screen-flow run |

**Passing inputs.** Declare variables with `isInput: true`, then send them in
`params` under the same names — only declared inputs are bound. `recordId` and
`objectName` are lifted into `params` for you (plus an `<objectName>Id` alias),
and `event` defaults to `'manual'`.

**Identity.** The caller's identity (user, positions, permissions, tenant) is
forwarded into the run, so a `runAs: 'user'` flow executes under that caller's
row-level security. See [API Authentication](/docs/api#authentication) for
credentials.

<Callout type="warn">
**`success: true` doesn't always mean "it ran".** If the start condition isn't
met, the response is a *successful* skip:
`{ "success": true, "data": { "skipped": true, "reason": "condition_not_met" } }`.
A self-triggering flow caught by the loop guard reports
`reason: "reentrancy_loop_guard"` the same way. Check `skipped` before assuming
the work happened.
</Callout>

## Console Flow Viewer & Test Runner

Every flow can surface in the Console metadata browser under `/_console/`.
Expand All @@ -558,8 +637,9 @@ the flow viewer plugin is installed:
| **Run** | `FlowTestRunner` | Auto-generates a form for every `isInput: true` variable (with type-aware coercion for `number` / `boolean` / `object` / `list`), executes the flow against the per-project kernel, and shows the returned outputs + run id |
| **Runs** | `FlowRunsPanel` | Lists historical executions for the selected flow with status, duration, and a deep-link to the run record |

The runner posts to the standard automation execute endpoint, scoped to
the active project. All three components participate in the Studio
The runner posts to the same
[`POST /api/v1/automation/:name/trigger`](#run-a-flow-via-api) endpoint,
scoped to the active project. All three components participate in the Studio
authentication / project-scope context, so they work identically in
single-project mode and in cloud mode.

Expand Down
1 change: 1 addition & 0 deletions content/docs/permissions/meta.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
"positions",
"delegated-administration",
"sharing-rules",
"rls",
"field-level-security",
"attachments-access",
"permission-metadata",
Expand Down
Loading
Loading