@lotics/cli 0.315.18 → 0.316.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/docs/workflows.md CHANGED
@@ -6,13 +6,27 @@ Only the forms below are accepted; anything else is refused at save with the lin
6
6
  what to write instead. A save that succeeds may still return `warnings` — advisory hints such as a
7
7
  loop that may never end. Read them.
8
8
 
9
- The same grammar serves an automation and an app's workflow, and the expressions of a table check.
10
- Only an automation's source opens with a trigger declaration; an app workflow takes its trigger from
11
- the app that calls it.
9
+ A workflow changes records, calls integrations, notifies and makes papers. A tool for anything else — views, history,
10
+ tables and apps, members, knowledge, skills, editing a file — is an agent's, on chat, MCP or the CLI, and a body that
11
+ calls one is refused at save with that reason. One saved before is refused at run, before its first step.
12
+
13
+ The same grammar serves an app's workflow, which `set_app_workflow` saves, an automation and the expressions of a table
14
+ check. An app workflow takes its trigger from the app that calls it; only an automation's source opens with a trigger
15
+ declaration.
12
16
 
13
17
  ## Triggers
14
18
 
15
- An automation answers an event outside the records: a schedule, a webhook, an inbound email.
19
+ An app workflow reads its call as `trigger.app_workflow.inputs.<name>`, or `inputs.<name>`, which is the same read. A Workflow node receives the inputs it declares;
20
+ an Act's `workflow` receives `record_id` (`record_ids` on an act over several rows) and what the Act asks.
21
+
22
+ ### Automations
23
+
24
+ An automation answers an event outside the records: a schedule, a webhook, an inbound email. `set_automation` saves one
25
+ from its source — the `on({...})` declaration, then its steps — and, given its `automation_id`, replaces it: the
26
+ trigger config is the one the header states, a key it leaves out removed, and steps left as it runs them are checked again
27
+ only when the call turns it on, so one a later rule refuses can still be switched off. `get_automation` reads that source back with its latest
28
+ runs, and `dry_run_workflow` rehearses a source before it is saved. It runs with its owner's current permissions, a connected service it names is its owner's account, and only its
29
+ owner or an admin reads, changes or deletes it.
16
30
 
17
31
  ```
18
32
  on({ type: "recurring_schedule", cron_expression: "0 9 * * MON" });
@@ -27,29 +41,13 @@ All three carry `trigger.trigger_id`.
27
41
  | `receive_webhook` | `secret`? | `trigger.method`, `trigger.headers[...]`, `trigger.query[...]`, `trigger.body` (any JSON, or raw text when the request is not JSON), `trigger.received_at` |
28
42
  | `receive_gmail_email` / `receive_outlook_email` | `connected_account_id`, `filter` | `trigger.email_id`, `trigger.from`, `trigger.to`, `trigger.cc`, `trigger.bcc`, `trigger.subject`, `trigger.date`, `trigger.body` (plain text), `trigger.reply_to`, `trigger.in_reply_to`, `trigger.attachments` (file refs — assign them straight to a files field). One type serves both providers, so `labels`, `thread_id`, `importance` and `conversation_id` are not readable. |
29
43
 
30
- A whole automation:
31
-
32
- ```
33
- on({ type: "recurring_schedule", cron_expression: "0 9 * * MON" });
34
-
35
- const open = await query_records({
36
- table_id: "tbl_tickets",
37
- filters: { node_type: "condition", field_key: "fld_status", operator: "has_any_of", value: ["opt_open"] },
38
- });
39
- await send_email({
40
- to: "team@example.com",
41
- subject: "Weekly digest",
42
- body: `${size(open.records)} tickets open as of ${formatDate(now(), "yyyy-MM-dd")}.`,
43
- });
44
- ```
45
-
46
44
  ## Steps
47
45
 
48
46
  | step | syntax |
49
47
  |---|---|
50
48
  | tool call, kept | `const <id> = await <tool>({ ...inputs });` — also `let x = await …` and `x = await …` |
51
- | tool call | `await <tool>({ ...inputs });` — a connection's tools too, each a workflow step (`bkav_submit_invoices`, `gmail_send_email`, …); a step that fails ends the run with its reason unless a `try`/`catch` around it handles it |
52
- | agent | `const <id> = await agent({ instructions, input, tools, model, output });` — see **An agent step** |
49
+ | tool call | `await <tool>({ ...inputs });` — the platform's own steps (`send_notification`, `generate_document`, …) and a connection's tools, each a workflow step (`bkav_submit_invoices`, `kiotviet_create_order`, …); a step that fails ends the run with its reason unless a `try`/`catch` around it handles it |
50
+ | agent | `const <id> = await agent({ instructions, input, model, output });` — see **An agent step** |
53
51
  | app agent | `const <id> = await app_agent({ alias: "<agent alias>", input: { ... } });` — runs one of the app's declared agents, in an app workflow only, and resolves to its declared outputs or its final text. `wait: false` only starts the run and resolves to `{ run_id }`; the run's failure is then its own, not the workflow's. Refused in a run an agent started. |
54
52
  | bind | `const <id> = <expression>;` — evaluated once; later steps read `<id>`. Bind any expression used twice. |
55
53
  | if / else | `if (<expr>) { ... } else { ... }` — `else` optional, `else if` chains allowed |
@@ -58,16 +56,25 @@ await send_email({
58
56
  | wait | `await wait({ duration_in_minutes: 5 });` |
59
57
  | wait for an event | `await wait_for_event({ event_type: "webhook", event_ref: <expr>, timeout_in_minutes: 60 });` |
60
58
  | wait for an approval | `await wait_for_approval({ approvers: <expr>, prompt: <expr> });` — see **Waiting for an approval** |
61
- | return | `return({ status: "success" \| "error", message: <expr>, field_errors: { ... }? });` |
59
+ | return | `return({ status: "success" \| "error", message: <expr>, field_errors: { ... }?, data: { ... }? });` — `data` holds the outputs a Workflow node declares |
62
60
  | validate | `validate({ checks: [{ fail_when: <expr>, field_key: "...", message: <expr> }, ...] });` |
63
61
 
64
- Every step's inputs, the values each takes and its answer: `lotics tools <name>` prints them; the steps only a body
65
- runs — a connection's, a mail's — are all in `workflow_steps`. A person connects a service in Lotics under
66
- Settings → Connections; a step names that connection by its `connected_account_id`.
62
+ A delayed run is never cancelled from a workflow: after a `wait`, it reads the row's state and acts only if it still holds.
63
+
64
+ A tool's inputs and answer are in its own description; the steps only a body runs — a connection's, a mail's — are each
65
+ an entry of `workflow_steps`. A person connects a service in Lotics under Settings → Connections; a step names that
66
+ connection by its `connected_account_id`, which `get_workspace` with `kind: "connection"` lists for the connections the
67
+ caller can use. The app's owner and whoever saves the body must both be able to use that connection.
68
+
69
+ In a workflow, email reaches a member of the workspace through the step `send_notification`: Lotics shows it to them and
70
+ mails it to their own address, with no connection. Its recipients are members by `member_id` (`get_workspace` with
71
+ `kind: "member"` lists them), member groups or the whole organization. Mail to anyone else goes from a person's
72
+ connected Gmail or Outlook (`gmail_send_email`, `outlook_send_email`), which leaves an unsent draft unless `draft` is
73
+ `false`.
67
74
 
68
75
  `return` is a call, not a JavaScript `return` statement. `validate` ends the workflow with an error
69
76
  when a check's `fail_when` is truthy; the check's `field_key` names the control its message lands on —
70
- in an app workflow a declared input, or the `fld_` key of a field on a table the body names; in an
77
+ in an app workflow a declared input, or the alias of a field on a table the body names; in an
71
78
  automation it is not checked.
72
79
 
73
80
  The name in `const x = await tool({...})` is the step's id; later expressions read its output as
@@ -82,8 +89,8 @@ test, a `validate` check — is refused; write the `if`.
82
89
 
83
90
  **Names are block-scoped, as in JavaScript** — `const`, `let`, loop items and `catch (e)`: sibling
84
91
  blocks may reuse a name and an inner block may shadow an outer one. A local may take a helper's name
85
- (`const size = 3;`), but calling `size(...)` while it is in scope is refused. Tool names, the reserved
86
- roots, `linked` and `_s` followed by digits cannot be bound.
92
+ (`const size = 3;`), but calling `size(...)` while it is in scope is refused. The names of the tools a
93
+ workflow calls, the reserved roots, `linked` and `_s` followed by digits cannot be bound.
87
94
 
88
95
  ### Waiting for an approval
89
96
 
@@ -112,34 +119,63 @@ is.
112
119
 
113
120
  ### An agent step
114
121
 
115
- `const x = await agent({ instructions, input, tools, model, output });` runs a model with tools as one
116
- step — summarize a record, classify, draft text.
122
+ `const x = await agent({ instructions, input, model, output });` runs a model as one step — read a paper, classify,
123
+ draft text. It calls no tool: the workflow's own steps write, send and notify with what it returns, and a step that
124
+ states `tools` is refused.
117
125
 
118
126
  - `instructions` — a fixed string.
119
127
  - `input` — an object literal; its fields are expressions, evaluated and handed to the model (`{}` for
120
128
  none). It is the ONLY workflow data the agent sees: read the trigger, prior steps, `runtime` or the loop
121
- item here, never inside `instructions` or `tools`, which are fixed literals and read no binding.
122
- - `tools` — the names of the tools it may call.
129
+ item here, never inside `instructions`, which is a fixed literal and reads no binding. A field holding a file, or a
130
+ list of files (a files field, a mail's attachments), hands the agent the files themselves: a picture or a PDF it
131
+ sees, any other paper read into text.
123
132
  - `model` — optional; omit it to follow the platform's default model.
124
133
  - `output` — `{ mode: "text" }` resolves to a string; `{ mode: "object", schema }` to a typed object.
125
134
 
126
135
  Only `x` comes back: in text mode `x` is the string itself, in object mode `x.field` reads a field the
127
- `schema` declares. The agent's own tool calls are not readable.
128
-
129
- ## Keys, not names
130
-
131
- 1. **Fields and options are named by key.** A field reads as `record["fld_status"]`, an option as
132
- `"opt_done"`, a linked field as `linked(record["fld_customer"])[0]["fld_name"]`. `get_table` lists
133
- each field's `key` and each option's `key`; a display name is refused at save with the key to use.
134
- 2. **`==` against a multi-select means "includes".** `record["fld_tags"] == "opt_urgent"` on a
135
- multi-select is stored as `includes(record["fld_tags"], "opt_urgent")`; either spelling works.
136
- 3. **Text renders labels.** Inside a template or a text tool input, a select, member or link value
137
- renders its display text: `` `${record["fld_status"]}` `` writes "Done", not `opt_done`.
136
+ `schema` declares.
137
+
138
+ ## Naming tables and templates
139
+
140
+ A body names a table or document template by its alias (`query_records({ table_id: "orders" })`); ids still work. `get_app_workflow` and `get_automation` show aliases. An app's workflow reaches only the tables the app uses.
141
+
142
+ ## Fields and options
143
+
144
+ 1. **A body names a table's fields and select options by alias**, the names the table's document states: a field reads
145
+ as `record["status"]`, an option as `"done"`, a linked field as `linked(record["customer"])[0]["name"]`, in the
146
+ body of a Workflow node, of an Act's own `workflow`, of an automation (`set_automation`) and of a table's workflow. It is stored by key and `get_app_workflow`, `get_automation` and `get_table` show it by alias
147
+ again. A field's or option's key is accepted where its alias is and is kept as written; a display name is refused with the
148
+ aliases there are, and a row that names one field by both its alias and its key is refused. A row written to a table is
149
+ stored by field key wherever the save can follow it to the record tool that writes it: a row or list written in the call,
150
+ a conditional of them, a name holding them, pushed to a `let`, and through `map`, `flatMap`, `concat`, `filter`, `sortBy`,
151
+ `slice` and `reverse`. A field of such rows is read by alias as well (`picks.toSorted((a, b) => a.quantity - b.quantity)`,
152
+ `sortBy(picks, "quantity")`, `rows[0].note`) and stored by key. A body that cannot be stored by key is refused at save,
153
+ naming the statement.
154
+ 2. **An Act's own `workflow`** has the app's guards placed at the top of the body, between `<lotics:guards>` tags,
155
+ replaced on every save: a save that leaves them out or edits them gets the app's again, at the top wherever the tags
156
+ sat, so the act's row rule, `when`, `requires`, checks and locks always run first.
157
+ 3. **A value is read as the record tools store it.** A single select reads as its option and a member as its id; an
158
+ option is written by its alias wherever it meets a select — compared, listed, switched on, written — also beside a
159
+ name set from the field (`const s = record.status; s == "done"`), and a select
160
+ value handed on whole (returned, written to a text field) is its key. An option's alias searched for in a list the
161
+ save cannot place it against (`includes(filter(record.tags, …), "hot")`) is refused: search the field itself. A link reads as the list of its row ids, a
162
+ one-row link too: `first(record.customer)` is the one id, and `record.customer` used as one id is refused at save,
163
+ naming `first(record.customer)`. A one-row link is written with its one id or a list of one; a many-row link with a list. A lookup reads as one flat list of the values it looks up: `includes(record.owner,
164
+ me)`. A select's value is one of another select's options only where that table shares its keys, by having been
165
+ duplicated from it, not where an option merely shares its alias. A row that takes its parent's option gets it from
166
+ `default_from`, and a lookup shows it.
167
+ 4. **What an app sends takes an option by alias or key.** An input or output drawn from a field (`field`, written
168
+ `<table id>.<alias>`) compares and returns by alias in the body, and the run hands it on as the option's key. A
169
+ refusal's `field_errors` and a `validate` check's `field_key` name a declared input or a field's alias.
170
+ 5. **`==` against a multi-select means "includes".** `record["tags"] == "urgent"` on a multi-select is stored as
171
+ `includes(record["tags"], "urgent")`; either spelling works.
172
+ 6. **Text renders labels.** Inside a template or a text tool input, a select, member or link value renders its display
173
+ text: `` `${record["status"]}` `` writes "Done", not `done`.
138
174
 
139
175
  ## What an expression reads
140
176
 
141
177
  - `record`, `prev_record`, `changes` — in a table check, the write it checks (see **Table checks**).
142
- `changes` is a PARTIAL map: `changes["fld_x"]?.next_value` and `?.prev_value`, the `?.` required.
178
+ `changes` is a PARTIAL map: `changes["x"]?.next_value` and `?.prev_value`, the `?.` required.
143
179
  - `trigger` — what the trigger carries (see **Triggers**).
144
180
  - a loop's item name, and `index`, the iteration's 0-based index.
145
181
  - `<step id>` — an earlier step's output (`dup.records`).
@@ -149,17 +185,18 @@ Only `x` comes back: in text mode `x` is the string itself, in object mode `x.fi
149
185
 
150
186
  ## Paths
151
187
 
152
- - `record.fld_x` and `record["fld_x"]` read a field; `[n]` reads a list's n-th item.
153
- - A path may start at an awaited call: `(await get_record({...})).data["fld_x"]`. It may not start at a
154
- helper's result — `first(found.records).id` is refused; bind `first(found.records)`, then read it.
188
+ - `record.x` and `record["x"]` read a field; `[n]` reads a list's n-th item.
189
+ - A path may start at any expression: an awaited call `(await get_record({...})).data["x"]`, a helper's
190
+ result `first(found.records).id`, a filter `rows.filter((r) => r.ok)[0].n`, a `(a ?? b).c`. `?.` reads
191
+ through a null as usual, except on a value drawn at random (`sample(xs)?.id` is refused: bind it first).
155
192
  - A record reads in its declared shape wherever it comes from — the trigger, a tool result, a `let`, a
156
- callback parameter: a single select or member as its key or null, a link as its ids. (The record
193
+ callback parameter: a single select as its option alias or null, a member as its id, a link as its ids. (The record
157
194
  tools called outside a workflow return the stored arrays instead.)
158
- - `linked(record["fld_link"])[n]["fld_x"]` fetches the n-th linked row and reads a field of it; bare
159
- `linked(record["fld_link"])` is every linked row, one fetch per row. Its argument is a path ending on
160
- a link field and carries the guard: `linked(record?.["fld_link"])[0]`.
161
- - `changes["fld_link"]?.next_value` is the same list of ids; `linked()` over a change is refused —
162
- read `linked(record["fld_link"])` or `linked(prev_record["fld_link"])`.
195
+ - `linked(record["link"])[n]["x"]` fetches the n-th linked row and reads a field of it; bare
196
+ `linked(record["link"])` is every linked row, one fetch per row. Its argument is a path ending on
197
+ a link field and carries the guard: `linked(record?.["link"])[0]`.
198
+ - `changes["link"]?.next_value` is the same list of ids; `linked()` over a change is refused —
199
+ read `linked(record["link"])` or `linked(prev_record["link"])`.
163
200
 
164
201
  ## Operators and statements
165
202
 
@@ -174,9 +211,9 @@ prefix `! -`.
174
211
  - `??` is `coalesce(left, right)`; `?.` reads through a null (`a?.b`, `a?.[0]`, and `s?.trim()` for the
175
212
  method names listed under **Helpers**). `x?.()` is refused: helpers and tools are not values.
176
213
  - Templates use backticks and `${...}`.
177
- - Destructuring — `const { fld_status, fld_name } = record;` binds each name. Renames
178
- (`{ fld_status: s }`), quoted keys (`{ "fld_ref": ref }`), array patterns with holes
179
- (`const [first, , third] = xs;`) and defaults (`{ fld_note = "" }`, which apply on null too) work on
214
+ - Destructuring — `const { status, name } = record;` binds each name. Renames
215
+ (`{ status: s }`), quoted keys (`{ "ref": ref }`), array patterns with holes
216
+ (`const [first, , third] = xs;`) and defaults (`{ note = "" }`, which apply on null too) work on
180
217
  `const`, `let` and `for (const { id } of rows)`. A tool result destructures directly:
181
218
  `const { records } = await query_records({...});`. `const a = 1, b = 2;` declares both. Nested
182
219
  patterns and rest are refused.
@@ -186,9 +223,11 @@ prefix `! -`.
186
223
  - `xs.push(a, b);` appends, also on a key (`o.items.push(v)`); `o.a.b = v;` sets a nested key;
187
224
  `x ??= v`, `x ||= v` and `x &&= v` assign. A `const` may be pushed to or have a key set, as in
188
225
  JavaScript; a loop item is read-only.
189
- - `undefined` is the same value as `null`. `=== undefined` and `!== undefined` are refused, since they
190
- cannot tell a missing key from null: test `isNull(x)`, or `includes(keys(o), "k")` for whether a key
191
- was sent.
226
+ - `undefined` is the same value as `null`, so `x === undefined` is `isNull(x)` and `x !== undefined` is
227
+ `isNotNull(x)`; a key set to null and a key never sent are not told apart (`"k" in o` tells a key that
228
+ is there).
229
+ - `typeof x` answers JavaScript's words, and a null (a missing value too) is `"object"`. `k in o` tests an
230
+ object's own keys, or a list's indexes and `length`.
192
231
  - A filter node may leave out `node_type` when its keys say which it is: `field_key` / `operator` /
193
232
  `value` a condition, `logic` / `children` a group, `path` / `condition` a traversal.
194
233
  - `function name(p = <default>) { return <expr>; }` — top level only, expanded at every call (a call
@@ -211,16 +250,52 @@ on the innermost loop; labels and `for-in` are refused.
211
250
  the same block is refused; shadowing in a nested block is allowed. A `let` keeps its value across a
212
251
  `wait`, `wait_for_event` or `wait_for_approval`.
213
252
 
214
- **Refused:** regex, `new`, `typeof`, `instanceof`, `in`, `delete`, `void`, rest elements, computed keys,
215
- classes, `throw`, `import`, `export`, and a function as a value (`const f = (x) => ...`).
253
+ **Refused, each because a save must know what runs and a run must give the same answer twice:** regex
254
+ (unbounded matching: use the string methods), `Math.random`, `Date.now()` and `new Date()` (use `now()` and
255
+ `parseDate`), a tool or helper as a value or a computed callee (callees are named), `new` of anything,
256
+ `instanceof`, `delete`, `void`, rest elements, computed keys, classes, `throw`, `finally`, `import`,
257
+ `export`, `eval`, labels, and a function as a value (`const f = (x) => ...`: pass the arrow to the helper
258
+ that takes it). `xs.sort(...)` on a name or path is refused too, since a value is never changed in place:
259
+ write `xs.toSorted(...)` or `[...xs].sort(...)`.
260
+
261
+ ## Typing
262
+
263
+ - The body is type-checked as TypeScript before it is read: annotations, `as`, `satisfies` and a non-null `!`
264
+ are accepted and erased, and the checker is strict about null. The stored body keeps none of them and is checked again as
265
+ stored, by field key, as every later change to its app or tables checks it: a body that types only through one of them is
266
+ refused at save.
267
+ - Narrow what is not typed (a connector's JSON is `unknown`) with `typeof`, `in`, `Array.isArray` and `isNull`.
268
+ - `first(xs)` may be undefined; `requireFirst(xs)` is the list's first item or an error.
269
+ - `obj[key]` needs `key` to be a key of `obj`, or `obj` to be a record with a string index.
216
270
 
217
271
  ## Helpers
218
272
 
219
- Called as `size(arr)`. The method form works only where the name is also a JavaScript method —
273
+ Ordinary JavaScript works — the standard library below, the array and string methods, `Math`, `JSON`,
274
+ `Object`, `Array`, `Number`, `parseInt` — and means what it means in JavaScript, with the differences
275
+ listed here. Where JavaScript answers NaN, Infinity or undefined, a workflow has none of them and the
276
+ answer is `null`.
277
+
278
+ - Standard library: `Math.abs/ceil/floor/trunc/sign/min/max/pow/sqrt`, `Number(x)`, `String(x)`,
279
+ `Boolean(x)`, `parseInt(s, radix?)`, `parseFloat(s)`, `Number.isFinite/isInteger`,
280
+ `Object.keys/values/entries/fromEntries`, `Array.isArray/from` (a list, text, or `{ length: n }`),
281
+ `JSON.parse/stringify`.
282
+ - Arrays: `length, map, filter, find, findIndex, findLast, some, every, reduce, includes, indexOf, join,
283
+ slice, concat, flat, flatMap, at, reverse, toReversed, toSorted` and `[...xs].sort(compare?)`.
284
+ - Strings: `length, toLowerCase, toUpperCase, trim, trimStart, trimEnd, startsWith, endsWith, includes,
285
+ indexOf, slice, substring, split, replace, replaceAll, padStart, padEnd, repeat, charAt, at,
286
+ localeCompare` (en-US order, wherever it runs). Numbers: `toFixed, toString`.
287
+ - Differences from JavaScript: `Math.round` is the engine's `round` (a half goes away from zero:
288
+ `Math.round(-2.5)` is `-3`); `Number(x)` and `parseFloat(x)` are `toNumber`, which refuses text that is no
289
+ number; `String(null)` is `""`; `join` writes a null item as `null`; `concat` skips a null; `toFixed`
290
+ rounds the decimal, not the binary double; `replaceAll` writes the replacement as text
291
+ (`$&` stays as typed), while `replace` expands `$&`, `$$`, `` $` `` and `$'` in it as JavaScript does; `split` takes no limit; `sort` without a comparator orders by text, as JavaScript does. `==`, `!=`, `===` and `!==` between two list values (files, a multi-select, a link of any cardinality, a lookup) are refused at save, since a list compares by identity, not by its items: `!isNull(changes.<field>)` asks whether one changed.
292
+
293
+ The same work can be written with the helpers below, which are additions to JavaScript. They are called as
294
+ `size(arr)`; the method form works only where the name is also a JavaScript method —
220
295
  `x.trim()`, `arr.includes(v)`, `arr.at(-1)`, `arr.map(fn)`, `s.split(",")`; `arr.size()` is refused.
221
296
 
222
297
  - **Null and type**: `isNull`, `isNotNull`, `isEmpty`, `isString`, `isNumber`, `isBoolean`, `isArray`,
223
- `isObject`, `coalesce`, `get`, `toNumber`, `toString`, `typeOf`, `parseJson`, `toJson`
298
+ `isObject`, `coalesce`, `get`, `toNumber`, `toFigure`, `toString` (a text, number, yes/no or date; a record, file or list is refused at save, an object at run), `typeOf`, `parseJson`, `toJson`. `isNull` is strict (`null` and `undefined` only), but on a list field of the record (a link, files, a multi-select) it asks whether the list holds nothing, since a cleared list is `[]`: `!isNull(record.vehicle)` reads as `!isEmpty(record.vehicle)`. `isNull(changes.<field>)` stays strict: null there means unchanged
224
299
  - **Lists**: `size`, `first`, `requireFirst` (the first item, refusing an empty list — after a
225
300
  `validate` on the size it saves an `if`), `last`, `nth`, `at` (`at(arr, -1)` counts from the end),
226
301
  `includes`, `filter`, `find`, `some`, `every`, `pluck`, `sortBy`, `groupBy`, `countBy`, `unique`,
@@ -230,19 +305,24 @@ Called as `size(arr)`. The method form works only where the name is also a JavaS
230
305
  - **Math**: `sum`, `sumBy`, `mean`, `meanBy`, `minBy`, `maxBy`, `round`, `ceil`, `floor`, `min`, `max`,
231
306
  `abs`, `mod`, `pow`, `sqrt`, `clamp`, `percentage`
232
307
  - **Text**: `upper`, `lower`, `capitalize`, `trim`, `contains`, `startsWith`, `endsWith`, `replace`,
233
- `replaceAll`, `substring`, `length`, `split`, `join`, `padStart(str, length, char)`,
308
+ `replaceAll`, `removeChars(str, chars)` (without every character that occurs in `chars`),
309
+ `removeSpaces(str)` (without any whitespace; a Word template command calls both too), `substring`, `length`, `split`, `join`, `padStart(str, length, char)`,
234
310
  `padEnd(str, length, char)`, `formatNumber(x, decimals)` (ungrouped; rounds the decimal, a half away
235
311
  from zero: `formatNumber(1.005, 2)` is `1.01`),
236
312
  `formatDecimal(x, decimals, locale)` (grouped: `formatDecimal(151000, 0, "vi-VN")` is `151.000`),
237
- `numberToWords(x, lang?)` (an integer in words: Vietnamese, or English with `"en"`)
313
+ `numberToWords(x, lang?)` (an integer in words: Vietnamese, or English with `"en"`),
314
+ `uuidFrom(text)` (the same UUID for the same text, refusing null and empty text: `uuidFrom(record.id)`)
238
315
  - **Objects**: `keys`, `values`, `entries`, `nonNullKeys`, `pick`, `omit`, `merge`
239
316
  - **Dates**, in the workspace's timezone: `now`, `formatDate`, `parseDate`, `addDays`, `subDays`,
240
317
  `addHours`, `subHours`, `addMinutes`, `subMinutes`, `startOfDay`, `endOfDay`,
241
318
  `differenceInCalendarDays`, `differenceInHours`, `differenceInMinutes`, `isBefore`, `isAfter`,
242
- `isSameDay`, `isToday`, `isWithinRange`
319
+ `isSameDay`, `isToday`, `isWithinRange`, `meetsBounds`
243
320
  - **Other**: `formatCurrency(amount, locale, currency)`, `randomNumber(len)`,
244
321
  `randomAlphaNumeric(len)`, `sample(items)` (a random item)
245
322
 
323
+ Over a null or empty list `mean`, `meanBy`, `min`, `max`, `minBy` and `maxBy` answer null, while `sum`
324
+ and `sumBy` answer 0: `maxBy(pieces, "reach")?.Name` prints a blank for a month with no pieces.
325
+
246
326
  A date helper given a null or empty date returns null, so guard its result before comparing or
247
327
  writing it: `differenceInCalendarDays(a, b) ?? 0`.
248
328
 
@@ -261,12 +341,12 @@ rows once, then read each group by its key. `groupBy` answers an object of lists
261
341
  is the group a code names, or an empty list:
262
342
 
263
343
  ```
264
- const parties = await query_records({ table_id: "tbl_parties" });
265
- const lines = await query_records({ table_id: "tbl_sheet_lines" });
266
- const byCode = groupBy(parties.records, (p) => p.data["fld_code"] ?? "");
344
+ const parties = await query_records({ table_id: "parties" });
345
+ const lines = await query_records({ table_id: "sheet_lines" });
346
+ const byCode = groupBy(parties.records, (p) => p.data["code"] ?? "");
267
347
  const holders = lines.records.map((line) => ({
268
348
  line: line.id,
269
- parties: (byCode[line.data["fld_code"] ?? ""] ?? []).map((p) => p.id),
349
+ parties: (byCode[line.data["code"] ?? ""] ?? []).map((p) => p.id),
270
350
  }));
271
351
  ```
272
352
 
@@ -281,7 +361,7 @@ In `update_records`' `set` and `create_records`' `records`:
281
361
  - `null` clears the field.
282
362
  - `undefined`, or leaving the key out, leaves the field as it is.
283
363
 
284
- So passing a read that may be null (`record["fld_x"]`) keeps a value when there is one and clears the
364
+ So passing a read that may be null (`record["x"]`) keeps a value when there is one and clears the
285
365
  field when there is none. Use `coalesce(x, fallback)` only for a real fallback value.
286
366
 
287
367
  ## Examples
@@ -291,13 +371,13 @@ string is a type error:
291
371
 
292
372
  ```
293
373
  const found = await query_records({
294
- table_id: "tbl_customers",
295
- filters: { node_type: "condition", field_key: "fld_name", operator: "equals", value: "ACME Corp" },
374
+ table_id: "customers",
375
+ filters: { node_type: "condition", field_key: "name", operator: "equals", value: "ACME Corp" },
296
376
  });
297
377
  const customer = first(found.records);
298
- const order = await get_record({ table_id: "tbl_orders", record_id: trigger.app_workflow.inputs.order_id });
378
+ const order = await get_record({ table_id: "orders", record_id: trigger.app_workflow.inputs.order_id });
299
379
 
300
- if (customer && includes(order.data["fld_customer"], customer.id)) {
380
+ if (customer && includes(order.data["customer"], customer.id)) {
301
381
  // …
302
382
  }
303
383
  ```
@@ -314,11 +394,11 @@ A sheet past what one call reads is refused with its size, never cut short: the
314
394
  names the fields that identify a row, compared as trimmed text with case ignored: a row matching no live
315
395
  row is added, one matching a row changes only the cells that differ, and one that changes nothing is
316
396
  counted and not written, so the same file run twice writes nothing the second time. `values` holds a
317
- row's cells by field key, as `create_records` takes them; a key the row omits is left as the table holds
318
- it, and `null` is an empty cell, which differs from a filled one as any other value does.
397
+ row's cells by field alias, as `create_records` takes them; a key the row omits is left as the table holds
398
+ it, and `null` is an empty cell, which differs from a filled one as any other value does. `dry_run: true` judges the import as a real one would be, rolls it back and answers what it would write or why it is refused.
319
399
 
320
400
  A `match` field may also be a link field holding one row, compared by the row linked: lines keyed by
321
- their parent and a line number state `match: ["fld_invoice", "fld_line"]`, the parent given as its
401
+ their parent and a line number state `match: ["invoice", "line"]`, the parent given as its
322
402
  record id or as `{ lookup: "<text>" }`.
323
403
 
324
404
  What the step is not certain of is held — answered with why, and not written: a row carrying `hold`
@@ -352,25 +432,15 @@ reach of the table, and a workflow hands its caller only what it returns. Two ca
352
432
  by the same fields take turns, so the second matches the rows the first added. The step's entry in
353
433
  `workflow_steps` states its bounds.
354
434
 
355
- `undo_record_changes`, a workflow step, puts back what one run wrote on records of a table. It takes the
356
- table, up to 500 record ids and the run — what `runtime.execution_id` held in it. A field goes back
357
- where the row still holds what the run wrote. A row the run created goes to the trash
358
- where nothing else has written it or linked a row to it since; in a table where a workflow writes each
359
- new row, every row created reads as written since, and is kept. A row the run moved to the trash comes
360
- back where nothing has touched it since, without the links to it that its removal took off other rows.
361
- A row the run brought back from the trash stays out of it. A write of the run that was itself an undo is
362
- not put back. What the step leaves as it stands is answered in `kept`, one entry a record and reason:
363
- something changed it since, it is outside the rows the member may write, the table refused the write,
364
- what the run wrote on it cannot be read, or the run brought it back from the trash.
365
-
366
- Two more workflow steps read and settle one record's saves: `get_record_activity`, a workflow step,
367
- lists them, and `undo_record_change`, a workflow step, puts one back. What they read is the run's to
368
- read; inside an app's workflow it is what the app reads — the fields of the app's entity over the table,
369
- a record that entity's own read reaches. An undo, of one save or of a run's writes, is the own save of the
370
- member the run acts for — whoever started the app's workflow, pressed the button, or made the write a
371
- table's workflow followed: that member holds the table's write as the step runs, each row within the
372
- rows they may write, and the table's locks, checks and write rules answer for them. A run that acts for
373
- no member — a schedule's, a public visitor's — reads, and its undos are refused.
435
+ A workflow does not put an import back: that reads the history of what the run wrote, which a member's agent reads
436
+ under the member's own access to the table and no workflow does. On chat, MCP or the CLI, `undo_record_changes` takes the
437
+ table, up to 500 record ids and the run — what `runtime.execution_id` held in it, which a body keeps on a row of its own —
438
+ and puts back what that run wrote, as the member's own writes. A field goes back where the row still holds what the run
439
+ wrote. A row the run created goes to the trash where nothing else has written it or linked a row to it since; in a table
440
+ where a workflow writes each new row, every row created reads as written since, and is kept. A row the run moved to the
441
+ trash comes back where nothing has touched it since. A row the run brought back from the trash stays out of it. What the
442
+ tool leaves as it stands is answered in `kept`, one entry a record and reason. `undo_record_change` puts back one edit
443
+ named by its audit-log entry, the same way.
374
444
 
375
445
  What a business keeps of an import — the file, its counts, the lines waiting for a person — is rows of
376
446
  its own tables, written by the workflow that ran the step. A garden centre brings its grower's price
@@ -379,8 +449,8 @@ holding many rows: one holding a single row would make the line that plant's own
379
449
  lines belong to is not moved to the trash.
380
450
 
381
451
  These tables are shown beside the plants, in the same app: the sheets as a log of the plants' register
382
- (`logs`, `lotics docs model/logs`), a sheet's lines on the page it opens. A line keeping what a cell held
383
- and what it holds now in two fields reads one against the other (`compare`, `lotics docs model/blocks`).
452
+ (a further List at the root, `model/app-node`), a sheet's lines on the page it opens. A line keeping what a cell held
453
+ and what it holds now in two fields reads one against the other (`compare`, `model/list-node`).
384
454
 
385
455
  ```
386
456
  const sheet = await excel_get_rows({ // a workflow step
@@ -389,57 +459,57 @@ const sheet = await excel_get_rows({ // a workflow step
389
459
  });
390
460
 
391
461
  const imported = await import_rows({ // a workflow step
392
- table_id: "tbl_plants",
393
- match: ["fld_code"],
462
+ table_id: "plants",
463
+ match: ["code"],
394
464
  rows: sheet.rows.map((row, at) => ({
395
465
  line: at + 2,
396
466
  hold: isNull(row["Price"]) ? "The sheet states no price" : null,
397
467
  values: {
398
- fld_code: toString(row["Code"]),
399
- fld_name: toString(row["Name"]),
400
- fld_price: toNumber(row["Price"]),
468
+ code: toString(row["Code"]),
469
+ name: toString(row["Name"]),
470
+ price: toNumber(row["Price"]),
401
471
  },
402
472
  })),
403
473
  });
404
474
 
405
475
  const kept = await create_records({
406
- table_id: "tbl_price_sheets",
476
+ table_id: "price_sheets",
407
477
  records: [{
408
- fld_file: [trigger.app_workflow.inputs.file],
409
- fld_run: runtime.execution_id,
410
- fld_read: imported.read,
411
- fld_created: imported.created,
412
- fld_changed: imported.changed,
413
- fld_held: imported.held,
478
+ file: [trigger.app_workflow.inputs.file],
479
+ run: runtime.execution_id,
480
+ read: imported.read,
481
+ created: imported.created,
482
+ changed: imported.changed,
483
+ held: imported.held,
414
484
  }],
415
485
  });
416
486
  const sheet_id = kept.record_ids[0];
417
487
 
418
488
  for (let start = 0; start < size(imported.written); start += 50) {
419
489
  await create_records({
420
- table_id: "tbl_sheet_lines",
490
+ table_id: "sheet_lines",
421
491
  records: slice(imported.written, start, start + 50).map((one) => ({
422
- fld_sheet: [sheet_id],
423
- fld_plant: [one.record_id],
424
- fld_line: one.line,
425
- fld_outcome: one.kind,
426
- fld_changes: join(one.changes.map((change) => `${change.label}: ${change.before ?? "—"} → ${change.after ?? "—"}`), "; "),
492
+ sheet: [sheet_id],
493
+ plant: [one.record_id],
494
+ line: one.line,
495
+ outcome: one.kind,
496
+ changes: join(one.changes.map((change) => `${change.label}: ${change.before ?? "—"} → ${change.after ?? "—"}`), "; "),
427
497
  })),
428
498
  });
429
499
  }
430
500
  for (let start = 0; start < size(imported.held_rows); start += 50) {
431
501
  await create_records({
432
- table_id: "tbl_sheet_lines",
502
+ table_id: "sheet_lines",
433
503
  records: slice(imported.held_rows, start, start + 50).map((one) => ({
434
- fld_sheet: [sheet_id],
435
- fld_plant: isNull(one.record_id) ? [] : [one.record_id],
436
- fld_line: one.line,
437
- fld_outcome: "held",
438
- fld_why: one.message,
439
- fld_code: one.key["fld_code"],
440
- fld_name: toString(one.values["fld_name"]),
441
- fld_price: toNumber(one.values["fld_price"]),
442
- fld_changes: join(one.changes.map((change) => `${change.label}: ${change.before ?? "—"} → ${change.after ?? "—"}`), "; "),
504
+ sheet: [sheet_id],
505
+ plant: isNull(one.record_id) ? [] : [one.record_id],
506
+ line: one.line,
507
+ outcome: "held",
508
+ why: one.message,
509
+ code: toString(sheet.rows[coalesce(one.line, 2) - 2]["Code"]),
510
+ name: toString(sheet.rows[coalesce(one.line, 2) - 2]["Name"]),
511
+ price: toNumber(sheet.rows[coalesce(one.line, 2) - 2]["Price"]),
512
+ changes: join(one.changes.map((change) => `${change.label}: ${change.before ?? "—"} → ${change.after ?? "—"}`), "; "),
443
513
  })),
444
514
  });
445
515
  }
@@ -447,22 +517,23 @@ for (let start = 0; start < size(imported.held_rows); start += 50) {
447
517
  return({ status: "success", message: `${imported.created} added, ${imported.changed} changed, ${imported.held} held` });
448
518
  ```
449
519
 
450
- A held line keeps the `values` the step answered for it — its key and each cell it would write — in
451
- fields of its own. It is applied by the act on it, whose workflow hands the step those values again and
520
+ A held line keeps the cells the sheet states for it, found by the `line` the step answered, in
521
+ fields of its own. A held row's `key` and `values` name fields by their keys, as every record a step answers a body does, so
522
+ the body reads the sheet again rather than those names. It is applied by the act on it, whose workflow hands the step those values again and
452
523
  lets it write over what the plant holds. A cell the line holds none of is left out, so a held line
453
524
  never empties a cell of the plant:
454
525
 
455
526
  ```
456
- const line = await get_record({ table_id: "tbl_sheet_lines", record_id: trigger.app_workflow.inputs.record_id });
527
+ const line = await get_record({ table_id: "sheet_lines", record_id: trigger.app_workflow.inputs.record_id });
457
528
  const values = {
458
- fld_code: line.data["fld_code"],
459
- ...(isEmpty(line.data["fld_name"]) ? {} : { fld_name: line.data["fld_name"] }),
460
- ...(isNull(line.data["fld_price"]) ? {} : { fld_price: line.data["fld_price"] }),
529
+ code: line.data["code"],
530
+ ...(isEmpty(line.data["name"]) ? {} : { name: line.data["name"] }),
531
+ ...(isNull(line.data["price"]) ? {} : { price: line.data["price"] }),
461
532
  };
462
533
 
463
534
  const applied = await import_rows({ // a workflow step
464
- table_id: "tbl_plants",
465
- match: ["fld_code"],
535
+ table_id: "plants",
536
+ match: ["code"],
466
537
  conflicts: { default: "overwrite" },
467
538
  rows: [{ values }],
468
539
  });
@@ -470,47 +541,12 @@ if (applied.held > 0) {
470
541
  return({ status: "error", message: applied.held_rows[0].message });
471
542
  }
472
543
 
473
- await update_records({ table_id: "tbl_sheet_lines", record_ids: [line.id], set: { fld_outcome: "applied" } });
544
+ await update_records({ table_id: "sheet_lines", record_ids: [line.id], set: { outcome: "applied" } });
474
545
  return({ status: "success", message: "Applied." });
475
546
  ```
476
547
 
477
- A sheet is undone by the act on it, whose workflow hands the step the run the sheet kept and the
478
- plants its lines name, 500 at a time:
479
-
480
- ```
481
- const sheet = await get_record({ table_id: "tbl_price_sheets", record_id: trigger.app_workflow.inputs.record_id });
482
-
483
- let reverted = 0;
484
- let left = 0;
485
- let offset = 0;
486
- let more = true;
487
- while (more) {
488
- const lines = await query_records({
489
- table_id: "tbl_sheet_lines",
490
- filters: {
491
- logic: "and",
492
- children: [
493
- { field_key: "fld_sheet", operator: "has_any_of", value: [sheet.id] },
494
- { field_key: "fld_outcome", operator: "is_any_of", value: ["created", "changed"] },
495
- ],
496
- },
497
- pagination: { offset, limit: 500 },
498
- });
499
- if (size(lines.records) > 0) {
500
- const undone = await undo_record_changes({ // a workflow step
501
- table_id: "tbl_plants",
502
- record_ids: flatten(lines.records.map((line) => line.data["fld_plant"])),
503
- workflow_execution_id: toString(sheet.data["fld_run"]),
504
- });
505
- reverted += undone.reverted;
506
- left += size(undone.kept);
507
- }
508
- more = size(lines.records) == 500;
509
- offset += 500;
510
- }
511
-
512
- return({ status: "success", message: `${reverted} put back, ${left} left as they stand` });
513
- ```
548
+ A sheet is put back by a member's agent on chat, MCP or the CLI: `undo_record_changes` over the plants its lines name,
549
+ 500 at a time, with the run the sheet kept.
514
550
 
515
551
  A shop matches a bank statement to the notes it is owed by an act that states `review`. That act's workflow only
516
552
  proposes: its body calls steps that read and no other, and runs no agent — either is refused when the body is saved —
@@ -530,7 +566,7 @@ Each row's `data` holds only stored fields that change — never one the platfor
530
566
  a `note` and a `ticked` the reader starts from, and `unplaced` lists what was read and matched nothing. Every approved
531
567
  row is checked against its fields before any is written, and they are written in one transaction, so a flawed one
532
568
  writes none. The status history of every row written that moved, or was made holding a status, is recorded in the
533
- same run, so the workflow step `undo_record_changes` with the run's id takes back both. A body that stops proposing a row once it is
569
+ same run. The answer's `applied.previous` holds what each updated record's written fields held before, so one `update_records` of it puts the values back, and `delete_records` of a `created` record takes that row away. A body that stops proposing a row once it is
534
570
  written — it matches against what the rows hold now — makes approving twice write once.
535
571
 
536
572
  ```
@@ -541,13 +577,13 @@ const sheet = await excel_get_rows({ // a workflow step
541
577
  columns: ["Reference", "Amount", "Paid on"],
542
578
  });
543
579
  const unpaid = await query_records({
544
- table_id: "tbl_notes",
545
- filters: { field_key: "fld_paid_on", operator: "is_empty" },
580
+ table_id: "notes",
581
+ filters: { field_key: "paid_on", operator: "is_empty" },
546
582
  pagination: { offset: 0, limit: 500 },
547
583
  });
548
584
  const acted = groupBy(inputs.record_ids, (id) => toString(id));
549
585
  const owed = filter(unpaid.records, (one) => size(acted[toString(one.id)] ?? []) > 0);
550
- const byRef = groupBy(owed, (one) => toString(one.data["fld_ref"]));
586
+ const byRef = groupBy(owed, (one) => toString(one.data["ref"]));
551
587
 
552
588
  const lines = sheet.rows.map((line) => ({
553
589
  line,
@@ -556,8 +592,8 @@ const lines = sheet.rows.map((line) => ({
556
592
  const updates = filter(lines, (one) => !isNull(one.note)).map((one) => ({
557
593
  record_id: toString(one.note?.id),
558
594
  note: `Transfer of ${toString(one.line["Amount"])}`,
559
- ticked: toNumber(one.line["Amount"]) == one.note?.data["fld_amount"],
560
- data: { fld_paid_on: toString(one.line["Paid on"]) },
595
+ ticked: toNumber(one.line["Amount"]) == sumBy(filter(owed, (owing) => owing.id == one.note?.id), (owing) => coalesce(owing.data["amount"], 0)),
596
+ data: { paid_on: toString(one.line["Paid on"]) },
561
597
  }));
562
598
  const unplaced = filter(lines, (one) => isNull(one.note)).map((one) => ({
563
599
  title: `${toString(one.line["Reference"])}: ${toString(one.line["Amount"])}`,
@@ -567,7 +603,7 @@ const unplaced = filter(lines, (one) => isNull(one.note)).map((one) => ({
567
603
  return({
568
604
  status: "success",
569
605
  message: `${size(updates)} of ${size(lines)} transfers match a note`,
570
- data: { review: { version: 1, table_id: "tbl_notes", creates: [], updates, unplaced } },
606
+ data: { review: { version: 1, table_id: "notes", creates: [], updates, unplaced } },
571
607
  });
572
608
  ```
573
609
 
@@ -606,16 +642,16 @@ const sheet = await excel_get_rows({ // a workflow step
606
642
  });
607
643
 
608
644
  const saved = await save_record({ // a workflow step
609
- table_id: "tbl_release_orders",
610
- match: { fld_number: inputs.number },
611
- set: { fld_note: "Read from the paper" },
645
+ table_id: "release_orders",
646
+ match: { number: inputs.number },
647
+ set: { note: "Read from the paper" },
612
648
  lines: [{
613
- table_id: "tbl_release_lines",
614
- link: "fld_order",
615
- match: ["fld_size"],
649
+ table_id: "release_lines",
650
+ link: "order",
651
+ match: ["size"],
616
652
  rows: sheet.rows.map((row) => ({
617
- fld_size: toString(row["Size"]),
618
- fld_quantity: toNumber(row["Quantity"]),
653
+ size: toString(row["Size"]),
654
+ quantity: toNumber(row["Quantity"]),
619
655
  })),
620
656
  }],
621
657
  });
@@ -627,6 +663,43 @@ return({
627
663
  });
628
664
  ```
629
665
 
666
+ ## Reaching a connected service
667
+
668
+ A service is reached only by a workflow step, and a step is called inside an app's workflow or an automation. From
669
+ nothing to an app's run:
670
+
671
+ 1. `get_workspace` with `kind: "connection"` lists each `connected_account_id`.
672
+ 2. `set_app` declares a Workflow node that states what a call takes and hands back:
673
+
674
+ ```json
675
+ {
676
+ "type": "Workflow",
677
+ "key": "invoice_order",
678
+ "description": "Issues the KiotViet invoice for a confirmed order and hands back its code.",
679
+ "inputs": { "order": { "type": "record_link", "entity": "order" } },
680
+ "outputs": { "invoice_code": { "type": "text" } }
681
+ }
682
+ ```
683
+
684
+ 3. `set_app_workflow` with the app, the alias `invoice_order` and this `source` (the step's entry is in `workflow_steps`):
685
+
686
+ ```
687
+ const order = await get_record({ table_id: "orders", record_id: trigger.app_workflow.inputs.order });
688
+ const made = await kiotviet_create_invoice({ // a workflow step
689
+ connected_account_id: "cac_…",
690
+ branch_id: 1,
691
+ sold_by_id: 7,
692
+ lines: [{ product_id: 42, quantity: toNumber(order.data["quantity"]), price: toNumber(order.data["price"]) }],
693
+ });
694
+ return({ status: "success", message: "Invoiced", data: { invoice_code: made.invoice.code } });
695
+ ```
696
+
697
+ 4. `run_app_workflow` with `dry_run: true`, then the same call without it.
698
+
699
+ A step wants the provider's own ids (`branch_id`, `sold_by_id`, `product_id`) and no tool lists them: declare a Workflow
700
+ whose body calls the provider's `list_*` workflow step (`kiotviet_list_branches`), run it, and read the ids from its answer.
701
+ No step lists KiotViet's users: read `sold_by_id` off a row `kiotviet_list_invoices` returns (KiotViet's `soldById`), or ask the owner.
702
+
630
703
  ## Table checks
631
704
 
632
705
  A table check is one condition its table's record writes must not meet, declared beside the table's
@@ -635,54 +708,181 @@ cannot change data. It runs inside the write, on every create, update or delete
635
708
  surface makes it — a person, the API, the CLI, an agent, a workflow. A refusal writes nothing and
636
709
  answers with the check's message, under its field when the check names one, and names the check.
637
710
 
638
- On the CLI or in chat, `set_table_check` creates one, or changes one when given its `table_check_id`
639
- and only the parts that change, and `remove_table_check` removes one. `get_table` lists a table's
640
- checks in the source they are written in.
711
+ A check is stated in its table's document, under `checks`, each under an alias of its own, and
712
+ `set_tables` writes it with the rest of the table: a merge patch edits one, `null` removes it, and a
713
+ call that makes a table can state its checks, a lookup naming another table of the call by its `ref`.
714
+ `get_table` states a table's checks in its document.
641
715
 
642
716
  | part | what it holds |
643
717
  |---|---|
644
718
  | `on` | the writes it checks: any of `create`, `update`, `delete` |
645
719
  | `when` | optional expression; the check runs only on a write where it is truthy. It cannot read lookups. |
646
- | `lookups` | optional, by name, the rows to read: `{ table_id, filter, limit }`, the filter as `query_records` takes it, `limit` from 1 to 50 (50 when left out). A filter value may read the record. |
720
+ | `lookups` | optional, by name, the rows to read: `{ table, filter, limit }`, `table` an id or the `ref` of a table of the call, the filter as `query_records` takes it with fields and select options named by alias (`id` matches a row by its own record id), `limit` from 1 to 50 (50 when left out). A filter value that reads the record is `{ "$expr": "record.code" }`. |
647
721
  | `fail_when` | expression; truthy refuses the write |
648
722
  | `message` | expression for the refusal's text — a fixed text is a quoted string |
649
- | `field_key` | optional; the field the refusal is shown under |
723
+ | `field` | optional; the alias of the field the refusal is shown under |
724
+ | `enabled` | `false` keeps the check without deciding writes; absent, it decides |
650
725
 
651
- Every expression is written in the grammar above and reads:
726
+ Every expression is written in the grammar above, names a table's fields and select options by
727
+ alias (`record.status == "closed"`), and reads:
652
728
 
653
729
  - `record` — the record as it will be stored; on a delete, the record being deleted.
654
730
  - `prev_record` — on an update or a delete, the record before.
655
- - `changes` — on an update, the fields it changes.
656
- - `lookups.<name>` — in `fail_when` and `message`, the rows that lookup read.
731
+ - `changes` — on an update, the fields it changes. `!isNull(changes.photos)` asks whether a field changed.
732
+ - `lookups.<name>` — in `fail_when` and `message`, the rows that lookup read. A row reads as `record` does: its fields by alias beside `id` (`row.start_on`), and a lookup field as the flat list of what it looks up (`first(row.booking_start)` through a one-row link). A lookup over the written table never returns the row being checked, and finds its other rows as the write leaves them, this call's other rows included. A field given to a helper as text names it by alias too (`sumBy(lookups.paid, "amount")`); text that names no field of the rows' table is refused when the check is saved.
657
733
  - `runtime.timezone`, `runtime.workspace_id`, `runtime.organization_id`, `runtime.change_origin` (the
658
734
  write's origin), `runtime.triggered_by_member_id` (the member writing; null for a write no member
659
- made), and `now()`.
735
+ made), `now()`, and `member_of("grp_…")` — true when the member writing belongs to the group.
736
+
737
+ In a check a select reads as its one value (`record.kind == "retail"`), and a link (a one-row link too) and a lookup read as lists (`first(record.customer)`).
660
738
 
661
739
  A check on several operations reads only what all of them carry, so one on creates and updates reads
662
740
  neither `prev_record` nor `changes`.
663
741
 
742
+ A write that recomputes another table's rows (a rollup over lines, a lookup or a formula over a parent) judges each recomputed row as an update, by the checks `on` `update` that read a field the recompute changed; `changes` is what the recompute changed. A check that refuses on `changes` or on a status the row holds can therefore refuse the write that moved the parent, so it is gated on the field it means (`!isNull(changes.status)`).
743
+
664
744
  A lookup reads with the access of the member who last saved the check, so saving one requires reading
665
745
  every table its lookups read. It reads committed rows: two writes at the same moment can each pass a
666
746
  check the other would fail, so values no two rows may share are the table's `unique`, not a check.
667
747
 
668
748
  A `when`, a lookup or a `fail_when` that cannot decide — it fails, or the write's time runs out —
669
- refuses the write. A draft is checked when it is submitted, as a create; its edits before that are
749
+ refuses the write, naming the part it was reading and, for an expression or a filter, what it met
750
+ (`lookups.clash: Filter on "start_on": date 'before' value … is not a date`). A draft is checked when it is submitted, as a create; its edits before that are
670
751
  not. A restored record is checked as a create. Cloning a table copies its checks to the member who
671
752
  clones it, and is refused when that member cannot read a table a check's lookup reads.
672
753
 
673
754
  One open order per reference, a row never counting against itself:
674
755
 
756
+ ```json
757
+ {
758
+ "tables": [
759
+ {
760
+ "ref": "orders",
761
+ "label": "Orders",
762
+ "fields": {
763
+ "reference": { "type": "text", "label": "Reference" },
764
+ "closed_at": { "type": "date", "label": "Closed at" }
765
+ },
766
+ "checks": {
767
+ "one_open_order_per_reference": {
768
+ "on": ["create", "update"],
769
+ "when": "isNull(record.closed_at)",
770
+ "lookups": {
771
+ "same_ref": {
772
+ "table": "orders",
773
+ "filter": {
774
+ "node_type": "group",
775
+ "logic": "and",
776
+ "children": [
777
+ { "node_type": "condition", "field_key": "reference", "operator": "equals", "value": { "$expr": "record.reference" } },
778
+ { "node_type": "condition", "field_key": "closed_at", "operator": "is_empty" }
779
+ ]
780
+ },
781
+ "limit": 2
782
+ }
783
+ },
784
+ "fail_when": "some(lookups.same_ref, (r) => r.id != record.id)",
785
+ "message": "\"An open order already has this reference.\"",
786
+ "field": "reference"
787
+ }
788
+ }
789
+ }
790
+ ]
791
+ }
675
792
  ```
793
+
794
+ ## Table workflows
795
+
796
+ A table workflow is what follows every committed write of a table's rows, whoever makes it: the grid, an import, an agent or
797
+ any app. An effect that must hold for all of them belongs to the table. A button on one screen is that app's act, a value fixed
798
+ when a row is made is `default_from`, and a rule that refuses a write is a check; a table workflow runs after the write has
799
+ committed and cannot refuse it.
800
+
801
+ It is stated in the table's document, under `workflows`, each under an alias of its own, and `set_tables` writes it with the
802
+ rest of the table: a merge patch edits one, `null` removes it, and `get_table` states a table's workflows in its document, with
803
+ the failures of the last 7 days beside them, to whoever manages the table. A dry run of `set_tables` with a `record_id` says what
804
+ each workflow the call adds or changes would write on that row; reads are real and nothing is written.
805
+
806
+ | part | what it holds |
807
+ |---|---|
808
+ | `on` | the write it follows: `create`, `update` or `delete` |
809
+ | `when` | optional expression over `record`, `prev_record` and `changes`; the workflow runs only after a write where it is truthy |
810
+ | `changed` | optional, with `on: update`: field aliases; the workflow runs only after an update that changes any one of them |
811
+ | `body` | the steps, written as an app workflow's body is |
812
+ | `enabled` | `false` keeps the workflow without running it; absent, it runs |
813
+
814
+ ```json
676
815
  {
677
- "table_id": "tbl_orders",
678
- "name": "One open order per reference",
679
- "on": ["create", "update"],
680
- "when": "isNull(record[\"fld_closed_at\"])",
681
- "lookups": {
682
- "same_ref": "{ table_id: \"tbl_orders\", filter: { node_type: \"group\", logic: \"and\", children: [ { field_key: \"fld_ref\", operator: \"equals\", value: record[\"fld_ref\"] }, { field_key: \"fld_closed_at\", operator: \"is_empty\" } ] }, limit: 2 }"
683
- },
684
- "fail_when": "some(lookups.same_ref, (r) => r.id != record.id)",
685
- "message": "\"An open order already has this reference.\"",
686
- "field_key": "fld_ref"
816
+ "tables": [
817
+ {
818
+ "ref": "repairs",
819
+ "label": "Repairs",
820
+ "fields": {
821
+ "status": { "type": "select", "label": "Status", "options": [{ "alias": "queued", "label": "Queued" }, { "alias": "fixed", "label": "Fixed" }] },
822
+ "fixed_on": { "type": "date", "label": "Fixed on" }
823
+ },
824
+ "workflows": {
825
+ "stamp_fixed": {
826
+ "on": "update",
827
+ "changed": ["status"],
828
+ "when": "record.status == \"fixed\"",
829
+ "body": "await update_records({ table_id: \"repairs\", record_ids: [trigger.record_id], set: { fixed_on: formatDate(now(), \"yyyy-MM-dd\") } });"
830
+ }
831
+ }
832
+ }
833
+ ]
687
834
  }
688
835
  ```
836
+
837
+ The body names the table's fields, select options and other tables by alias, and reads the write as `record`,
838
+ `prev_record` and `changes` and the row's id as `trigger.record_id`. It operates on records and integrations: a body
839
+ that calls a tool changing a table, an app or any other structure is refused when it is saved. It runs as the table's owner, so
840
+ it reaches the tables the owner reaches, and its writes meet the table's checks like any other write; a write refused by a
841
+ check fails that run and leaves the row as it was. A body names a field by alias where it can tell which table's field it is: a
842
+ read of `record`, `prev_record` or `changes`, a row from `get_record` or `query_records` called with a literal `table_id`, and a
843
+ loop over such rows. A name it cannot tie to a table, and an option compared to a value of unknown type (not a read of a field, and
844
+ not a text, number or other value the type proves holds no option), are refused with what to change. A link reads as the list of the row ids it holds, a one-row link too: compare `first(record.customer) == id`. A lookup reads as one flat list of what it looks up: `record.x != prev_record.x` on one is refused, so compare `first(record.x)` with `first(prev_record.x)`. A single member, a single select, a number or a text reads as the value itself: `first(record.technician)` is refused at save, naming the read.
845
+
846
+ A write the workflow itself makes sets off the workflows of its table in turn, and a chain of them stops after five. A workflow
847
+ that writes the row it follows is gated by a `changed` or a `when` that its own write does not satisfy. One run is queued for a
848
+ row and a workflow at a time, so a burst of writes to one row runs it once more after the run in flight. A field a workflow
849
+ reads cannot be deleted or retyped until the workflow is changed or removed in the same call or before. Cloning a table copies its
850
+ workflows for whoever manages it (`include_workflows: false` leaves them), each with a body of its own, and is refused when a
851
+ body names a table the cloner cannot reach. A workflow runs as the table's owner, so whoever saves or rehearses one must manage the
852
+ table and read every table it reads in full: the body's, the gate's links', the written table. An option is written where
853
+ it is compared to, listed against or set on its field; a constant holding one is refused.
854
+
855
+ A lock is the table's: a table workflow calls `lock_records` on a row (an app's act may too), and every door then refuses
856
+ the row's writes until whoever manages the table calls `unlock_records` — in an app's run, judged as the member who pressed.
857
+ A locked row reads locked in every app. An app's own Check or `edit: false` only makes a row read-only in that app.
858
+
859
+ ## Runs
860
+
861
+ Each start of a workflow is a run: an app's workflow or act, a table workflow, a button, an automation. An agent finds,
862
+ reads, retries and stops runs on chat, MCP and the CLI. A run is read by whoever manages what its workflow belongs to: its
863
+ automation or its table, and for an app's workflow the app's owner or an admin, never a manager of the app.
864
+
865
+ - **Find.** `query_workflow_executions` lists one workflow's runs (`workflow_id`), every run started for one row
866
+ (`record_id`) — a table workflow's written row, a pressed button's row, an app run's `record_id` input — or both, newest
867
+ first, a page at a time (`next_cursor`). Each run names its workflow and kind, what started it, its row and how it ended:
868
+ for a failure, the step that threw, its tool and the error; for a waiting run, when it wakes, or the approval and the
869
+ approvers it waits on, or the payment. A run started before runs recorded their row carries no `record_id`.
870
+ - **Read.** `get_workflow_execution` gives one run's steps in order with each one's tool, input, output and error, what it
871
+ returned, how many rows it wrote (a row's own history is in `query_audit_logs`) and the runs its writes started. Ninety days
872
+ after a run starts, its trigger and its steps' input and output are pruned; the read says so and gives the rest.
873
+ `run_app_workflow` answers each run it makes with its `execution_id`.
874
+ - **Retry.** `retry_workflow_execution` runs a failed, refused or cancelled run again as a new run linked to the first
875
+ (`retry_of`): the first run's trigger and row, the workflow's current steps and its owner's current access, with the
876
+ member who retries it as the one who started it. A run is retried once; a retry that fails is retried in turn. It asks
877
+ first when the run, or a run it retried, already wrote rows or called a service no delete reverses. An automation's or a
878
+ table workflow's retry runs in the background, as its runs do, and answers `queued`; a button's and an app's run while
879
+ the call waits. An app's call is checked again against the workflow's current inputs. A pruned run, a run of a workflow
880
+ that is off, a reviewed act's run (call the act again) and a recording's filing (the recording retries its delivery)
881
+ are not run again.
882
+ - **Stop.** `cancel_workflow_execution` stops a waiting run and withdraws its pending approvals.
883
+
884
+ Off means off: a waiting run whose workflow was turned off, deleted, or taken off its automation, table, button or app ends
885
+ `cancelled` when it would resume, saying why, and runs no further step.
886
+
887
+ A waiting run keeps its version: when it resumes, it runs the steps of the version it started with, so an edit saved while it
888
+ waited changes later runs only. When that version is gone, the run ends failed and says why.