@lotics/cli 0.306.1 → 0.307.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,254 @@
1
+ # An app's agent surface
2
+
3
+ Everything a person reads or does in an app, an agent reads or does through that app's **queries** and
4
+ **workflows** — the same ones its screen runs, under the same permissions and the same rules. Three tools
5
+ reach them, over MCP and as `lotics run <tool>`:
6
+
7
+ | Tool | Answers |
8
+ |---|---|
9
+ | `get_app_capabilities` | The app's queries and workflows by alias: each one's description, its params or inputs with their types and legal values, and under a query's line its output columns (`returns`) — beside what each table they name holds, in its owner's words |
10
+ | `run_app_query` | The rows, a count or the groups of one query — or of several in one call |
11
+ | `run_app_workflow` | One write — or several in order — or a preview of it that writes nothing |
12
+
13
+ An app stated in a model generates this surface from the model; the model declares what the screen never
14
+ asks (§ Declaring more). An app's logs (`logs`) are part of it: a log's list, its rows by id, its record's
15
+ blocks and its acts are queries and workflows of the same app, named by the log's entity as the register's
16
+ are by its own — `get_app_capabilities` lists them in the one roster, and nothing of a log is another app's. What only a log reads or runs answers the organization's own members alone, never a caller through the app's public link. A custom-code app's surface is the queries and workflows bound on it
17
+ (`lotics docs app_bindings`).
18
+
19
+ ## What every app generates
20
+
21
+ A name in angle brackets is the model's alias for that thing. `get_app_capabilities` lists the ones a given
22
+ app has.
23
+
24
+ ### Queries
25
+
26
+ <!-- generated:start queries -->
27
+
28
+ | Called | What it says of itself | Takes | Generated for |
29
+ |---|---|---|---|
30
+ | `<entity>_list` | Every row of <entity> the reader may see. | `search`?; `<scope entity>_id` where the app states `scope`; `<filter>_is`? per `register.filters` field holding one value — a select, a one-row link, a member, a yes/no; `<filter>_from`? / `<filter>_to`? per date filter — none for a date holding a span; `<filter>_min`? / `<filter>_max`? per number filter — none over `books`, none for a looked-up link or member, none for a number read in each row's own unit or currency | every entity the app draws; the filter params on the app's own entity alone |
31
+ | `<entity>_list_<tab>` | The <entity> rows of the tab "<tab>". | the list's; `from`? / `to`? on a tab dated `in` a period; `to` on a tab of rows `open` on a day | each of `register.tabs`; none over `books`, none for a tab whose period reads a date holding a span |
32
+ | `<entity>_record` | One row of <entity> by its id. | `<entity>_id` | every entity the app draws |
33
+ | `<child>_by_<link>` | The <child> rows under one <entity>, through "<link>". | `<entity>_id` | each block of child rows on the record; one narrowed by `where` or ordered by `sort` adds `_<field>_<option>` and `_sorted_<field>` to its name |
34
+ | `<child>_by_<link>_days` | The <child> rows of the <entity> rows handed in whose "<day>" falls from one day to another. | `<entity>_ids`; `from`; `to` | `register.roster` |
35
+ | `<entity>_<link>_pick` | The <target> rows "<link>" may point at, narrowed by what the reader typed. | `search`?; `same_<link>` per `same` link | a written link a write rule narrows (`options_where`, `same`) |
36
+ | `<child>_by_<link>_picks` | The <child> rows no <entity> holds through "<link>" yet, that one <entity> may pick, narrowed by what the reader typed. | `search`?; `same_<link>` per `same` link | a block that picks existing rows into the record |
37
+ | `register_reading_<n>` | <label>: how many <entity> rows there are. Each row carries `__count`. | the list's; `from`? / `to`? where the reading states `over`, or its `tab` is dated `in` a period; `to` where its `tab` is of rows `open` on a day | each of `register.readings` by its place among them, counted from 0 — but the work against its plan (`progress`), which reads the list's own rows; none over `books`. A reading stating `alias` is called by it |
38
+ | `dashboard_reading_<n>` | <label>: how many <entity> rows there are. Each row carries `__count`. | `from`? / `to`? where the reading states `over` | each of a dashboard's readings, counted from 0; a reading stating `alias` is called by it |
39
+ | `block_reading_<n>` | <label>: how many <child> rows there are. Each row carries `__count`. | `<entity>_id` | each reading or funnel block of the record, by its place among the record's blocks, counted from 0; a reading stating `alias` is called by it |
40
+ | `<entity>_register_reading_<n>` | <label>: how many <entity> rows there are. Each row carries `__count`. | the list's; `from`? / `to`? where the reading states `over` | each of a log's `register.readings` by its place among them, counted from 0, named by the log's entity; a reading stating `alias` is called `<entity>_<alias>` |
41
+ | `<entity>_block_reading_<n>` | <label>: how many <child> rows there are. Each row carries `__count`. | `<entity>_id` | each reading or funnel block of the record a row of a book or of a log opens, by its place among that record's blocks, counted from 0, named by the book's or the log's entity |
42
+ | `<reading>_bound` | <label> / <bound>: the sum of "<bound>" over the <entity> rows. Each row carries `__count`, `__sum`. | its reading's | a metric whose value `records.limits` bounds: the bound's sum, beside the reading's own |
43
+ | `<entity>_export_rows` | Every row of <entity> the reader may see, with every field "<template>" may print. | the list's | a `register.export` that fills a template |
44
+ | `<entity>_export_rows_of_<book>` | Every row of <entity> the reader may see, with every field "<template>" may print. | the list's | a report of some of an app's `books`: their rows alone, each book named in turn |
45
+ | `<entity>_moves` | Each move in <history> of the <entity> rows handed in: the status it went to, when and by whom, earliest first. | `<entity>_ids` | an entity whose status keeps a history (`records.status.history`) |
46
+
47
+ <!-- generated:end queries -->
48
+
49
+ A reading's query returns groups, never the rows it read: one row for all of them, or one per value it
50
+ splits by, or per day. Each row carries the alias of each field it splits by, `__day` where it splits by
51
+ day, `__count`, and `__sum` where it sums a number; its description states which. An average is `__sum`
52
+ over `__count`, both over the rows holding a value.
53
+
54
+ A reading that states no `alias` is called by its place, so moving it in the model changes the name it is
55
+ called by. A place that generates no query keeps its number, so the names may skip one. A reading an agent
56
+ or a published API calls states its `alias` (`lotics docs model/readings`). A log's reading is called by the
57
+ log's entity before either name: `<entity>_register_reading_<n>`, or `<entity>_<alias>`.
58
+
59
+ A register reading's query reads the rows it names, whatever a screen has open: the rows of its `tab` where
60
+ it states one, every tab's where it states none — its description says which. One placed in time (`over`)
61
+ reads every day unless the call states `from` or `to`.
62
+
63
+ ### Queries the screen runs for itself
64
+
65
+ They run like any other, and `get_app_capabilities` leaves them out: each feeds one control, and takes ids
66
+ the screen already holds.
67
+
68
+ <!-- generated:start screen-queries -->
69
+
70
+ | Called | What it says of itself | Takes | Generated for |
71
+ |---|---|---|---|
72
+ | `<child>_<link>_target` | One <entity> row by its id, where a <child> row's "<link>" may point at it. | `<entity>_id` | a picking block whose link a write rule narrows |
73
+ | `<entity>_<field>_values` | Each "<field>" the <entity> rows hold, once, most held first, narrowed by what the reader typed. | `search`? | a text field a write rule offers values to (`suggest`) |
74
+ | `<entity>_pictures` | The picture and name of each <entity> row among the ids handed in. | `<entity>_ids` | a link to several pictured rows |
75
+ | `<entity>_count_by_<scope>` | How many <entity> rows each <scope entity> holds. | | an app stating `scope` |
76
+ | `<task>_progress_by_<link>` | How many <task> rows each <entity> row handed in holds, at each status. | `<entity>_ids` | rows whose task rows are counted done |
77
+ | `<rows>_measured_by_<link>` | The share of each <entity> row handed in its <rows> rows add, day by day. | `<entity>_ids` | a `progress` reading stating `measured` |
78
+ | `<lane>_lanes` | The <lane> rows of one <scope entity>, each a lane of the board. | `<scope entity>_id` | a lanes board inside an app stating `scope` |
79
+ | `<entity>_same_<link>` | The <entity> rows naming one "<link>". | `same`; `before` where its line reads rows before a moment | a statement line that carries rows (`parts`) |
80
+
81
+ <!-- generated:end screen-queries -->
82
+
83
+ ### Workflows
84
+
85
+ <!-- generated:start workflows -->
86
+
87
+ | Called | What it says of itself | Takes | Generated for |
88
+ |---|---|---|---|
89
+ | `create_<entity>` | Opens one row of <entity>. | every field the add asks, and the link it files the row under | an entity the app adds rows of |
90
+ | `update_<entity>` | Changes one row of <entity>; every input but the row is optional, and one left out is left as it is. | `record_id`, and each field a person edits — a files field or a link to several rows as `<field>_added` / `<field>_removed` | an entity the app edits |
91
+ | `remove_<entity>` | Removes one row of <entity>. | `record_id` | an entity the app removes rows of |
92
+ | `act_<act>` | <label> — on one row of <entity>, refused where its conditions do not hold. | `record_id` — `record_ids` on an act over several rows — and what the act asks | each of `acts` that names no workflow of its own |
93
+ | `archive_<act>` | Hands back every file of <label> on one row of <entity> as one archive. | `record_id` | an act making a set of papers (`templates`) |
94
+ | `record_activity` | Lists one record's activity, newest first: each save with its fields before and after and who made it, and whether the caller may put it back. `next` is the `after` of the next page. | `record_id`; `after`? | every register app, unless `record.activity` is `false` |
95
+ | `record_activity_undo` | Puts back one change entry of a record's activity, as the caller's own save, each field where the record still holds what the entry wrote. The fields kept are handed back with why. | `record_id`; `entry_id` | every register app, unless `record.activity` is `false` |
96
+ | `<workflow>_<book>` | The workflow of that name as one of the app's `books`, or a log of it, runs it, where its own table or page makes it differ. | that workflow's | an app stating `books` or `logs` |
97
+
98
+ <!-- generated:end workflows -->
99
+
100
+ A workflow the model declares (`workflows`) and an act that names its own are called by the alias the model
101
+ gives them.
102
+
103
+ ## Calling a query
104
+
105
+ ```
106
+ lotics run get_app_capabilities '{"app_id":"app_…"}'
107
+ lotics run run_app_query '{"app_id":"app_…","alias":"hire_list","params":{"status_is":"opt_…","due_back_to":"2031-03-14"}}'
108
+ ```
109
+
110
+ | Argument | Does |
111
+ |---|---|
112
+ | `params` | The query's declared params. A required one is always sent; an optional one left out narrows nothing. A select takes an option's value, a link a record id, a member a member id, a date `yyyy-mm-dd` |
113
+ | `filters` | Narrows the result further, by the query's output columns (`lotics docs filters`) |
114
+ | `fields` | The output columns to return, in place of all of them; each row then holds them and the id of the record it is |
115
+ | `sort` | An order in place of the query's own: `[{ "field_key": "<column>", "order": "asc" }]`, `asc` or `desc`, each `field_key` an output column's name |
116
+ | `count` | `true`: `total`, the number of rows, in place of the rows; with `count_by`, `counts` per value of one column, keyed by an option's label, a member's name or a linked row's display; with `count_where` — a map of name → filter, never beside `count_by` — `counts` per name. Never with `fields`, `sort`, `limit` or `cursor` |
117
+ | `aggregate` | Groups of the result in place of its rows, one row per group: `by` (up to 3 columns), or `day` (a date column, answered as `__day`), and `sum` (a number column, answered as `__sum`), each group with its `__count`. Sent with `params` and `filters` alone |
118
+ | `limit`, `cursor` | A page — 200 rows at most, 50 where no `limit` is stated — and the `next_cursor` of the page before it; `truncated` says more rows matched |
119
+ | `runs` | Several reads in one call, 20 at most, each with its own `alias` and arguments, answered in order under `runs`. Never beside an `alias` of the call's own |
120
+
121
+ The rows of one call, across its reads, total at most 262,144 bytes: a read past that is refused, naming
122
+ `fields` and `limit` as what narrows it. The names `filters`, `fields`, `sort`, `count_by` and `aggregate`
123
+ take are the ones on the query's `returns` line.
124
+
125
+ A list's params are how a person narrows that register: `search`, the row's id on an app that works inside
126
+ one row (`scope`), and a param for each filter the register states that one value or a range narrows. A
127
+ filter with no param — on a looked-up link or member, on a number read in each row's own unit or currency,
128
+ on a date holding a span, or on a register of several tables (`books`) — is narrowed by `filters`, where a
129
+ number condition names the unit its value is in as `unit_option`, an option of the figure's select by label
130
+ or key. Each tab of a register is a query of its own, taking the list's params and the period its rows are
131
+ read over.
132
+
133
+ ## Calling a workflow
134
+
135
+ ```
136
+ lotics run run_app_workflow '{"app_id":"app_…","alias":"act_check_out","inputs":{"record_id":"rec_…"},"preview":true}'
137
+ lotics run run_app_workflow '{"app_id":"app_…","alias":"act_check_out","inputs":{"record_id":"rec_…"}}'
138
+ ```
139
+
140
+ | Argument | Does |
141
+ |---|---|
142
+ | `inputs` | The workflow's declared inputs. An update takes the record and only the fields that change |
143
+ | `preview` | `true`: nothing is written, and the answer is what the run would return, with the calls it would make — or `status: "not_previewed"` where the workflow cannot be previewed (§ A preview) |
144
+ | `runs` | Several runs in one call, 50 at most, in order, each its own write, answered under `runs`. The first one refused, failed or not previewed ends the call. An input `{"from_run": 0, "path": "data.record_id"}` is an earlier run's answer at that path. A run states its own `preview`, or takes the call's |
145
+
146
+ ```
147
+ lotics run run_app_workflow '{"app_id":"app_…","runs":[
148
+ {"alias":"create_hire","inputs":{"customer":"rec_…","machine":"rec_…","due_back":"2031-03-14"}},
149
+ {"alias":"act_check_out","inputs":{"record_id":{"from_run":0,"path":"data.record_id"}}}
150
+ ]}'
151
+ ```
152
+
153
+ ## A preview
154
+
155
+ A previewed run makes every read the run would make and none of its writes. Its answer carries `status`,
156
+ `message`, `field_errors` and `data` as the run would return them, and under `preview`:
157
+
158
+ | Key | Holds |
159
+ |---|---|
160
+ | `available` | `true` |
161
+ | `writes` | The calls the run would make that are not reads, in order. The app's owner and an admin, who may read the workflow's body, read each call's `step_id`, `tool_name` and `input`. Another member reads each step that would write once: its `step_id` and `tool_name`, and where the step states them outright, the `table_id` it names and how many `records` it lists |
162
+ | `unpredicted` | The parts of the answer left out — `message`, `field_errors`, `data` — because each reads what a call the preview did not make would answer |
163
+
164
+ A preview says nothing of a call its target refuses when it is made — a locked record, a required field, a
165
+ permission — nor of what a write sets off in turn: a table's own workflows, its computed fields.
166
+
167
+ A workflow is not previewed where its body reads the answer of a call the preview does not make, to branch,
168
+ loop, check or feed another call, where it reads a linked record after such a call, where it runs an agent,
169
+ where it waits, where a step cannot be evaluated or sends its call an input the call refuses, and where the
170
+ run is past what one preview walks — its steps, the passes of a loop, its reads or the time it takes. The
171
+ answer is then `status: "not_previewed"` with
172
+ `preview: { "available": false, "reason" }`, and nothing ran — within `runs`, it ends the call. The same
173
+ answers a caller outside the app's organization.
174
+
175
+ ## A refusal
176
+
177
+ A workflow re-checks on the server every rule the screen shows: what an act requires, a bound, a duplicate,
178
+ whose step it is, a row the caller may not see. A run that is refused writes nothing and answers
179
+ `status: "error"` with the sentence a person would read as `message`, and under `field_errors` the sentence
180
+ for each input it was refused over. A value of the wrong type, a missing required input and an unknown
181
+ alias are refused before the body runs: the call itself fails with the sentence, and `field_errors` where
182
+ an input is at fault — within `runs`, a run refused for its inputs answers `status: "error"` in its place,
183
+ and the runs before it stand. A refused read fails the same way, naming the param or the column; within
184
+ `runs`, it answers its `error` and the other reads are still answered.
185
+
186
+ ## Declaring more
187
+
188
+ What a person would ask an agent in the app, and no screen draws, is stated in the model beside the app's
189
+ acts. Both are checked by `check_model` and compiled by `apply_model` (`lotics docs model/queries`, `lotics docs model/workflows`).
190
+
191
+ **A query** — the rows of one entity, or one figure of them, in the model's own aliases:
192
+
193
+ ```jsonc
194
+ "queries": {
195
+ "hires_overdue": {
196
+ "description": "A customer's hires still out past the day they were due back, longest overdue first.",
197
+ "params": {
198
+ "customer": { "type": "record_link", "entity": "customer", "required": false, "description": "Only this customer's hires." },
199
+ "as_of": { "type": "date", "description": "The day a hire is overdue on: due back before it." }
200
+ },
201
+ "rows": "hire",
202
+ "fields": ["customer", "machine", "due_back", "daily_rate"],
203
+ "where": { "status": ["out"], "customer": { "param": "customer" }, "due_back": { "to": { "param": "as_of" } } },
204
+ "sort": { "field": "due_back" }
205
+ }
206
+ }
207
+ ```
208
+
209
+ It reads under the entity's read scope and, on the app's own entity, exactly the rows its list reads —
210
+ so never the entity of an app that states `books`, whose list reads several tables. A condition reading an
211
+ optional param narrows nothing when the call leaves it out; no param compares a number read in each row's
212
+ own unit or currency, or a date holding a span. `"reading"` in place of `"fields"` returns a count or a sum —
213
+ `{ "value": "daily_rate", "by": "machine" }` — as a reading does. A declared query, a declared workflow and
214
+ a reading's `alias` take no name the app generates (`lotics docs model/app.generated-name`).
215
+
216
+ **A workflow** — a write no act of the record makes, typed in the model and its body set on the app:
217
+
218
+ ```jsonc
219
+ "workflows": {
220
+ "import_bookings": {
221
+ "description": "Books every hire a booking sheet lists, reusing a hire the sheet already booked.",
222
+ "inputs": {
223
+ "sheet": { "type": "file", "description": "The booking sheet, one hire per row." },
224
+ "booked_by": { "type": "member", "required": false, "description": "Who the hires are booked by; absent, the caller." }
225
+ },
226
+ "outputs": { "added": { "type": "number" }, "reused": { "type": "number" } }
227
+ }
228
+ }
229
+ ```
230
+
231
+ `lotics run set_app_workflow` sets its body (`lotics docs workflows`). A query set on a model's app outside
232
+ the model (`set_app_queries`) is removed by the next apply, whose plan and answer note it; declared under
233
+ its alias in `queries`, it is the model's. A caller sees the description and
234
+ each input's `description` and nothing else, so each says what the value is and what leaving it out means;
235
+ `check_model` notes one that says nothing.
236
+
237
+ **Shape each for a caller that is not looking at a screen.** Take a list where one job covers many records.
238
+ Make a write survive running twice. Return the ids of what was written under `outputs`, so the next run can
239
+ read them. Gate what cannot be undone behind an approval step.
240
+
241
+ ## Prove an app as an agent
242
+
243
+ Run on a throwaway record.
244
+
245
+ 1. `get_app_capabilities` — every job the app's `description` names has an alias that does it or reads it.
246
+ 2. `run_app_query` the list with no param, then with each param a person would narrow by; `count: true`
247
+ answers how many without the rows.
248
+ 3. `run_app_query` `<entity>_record` with one row's id, and each `<child>_by_<link>` under it.
249
+ 4. `run_app_workflow` an act with `preview: true`, then without.
250
+ 5. `run_app_workflow` an act its record is not ready for: the answer is `status: "error"` with the sentence
251
+ the screen would show.
252
+ 6. `run_app_workflow` `record_activity` for the record, then `record_activity_undo` with the entry the act
253
+ wrote: each field the act changed reads as it did before step 4, but one written since, which `kept`
254
+ names. A row the act added under the record and a file it made stay.
@@ -16,8 +16,8 @@ workflow wrote while you tried it, stay where they are. Try a write on a throwaw
16
16
  ## 1 — Two kinds of app
17
17
 
18
18
  - **An app stated in a model** — the default, and the right one for almost every job. `model.json`
19
- says how a row of each entity is recognised (`records`) and, in `apps`, one register over one
20
- entity and the record its rows open: its columns and filters, its sections in the order its work
19
+ says how a row of each entity is recognised (`records`) and, in `apps`, one working register over
20
+ one entity and the record its rows open, with the logs that stand beside it (`logs`, read by the organization's own members alone): its columns and filters, its sections in the order its work
21
21
  reaches them, its acts and the checks that guard them (`lotics docs model/register`,
22
22
  `model/record-page`, `model/acts`, `model/checks`). The platform
23
23
  compiles each app and draws it with the runtime every such app shares, so how each piece looks is
@@ -104,12 +104,19 @@ decisions beside the workspace's rows, and with `--json` gives each app as the p
104
104
  apply it. An act whose write the model cannot say names its own `workflow`; that workflow's body is
105
105
  the live one, and `lotics run set_app_workflow` changes it. A job no press starts — an agent or a
106
106
  service handing the app a file or a list of rows — is a workflow the app declares in `workflows`
107
- (`lotics docs model/apps`): the model states what it takes and hands back, `lotics run
107
+ (`lotics docs model/workflows`): the model states what it takes and hands back, `lotics run
108
108
  set_app_workflow` sets its body with `inputs` and `outputs` left out, and every apply keeps that
109
- body. A workflow set on the app that its model declares nowhere is archived by the next apply. An act that reads papers runs an agent
109
+ body. A workflow set on the app that its model declares nowhere is archived by the next apply; a query set on it that its model declares nowhere (`queries`) is removed by the next apply, which says so in its plan and its answer. An act that reads papers runs an agent
110
110
  apply owns: made, replaced and removed with the act, and an agent of that alias apply did not make
111
111
  is refused, never rewritten.
112
112
 
113
+ **What a person reads and does in the app, an agent reads and does through it.** The apply generates a
114
+ query for every list, record, child block and figure the screen reads, and a workflow for every add, edit,
115
+ removal and act — `lotics run get_app_capabilities` lists them, and `lotics docs agent_surface` names each
116
+ family and how it is called. A question the screen never asks is a query the app declares in `queries`
117
+ (`lotics docs model/queries`), beside its `workflows`. Both are part of the design: step 5 of
118
+ `lotics docs design`.
119
+
113
120
  ## 5 — A custom-code app
114
121
 
115
122
  ```
@@ -131,10 +138,9 @@ and carries every binding forward unchanged.
131
138
  `is_current_member` **inside the query** — a member id passed from the client is chosen by the
132
139
  caller. Write one `description` per alias: it is the line a chat or MCP caller chooses by.
133
140
 
134
- **Workflows are the only way an app writes.** Every workflow bound to an app is also the chat
135
- agent's write surface, so its shape is an agent-facing decision: take a list where one job covers
136
- many records, say in an optional input's `description` what omitting it means, make the write
137
- survive running twice, and gate anything irreversible with `wait_for_approval`.
141
+ **Workflows are the only way an app writes**, and every query and workflow bound to an app is also what
142
+ an agent reads and runs there: `lotics docs agent_surface` § Declaring more states how each is shaped for
143
+ that caller.
138
144
 
139
145
  ## 6 — Prove it, without a screen
140
146
 
@@ -28,7 +28,8 @@ Per-command syntax, flags, contracts, and gotchas for the public `lotics` CLI. S
28
28
  | `lotics run <tool> --json '<json>'` | Execute a tool (full JSON output) |
29
29
  | `lotics run <tool>` — **file cells** | A file in a tool's result carries its `fil_…` id and metadata and **no `url`**, on every tool and in both output modes. That is not a broken file — this surface resolves no URL for a cell. Reach the bytes with `lotics file download <file_id>`, which takes the id straight from the cell; the text output says so whenever a result carries one. |
30
30
  | — | **Every tool is invoked here, including the ones that RUN something** (`run_app_workflow`, `run_app_agent`, `run_app_query`) and every one that changes an app (`set_app_queries`, `set_app_workflow`, `set_app_agent`, `update_app`, `rollback_app`). A command exists only for work that touches a local file: `model apply`, `model check`, `model pull`, `app create --custom`, `app pull`, `app deploy`. |
31
- | — | **The exit code reports the WORK, not just the call — for the two tools that RUN one.** `run_app_workflow` and `run_app_agent` whose envelope carries a failed `status` (`error`/`failed`/`cancelled`) exit non-zero and print `<tool> → <status>: <message>` to stderr, so `lotics run … && next-step` cannot walk past a refused run. The rule is an allowlist of FAILURE — an unrecognized status exits 0, so a status added later never turns a working script red. A parked run (`awaiting_input`) is not a failure: it is waiting for an answer and the work is still live. Only a TOP-LEVEL `status` counts; one inside the data belongs to the data. Any OTHER tool's `status` is data, and exits 0. |
31
+ | — | **An app's queries and workflows take more than their params and inputs** (`lotics docs agent_surface`). `run_app_query '{"app_id","alias","params"}'` also takes `filters` and `sort` over the query's output columns, `fields` (the columns to return), `count` with `count_by` or `count_where`, `aggregate`, `limit` (200 rows at most; 50 where none is stated) and `cursor`, and `runs` — up to 20 reads in one call, each `{"alias", …}`, a refused one answered with its `error`; one call's rows total at most 262,144 bytes. `run_app_workflow '{"app_id","alias","inputs"}'` also takes `preview: true` — nothing is written; the answer is what the run would return, with the calls it would make under `preview.writes` and the parts of the answer it could not say under `preview.unpredicted`; a workflow no preview is offered of answers `status: "not_previewed"` — and `runs` — up to 50 runs in order, the first refused, failed or not previewed ending the call, an input `{"from_run": <index>, "path": "data.<key>"}` reading an earlier run's answer. |
32
+ | — | **The exit code reports the WORK, not just the call — for the two tools that RUN one.** `run_app_workflow` and `run_app_agent` whose envelope carries a failed `status` (`error`/`failed`/`cancelled`, or `not_previewed` — a preview asked for and not given) exit non-zero and print `<tool> → <status>: <message>` to stderr, so `lotics run … && next-step` cannot walk past a refused run. The rule is an allowlist of FAILURE — an unrecognized status exits 0, so a status added later never turns a working script red. A parked run (`awaiting_input`) is not a failure: it is waiting for an answer and the work is still live. Only a TOP-LEVEL `status` counts; one inside the data belongs to the data. Any OTHER tool's `status` is data, and exits 0. |
32
33
  | `lotics run <tool> '<json>' -o <dir>` | Also save every **signed link** the answer carries — an object with a `url`, a `filename` and the `expires_at` its signature ends at, at any depth, such as `screenshot_app`'s images. Each is fetched into `<dir>` (created if missing) under its `filename`, taking a free spelling beside a file of that name already there, and a link's `snapshot` text is written beside it as `<name>.txt`. The written paths follow the answer on **stdout**, one per line (on stderr under `--json`, whose stdout stays the one object). A link is signed for an hour; one the store no longer serves exits non-zero. A workspace file states no `expires_at` and is left to `lotics file download`. |
33
34
  | `lotics run <tool> --print-created` | Report the records the call created, grouped by table, with a paste-ready `delete_records` per table and the mandatory caveat naming what cannot be auto-undone (external integrations, notifications, possible sub-workflows). Works for any tool that returns a `side_effects` block, not workflows alone. |
34
35
  | `lotics run <tool> --cleanup` | Implies `--print-created`, then runs those deletes — harvested records **only**, never files / external calls / notifications. **Not a rollback**; a rollback is structurally impossible here. A partial cleanup exits non-zero so a script cannot read it as success. |
package/docs/design.md CHANGED
@@ -17,7 +17,10 @@ rows in time, a template's act — until the model states it or declines it.
17
17
  1. **People and jobs, before any table.** Name each person by the work they do, then each JOB in one
18
18
  sentence: who does it, what exists when it is done, the record it moves. The sentence is the app's
19
19
  `name` and `description`. A job is a desk a person works every day from its own list; one app per job,
20
- each one register over one entity — an app per table, or one app holding every table, is no job's screen.
20
+ each one working register over one entity — an app per table, or one app holding every table, is no job's
21
+ screen. What was brought into that register's rows or done to them — the files imported, the runs of a
22
+ batch — is read beside it as a log of the same app (`logs`, at most three), never an app of its own; a
23
+ second list people work as a desk is another app.
21
24
  A list worked only in passing on the way through another job — the rates a quote is priced from, the
22
25
  transfers its receipts are matched to, the statement sent from its records — is a block, a section or an
23
26
  act of that job's app, never an app of its own. A small business usually lands at four to six.
@@ -36,9 +39,15 @@ rows in time, a template's act — until the model states it or declines it.
36
39
  and `acts`; the `checks` that warn or refuse. A rule the business states — a field needed before an act,
37
40
  a quantity bounded, a picker narrowed, a duplicate reused — is the model's (an act's `requires`, a check's
38
41
  `blocks`, `write_rules`), and every write re-checks it; a check without `blocks` only warns.
39
- 5. **A treatment for every list and every entity** — the next section. Choose it from what the rows ARE,
42
+ 5. **What a person would ask an agent in each app.** Write the asks down per app — every act, each list
43
+ narrowed the way a person narrows it, one record with its lines and papers, every figure, data brought
44
+ in — and beside each the query or workflow of the app that answers it (`agent_surface`). What the screen
45
+ reads and does is generated. An ask no screen makes is declared: a read in `queries` (`model/queries`),
46
+ a write in `workflows` (`model/workflows`). A figure an agent calls states its `alias`. An ask with
47
+ nothing to answer it is a gap in the model, closed before the apply.
48
+ 6. **A treatment for every list and every entity** — the next section. Choose it from what the rows ARE,
40
49
  before writing any register.
41
- 6. **Plan, apply, look.** The `check_model` tool (`lotics model check` on the CLI) checks the
50
+ 7. **Plan, apply, look.** The `check_model` tool (`lotics model check` on the CLI) checks the
42
51
  whole model beside the workspace's rows and writes nothing — the patch adopting each app's decisions in
43
52
  `designs`, and the app's body as they leave it its `draft`; `apply_model` (`lotics model apply`) mints each app live. Then look at each app —
44
53
  § Look at it — and judge it against § The visual bar. A correction is a change to the model, applied again.
@@ -99,8 +108,8 @@ row's face — its mark, its name, its status and the line under it — and noth
99
108
  row's cell or on its line opens from its name; a link the record keeps as a field rests as its picker, and
100
109
  its way out stands at the field's label where the row has somewhere to go. The peek's one act leaves for
101
110
  the app whose register is that entity's rows — the first such app, in the model's order, that the member
102
- can open — at the row's own address, or goes to the row inside this app where the entity is the app's own
103
- or one of its books'. With no such app the peek has no act, and a link answered in what an act asks opens
111
+ can open — at the row's own address, or goes to the row inside this app where the entity is the app's own,
112
+ one of its books' or a log's, in that list — a log's rows open from their own app alone. With no such app the peek has no act, and a link answered in what an act asks opens
104
113
  no peek. The model states none of it: give the entity an app of its own and
105
114
  its links lead there. The app opened decides what the member reads of the row; where it keeps the row
106
115
  from them, they land on its not-found.
@@ -1525,6 +1525,28 @@ above. A provider's refusal carries the provider's own words.
1525
1525
  | `mime_type` | string | yes | |
1526
1526
  | `url` | string | yes | |
1527
1527
 
1528
+ ## get
1529
+
1530
+ ### get_record_activity (workflow step)
1531
+
1532
+ - List a record's activity, newest first: one entry per save — a person's edits made close together read as one, each field from before the first to after the last — and, inside an app's workflow, the comments the app takes.
1533
+ - A change entry says who made it and when, whether it made the record (created), whether it put an earlier change back (undo), who put it back since (undone), and whether the member the run acts for may put it back (undoable).
1534
+ - Inside an app's workflow the fields are the ones the app reads, and a record the app's own read does not reach for that member is not found.
1535
+
1536
+ **Inputs**
1537
+
1538
+ | Name | Type | Required | What it is |
1539
+ | --- | --- | --- | --- |
1540
+ | `record_id` | string | yes | |
1541
+ | `after` | string \\| null | | A page's `next`. |
1542
+
1543
+ **Answer**
1544
+
1545
+ | Name | Type | Required | What it is |
1546
+ | --- | --- | --- | --- |
1547
+ | `entries` | any[] | yes | Newest first. |
1548
+ | `next` | string | | Where the trail continues: pass it as `after`. Absent at its end. |
1549
+
1528
1550
  ## gmail
1529
1551
 
1530
1552
  ### gmail_get_thread (workflow step)
@@ -1711,6 +1733,78 @@ above. A provider's refusal carries the provider's own words.
1711
1733
  | `markdown` | string | | The body as written in markdown; absent when a template rendered it |
1712
1734
  | `to` | string | yes | The recipients as sent, comma-separated |
1713
1735
 
1736
+ ## import
1737
+
1738
+ ### import_rows (workflow step)
1739
+
1740
+ - Match a source's rows to a table by a key and write what differs: a row whose match fields equal no live row's is added, one equalling a row's changes the cells that differ, one that changes nothing is counted and not written.
1741
+ - It keeps nothing of its own: what it wrote and what it held are its answer, read under the run's own reach of the table — a workflow hands its caller only what it returns. match names the fields that identify a row: text, number or date fields a row is written with, compared as trimmed text, case ignored, and link fields holding one row, compared by the row linked. values holds a row's cells by field key, as create_records takes them; a link cell also takes { "lookup": "<text>" }, the linked row found by that text in its table's unique or autonumber field, else its title, among the rows the run reads of that table, and a link holding many rows a list of them. (workflow step, chat, MCP, CLI)
1742
+ - A row is held, never written, when: it carries hold (its text is the message); several live rows or an earlier row of the call carry its key; a link's text names no row, or several; the table refuses it or its row is locked; create is false and no row carries its key; or, under conflicts "hold", a live cell holds another value — the row's empty cells are still written. conflicts "overwrite" writes over a differing cell, "fill_empty" leaves it. conflicts.fields sets one field's own.
1743
+ - A number and the field its unit or currency is read from are one cell, under the number's policy: both written, or both held.
1744
+ - A row whose key names a row outside the rows the run may write is held "refused" with nothing of that row: no record_id, no changes.
1745
+ - A row carrying remove: true proposes moving the row its key names to the trash: always held, whatever create and conflicts say, and counted unchanged where no row carries its key.
1746
+ - A row the source leaves out is never touched.
1747
+ - One call takes at most 10000 rows; more is refused with the count, never cut short.
1748
+ - Returns { read, created, changed, unchanged, held, written, written_omitted, held_rows, held_omitted }: the rows the call took, the rows it added, the rows it changed a cell of, the rows that changed nothing, and the rows held — a row can be both changed and held. written lists each record written: record_id, kind ("created" or "changed"), line, and changes — each field changed, with its field_key, its label and its before and after as text, as a read of the table shows them (an option's label, a linked row's display, a member's name; null where empty).
1749
+ - A created row lists no changes: its values are the row's own. held_rows lists each row held: kind ("create", "update", "remove", or "unmatched" where no row carries its key and create is false), reason, message, record_id where one row carries its key, key (each match field as text), the changes it would make, links (each link text naming no one row, with the rows it could be as candidates { id, display }), and values — its key and the cells it would write, as the call gave them.
1750
+ - A later call given those values under conflicts { "default": "overwrite" } writes it.
1751
+ - The two lists hold at most 10000 entries and 8388608 characters of JSON between them, each in the rows' order, what was written before what was held; written_omitted and held_omitted count what was left out.
1752
+ - A row gives each list at most one entry.
1753
+
1754
+ **Inputs**
1755
+
1756
+ | Name | Type | Required | What it is |
1757
+ | --- | --- | --- | --- |
1758
+ | `table_id` | string | yes | |
1759
+ | `match` | string[] | yes | |
1760
+ | `rows` | object[] | yes | |
1761
+ | `rows[].values` | object | yes | |
1762
+ | `rows[].hold` | string \\| null | | |
1763
+ | `rows[].remove` | boolean \\| null | | |
1764
+ | `rows[].line` | number \\| null | | Its line in the source, 1-based. |
1765
+ | `create` | boolean \\| null | | |
1766
+ | `conflicts` | object \\| null | | |
1767
+
1768
+ **Answer**
1769
+
1770
+ | Name | Type | Required | What it is |
1771
+ | --- | --- | --- | --- |
1772
+ | `read` | number | yes | |
1773
+ | `created` | number | yes | |
1774
+ | `changed` | number | yes | |
1775
+ | `unchanged` | number | yes | |
1776
+ | `held` | number | yes | |
1777
+ | `written` | object[] | yes | |
1778
+ | `written[].record_id` | string | yes | |
1779
+ | `written[].kind` | "created" \\| "changed" | yes | |
1780
+ | `written[].line` | number | | |
1781
+ | `written[].changes` | object[] | yes | |
1782
+ | `written[].changes[].field_key` | string | yes | |
1783
+ | `written[].changes[].label` | string | yes | |
1784
+ | `written[].changes[].before` | string \\| null | yes | |
1785
+ | `written[].changes[].after` | string \\| null | yes | |
1786
+ | `written_omitted` | number | yes | |
1787
+ | `held_rows` | object[] | yes | |
1788
+ | `held_rows[].line` | number | | |
1789
+ | `held_rows[].kind` | "create" \\| "update" \\| "remove" \\| "unmatched" | yes | |
1790
+ | `held_rows[].reason` | "stated" \\| "removal" \\| "conflict" \\| "no_match" \\| "ambiguous_key" \\| "unresolved_link" \\| "ambiguous_link" \\| "locked" \\| "refused" | yes | |
1791
+ | `held_rows[].message` | string | yes | |
1792
+ | `held_rows[].record_id` | string | | |
1793
+ | `held_rows[].key` | object | yes | |
1794
+ | `held_rows[].values` | object | yes | |
1795
+ | `held_rows[].changes` | object[] | yes | |
1796
+ | `held_rows[].changes[].field_key` | string | yes | |
1797
+ | `held_rows[].changes[].label` | string | yes | |
1798
+ | `held_rows[].changes[].before` | string \\| null | yes | |
1799
+ | `held_rows[].changes[].after` | string \\| null | yes | |
1800
+ | `held_rows[].links` | object[] | yes | |
1801
+ | `held_rows[].links[].field_key` | string | yes | |
1802
+ | `held_rows[].links[].text` | string | yes | |
1803
+ | `held_rows[].links[].candidates` | object[] | yes | |
1804
+ | `held_rows[].links[].candidates[].id` | string | yes | |
1805
+ | `held_rows[].links[].candidates[].display` | string | yes | |
1806
+ | `held_omitted` | number | yes | |
1807
+
1714
1808
  ## instagram
1715
1809
 
1716
1810
  ### instagram_create_post (workflow step)
@@ -3267,6 +3361,63 @@ above. A provider's refusal carries the provider's own words.
3267
3361
  | `post_id` | string | yes | |
3268
3362
  | `url` | string | yes | |
3269
3363
 
3364
+ ## undo
3365
+
3366
+ ### undo_record_change (workflow step)
3367
+
3368
+ - Put back one change entry of a record's activity, as the own save of the member the run acts for. entry_id is the entry's id as get_record_activity lists it. (workflow step)
3369
+ - Each field the entry changed returns to what it held before it where the record still holds what it wrote; a field changed since is kept.
3370
+ - Refused: the entry that made the record, an entry that is itself an undo, an entry already put back, and a record in the trash.
3371
+ - The member holds write access to the record's table as the step runs, the record within the rows they may write; a run that acts for no member is refused.
3372
+ - Returns { reverted, kept }: the fields put back, and those left as they stand, each with why.
3373
+
3374
+ **Inputs**
3375
+
3376
+ | Name | Type | Required | What it is |
3377
+ | --- | --- | --- | --- |
3378
+ | `record_id` | string | yes | |
3379
+ | `entry_id` | string | yes | |
3380
+
3381
+ **Answer**
3382
+
3383
+ | Name | Type | Required | What it is |
3384
+ | --- | --- | --- | --- |
3385
+ | `reverted` | object[] | yes | The fields put back to what they held before the change. |
3386
+ | `reverted[].field_id` | string | yes | |
3387
+ | `reverted[].label` | string | yes | |
3388
+ | `kept` | object[] | yes | The fields left as they stand, each with why. A field already holding what it held before the change is in neither list. |
3389
+ | `kept[].field_id` | string | yes | |
3390
+ | `kept[].label` | string | yes | |
3391
+ | `kept[].reason` | "changed_since" \\| "refused" | yes | Someone changed it since; or the table refused the write (a lock, a check). |
3392
+
3393
+ ### undo_record_changes (workflow step)
3394
+
3395
+ - Put back what one workflow run wrote on records of a table, as the own writes of the member the run acts for.
3396
+ - A field the run changed returns to what it held before where the record still holds what the run wrote; a record the run created moves to the trash where nothing else has written it or linked a row to it since; a record the run moved to the trash comes back where nothing has touched it since, without the links to it its removal took off other rows.
3397
+ - A record the run wrote nothing on is left out, and so is a write of the run that was itself an undo.
3398
+ - The member holds write access to the table as the step runs, each record within the rows they may write; a run that acts for no member is refused.
3399
+ - Takes at most 500 record ids a call.
3400
+ - Returns { reverted, kept }: the count of records put back, and the records left as they stand, one entry a record and reason with the field keys kept — none where the whole record was.
3401
+ - A record created in a table where a workflow writes each new row reads as written since, and is kept.
3402
+
3403
+ **Inputs**
3404
+
3405
+ | Name | Type | Required | What it is |
3406
+ | --- | --- | --- | --- |
3407
+ | `table_id` | string | yes | |
3408
+ | `record_ids` | string[] | yes | |
3409
+ | `workflow_execution_id` | string | yes | The run whose writes are put back: what runtime.execution_id held in it. |
3410
+
3411
+ **Answer**
3412
+
3413
+ | Name | Type | Required | What it is |
3414
+ | --- | --- | --- | --- |
3415
+ | `reverted` | number | yes | |
3416
+ | `kept` | object[] | yes | |
3417
+ | `kept[].record_id` | string | yes | |
3418
+ | `kept[].fields` | string[] | yes | |
3419
+ | `kept[].reason` | "changed_since" \\| "refused" \\| "outside_your_rows" \\| "unreadable" \\| "restored" | yes | Someone changed it since — wrote it, moved it to the trash, or linked a row to it; the table refused the write (a lock, a check); it is outside the rows the caller may write; the trail of what was written cannot be read; or the run brought it back from the trash, where an undo returns no row. |
3420
+
3270
3421
  ## vietcombank
3271
3422
 
3272
3423
  ### vietcombank_convert_currency (workflow step)