@lotics/cli 0.316.19 → 0.316.21

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.
@@ -12,6 +12,73 @@ answer the provider refuses — ends the run, and the run's error reads `[<step>
12
12
  (e) { … }` the body handles it instead: `e` is `{ message, type, step_id?, detail? }`, `message` the reason as
13
13
  above. A provider's refusal carries the provider's own words.
14
14
 
15
+ ## aggregate
16
+
17
+ ### aggregate_records (workflow step)
18
+
19
+ - Compute aggregate statistics over records.
20
+ - Fields and select options are named by their alias, as get_table shows them. (chat, MCP, CLI)
21
+ - A workspace admin's `as_member` computes over what that member would see.
22
+ - Pass either `aggregate_option` for a single statistic, or `aggregations` to compute several in one call.
23
+ - Example: { "table_id": "...", "aggregate_option": { "operation": "sum", "field_key": "amount" } } Operations by field type: No field: count — the rows matching `filters`.
24
+ - `filled` counts populated CELLS, which is a different number the moment any are empty.
25
+ - All fields: empty, filled, percent_empty, percent_filled, unique, percent_unique Number: sum, avg, median, min, max, range Date: earliest, latest, date_range Boolean: checked, unchecked, percent_checked, percent_unchecked `group_by` breaks every requested statistic down per distinct value of one field, computed over the same filters — one call in place of a call per bucket.
26
+ - Example: { "table_id": "...", "group_by": "status", "filters": {...}, "aggregations": [{ "operation": "filled", "field_key": "created_on" }, { "operation": "sum", "field_key": "amount" }] } Restrict the period through `filters` on a date field rather than grouping by that date — a raw date/datetime groups per distinct stored value.
27
+ - Filters: Operators: `is`, `is_not` on a field holding one value; `is_any_of`, `is_none_of` on text, number, date, select, member and link fields; `lt`, `lte`, `gt`, `gte` on number and date; `is_empty`, `is_not_empty` on every field but yes/no.
28
+ - A date is "YYYY-MM-DD" for that whole day.
29
+ - `docs path="filters"`. (chat, MCP, CLI)
30
+
31
+ **Inputs**
32
+
33
+ | Name | Type | Required | What it is |
34
+ | --- | --- | --- | --- |
35
+ | `table_id` | string | yes | |
36
+ | `as_member` | string | | For a workspace admin: reads as this member would — their row rules and access. Anyone else is refused. |
37
+ | `filters` | any | | |
38
+ | `aggregate_option` | any | | |
39
+ | `aggregations` | any[] | | At most 25 entries. |
40
+ | `group_by` | string | | Field alias to break the statistics down by. Rows with no value in it are reported separately, not folded into a group. |
41
+
42
+ **Answer**
43
+
44
+ | Name | Type | Required | What it is |
45
+ | --- | --- | --- | --- |
46
+ | `result` | number \\| string | | Result of a single `aggregate_option` call — a number, or a string for dates. |
47
+ | `_units` | object[] | | One answer per unit or currency the rows hold, most rows first; unit null for rows naming none. `result` is the first. |
48
+ | `_units[].unit` | string \\| null | yes | |
49
+ | `_units[].value` | number | yes | |
50
+ | `results` | object[] | | One entry per aggregation when `aggregations` is used. |
51
+ | `results[].operation` | string | yes | |
52
+ | `results[].field_key` | string | yes | |
53
+ | `results[].field_name` | string | yes | |
54
+ | `results[].result` | number \\| string | yes | |
55
+ | `results[].units` | object[] | | One answer per unit or currency the rows hold, most rows first; unit null for rows naming none. `result` is the first. |
56
+ | `results[].units[].unit` | string \\| null | yes | |
57
+ | `results[].units[].value` | number | yes | |
58
+ | `groups` | object[] | | One entry per distinct value of `group_by`, each carrying every requested statistic. |
59
+ | `groups[].key` | string | yes | The group's identity. `label` is a spelling of it, and two rows can spell one identity differently. |
60
+ | `groups[].label` | string | | Readable name for `key` where one is known: an option's name, a linked record's display text, a filename. Absent means no name was available, never that the group is empty. |
61
+ | `groups[].results` | object[] | yes | |
62
+ | `groups[].results[].operation` | string | yes | |
63
+ | `groups[].results[].field_key` | string | yes | |
64
+ | `groups[].results[].field_name` | string | yes | |
65
+ | `groups[].results[].result` | number \\| string | yes | |
66
+ | `groups[].results[].units` | object[] | | One answer per unit or currency the rows hold, most rows first; unit null for rows naming none. `result` is the first. |
67
+ | `groups[].results[].units[].unit` | string \\| null | yes | |
68
+ | `groups[].results[].units[].value` | number | yes | |
69
+ | `_ungrouped_rows` | number | | Matching rows with no value in the `group_by` field — counted in no group. |
70
+ | `_group_field_name` | string | | |
71
+ | `_operation` | string | | |
72
+ | `_field_key` | string | | |
73
+ | `_field_name` | string | | |
74
+ | `_value_coverage` | object | | |
75
+ | `_value_coverage.requested` | number | yes | |
76
+ | `_value_coverage.field_name` | string | yes | |
77
+ | `_value_coverage.composite` | boolean | | |
78
+ | `_value_coverage.per_value` | object | | |
79
+ | `_value_coverage.per_value.matched` | number | yes | |
80
+ | `_value_coverage.per_value.missing` | string[] | yes | |
81
+
15
82
  ## bkav
16
83
 
17
84
  ### bkav_attach_files (workflow step)
@@ -739,6 +806,28 @@ above. A provider's refusal carries the provider's own words.
739
806
  | `results[].MessLog` | string | yes | |
740
807
  | `failed` | number | yes | Invoices whose own Status is not 0 |
741
808
 
809
+ ## delete
810
+
811
+ ### delete_file (workflow step)
812
+
813
+ - Soft-delete a file by its file_id — archives it (never a hard delete).
814
+ - Deleting an original file deletes every version of it; a file's current version cannot be deleted on its own.
815
+ - Refuses while the file or a version it takes along is still referenced by a record, comment, knowledge doc, document template, voice session, or pending change request, and names the referents so you can detach them first.
816
+
817
+ **Inputs**
818
+
819
+ | Name | Type | Required | What it is |
820
+ | --- | --- | --- | --- |
821
+ | `file_id` | string | yes | |
822
+
823
+ **Answer**
824
+
825
+ | Name | Type | Required | What it is |
826
+ | --- | --- | --- | --- |
827
+ | `id` | string | yes | |
828
+ | `filename` | string | yes | |
829
+ | `archived` | boolean | yes | |
830
+
742
831
  ## drive
743
832
 
744
833
  ### drive_export_file (workflow step)
@@ -766,7 +855,7 @@ above. A provider's refusal carries the provider's own words.
766
855
  ### drive_import_file (workflow step)
767
856
 
768
857
  - Import a Google Drive file into the workspace as a Lotics file, and return its file id.
769
- - Google Docs arrive as .docx, Sheets as .xlsx, Slides as .pptx; everything else transfers unchanged. **This is also how you READ a Drive file.** There is no separate Drive read tool: import the file, then use `view_files` on the returned id. (chat)
858
+ - Google Docs arrive as .docx, Sheets as .xlsx, Slides as .pptx; everything else transfers unchanged. **This is also how you READ a Drive file.** There is no separate Drive read tool: import the file, then use `view_files` on the returned id. (chat, MCP, CLI)
770
859
  - That path handles scanned documents — a photographed B/L or a stamped invoice inside a .docx reaches the model as a picture.
771
860
  - Exporting Drive content as plain text would return an empty document for exactly those files.
772
861
  - Folders, Forms, shortcuts and other Drive items with no file form cannot be imported.
@@ -782,7 +871,7 @@ above. A provider's refusal carries the provider's own words.
782
871
 
783
872
  | Name | Type | Required | What it is |
784
873
  | --- | --- | --- | --- |
785
- | `id` | string | yes | Lotics file ID — pass this to view_files to read the contents (chat) |
874
+ | `id` | string | yes | Lotics file ID — pass this to view_files to read the contents (chat, MCP, CLI) |
786
875
  | `filename` | string | yes | |
787
876
  | `mime_type` | string | yes | |
788
877
  | `url` | string | yes | |
@@ -1330,6 +1419,49 @@ above. A provider's refusal carries the provider's own words.
1330
1419
  | `mime_type` | string | yes | |
1331
1420
  | `url` | string | yes | |
1332
1421
 
1422
+ ## get
1423
+
1424
+ ### get_record (workflow step)
1425
+
1426
+ - Fetch a single record with all field values.
1427
+ - Returns data (raw values) and display (human-readable labels).
1428
+ - A workspace admin's `as_member` fetches it as that member would see it.
1429
+ - Cell shapes in `data`, keyed by field alias: select / member / link, and a formula with options, read back as arrays — a single-select as ["open"], the option's alias; one member as ["mbr_x"].
1430
+ - A workflow body reads the same single-select, single-member or option-formula cell as the bare value.
1431
+
1432
+ **Inputs**
1433
+
1434
+ | Name | Type | Required | What it is |
1435
+ | --- | --- | --- | --- |
1436
+ | `record_id` | string | yes | |
1437
+ | `table_id` | string | | |
1438
+ | `as_member` | string | | For a workspace admin: reads as this member would — their row rules and access. Anyone else is refused. |
1439
+ | `field_keys` | string[] | | Field aliases to return. Omit to return all fields. |
1440
+ | `comments` | boolean | | Return the record's newest comments, 20 at most; `comment_count` is the whole thread. |
1441
+
1442
+ **Answer**
1443
+
1444
+ | Name | Type | Required | What it is |
1445
+ | --- | --- | --- | --- |
1446
+ | `id` | string | yes | |
1447
+ | `data` | object | yes | |
1448
+ | `display` | object | yes | Human-readable display values for select, member, and record link fields. |
1449
+ | `links` | object | | Each link field's linked rows with the title the cell carries, by field alias; `data` holds their ids. |
1450
+ | `locked` | boolean | | |
1451
+ | `comment_count` | number | yes | How many comments the record holds. |
1452
+ | `comments` | object[] | | With `comments: true`: the newest first. |
1453
+ | `comments[].id` | string | yes | Unique identifier for the comment |
1454
+ | `comments[].member_id` | string | yes | ID of the member who created this comment |
1455
+ | `comments[].content` | string | yes | Comment text content (0-10,000 characters) |
1456
+ | `comments[].files` | object[] \\| null | | Array of files attached to this comment |
1457
+ | `comments[].created_at` | string | yes | Timestamp when comment was created |
1458
+ | `comments[].updated_at` | string | yes | Timestamp of last update |
1459
+ | `archived_at` | string \\| null | | |
1460
+ | `created_at` | string | | |
1461
+ | `updated_at` | string | | |
1462
+ | `_field_names` | object | | |
1463
+ | `units` | object | | The unit or currency this row reads a figure in, by the figure's field alias, where each row states its own. |
1464
+
1333
1465
  ## gmail
1334
1466
 
1335
1467
  ### gmail_get_thread (workflow step)
@@ -1415,7 +1547,7 @@ above. A provider's refusal carries the provider's own words.
1415
1547
  ### gmail_read_email (workflow step)
1416
1548
 
1417
1549
  - Fetch a Gmail message's body and the list of its attachments.
1418
- - Attachments are stored as workspace files — the result names each one's file_id; read one with view_files, attach one to a record with update_records. (chat)
1550
+ - Attachments are stored as workspace files — the result names each one's file_id; read one with view_files, attach one to a record with update_records. (chat, MCP, CLI)
1419
1551
 
1420
1552
  **Inputs**
1421
1553
 
@@ -1450,7 +1582,7 @@ above. A provider's refusal carries the provider's own words.
1450
1582
  | `message.attachments[].filename` | string | yes | Attachment filename as stored, which may differ from the sender's |
1451
1583
  | `message.attachments[].mimeType` | string | yes | MIME type as stored, recovered from the filename when the sender declared a generic one |
1452
1584
  | `message.attachments[].attachmentId` | string | yes | Gmail attachment ID |
1453
- | `message.attachments[].file_id` | string | | Stored file ID — pass to view_files to read it, or to update_records to attach it (chat) |
1585
+ | `message.attachments[].file_id` | string | | Stored file ID — pass to view_files to read it, or to update_records to attach it (chat, MCP, CLI) |
1454
1586
  | `message.attachments[].size` | number | | Stored size in bytes |
1455
1587
 
1456
1588
  ### gmail_reply_email (workflow step)
@@ -2427,6 +2559,28 @@ above. A provider's refusal carries the provider's own words.
2427
2559
  | `banks[].short_name` | string | yes | |
2428
2560
  | `banks[].full_name` | string | yes | |
2429
2561
 
2562
+ ## lock
2563
+
2564
+ ### lock_records (workflow step)
2565
+
2566
+ - Lock records to prevent editing.
2567
+ - Returns `{ locked, record_ids }`.
2568
+
2569
+ **Inputs**
2570
+
2571
+ | Name | Type | Required | What it is |
2572
+ | --- | --- | --- | --- |
2573
+ | `table_id` | string | yes | |
2574
+ | `record_ids` | string[] | | |
2575
+ | `filters` | any | | |
2576
+
2577
+ **Answer**
2578
+
2579
+ | Name | Type | Required | What it is |
2580
+ | --- | --- | --- | --- |
2581
+ | `locked` | number | yes | |
2582
+ | `record_ids` | string[] | yes | |
2583
+
2430
2584
  ## lookup
2431
2585
 
2432
2586
  ### lookup_business (workflow step)
@@ -2856,7 +3010,7 @@ above. A provider's refusal carries the provider's own words.
2856
3010
  ### outlook_read_email (workflow step)
2857
3011
 
2858
3012
  - Fetch an Outlook email's body and the list of its attachments.
2859
- - Attachments are stored as workspace files — the result names each one's file_id; read one with view_files, attach one to a record with update_records. (chat)
3013
+ - Attachments are stored as workspace files — the result names each one's file_id; read one with view_files, attach one to a record with update_records. (chat, MCP, CLI)
2860
3014
 
2861
3015
  **Inputs**
2862
3016
 
@@ -2894,7 +3048,7 @@ above. A provider's refusal carries the provider's own words.
2894
3048
  | `message.attachments[].fileName` | string | | Attachment filename as stored, which may differ from the sender's |
2895
3049
  | `message.attachments[].contentType` | string | | MIME type as stored, recovered from the filename when Graph declared a generic one |
2896
3050
  | `message.attachments[].size` | number | | Stored size in bytes |
2897
- | `message.attachments[].file_id` | string | | Stored file ID — pass to view_files to read it, or to update_records to attach it (chat) |
3051
+ | `message.attachments[].file_id` | string | | Stored file ID — pass to view_files to read it, or to update_records to attach it (chat, MCP, CLI) |
2898
3052
 
2899
3053
  ### outlook_reply_email (workflow step)
2900
3054
 
@@ -3007,6 +3161,31 @@ above. A provider's refusal carries the provider's own words.
3007
3161
  | `filename` | string | yes | The filename of the generated file, including extension. |
3008
3162
  | `mime_type` | string | yes | The MIME type of the generated file. |
3009
3163
 
3164
+ ## request
3165
+
3166
+ ### request_locked_record_change (workflow step)
3167
+
3168
+ - Submit one approval request to change locked field values on a record.
3169
+ - Include every blocked field in the same call — the approver accepts or rejects the change set as a whole.
3170
+ - Values are NOT changed immediately; an approver must accept the request first.
3171
+ - Returns `{ approval_request_id, status }`.
3172
+
3173
+ **Inputs**
3174
+
3175
+ | Name | Type | Required | What it is |
3176
+ | --- | --- | --- | --- |
3177
+ | `table_id` | string | yes | |
3178
+ | `record_id` | string | yes | |
3179
+ | `changes` | object | yes | Map of field_key to proposed new value. Every key must be locked on this record. |
3180
+ | `reason` | string | yes | |
3181
+
3182
+ **Answer**
3183
+
3184
+ | Name | Type | Required | What it is |
3185
+ | --- | --- | --- | --- |
3186
+ | `approval_request_id` | string | yes | |
3187
+ | `status` | "pending" | yes | |
3188
+
3010
3189
  ## save
3011
3190
 
3012
3191
  ### save_file (workflow step)
package/docs/workflows.md CHANGED
@@ -7,25 +7,50 @@ what to write instead. A save that succeeds may still return `warnings` — advi
7
7
  loop that may never end. Read them.
8
8
 
9
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
10
+ tables and apps, members, knowledge, skills, editing a file — is an agent's, and a body that
11
11
  calls one is refused at save with that reason. One saved before is refused at run, before its first step.
12
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.
13
+ The same grammar serves a table's workflow, an automation and an app's workflow, which `set_workflow` saves, and the
14
+ expressions of a table check.
16
15
 
17
16
  ## Triggers
18
17
 
18
+ Every workflow's source opens with `on({ ... })`, which states what starts it. `set_workflow` saves the source, and
19
+ `get_workflow` reads it back with whether it is on and how its latest runs ended. `get_workflow` without a
20
+ `workflow_id` lists the workflows you may read, a page at a time (`limit` 1 to 100, `offset`, `total`), each with its
21
+ `kind`, its trigger in a line, whether it is on and its failed runs of the last 7 days; `table_id` and `app_id` keep the
22
+ ones that follow or name them.
23
+
24
+ | kind | header |
25
+ |---|---|
26
+ | table | `on({ type: "create" \| "update" \| "delete", table: "<alias>", changed?: ["<alias>", ...], when?: "<expression>" })` |
27
+ | automation | `on({ type: "recurring_schedule", cron_expression })`, `receive_webhook`, `receive_gmail_email` or `receive_outlook_email` — see **Automations** |
28
+ | app | `on({ type: "app", app_id, alias, inputs?, outputs? })` — `inputs` and `outputs` only for a custom-code app |
29
+
30
+ What starts a workflow is fixed once it is made: a table workflow keeps its trigger type and its table, an app workflow its
31
+ app and alias. To change either, delete the workflow and make another. A replace states the whole trigger, so a key the
32
+ header leaves out is removed (an app workflow keeps the inputs it leaves out, and derives the outputs it leaves out from
33
+ what the body returns). `expected_version` refuses a replace when the workflow has moved since the `version` you read.
34
+ `dry_run` checks the source and stores nothing; given the trial inputs its trigger supplies, it also walks the steps once
35
+ and returns the calls planned, the return value, the failures and the papers rendered. A table trigger takes `record_id`,
36
+ `prev_data` and `changes`; a webhook or email trigger `trigger_payload`; an app trigger `record_id` and `preview_inputs`; a
37
+ table or automation trigger also `live_reads` (an app's rehearsal always reads real data). A trial input its trigger does
38
+ not supply is refused, naming the ones it takes. A rehearsal needs write scope: a key that reads data alone cannot make one.
39
+
40
+ `delete: true` with a `workflow_id` removes a workflow, and is the one call that asks for approval; a replace asks for none,
41
+ a step it drops included. `even_if_invoked` and `acknowledge_breaking_api_change` belong to an app workflow and are
42
+ refused for another kind.
43
+
19
44
  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
45
  an Act's `workflow` receives `record_id` (`record_ids` on an act over several rows) and what the Act asks.
21
46
 
22
47
  ### Automations
23
48
 
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
49
+ An automation answers an event outside the records: a schedule, a webhook, an inbound email. `set_workflow` saves one
50
+ from its source — the `on({...})` declaration, then its steps — and, given its `workflow_id`, replaces it: the
26
51
  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
52
+ only when the call turns it on (`enabled`), so one a later rule refuses can still be switched off. An admin names the owner at creation with
53
+ `owner_member_id`. It runs with its owner's current permissions, a connected service it names is its owner's account, and only its
29
54
  owner or an admin reads, changes or deletes it.
30
55
 
31
56
  ```
@@ -137,13 +162,13 @@ Only `x` comes back: in text mode `x` is the string itself, in object mode `x.fi
137
162
 
138
163
  ## Naming tables and templates
139
164
 
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.
165
+ A body names a table or document template by its alias (`query_records({ table_id: "orders" })`); ids still work. `get_workflow` shows aliases. An app's workflow reaches only the tables the app uses.
141
166
 
142
167
  ## Fields and options
143
168
 
144
169
  1. **A body names a table's fields and select options by alias**, the names the table's document states: a field reads
145
170
  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
171
+ body of a Workflow node, of an Act's own `workflow`, of an automation and of a table's workflow (`set_workflow`). It is stored by key and `get_workflow` shows it by alias
147
172
  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
173
  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
174
  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,
@@ -186,7 +211,7 @@ A body names a table or document template by its alias (`query_records({ table_i
186
211
  ## Paths
187
212
 
188
213
  - `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
214
+ - A path may start at any expression: an awaited call `(await query_records({ table_id: "orders", record_ids: [id] })).records[0].data["x"]`, a helper's
190
215
  result `first(found.records).id`, a filter `rows.filter((r) => r.ok)[0].n`, a `(a ?? b).c`. `?.` reads
191
216
  through a null as usual, except on a value drawn at random (`sample(xs)?.id` is refused: bind it first).
192
217
  - A record reads in its declared shape wherever it comes from — the trigger, a tool result, a `let`, a
@@ -375,7 +400,7 @@ const found = await query_records({
375
400
  filters: { node_type: "condition", field_key: "name", operator: "equals", value: "ACME Corp" },
376
401
  });
377
402
  const customer = first(found.records);
378
- const order = await get_record({ table_id: "orders", record_id: trigger.app_workflow.inputs.order_id });
403
+ const order = (await query_records({ table_id: "orders", record_ids: [trigger.app_workflow.inputs.order_id] })).records[0];
379
404
 
380
405
  if (customer && includes(order.data["customer"], customer.id)) {
381
406
  // …
@@ -433,14 +458,11 @@ by the same fields take turns, so the second matches the rows the first added. T
433
458
  `workflow_steps` states its bounds.
434
459
 
435
460
  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.
461
+ under the member's own access to the table and no workflow does. A member's agent puts an import back with
462
+ `update_records`: `query_audit_logs` on the table shows what the run wrote — the run is what `runtime.execution_id` held in
463
+ it, which a body keeps on a row of its own — and each field's value before it, `update_records` writes that value back as
464
+ the member's own write, `update_records` with `restore: true` brings back a row the run moved to the trash, and
465
+ `delete_records` removes a row the run created. Write a field back only where the row still holds what the run wrote.
444
466
 
445
467
  What a business keeps of an import — the file, its counts, the lines waiting for a person — is rows of
446
468
  its own tables, written by the workflow that ran the step. A garden centre brings its grower's price
@@ -524,7 +546,7 @@ lets it write over what the plant holds. A cell the line holds none of is left o
524
546
  never empties a cell of the plant:
525
547
 
526
548
  ```
527
- const line = await get_record({ table_id: "sheet_lines", record_id: trigger.app_workflow.inputs.record_id });
549
+ const line = (await query_records({ table_id: "sheet_lines", record_ids: [trigger.app_workflow.inputs.record_id] })).records[0];
528
550
  const values = {
529
551
  code: line.data["code"],
530
552
  ...(isEmpty(line.data["name"]) ? {} : { name: line.data["name"] }),
@@ -545,8 +567,8 @@ await update_records({ table_id: "sheet_lines", record_ids: [line.id], set: { ou
545
567
  return({ status: "success", message: "Applied." });
546
568
  ```
547
569
 
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.
570
+ A sheet is put back by a member's agent with `update_records` over the plants its lines name, 50 at a time, from the values
571
+ `query_audit_logs` shows for the run the sheet kept.
550
572
 
551
573
  A shop matches a bank statement to the notes it is owed by an act that states `review`. That act's workflow only
552
574
  proposes: its body calls steps that read and no other, and runs no agent — either is refused when the body is saved —
@@ -681,10 +703,10 @@ nothing to an app's run:
681
703
  }
682
704
  ```
683
705
 
684
- 3. `set_app_workflow` with the app, the alias `invoice_order` and this `source` (the step's entry is in `workflow_steps`):
706
+ 3. `set_workflow` with a `source` that opens with `on({ type: "app", app_id: "app_…", alias: "invoice_order" });` and this body (the step's entry is in `workflow_steps`):
685
707
 
686
708
  ```
687
- const order = await get_record({ table_id: "orders", record_id: trigger.app_workflow.inputs.order });
709
+ const order = (await query_records({ table_id: "orders", record_ids: [trigger.app_workflow.inputs.order] })).records[0];
688
710
  const made = await kiotviet_create_invoice({ // a workflow step
689
711
  connected_account_id: "cac_…",
690
712
  branch_id: 1,
@@ -798,40 +820,25 @@ any app. An effect that must hold for all of them belongs to the table. A button
798
820
  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
821
  committed and cannot refuse it.
800
822
 
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.
823
+ `set_workflow` writes it: the `source` opens with `on({ type, table, ... })` and carries the steps, and `enabled: false`
824
+ keeps the workflow without running it (absent, it runs). `get_table` lists a table's workflows to whoever manages the
825
+ table — `workflow_id`, name, trigger, whether it is on and its failed runs of the last 7 days — and `get_workflow` reads one
826
+ back. The table and the trigger type (`create`, `update` or `delete`) are fixed once the workflow is made: to change
827
+ either, delete it and make another. A replace that changes only the gate, the trigger config or `enabled` moves the
828
+ workflow's version. `dry_run` with a `record_id` (and `prev_data` and `changes` for an update) says what the workflow would
829
+ write on that row; reads are real and nothing is written.
805
830
 
806
- | part | what it holds |
831
+ | part of the header | what it holds |
807
832
  |---|---|
808
- | `on` | the write it follows: `create`, `update` or `delete` |
833
+ | `type` | the write it follows: `create`, `update` or `delete` |
834
+ | `table` | the table's alias |
809
835
  | `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 |
836
+ | `changed` | optional, with `type: "update"`: field aliases; the workflow runs only after an update that changes any one of them |
813
837
 
814
- ```json
815
- {
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
- ]
834
- }
838
+ ```
839
+ on({ type: "update", table: "repairs", changed: ["status"], when: "record.status == \"fixed\"" });
840
+
841
+ await update_records({ table_id: "repairs", record_ids: [trigger.record_id], set: { fixed_on: formatDate(now(), "yyyy-MM-dd") } });
835
842
  ```
836
843
 
837
844
  The body names the table's fields, select options and other tables by alias, and reads the write as `record`,
@@ -839,27 +846,27 @@ The body names the table's fields, select options and other tables by alias, and
839
846
  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
847
  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
848
  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
849
+ read of `record`, `prev_record` or `changes`, a row from `query_records` called with a literal `table_id`, and a
843
850
  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
851
  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
852
 
846
853
  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
854
  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
855
  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
856
+ reads cannot be deleted or retyped until the workflow is changed or deleted with `set_workflow` before. Cloning a table copies its
850
857
  workflows for whoever manages it (`include_workflows: false` leaves them), each with a body of its own, and is refused when a
851
858
  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
859
  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
860
  it is compared to, listed against or set on its field; a constant holding one is refused.
854
861
 
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.
862
+ A lock is the table's: a table workflow locks a row with `update_records` and `locked: true` (an app's act may too), and every door then refuses
863
+ the row's writes until whoever manages the table unlocks it with `update_records` and `locked: false` — in an app's run, judged as the member who pressed.
857
864
  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
865
 
859
866
  ## Runs
860
867
 
861
868
  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
869
+ reads, retries and stops runs. A run is read by whoever manages what its workflow belongs to: its
863
870
  automation or its table, and for an app's workflow the app's owner or an admin, never a manager of the app.
864
871
 
865
872
  - **Find.** `query_workflow_executions` lists one workflow's runs (`workflow_id`), every run started for one row
@@ -871,7 +878,7 @@ automation or its table, and for an app's workflow the app's owner or an admin,
871
878
  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
879
  after a run starts, its trigger and its steps' input and output are pruned; the read says so and gives the rest.
873
880
  `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
881
+ - **Retry.** `set_workflow_execution` with `action: "retry"` runs a failed, refused or cancelled run again as a new run linked to the first
875
882
  (`retry_of`): the first run's trigger and row, the workflow's current steps and its owner's current access, with the
876
883
  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
884
  first when the run, or a run it retried, already wrote rows or called a service no delete reverses. An automation's or a
@@ -879,7 +886,9 @@ automation or its table, and for an app's workflow the app's owner or an admin,
879
886
  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
887
  that is off, a reviewed act's run (call the act again) and a recording's filing (the recording retries its delivery)
881
888
  are not run again.
882
- - **Stop.** `cancel_workflow_execution` stops a waiting run and withdraws its pending approvals.
889
+ - **Stop.** `set_workflow_execution` with `action: "cancel"` stops a waiting run and withdraws its pending approvals. Both answer
890
+ `execution_id`, `action`, `status`, `retry_of`, `error` and, for a retry, `original_wrote`: the rows and the irreversible
891
+ calls the first run wrote, which the retry makes again.
883
892
 
884
893
  Off means off: a waiting run whose workflow was turned off, deleted, or taken off its automation, table, button or app ends
885
894
  `cancelled` when it would resume, saying why, and runs no further step.
@@ -7,13 +7,13 @@ Once tables and apps exist, an agent does the team's work through them. The mean
7
7
  | A business operation: move an order on, issue a paper, notify someone | `run_app_workflow` on the app's act, `act_<key>` or the `workflow` it names | The outcome equals a person's press: status, stamps, documents, notifications. `update_records` on an act's field skips all of them. |
8
8
  | Data entry, a correction, loading the owner's lists | `create_records`, `update_records` | The table's rules hold for every writer. |
9
9
  | A rule or policy the business wrote | `get_workspace` with `search`, then `read_knowledge` | The business's own words, not a guess. |
10
- | A file someone hands over | `read_file_content`; `upload_file` to keep it (over MCP or the CLI) | Reads its content; the upload files it in the workspace. When your host passes no bytes, `upload_file` with `person: true` gives the person a link to drop it on. |
10
+ | A file someone hands over | `view_files`; `upload_file` to keep it. A picture reaches a CLI as a note naming `download_file` and `lotics file download <file_id>` | Reads its content; the upload files it in the workspace. When your host passes no bytes, `upload_file` with `person: true` gives the person a link to drop it on. |
11
11
  | A paper an app makes | the act that makes it, which files it on the record | `generate_document` makes an unattached file, for a paper no app makes. |
12
12
  | A connected service (KiotViet, MISA, a carrier) | a workflow step, reached through a Workflow node | No tool calls a service directly (`workflows/reaching-a-connected-service`). |
13
13
  | A figure or list the business defines, or an operation it repeats | the app's declared Query or Workflow node: `run_app_query`, `run_app_workflow` | The node is the business's own definition, found by `get_workspace` with `search` and read in `get_app`. |
14
- | A count or sum no app answers | `aggregate_records` (grouped by `group_by`), `query_records` | |
14
+ | A count or sum no app answers | `query_records` with `aggregate` (grouped by `group_by`), or its rows; `record_ids` reads those records by id | |
15
15
 
16
- Find the means in this order: `get_workspace` maps the workspace; `search` it by what is asked ("overdue"), which matches an app's declared Query and Workflow keys and descriptions and names the matching keys under the app; `get_app` lists that app's queries and workflows with their inputs. Run the declared node. Only a question no node answers goes to `aggregate_records`.
16
+ Find the means in this order: `get_workspace` maps the workspace; `search` it by what is asked ("overdue"), which matches an app's declared Query and Workflow keys and descriptions and names the matching keys under the app; `get_app` lists that app's queries and workflows with their inputs. Run the declared node. Only a question no node answers goes to `query_records`.
17
17
 
18
18
  ## Workspace settings and standing instructions
19
19
 
@@ -21,7 +21,38 @@ Find the means in this order: `get_workspace` maps the workspace; `search` it by
21
21
 
22
22
  ## People
23
23
 
24
- `get_workspace` with kind `member` lists the members. `set_member` is an admin's: `members` is a map keyed by an email address or a member id, where an email no member holds invites that person (`role` is `member` or `admin`; the invitation email goes out), a current member's entry sets `name`, a removed member's entry restores them, and `null` removes a member or cancels the invitation pending to that address. A member's role cannot be changed through it. The whole map is checked before anything is written or sent. A member's tool list does not carry `set_member`, `set_group` or `set_workspace`.
24
+ `get_workspace` with kind `member` lists every member's name and id; an admin also sees each one's email, role, `managed` and `sign_in_name`, and a `unavailable` list (kind `knowledge` or `template`) of the docs and templates the admin cannot open, while usable ones carry `access`. An admin also reads the credit limits there.
25
+
26
+ `set_members` is an admin's: `members` is a map keyed by an email address, a member id or a managed member's sign-in name, and `groups` a map keyed by a group id or the name of a group to create. In `members`, an email no member holds invites that person (`role` is `member` or `admin`; the invitation email goes out), a current member's entry sets `name` and `role`, a removed member's entry restores them and takes only `name`, and `null` removes a member or cancels the invitation pending to that address. A managed member's entry takes a `password` of 8 to 128 characters (sign-in names are compared in lowercase and checked for uniqueness across Lotics before any write), and the result carries the `sign_in_name`. The organization keeps at least one admin: the last one cannot be demoted or removed. In `groups`, an entry sets `name`, `description` and the roster with `member_ids`, or with `add_member_ids` and `remove_member_ids` that leave the rest in place; a roster entry may be a key of `members`, so a person invited in the same call can join; `null` deletes the group. The whole input is checked before anything is written or sent; members are applied before groups, in the order given, and the caller's own step-down last. A member's tool list does not carry `set_members` or `set_workspace`.
27
+
28
+ `set_access` gives members, groups or the whole organization a role on resources. Role `owner`, on one member, hands every resource listed to that member, whom only the resource's owner or an admin may name; a call cannot share an app and hand it over together, an app handed over loses its sharing, and an automation is turned off until its new owner turns it on. Handovers are applied one resource at a time, and a failure part-way names what was already applied. `set_workspace` takes `credit_limits` (`workspace`, `apps` by app id, `members` by member id; a number is the daily ceiling in credits, `null` removes it and asks for approval).
29
+
30
+ ## Comments
31
+
32
+ Every record read carries `comment_count`. `query_records` takes `comments: true` to return the thread (`limit` defaults to 20, 20 at most). `set_record_comments` writes in one transaction, 1 to 20 entries: an entry without `comment_id` creates, one with it edits, and `content: null` deletes. Only a comment's author edits or deletes it.
33
+
34
+ ## Deleting and restoring
35
+
36
+ A keyed entry is removed by `null`: a field, check, view, option, comment, instruction, query alias, a member or a group. A resource addressed by its id is deleted with `delete: true` and brought back with `restore: true`, beside its id: a table in a `set_tables` item (`{ table_id, delete: true }`; `cascade` deletes the links that point at the deleted tables and is refused unless the call deletes; `force` goes with a patch that loses stored values and is refused on a call that only deletes or restores), a knowledge doc (`set_knowledge` with `knowledge_doc_id`), a template (`set_template` with `template_id`), an app (`set_app` with `app_id`) and a workflow (`set_workflow` with `workflow_id`; a deletion only). Records are deleted by `delete_records` and brought back by `update_records` with `restore: true` and `record_ids` or `filters`. `delete` takes no other key. A call that deletes asks for approval; one that does not never does.
37
+
38
+ ## Locking and changing a locked record
39
+
40
+ `update_records` with `locked: true` locks the targets (write access) and `locked: false` unlocks them (the table's manager). Beside field edits, a record that is locked before and after the call is refused into `blocked`; an unlock lands before the edit and a lock after it. `request_change: { reason }` files one approval request per blocked record instead, carrying the values the call resolves, and lists them in `requested` as `{ record_id, approval_request_id }`; the unlocked targets of the same call are written, and the call and its requests are one transaction, so a request that cannot be filed leaves them unwritten too.
41
+
42
+ ## Undoing a write
43
+
44
+ A write is put back by writing the value `query_audit_logs` shows before the change: `update_records` for a field, `update_records` with `restore: true` for a deleted record, `delete_records` for a record a run created.
45
+
46
+ ## Files
47
+
48
+ `view_files` reads what a file says (up to 100 by id, or the files of a record's file field with `source`). Inside an app's agent only the `file_ids` a query returned may be opened, and `source` is refused. `download_file` returns a `download_url` and, for a caller on a key or a connector, the bytes (up to 512 KB; `bytes_omitted` says why they are absent). In the in-product chat the `download_url` is the stable address a signed-in member opens, so it is for the person, not a link a tool fetches.
49
+
50
+ `upload_file` names one source:
51
+
52
+ - `url`: a public http(s) address the server fetches.
53
+ - `file`: the `{download_url, file_id}` reference a host hands over for a member's attachment (a ChatGPT file parameter arrives hydrated into this shape). Never construct one.
54
+ - `files`: bytes you hold locally. Each entry gets an `upload_url`, valid for 5 minutes, on the same host as your other calls; PUT the bytes to it and the response is the `file_id`. Where your sandbox allows no outbound request to that host (in Claude, a Team or Enterprise owner lists it under Admin settings → Capabilities), the PUT fails before it leaves: give the person the file with `person: true` instead.
55
+ - `person: true`: returns a `drop_url`, valid for an hour, to give the person, and a `ticket_id` per file; once they say they dropped the files, call again with each `ticket_id` for its `file_id`.
25
56
 
26
57
  ## Doing an operation
27
58
 
@@ -43,8 +74,8 @@ tables, loaded this way after `set_tables`.
43
74
  `params` returns it; `filters`, `fields`, `sort` and `count` narrow the result.
44
75
  2. A period's total is one call per period, each with `from` and `to` or a date filter. `aggregate` groups a result by
45
76
  a column (`by`), a number's bands (`bands`) or a date column's day (`day`), and sums one (`sum`).
46
- 3. A question no app answers is `aggregate_records` (counts and sums grouped by a field) or `query_records`, over one
47
- table; `filters` is the grammar of both.
77
+ 3. A question no app answers is `query_records` over one table: its rows, or with `aggregate` counts and sums grouped by a
78
+ field; `filters` is the grammar of both.
48
79
 
49
80
  ## A worked day
50
81
 
@@ -56,5 +87,5 @@ get_app app_id → aliases: order_list,
56
87
  run_app_query app_id, alias "order_list", params {"search": "1042"}
57
88
  run_app_workflow app_id, alias "act_ship", inputs {"record_id": "rec_…"}, dry_run true
58
89
  run_app_workflow the same call without dry_run
59
- aggregate_records table_id, group_by "stage" → how many orders stand at each stage now
90
+ query_records table_id, aggregate {group_by "stage", count} → how many orders stand at each stage now
60
91
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/cli",
3
- "version": "0.316.19",
3
+ "version": "0.316.21",
4
4
  "description": "Lotics SDK and CLI for AI agents",
5
5
  "type": "module",
6
6
  "bin": {