@lotics/cli 0.275.0 → 0.277.0

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.
@@ -0,0 +1,336 @@
1
+ # Workflows — the steps a workflow runs
2
+
3
+ A workflow is written as a strict subset of JavaScript. The source is never run as JavaScript: a
4
+ save parses it into steps, type-checks it against what its trigger supplies, and stores the steps.
5
+ Only the forms below are accepted; anything else is refused at save with the line, the column and
6
+ what to write instead. A save that succeeds may still return `warnings` — advisory hints such as a
7
+ loop that may never end. Read them.
8
+
9
+ The same grammar serves an automation, a table's lifecycle workflow and an app's workflow. Only an
10
+ automation's source opens with a trigger declaration; a lifecycle workflow takes its trigger from its
11
+ table and event, an app workflow from the app that calls it.
12
+
13
+ ## Triggers
14
+
15
+ An automation answers an event outside the records: a schedule, a webhook, an inbound email. A
16
+ reaction to a record being created, updated or deleted is a table lifecycle workflow (see
17
+ **Table lifecycle workflows**).
18
+
19
+ ```
20
+ on({ type: "recurring_schedule", cron_expression: "0 9 * * MON" });
21
+ ```
22
+
23
+ Every source below is typed, so a misspelled member is refused at save rather than read as nothing.
24
+ All three carry `trigger.trigger_id`.
25
+
26
+ | trigger | required config | what the body reads |
27
+ |---|---|---|
28
+ | `recurring_schedule` | `cron_expression` | `runtime.timezone`; the current time is `now()` |
29
+ | `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` |
30
+ | `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. |
31
+
32
+ A whole automation:
33
+
34
+ ```
35
+ on({ type: "recurring_schedule", cron_expression: "0 9 * * MON" });
36
+
37
+ const open = await query_records({
38
+ table_id: "tbl_tickets",
39
+ filters: { node_type: "condition", field_key: "fld_status", operator: "has_any_of", value: ["opt_open"] },
40
+ });
41
+ await send_email({
42
+ to: "team@example.com",
43
+ subject: "Weekly digest",
44
+ body: `${size(open.records)} tickets open as of ${formatDate(now(), "YYYY-MM-DD")}.`,
45
+ });
46
+ ```
47
+
48
+ ## Steps
49
+
50
+ | step | syntax |
51
+ |---|---|
52
+ | tool call, kept | `const <id> = await <tool>({ ...inputs });` — also `let x = await …` and `x = await …` |
53
+ | tool call | `await <tool>({ ...inputs });` |
54
+ | agent | `const <id> = await agent({ instructions, input, tools, model, output });` — see **An agent step** |
55
+ | 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. |
56
+ | bind | `const <id> = <expression>;` — evaluated once; later steps read `<id>`. Bind any expression used twice. |
57
+ | if / else | `if (<expr>) { ... } else { ... }` — `else` optional, `else if` chains allowed |
58
+ | switch | `switch (<expr>) { case "X": { ... } default: { ... } }` — string cases only, no fallthrough |
59
+ | for | `for (const <name> of <expr>) { ... }` |
60
+ | wait | `await wait({ duration_in_minutes: 5 });` |
61
+ | wait for an event | `await wait_for_event({ event_type: "webhook", event_ref: <expr>, timeout_in_minutes: 60 });` |
62
+ | wait for an approval | `await wait_for_approval({ approvers: <expr>, prompt: <expr> });` — see **Waiting for an approval** |
63
+ | return | `return({ status: "success" \| "error", message: <expr>, field_errors: { ... }? });` |
64
+ | validate | `validate({ checks: [{ fail_when: <expr>, field_key: "...", message: <expr> }, ...] });` |
65
+
66
+ `return` is a call, not a JavaScript `return` statement. `validate` refuses the write when a check's
67
+ `fail_when` is truthy; the check's `field_key` names the control its message lands on — a field of the
68
+ trigger table in a lifecycle workflow; in an app workflow a declared input, or the `fld_` key of a
69
+ field on a table the body names; in an automation it is not checked.
70
+
71
+ The name in `const x = await tool({...})` is the step's id; later expressions read its output as
72
+ `x.records`, `x.id` and so on. A `// id: my_step` comment directly above a statement sets an explicit
73
+ id; any other `//` comment there becomes the step's description.
74
+
75
+ **`await` inside a statement** runs the call as its own step first, then reads its output where it is
76
+ written: `size((await query_records({...})).records)`, an `if` test, a `return` value, a `for-of`
77
+ iterable, a tool input. `const x = c ? await t({...}) : null;` is stored as the `if` it means. An
78
+ `await` that would run on some paths only — inside `&&`, `||`, `??`, `?.`, a nested ternary, a loop
79
+ test, a `validate` check — is refused; write the `if`.
80
+
81
+ **Names are block-scoped, as in JavaScript** — `const`, `let`, loop items and `catch (e)`: sibling
82
+ blocks may reuse a name and an inner block may shadow an outer one. A local may take a helper's name
83
+ (`const size = 3;`), but calling `size(...)` while it is in scope is refused. Tool names, the reserved
84
+ roots, `linked` and `_s` followed by digits cannot be bound.
85
+
86
+ ### Waiting for an approval
87
+
88
+ `wait_for_approval` is the wait worth binding — `wait` and `wait_for_event` carry nothing to read.
89
+ Bind it to branch on the decision:
90
+
91
+ ```
92
+ const approval = await wait_for_approval({
93
+ approvers: record["fld_approvers"],
94
+ prompt: `Approve the quote for ${record["fld_name"]}?`,
95
+ });
96
+
97
+ if (approval.status == "approved") {
98
+ // the approved path
99
+ } else {
100
+ // the rejected or timed-out path; approval.decision_comment says why
101
+ }
102
+ ```
103
+
104
+ It resolves to `{ status: "approved" | "rejected" | "timed_out", decided_by: MemberId | null,
105
+ decided_at: ISO string, decision_comment: string | null }`.
106
+
107
+ `approvers` takes a bare member id (`"mbr_…"`), a bare group id (`"grp_…"`) or a full principal
108
+ (`{ type: "member_group", id: "grp_x" }`), alone or in a list — so a member field's value passes as it
109
+ is.
110
+
111
+ ### An agent step
112
+
113
+ `const x = await agent({ instructions, input, tools, model, output });` runs a model with tools as one
114
+ step — summarize a record, classify, draft text.
115
+
116
+ - `instructions` — a fixed string.
117
+ - `input` — an object literal; its fields are expressions, evaluated and handed to the model (`{}` for
118
+ none). It is the ONLY workflow data the agent sees: read `record`, prior steps, `runtime` or the loop
119
+ item here, never inside `instructions` or `tools`, which are fixed literals and read no binding.
120
+ - `tools` — the names of the tools it may call.
121
+ - `model` — optional; omit it to follow the platform's default model.
122
+ - `output` — `{ mode: "text" }` resolves to a string; `{ mode: "object", schema }` to a typed object.
123
+
124
+ Only `x` comes back: in text mode `x` is the string itself, in object mode `x.field` reads a field the
125
+ `schema` declares. The agent's own tool calls are not readable. An agent step cannot give a
126
+ synchronous verdict, so a `before_*` lifecycle workflow refuses it.
127
+
128
+ ## Keys, not names
129
+
130
+ 1. **Fields and options are named by key.** A field reads as `record["fld_status"]`, an option as
131
+ `"opt_done"`, a linked field as `linked(record["fld_customer"])[0]["fld_name"]`. `get_table` lists
132
+ each field's `key` and each option's `key`; a display name is refused at save with the key to use.
133
+ 2. **`==` against a multi-select means "includes".** `record["fld_tags"] == "opt_urgent"` on a
134
+ multi-select is stored as `includes(record["fld_tags"], "opt_urgent")`; either spelling works.
135
+ 3. **Text renders labels.** Inside a template or a text tool input, a select, member or link value
136
+ renders its display text: `` `${record["fld_status"]}` `` writes "Done", not `opt_done`.
137
+
138
+ ## What an expression reads
139
+
140
+ - `record` — the trigger record (on an update, the record as it now stands).
141
+ - `prev_record` — the record before the change, same shape (updates and deletes only).
142
+ - `changes` — the fields an update changed, a PARTIAL map: `changes["fld_x"]?.next_value` and
143
+ `?.prev_value`, the `?.` required (updates only).
144
+ - `trigger` — what a non-record trigger carries (see **Triggers**). `trigger.data`,
145
+ `trigger.prev_data` and `trigger.changes` are the same three roots spelled long; a saved body reads
146
+ back in the short form.
147
+ - a loop's item name, and `index`, the iteration's 0-based index.
148
+ - `<step id>` — an earlier step's output (`dup.records`).
149
+ - `runtime.timezone`, `runtime.workflow_id`, `runtime.execution_id`, `runtime.workspace_id`,
150
+ `runtime.organization_id`, `runtime.change_origin`, `runtime.triggered_by_member_id`. The current
151
+ time is `now()`.
152
+
153
+ ## Paths
154
+
155
+ - `record.fld_x` and `record["fld_x"]` read a field; `[n]` reads a list's n-th item.
156
+ - A path may start at an awaited call: `(await get_record({...})).data["fld_x"]`. It may not start at a
157
+ helper's result — `first(found.records).id` is refused; bind `first(found.records)`, then read it.
158
+ - A record reads in its declared shape wherever it comes from — the trigger, a tool result, a `let`, a
159
+ callback parameter: a single select or member as its key or null, a link as its ids. (The record
160
+ tools called outside a workflow return the stored arrays instead.)
161
+ - `linked(record["fld_link"])[n]["fld_x"]` fetches the n-th linked row and reads a field of it; bare
162
+ `linked(record["fld_link"])` is every linked row, one fetch per row. Its argument is a path ending on
163
+ a link field and carries the guard: `linked(record?.["fld_link"])[0]`.
164
+ - `changes["fld_link"]?.next_value` is the same list of ids; `linked()` over a change is refused —
165
+ read `linked(record["fld_link"])` or `linked(prev_record["fld_link"])`.
166
+
167
+ ## Operators and statements
168
+
169
+ Operators in JavaScript precedence: `? :`, `||`, `??`, `&&`, `== !=`, `< <= > >=`, `+ -`, `* /`, prefix
170
+ `! -`.
171
+
172
+ - `??` is `coalesce(left, right)`; `?.` reads through a null (`a?.b`, `a?.[0]`, and `s?.trim()` for the
173
+ method names listed under **Helpers**). `x?.()` is refused: helpers and tools are not values.
174
+ - Templates use backticks and `${...}`.
175
+ - Destructuring — `const { fld_status, fld_name } = record;` binds each name. Renames
176
+ (`{ fld_status: s }`), quoted keys (`{ "fld_ref": ref }`), array patterns with holes
177
+ (`const [first, , third] = xs;`) and defaults (`{ fld_note = "" }`, which apply on null too) work on
178
+ `const`, `let` and `for (const { id } of rows)`. A tool result destructures directly:
179
+ `const { records } = await query_records({...});`. `const a = 1, b = 2;` declares both. Nested
180
+ patterns and rest are refused.
181
+ - Spread — `[...a, b]` and `{ ...a, b: 1 }` (later keys win). Refused inside a tool input: bind the
182
+ merged value first and pass the binding.
183
+ - Shorthand — `{ table_id }` is `{ table_id: table_id }`.
184
+ - `xs.push(a, b);` appends, also on a key (`o.items.push(v)`); `o.a.b = v;` sets a nested key;
185
+ `x ??= v`, `x ||= v` and `x &&= v` assign. A `const` may be pushed to or have a key set, as in
186
+ JavaScript; a loop item is read-only.
187
+ - `undefined` is the same value as `null`. `=== undefined` and `!== undefined` are refused, since they
188
+ cannot tell a missing key from null: test `isNull(x)`, or `includes(keys(o), "k")` for whether a key
189
+ was sent.
190
+ - A filter node may leave out `node_type` when its keys say which it is: `field_key` / `operator` /
191
+ `value` a condition, `logic` / `children` a group, `path` / `condition` a traversal.
192
+ - `function name(p = <default>) { return <expr>; }` — top level only, expanded at every call (a call
193
+ may come before it). Every parameter needs a default, which gives it its type; the body is one
194
+ `return <expr>;` reading only its parameters, helpers and the reserved roots — never the caller's
195
+ names, nor `index`. No recursion; a function never called is refused. A body read back shows the
196
+ expression at each call, not the `function`.
197
+
198
+ **try / catch.** `try { ... } catch (e) { ... }` catches a tool error or an expression error inside the
199
+ body; `e` is `{ message, type, step_id?, detail? }`. A failed `validate` and a `return` are not
200
+ errors — they end the workflow — and a step that runs after a wait inside the `try` is outside it.
201
+ `finally` is refused: put always-run steps after the `try`.
202
+
203
+ **Loops.** `for-of`, `while (cond) { ... }`, `do { ... } while (cond);` and
204
+ `for (let i = 0; i < n; i++) { ... }`, each capped at 10,000 iterations. `break;` and `continue;` act
205
+ on the innermost loop; labels and `for-in` are refused.
206
+
207
+ **`let`.** `let x = <expr>;` declares a block-scoped variable; the initializer is required
208
+ (`let x = null;`). Reassign with `=`, `+=`, `-=`, `*=`, `/=`, `%=`, `??=`, `++` and `--`. Re-declaring in
209
+ the same block is refused; shadowing in a nested block is allowed. A `let` keeps its value across a
210
+ `wait`, `wait_for_event` or `wait_for_approval`.
211
+
212
+ **Refused:** regex, `new`, `typeof`, `instanceof`, `in`, `delete`, `void`, rest elements, computed keys,
213
+ classes, `throw`, `import`, `export`, and a function as a value (`const f = (x) => ...`).
214
+
215
+ ## Helpers
216
+
217
+ Called as `size(arr)`. The method form works only where the name is also a JavaScript method —
218
+ `x.trim()`, `arr.includes(v)`, `arr.at(-1)`, `arr.map(fn)`, `s.split(",")`; `arr.size()` is refused.
219
+
220
+ - **Null and type**: `isNull`, `isNotNull`, `isEmpty`, `isString`, `isNumber`, `isBoolean`, `isArray`,
221
+ `isObject`, `coalesce`, `get`, `toNumber`, `toString`, `typeOf`, `parseJson`, `toJson`
222
+ - **Lists**: `size`, `first`, `requireFirst` (the first item, refusing an empty list — after a
223
+ `validate` on the size it saves an `if`), `last`, `nth`, `at` (`at(arr, -1)` counts from the end),
224
+ `includes`, `filter`, `find`, `some`, `every`, `pluck`, `sortBy`, `groupBy`, `countBy`, `unique`,
225
+ `uniqueBy`, `compact` (drops falsy items), `flatten`, `reverse`, `slice`, `concat`, `difference`,
226
+ `differenceBy`, `intersection`, `intersectionBy`, `list`, `reduce(arr, (acc, x) => ..., initial)`,
227
+ `range(end)` / `range(start, end)`
228
+ - **Math**: `sum`, `sumBy`, `mean`, `meanBy`, `minBy`, `maxBy`, `round`, `ceil`, `floor`, `min`, `max`,
229
+ `abs`, `mod`, `pow`, `sqrt`, `clamp`, `percentage`
230
+ - **Text**: `upper`, `lower`, `capitalize`, `trim`, `contains`, `startsWith`, `endsWith`, `replace`,
231
+ `replaceAll`, `substring`, `length`, `split`, `join`, `padStart(str, length, char)`,
232
+ `padEnd(str, length, char)`, `formatNumber(x, decimals)` (ungrouped, as `x.toFixed`),
233
+ `formatDecimal(x, decimals, locale)` (grouped: `formatDecimal(151000, 0, "vi-VN")` is `151.000`),
234
+ `numberToWords` (an integer in Vietnamese words)
235
+ - **Objects**: `keys`, `values`, `entries`, `nonNullKeys`, `pick`, `omit`, `merge`
236
+ - **Dates**, in the workspace's timezone: `now`, `formatDate`, `parseDate`, `addDays`, `subDays`,
237
+ `addHours`, `subHours`, `addMinutes`, `subMinutes`, `startOfDay`, `endOfDay`,
238
+ `differenceInCalendarDays`, `differenceInHours`, `differenceInMinutes`, `isBefore`, `isAfter`,
239
+ `isSameDay`, `isToday`, `isWithinRange`
240
+ - **Other**: `formatCurrency(amount, locale, currency)`, `randomNumber(len)`,
241
+ `randomAlphaNumeric(len)`, `sample(items)` (a random item)
242
+
243
+ A date helper given a null or empty date returns null, so guard its result before comparing or
244
+ writing it: `differenceInCalendarDays(a, b) ?? 0`.
245
+
246
+ `filter`, `find`, `some`, `every`, `sortBy`, `pluck`, `sumBy`, `meanBy`, `minBy`, `maxBy`, `groupBy`,
247
+ `countBy`, `uniqueBy`, `differenceBy` and `intersectionBy` take a path string
248
+ (`filter(record.items, "Status", "open")`) or a callback
249
+ (`filter(record.items, x => x.Status == "open" && x.Amount > 100)`); `reduce` takes a callback and an
250
+ initial value. A callback gets `(item, idx)` (`reduce`: `(acc, item, idx)`) and reads `record`,
251
+ `runtime`, the names in scope, loop items and an enclosing callback's parameters. Its body is one
252
+ expression — `(x) => <expr>`, `(x) => { return <expr>; }` or `function (x) { return <expr>; }`;
253
+ several statements, `async` and named function expressions are refused.
254
+
255
+ ## Writing a field: null and undefined
256
+
257
+ In `update_records`' `set` and `create_records`' `records`:
258
+
259
+ - `null` clears the field.
260
+ - `undefined`, or leaving the key out, leaves the field as it is.
261
+
262
+ So passing a read that may be null (`record["fld_x"]`) keeps a value when there is one and clears the
263
+ field when there is none. Use `coalesce(x, fallback)` only for a real fallback value.
264
+
265
+ ## Examples
266
+
267
+ Refuse a duplicate before it is created (a `before_create` lifecycle workflow; the keys come from
268
+ `get_table`):
269
+
270
+ ```
271
+ const dup = await query_records({
272
+ table_id: "tbl_orders",
273
+ filters: { node_type: "group", logic: "and", children: [
274
+ { node_type: "condition", field_key: "fld_ref", operator: "equals", value: record["fld_ref"] },
275
+ { node_type: "condition", field_key: "fld_closed_at", operator: "is_empty" },
276
+ ]},
277
+ });
278
+
279
+ validate({ checks: [{
280
+ fail_when: size(dup.records) > 0,
281
+ field_key: "fld_ref",
282
+ message: "An open order already has this reference.",
283
+ }]});
284
+ ```
285
+
286
+ Compare a link by id. Display text is not unique, and a link reads as a list of ids — `==` against a
287
+ string is a type error:
288
+
289
+ ```
290
+ const found = await query_records({
291
+ table_id: "tbl_customers",
292
+ filters: { node_type: "condition", field_key: "fld_name", operator: "equals", value: "ACME Corp" },
293
+ });
294
+ const customer = first(found.records);
295
+
296
+ if (customer && includes(record["fld_customer"], customer.id)) {
297
+ // …
298
+ }
299
+ ```
300
+
301
+ ## Table lifecycle workflows
302
+
303
+ A lifecycle workflow is bound to one table and one event, and its source has no `on({...})` line:
304
+
305
+ - `before_create` / `after_create` — a create, and a draft's submit (the moment it becomes a record).
306
+ - `before_update` / `after_update` — every field edit, a draft's included: a `before_update` check
307
+ runs while someone fills in a draft, not only once it is submitted.
308
+ - `before_delete` / `after_delete`.
309
+
310
+ A `before_*` workflow runs before the write commits and may refuse it, with `validate` or
311
+ `return({ status: "error", ... })`; its errors come back as field errors on the write. It cannot
312
+ `wait`, `wait_for_event`, `wait_for_approval` or run an `agent` step. An `after_*` workflow runs after
313
+ the commit, with every step; its errors are logged and never fail the write. An `after_update`
314
+ workflow that writes its own table saves with a `loop_potential` warning: its write fires it again.
315
+
316
+ | reads | on |
317
+ |---|---|
318
+ | `record` | every event — the new record on create, the record as it now stands on update, the deleted record on delete |
319
+ | `prev_record` | updates and deletes — the record before |
320
+ | `changes["fld_x"]?.next_value` / `?.prev_value` | updates — the fields that changed |
321
+
322
+ ### Gating with `if_source`
323
+
324
+ `if_source` is one JavaScript expression, reading what the body reads, tested before the workflow
325
+ runs. When it is falsy the workflow does not run at all — no execution, no log. Use it so a workflow
326
+ that cares about some events only skips the rest:
327
+
328
+ - a status reaching an option: `changes["fld_status"]?.next_value == "opt_done"`
329
+ - a field cleared: `isNull(record["fld_assignee"])`
330
+ - a direct write, not a workflow's cascade:
331
+ `includes(["member", "chat_agent", "api_client"], runtime.change_origin.type)` — name every
332
+ direct-write origin rather than testing for one: `member` is a person in the browser, and the same
333
+ edit over MCP, the CLI or the API arrives as `api_client`.
334
+
335
+ In `if_source`, `runtime.change_origin` is the origin of the write that fired it; in the body, it is
336
+ the workflow's own.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/cli",
3
- "version": "0.275.0",
3
+ "version": "0.277.0",
4
4
  "description": "Lotics SDK and CLI for AI agents",
5
5
  "type": "module",
6
6
  "bin": {