@lotics/cli 0.316.13 → 0.316.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/src/cli.js +6 -5
- package/docs/agent_surface.md +33 -0
- package/docs/cli_reference.md +1 -1
- package/package.json +1 -1
package/dist/src/cli.js
CHANGED
|
@@ -1590,6 +1590,7 @@ var TABLE_ID_PATTERN = new RegExp(
|
|
|
1590
1590
|
"i"
|
|
1591
1591
|
);
|
|
1592
1592
|
var PREFIXED_ID_PATTERN = new RegExp(`^[a-z]{3}_[${ID_ALPHABET}]{${ID_LENGTH}}$`);
|
|
1593
|
+
var MEMBER_ID_PATTERN = new RegExp(`^[${ID_ALPHABET}]{${ID_LENGTH},}$`);
|
|
1593
1594
|
|
|
1594
1595
|
// src/download_args.ts
|
|
1595
1596
|
function isDirectoryOnDisk(candidate) {
|
|
@@ -1729,8 +1730,8 @@ async function saveSignedLinks(links, dir) {
|
|
|
1729
1730
|
}
|
|
1730
1731
|
|
|
1731
1732
|
// src/version.ts
|
|
1732
|
-
var VERSION = "0.316.
|
|
1733
|
-
var APP_SDK_VERSION = "0.124.
|
|
1733
|
+
var VERSION = "0.316.15";
|
|
1734
|
+
var APP_SDK_VERSION = "0.124.7";
|
|
1734
1735
|
|
|
1735
1736
|
// src/timezone.ts
|
|
1736
1737
|
function machineTimezone() {
|
|
@@ -1767,7 +1768,7 @@ var model_reference_default = '# Tables and apps\n\nThe tables of a workspace an
|
|
|
1767
1768
|
var examples_default = '# Worked examples\n\nEach section is one treatment, cut from a complete workspace of an invented business: the job, the keys to\nread, where the treatment is wrong, and the excerpt \u2014 the app\'s tree, the App node `set_app` takes as `body`,\nwhich the check passes with no finding; the tables it draws as the items of a `set_tables` call, each with its\n`title` and `records`; those tables\' fields it names, by what each holds and how it is drawn; and the templates it makes.\nLeft out, for the page\'s size: a Section that carries nothing of the treatment, every `label` and `description` of a table or field, `required`, `unique`, `default`, a link\'s\n`pair`, how a computed field computes, the `tables` and `templates` maps an App adds (ids from the\n`set_tables` answer), and template contents \u2014 a call states each table\'s and field\'s `label`. Choosing\namong them: the `design` reference. Every key: the `model` reference, at the pages each section names under\n**Keys**.\n\n## Calendar\n\n**The job.** Book visits on the days they happen, and see a week\'s work at a glance.\n\n**Read.** `layout: "calendar"` on the List; each visit stands over its span because its `records` line holds two dates (`scheduled_for`, `ends_at`).\n\n**Keys.** `model/calendar`\n\n**Not when.** Rows booked on a resource that two may not share at once \u2014 that is `lanes`. Rows with a date nobody plans by (an invoice\'s date) stay a table.\n\n```json\n{\n "tables": [\n {\n "ref": "service_visit",\n "title": "customer",\n "fields": {\n "customer": {"type": "link", "table": "customer", "multi": false},\n "scheduled_for": {"type": "date", "format": "datetime"},\n "ends_at": {"type": "date", "format": "datetime"},\n "engineer": {"type": "link", "table": "engineer", "multi": false},\n "job": {\n "type": "select",\n "options": [{"alias": "inspection", "color": "sky"}, {"alias": "repair", "color": "orange"}]\n },\n "stage": {\n "type": "select",\n "options": [\n {"alias": "booked_on", "color": "blue"},\n {"alias": "parts_ready_on", "color": "blue"},\n {"alias": "arrived_on", "color": "blue"},\n {"alias": "completed_on", "color": "green"}\n ]\n },\n "booked_on": {"type": "date"},\n "parts_ready_on": {"type": "date"},\n "arrived_on": {"type": "date"},\n "hours": {"type": "number", "notation": {"style": "unit", "unit": "h"}},\n "report": {"type": "text", "format": "markdown"},\n "completed_on": {"type": "date"}\n },\n "records": {\n "subtitle": ["scheduled_for", "ends_at"],\n "figure": "hours",\n "due": ["scheduled_for"],\n "status": {\n "field": "stage",\n "closed": ["completed_on"],\n "dates": {\n "booked_on": "booked_on",\n "parts_ready_on": "parts_ready_on",\n "arrived_on": "arrived_on",\n "completed_on": "completed_on"\n }\n }\n }\n }\n ],\n "body": {\n "type": "App",\n "key": "visits",\n "name": "Service visits",\n "icon": "calendar",\n "color": "emerald",\n "children": [\n {\n "type": "List",\n "entity": "service_visit",\n "row": {"title": {"add": ["name", "credit_limit"]}},\n "columns": ["engineer", "job"],\n "filters": ["customer"],\n "sort": {"field": "scheduled_for"},\n "layout": "calendar",\n "create": [\n "customer",\n "job",\n "scheduled_for",\n "ends_at",\n {"field": "engineer", "add": ["name", {"field": "base", "add": ["name", "city"]}]}\n ],\n "children": [\n {"type": "Reading", "key": "register_reading_0", "label": "Repairs", "where": {"job": ["repair"]}},\n {"type": "Reading", "key": "register_reading_1", "value": "hours", "by": "engineer"},\n {\n "type": "Record",\n "children": [\n {\n "type": "Section",\n "key": "visit",\n "title": "Visit",\n "at": ["booked_on", "parts_ready_on"],\n "children": [\n "job",\n "booked_on",\n "parts_ready_on",\n {\n "type": "Act",\n "key": "reschedule",\n "label": "Reschedule",\n "asks": ["scheduled_for", "ends_at"],\n "when": {"stage": ["booked_on", "parts_ready_on"]}\n },\n {\n "type": "Act",\n "key": "to_booked_on",\n "label": "Booked",\n "when": {"stage": {"empty": true}},\n "asks": [{"field": "booked_on", "starts": "today"}],\n "set": {"stage": "booked_on"}\n },\n {\n "type": "Act",\n "key": "to_parts_ready_on",\n "label": "Parts ready",\n "when": {"stage": ["booked_on"], "job": ["repair"]},\n "asks": [{"field": "parts_ready_on", "starts": "today"}],\n "set": {"stage": "parts_ready_on"}\n },\n {\n "type": "Act",\n "key": "to_arrived_on",\n "label": "On site",\n "when": {"stage": ["booked_on"], "job": ["inspection"]},\n "asks": [{"field": "arrived_on", "starts": "today"}],\n "set": {"stage": "arrived_on"}\n },\n {\n "type": "Act",\n "key": "to_arrived_on_2",\n "label": "On site",\n "when": {"stage": ["parts_ready_on"]},\n "asks": [{"field": "arrived_on", "starts": "today"}],\n "set": {"stage": "arrived_on"}\n }\n ]\n },\n {\n "type": "Section",\n "key": "report",\n "title": "Report",\n "at": ["arrived_on"],\n "children": [\n "engineer",\n "arrived_on",\n {"field": "report", "block": false},\n {\n "type": "Act",\n "key": "complete",\n "label": "Complete visit",\n "requires": ["engineer"],\n "asks": [\n "hours",\n {"input": "summary", "label": "What was done", "type": "long_text", "required": true}\n ],\n "set": {"report": {"input": "summary"}, "stage": "completed_on"},\n "when": {"stage": ["arrived_on"]}\n }\n ]\n }\n ]\n }\n ]\n }\n ]\n }\n}\n```\n\n## Lanes\n\n**The job.** Put each service visit on an engineer\'s day by the hour, and see at once who is free between visits.\n\n**Read.** `layout` `lanes` with `by` naming the one-row link to the resource (the engineer); every engineer stands as a lane, empty or not.\n\n**Keys.** `model/lanes`\n\n**Not when.** The resource is not a row of the model (a free-text room name), or rows never compete for one \u2014 a `calendar` reads them by day.\n\n```json\n{\n "tables": [\n {\n "ref": "service_visit",\n "title": "customer",\n "fields": {\n "customer": {"type": "link", "table": "customer", "multi": false},\n "scheduled_for": {"type": "date", "format": "datetime"},\n "ends_at": {"type": "date", "format": "datetime"},\n "engineer": {"type": "link", "table": "engineer", "multi": false},\n "job": {\n "type": "select",\n "options": [{"alias": "inspection", "color": "sky"}, {"alias": "repair", "color": "orange"}]\n },\n "stage": {\n "type": "select",\n "options": [\n {"alias": "booked_on", "color": "blue"},\n {"alias": "parts_ready_on", "color": "blue"},\n {"alias": "arrived_on", "color": "blue"},\n {"alias": "completed_on", "color": "green"}\n ]\n },\n "booked_on": {"type": "date"},\n "parts_ready_on": {"type": "date"},\n "arrived_on": {"type": "date"},\n "hours": {"type": "number", "notation": {"style": "unit", "unit": "h"}},\n "completed_on": {"type": "date"}\n },\n "records": {\n "subtitle": ["scheduled_for", "ends_at"],\n "figure": "hours",\n "due": ["scheduled_for"],\n "status": {\n "field": "stage",\n "closed": ["completed_on"],\n "dates": {\n "booked_on": "booked_on",\n "parts_ready_on": "parts_ready_on",\n "arrived_on": "arrived_on",\n "completed_on": "completed_on"\n }\n }\n }\n },\n {\n "ref": "engineer",\n "title": "name",\n "fields": {"name": {"type": "text"}, "base": {"type": "link", "table": "warehouse", "multi": false}},\n "records": {"subtitle": ["base"], "party": "person"}\n }\n ],\n "body": {\n "type": "App",\n "key": "dispatch",\n "name": "Dispatch",\n "icon": "wrench",\n "color": "sky",\n "children": [\n {\n "type": "List",\n "entity": "service_visit",\n "row": {\n "title": {"add": ["name", "credit_limit"]},\n "subtitle": [{"field": "scheduled_for", "edit": false}, {"field": "ends_at", "edit": false}]\n },\n "columns": ["job"],\n "search": ["customer"],\n "layout": {"lanes": {"by": "engineer"}},\n "create": [\n {"field": "engineer", "add": ["name", {"field": "base", "add": ["name", "city"]}]},\n "scheduled_for",\n "ends_at",\n "customer",\n "job"\n ],\n "children": [\n {\n "type": "Record",\n "door": "drawer",\n "children": [\n {\n "type": "Section",\n "key": "booking",\n "title": "Booking",\n "children": [\n "engineer",\n "job",\n {\n "type": "Act",\n "key": "to_booked_on",\n "label": "Booked",\n "when": {"stage": {"empty": true}},\n "asks": [{"field": "booked_on", "starts": "today"}],\n "set": {"stage": "booked_on"}\n },\n {\n "type": "Act",\n "key": "to_parts_ready_on",\n "label": "Parts ready",\n "when": {"stage": ["booked_on"], "job": ["repair"]},\n "asks": [{"field": "parts_ready_on", "starts": "today"}],\n "set": {"stage": "parts_ready_on"}\n },\n {\n "type": "Act",\n "key": "to_arrived_on",\n "label": "On site",\n "when": {"stage": ["booked_on"], "job": ["inspection"]},\n "asks": [{"field": "arrived_on", "starts": "today"}],\n "set": {"stage": "arrived_on"}\n },\n {\n "type": "Act",\n "key": "to_arrived_on_2",\n "label": "On site",\n "when": {"stage": ["parts_ready_on"]},\n "asks": [{"field": "arrived_on", "starts": "today"}],\n "set": {"stage": "arrived_on"}\n },\n {\n "type": "Act",\n "key": "to_completed_on",\n "label": "Completed",\n "when": {"stage": ["arrived_on"]},\n "asks": [{"field": "completed_on", "starts": "today"}],\n "set": {"stage": "completed_on"}\n }\n ]\n }\n ]\n }\n ]\n }\n ]\n }\n}\n```\n\n## Gantt\n\n**The job.** Read every hire as a bar from the day the machine goes out to the day it is due back, against today.\n\n**Read.** `layout` `gantt` with its `start`; with no end stated, the bar ends at the entity\'s first `due`. The day a hire comes back (`ngay_tra`) is stamped by the status\'s `dates`, so no act sets it.\n\n**Keys.** `model/gantt`\n\n**Not when.** Rows that last a day or less (a visit, a shift) \u2014 `calendar` or `lanes`; a plan with no dates set yet.\n\n```json\n{\n "tables": [\n {\n "ref": "phieu_thue",\n "title": "khach_hang",\n "fields": {\n "khach_hang": {"type": "link", "table": "khach_hang", "multi": false},\n "so_phieu": {"type": "text"},\n "trang_thai": {\n "type": "select",\n "options": [\n {"alias": "moi", "color": "slate"},\n {"alias": "dang_thue", "color": "blue"},\n {"alias": "da_tra", "color": "green"},\n {"alias": "huy", "color": "gray"}\n ]\n },\n "phu_trach": {"type": "member"},\n "ngay_giao": {"type": "date"},\n "han_tra": {"type": "date"},\n "ngay_tra": {"type": "date"},\n "tinh_trang_tra": {\n "type": "select",\n "options": [\n {"alias": "tot", "color": "green"},\n {"alias": "tray_xuoc", "color": "amber"},\n {"alias": "hu_hong", "color": "red"}\n ]\n },\n "so_ngay": {"type": "formula"},\n "tong_tien": {"type": "rollup"}\n },\n "records": {\n "subtitle": ["so_phieu"],\n "figure": "tong_tien",\n "due": ["han_tra"],\n "status": {"field": "trang_thai", "closed": ["da_tra", "huy"], "dates": {"da_tra": "ngay_tra"}}\n }\n }\n ],\n "body": {\n "type": "App",\n "key": "tien_do_thue",\n "name": "Ti\u1EBFn \u0111\u1ED9 cho thu\xEA",\n "icon": "calendar-range",\n "color": "teal",\n "children": [\n {\n "type": "List",\n "entity": "phieu_thue",\n "row": {"title": {"add": true}},\n "columns": ["phu_trach"],\n "filters": ["phu_trach", "khach_hang"],\n "layout": {"gantt": {"start": "ngay_giao"}},\n "create": false,\n "remove": false,\n "children": [\n {\n "type": "Reading",\n "key": "register_reading_0",\n "label": "Phi\u1EBFu \u0111ang cho thu\xEA",\n "where": {"trang_thai": ["dang_thue"]}\n },\n {\n "type": "Reading",\n "key": "register_reading_1",\n "label": "Ti\u1EC1n thu\xEA \u0111ang ch\u1EA1y",\n "value": "tong_tien",\n "where": {"trang_thai": ["dang_thue"]}\n },\n {\n "type": "Record",\n "children": [\n {\n "type": "Section",\n "key": "phieu_thue",\n "title": "Phi\u1EBFu thu\xEA",\n "children": ["ngay_giao", "han_tra"]\n },\n {\n "type": "Section",\n "key": "dang_thue",\n "title": "\u0110ang thu\xEA",\n "at": ["dang_thue"],\n "children": [\n "so_ngay",\n "ngay_tra",\n {\n "type": "Act",\n "key": "nhan_tra",\n "label": "Nh\u1EADn m\xE1y tr\u1EA3",\n "when": {"trang_thai": ["dang_thue"]},\n "asks": ["tinh_trang_tra"],\n "set": {"trang_thai": "da_tra"},\n "confirm": true\n }\n ]\n }\n ]\n }\n ]\n }\n ]\n }\n}\n```\n\n## Roster\n\n**The job.** See who works which shift on each day of the week or the month.\n\n**Read.** `layout: "roster"` over the people; the child List (`lich_truc`) is the rows that fill the days \u2014 one link back and a date on its line.\n\n**Keys.** `model/roster`\n\n**Not when.** Rows that are themselves the bookings (a stay over several days) \u2014 a `calendar` or `lanes` draws them; a roster\'s row is what the days are filled for.\n\n```json\n{\n "tables": [\n {\n "ref": "nha_si",\n "title": "ho_ten",\n "fields": {\n "ho_ten": {"type": "text"},\n "chuyen_mon": {\n "type": "select",\n "options": [\n {"alias": "tong_quat", "color": "blue"},\n {"alias": "chinh_nha", "color": "purple"},\n {"alias": "nha_chu", "color": "green"},\n {"alias": "phuc_hinh", "color": "orange"}\n ]\n },\n "dien_thoai": {"type": "text"}\n },\n "records": {"subtitle": ["chuyen_mon"], "party": "person"}\n },\n {\n "ref": "lich_truc",\n "title": "ca",\n "fields": {\n "nha_si": {"type": "link", "table": "nha_si", "multi": false},\n "ca": {\n "type": "select",\n "options": [\n {"alias": "sang", "color": "blue"},\n {"alias": "chieu", "color": "purple"},\n {"alias": "ca_ngay", "color": "green"},\n {"alias": "nghi", "color": "gray"}\n ]\n },\n "ngay": {"type": "date"},\n "ghe": {"type": "link", "table": "ghe", "multi": false}\n },\n "records": {"subtitle": ["ngay", "ghe"]}\n }\n ],\n "body": {\n "type": "App",\n "key": "lich_truc",\n "name": "L\u1ECBch tr\u1EF1c",\n "icon": "users",\n "color": "indigo",\n "children": [\n {\n "type": "List",\n "entity": "nha_si",\n "filters": ["chuyen_mon"],\n "layout": "roster",\n "create": ["ho_ten", "chuyen_mon", "dien_thoai"],\n "children": [\n {\n "type": "List",\n "entity": "lich_truc",\n "row": {"subtitle": ["ngay", {"field": "ghe", "add": ["ten", "phong"]}]},\n "create": true,\n "remove": true\n }\n ]\n }\n ]\n }\n}\n```\n\n## Cards\n\n**The job.** Find an item by its picture, and count what the shelf holds.\n\n**Read.** `layout: "cards"` over rows whose `records.image` is a photo; `scope` keeps the app inside one warehouse at a time.\n\n**Keys.** `model/cards`, `model/records`, `model/app-node`\n\n**Not when.** Rows with no picture \u2014 cards of words are a table drawn worse.\n\n```json\n{\n "tables": [\n {\n "ref": "stock_item",\n "title": "name",\n "fields": {\n "name": {"type": "text"},\n "warehouse": {"type": "link", "table": "warehouse", "multi": false},\n "photo": {"type": "files"},\n "sku": {"type": "text"},\n "category": {\n "type": "select",\n "options": [\n {"alias": "pumps", "color": "blue"},\n {"alias": "valves", "color": "teal"},\n {"alias": "seals", "color": "amber"},\n {"alias": "motors", "color": "violet"}\n ]\n },\n "on_hand": {"type": "number"},\n "minimum": {"type": "number"},\n "price": {"type": "number", "notation": {"style": "currency", "currency": "USD"}},\n "supplier_page": {"type": "text", "format": "link"},\n "last_counted": {"type": "date"},\n "counted_by": {"type": "member"},\n "count_note": {"type": "text"}\n },\n "records": {\n "subtitle": ["sku", "category"],\n "image": "photo",\n "figure": "on_hand",\n "limits": {"on_hand": 400},\n "gates": {"on_hand": "minimum"}\n }\n },\n {\n "ref": "order_line",\n "title": "item",\n "fields": {\n "item": {"type": "link", "table": "stock_item", "multi": false},\n "order": {"type": "link", "table": "order", "multi": false},\n "quantity": {"type": "number"},\n "shipped": {"type": "number"},\n "picked": {"type": "number"},\n "unit_price": {"type": "number", "notation": {"style": "currency", "currency": "USD"}},\n "amount": {"type": "formula", "notation": {"style": "currency", "currency": "USD"}},\n "settled": {"type": "formula"},\n "category": {"type": "lookup"}\n },\n "records": {\n "subtitle": ["order", "unit_price"],\n "figure": "amount",\n "limits": {"shipped": "quantity", "picked": "quantity"}\n }\n }\n ],\n "body": {\n "type": "App",\n "key": "stock",\n "name": "Stock",\n "icon": "boxes",\n "color": "teal",\n "scope": "warehouse",\n "children": [\n {\n "type": "List",\n "entity": "stock_item",\n "columns": ["price"],\n "filters": ["on_hand"],\n "search": ["name", "sku"],\n "layout": "cards",\n "children": [\n {"type": "Reading", "key": "register_reading_0", "value": "on_hand", "by": "category"},\n {"type": "Export", "key": "export"},\n {\n "type": "Record",\n "children": [\n {\n "type": "Section",\n "key": "item",\n "title": "Item",\n "children": ["price", "minimum", "supplier_page"]\n },\n {\n "type": "Section",\n "key": "last_count",\n "title": "Last count",\n "children": [\n "last_counted",\n "counted_by",\n "count_note",\n {\n "type": "Act",\n "key": "count",\n "label": "Count stock",\n "asks": [\n {"input": "counted", "label": "Units on the shelf", "type": "number", "required": true},\n {"input": "remark", "label": "Remark", "type": "long_text"}\n ],\n "set": {\n "on_hand": {"input": "counted"},\n "last_counted": {"now": true},\n "counted_by": {"me": true},\n "count_note": {"input": "remark"}\n }\n }\n ]\n },\n {\n "type": "Section",\n "key": "on_order",\n "title": "On order",\n "children": [\n {\n "type": "List",\n "entity": "order_line",\n "row": {\n "subtitle": [\n {\n "field": "order",\n "add": [{"field": "customer", "add": ["name", "credit_limit"]}, "reference"]\n },\n "unit_price"\n ]\n },\n "columns": ["quantity"],\n "children": [\n {"type": "Check", "field": "settled", "on": ["update"]},\n {"type": "Record", "children": ["item", "order", "quantity", "shipped", "unit_price"]}\n ]\n }\n ]\n }\n ]\n }\n ]\n }\n ]\n }\n}\n```\n\n## Party\n\n**The job.** Keep each customer account \u2014 who they are, what they ordered, what was said to them.\n\n**Read.** `records.customer.party: "organization"` and its `image` (the logo); a List with `layout: "timeline"` for the touches, a Reading `by` the day placed for its orders, and its papers `expect`ed by kind.\n\n**Keys.** `model/records`, `model/layouts`, `model/list-node`, `model/reading-node`\n\n**Not when.** A list of customers is not a job by itself: the customer is a link on the desk whose rows name it, unless the job is the account itself.\n\n```json\n{\n "tables": [\n {\n "ref": "customer",\n "title": "name",\n "fields": {\n "name": {"type": "text"},\n "logo": {"type": "files"},\n "code": {"type": "text"},\n "tier": {\n "type": "select",\n "options": [\n {"alias": "key", "color": "violet"},\n {"alias": "standard", "color": "sky"},\n {"alias": "lapsed", "color": "gray"}\n ]\n },\n "phone": {"type": "text"},\n "website": {"type": "text", "format": "link"},\n "account_manager": {"type": "member"},\n "credit_limit": {"type": "number", "notation": {"style": "currency", "currency": "USD"}},\n "orders": {"type": "link", "table": "order", "multi": true},\n "outstanding": {"type": "rollup"},\n "over_limit": {"type": "formula"},\n "papers_needed": {\n "type": "select",\n "options": [\n {"alias": "credit_application", "color": "blue"},\n {"alias": "trade_reference", "color": "violet"},\n {"alias": "vat_certificate", "color": "teal"}\n ],\n "multi": true\n },\n "notes": {"type": "text", "format": "markdown"}\n },\n "records": {\n "subtitle": ["tier"],\n "image": "logo",\n "party": "organization",\n "figure": "outstanding",\n "limits": {"outstanding": "credit_limit"}\n }\n },\n {\n "ref": "order",\n "title": "reference",\n "fields": {\n "customer": {"type": "link", "table": "customer", "multi": false},\n "reference": {"type": "text"},\n "stage": {\n "type": "select",\n "options": [\n {"alias": "draft", "color": "slate"},\n {"alias": "confirmed", "color": "blue"},\n {"alias": "picking", "color": "amber"},\n {"alias": "shipped", "color": "indigo"},\n {"alias": "received", "color": "green"},\n {"alias": "cancelled", "color": "rose"}\n ]\n },\n "placed": {"type": "date"},\n "due_date": {"type": "date"},\n "handler": {"type": "member"},\n "priority": {"type": "boolean"},\n "lines": {"type": "link", "table": "order_line", "multi": true},\n "payments": {"type": "link", "table": "payment", "multi": true},\n "total": {"type": "rollup"},\n "balance": {"type": "formula", "notation": {"style": "currency", "currency": "USD"}},\n "units_ordered": {"type": "rollup"},\n "units_shipped": {"type": "rollup"},\n "units_picked": {"type": "rollup"},\n "carrier": {"type": "text"},\n "tracking_no": {"type": "text"},\n "shipped_on": {"type": "date"},\n "cancellation_reason": {"type": "text"},\n "cancelled_by": {"type": "member"},\n "replaced_by": {"type": "link", "table": "order", "multi": false},\n "attachments": {"type": "files"},\n "delivery_note": {"type": "files"},\n "notes": {"type": "text", "format": "markdown"},\n "paid_in_full": {"type": "formula"},\n "has_payments": {"type": "formula"},\n "reminder_ref": {"type": "text"}\n },\n "records": {\n "subtitle": ["customer", "placed"],\n "figure": "total",\n "limits": {"units_shipped": "units_ordered", "units_picked": "units_ordered"},\n "due": ["due_date"],\n "status": {\n "field": "stage",\n "closed": ["received", "cancelled"],\n "dates": {"shipped": "shipped_on"},\n "history": "order_stage"\n }\n }\n },\n {\n "ref": "customer_paper",\n "title": "kind",\n "fields": {\n "kind": {\n "type": "select",\n "options": [\n {"alias": "credit_application", "color": "blue"},\n {"alias": "trade_reference", "color": "violet"},\n {"alias": "vat_certificate", "color": "teal"}\n ]\n },\n "customer": {"type": "link", "table": "customer", "multi": false},\n "received_on": {"type": "date"},\n "file": {"type": "files"}\n },\n "records": {"image": "file"}\n },\n {\n "ref": "touch",\n "title": "said",\n "fields": {\n "said": {"type": "text"},\n "customer": {"type": "link", "table": "customer", "multi": false},\n "on": {"type": "date"},\n "by": {"type": "member"}\n }\n },\n {\n "ref": "service_visit",\n "title": "customer",\n "fields": {\n "customer": {"type": "link", "table": "customer", "multi": false},\n "scheduled_for": {"type": "date", "format": "datetime"},\n "ends_at": {"type": "date", "format": "datetime"},\n "engineer": {"type": "link", "table": "engineer", "multi": false},\n "job": {\n "type": "select",\n "options": [{"alias": "inspection", "color": "sky"}, {"alias": "repair", "color": "orange"}]\n },\n "stage": {\n "type": "select",\n "options": [\n {"alias": "booked_on", "color": "blue"},\n {"alias": "parts_ready_on", "color": "blue"},\n {"alias": "arrived_on", "color": "blue"},\n {"alias": "completed_on", "color": "green"}\n ]\n },\n "booked_on": {"type": "date"},\n "parts_ready_on": {"type": "date"},\n "arrived_on": {"type": "date"},\n "hours": {"type": "number", "notation": {"style": "unit", "unit": "h"}},\n "report": {"type": "text", "format": "markdown"},\n "completed_on": {"type": "date"}\n },\n "records": {\n "subtitle": ["scheduled_for", "ends_at"],\n "figure": "hours",\n "due": ["scheduled_for"],\n "status": {\n "field": "stage",\n "closed": ["completed_on"],\n "dates": {\n "booked_on": "booked_on",\n "parts_ready_on": "parts_ready_on",\n "arrived_on": "arrived_on",\n "completed_on": "completed_on"\n }\n }\n }\n }\n ],\n "body": {\n "type": "App",\n "key": "customers",\n "name": "Customers",\n "icon": "users",\n "color": "violet",\n "children": [\n {\n "type": "List",\n "entity": "customer",\n "columns": ["phone", "account_manager"],\n "filters": ["account_manager"],\n "sort": {"field": "name"},\n "opens": {"tier": ["key", "standard"]},\n "create": ["name", "code", "credit_limit"],\n "children": [\n {"type": "Reading", "key": "register_reading_0", "value": "outstanding", "by": "tier"},\n {"type": "Reading", "key": "register_reading_1", "value": "outstanding", "by": "account_manager"},\n {"type": "Check", "field": "over_limit"},\n {\n "type": "Record",\n "door": "drawer",\n "children": [\n {"type": "Section", "key": "contact", "title": "Contact", "children": ["phone", "website"]},\n {\n "type": "Section",\n "key": "account",\n "title": "Account",\n "children": [\n "code",\n "account_manager",\n "credit_limit",\n {\n "type": "Act",\n "key": "reassign",\n "label": "Reassign",\n "asks": [{"input": "to", "label": "Account manager", "type": "member", "required": true}],\n "set": {"account_manager": {"input": "to"}}\n },\n {\n "type": "Act",\n "key": "lapse",\n "label": "Mark lapsed",\n "when": {"tier": ["key", "standard"]},\n "set": {"tier": "lapsed"},\n "confirm": true\n },\n {\n "type": "Act",\n "key": "reinstate",\n "label": "Reinstate",\n "when": {"tier": ["lapsed"]},\n "asks": [\n {\n "input": "back_to",\n "label": "Back to",\n "type": "select",\n "options": [\n {"alias": "key", "label": "Key account", "color": "violet"},\n {"alias": "standard", "label": "Standard", "color": "sky"}\n ],\n "required": true\n }\n ],\n "set": {"tier": {"input": "back_to"}}\n }\n ]\n },\n {\n "type": "Section",\n "key": "orders",\n "title": "Orders",\n "children": [\n {\n "type": "Reading",\n "key": "block_reading_0",\n "label": "Order value",\n "entity": "order",\n "value": "total",\n "where": {"stage": ["confirmed", "picking", "shipped", "received"]},\n "by": "placed"\n },\n {\n "type": "List",\n "entity": "order",\n "where": {"stage": ["draft", "confirmed", "picking", "shipped"]},\n "label": "Open orders",\n "columns": ["due_date"],\n "create": false,\n "children": [\n {"type": "Check", "field": "paid_in_full", "on": ["update"]},\n {"type": "Check", "field": "has_payments"},\n {\n "type": "Record",\n "children": [\n "due_date",\n "handler",\n {"field": "priority", "edit": false},\n {\n "field": "lines",\n "add": [\n {\n "field": "item",\n "add": ["name", {"field": "warehouse", "add": ["name", "city"]}, "sku", "category"]\n },\n "quantity"\n ]\n },\n {"field": "payments", "add": true},\n {"field": "carrier", "edit": false},\n {"field": "tracking_no", "edit": false},\n {"field": "shipped_on", "edit": false},\n {"field": "cancellation_reason", "edit": false},\n {"field": "cancelled_by", "edit": false},\n {"field": "replaced_by", "edit": false},\n {"field": "attachments", "edit": false},\n "delivery_note",\n "notes",\n {"field": "reminder_ref", "edit": false}\n ]\n }\n ]\n },\n {\n "type": "List",\n "key": "order_2",\n "entity": "order",\n "where": {"stage": ["shipped", "received"], "paid_in_full": false},\n "label": "Shipped, still owed",\n "columns": ["balance"],\n "create": false,\n "children": [{"type": "Record", "children": ["due_date"]}]\n },\n {\n "type": "List",\n "key": "block_reading_3",\n "entity": "service_visit",\n "app": "visits",\n "label": "Service visits",\n "columns": ["engineer", "job"],\n "children": [\n {\n "type": "Act",\n "key": "clear_visit_hours",\n "label": "Clear hours",\n "set": {"hours": null},\n "confirm": true\n },\n {"type": "Record", "edit": false, "children": ["report", "arrived_on", "completed_on"]}\n ]\n }\n ]\n },\n {\n "type": "Section",\n "key": "account_papers",\n "title": "Account papers",\n "children": [\n {\n "type": "List",\n "entity": "customer_paper",\n "expect": {"field": "kind", "of": "papers_needed"},\n "columns": ["received_on"]\n }\n ]\n },\n {\n "type": "Section",\n "key": "contact_log",\n "title": "Contact log",\n "children": [{"type": "List", "entity": "touch", "layout": "timeline"}]\n },\n {"type": "Section", "key": "notes", "title": "Notes", "children": ["notes"]}\n ]\n }\n ]\n }\n ]\n }\n}\n```\n\n## Case\n\n**The job.** Gather an applicant\'s papers into one case, make the forms it needs, and file it on time.\n\n**Read.** Lists with `expect` for the papers brought (one line per kind, the missing ones empty), an Act\'s `templates` for the forms made (ticked and made at once, kept in `into`), and `intake` reading the papers brought.\n\n**Keys.** `model/list-node`, `model/act-node`\n\n**Not when.** A record that needs no papers, or one paper made once \u2014 a single `template` act.\n\n```json\n{\n "tables": [\n {\n "ref": "ho_so",\n "title": "khach_hang",\n "fields": {\n "khach_hang": {"type": "link", "table": "khach_hang", "multi": false},\n "du_an": {"type": "link", "table": "du_an", "multi": false},\n "ma_ho_so": {"type": "autonumber"},\n "dich_vu": {\n "type": "select",\n "options": [{"alias": "tron_goi", "color": "blue"}, {"alias": "lam_ho_so", "color": "violet"}]\n },\n "doi_tuong": {\n "type": "select",\n "options": [\n {"alias": "thu_nhap_thap", "color": "sky"},\n {"alias": "cong_nhan", "color": "teal"},\n {"alias": "can_bo", "color": "indigo"},\n {"alias": "luc_luong", "color": "green"},\n {"alias": "ho_ngheo", "color": "amber"},\n {"alias": "nguoi_co_cong", "color": "rose"}\n ]\n },\n "tinh_trang_nha_o": {\n "type": "select",\n "options": [{"alias": "chua_co_nha", "color": "gray"}, {"alias": "nha_chat", "color": "orange"}]\n },\n "giay_chung_nhan_so": {"type": "text"},\n "dien_tich_nha": {"type": "number"},\n "sale": {"type": "member"},\n "xu_ly": {"type": "member"},\n "can_bo_sung": {"type": "boolean"},\n "phi_dich_vu": {"type": "number", "notation": {"style": "currency", "currency": "VND"}},\n "loai_can": {\n "type": "select",\n "options": [\n {"alias": "studio", "color": "sky"},\n {"alias": "mot_pn", "color": "blue"},\n {"alias": "hai_pn", "color": "indigo"},\n {"alias": "ba_pn", "color": "violet"}\n ]\n },\n "giai_doan": {\n "type": "select",\n "options": [\n {"alias": "ngay_thu_phi", "color": "blue"},\n {"alias": "ngay_nhan_giay_to", "color": "blue"},\n {"alias": "ngay_lap_ho_so", "color": "blue"},\n {"alias": "ngay_nop_cdt", "color": "blue"},\n {"alias": "ngay_cdt_duyet", "color": "blue"},\n {"alias": "ngay_so_xd_duyet", "color": "blue"},\n {"alias": "ngay_boc_tham", "color": "blue"},\n {"alias": "ngay_ky_hdmb", "color": "green"},\n {"alias": "ngay_ban_giao", "color": "green"}\n ]\n },\n "ngay_thu_phi": {"type": "date"},\n "ngay_nhan_giay_to": {"type": "date"},\n "ngay_lap_ho_so": {"type": "date"},\n "ngay_nop_cdt": {"type": "date"},\n "ngay_cdt_duyet": {"type": "date"},\n "ngay_so_xd_duyet": {"type": "date"},\n "ngay_boc_tham": {"type": "date"},\n "ngay_ky_hdmb": {"type": "date"},\n "ngay_ban_giao": {"type": "date"},\n "bo_ho_so": {"type": "files"},\n "giay_to": {"type": "link", "table": "giay_to", "multi": true},\n "thanh_vien": {"type": "link", "table": "thanh_vien", "multi": true},\n "phieu_thu": {"type": "link", "table": "phieu_thu", "multi": true},\n "dien_thoai": {"type": "lookup"},\n "cccd": {"type": "lookup"},\n "ngay_sinh": {"type": "lookup"},\n "dia_chi": {"type": "lookup"},\n "da_thu": {"type": "rollup"},\n "chua_thu_phi": {"type": "formula"},\n "con_no_phi": {"type": "formula"},\n "vuot_thu_nhap": {"type": "formula"},\n "da_hoan_tat": {"type": "formula"}\n },\n "records": {\n "subtitle": ["dich_vu"],\n "figure": "phi_dich_vu",\n "status": {\n "field": "giai_doan",\n "closed": ["ngay_ky_hdmb", "ngay_ban_giao"],\n "dates": {\n "ngay_thu_phi": "ngay_thu_phi",\n "ngay_nhan_giay_to": "ngay_nhan_giay_to",\n "ngay_lap_ho_so": "ngay_lap_ho_so",\n "ngay_nop_cdt": "ngay_nop_cdt",\n "ngay_cdt_duyet": "ngay_cdt_duyet",\n "ngay_so_xd_duyet": "ngay_so_xd_duyet",\n "ngay_boc_tham": "ngay_boc_tham",\n "ngay_ky_hdmb": "ngay_ky_hdmb",\n "ngay_ban_giao": "ngay_ban_giao"\n }\n }\n }\n },\n {\n "ref": "thanh_vien",\n "title": "ho_ten",\n "fields": {\n "ho_ten": {"type": "text"},\n "ho_so": {"type": "link", "table": "ho_so", "multi": false},\n "quan_he": {\n "type": "select",\n "options": [\n {"alias": "nguoi_dung_don", "color": "blue"},\n {"alias": "vo_chong", "color": "violet"},\n {"alias": "con", "color": "teal"},\n {"alias": "bo_me", "color": "amber"},\n {"alias": "khac", "color": "gray"}\n ]\n },\n "ngay_sinh": {"type": "date"},\n "cccd": {"type": "text"},\n "thu_nhap": {"type": "number", "notation": {"style": "currency", "currency": "VND"}}\n },\n "records": {"subtitle": ["quan_he", "cccd"], "party": "person", "figure": "thu_nhap"}\n },\n {\n "ref": "giay_to",\n "title": "loai",\n "fields": {\n "loai": {\n "type": "select",\n "options": [\n {"alias": "don_dang_ky", "color": "blue"},\n {"alias": "can_cuoc", "color": "sky"},\n {"alias": "giay_doi_tuong", "color": "violet"},\n {"alias": "xac_nhan_nha_o", "color": "teal"},\n {"alias": "xac_nhan_thu_nhap", "color": "amber"},\n {"alias": "hon_nhan", "color": "indigo"}\n ]\n },\n "ho_so": {"type": "link", "table": "ho_so", "multi": false},\n "ngay_nhan": {"type": "date"},\n "tep": {"type": "files"},\n "phai_sua": {"type": "formula"}\n },\n "records": {"image": "tep", "starts": {"ngay_nhan": "today"}}\n },\n {\n "ref": "phieu_thu",\n "title": "khoan",\n "fields": {\n "khoan": {\n "type": "select",\n "options": [\n {"alias": "dat_coc", "color": "blue"},\n {"alias": "tat_toan", "color": "indigo"},\n {"alias": "phi_ho_so", "color": "violet"}\n ]\n },\n "ho_so": {"type": "link", "table": "ho_so", "multi": false},\n "so_tien": {"type": "number", "notation": {"style": "currency", "currency": "VND"}},\n "hinh_thuc": {\n "type": "select",\n "options": [\n {"alias": "tien_mat", "color": "green", "mark": {"kind": "icon", "name": "banknote"}},\n {"alias": "chuyen_khoan", "color": "blue", "mark": {"kind": "icon", "name": "arrow-right-left"}}\n ]\n },\n "trang_thai": {\n "type": "select",\n "options": [\n {"alias": "cho_thu", "color": "amber"},\n {"alias": "da_thu", "color": "green"},\n {"alias": "huy", "color": "gray"}\n ]\n },\n "ngay_thu": {"type": "date"}\n },\n "records": {\n "subtitle": ["hinh_thuc", "ngay_thu"],\n "figure": "so_tien",\n "status": {"field": "trang_thai", "closed": ["da_thu", "huy"]}\n }\n }\n ],\n "templates": [\n {"alias": "don_dang_ky", "type": "html", "label": "\u0110\u01A1n \u0111\u0103ng k\xFD mua nh\xE0 \u1EDF x\xE3 h\u1ED9i"},\n {"alias": "mau_02_chua_co_nha", "type": "html", "label": "M\u1EABu 02 \u2014 X\xE1c nh\u1EADn ch\u01B0a c\xF3 nh\xE0 \u1EDF"},\n {"alias": "mau_03_nha_chat", "type": "html", "label": "M\u1EABu 03 \u2014 X\xE1c nh\u1EADn nh\xE0 \u1EDF d\u01B0\u1EDBi 15 m\xB2/ng\u01B0\u1EDDi"},\n {"alias": "mau_05_thu_nhap", "type": "html", "label": "M\u1EABu 05 \u2014 X\xE1c nh\u1EADn thu nh\u1EADp"},\n {"alias": "danh_sach_nop", "type": "html", "label": "Danh s\xE1ch h\u1ED3 s\u01A1 n\u1ED9p ch\u1EE7 \u0111\u1EA7u t\u01B0"}\n ],\n "body": {\n "type": "App",\n "key": "ho_so",\n "name": "H\u1ED3 s\u01A1 nh\xE0 \u1EDF x\xE3 h\u1ED9i",\n "icon": "folder-open",\n "color": "blue",\n "scope": "du_an",\n "children": [\n {\n "type": "List",\n "entity": "ho_so",\n "row": {"title": {"add": true}},\n "columns": ["xu_ly", "can_bo_sung", "doi_tuong"],\n "filters": ["xu_ly", "can_bo_sung"],\n "search": ["khach_hang", "ma_ho_so", "dien_thoai"],\n "create": ["khach_hang", "dich_vu", "doi_tuong", "tinh_trang_nha_o", "loai_can", "phi_dich_vu", "sale"],\n "children": [\n {\n "type": "Reading",\n "key": "register_reading_0",\n "label": "Ch\u1EDD kh\xE1ch b\u1ED5 sung",\n "where": {"can_bo_sung": true}\n },\n {\n "type": "Reading",\n "key": "register_reading_1",\n "label": "Ph\xED \u0111\xE3 thu",\n "value": "da_thu",\n "over": "ngay_nhan_giay_to"\n },\n {"type": "Reading", "key": "register_reading_2", "by": "doi_tuong"},\n {"type": "Export", "key": "export"},\n {"type": "Check", "field": "chua_thu_phi", "on": ["nop_cdt"]},\n {"type": "Check", "field": "con_no_phi"},\n {"type": "Check", "field": "vuot_thu_nhap"},\n {"type": "Check", "field": "da_hoan_tat", "on": ["doc_giay_to"]},\n {\n "type": "Record",\n "children": [\n {\n "type": "Section",\n "key": "nguoi_dung_don",\n "title": "Ng\u01B0\u1EDDi \u0111\u1EE9ng \u0111\u01A1n",\n "children": [\n "ma_ho_so",\n "dien_thoai",\n "cccd",\n "ngay_sinh",\n "dia_chi",\n "doi_tuong",\n "tinh_trang_nha_o",\n "sale",\n "xu_ly",\n "can_bo_sung",\n "ngay_ky_hdmb",\n "ngay_ban_giao",\n {\n "type": "List",\n "entity": "thanh_vien",\n "label": "H\u1ED9 gia \u0111\xECnh",\n "columns": ["ngay_sinh"],\n "create": ["ho_ten", "quan_he", "ngay_sinh", "cccd", "thu_nhap"]\n },\n {\n "type": "List",\n "entity": "giay_to",\n "expect": {"field": "loai", "only": ["can_cuoc", "hon_nhan"]},\n "label": "Gi\u1EA5y t\u1EDD c\xE1 nh\xE2n",\n "columns": ["ngay_nhan"],\n "children": [{"type": "Check", "field": "phai_sua"}]\n },\n {\n "type": "Act",\n "key": "doc_giay_to",\n "label": "\u0110\u1ECDc gi\u1EA5y t\u1EDD",\n "intake": "giay_to",\n "fills": [\n "doi_tuong",\n "tinh_trang_nha_o",\n "giay_chung_nhan_so",\n "dien_tich_nha",\n "khach_hang.ho_ten",\n "khach_hang.cccd",\n "khach_hang.ngay_sinh",\n "khach_hang.dia_chi",\n "thanh_vien"\n ]\n },\n {\n "type": "Act",\n "key": "giao_xu_ly",\n "label": "Giao ng\u01B0\u1EDDi x\u1EED l\xFD",\n "asks": [{"input": "nguoi", "label": "Ng\u01B0\u1EDDi x\u1EED l\xFD", "type": "member", "required": true}],\n "set": {"xu_ly": {"input": "nguoi"}}\n },\n {\n "type": "Act",\n "key": "chuyen_du_an",\n "label": "Chuy\u1EC3n d\u1EF1 \xE1n",\n "asks": [\n {\n "input": "du_an_moi",\n "label": "Sang d\u1EF1 \xE1n",\n "type": "link",\n "entity": "du_an",\n "required": true,\n "add": ["ten_du_an", "chu_dau_tu", "so_can"]\n }\n ],\n "set": {"du_an": {"input": "du_an_moi"}},\n "confirm": true\n }\n ]\n },\n {\n "type": "Section",\n "key": "bo_ho_so",\n "title": "B\u1ED9 h\u1ED3 s\u01A1",\n "at": ["ngay_nhan_giay_to", "ngay_lap_ho_so"],\n "children": [\n "ngay_nhan_giay_to",\n "ngay_lap_ho_so",\n {\n "type": "Act",\n "key": "tao_giay_to",\n "label": "T\u1EA1o gi\u1EA5y t\u1EDD",\n "templates": [\n {"template": "don_dang_ky"},\n {"template": "mau_02_chua_co_nha", "when": {"tinh_trang_nha_o": ["chua_co_nha"]}},\n {"template": "mau_03_nha_chat", "when": {"tinh_trang_nha_o": ["nha_chat"]}},\n {"template": "mau_05_thu_nhap", "when": {"doi_tuong": ["thu_nhap_thap"]}}\n ],\n "into": "bo_ho_so",\n "when": {"giai_doan": ["ngay_nhan_giay_to", "ngay_lap_ho_so"]}\n },\n {\n "type": "Act",\n "key": "nop_cdt",\n "label": "N\u1ED9p ch\u1EE7 \u0111\u1EA7u t\u01B0",\n "when": {"dich_vu": ["tron_goi"], "giai_doan": ["ngay_lap_ho_so"]},\n "requires": ["bo_ho_so", "loai_can"],\n "asks": ["loai_can"],\n "set": {"giai_doan": "ngay_nop_cdt"},\n "confirm": true\n },\n {\n "type": "Act",\n "key": "to_ngay_nhan_giay_to",\n "label": "\u0110\xE3 nh\u1EADn gi\u1EA5y t\u1EDD",\n "when": {"giai_doan": {"empty": true}, "dich_vu": ["tron_goi"]},\n "asks": [{"field": "ngay_nhan_giay_to", "starts": "today"}],\n "set": {"giai_doan": "ngay_nhan_giay_to"}\n },\n {\n "type": "Act",\n "key": "to_ngay_lap_ho_so",\n "label": "\u0110\xE3 l\u1EADp h\u1ED3 s\u01A1",\n "when": {"giai_doan": ["ngay_nhan_giay_to"], "dich_vu": ["tron_goi"]},\n "asks": [{"field": "ngay_lap_ho_so", "starts": "today"}],\n "set": {"giai_doan": "ngay_lap_ho_so"}\n },\n {\n "type": "Act",\n "key": "to_ngay_ban_giao",\n "label": "\u0110\xE3 b\xE0n giao h\u1ED3 s\u01A1",\n "when": {"giai_doan": ["ngay_nhan_giay_to"], "dich_vu": ["lam_ho_so"]},\n "asks": [{"field": "ngay_ban_giao", "starts": "today"}],\n "set": {"giai_doan": "ngay_ban_giao"}\n }\n ]\n }\n ]\n },\n {\n "type": "Act",\n "key": "in_danh_sach",\n "label": "In danh s\xE1ch n\u1ED9p ch\u1EE7 \u0111\u1EA7u t\u01B0",\n "on": "view",\n "template": "danh_sach_nop"\n }\n ]\n }\n ]\n }\n}\n```\n\n## Calls\n\n**The job.** Call a lead from their record, and keep what was said as a touch on its timeline.\n\n**Read.** An Act\'s `record` (into the `timeline` child: audio, transcript, when and who) and `fills` (the touch\'s fields an agent fills from the transcript).\n\n**Keys.** `model/act-node`, `model/layouts`\n\n**Not when.** Calls made and logged elsewhere \u2014 a `timeline` with its own add keeps the note.\n\n```json\n{\n "tables": [\n {\n "ref": "khach_tiem_nang",\n "title": "ho_ten",\n "fields": {\n "ho_ten": {"type": "text"},\n "ten_cong_ty": {"type": "text"},\n "vai_tro": {\n "type": "select",\n "options": [\n {"alias": "chu_doanh_nghiep", "color": "emerald"},\n {"alias": "ke_toan_truong", "color": "blue"},\n {"alias": "quan_ly", "color": "teal"},\n {"alias": "nhan_vien", "color": "sky"},\n {"alias": "chua_ro", "color": "zinc"}\n ]\n },\n "loai_hinh": {\n "type": "select",\n "options": [\n {"alias": "thuong_mai", "color": "blue"},\n {"alias": "san_xuat", "color": "amber"},\n {"alias": "dich_vu", "color": "violet"},\n {"alias": "xay_dung", "color": "orange"},\n {"alias": "ho_kinh_doanh", "color": "teal"},\n {"alias": "chua_ro", "color": "zinc"}\n ]\n },\n "quy_mo": {\n "type": "select",\n "options": [\n {"alias": "q_1_10", "color": "sky"},\n {"alias": "q_11_50", "color": "blue"},\n {"alias": "q_51_200", "color": "indigo"},\n {"alias": "q_200", "color": "violet"},\n {"alias": "chua_ro", "color": "zinc"}\n ]\n },\n "so_dien_thoai": {"type": "text"},\n "zalo": {"type": "text"},\n "email": {"type": "text"},\n "trang_ca_nhan": {"type": "text", "format": "link"},\n "anh": {"type": "files"},\n "trang_thai": {\n "type": "select",\n "options": [\n {"alias": "moi", "color": "gray"},\n {"alias": "du_dieu_kien", "color": "blue"},\n {"alias": "da_lien_he", "color": "amber"},\n {"alias": "da_chuyen_doi", "color": "emerald"},\n {"alias": "loai", "color": "red"}\n ]\n },\n "ly_do_loai": {\n "type": "select",\n "options": [\n {"alias": "khong_phu_hop", "color": "orange"},\n {"alias": "qua_nho", "color": "amber"},\n {"alias": "chua_co_ngan_sach", "color": "yellow"},\n {"alias": "sai_nguoi", "color": "blue"},\n {"alias": "yeu_cau_dung", "color": "red"},\n {"alias": "trung", "color": "zinc"}\n ]\n },\n "vi_sao_du_dieu_kien": {"type": "text"},\n "phu_trach": {"type": "member"},\n "nguon": {"type": "link", "table": "kenh", "multi": false},\n "lien_he": {"type": "link", "table": "lien_he", "multi": false},\n "cong_ty": {"type": "link", "table": "cong_ty", "multi": false},\n "ngay_chuyen_doi": {"type": "date", "format": "datetime", "timezone": "Asia/Ho_Chi_Minh"},\n "ghi_chu": {"type": "text", "format": "markdown"},\n "tin_hieu": {"type": "link", "table": "tin_hieu", "multi": true},\n "buoc_tiep": {"type": "link", "table": "buoc_tiep", "multi": true},\n "tuong_tac": {"type": "link", "table": "tuong_tac", "multi": true},\n "do_nong": {\n "type": "formula",\n "options": [\n {"alias": "nong", "label": "N\xF3ng", "color": "orange"},\n {"alias": "nguoi", "label": "Ngu\u1ED9i", "color": "sky"}\n ]\n },\n "han_buoc_tiep": {"type": "rollup"},\n "khong_lien_he": {"type": "formula"}\n },\n "records": {\n "subtitle": ["ten_cong_ty"],\n "image": "anh",\n "party": "person",\n "due": ["han_buoc_tiep"],\n "status": {\n "field": "trang_thai",\n "closed": ["da_chuyen_doi", "loai"],\n "dates": {"da_chuyen_doi": "ngay_chuyen_doi"}\n }\n }\n },\n {\n "ref": "tin_hieu",\n "title": "tom_tat",\n "fields": {\n "tom_tat": {"type": "text"},\n "noi_dung": {"type": "text", "format": "markdown"},\n "anh_chup": {"type": "files"},\n "lien_ket": {"type": "text", "format": "link"},\n "kenh": {"type": "link", "table": "kenh", "multi": false},\n "loai": {\n "type": "select",\n "options": [\n {"alias": "hoi_dich_vu", "color": "emerald"},\n {"alias": "than_phien", "color": "blue"},\n {"alias": "tuyen_ke_toan", "color": "amber"},\n {"alias": "moi_thanh_lap", "color": "violet"},\n {"alias": "gioi_thieu", "color": "teal"},\n {"alias": "danh_ba", "color": "zinc"},\n {"alias": "nhieu", "color": "stone"}\n ]\n },\n "ngay_dang": {"type": "date", "timezone": "Asia/Ho_Chi_Minh"},\n "ngay_xay_ra": {"type": "formula"}\n },\n "records": {"subtitle": ["kenh", "ngay_xay_ra"], "image": "anh_chup"}\n },\n {\n "ref": "buoc_tiep",\n "title": "vi_sao_luc_nay",\n "fields": {\n "vi_sao_luc_nay": {"type": "text"},\n "viec": {\n "type": "select",\n "options": [\n {"alias": "binh_luan", "color": "blue", "mark": {"kind": "icon", "name": "message-square"}},\n {"alias": "nhan_facebook", "color": "violet", "mark": {"kind": "brand", "name": "facebook"}},\n {"alias": "nhan_zalo", "color": "sky", "mark": {"kind": "brand", "name": "zalo"}},\n {"alias": "goi_dien", "color": "teal", "mark": {"kind": "icon", "name": "phone"}},\n {"alias": "gui_email", "color": "indigo", "mark": {"kind": "icon", "name": "mail"}},\n {"alias": "gui_bang_gia", "color": "amber", "mark": {"kind": "icon", "name": "receipt"}}\n ]\n },\n "ban_nhap": {"type": "text", "format": "markdown"},\n "han": {"type": "date", "format": "datetime", "timezone": "Asia/Ho_Chi_Minh"},\n "tinh_trang": {\n "type": "select",\n "options": [\n {"alias": "cho", "color": "blue"},\n {"alias": "xong", "color": "emerald"},\n {"alias": "bo_qua", "color": "zinc"}\n ]\n }\n },\n "records": {\n "subtitle": ["viec"],\n "mark": "viec",\n "due": ["han"],\n "status": {"field": "tinh_trang", "closed": ["xong", "bo_qua"]}\n }\n },\n {\n "ref": "tuong_tac",\n "title": "tom_tat",\n "fields": {\n "tom_tat": {"type": "text"},\n "thoi_diem": {"type": "date", "format": "datetime", "timezone": "Asia/Ho_Chi_Minh"},\n "qua": {\n "type": "select",\n "options": [\n {"alias": "goi_dien", "color": "teal", "mark": {"kind": "icon", "name": "phone"}},\n {"alias": "zalo", "color": "sky", "mark": {"kind": "brand", "name": "zalo"}},\n {"alias": "facebook", "color": "blue", "mark": {"kind": "brand", "name": "facebook"}},\n {"alias": "email", "color": "indigo", "mark": {"kind": "icon", "name": "mail"}},\n {"alias": "gap_truc_tiep", "color": "emerald", "mark": {"kind": "icon", "name": "users"}},\n {"alias": "khac", "color": "zinc", "mark": {"kind": "icon", "name": "ellipsis"}}\n ]\n },\n "phan_hoi": {\n "type": "select",\n "options": [\n {"alias": "dang_cho", "color": "blue"},\n {"alias": "da_tra_loi", "color": "green"},\n {"alias": "quan_tam", "color": "amber"},\n {"alias": "khong_phu_hop", "color": "red"},\n {"alias": "khong_phan_hoi", "color": "zinc"},\n {"alias": "sai_so", "color": "stone"}\n ]\n },\n "ghi_am": {"type": "files"},\n "nguyen_van": {"type": "text", "format": "markdown"},\n "hen_lai": {"type": "date", "timezone": "Asia/Ho_Chi_Minh"},\n "nguoi_thuc_hien": {"type": "member"}\n },\n "records": {"subtitle": ["qua", "phan_hoi"]}\n }\n ],\n "body": {\n "type": "App",\n "key": "khach_tiem_nang",\n "name": "Kh\xE1ch ti\u1EC1m n\u0103ng",\n "icon": "radar",\n "color": "violet",\n "children": [\n {\n "type": "List",\n "entity": "khach_tiem_nang",\n "columns": ["phu_trach", "han_buoc_tiep"],\n "filters": ["phu_trach"],\n "create": [\n "ho_ten",\n "trang_ca_nhan",\n "so_dien_thoai",\n "ten_cong_ty",\n {"field": "nguon", "add": ["ten", "loai"]}\n ],\n "children": [\n {"type": "Reading", "key": "register_reading_0", "by": "phu_trach"},\n {\n "type": "Reading",\n "key": "register_reading_1",\n "label": "Kh\xE1ch chuy\u1EC3n \u0111\u1ED5i",\n "by": "ngay_chuyen_doi"\n },\n {"type": "Check", "field": "khong_lien_he", "resolve": "da_lien_he"},\n {\n "type": "Record",\n "children": [\n {\n "type": "Section",\n "key": "danh_gia",\n "title": "\u0110\xE1nh gi\xE1",\n "at": ["moi"],\n "children": [\n "vai_tro",\n "loai_hinh",\n "quy_mo",\n "vi_sao_du_dieu_kien",\n "do_nong",\n {\n "type": "List",\n "entity": "tin_hieu",\n "label": "T\xEDn hi\u1EC7u",\n "columns": ["loai", "noi_dung"],\n "create": ["tom_tat", "anh_chup", "loai", "noi_dung", "kenh", "lien_ket", "ngay_dang"]\n },\n {\n "type": "Act",\n "key": "danh_gia_dat",\n "label": "\u0110\u1EE7 \u0111i\u1EC1u ki\u1EC7n",\n "when": {"trang_thai": ["moi"]},\n "requires": ["vi_sao_du_dieu_kien"],\n "set": {"trang_thai": "du_dieu_kien"}\n }\n ]\n },\n {\n "type": "Section",\n "key": "lien_he",\n "title": "Li\xEAn h\u1EC7",\n "at": ["du_dieu_kien"],\n "children": [\n "so_dien_thoai",\n "phu_trach",\n "trang_ca_nhan",\n "zalo",\n "email",\n {"field": "ghi_chu", "block": false},\n {\n "type": "List",\n "entity": "buoc_tiep",\n "label": "B\u01B0\u1EDBc ti\u1EBFp theo",\n "columns": ["ban_nhap"],\n "create": ["vi_sao_luc_nay", "viec", "han", "ban_nhap"],\n "children": [\n {\n "type": "Act",\n "key": "lam_xong",\n "label": "\u0110\xE3 l\xE0m",\n "when": {"tinh_trang": ["cho"]},\n "set": {"tinh_trang": "xong"}\n }\n ]\n },\n {\n "type": "List",\n "entity": "tuong_tac",\n "label": "T\u01B0\u01A1ng t\xE1c",\n "layout": "timeline",\n "create": ["tom_tat", "qua", "phan_hoi", "thoi_diem", "hen_lai"]\n },\n {\n "type": "Act",\n "key": "ghi_cuoc_goi",\n "label": "Ghi \xE2m cu\u1ED9c g\u1ECDi",\n "when": {"trang_thai": ["du_dieu_kien"]},\n "fills": ["tom_tat", "phan_hoi"],\n "record": {\n "into": "tuong_tac",\n "audio": "ghi_am",\n "transcript": "nguyen_van",\n "at": "thoi_diem",\n "by": "nguoi_thuc_hien"\n }\n },\n {\n "type": "Act",\n "key": "da_lien_he",\n "label": "\u0110\xE1nh d\u1EA5u \u0111\xE3 li\xEAn h\u1EC7",\n "when": {"trang_thai": ["du_dieu_kien"]},\n "set": {"trang_thai": "da_lien_he"}\n }\n ]\n },\n {\n "type": "Section",\n "key": "chuyen_doi",\n "title": "Chuy\u1EC3n \u0111\u1ED5i",\n "at": ["da_lien_he"],\n "children": [\n "nguon",\n {"field": "lien_he", "edit": false},\n {"field": "cong_ty", "edit": false},\n {\n "type": "Act",\n "key": "chuyen_doi",\n "label": "Chuy\u1EC3n \u0111\u1ED5i",\n "when": {"trang_thai": ["da_lien_he"]},\n "workflow": "chuyen_doi_khach",\n "confirm": true\n }\n ]\n },\n {\n "type": "Section",\n "key": "loai",\n "title": "Lo\u1EA1i",\n "at": ["loai"],\n "children": [\n "ly_do_loai",\n {\n "type": "Act",\n "key": "loai",\n "label": "Lo\u1EA1i",\n "when": {"trang_thai": ["moi", "du_dieu_kien", "da_lien_he"]},\n "asks": ["ly_do_loai"],\n "set": {"trang_thai": "loai"},\n "danger": true\n }\n ]\n }\n ]\n }\n ]\n }\n ]\n }\n}\n```\n\n## Approval\n\n**The job.** Raise a payment request, see it through approval, and read what is waiting at the top of the list.\n\n**Read.** `records.de_nghi.status` with its `history` (each move: when and who), Sections staged by `at`, and the List\'s Readings leading with the headline number. The history stays because the business reads who signed each move; where only the day a stage was reached matters, the status stamps a date on the row instead (the Gantt above).\n\n**Keys.** `model/status`, `model/section-node`, `model/reading-node`\n\n**Not when.** Rows that never move \u2014 a reference list (a price list, a catalog) has no status and no headline number.\n\n```json\n{\n "tables": [\n {\n "ref": "de_nghi",\n "title": "noi_dung",\n "fields": {\n "noi_dung": {"type": "text"},\n "so_de_nghi": {"type": "text"},\n "loai": {\n "type": "select",\n "options": [\n {"alias": "thanh_toan_ncc", "color": "violet"},\n {"alias": "nhan_cong", "color": "indigo"},\n {"alias": "chi_phi_cong_truong", "color": "sky"},\n {"alias": "tam_ung", "color": "amber"},\n {"alias": "hoan_ung", "color": "teal"}\n ]\n },\n "du_an": {"type": "link", "table": "du_an", "multi": false},\n "nha_cung_cap": {"type": "link", "table": "nha_cung_cap", "multi": false},\n "so_tien": {"type": "number", "notation": {"style": "currency", "currency": "VND"}},\n "han_thanh_toan": {"type": "date"},\n "trang_thai": {\n "type": "select",\n "options": [\n {"alias": "nhap", "color": "slate"},\n {"alias": "cho_duyet", "color": "amber"},\n {"alias": "cho_chu_tich", "color": "violet"},\n {"alias": "da_duyet", "color": "blue"},\n {"alias": "da_chi", "color": "green"},\n {"alias": "tu_choi", "color": "rose"}\n ]\n },\n "nguoi_de_nghi": {"type": "member"},\n "chung_tu": {"type": "files"},\n "nguoi_duyet": {"type": "member"},\n "duyet_luc": {"type": "date", "format": "datetime"},\n "ly_do_tu_choi": {"type": "text"},\n "phieu_chi": {"type": "files"},\n "ngan_sach_du_an": {"type": "lookup"},\n "da_duyet_du_an": {"type": "lookup"},\n "vuot_ngan_sach": {"type": "formula"},\n "sat_ngan_sach": {"type": "formula"},\n "da_gui": {"type": "formula"},\n "nhap_at": {"type": "date", "format": "datetime"},\n "cho_duyet_at": {"type": "date", "format": "datetime"},\n "cho_chu_tich_at": {"type": "date", "format": "datetime"},\n "da_duyet_at": {"type": "date", "format": "datetime"}\n },\n "records": {\n "subtitle": ["du_an"],\n "figure": "so_tien",\n "limits": {"da_duyet_du_an": "ngan_sach_du_an"},\n "due": ["han_thanh_toan"],\n "starts": {"nguoi_de_nghi": "me"},\n "status": {\n "field": "trang_thai",\n "closed": ["da_chi", "tu_choi"],\n "dropped": ["tu_choi"],\n "dates": {\n "nhap": "nhap_at",\n "cho_duyet": "cho_duyet_at",\n "cho_chu_tich": "cho_chu_tich_at",\n "da_duyet": "da_duyet_at"\n },\n "history": "lich_su_duyet",\n "stages": {\n "nhap": {"owner": "nguoi_de_nghi"},\n "cho_duyet": {"owner": {"group": "<id of the giam_doc group>"}, "within": 1},\n "cho_chu_tich": {\n "owner": {"group": "<id of the chu_tich group>"},\n "within": 2,\n "when": {"so_tien": {"gt": 200000000}}\n },\n "da_duyet": {"owner": {"group": "<id of the ke_toan group>"}, "within": 2}\n }\n }\n }\n }\n ],\n "body": {\n "type": "App",\n "key": "de_nghi_chi",\n "name": "\u0110\u1EC1 ngh\u1ECB thanh to\xE1n",\n "icon": "hand-coins",\n "color": "amber",\n "children": [\n {\n "type": "List",\n "entity": "de_nghi",\n "row": {"subtitle": [{"field": "du_an", "add": ["ten", "ma_du_an", "chu_dau_tu", "ngan_sach"]}]},\n "columns": ["nguoi_de_nghi"],\n "filters": ["nguoi_de_nghi", "du_an"],\n "group": "du_an",\n "create": [\n "noi_dung",\n "loai",\n "du_an",\n {"field": "nha_cung_cap", "add": true},\n "so_tien",\n "han_thanh_toan",\n "nguoi_de_nghi"\n ],\n "children": [\n {\n "type": "Reading",\n "key": "register_reading_0",\n "label": "\u0110\xE3 duy\u1EC7t, ch\u1EDD chi",\n "value": "so_tien",\n "where": {"trang_thai": ["da_duyet"]}\n },\n {"type": "Reading", "key": "register_reading_1", "value": "so_tien", "by": "han_thanh_toan"},\n {"type": "Check", "field": "da_gui", "on": ["update"]},\n {"type": "Check", "field": "vuot_ngan_sach"},\n {"type": "Check", "field": "sat_ngan_sach"},\n {\n "type": "Record",\n "door": "drawer",\n "children": [\n {\n "type": "Section",\n "key": "de_nghi",\n "title": "\u0110\u1EC1 ngh\u1ECB",\n "at": ["nhap"],\n "children": [\n "so_de_nghi",\n "loai",\n "nha_cung_cap",\n "han_thanh_toan",\n "nguoi_de_nghi",\n {"field": "chung_tu", "label": "Ch\u1EE9ng t\u1EEB k\xE8m"},\n {\n "type": "Act",\n "key": "gui_duyet",\n "label": "G\u1EEDi duy\u1EC7t",\n "when": {"trang_thai": ["nhap"]},\n "requires": ["chung_tu", "nguoi_de_nghi"],\n "set": {"trang_thai": "cho_duyet"}\n }\n ]\n },\n {\n "type": "Section",\n "key": "duyet",\n "title": "Duy\u1EC7t",\n "at": ["cho_duyet", "cho_chu_tich"],\n "children": [\n {"field": "nguoi_duyet", "edit": false},\n {"field": "duyet_luc", "edit": false},\n {"field": "ly_do_tu_choi", "edit": false},\n {\n "type": "Act",\n "key": "rut_lai",\n "label": "R\xFAt l\u1EA1i",\n "when": {"trang_thai": ["cho_duyet", "cho_chu_tich"]},\n "set": {"trang_thai": "nhap"},\n "confirm": true\n }\n ]\n },\n {\n "type": "Section",\n "key": "da_chi",\n "title": "\u0110\xE3 chi",\n "at": ["da_chi"],\n "children": [{"field": "phieu_chi", "label": "Phi\u1EBFu chi", "edit": false}]\n }\n ]\n }\n ]\n }\n ]\n }\n}\n```\n\n## Dashboard\n\n**The job.** The owner\'s question \u2014 what came in, what went out, where it went, and what is still unmatched \u2014 answered over one period.\n\n**Read.** An App of Readings and no List: Readings with `over` (the period windows them), a Reading `by` the day, Readings `by` a field, and a List of rows to act on that opens in its `app`.\n\n**Keys.** `model/app-node`, `model/reading-node`\n\n**Not when.** One job\'s headline numbers \u2014 those lead that job\'s List as its Readings.\n\n```json\n{\n "tables": [\n {\n "ref": "giao_dich",\n "title": "noi_dung",\n "fields": {\n "noi_dung": {"type": "text"},\n "ngay": {"type": "date"},\n "loai": {"type": "select", "options": [{"alias": "thu", "color": "green"}, {"alias": "chi", "color": "rose"}]},\n "so_tien": {"type": "number", "notation": {"style": "currency", "currency": "VND"}},\n "bien_dong": {"type": "formula", "notation": {"style": "currency", "currency": "VND"}},\n "doi_tuong": {"type": "link", "table": "doi_tuong", "multi": false},\n "danh_muc": {\n "type": "select",\n "options": [\n {"alias": "doanh_thu", "color": "green"},\n {"alias": "hang_hoa", "color": "blue"},\n {"alias": "van_chuyen", "color": "indigo"},\n {"alias": "nhan_su", "color": "violet"},\n {"alias": "van_phong", "color": "sky"},\n {"alias": "thue_phi", "color": "slate"},\n {"alias": "ngan_hang", "color": "teal"},\n {"alias": "khac", "color": "gray"}\n ]\n },\n "sao_ke": {"type": "link", "table": "sao_ke", "multi": false},\n "hoa_don": {"type": "link", "table": "hoa_don", "multi": true},\n "trang_thai": {\n "type": "select",\n "options": [\n {"alias": "chua_khop", "color": "amber"},\n {"alias": "da_khop", "color": "green"},\n {"alias": "khong_can_hd", "color": "slate"}\n ]\n },\n "khop_luc": {"type": "date", "format": "datetime"}\n },\n "records": {\n "subtitle": ["ngay"],\n "figure": "bien_dong",\n "status": {"field": "trang_thai", "closed": ["da_khop", "khong_can_hd"], "dates": {"da_khop": "khop_luc"}}\n }\n },\n {\n "ref": "hoa_don",\n "title": "doi_tuong",\n "fields": {\n "doi_tuong": {"type": "link", "table": "doi_tuong", "multi": false},\n "so_hoa_don": {"type": "text"},\n "ngay_hoa_don": {"type": "date"},\n "so_ngay_qua_han": {"type": "formula", "notation": {"style": "unit", "unit": "day"}},\n "chieu": {\n "type": "select",\n "options": [{"alias": "ban_ra", "color": "green"}, {"alias": "mua_vao", "color": "violet"}]\n },\n "tong_cong": {"type": "formula", "notation": {"style": "currency", "currency": "VND"}},\n "thue_suat": {"type": "formula", "notation": {"style": "unit", "unit": "percent"}},\n "tep": {"type": "files"},\n "giao_dich": {"type": "link", "table": "giao_dich", "multi": true}\n },\n "records": {"subtitle": ["so_hoa_don", "ngay_hoa_don"], "image": "tep", "figure": "tong_cong"}\n }\n ],\n "body": {\n "type": "App",\n "key": "dong_tien",\n "name": "D\xF2ng ti\u1EC1n",\n "icon": "chart-line",\n "color": "emerald",\n "children": [\n {\n "type": "Reading",\n "key": "dashboard_reading_0",\n "label": "Ti\u1EC1n v\xE0o",\n "entity": "giao_dich",\n "value": "so_tien",\n "where": {"loai": ["thu"]},\n "over": "ngay"\n },\n {\n "type": "Reading",\n "key": "dashboard_reading_1",\n "label": "Ti\u1EC1n ra",\n "entity": "giao_dich",\n "value": "so_tien",\n "where": {"loai": ["chi"]},\n "over": "ngay",\n "better": "down"\n },\n {\n "type": "Reading",\n "key": "dashboard_reading_2",\n "label": "D\xF2ng ti\u1EC1n r\xF2ng",\n "entity": "giao_dich",\n "value": "bien_dong",\n "by": "ngay"\n },\n {\n "type": "Reading",\n "key": "dashboard_reading_3",\n "label": "Chi theo danh m\u1EE5c",\n "entity": "giao_dich",\n "value": "so_tien",\n "where": {"loai": ["chi"]},\n "by": "danh_muc",\n "over": "ngay"\n },\n {\n "type": "Reading",\n "key": "dashboard_reading_4",\n "label": "Thu theo kh\xE1ch h\xE0ng",\n "entity": "giao_dich",\n "value": "so_tien",\n "where": {"loai": ["thu"]},\n "by": "doi_tuong",\n "over": "ngay"\n },\n {\n "type": "Reading",\n "key": "dashboard_reading_5",\n "label": "C\xF4ng n\u1EE3 b\xE1n ra theo tu\u1ED5i n\u1EE3",\n "entity": "hoa_don",\n "value": "tong_cong",\n "where": {"chieu": ["ban_ra"]},\n "by": "doi_tuong",\n "across": {"field": "so_ngay_qua_han", "tiers": [1, 8, 16, 31]}\n },\n {\n "type": "Reading",\n "key": "dashboard_reading_6",\n "label": "S\u1ED1 ng\xE0y qu\xE1 h\u1EA1n b\xECnh qu\xE2n",\n "entity": "hoa_don",\n "value": {"avg": "so_ngay_qua_han"},\n "where": {"chieu": ["ban_ra"]}\n },\n {\n "type": "Reading",\n "key": "dashboard_reading_7",\n "label": "Thu\u1EBF su\u1EA5t b\xECnh qu\xE2n",\n "entity": "hoa_don",\n "value": {"avg": "thue_suat"}\n },\n {\n "type": "List",\n "key": "dashboard_reading_8",\n "entity": "giao_dich",\n "where": {"trang_thai": ["chua_khop"]},\n "app": "doi_chieu",\n "label": "D\xF2ng ch\u01B0a kh\u1EDBp",\n "columns": ["sao_ke"]\n }\n ]\n }\n}\n```\n';
|
|
1768
1769
|
|
|
1769
1770
|
// docs/agent_surface.md
|
|
1770
|
-
var agent_surface_default = '# An app\'s agent surface\n\nEverything a person reads or does in an app, an agent reads or does through that app\'s **queries** and\n**workflows** \u2014 the same ones its screen runs, under the same permissions and the same rules. Three tools\nreach them, over MCP and as `lotics run <tool>`:\n\n| Tool | Answers |\n|---|---|\n| `get_app` | The app\'s `catalog`: its 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`) \u2014 beside what each table they name holds, in its owner\'s words |\n| `run_app_query` | The rows, a count or the groups of one query \u2014 or of several in one call |\n| `run_app_workflow` | One write \u2014 or several in order \u2014 or a dry run of it that writes nothing |\n\nAn app generates this surface from its tree; Query and Workflow nodes declare what the screen never\nasks (\xA7 Declaring more). An app\'s logs (its further Lists at the root, which a change may add at any time) are part of it: a log\'s list, its rows by id, its record\'s\nLists and its Acts are queries and workflows of the same app, named by the log\'s table as the register\'s\nare by its own \u2014 `get_app` 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\n(`app_bindings`).\n\n## Finding what an app offers\n\n`get_workspace` maps the workspace. Its `search` matches the keys and descriptions of the Query and Workflow nodes an app declares, and the app\'s line names the matching keys; it never matches the generated `act_`, `create_` or `update_` aliases. `get_app` then gives the contracts. An agent runs a declared node before it falls back to `aggregate_records`.\n\n## What every app generates\n\nA name in angle brackets is the app\'s alias for that thing; an `<entity>` is the alias of a table the app reads. `get_app` lists the ones a given\napp has.\n\n### Queries\n\n<!-- generated:start queries -->\n\n| Called | What it says of itself | Takes | Generated for |\n|---|---|---|---|\n| `<entity>_list` | Every row of <entity> the reader may see. | `search`?; `<scope entity>_id` where the app states `scope`; `<filter>_is`? per List `filters` field holding one value \u2014 a select, a one-row link, a member, a yes/no; `<filter>_from`? / `<filter>_to`? per date filter \u2014 none for a date holding a span; `<filter>_min`? / `<filter>_max`? per number filter \u2014 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 |\n| `<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 Tab of the register; none over Books, none for a Tab whose period reads a date holding a span |\n| `<entity>_totals_<tab>` | The <child> rows added per <entity> row, by "<link>", over the period from `from` to `to`, dated by "<date>": . | `from`; `to` | each Tab of the register stating `totals` |\n| `<entity>_record` | One row of <entity> by its id. | `<entity>_id` | every entity the app draws |\n| `<child>_by_<link>` | The <child> rows under one <entity>, through "<link>". | `<entity>_id` | each List of child rows on the record; one narrowed by `where` or ordered by `sort` adds `_<field>_<option>` and `_sorted_<field>` to its name |\n| `outside_<key>_one` | One row of <entity> by its id. | `record` | each Outside: the record a link opens, projected to the fields the Outside states |\n| `outside_<key>_<child>` | The <child> rows under one <entity>, through "<link>". | `record` | each List of an Outside: the rows under the record a link opens, projected to the List\'s columns |\n| `<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` | a roster List: the List of its cells |\n| `<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`) |\n| `<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 List with `pick`: it picks existing rows into the record |\n| `<reading key>` | <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 Reading of the register List, called by its `key` (`register_reading_<n>`, n counted from 0, only for an app stored before trees) \u2014 but the work against its plan (Progress), which reads the list\'s own rows; none over Books |\n| `<reading key>` | <label>: how many <entity> rows there are. Each row carries `__count`. | `from`? / `to`? where the reading states `over` | each Reading of a dashboard, called by its `key` (`dashboard_reading_<n>` only for an app stored before trees) |\n| `<reading key>` | <label>: how many <child> rows there are. Each row carries `__count`. | `<entity>_id`; `same_<field>` where it is a list stating `same` | each Reading of the record and each List or funnel on it, called by its `key` (`block_reading_<n>` only for an app stored before trees) |\n| `<entity>_<key>` | <label>: how many <entity> rows there are. Each row carries `__count`. | the list\'s; `from`? / `to`? where the reading states `over` | each Reading of a log\'s List, called `<entity>_<key>` (`<entity>_register_reading_<n>` only for an app stored before trees) |\n| `<entity>_<key>` | <label>: how many <child> rows there are. Each row carries `__count`. | `<entity>_id`; `same_<field>` where it is a list stating `same` | each Reading of the record a row of a Book or of a log opens and each List or funnel on it, called `<entity>_<key>` by the Book\'s or the log\'s entity (`<entity>_block_reading_<n>` only for an app stored before trees) |\n| `<reading>_bound` | <label> / <bound>: the sum of "<bound>" over the <entity> rows. Each row carries `__count`, `__sum`. | its reading\'s | a Reading whose value `records.limits` bounds: the bound\'s sum, beside the Reading\'s own |\n| `<reading>_on_hand` | <label> / <on-hand entity>: how many <on-hand entity> rows there are, per "<field>". Each row carries `<field>`, `__count`. | none: every row of its entity its `where` keeps, whatever the place | a Reading with `on_hand` (a shortfall): the on-hand rows\' groups, beside the Reading\'s own |\n| `<entity>_export_rows` | Every row of <entity> the reader may see, with every field "<template>" may print. | the list\'s | an Export of the register that fills a template |\n| `<entity>_export_rows_of_<book>` | Every row of <entity> the reader may see, with every field "<template>" may print. | the list\'s | an Export over some of an app\'s Books: their rows alone, each Book named in turn |\n| `<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`) |\n| `<entity>_plan_load` | How many <entity> rows each <place> holds, over every table the register reads \u2014 the rows with no <place> counted apart. | | a register laid out as a `plan`: its places\' counts of present rows |\n\n<!-- generated:end queries -->\n\nA reading\'s query returns groups, never the rows it read: one row for all of them, or one per value it\nsplits by, or per day. Each row carries the alias of each field it splits by, `__day` where it splits by\nday, `__count`, and `__sum` where it sums a number; its description states which. An average is `__sum`\nover `__count`, both over the rows holding a value.\n\nA Reading is called by its `key` (`model/reading-node`), so moving it in the tree changes no name. A log\'s Reading is called by the\nlog\'s table before the name: `<entity>_<key>`.\n\nA register reading\'s query reads the rows it names, whatever a screen has open: the rows of its `tab` where\nit states one, every tab\'s where it states none \u2014 its description says which. One placed in time (`over`)\nreads every day unless the call states `from` or `to`.\n\n### Queries the screen runs for itself\n\nThey run like any other, and `get_app` leaves them out: each feeds one control, and takes ids\nthe screen already holds.\n\n<!-- generated:start screen-queries -->\n\n| Called | What it says of itself | Takes | Generated for |\n|---|---|---|---|\n| `<child>_<link>_target` | One <entity> row by its id, where a <child> row\'s "<link>" may point at it. | `<entity>_id` | a List with `pick` whose link a write rule narrows |\n| `<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`) |\n| `<entity>_pictures` | The picture and name of each <entity> row among the ids handed in. | `<entity>_ids` | a link to several pictured rows |\n| `<entity>_count_by_<scope>` | How many <entity> rows each <scope entity> holds. | | an app stating `scope` |\n| `<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 |\n| `<rows>_measured_by_<link>` | The share of each <entity> row handed in its <rows> rows add, day by day. | `<entity>_ids` | a Progress node stating `measured` |\n| `<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` |\n| `<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`) |\n\n<!-- generated:end screen-queries -->\n\n### Workflows\n\n<!-- generated:start workflows -->\n\n| Called | What it says of itself | Takes | Generated for |\n|---|---|---|---|\n| `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 |\n| `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 \u2014 a files field or a link to several rows as `<field>_added` / `<field>_removed` | an entity the app edits |\n| `remove_<entity>` | Removes one row of <entity>. | `record_id` | an entity the app removes rows of |\n| `act_<act>` | <label> \u2014 on one row of <entity>, refused where its conditions do not hold. | `record_id` \u2014 `record_ids` on an act over several rows \u2014 and what the act asks | each Act that names no workflow of its own |\n| `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`) |\n| `<workflow>_<book>` | The workflow of that name as a Book of the app, or a log of it, runs it, where its own table or page makes it differ. | that workflow\'s | an app with Books or logs |\n\n<!-- generated:end workflows -->\n\nA Workflow node is called by its `key`; an Act that names its own `workflow` is called by that workflow\'s alias, and every other Act by `act_<key>`.\n\n## Calling a query\n\n```\nget_app {"app_id":"app_\u2026"}\nrun_app_query {"app_id":"app_\u2026","alias":"order_list","params":{"status_is":"open","due_on_to":"2031-03-14"}}\n```\n\n| Argument | Does |\n|---|---|\n| `params` | The query\'s declared params. A required one is always sent; an optional one left out narrows nothing. A select takes the option\'s alias as `get_app` lists it in `select(label=alias|\u2026)` \u2014 a link a record id, a member a member id, a date `yyyy-mm-dd` |\n| `filters` | Narrows the result further, by the query\'s output columns (`filters`) |\n| `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 |\n| `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 |\n| `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` \u2014 a map of name \u2192 filter, never beside `count_by` \u2014 `counts` per name. Never with `fields`, `sort`, `limit` or `cursor` |\n| `aggregate` | Groups of the result in place of its rows, one row per group: `by` (up to 3 columns), `bands` (a number column of `by` grouped by its breakpoints), 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. Buckets are by day only: a period\'s total is one call per period, with `from` and `to` or a date filter |\n| `limit`, `cursor` | A page \u2014 200 rows at most, 50 where no `limit` is stated \u2014 and the `next_cursor` of the page before it; `truncated` says more rows matched than `rows` holds |\n| `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 |\n\nA query that states its own `limit` lists at most that many rows: `truncated` is `true` where more match, and `total` is\nthe rows the filtered query matches without it. A `count`, `count_by`, `count_where` and an `aggregate` are over every row\nthe query matches, never over the rows its `limit` lists.\n\nThe rows of one call, across its reads, total at most 262,144 bytes: a read past that is refused, naming\n`fields` and `limit` as what narrows it. The names `filters`, `fields`, `sort`, `count_by` and `aggregate`\ntake are the ones on the query\'s `returns` line.\n\nA list\'s params are how a person narrows that register: `search`, the row\'s id on an app that works inside\none row (`scope`), and a param for each filter the register states that one value or a range narrows. A\nfilter with no param \u2014 on a looked-up link or member, on a number read in each row\'s own unit or currency,\non a date holding a span, or on a register of several tables (Books) \u2014 is narrowed by `filters`, where a\nnumber condition names the unit its value is in as `unit_option`, an option of the figure\'s select by label\nor key. Each tab of a register is a query of its own, taking the list\'s params and the period its rows are\nread over.\n\n## Calling a workflow\n\n```\nrun_app_workflow {"app_id":"app_\u2026","alias":"act_hand_over","inputs":{"record_id":"rec_\u2026"},"dry_run":true}\nrun_app_workflow {"app_id":"app_\u2026","alias":"act_hand_over","inputs":{"record_id":"rec_\u2026"}}\n```\n\n| Argument | Does |\n|---|---|\n| `inputs` | The workflow\'s declared inputs. An update takes the record and only the fields that change |\n| `dry_run` | `true`: the run is made and taken back, so nothing is kept, and the answer is what the run would return, with the calls it would make \u2014 or `status: "not_previewed"` where the workflow cannot be dry-run (\xA7 A dry run) |\n| `as_member` | With `dry_run`, for a workspace admin: the run is judged as that member would press it \u2014 their stage owners, row rules and app access \u2014 and writes nothing. Anyone else is refused; `dry_run` on a record tool takes it the same way, and so do `query_records`, `aggregate_records` and `get_record`, which answer what that member would read |\n| `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 dry-run 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 `dry_run`, or takes the call\'s. Dry runs one after another share their rows, taken back together, so a run reads what the one before it made; a run reads a dry run\'s answer only from a run dry-run in that stretch |\n\n```\nrun_app_workflow {"app_id":"app_\u2026","runs":[\n {"alias":"create_order","inputs":{"customer":"rec_\u2026","item":"rec_\u2026","due_on":"2031-03-14"}},\n {"alias":"act_hand_over","inputs":{"record_id":{"from_run":0,"path":"data.record_id"}}}\n]}\n```\n\n## A dry run\n\nA dry run is the run itself on the real inputs, taken back: it is made in a transaction that is rolled back, so a read after a write reads that write, a branch or a loop walks the values the run would hold, and a call fed by an earlier call\'s answer gets it. No row, number, event, audit entry or table workflow outlives it. A write of the workspace\'s rows is made by its table \u2014 checks, write rules, stage owners \u2014 and the first refusal is the dry run\'s answer. A connected service\'s read-only call is made; a service write, a send, a notification or a button that runs another workflow is not, and is answered as a "would" with its payload. A guard (`when`, `requires`, a check, a lock) refuses it with the press\'s own sentence. A paper it makes is rendered for the answer and its file is removed with the dry run; an act that makes a paper is dry-run like any other, never refused for it. A body that sets the record\'s status also answers `moves`. Its answer carries `status`,\n`message`, `field_errors` and `data` as the run would return them, and under `dry_run`:\n\n| Key | Holds |\n|---|---|\n| `available` | `true` |\n| `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 |\n| `reads` | The reads made for real, in order, each with its `input` and what it `returned` (cut at 2,000 characters, `truncated`), for the app\'s owner and an admin |\n| `papers` | Each paper rendered: its `step_id` and `filename`. The file is not kept |\n| `unpredicted` | The parts of the answer left out \u2014 `message`, `field_errors`, `data` \u2014 because each reads what a call the dry run did not make would answer |\n\nWhat a write sets off once it commits \u2014 a table\'s own workflows, its events \u2014 does not happen in a dry run.\n\nA workflow is not dry-run where its body reads the answer of a call that leaves the workspace, to branch,\nloop, check or feed another call, where it reads or writes rows after such a call (the run would find them as\nthat call left them), where it runs an agent, where it waits, where a step cannot be evaluated, fails\nfor a reason that is no table\'s refusal, or sends its call an input the call refuses, and where the run is past\nwhat one dry run walks \u2014 its steps, the passes of a loop, 50 reads, 50 writes or 20 seconds, which the runs of\none call share and the database holds it to: a lock wait that outlasts them ends the dry run. The\nanswer is then `status: "not_previewed"` with\n`dry_run: { "available": false, "reason" }`, and nothing ran \u2014 within `runs`, it ends the call. The same\nanswers a caller outside the app\'s organization.\n\n## A reviewed act\n\nAn act that states `review` runs a workflow that only proposes: call it with the inputs the act asks and it answers,\nwriting nothing, the changeset its body proposes in `data.review` \u2014 `table_id`, `creates` (each with its `key`) and\n`updates` (each with its `record_id`), each row\'s `data` holding only the fields that change, by field alias, with an\noptional `note` and `ticked` (`false` where the body leaves it for a person to choose), and `unplaced`, what was read\nand matched nothing; the platform adds a `sha` to every row. Call it again with the same inputs and `approved`,\n`{ "updates": [{ "record_id", "sha" }], "creates": [{ "key", "sha" }] }`, listing the rows to write with the `sha` each\ncarries in the proposal, copied as it came \u2014 never computed \u2014 and the platform writes those rows as that second call\nproposes them, each only where it proposes the values read:\n\n```\nrun_app_workflow {"app_id":"app_\u2026","alias":"match_statement","inputs":{"record_ids":["rec_\u2026"],"statement":"fil_\u2026"}}\nrun_app_workflow {"app_id":"app_\u2026","alias":"match_statement","inputs":{"record_ids":["rec_\u2026"],"statement":"fil_\u2026","approved":{"updates":[{"record_id":"rec_\u2026","sha":"9f2c\u2026"}],"creates":[{"key":"line-4","sha":"41d0\u2026"}]}}}\n```\n\nThe answer\'s `data.applied` names the run (`execution_id`), the records `updated`, what each held before (`previous`), each `created` record by its key,\nand each approved row `left` with its `reason`: `not_proposed` (the second call no longer proposes it), `not_acted`\n(an update of a record the act was not run on), `changed` (the second call proposes other values than were read \u2014 read\nthe proposal again), `empty` (an update changing no field), `missing`, `locked` or `conflict`. Every approved row is\nchecked before any is written, and all are written in one transaction or none; the status history of each row written\nthat moved is recorded by the same run. `applied.previous` holds, for each record in `updated`, what the fields the call wrote held before (null for an empty one): `update_records` with it as `set` puts them back, and `delete_records` of a `created` record takes that row away. An act over every\nrow in view takes no `record_ids`. A `file` input takes the id of a file uploaded to the workspace.\n\n## A refusal\n\nA workflow re-checks on the server what the screen shows of its own act or save: what an act requires, a\nduplicate, whose step it is, a row the caller may not see. Each write it makes also meets the table\'s own\nrules (a bound, a span, a stage\'s owner, its lock), which hold on every path a record is written by. A row its table\nlocked is refused in every app\'s act and save, naming who can change it; an app\'s own Check only makes a row read-only\nin that app. An unlock in an app\'s run is judged as the member it runs for, never the app\'s owner. A run refused\nbefore it writes writes nothing; one whose later write a table\'s rule refuses keeps the writes before it.\nEither answers `status: "error"` with the sentence a person would read as `message`, and under `field_errors`\nthe sentence for each input it was refused over. A value of the wrong type, a missing required input and an unknown\nalias are refused before the body runs: the call itself fails with the sentence, and `field_errors` where\nan input is at fault \u2014 within `runs`, a run refused for its inputs answers `status: "error"` in its place,\nand the runs before it stand. A refused read fails the same way, naming the param or the column; within\n`runs`, it answers its `error` and the other reads are still answered.\n\n## Declaring more\n\nWhat the business asks an agent in the app again and again, and no screen draws, is stated in the app\'s tree as Query and Workflow nodes, beside its\nActs: every figure or list whose definition is the business\'s own (open orders, what a customer owes, low stock) and every recurring operation (close the month, send to the accounting service, chase overdue invoices), each with a `description` written for an agent. A question asked once stays on `aggregate_records`. A Workflow gets an Act only where a person presses it; work only an agent does has no button. Both are checked and compiled when the tree is written (`set_app`; `model/query-node`, `model/workflow-node`).\n\n**A query** \u2014 the rows of one table, or one figure of them, in the app\'s own aliases:\n\n```jsonc\n{\n "type": "Query",\n "key": "orders_overdue",\n "description": "A customer\'s orders still open past the day they were due, longest overdue first.",\n "params": {\n "customer": { "type": "record_link", "entity": "customer", "required": false, "description": "Only this customer\'s orders." },\n "as_of": { "type": "date", "description": "The day an order is overdue on: due before it." }\n },\n "rows": "order",\n "fields": ["customer", "item", "due_on", "price"],\n "where": { "status": ["baking"], "customer": { "param": "customer" }, "due_on": { "to": { "param": "as_of" } } },\n "sort": { "field": "due_on" }\n}\n```\n\nIt reads under the table\'s read scope and, on the app\'s own table, exactly the rows its list reads \u2014\nso never the table of an app that states Books, whose list reads several tables. A condition reading an\noptional param narrows nothing when the call leaves it out; no param compares a number read in each row\'s\nown unit or currency, or a date holding a span. `"reading"` in place of `"fields"` returns a count or a sum \u2014\n`{ "value": "price", "by": "item" }` \u2014 as a Reading does. A Query, a Workflow and\na Reading\'s `key` take no name the app generates (`model/app.generated-name`).\n\n**A workflow** \u2014 a write no Act of the record makes, typed in the app\'s tree and its code set on the app:\n\n```jsonc\n{\n "type": "Workflow",\n "key": "import_bookings",\n "description": "Books every order a sheet lists, reusing an order the sheet already booked.",\n "inputs": {\n "sheet": { "type": "file", "description": "The order sheet, one order per row." },\n "booked_by": { "type": "member", "required": false, "description": "Who the orders are taken by; absent, the caller." }\n },\n "outputs": { "added": { "type": "number" }, "reused": { "type": "number" } }\n}\n```\n\n`set_app_workflow` sets its code (`workflows`). A query set on an app outside\nthe tree (`set_app_queries`, on the CLI or in chat) is removed by the next write, whose answer notes it; declared as a Query\nnode, it is the tree\'s. A caller sees the description and\neach input\'s `description` and nothing else, so each says what the value is and what leaving it out means;\nthe check notes one that says nothing.\n\n**Shape each for a caller that is not looking at a screen.** One job a person hands an agent \u2014 a paper read,\na message answered, a sheet brought in \u2014 is one declared workflow named for the job, never a chain of the\ngenerated per-row workflows. Take a list where the job covers many rows, a record\'s lines among them. A workflow\'s code\nthat writes more than one row makes them in one `save_record` workflow step, all or nothing, and recognises\nwhat exists by the identity the input itself carries, declared unique on its table, so the same input sent\ntwice updates instead of adding. Return the ids under `outputs`, so the next run can read them. Gate what\ncannot be undone behind an approval step.\n\n## Prove an app as an agent\n\nRun on one of the owner\'s own rows, moved only as far as its stage; a record made for the run takes a number and a stage move nobody asked for.\n\n1. `get_app` \u2014 every job the app\'s `description` names has an alias that does it or reads it.\n2. `run_app_query` the list with no param, then with each param a person would narrow by; `count: true`\n answers how many without the rows.\n3. `run_app_query` `<entity>_record` with one row\'s id, and each `<child>_by_<link>` under it.\n4. `run_app_workflow` an act with `dry_run: true`, then without.\n5. `run_app_workflow` an act its record is not ready for: the answer is `status: "error"` with the sentence\n the screen would show.\n6. `run_app_workflow` the app\'s job workflow with the input a person would hand it \u2014 the paper read, the row:\n it completes in one call. Call it again with the same input: nothing is added.\n';
|
|
1771
|
+
var agent_surface_default = '# An app\'s agent surface\n\nEverything a person reads or does in an app, an agent reads or does through that app\'s **queries** and\n**workflows** \u2014 the same ones its screen runs, under the same permissions and the same rules. Three tools\nreach them, over MCP and as `lotics run <tool>`:\n\n| Tool | Answers |\n|---|---|\n| `get_app` | The app\'s `catalog`: its 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`) \u2014 beside what each table they name holds, in its owner\'s words |\n| `run_app_query` | The rows, a count or the groups of one query \u2014 or of several in one call |\n| `run_app_workflow` | One write \u2014 or several in order \u2014 or a dry run of it that writes nothing |\n\nAn app generates this surface from its tree; Query and Workflow nodes declare what the screen never\nasks (\xA7 Declaring more). An app\'s logs (its further Lists at the root, which a change may add at any time) are part of it: a log\'s list, its rows by id, its record\'s\nLists and its Acts are queries and workflows of the same app, named by the log\'s table as the register\'s\nare by its own \u2014 `get_app` 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\n(`app_bindings`).\n\n## Finding what an app offers\n\n`get_workspace` maps the workspace. Its `search` matches the keys and descriptions of the Query and Workflow nodes an app declares, and the app\'s line names the matching keys; it never matches the generated `act_`, `create_` or `update_` aliases. `get_app` then gives the contracts. An agent runs a declared node before it falls back to `aggregate_records`.\n\n## What every app generates\n\nA name in angle brackets is the app\'s alias for that thing; an `<entity>` is the alias of a table the app reads. `get_app` lists the ones a given\napp has.\n\n### Queries\n\n<!-- generated:start queries -->\n\n| Called | What it says of itself | Takes | Generated for |\n|---|---|---|---|\n| `<entity>_list` | Every row of <entity> the reader may see. | `search`?; `<scope entity>_id` where the app states `scope`; `<filter>_is`? per List `filters` field holding one value \u2014 a select, a one-row link, a member, a yes/no; `<filter>_from`? / `<filter>_to`? per date filter \u2014 none for a date holding a span; `<filter>_min`? / `<filter>_max`? per number filter \u2014 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 |\n| `<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 Tab of the register; none over Books, none for a Tab whose period reads a date holding a span |\n| `<entity>_totals_<tab>` | The <child> rows added per <entity> row, by "<link>", over the period from `from` to `to`, dated by "<date>": . | `from`; `to` | each Tab of the register stating `totals` |\n| `<entity>_record` | One row of <entity> by its id. | `<entity>_id` | every entity the app draws |\n| `<child>_by_<link>` | The <child> rows under one <entity>, through "<link>". | `<entity>_id` | each List of child rows on the record; one narrowed by `where` or ordered by `sort` adds `_<field>_<option>` and `_sorted_<field>` to its name |\n| `outside_<key>_one` | One row of <entity> by its id. | `record` | each Outside: the record a link opens, projected to the fields the Outside states |\n| `outside_<key>_<child>` | The <child> rows under one <entity>, through "<link>". | `record` | each List of an Outside: the rows under the record a link opens, projected to the List\'s columns |\n| `<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` | a roster List: the List of its cells |\n| `<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`) |\n| `<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 List with `pick`: it picks existing rows into the record |\n| `<reading key>` | <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 Reading of the register List, called by its `key` (`register_reading_<n>`, n counted from 0, only for an app stored before trees) \u2014 but the work against its plan (Progress), which reads the list\'s own rows; none over Books |\n| `<reading key>` | <label>: how many <entity> rows there are. Each row carries `__count`. | `from`? / `to`? where the reading states `over` | each Reading of a dashboard, called by its `key` (`dashboard_reading_<n>` only for an app stored before trees) |\n| `<reading key>` | <label>: how many <child> rows there are. Each row carries `__count`. | `<entity>_id`; `same_<field>` where it is a list stating `same` | each Reading of the record and each List or funnel on it, called by its `key` (`block_reading_<n>` only for an app stored before trees) |\n| `<entity>_<key>` | <label>: how many <entity> rows there are. Each row carries `__count`. | the list\'s; `from`? / `to`? where the reading states `over` | each Reading of a log\'s List, called `<entity>_<key>` (`<entity>_register_reading_<n>` only for an app stored before trees) |\n| `<entity>_<key>` | <label>: how many <child> rows there are. Each row carries `__count`. | `<entity>_id`; `same_<field>` where it is a list stating `same` | each Reading of the record a row of a Book or of a log opens and each List or funnel on it, called `<entity>_<key>` by the Book\'s or the log\'s entity (`<entity>_block_reading_<n>` only for an app stored before trees) |\n| `<reading>_bound` | <label> / <bound>: the sum of "<bound>" over the <entity> rows. Each row carries `__count`, `__sum`. | its reading\'s | a Reading whose value `records.limits` bounds: the bound\'s sum, beside the Reading\'s own |\n| `<reading>_on_hand` | <label> / <on-hand entity>: how many <on-hand entity> rows there are, per "<field>". Each row carries `<field>`, `__count`. | none: every row of its entity its `where` keeps, whatever the place | a Reading with `on_hand` (a shortfall): the on-hand rows\' groups, beside the Reading\'s own |\n| `<entity>_export_rows` | Every row of <entity> the reader may see, with every field "<template>" may print. | the list\'s | an Export of the register that fills a template |\n| `<entity>_export_rows_of_<book>` | Every row of <entity> the reader may see, with every field "<template>" may print. | the list\'s | an Export over some of an app\'s Books: their rows alone, each Book named in turn |\n| `<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`) |\n| `<entity>_plan_load` | How many <entity> rows each <place> holds, over every table the register reads \u2014 the rows with no <place> counted apart. | | a register laid out as a `plan`: its places\' counts of present rows |\n\n<!-- generated:end queries -->\n\nA reading\'s query returns groups, never the rows it read: one row for all of them, or one per value it\nsplits by, or per day. Each row carries the alias of each field it splits by, `__day` where it splits by\nday, `__count`, and `__sum` where it sums a number; its description states which. An average is `__sum`\nover `__count`, both over the rows holding a value.\n\nA Reading is called by its `key` (`model/reading-node`), so moving it in the tree changes no name. A log\'s Reading is called by the\nlog\'s table before the name: `<entity>_<key>`.\n\nA register reading\'s query reads the rows it names, whatever a screen has open: the rows of its `tab` where\nit states one, every tab\'s where it states none \u2014 its description says which. One placed in time (`over`)\nreads every day unless the call states `from` or `to`.\n\n### Queries the screen runs for itself\n\nThey run like any other, and `get_app` leaves them out: each feeds one control, and takes ids\nthe screen already holds.\n\n<!-- generated:start screen-queries -->\n\n| Called | What it says of itself | Takes | Generated for |\n|---|---|---|---|\n| `<child>_<link>_target` | One <entity> row by its id, where a <child> row\'s "<link>" may point at it. | `<entity>_id` | a List with `pick` whose link a write rule narrows |\n| `<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`) |\n| `<entity>_pictures` | The picture and name of each <entity> row among the ids handed in. | `<entity>_ids` | a link to several pictured rows |\n| `<entity>_count_by_<scope>` | How many <entity> rows each <scope entity> holds. | | an app stating `scope` |\n| `<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 |\n| `<rows>_measured_by_<link>` | The share of each <entity> row handed in its <rows> rows add, day by day. | `<entity>_ids` | a Progress node stating `measured` |\n| `<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` |\n| `<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`) |\n\n<!-- generated:end screen-queries -->\n\n### Workflows\n\n<!-- generated:start workflows -->\n\n| Called | What it says of itself | Takes | Generated for |\n|---|---|---|---|\n| `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 |\n| `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 \u2014 a files field or a link to several rows as `<field>_added` / `<field>_removed` | an entity the app edits |\n| `remove_<entity>` | Removes one row of <entity>. | `record_id` | an entity the app removes rows of |\n| `act_<act>` | <label> \u2014 on one row of <entity>, refused where its conditions do not hold. | `record_id` \u2014 `record_ids` on an act over several rows \u2014 and what the act asks | each Act that names no workflow of its own |\n| `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`) |\n| `<workflow>_<book>` | The workflow of that name as a Book of the app, or a log of it, runs it, where its own table or page makes it differ. | that workflow\'s | an app with Books or logs |\n\n<!-- generated:end workflows -->\n\nA Workflow node is called by its `key`; an Act that names its own `workflow` is called by that workflow\'s alias, and every other Act by `act_<key>`.\n\n## Calling a query\n\n```\nget_app {"app_id":"app_\u2026"}\nrun_app_query {"app_id":"app_\u2026","alias":"order_list","params":{"status_is":"open","due_on_to":"2031-03-14"}}\n```\n\n| Argument | Does |\n|---|---|\n| `params` | The query\'s declared params. A required one is always sent; an optional one left out narrows nothing. A select takes the option\'s alias as `get_app` lists it in `select(label=alias|\u2026)` \u2014 a link a record id, a member a member id, a date `yyyy-mm-dd` |\n| `filters` | Narrows the result further, by the query\'s output columns (`filters`) |\n| `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 |\n| `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 |\n| `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` \u2014 a map of name \u2192 filter, never beside `count_by` \u2014 `counts` per name. Never with `fields`, `sort`, `limit` or `cursor` |\n| `aggregate` | Groups of the result in place of its rows, one row per group: `by` (up to 3 columns), `bands` (a number column of `by` grouped by its breakpoints), 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. Buckets are by day only: a period\'s total is one call per period, with `from` and `to` or a date filter |\n| `limit`, `cursor` | A page \u2014 200 rows at most, 50 where no `limit` is stated \u2014 and the `next_cursor` of the page before it; `truncated` says more rows matched than `rows` holds |\n| `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 |\n\nA query that states its own `limit` lists at most that many rows: `truncated` is `true` where more match, and `total` is\nthe rows the filtered query matches without it. A `count`, `count_by`, `count_where` and an `aggregate` are over every row\nthe query matches, never over the rows its `limit` lists.\n\nThe rows of one call, across its reads, total at most 262,144 bytes: a read past that is refused, naming\n`fields` and `limit` as what narrows it. The names `filters`, `fields`, `sort`, `count_by` and `aggregate`\ntake are the ones on the query\'s `returns` line.\n\nA list\'s params are how a person narrows that register: `search`, the row\'s id on an app that works inside\none row (`scope`), and a param for each filter the register states that one value or a range narrows. A\nfilter with no param \u2014 on a looked-up link or member, on a number read in each row\'s own unit or currency,\non a date holding a span, or on a register of several tables (Books) \u2014 is narrowed by `filters`, where a\nnumber condition names the unit its value is in as `unit_option`, an option of the figure\'s select by label\nor key. Each tab of a register is a query of its own, taking the list\'s params and the period its rows are\nread over.\n\n## Exporting a file\n\n```\nrun_app_query {"app_id":"app_\u2026","export":{"key":"by_carrier","per":"\u2026","tab":"in_yard","status":["\u2026"]}}\n```\n\n`export` makes the file the register\'s Export button makes, from what the caller reads, and answers it under `files` as a workspace\nfile like any other a tool makes: `file_id`, `url`, `filename`, `mime_type`. `key` is the\nkey of an Export under the register List in `get_app`\'s tree; it is sent without `alias` or any other argument of a\nread. An option is named by its name or its id, as a read\'s filters name it; a member by its id or its name, for a member\nof the app\'s organization; a row, by its id. The other keys\nnarrow the file as the register\'s own controls do, each absent one leaving the register\'s opening view:\n\n| Key | Takes |\n|---|---|\n| `per` | The option, row id or member id the report is made for \u2014 required by an Export that states `per`, refused by one that does not; a member, here and in a member chip, is an id or a name, found as a filter on a member column finds one |\n| `tab`, `period`, `window` | A Tab\'s key (the first when absent); `{ "from", "to" }` days `yyyy-MM-dd` (`yyyy-MM-ddTHH:mm` on a date-and-time field) the tabs read over; a calendar\'s or board\'s days |\n| `status` | Options of the register\'s status that are kept |\n| `filters` | `{ "<field>": <value> }` by the field\'s alias, a value `null` clearing the register\'s opening chip; a value is one of the shapes below |\n| `search`, `overdue`, `waiting` | Words searched, one line each of a pasted list; `true`: only the rows past a deadline; `true`: only the rows waiting on the caller |\n| `sort`, `group`, `folder` | `{ "field", "dir" }`; a field alias; a folder id |\n\n| A `filters` value | For a field that is |\n|---|---|\n| `{ "kind": "ids", "ids": [...], "unset"?: true }` | a select (options) or a link to rows (row ids); `unset` also keeps rows holding none |\n| `{ "kind": "members", "mine": false, "ids": [...], "unset"?: true }` | a member; `mine` is the caller |\n| `{ "kind": "dates", "from": "yyyy-MM-dd" \\| null, "to": \u2026 \\| null, "unset"?: true }` | a date; `null` leaves an end open; `unset` also keeps rows holding none |\n| `{ "kind": "yes_no", "value": true }` | a yes/no |\n| `{ "kind": "number", "min": 1 \\| null, "max": \u2026 \\| null, "unit"?: "\u2026" }` | a number; `unit` the option of a unit or currency field |\n| `{ "kind": "bands", "bands": [{ "min", "max" }] }` | a number the register cuts into tiers |\n\nA key the register lacks, a tab it does not hold or a value its field cannot take is refused, naming it.\n\n## Calling a workflow\n\n```\nrun_app_workflow {"app_id":"app_\u2026","alias":"act_hand_over","inputs":{"record_id":"rec_\u2026"},"dry_run":true}\nrun_app_workflow {"app_id":"app_\u2026","alias":"act_hand_over","inputs":{"record_id":"rec_\u2026"}}\n```\n\n| Argument | Does |\n|---|---|\n| `inputs` | The workflow\'s declared inputs. An update takes the record and only the fields that change |\n| `dry_run` | `true`: the run is made and taken back, so nothing is kept, and the answer is what the run would return, with the calls it would make \u2014 or `status: "not_previewed"` where the workflow cannot be dry-run (\xA7 A dry run) |\n| `as_member` | With `dry_run`, for a workspace admin: the run is judged as that member would press it \u2014 their stage owners, row rules and app access \u2014 and writes nothing. Anyone else is refused; `dry_run` on a record tool takes it the same way, and so do `query_records`, `aggregate_records` and `get_record`, which answer what that member would read |\n| `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 dry-run 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 `dry_run`, or takes the call\'s. Dry runs one after another share their rows, taken back together, so a run reads what the one before it made; a run reads a dry run\'s answer only from a run dry-run in that stretch |\n\n```\nrun_app_workflow {"app_id":"app_\u2026","runs":[\n {"alias":"create_order","inputs":{"customer":"rec_\u2026","item":"rec_\u2026","due_on":"2031-03-14"}},\n {"alias":"act_hand_over","inputs":{"record_id":{"from_run":0,"path":"data.record_id"}}}\n]}\n```\n\n## A dry run\n\nA dry run is the run itself on the real inputs, taken back: it is made in a transaction that is rolled back, so a read after a write reads that write, a branch or a loop walks the values the run would hold, and a call fed by an earlier call\'s answer gets it. No row, number, event, audit entry or table workflow outlives it. A write of the workspace\'s rows is made by its table \u2014 checks, write rules, stage owners \u2014 and the first refusal is the dry run\'s answer. A connected service\'s read-only call is made; a service write, a send, a notification or a button that runs another workflow is not, and is answered as a "would" with its payload. A guard (`when`, `requires`, a check, a lock) refuses it with the press\'s own sentence. A paper it makes is rendered for the answer and its file is removed with the dry run; an act that makes a paper is dry-run like any other, never refused for it. A body that sets the record\'s status also answers `moves`. Its answer carries `status`,\n`message`, `field_errors` and `data` as the run would return them, and under `dry_run`:\n\n| Key | Holds |\n|---|---|\n| `available` | `true` |\n| `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 |\n| `reads` | The reads made for real, in order, each with its `input` and what it `returned` (cut at 2,000 characters, `truncated`), for the app\'s owner and an admin |\n| `papers` | Each paper rendered: its `step_id` and `filename`. The file is not kept |\n| `unpredicted` | The parts of the answer left out \u2014 `message`, `field_errors`, `data` \u2014 because each reads what a call the dry run did not make would answer |\n\nWhat a write sets off once it commits \u2014 a table\'s own workflows, its events \u2014 does not happen in a dry run.\n\nA workflow is not dry-run where its body reads the answer of a call that leaves the workspace, to branch,\nloop, check or feed another call, where it reads or writes rows after such a call (the run would find them as\nthat call left them), where it runs an agent, where it waits, where a step cannot be evaluated, fails\nfor a reason that is no table\'s refusal, or sends its call an input the call refuses, and where the run is past\nwhat one dry run walks \u2014 its steps, the passes of a loop, 50 reads, 50 writes or 20 seconds, which the runs of\none call share and the database holds it to: a lock wait that outlasts them ends the dry run. The\nanswer is then `status: "not_previewed"` with\n`dry_run: { "available": false, "reason" }`, and nothing ran \u2014 within `runs`, it ends the call. The same\nanswers a caller outside the app\'s organization.\n\n## A reviewed act\n\nAn act that states `review` runs a workflow that only proposes: call it with the inputs the act asks and it answers,\nwriting nothing, the changeset its body proposes in `data.review` \u2014 `table_id`, `creates` (each with its `key`) and\n`updates` (each with its `record_id`), each row\'s `data` holding only the fields that change, by field alias, with an\noptional `note` and `ticked` (`false` where the body leaves it for a person to choose), and `unplaced`, what was read\nand matched nothing; the platform adds a `sha` to every row. Call it again with the same inputs and `approved`,\n`{ "updates": [{ "record_id", "sha" }], "creates": [{ "key", "sha" }] }`, listing the rows to write with the `sha` each\ncarries in the proposal, copied as it came \u2014 never computed \u2014 and the platform writes those rows as that second call\nproposes them, each only where it proposes the values read:\n\n```\nrun_app_workflow {"app_id":"app_\u2026","alias":"match_statement","inputs":{"record_ids":["rec_\u2026"],"statement":"fil_\u2026"}}\nrun_app_workflow {"app_id":"app_\u2026","alias":"match_statement","inputs":{"record_ids":["rec_\u2026"],"statement":"fil_\u2026","approved":{"updates":[{"record_id":"rec_\u2026","sha":"9f2c\u2026"}],"creates":[{"key":"line-4","sha":"41d0\u2026"}]}}}\n```\n\nThe answer\'s `data.applied` names the run (`execution_id`), the records `updated`, what each held before (`previous`), each `created` record by its key,\nand each approved row `left` with its `reason`: `not_proposed` (the second call no longer proposes it), `not_acted`\n(an update of a record the act was not run on), `changed` (the second call proposes other values than were read \u2014 read\nthe proposal again), `empty` (an update changing no field), `missing`, `locked` or `conflict`. Every approved row is\nchecked before any is written, and all are written in one transaction or none; the status history of each row written\nthat moved is recorded by the same run. `applied.previous` holds, for each record in `updated`, what the fields the call wrote held before (null for an empty one): `update_records` with it as `set` puts them back, and `delete_records` of a `created` record takes that row away. An act over every\nrow in view takes no `record_ids`. A `file` input takes the id of a file uploaded to the workspace.\n\n## A refusal\n\nA workflow re-checks on the server what the screen shows of its own act or save: what an act requires, a\nduplicate, whose step it is, a row the caller may not see. Each write it makes also meets the table\'s own\nrules (a bound, a span, a stage\'s owner, its lock), which hold on every path a record is written by. A row its table\nlocked is refused in every app\'s act and save, naming who can change it; an app\'s own Check only makes a row read-only\nin that app. An unlock in an app\'s run is judged as the member it runs for, never the app\'s owner. A run refused\nbefore it writes writes nothing; one whose later write a table\'s rule refuses keeps the writes before it.\nEither answers `status: "error"` with the sentence a person would read as `message`, and under `field_errors`\nthe sentence for each input it was refused over. A value of the wrong type, a missing required input and an unknown\nalias are refused before the body runs: the call itself fails with the sentence, and `field_errors` where\nan input is at fault \u2014 within `runs`, a run refused for its inputs answers `status: "error"` in its place,\nand the runs before it stand. A refused read fails the same way, naming the param or the column; within\n`runs`, it answers its `error` and the other reads are still answered.\n\n## Declaring more\n\nWhat the business asks an agent in the app again and again, and no screen draws, is stated in the app\'s tree as Query and Workflow nodes, beside its\nActs: every figure or list whose definition is the business\'s own (open orders, what a customer owes, low stock) and every recurring operation (close the month, send to the accounting service, chase overdue invoices), each with a `description` written for an agent. A question asked once stays on `aggregate_records`. A Workflow gets an Act only where a person presses it; work only an agent does has no button. Both are checked and compiled when the tree is written (`set_app`; `model/query-node`, `model/workflow-node`).\n\n**A query** \u2014 the rows of one table, or one figure of them, in the app\'s own aliases:\n\n```jsonc\n{\n "type": "Query",\n "key": "orders_overdue",\n "description": "A customer\'s orders still open past the day they were due, longest overdue first.",\n "params": {\n "customer": { "type": "record_link", "entity": "customer", "required": false, "description": "Only this customer\'s orders." },\n "as_of": { "type": "date", "description": "The day an order is overdue on: due before it." }\n },\n "rows": "order",\n "fields": ["customer", "item", "due_on", "price"],\n "where": { "status": ["baking"], "customer": { "param": "customer" }, "due_on": { "to": { "param": "as_of" } } },\n "sort": { "field": "due_on" }\n}\n```\n\nIt reads under the table\'s read scope and, on the app\'s own table, exactly the rows its list reads \u2014\nso never the table of an app that states Books, whose list reads several tables. A condition reading an\noptional param narrows nothing when the call leaves it out; no param compares a number read in each row\'s\nown unit or currency, or a date holding a span. `"reading"` in place of `"fields"` returns a count or a sum \u2014\n`{ "value": "price", "by": "item" }` \u2014 as a Reading does. A Query, a Workflow and\na Reading\'s `key` take no name the app generates (`model/app.generated-name`).\n\n**A workflow** \u2014 a write no Act of the record makes, typed in the app\'s tree and its code set on the app:\n\n```jsonc\n{\n "type": "Workflow",\n "key": "import_bookings",\n "description": "Books every order a sheet lists, reusing an order the sheet already booked.",\n "inputs": {\n "sheet": { "type": "file", "description": "The order sheet, one order per row." },\n "booked_by": { "type": "member", "required": false, "description": "Who the orders are taken by; absent, the caller." }\n },\n "outputs": { "added": { "type": "number" }, "reused": { "type": "number" } }\n}\n```\n\n`set_app_workflow` sets its code (`workflows`). A query set on an app outside\nthe tree (`set_app_queries`, on the CLI or in chat) is removed by the next write, whose answer notes it; declared as a Query\nnode, it is the tree\'s. A caller sees the description and\neach input\'s `description` and nothing else, so each says what the value is and what leaving it out means;\nthe check notes one that says nothing.\n\n**Shape each for a caller that is not looking at a screen.** One job a person hands an agent \u2014 a paper read,\na message answered, a sheet brought in \u2014 is one declared workflow named for the job, never a chain of the\ngenerated per-row workflows. Take a list where the job covers many rows, a record\'s lines among them. A workflow\'s code\nthat writes more than one row makes them in one `save_record` workflow step, all or nothing, and recognises\nwhat exists by the identity the input itself carries, declared unique on its table, so the same input sent\ntwice updates instead of adding. Return the ids under `outputs`, so the next run can read them. Gate what\ncannot be undone behind an approval step.\n\n## Prove an app as an agent\n\nRun on one of the owner\'s own rows, moved only as far as its stage; a record made for the run takes a number and a stage move nobody asked for.\n\n1. `get_app` \u2014 every job the app\'s `description` names has an alias that does it or reads it.\n2. `run_app_query` the list with no param, then with each param a person would narrow by; `count: true`\n answers how many without the rows.\n3. `run_app_query` `<entity>_record` with one row\'s id, and each `<child>_by_<link>` under it.\n4. `run_app_workflow` an act with `dry_run: true`, then without.\n5. `run_app_workflow` an act its record is not ready for: the answer is `status: "error"` with the sentence\n the screen would show.\n6. `run_app_workflow` the app\'s job workflow with the input a person would hand it \u2014 the paper read, the row:\n it completes in one call. Call it again with the same input: nothing is added.\n';
|
|
1771
1772
|
|
|
1772
1773
|
// AGENTS.md
|
|
1773
1774
|
var AGENTS_default = "# @lotics/cli \u2014 agent index\n\nThe model an agent needs before driving this CLI: which surface answers which question, what the\nconventions are, and where the traps are.\n\n| Read | For |\n|---|---|\n| `lotics --help` | The verb inventory (\xA7 COMMANDS) and global flags. The verb LIST is generated and never stale; the prose beside each verb is hand-written, so where it disagrees with `docs/cli_reference.md`, the reference wins. |\n| `lotics tools` \xB7 `lotics tools <name>` | The agent tool registry and one tool's full JSON Schema. |\n| `lotics docs` \xB7 `lotics docs <area>[/<section>]` \xB7 `lotics docs [<area>] --grep <text>` | This CLI's references, carried inside it so each describes the binary answering. Capped at a page: a doc that does not fit hands back its opening and the addresses into it, so the next call is smaller than the last. A page the copy lacks \u2014 one the server added since this binary was built, which a refusal can cite \u2014 is read from the server's `docs` tool with this machine's credential, and so is every `--grep`, over the references the server serves. A custom-code app's SDK reference is `node_modules/@lotics/app-sdk/AGENTS.md` inside the app. |\n| `lotics docs model` \xB7 `lotics docs model/<section>` | The keys of a table document and the components of an app tree: a table's fields keyed by alias, how a row of each table is recognised (`records`), and the apps stated over them. Every key, every field type with the config it needs, the rules, and a worked example; example files of several trades are listed at `https://lotics.ai/presets/index.json` \u2014 read one and pass its tables to `set_tables`. |\n| `lotics docs design` \xB7 `lotics docs design/<section>` | Designing an app \u2014 the treatment each kind of row takes with the `model` page stating its keys, the visual bar, and looking at what a write built. Opens on the common treatments; a finding names its section. |\n| `lotics docs examples` \xB7 `lotics docs examples/<treatment>` | Each treatment worked through in an app of a complete workspace: the job, the props to read, where it is the wrong one, and the excerpt. |\n| [docs/getting_started.md](./docs/getting_started.md) | What a workspace holds, and the one page to open for each job: building a workspace, changing an app, a rule on a table, a document, data and questions, a connected service, sharing. The first page an agent outside the product reads. |\n| [docs/first_build.md](./docs/first_build.md) | A whole workspace built call by call \u2014 understand the work, tables with a check, the paper, the app (which apps, a treatment per kind of row, what the check refuses, who gets it), the owner's rows, pressing it \u2014 in one worked example. |\n| [docs/app_architecture.md](./docs/app_architecture.md) | One factory's split into apps: each app's job sentence, person, register and what sits inside it. The questions that split a business are in first_build. |\n| [docs/working_in_a_workspace.md](./docs/working_in_a_workspace.md) | The right means for each job once the apps exist: an app's act for a business operation, a record write for data entry, a query for a figure the business defines, a count for the rest. |\n| [docs/building_an_app.md](./docs/building_an_app.md) | The SEQUENCE \u2014 clarify, tables, apps, prove, look \u2014 for an app stated as a tree and for a custom-code app. Read it once before starting an app. Looking is `lotics run screenshot_app '<json>' -o <dir>`, which saves each PNG and its text snapshot there. |\n| [docs/cli_reference.md](./docs/cli_reference.md) | Per-command contracts, flags, exit codes, and gotchas \u2014 the detail `--help` compresses. |\n| [docs/data_model.md](./docs/data_model.md) | Tables, their fields, and how they relate \u2014 one fact per column, one table per kind of thing and the NAME-OVERLAP probe that says when a split has broken, one vocabulary wherever values are copied, a copy boundary that accounts for every source field, provenance as a link, a declared natural key, history as rows, and why derived DEPTH costs more than row count; then the choices around a field's type, and how a field is changed (a list replaces whole, what refuses a deletion, what `force` consents to). Separate from building_an_app because every workspace starts with tables and many never get an app. |\n| [docs/filters.md](./docs/filters.md) | The one filter grammar every filter-taking tool, view and rollup reads \u2014 conditions, groups, and the operators per field type. |\n| [docs/field_values.md](./docs/field_values.md) | The value each field type takes in a write. |\n| [docs/workflows.md](./docs/workflows.md) | Writing a workflow body \u2014 triggers, steps, what an expression reads, the helpers, approvals and agent steps, and table checks. |\n| [docs/app_bindings.md](./docs/app_bindings.md) | The queries, workflows and agents an app binds \u2014 the query tree, typed inputs and outputs, and an agent's declaration. |\n| [docs/document_templates.md](./docs/document_templates.md) | Generating PDF/Excel/Word/email from reusable templates, and what a template an app's Act makes is handed. |\n| [docs/knowledge_docs.md](./docs/knowledge_docs.md) | Authoring the procedures, policies and glossary an agent can't guess with `set_knowledge`; who can read a doc; searching and reading it line by line. |\n| [docs/migration.md](./docs/migration.md) | What to DO when something an earlier CLI wrote to disk no longer matches what it does \u2014 local app projects are gone, and each verb that read one has a replacement. |\n| [README.md](./README.md) | Install, auth, and worked examples. |\n\n## Two surfaces, and the trap between them\n\n**`lotics tools` is not the CLI's capability list.** There are two disjoint surfaces:\n\n- **Tools** (`lotics tools`, `lotics run <tool>`) \u2014 the *agent tool registry*: what an agent, workflow,\n or automation may call. Workspace data, templates, knowledge, admin.\n- **Commands** (`lotics --help` \xA7 COMMANDS) \u2014 the CLI's *own verbs*: auth and org/workspace scoping,\n file upload and download, and those that touch a local file for an app \u2014 `app apply`,\n `app create --custom`, `app pull` and `app deploy`.\n\nSeveral capabilities exist **only** as commands and appear nowhere in `lotics tools` \u2014 downloading a\nfile is the one most often mistaken for missing. Concluding \"the platform can't do X\" from the tool\nlist alone is a mistake; check both.\n\n**`lotics <verb> --help` prints that verb's entries from \xA7 COMMANDS** (`lotics app --help`,\n`lotics file download --help`); `lotics report --help` prints the report frame. To find out whether\nsomething exists, read `lotics --help` \xA7 COMMANDS \u2014 the whole section, not a narrow grep.\n\n## Conventions that hold across every command\n\n- **Scope is resolved per invocation.** `LOTICS_ORG` / `LOTICS_WORKSPACE` (or `LOTICS_API_KEY`) scope a\n single call without changing the active org or a directory pin \u2014 the safe way to touch one tenant\n from a shell serving many. Every command that resolves a workspace echoes its target to **stderr**\n (`lotics \u2192 <org> / <workspace>`); read it back before trusting a write. Resolution precedence is\n in README \xA7 Organizations.\n- **A machine with no key can still sign in, and the sign-in never blocks you.** `lotics auth\n login <email>` prints the page a person opens (also mailed) and the code that page must show,\n records the request, and EXITS. They press Confirm whenever they get to it; the next command that\n NEEDS a credential collects the key before doing its own work, so \"sign in\" costs you one command\n and then re-running what you wanted. Never wrap it in a timeout waiting for a human \u2014 `--wait`\n exists if you really want one blocking command, and killing that one is safe too (the request\n survives and the next command claims it). A command run before Confirm exits 1 naming the page and\n code again; once the 15 minutes are up it says to ask again. `lotics auth signup <email>` for an email that\n already has an account creates nothing and names `auth login`.\n- **A credential is either a SIGN-IN or an API KEY, and `logout` treats them differently.** A profile\n from `auth login` / `auth signup` acts as the person who confirmed it and is theirs \u2014 `lotics auth\n logout` revokes it server-side, and it lapses on its own after 90 idle days (each use pushes that\n out). A profile from `auth api-key` holds a key an ADMIN issued. A key created in Settings carries\n its OWN access \u2014 every app and table, or only the ones chosen on the key, so a listing that comes\n back short is the key's reach, not a bug \u2014 while a key created FOR a person carries that\n person's access and dies with their membership. Either is routinely also on a server and on\n other machines, so logout only forgets it locally and says so; only an admin revokes it. A profile that states no kind (saved before the field existed) is resolved against the SERVER\n and revoked only if the answer is a sign-in; a bare `--api-key` / `LOTICS_API_KEY` names no\n profile to remove at all. Nothing is ever revoked on a guess \u2014 between two, the destructive one is\n wrong. `lotics auth whoami` prints the kind, asking the server when the store cannot say.\n- **A key created in Settings never administers the organization, whatever its access.** The verbs\n `docs/cli_reference.md` marks *admin only* split in two under a key: `workspace doctor`, which only\n reads inside a workspace the key reaches, runs as before, while managing people, sharing or\n ownership, creating or deleting a workspace, changing workspace settings, setting credit limits,\n reading the access log and publishing an app's API answer `403` and name the remedy: an admin signed in, so\n `lotics auth login <email>` and run it again. A sign-in acts as that person and is refused none of\n them. Do not retry a `403` with the same credential and do not ask for a wider key \u2014 no answer on\n the key's own screen grants this.\n- **A 401 names its remedy \u2014 act on the hint, do not retry.** \"This credential expired / was\n revoked / belongs to a member who is no longer active\" carries the one remedy that ends this\n credential: run `lotics auth login <email>` for a sign-in, or ask the admin who issued it for a\n new key. A credential minted before that was recorded carries BOTH, because nothing on the row\n tells them apart \u2014 so on a box with no browser, take the second. Only an UNRECOGNIZED key gets\n the generic \"Invalid or disabled API key\", and that one is generic on purpose, so re-sending it\n teaches nothing. A `reason` rides on the body for a script to branch on, since the code stays\n `unauthorized` for every 401.\n- **Large payloads bypass `ARG_MAX`** \u2014 `lotics run <tool> @args.json`, or piped stdin behind `-`. A leading `@` is\n unambiguously a file path (JSON args start with `{`).\n- **stdout is the payload, stderr is the narration.** Progress, status lines, and the target echo go to\n stderr; the result goes to stdout, so piping stays clean. `--json` switches stdout from the\n agent-readable summary to the full structured object.\n- **Every tool is invoked one way \u2014 `lotics run <tool>`.** Including the ones that RUN something\n (`run_app_workflow`, `run_app_query`) and every one that builds\n (`set_tables`, `set_app`, `set_app_queries`, `set_app_workflow`). A command\n exists only for work that touches a local file.\n- **Building is two tools, each a writer that creates without an id and edits with one.** `set_tables`\n takes `{ \"tables\": [...] }` \u2014 always a list, one or many, all or nothing \u2014 each table a document keyed\n by alias (fields keyed by alias; a link names its table by id, or by the `ref` of another table of the\n same call, and states `multi`; a two-way link states `pair` on both tables), edited by an RFC 7396\n merge patch of that document, where `null` deletes. A call carries at most 50 tables, 200 first rows a table,\n and 2000 rows and 2000 documents in all. `set_app` takes ONE app: `{ \"body\": <the App tree> }` to create, the app's\n `name`, `description`, `icon` and `color` being props of the App node; `{ \"app_id\": \u2026, \"nodes\": { \"<address>\": <patch> } }`\n to change a node, refused with `base_version_id` when the app has moved past the version you read. `dry_run` checks\n without writing, a refusal lists everything wrong in the call, and every answer states the permissions the write\n granted and removed. The answer notes the treatments an author leaves unstated; nothing is written into the tree for them. A new workspace: one `set_tables` call with\n the tables, then every `set_app` call, then fixes. The example files under `https://lotics.ai/presets/index.json` are read and their tables passed to\n `set_tables`.\n- **Every change to an app mints a version of it, and applying the earlier tree again is the undo.** An apply, a\n deploy, a single `set_app_*` or `remove_app_binding`: each is a new version. Applying the earlier tree again restores the app \u2014 never a table change or a row a workflow wrote,\n so try a write on a throwaway record.\n- **Exit codes are assertable, and they report the WORK rather than the call.** `lotics run` exits\n non-zero when a `run_app_workflow` result's own envelope carries a failed\n `status` (`error`/`failed`/`cancelled`, or `not_previewed`: a dry run asked for and not given) \u2014 any other tool's top-level status is data and exits 0 \u2014\n so `lotics run \u2026 && next-step` cannot walk past a refused run; `workspace doctor` exits non-zero on\n findings. An unrecognized status exits 0 \u2014 the list is an allowlist of failure, so a status added\n later never turns a working script red \u2014 and a parked run (`awaiting_input`) is not a failure.\n- **An app that PUBLISHES an API turns every later binding write into a release.** Publishing\n snapshots what the app's queries, workflows and agents promise to callers outside it \u2014 a\n customer's own site or server, which nobody here can redeploy. From then on an additive change\n re-snapshots silently and a breaking one is REFUSED, naming each change; the tool's\n `acknowledge_breaking_api_change` carries it out and snapshots the break as a new contract version.\n- **Exposure is per app, all or nothing** \u2014 a public share or a key reaches every alias an app\n declares, so what outsiders may call is a second app over the same tables\n ([docs/building_an_app.md](./docs/building_an_app.md) \xA7 7).\n- **`--print-created` / `--cleanup` on any call that reports `side_effects`.** The first prints the\n records created plus a paste-ready cleanup plan and what cannot be auto-undone; the second runs\n those deletes (records only \u2014 never files, integrations or notifications). Neither is a rollback.\n- **Text output is the default and is built for reading**; reach for `--json` only when a field is\n needed programmatically.\n- **`lotics report '<json>'` is the channel for what nothing else records.** Reach for it\n when the platform is genuinely missing something (a capability that does not exist \u2014 no\n command ran), when a success was wrong (exited 0, wrong effect), or when an error did\n not name the remedy \u2014 not for your own mistakes, which the logs already show.\n **It is a frame, not a paragraph** \u2014 `{goal, actual, expected?, tried?, wanted?}`, because a log\n reconstructs what you RAN and never what you WANTED, and that gap is the report. `goal` and\n `actual` are required; there is no severity or category to pick. The\n session's commands attach themselves \u2014 do not retype them. Run it bare for the full prompt; a\n long one rides `@file.json` or an explicit `-` for stdin (bare NEVER reads stdin).\n- **`LOTICS_TELEMETRY=1` correlates a whole session** so the authoring loop's rough edges can be\n found and fixed. Off by default; set it in the shell profile, not per command (each invocation is\n its own process). It sends no arguments, no file contents, and no record data \u2014 see README\n \xA7 Diagnostics.\n\n## Where this CLI is not the answer\n\n- **Editing an app in a local directory.** An app stated as a tree is changed by applying the\n tree again; its bindings through their `set_app_*` tools; a custom-code app's code by deploying\n its directory again. Nothing reads a local copy of a live app back.\n- **OAuth connections.** Attaching a connected account is web-only; the CLI can list them.\n";
|
|
@@ -1776,7 +1777,7 @@ var AGENTS_default = "# @lotics/cli \u2014 agent index\n\nThe model an agent nee
|
|
|
1776
1777
|
var building_an_app_default = '# Building an app, end to end\n\nThe other references here describe **grammars** \u2014 what a table document or an app tree may state, what a tool takes. This\none describes the **sequence**: the order the steps go in, and why. Read it once for the shape, then\nreach for the area doc (`lotics docs`) whenever you need the detail.\n\n**The live app is the only edit surface.** Every change to an app \u2014 applying a tree, deploying a\nbuild, setting one query or workflow \u2014 mints a new version of it. To go back, apply the earlier tree\nagain (or deploy the earlier source). Nothing about an app lives in a local directory the platform\nreads back.\n\n**Going back restores the app, not the data.** A table change, and every row a workflow wrote while\nyou tried it, stay where they are. Try a write on a throwaway record.\n\n---\n\n## 1 \u2014 Two kinds of app\n\n- **An app stated as a tree** \u2014 the default, and the right one for almost every job. The tree (a\n JSON file, one per app) is a List of one table, whose rows are recognised by the\n table\'s own `records`, and the Record its rows open, with the logs that stand beside it (further Lists at the root, which a change may add at any time, read by the organization\'s own members alone): its columns and filters, its Sections in the order its work\n reaches them, its Acts and the Checks that guard them (`lotics docs model/app-tree`,\n `model/list-node`, `model/section-node`, `model/act-node`, `model/check-node`). The platform\n compiles each app and draws it with the runtime every such app shares, so how each piece looks is\n the platform\'s, one way per concept, and no prop changes it. Every write the app makes is a\n generated workflow that re-checks on the server what the tables state.\n- **A custom-code app** \u2014 React you write, for a surface the tree\'s components cannot state. It\n reads and writes through `@lotics/app-sdk` (queries, workflows, files, AI) and draws with whatever\n React you choose.\n\n## 2 \u2014 Clarify what is being asked, before designing it\n\n**A metric name is not a definition.** "Revenue", "in stock", "active", "overdue" \u2014 each is a\nbusiness rule the person asking owns, and the cost of guessing is a screen that is confidently\nwrong. Ask until there is no ambiguity left:\n\n- Which rows count, keyed off which field \u2014 a date, a status, a flag?\n- Does the same metric need a **different rule per table**? One table may key off a date and\n another off a status; one rule rarely covers both.\n- Snapshot or flow? "Current stock" is as-of-now; "revenue this month" is a window. They compile\n to different filters.\n- If the data cannot support the definition asked for \u2014 the field simply is not there \u2014 **say so\n and show the options.** Silently substituting a near-miss produces a number nobody can trace.\n\nThis is the step that gets skipped under time pressure, and it is the only one whose mistakes are\ninvisible in review: every later artifact is correct with respect to the wrong definition.\n\n## 3 \u2014 The data model, before any app\n\nGet this wrong and nothing above it can be precise. Each kind of thing is its own table with links into\nthe spine; attributes and evidence are fields on their owner. A single table with a `type` column\nstanding in for three kinds of thing collapses the distinctions every later query needs.\n\n**Verify real VALUES, never just that a field exists.** `lotics run query_records` a sample and\nlook at fill rates \u2014 a field that is present and empty on 90% of rows will not support the screen\nyou are about to design.\n\n**That includes imagery.** If the thing has a likeness \u2014 a product, a property, a vehicle, a\nperson \u2014 its picture is the strongest identifier a register row can carry, and an empty image field\nis a data gap to fill before you design around it: `lotics file upload`, then `update_records`.\n\n**Match on ids and option aliases, never on rendered text.** Resolve a name to its `rec_\u2026` or its option alias\nonce, at the boundary, and compare those. Treat an unresolved name as UNKNOWN, never as a wildcard.\n\n**How the tables RELATE, and one fact in one column, is `lotics docs data_model`** \u2014 read it before designing a\nschema; those decisions outlive any one app.\n\n## 4 \u2014 An app stated as a tree\n\nTables first, then apps. `set_tables` takes a list of table documents, one or many, and writes them all or\nnone; `set_app` takes one app per call. Each creates when it is given no id and edits when it is given one.\n\n```\nlotics docs model # the keys of a table document and the app tree\'s components, with a worked example\nlotics docs design # the treatment per kind of row\nlotics run set_tables @tables.json # one call, every table: {"tables": [...]}\nlotics run set_app @orders.call.json # one app: {"body": <the App tree>} to create (add "dry_run": true to check)\nlotics app apply orders.app.json --check # the same tool from a bare tree file: what would refuse it, writing nothing\nlotics app apply orders.app.json # no app known: creates it; known: edits it to the file\nlotics app pull <app_id> # the app\'s tree, as ./<name>.app.json\n```\n\nA new workspace is built in one order: one `set_tables` call with the tables, then every `set_app`\ncall, then fixes. One call carries at most 50 tables, 200 first rows a table, and 2000 rows and 2000\ndocuments in all; a workspace of more tables is built in further calls, each naming the tables already made by\ntheir ids. The example files at `https://lotics.ai/presets/index.json` are read and their tables passed to\n`set_tables`.\n\nA table is a document: its `fields` are keyed by alias, and a link field names the table it\npoints at by id, or by the `ref` of another table of the same call, and states `multi`. A\ntwo-way link states `pair` on one table, as `true` or as a free alias, and the partner is made on the other; a many-to-many link states both sides. A table is edited by an RFC 7396 merge patch of its document:\nwhat the patch leaves alone is left alone and `null` deletes. `dry_run` checks the call and writes\nnothing; a refusal lists everything wrong in it at once.\n\nThe App node is the app: its `name`, `description`, `icon` and `color` are props of it, and no other input of\n`set_app` states them. It names the tables and templates it uses by their aliases (an entity is a table\'s alias, a `template` a template\'s alias);\n`set_app` fills the `tables` and `templates` maps by id from them and `get_app` shows aliases (`lotics docs model/app-node`). A call that creates\nis `{"body": <the App tree>}`; one that changes a part is `{"app_id": "app_\u2026", "nodes": {"<address>": <patch>}}`,\neach address a node\'s path of keys (`get_app` prints every one), a patch a JSON merge patch of that node\'s props\nand `null` removing the node; the App\'s own props change through `body`. A call stating `base_version_id` \u2014 the\nversion you read \u2014 is refused when the app has moved on. The old body grammar (`register`, `record`, `acts`,\n`edits`) is refused with a sentence pointing here.\n\nA tree states what it means once: a statement equal to what the runtime draws anyway (a column the row already shows) is kept,\ndrawn once and noted, while a statement that conflicts is refused. A register List\'s `label` is the register\'s heading in the app\'s lists; its rows are named by the table\'s own label and singular.\n`type` may be omitted, and a Section, Side or Page\nwithout a `key` is keyed by its title\'s slug, written once and kept.\n\n`app apply` with no app known creates one from the file, named by the tree\'s `name`, and records its id and\nversion in `<file>.lotics.json` (`app pull` writes the same file), so the next apply of that file edits that\napp; `--app <app_id>` names it instead, and is refused for a file whose record names another app. A known app\nis read first and only what the file changes is sent, as a JSON merge patch (RFC 7396): a key the file drops\nis removed, a list is replaced whole, and what the file leaves alone is left alone. A file pulled at a\nversion the app has since left is refused, naming the version: pull the current tree into another path and\ncarry your edits over. Applying a tree the app already has mints nothing.\n`--check` sends the same change as `dry_run`: what would refuse the tree, the notes on it, and the permissions the\nwrite would grant and remove, writing nothing. Every write\'s answer states those permissions: the saves, adds,\nDeletes and act writes the new tree grants and removes, from the previous tree to the new one.\n\n**Nothing is written into the tree for you.** The answer notes a treatment the rows call for that the tree leaves out \u2014\nReadings over its rows, a record\'s Sections, an Act per status move, a picture where its rows hold photos\n(`lotics docs design`) \u2014 and the tree states the ones the job needs.\n\n**The tree changes after the app exists, and applying it again is how it lands.** Pull the tree, edit\nthe file, apply it; or send `nodes` for one part. An Act whose write the tree cannot say names its own `workflow`; that workflow\'s code is\nthe live one, and `lotics run set_app_workflow` changes it. A job no press starts \u2014 an agent or a\nservice handing the app a file or a list of rows \u2014 is a Workflow node the app declares\n(`lotics docs model/workflow-node`): the node states what it takes and hands back, and `lotics run\nset_app_workflow` sets its code with `inputs` and `outputs` left out.\n\n**What a person reads and does in the app, an agent reads and does through it.** Writing the tree generates a\nquery for every List, record, child List and figure the screen reads, and a workflow for every add, edit,\nremoval and act \u2014 `lotics run get_app` lists them, and `lotics docs agent_surface` names each\nfamily and how it is called. A question the screen never asks is a Query node the app declares\n(`lotics docs model/query-node`), beside its Workflow nodes. Both are part of the design: `lotics docs\nagent_surface`, \xA7 Declaring more.\n\n## 5 \u2014 A custom-code app\n\n```\nlotics app create "<name>" --custom # the app, plus a Vite + React + TS project using @lotics/app-sdk\ncd <dir>\n# edit src/App.tsx \u2014 node_modules/@lotics/app-sdk/AGENTS.md is the reference\nnpm run typecheck && npm run lint && npm test\nlotics app deploy -m "<what changed>" # build, upload, a new version live\nlotics app pull <app_id> # the live version\'s source, on a machine without the project or after another deploy\n```\n\nWhat the app reads and writes is bound on the app, never in the project: `lotics run\nset_app_queries` binds named queries, `lotics run set_app_workflow` a workflow body, and each mints a\nversion. `useQuery("<alias>")` and `useWorkflow("<alias>")` call them. A deploy uploads the build\nand carries every binding forward unchanged.\n\n**Named queries.** Author them as `kind: "project"` with a `filter`, naming each projected column\n(`{ "source": "amount", "output": "total" }`), so a row reads as `r.total`. Scope per-user reads with\n`is_current_member` **inside the query** \u2014 a member id passed from the client is chosen by the\ncaller. Write one `description` per alias: it is the line a chat or MCP caller chooses by.\n\n**Workflows are the only way an app writes**, and every query and workflow bound to an app is also what\nan agent reads and runs there: `lotics docs agent_surface` \xA7 Declaring more states how each is shaped for\nthat caller.\n\n## 6 \u2014 Prove it, without a screen\n\nStatic checks prove parse, types and names \u2014 they evaluate nothing. Rehearse a write first:\n\n```\nlotics run set_app_workflow \'{"app_id":"app_\u2026","alias":"\u2026","source":"\u2026","verify_only":true,"record_id":"rec_\u2026"}\'\n```\n\nIt walks the draft through the same engine an Act\'s preview uses, under the app\'s authority: reads are real,\nwrites and sends are said and not made, and it returns what the run would answer plus the calls it would make.\nPass `preview_inputs` for the values of a workflow that takes inputs and no row.\n\nThen run it end to end, on a throwaway record:\n\n```\nlotics run run_app_query \'{"app_id":"app_\u2026","alias":"\u2026","params":{\u2026}}\'\nlotics run run_app_workflow \'{"app_id":"app_\u2026","alias":"\u2026","inputs":{\u2026}}\'\n# exits non-zero when the run failed, so it is assertable\n```\n\nA workflow is also how an app **produces a document** \u2014 `generate_document` fills a template\nyou registered once (`lotics docs document_templates`).\n\n## 7 \u2014 Look at it, then name it\n\nLook at every app you apply, at a desktop width and at a phone\'s \u2014 every check above reads the\ndefinition, none of them the pixels:\n\n```\nlotics run screenshot_app \'{"app_id":"app_\u2026"}\' -o ./shots\n# one PNG per width saved into ./shots, each with its text snapshot beside it as <name>.txt;\n# "path":"/rec_\u2026" opens a screen inside the app\n```\n\nFrom the CLI each image is a link signed for an hour, and `-o` saves it. Each shot is one screen, as a member sees it; `"screen":2` draws the next screen of\nthe content scrolling inside the app, and so on to its end, and `"full_page":true` grows one image\nto the first five screens \u2014 one or the other. Beside each image comes its `snapshot`, the screen\'s\naccessibility tree as text \u2014 read it to check labels and values without opening the picture \u2014 and,\nwhere a table left columns off at that width, `shed` names them.\n\nThe app is drawn live, as you, read-only: a screen that writes when it opens shows that write\nrefused, and `errors` lists what failed on the page. A version that looks wrong is one\n`lotics app apply` away from the one before: apply the earlier tree again.\n\nFor an app stated as a tree, `verdict` comes beside the images: what a check of that app finds now,\nbeside its rows \u2014 what the next write would refuse it for and the notes\non it. `verdict_unread` says why none was read.\n\nThe app\'s `name`, `icon`, `color` and `description` are props of the App node; an app\nstating no `color` gets the platform\'s grey tile. `lotics run set_app` with the app\'s id and a `body` patch changes them\non the app, and `lotics app pull` reads the tree back as the app holds it. The\n`description` heads the capability listing the member\'s chat agent reads on **every** turn, so a\nstanding process the app expects that agent to carry out belongs there and nowhere else.\n\n**A caller outside the team gets its own app.** Sharing an app publicly, or giving an API key\naccess to it, reaches every alias the app declares; no alias can be held back. So whatever\noutsiders may call is a SECOND app over the same tables: only the queries they may read and the\nworkflows they may run. The desk the team works in stays a separate, private app.\n';
|
|
1777
1778
|
|
|
1778
1779
|
// docs/cli_reference.md
|
|
1779
|
-
var cli_reference_default = "# @lotics/cli \u2014 CLI Command Reference\n\nPer-command syntax, flags, contracts, and gotchas for the public `lotics` CLI. Start at [AGENTS.md](../AGENTS.md) for the model this reference assumes; `lotics --help` is the authoritative, always-current verb list.\n\n| Command | What it does |\n|---|---|\n| `lotics` / `lotics --help` | Show full help: capabilities, the verb list (\xA7 COMMANDS), flags, config. `lotics <verb> --help` prints that verb's entries alone (`lotics app --help`, `lotics file download --help`); `lotics report --help` prints the report frame. |\n| `lotics auth signup <email>` | Create account + org + API key, sends magic link email. Registers the new org as a profile; `--local` pins this directory to it (pointer) instead of setting the global default. |\n| `lotics auth login <email>` | Sign in an account that already exists, on a machine holding no key. **Two steps, and it does not wait for the person.** The first prints the page to open \u2014 `https://lotics.ai/cli_login/<request_id>`, also mailed \u2014 and the code that page must show, records the request, and exits 0. They sign in there if asked, check the code and press Confirm. **Then the next command that needs a credential collects the key** before it does its own work, so the second step is just re-running whatever was wanted; a command run before Confirm exits 1 naming the page and the code again, and once the 15 minutes are up it says to ask again. The handful that run WITHOUT a credential \u2014 `docs` among them \u2014 claim nothing, so one of those run after Confirm still answers as though signed out. `--wait` keeps one command instead, holding the terminal until Confirm; `--local` pins this directory to that org rather than setting the global default, and implies `--wait` (a pin names THIS directory, so only the terminal that stays in it can write one). `--json` prints `organization_id`, `workspace_id` and `organization_name` when it finishes signed in, and `request_id`, `confirm_url`, `code`, `email`, `expires_at` when it is the first step. The request's secret is never printed and the org's key never leaves the store. |\n| \u2014 | **A flag is refused on a verb that does not read it**, naming the verbs that do, before any request or credential: `--check` on `lotics run` would otherwise be parsed and ignored, and the write it was meant to stop would run. `--json`, `--workspace`, `--api-key` and `--timeout` are read by every verb. |\n| `lotics auth api-key [key]` | `whoami` \u2192 **upsert** the key's org as a profile in the global store (never overwrites). The profile records the instance the key was verified against (`LOTICS_API_URL`, default `https://api.lotics.ai`), and every later command for that org goes there. `--local` additionally pins this directory to it (pointer) instead of setting the global default. |\n| `lotics auth web` | Send a magic link email to access the web app (requires auth) |\n| `lotics auth whoami` | Print active account name, email, org, resolved workspace, the instance the credential belongs to, which **kind** of credential this machine holds (a sign-in from `auth login`, or an API key \u2014 read from the saved profile, and from the server when the profile does not say, which covers `--api-key`/`LOTICS_API_KEY` and a profile saved before the field existed; unknown only when neither can say), and the resolution **source** (flag/env/local/app-manifest/global). `--json` adds `workspace_id`, `api_url`, `credential_kind` + `source`. |\n| `lotics auth logout [<name\\|id>]` | In a pinned dir: delete the local pin. Else: remove the profile (default the active org), `--all` for every one. What happens server-side depends on which KIND of credential it is. A **sign-in** (`auth login` / `auth signup`) is revoked \u2014 logging that terminal out ends its credential rather than leaving a live one behind; a server that cannot be reached, or a credential already dead, never blocks the local forget, and one line names the org and Settings \u2192 Security \u2192 *Keys and terminals*. An **API key** (`auth api-key`) is only forgotten here \u2014 an admin issued it and it is routinely on a server and on other machines, so one terminal signing out must not kill it for everyone; the line says it is still active and names both pages, because Settings \u2192 API keys is admin-only and the credential may well be the holder's own sign-in, which they revoke themselves at Settings \u2192 Security \u2192 *Keys and terminals*. A profile saved before the kind was recorded states nothing, so the SERVER is asked (`auth whoami`) and it is revoked only if the answer is a sign-in: an older server, a credential minted before the column, and a request that fails all leave it alone. |\n| \u2014 | **A refused credential says which of three ways it is dead, and names the remedy that ends its kind.** `This credential expired.` / `was revoked.` / `belongs to a member who is no longer active in this organization.` carries `Run \\`lotics auth login <email>\\` to sign in again.` for a sign-in and `Ask an admin for a new API key (Settings \u2192 API keys).` for an issued key. A credential minted before that was recorded still gets BOTH in one sentence, because nothing on the row tells them apart \u2014 so a headless box is never sent looking for a browser alone. A key the server does not recognize at all gets one flat `Invalid or disabled API key.` \u2014 deliberately, so a guessed key learns nothing, not even that it named a row. The body carries `reason` for a script to branch on, since the code stays `unauthorized` for every 401. |\n| `lotics org` | List saved orgs (profiles) from the global store with the instance each belongs to, marks active for this directory (a local pin wins over the global default). |\n| `LOTICS_ORG=<name\\|id>` | Scope every command in this shell to one saved org. **Resolved once, before any command dispatches**, so a value matching no saved credential refuses every verb with one sentence \u2014 a read, a write, and a local check that needs no credential alike \u2014 and refuses it before the first byte is written. It refuses even when a credential arrives another way, because `--api-key` / `LOTICS_API_KEY` outrank it in the precedence chain and a write must never fall through to whatever THOSE name while the variable says otherwise; when the variable resolves and a key is also given, the key decides and the command says so. The refusal lists the orgs this machine holds, so it is answerable without another command (`lotics org` is refused by the same rule). A name is whatever the credential was SAVED under \u2014 a server-side rename never moves it, and the new name resolves too, so both keep working and `lotics org` prints the pair. |\n| `lotics org use <name\\|id> [--local]` | Switch the active org by org name (case-insensitive, ambiguous \u2192 error) or id. No flag \u2192 global `active_org`; `--local` \u2192 a `.lotics/config.json` pointer in the current dir. |\n| `lotics workspace` | List workspaces in the active org, marks current with `(current)` |\n| `lotics workspace select <id>` | Set the workspace in the **active scope** \u2014 a local pin if the dir has one, else the active org's global profile. Records the workspace's NAME beside its id, which is what the `lotics \u2192 <org> / <workspace>` echo prints; `workspace list`, `workspace create`, `workspace rename` and `org use` record it too, so a target is named rather than identified. Until one command has listed it, the echo prints the id and says the name is not known yet. |\n| `lotics workspace create <name> [--timezone <Area/City>] [--currency <ISO>]` | Create a new workspace (admin only), auto-switches to it. Neither flag is defaulted from THIS machine, unlike signup: an extra workspace is routinely created by an operator for somebody else. Without `--timezone` the new workspace inherits the zone of the org's OLDEST workspace; without `--currency` it takes the org's default. Both ride the create, so the workspace is never briefly denominated in a currency nobody asked for. `--currency` takes an ISO-4217 code (case-insensitive; anything else is refused). |\n| `lotics workspace rename <name>` | Rename the **current** workspace (admin only) \u2014 the endpoint takes its target from the request's workspace, never a path id, so switch with `workspace select <id>` first and read the `lotics \u2192 <org> / <workspace>` echo before trusting it \u2014 both halves are names, and the rename moves the cached one in the same act. Carries the workspace's existing `default_currency` and `timezone` through unchanged: the endpoint takes the whole settings triple, so sending only a name would blank the other two. |\n| `lotics workspace settings [--name <n>] [--currency <ISO>] [--timezone <Area/City>]` | Change the CURRENT workspace's name, default currency or timezone \u2014 `PATCH /v1/workspace`, admin only. Only what you name changes; the endpoint takes the whole triple, so the CLI carries the two you did not. `rename` is this verb with the name alone, which is why it can never forget the other two. Both values are invisible once they are wrong: the currency decides how every money field RENDERS and the zone decides how every date BUCKETS, on a workspace whose whole purpose may be to look like the customer's own. `--json` prints the updated workspace. |\n| `lotics workspace delete <id> --yes` | Delete a workspace by id (admin only). **Soft delete** \u2014 `archived_at` is set, so it drops out of listings, can no longer be selected, and its tables/records go dark, while the data is retained and recoverable. Its **apps are cascade-archived** too \u2014 every app entry point (embedded, public link, standalone subdomain, incl. anonymous public links) stops serving. Refuses the org's **only** active workspace (400) and any workspace outside the caller's org (404). Requires `--yes` to confirm (destructive; the CLI is used non-interactively). |\n| `lotics workspace doctor` | Report workspace-wide dangling schema references via `GET /v1/workspaces/dangling-references` \u2014 every active app/workflow artifact whose prefixed schema id no longer resolves, printed as `<referent.kind> \"<name>\" (<id>) \u2192 <namespace> <id> (missing)`; healthy prints a one-line all-clear. **Exits non-zero (exit 1) on findings** so scripts can gate on it. Resolves the first workspace like every data command (runs before the global workspace resolution). Admin-only. |\n| `lotics tools` | List tools by category with descriptions |\n| `lotics tools <name>` | Full description, input JSON Schema, and the output schema where the tool answers a shape, for one tool; a workflow step says to call it from a workflow body, not `lotics run` |\n| `lotics run <tool> '<json>'` | Execute a tool (text output via toModelOutput). Args may also come from a file (`lotics run <tool> @args.json`) or stdin behind the `-` sentinel (`cat args.json \\| lotics run <tool> -`) \u2014 both bypass the OS `ARG_MAX` limit for large payloads (a knowledge-doc `content`, a bulk update). A leading `@` on the args is unambiguously a file path (JSON args start with `{`). **Stdin is asked for, never guessed.** `lotics run <tool>` with no payload runs the tool with no arguments and returns at once. `lotics report` takes the same sentinel. In PowerShell use `@file`: quotes inside an inline argument are consumed by the shell, and the CLI reports the JSON it received with its quotes gone \u2014 the error names both escapes. |\n| `lotics run <tool> --json '<json>'` | Execute a tool (full JSON output) |\n| `lotics run <tool>` \u2014 **file cells** | A file in a tool's result carries its `fil_\u2026` id and metadata and **no `url`**, on every tool and in both output modes. That is not a broken file \u2014 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. |\n| \u2014 | **Every tool is invoked here, including the ones that RUN something** (`run_app_workflow`, `run_app_query`) and every one that builds: `set_tables` (a list of table documents), `set_app` (one app), `set_app_queries`, `set_app_workflow`. A command exists only for work that touches a local file: `app apply`, `app create --custom`, `app pull`, `app deploy`. |\n| \u2014 | **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` \u2014 up to 20 reads in one call, each `{\"alias\", \u2026}`, 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 `dry_run: true` \u2014 a rehearsal on the real inputs, nothing written or sent; the answer is what the run would return, with the reads it made under `dry_run.reads`, the calls it would make under `dry_run.writes` and the papers it rendered under `dry_run.papers` and the parts of the answer it could not say under `dry_run.unpredicted`; a workflow no dry run is offered of answers `status: \"not_previewed\"` \u2014 and `runs` \u2014 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. |\n| \u2014 | **The exit code reports the WORK, not just the call \u2014 for the tool that RUNS one.** `run_app_workflow` whose envelope carries a failed `status` (`error`/`failed`/`cancelled`, or `not_previewed` \u2014 a dry run asked for and not given) exit non-zero and print `<tool> \u2192 <status>: <message>` to stderr, so `lotics run \u2026 && next-step` cannot walk past a refused run. The rule is an allowlist of FAILURE \u2014 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. |\n| `lotics run <tool> '<json>' -o <dir>` | Also save every **signed link** the answer carries \u2014 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`. |\n| `lotics run set_member '{\"members\": {\"<email or member id>\": {\"role\": \"member\", \"name\": \"\u2026\"}}}'` | Invite, rename, remove (`null`) or restore members, admin only: the whole map is checked before an email is sent. A member's `lotics tools` does not list it, nor `set_group` or `set_workspace`. View-as is the web app's preview alone; the CLI acts as the signed-in member. |\n| `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. |\n| `lotics run <tool> --cleanup` | Implies `--print-created`, then runs those deletes \u2014 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. |\n| `lotics file upload <file\\|dir...>` (alias `lotics upload`) \xB7 `--stdin` \xB7 `--base64` \xB7 `--url <url>` | Upload files/directories. **The transport is chosen by size and is not a flag**: under 8 MiB the file is POSTed to `/v1/files`, several such files to a request up to half that route's 100 MiB cap; at or above it the CLI takes presigned part URLs and PUTs the bytes straight to object storage, so they never pass through the API. That threshold matches the AWS CLI's own `multipart_threshold`, and the number matters less than there being nothing to choose \u2014 one verb, any size, up to the 2 GiB a workspace may store. A large upload reads one part at a time, so memory stays flat regardless of file size, and a failure part-way abandons the parts already sent rather than leaving them billable and invisible. A directory expands to its immediate files; `--as <name>` renames a single upload. **Three alternative byte sources, for a caller that never had the bytes on disk** \u2014 an attachment decoded in memory, a generated document, a signed download link \u2014 each mutually exclusive with the others and with a path argument: `--stdin` takes raw bytes on stdin, `--base64` takes base64 on stdin (the shape attachments arrive in), `--url <url>` fetches the URL first. `--stdin`/`--base64` REQUIRE `--as`, because stdin carries no filename and the mime type is derived from it; `--url` falls back to `Content-Disposition` then the URL's last path segment. `--base64` decodes STRICTLY \u2014 `Buffer.from(s, \"base64\")` silently skips invalid characters and truncates on bad padding, so a corrupted pipe would otherwise store a short file that only fails when a human opens it. The `--url` fetch happens in the CLI, not the server: the URL comes from the operator running the command, so routing it through the backend would add an SSRF surface to buy what `curl` already does. |\n| `lotics file download <file_id> [<path>]` \xB7 `-o <dir>` | (alias `lotics download`) Download a stored file: `GET /v1/files/{id}/signed_url` \u2192 fetch the presigned URL and write it where you asked. **The two spellings mean two different things, and neither is read by shape: the positional `<path>` is the FILE to write, `-o <dir>` is the DIRECTORY to save into.** That is `cp` and `curl -o`, so nothing here consults an extension. A named file is written as named, its parent created, overwriting what is there \u2014 the point of naming it is that the next command opens that exact path. A directory is created if missing and written into under the stored filename (the response's `Content-Disposition`), taking a free spelling beside a file of that name already there so a repeat download never clobbers the first; with no destination at all, that filename lands in cwd. Give the destination once \u2014 a positional and `-o` together is refused, as is a positional that names an existing directory or ends in a separator (`a directory goes in -o`). The first argument is a **file id**, so a path in that slot is refused rather than sent as an id. The written path goes to **stdout** (under `--json`, `{file_id, path, filename, stored_filename}`) and the narration to stderr, so a download pipes into whatever opens it. `lotics file download record <record_id> <field_alias> [-o <dir>]` (the field named by the alias `get_record` shows it under) spreads every file on a record's file field over a DIRECTORY \u2014 there is no single file for N files to be. |\n| `lotics file list [--limit <n>] [--cursor <token>]` | The workspace's files, newest first \u2014 id, upload time, bytes, MIME type, filename on stdout, one per line (`--json` for the object). `GET /v1/files` with no `file_ids`. A file holding the content of a knowledge doc or template you cannot use is left out. **A page, not a dump**: the store only ever grows, so the last line prints the command for the next page and `next_cursor` is null on the last one. The cursor is opaque and keyset \u2014 pass it back as given \u2014 so an upload landing mid-sweep cannot make a walk skip or repeat a row. Every other file verb takes an id, so this is the only answer to \"what is in here\" short of reading Postgres. |\n| `lotics file delete <file_id>` | Archive a stored file, over the `delete_file` tool. Deleting an original deletes every version of it; a file's current version cannot be deleted on its own, and a document template's file, or a version of it, only by a member who manages the template. **Refused while a record cell, a comment, a knowledge doc, a document template, a voice session or a pending change request still references the file or a version it takes along** \u2014 the refusal names the referents, so this is safe to try. The bytes are left in object storage; the row no longer serves them, which is what \"deleted\" means here. There is no `lotics delete`: the verb needs its noun. |\n| `lotics knowledge list [--include-hidden \\| --deleted]` | `GET /v1/knowledge_docs` \u2014 a table of id, name, tags, description (`--json` for the docs). **REST, not `get_workspace` kind `knowledge`**: the tool answers what the ASSISTANT may browse, and a hidden doc is out of that corpus by definition, so a tool-backed listing could never show one and the person who hid it would have no way back to it. Hidden docs are left out unless `--include-hidden` asks; those rows are marked `(hidden)`. **`--deleted` is the one tool-backed form**: it calls `get_workspace` with `kind: \"knowledge\"` and `include_archived: true`, page by page, and prints the deleted docs you can manage (id, name, deleted time, description); it cannot be combined with `--include-hidden`. |\n| `lotics knowledge create --name <n> [--description <d>] [--tags <a,b>] (--from <file.md> \\| --content <str>)` | Read the body client-side (a file XOR an inline string \u2014 exactly one required), then call `set_knowledge` with `{ name, description, content, tags? }` and no `knowledge_doc_id`, which makes a doc (description defaults to `\"\"`). `--tags` files the doc as it is made, which is the only moment a corpus reliably gets labelled. Prints the new id to stdout. Large files ride the POST body fine. |\n| `lotics knowledge get <id> [-o <file.md>]` | `GET /v1/knowledge_docs/{id}` (`getKnowledgeDoc`) \u2192 the doc with its **hydrated `content`** (the one whole-content read for a non-sandbox client; the tool `read_knowledge` reads a line range). `-o` writes the body via `writeFileAtomic`; else the body goes to stdout. `--json` prints the full doc instead. |\n| `lotics knowledge update <id> [--from <file.md> \\| --content <str>] [--name <n>] [--description <d>] [--tags <a,b>]` | Call `set_knowledge` with a `knowledge_doc_id` and **only** the provided fields (a body from --from/--content becomes `content`; the tool diffs + CASes the content change internally, so the CLI passes no `expected_content_file_id`). `--tags` REPLACES the doc's label set \u2014 the single-doc form, where the caller is looking at one doc and can state what it should carry. At least one field required; --from and --content are mutually exclusive. |\n| `lotics knowledge tag <id...> [--add <a,b>] [--remove <c,d>]` | `PATCH /v1/knowledge_docs` with `{ knowledge_doc_ids, add_tags?, remove_tags? }` \u2014 one transaction over the whole set. A **DIFF applied to each doc's own labels**, never a replacement: the docs named on one command line carry different labels, so one array across them would strip whatever the others were filed under. Removal matches case-insensitively; adding a label a doc already carries writes nothing. Ids may be separate arguments or comma-separated. At least one of --add/--remove required. |\n| `lotics knowledge hide <id...>` / `lotics knowledge unhide <id...>` | `PATCH /v1/knowledge_docs` with `{ knowledge_doc_ids, hidden }`. Hiding takes docs out of every **listing** \u2014 the Library's list, and the corpus `get_workspace` lists and its `search` searches \u2014 while leaving IAM untouched and keeping them readable **by id** (`read_knowledge` with an id, a code run staging one, an app agent's declared set). So it can never silently break an app that depends on a doc, and unhiding costs nothing. Refuses the no-argument form rather than reading it as \"everything\". |\n| `lotics knowledge rm <id...>` | Delete docs via `delete_knowledge` (`{ knowledge_doc_ids }`), in one call: every id must be a live doc you manage that no app or workflow names, or nothing is deleted. Sharing and history are kept. The REST execute path does not gate `needsApproval`, so this runs unattended. |\n| `lotics knowledge restore <id...>` | Bring deleted docs back via `restore_knowledge` (`{ knowledge_doc_ids }`), in one call: every id must be a deleted doc you manage, or nothing is restored. Refuses the no-argument form. |\n| `lotics app create <name> --custom [path]` | **A custom-code app**: creates the app (`POST /v1/apps`), scaffolds a Vite + React + TypeScript project into `[path]` (default `./<name>`, refused when not empty \u2014 before the app row exists) that depends on `@lotics/app-sdk` alone and draws with plain React, and installs it (`npm install --ignore-scripts`), then writes the declarations of the app's live bindings (`GET /v1/apps/{id}/types`) into `.lotics/`, which the project's `tsconfig.json` includes. `package.json#lotics` names the app and its workspace, which is how `app deploy` in that directory finds both. The app has no version until the first `lotics app deploy`. `--custom` is required: a JSON app is made by `lotics app apply` (the tool `set_app`). The SDK's reference is `node_modules/@lotics/app-sdk/AGENTS.md` inside the project. |\n| `lotics app pull [app_id] [path]` | **An app's live definition, pulled.** The app is read with `get_app`: its author is shown a body, a JSON **tree** (with the addresses of its nodes) pulled as that tree and a custom-code app (the identity alone) as its source; an app whose stored tree cannot be read is refused with the reason `get_app` gives, and so is an app that shows the caller no body (the caller does not author it, or it has no version). **A JSON app**: the tree is written as JSON to `[path]` (a directory receives `./<name>.app.json`; default `./<name>.app.json`), and the app's id and current version beside it in `<file>.lotics.json` (`{ \"app_id\": \"\u2026\", \"version_id\": \"\u2026\", \"digest\": \"\u2026\" }`, the digest being the file as the CLI last left it; a record without all three is refused by `app apply`, naming `lotics app pull`) \u2014 a custom-code project records both in `package.json#lotics`, a single file has no such place \u2014 so the next `lotics app apply <file>` edits that app, and is refused if the app has since moved past that version. An existing file is overwritten only when its `.lotics.json` names this same app and the file is unchanged since it was last pulled or applied; a file with edits, or whose record names the app but no digest, is refused, naming `--force`, which overwrites it. `--force` is a flag of `app pull` alone. `--json` prints `{workspace_id, app_id, path, outcome}`. **A custom-code app**: its live source, as a project ready to deploy on it. The target is `[path]`, else this directory when it is the app's project (or no app is named), else `./<name>`. **The app's own project is brought up to date in place** \u2014 but only when it holds no edit since the version `package.json#lotics.current_version_id` names: its source (packed as a deploy packs it) is compared with that version's archive, ignoring `.lotics/` and `package.json#lotics`, and any difference refuses the pull, naming the changed files; a pull never merges, so local work is never lost. Already at the live version is a no-op that says so. **An empty or new directory receives the source whole**; any other directory is refused. Downloads come from `GET /v1/apps/{id}/versions/{version_id}/source`. `package.json#lotics` is then set to exactly the app, its workspace and the pulled version, dropping every other key an older CLI wrote there, so the next `lotics app deploy` builds on the live version; then `.lotics/` is written (`GET /v1/apps/{id}/types`) and dependencies installed (`npm ci` with a lockfile, else `npm install`, both `--ignore-scripts`). |\n| `lotics app apply <file> [--app <app_id>] [--check] [--json]` | **A JSON app, one at a time, through the tool `set_app`.** The file is an app's tree (`lotics docs model`: the App node with the nodes under it, its tables and templates named by alias). **No app known** (no `--app`, no `<file>.lotics.json` beside it): the file is sent as `{ \"body\": \u2026 }` to make a new app, named by the body's `name`, and the id made is printed, then recorded with its version in `<file>.lotics.json`. **An app known**: its current tree is read (`get_app`) and the **RFC 7396 merge patch** that turns it into the file is sent as `body` with the `app_id` and the version the patch was computed against as `base_version_id` \u2014 a key the file drops is `null` in the patch, a list is replaced whole, what the file leaves alone is left alone, and a tree the app already has sends nothing and says so. **A file pulled at a version the app has since left is refused** before anything is sent, naming both versions and `lotics app pull <app_id> <path>`: pull the current tree into another path, carry the edits over, apply that file. `--app` naming an app other than the sibling file's is refused too. A write that races the apply is refused by the server, which names the app's current version. A file that sets an object key to `null` is refused naming the key, since a merge patch reads `null` as a removal. `--app` names the app, else the sibling file does; an app whose `get_app` shows no body (it is not yours) or whose stored tree cannot be read is refused, and a custom-code app takes its identity (name, description, icon, colour) only, and no `<file>.lotics.json` records it, since an identity write makes no version \u2014 its source deploys with `app deploy`. **The file is never rewritten**: a node the tree leaves out draws as the rows call for it and is stored nowhere (`lotics docs design`). It prints what `set_app` answered and reports its `outcome`: its notes and findings, every finding at once, and the saves, adds and Deletes the write grants and removes. **`--check` sends `dry_run`** (a flag of `app apply` alone, as is `--app`) \u2014 what would refuse the tree, and what it would grant and remove \u2014 and writes nothing, the file and its sibling included; an `outcome` of `refused` exits 1. A tree the app already has, or one `set_app` answers `unchanged`, records the version the app stays at in the sibling. **To go back to an earlier version, apply the earlier tree again.** Resolves and ANNOUNCES its workspace first. `--json` prints `{workspace_id, app_id, outcome, write}`, `outcome` being `set_app`'s: `created`, `updated`, `unchanged` or `refused` (with `--check`, what it would be). |\n| `lotics app deploy [-m <message>]` | **Build this directory and upload it as a new version of the live app.** Rewrites `.lotics/` with the declarations of the app's live bindings (`GET /v1/apps/{id}/types`, replacing each file there), then runs the project's `npm run typecheck` (warned about when absent; a type error in the app's own files stops the deploy, one inside an installed package is reported and does not) and `npm run build`, tars the source (without `node_modules`, `dist`, `.git`, `*.tsbuildinfo`) and `dist/`, and posts both to `POST /v1/apps/{id}/versions` on the version `package.json#lotics.current_version_id` names, then stamps the new one there. The version carries the app's queries, workflows, agents and capabilities forward unchanged \u2014 those are written through their tools. A project with no `build` script, or whose `package.json#lotics` still declares `queries`, `workflows`, `agents` or `capabilities`, is refused before anything is built; the refusal names the tool that sets each. **A 409 because another version went live since this directory's last deploy** (a deploy from elsewhere, a rollback) prints the server's sentence and the version that is live, and names `lotics app pull`: run in this directory, it brings an unedited project up to date, and lists the files a project with edits changed, to carry over into a fresh pull. `-m` (or a bare positional) is the version's message, optional. The workspace comes from `package.json#lotics.workspace_id` unless `--workspace` / `LOTICS_WORKSPACE` names another. |\n| `lotics docs` \\| `lotics docs <area>[/<section>]` \\| `lotics docs [<area>[/<section>]] --grep <text>` | **This CLI's own references, carried inside the binary** \u2014 the model reference, this index, and every doc under `docs/` \u2014 so the doc a reader opens describes the binary answering, listed by the job a reader comes to do. Capped at ONE PAGE: a doc that does not fit prints its opening and the addresses of what it holds (`lotics docs <area>/<section>`, each section's size beside it, or a table's row names), and every address prints within a page. **A reference the binary's copy lacks, or a part of one the server serves, is read from the server's `docs` tool** with this machine's credential, after the copy's refusal \u2014 a page the server added since this binary was built, which a server refusal can cite. A name matching more than one reference, or a part missing from a guide to this CLI, is answered by the copy alone. **`--grep` searches the references the server serves** (not this CLI's own guides, which it refuses), with this machine's credential, narrowed to the area or section named: literal text unless `--regex`; a literal search that finds nothing while the pattern read as an expression matches (`a|b`) returns those lines under a note saying so, and `--literal` keeps the literal zero; with `--case-sensitive`, `--diacritic-insensitive`, `--context-lines <n>` and `--limit <n>` \u2014 the dialect the knowledge lines of `get_workspace` with `search` are read in \u2014 each hit under the address that opens its page; any of those options without `--grep` is refused. A custom-code app's SDK reference ships inside `@lotics/app-sdk` in the app's `node_modules`. |\n| `lotics upgrade` | Update this CLI in place. Runs the same installer a person would, chosen by how THIS copy arrived: an npm install upgrades through npm, a script install re-runs the script \u2014 the runtime knows which (the executable is compiled, the npm bin runs under node), so nobody has to. It downloads nothing itself; resolving a version, verifying the checksum and replacing a running executable already exist in the installers, and a second copy of that inside the binary would be a second thing to get right. Replacing the binary while it runs is safe \u2014 a rename leaves the running image mapped on unix, and on Windows the installer moves the old aside precisely because the file is in use. Already current is a no-op that says so. Needs no auth. |\n| `lotics docs model` \\| `lotics docs model/<section>[/\u2026]` | **The reference for tables and apps, from inside the binary** \u2014 at this CLI's version; `lotics docs` lists it under \"Build an app\", after `design`. Its first page is what the documents compose with, the working order (jobs \u2192 tables and fields \u2192 `records` \u2192 one app per job \u2192 one `set_tables` call with every table, then every `set_app` call) and the section addresses; every page of it is whole. Every key of a table document and an app body, every field `type` with the config each one needs, the option / view / inline-template shapes, `records` (how a row of a table is recognised), `write_rules`, the apps (each register, record, act and check), the row format (relative dates `@today` / `@month-start` with whole-day offsets; links as `\"<table-ref>:<row-ref>\"`), the rules, the caps of one `set_tables` call, and a worked example; it points at `https://lotics.ai/presets/index.json` for example files of several trades, which an agent reads and passes to `set_tables`. **Offline, no account.** |\n| `lotics report '<json>'` \\| `lotics report @report.json` | File a report with the Lotics team about what got in your way. **Covers the classes telemetry structurally cannot see**: a capability that does not exist (no command ran, so nothing was recorded), a command that exited 0 having done the wrong thing, an error whose message did not name the remedy, and anything that made authoring slower than it should be. **A frame, not a paragraph** \u2014 `{goal, actual, expected?, tried?, wanted?}`, `goal` and `actual` required, unknown keys dropped rather than refused. **No severity or category.** Ingest is inline JSON, `@file`, or `-` for stdin. A bare sentence is refused with the frame printed beside it, so the fix is one step; a bare invocation prints the frame BEFORE asking for a credential, since someone whose key will not resolve is exactly who has something to report. **Not spooled**: unlike telemetry it posts inline, prints whether it landed, and exits non-zero if it did not, echoing the report back so a failed send never loses it. Runs regardless of `LOTICS_TELEMETRY` \u2014 invoking it IS the consent that passive collection needs an opt-in for \u2014 but with telemetry off there are no recorded commands to attach, and it says so rather than implying context it does not have. Requires auth. Never paste records, file contents, or credentials. **Prints the id of each frame filed** \u2014 a filing nobody can cite cannot be answered about. The ids come from the server, so an instance that only logs the frames prints the count alone; the CLI never mints one of its own, which would hand back a token that resolves to nothing. |\n\n";
|
|
1780
|
+
var cli_reference_default = "# @lotics/cli \u2014 CLI Command Reference\n\nPer-command syntax, flags, contracts, and gotchas for the public `lotics` CLI. Start at [AGENTS.md](../AGENTS.md) for the model this reference assumes; `lotics --help` is the authoritative, always-current verb list.\n\n| Command | What it does |\n|---|---|\n| `lotics` / `lotics --help` | Show full help: capabilities, the verb list (\xA7 COMMANDS), flags, config. `lotics <verb> --help` prints that verb's entries alone (`lotics app --help`, `lotics file download --help`); `lotics report --help` prints the report frame. |\n| `lotics auth signup <email>` | Create account + org + API key, sends magic link email. Registers the new org as a profile; `--local` pins this directory to it (pointer) instead of setting the global default. |\n| `lotics auth login <email>` | Sign in an account that already exists, on a machine holding no key. **Two steps, and it does not wait for the person.** The first prints the page to open \u2014 `https://lotics.ai/cli_login/<request_id>`, also mailed \u2014 and the code that page must show, records the request, and exits 0. They sign in there if asked, check the code and press Confirm. **Then the next command that needs a credential collects the key** before it does its own work, so the second step is just re-running whatever was wanted; a command run before Confirm exits 1 naming the page and the code again, and once the 15 minutes are up it says to ask again. The handful that run WITHOUT a credential \u2014 `docs` among them \u2014 claim nothing, so one of those run after Confirm still answers as though signed out. `--wait` keeps one command instead, holding the terminal until Confirm; `--local` pins this directory to that org rather than setting the global default, and implies `--wait` (a pin names THIS directory, so only the terminal that stays in it can write one). `--json` prints `organization_id`, `workspace_id` and `organization_name` when it finishes signed in, and `request_id`, `confirm_url`, `code`, `email`, `expires_at` when it is the first step. The request's secret is never printed and the org's key never leaves the store. |\n| \u2014 | **A flag is refused on a verb that does not read it**, naming the verbs that do, before any request or credential: `--check` on `lotics run` would otherwise be parsed and ignored, and the write it was meant to stop would run. `--json`, `--workspace`, `--api-key` and `--timeout` are read by every verb. |\n| `lotics auth api-key [key]` | `whoami` \u2192 **upsert** the key's org as a profile in the global store (never overwrites). The profile records the instance the key was verified against (`LOTICS_API_URL`, default `https://api.lotics.ai`), and every later command for that org goes there. `--local` additionally pins this directory to it (pointer) instead of setting the global default. |\n| `lotics auth web` | Send a magic link email to access the web app (requires auth) |\n| `lotics auth whoami` | Print active account name, email, org, resolved workspace, the instance the credential belongs to, which **kind** of credential this machine holds (a sign-in from `auth login`, or an API key \u2014 read from the saved profile, and from the server when the profile does not say, which covers `--api-key`/`LOTICS_API_KEY` and a profile saved before the field existed; unknown only when neither can say), and the resolution **source** (flag/env/local/app-manifest/global). `--json` adds `workspace_id`, `api_url`, `credential_kind` + `source`. |\n| `lotics auth logout [<name\\|id>]` | In a pinned dir: delete the local pin. Else: remove the profile (default the active org), `--all` for every one. What happens server-side depends on which KIND of credential it is. A **sign-in** (`auth login` / `auth signup`) is revoked \u2014 logging that terminal out ends its credential rather than leaving a live one behind; a server that cannot be reached, or a credential already dead, never blocks the local forget, and one line names the org and Settings \u2192 Security \u2192 *Keys and terminals*. An **API key** (`auth api-key`) is only forgotten here \u2014 an admin issued it and it is routinely on a server and on other machines, so one terminal signing out must not kill it for everyone; the line says it is still active and names both pages, because Settings \u2192 API keys is admin-only and the credential may well be the holder's own sign-in, which they revoke themselves at Settings \u2192 Security \u2192 *Keys and terminals*. A profile saved before the kind was recorded states nothing, so the SERVER is asked (`auth whoami`) and it is revoked only if the answer is a sign-in: an older server, a credential minted before the column, and a request that fails all leave it alone. |\n| \u2014 | **A refused credential says which of three ways it is dead, and names the remedy that ends its kind.** `This credential expired.` / `was revoked.` / `belongs to a member who is no longer active in this organization.` carries `Run \\`lotics auth login <email>\\` to sign in again.` for a sign-in and `Ask an admin for a new API key (Settings \u2192 API keys).` for an issued key. A credential minted before that was recorded still gets BOTH in one sentence, because nothing on the row tells them apart \u2014 so a headless box is never sent looking for a browser alone. A key the server does not recognize at all gets one flat `Invalid or disabled API key.` \u2014 deliberately, so a guessed key learns nothing, not even that it named a row. The body carries `reason` for a script to branch on, since the code stays `unauthorized` for every 401. |\n| `lotics org` | List saved orgs (profiles) from the global store with the instance each belongs to, marks active for this directory (a local pin wins over the global default). |\n| `LOTICS_ORG=<name\\|id>` | Scope every command in this shell to one saved org. **Resolved once, before any command dispatches**, so a value matching no saved credential refuses every verb with one sentence \u2014 a read, a write, and a local check that needs no credential alike \u2014 and refuses it before the first byte is written. It refuses even when a credential arrives another way, because `--api-key` / `LOTICS_API_KEY` outrank it in the precedence chain and a write must never fall through to whatever THOSE name while the variable says otherwise; when the variable resolves and a key is also given, the key decides and the command says so. The refusal lists the orgs this machine holds, so it is answerable without another command (`lotics org` is refused by the same rule). A name is whatever the credential was SAVED under \u2014 a server-side rename never moves it, and the new name resolves too, so both keep working and `lotics org` prints the pair. |\n| `lotics org use <name\\|id> [--local]` | Switch the active org by org name (case-insensitive, ambiguous \u2192 error) or id. No flag \u2192 global `active_org`; `--local` \u2192 a `.lotics/config.json` pointer in the current dir. |\n| `lotics workspace` | List workspaces in the active org, marks current with `(current)` |\n| `lotics workspace select <id>` | Set the workspace in the **active scope** \u2014 a local pin if the dir has one, else the active org's global profile. Records the workspace's NAME beside its id, which is what the `lotics \u2192 <org> / <workspace>` echo prints; `workspace list`, `workspace create`, `workspace rename` and `org use` record it too, so a target is named rather than identified. Until one command has listed it, the echo prints the id and says the name is not known yet. |\n| `lotics workspace create <name> [--timezone <Area/City>] [--currency <ISO>]` | Create a new workspace (admin only), auto-switches to it. Neither flag is defaulted from THIS machine, unlike signup: an extra workspace is routinely created by an operator for somebody else. Without `--timezone` the new workspace inherits the zone of the org's OLDEST workspace; without `--currency` it takes the org's default. Both ride the create, so the workspace is never briefly denominated in a currency nobody asked for. `--currency` takes an ISO-4217 code (case-insensitive; anything else is refused). |\n| `lotics workspace rename <name>` | Rename the **current** workspace (admin only) \u2014 the endpoint takes its target from the request's workspace, never a path id, so switch with `workspace select <id>` first and read the `lotics \u2192 <org> / <workspace>` echo before trusting it \u2014 both halves are names, and the rename moves the cached one in the same act. Carries the workspace's existing `default_currency` and `timezone` through unchanged: the endpoint takes the whole settings triple, so sending only a name would blank the other two. |\n| `lotics workspace settings [--name <n>] [--currency <ISO>] [--timezone <Area/City>]` | Change the CURRENT workspace's name, default currency or timezone \u2014 `PATCH /v1/workspace`, admin only. Only what you name changes; the endpoint takes the whole triple, so the CLI carries the two you did not. `rename` is this verb with the name alone, which is why it can never forget the other two. Both values are invisible once they are wrong: the currency decides how every money field RENDERS and the zone decides how every date BUCKETS, on a workspace whose whole purpose may be to look like the customer's own. `--json` prints the updated workspace. |\n| `lotics workspace delete <id> --yes` | Delete a workspace by id (admin only). **Soft delete** \u2014 `archived_at` is set, so it drops out of listings, can no longer be selected, and its tables/records go dark, while the data is retained and recoverable. Its **apps are cascade-archived** too \u2014 every app entry point (embedded, public link, standalone subdomain, incl. anonymous public links) stops serving. Refuses the org's **only** active workspace (400) and any workspace outside the caller's org (404). Requires `--yes` to confirm (destructive; the CLI is used non-interactively). |\n| `lotics workspace doctor` | Report workspace-wide dangling schema references via `GET /v1/workspaces/dangling-references` \u2014 every active app/workflow artifact whose prefixed schema id no longer resolves, printed as `<referent.kind> \"<name>\" (<id>) \u2192 <namespace> <id> (missing)`; healthy prints a one-line all-clear. **Exits non-zero (exit 1) on findings** so scripts can gate on it. Resolves the first workspace like every data command (runs before the global workspace resolution). Admin-only. |\n| `lotics tools` | List tools by category with descriptions |\n| `lotics tools <name>` | Full description, input JSON Schema, and the output schema where the tool answers a shape, for one tool; a workflow step says to call it from a workflow body, not `lotics run` |\n| `lotics run <tool> '<json>'` | Execute a tool (text output via toModelOutput). Args may also come from a file (`lotics run <tool> @args.json`) or stdin behind the `-` sentinel (`cat args.json \\| lotics run <tool> -`) \u2014 both bypass the OS `ARG_MAX` limit for large payloads (a knowledge-doc `content`, a bulk update). A leading `@` on the args is unambiguously a file path (JSON args start with `{`). **Stdin is asked for, never guessed.** `lotics run <tool>` with no payload runs the tool with no arguments and returns at once. `lotics report` takes the same sentinel. In PowerShell use `@file`: quotes inside an inline argument are consumed by the shell, and the CLI reports the JSON it received with its quotes gone \u2014 the error names both escapes. |\n| `lotics run <tool> --json '<json>'` | Execute a tool (full JSON output) |\n| `lotics run <tool>` \u2014 **file cells** | A file in a tool's result carries its `fil_\u2026` id and metadata and **no `url`**, on every tool and in both output modes. That is not a broken file \u2014 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. |\n| \u2014 | **Every tool is invoked here, including the ones that RUN something** (`run_app_workflow`, `run_app_query`) and every one that builds: `set_tables` (a list of table documents), `set_app` (one app), `set_app_queries`, `set_app_workflow`. A command exists only for work that touches a local file: `app apply`, `app create --custom`, `app pull`, `app deploy`. |\n| \u2014 | **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` \u2014 up to 20 reads in one call, each `{\"alias\", \u2026}`, a refused one answered with its `error`; one call's rows total at most 262,144 bytes. `export: {\"key\", \u2026narrowing}` instead of the read arguments makes the file an app's Export makes (`lotics docs agent_surface`). `run_app_workflow '{\"app_id\",\"alias\",\"inputs\"}'` also takes `dry_run: true` \u2014 a rehearsal on the real inputs, nothing written or sent; the answer is what the run would return, with the reads it made under `dry_run.reads`, the calls it would make under `dry_run.writes` and the papers it rendered under `dry_run.papers` and the parts of the answer it could not say under `dry_run.unpredicted`; a workflow no dry run is offered of answers `status: \"not_previewed\"` \u2014 and `runs` \u2014 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. |\n| \u2014 | **The exit code reports the WORK, not just the call \u2014 for the tool that RUNS one.** `run_app_workflow` whose envelope carries a failed `status` (`error`/`failed`/`cancelled`, or `not_previewed` \u2014 a dry run asked for and not given) exit non-zero and print `<tool> \u2192 <status>: <message>` to stderr, so `lotics run \u2026 && next-step` cannot walk past a refused run. The rule is an allowlist of FAILURE \u2014 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. |\n| `lotics run <tool> '<json>' -o <dir>` | Also save every **signed link** the answer carries \u2014 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`. |\n| `lotics run set_member '{\"members\": {\"<email or member id>\": {\"role\": \"member\", \"name\": \"\u2026\"}}}'` | Invite, rename, remove (`null`) or restore members, admin only: the whole map is checked before an email is sent. A member's `lotics tools` does not list it, nor `set_group` or `set_workspace`. View-as is the web app's preview alone; the CLI acts as the signed-in member. |\n| `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. |\n| `lotics run <tool> --cleanup` | Implies `--print-created`, then runs those deletes \u2014 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. |\n| `lotics file upload <file\\|dir...>` (alias `lotics upload`) \xB7 `--stdin` \xB7 `--base64` \xB7 `--url <url>` | Upload files/directories. **The transport is chosen by size and is not a flag**: under 8 MiB the file is POSTed to `/v1/files`, several such files to a request up to half that route's 100 MiB cap; at or above it the CLI takes presigned part URLs and PUTs the bytes straight to object storage, so they never pass through the API. That threshold matches the AWS CLI's own `multipart_threshold`, and the number matters less than there being nothing to choose \u2014 one verb, any size, up to the 2 GiB a workspace may store. A large upload reads one part at a time, so memory stays flat regardless of file size, and a failure part-way abandons the parts already sent rather than leaving them billable and invisible. A directory expands to its immediate files; `--as <name>` renames a single upload. **Three alternative byte sources, for a caller that never had the bytes on disk** \u2014 an attachment decoded in memory, a generated document, a signed download link \u2014 each mutually exclusive with the others and with a path argument: `--stdin` takes raw bytes on stdin, `--base64` takes base64 on stdin (the shape attachments arrive in), `--url <url>` fetches the URL first. `--stdin`/`--base64` REQUIRE `--as`, because stdin carries no filename and the mime type is derived from it; `--url` falls back to `Content-Disposition` then the URL's last path segment. `--base64` decodes STRICTLY \u2014 `Buffer.from(s, \"base64\")` silently skips invalid characters and truncates on bad padding, so a corrupted pipe would otherwise store a short file that only fails when a human opens it. The `--url` fetch happens in the CLI, not the server: the URL comes from the operator running the command, so routing it through the backend would add an SSRF surface to buy what `curl` already does. |\n| `lotics file download <file_id> [<path>]` \xB7 `-o <dir>` | (alias `lotics download`) Download a stored file: `GET /v1/files/{id}/signed_url` \u2192 fetch the presigned URL and write it where you asked. **The two spellings mean two different things, and neither is read by shape: the positional `<path>` is the FILE to write, `-o <dir>` is the DIRECTORY to save into.** That is `cp` and `curl -o`, so nothing here consults an extension. A named file is written as named, its parent created, overwriting what is there \u2014 the point of naming it is that the next command opens that exact path. A directory is created if missing and written into under the stored filename (the response's `Content-Disposition`), taking a free spelling beside a file of that name already there so a repeat download never clobbers the first; with no destination at all, that filename lands in cwd. Give the destination once \u2014 a positional and `-o` together is refused, as is a positional that names an existing directory or ends in a separator (`a directory goes in -o`). The first argument is a **file id**, so a path in that slot is refused rather than sent as an id. The written path goes to **stdout** (under `--json`, `{file_id, path, filename, stored_filename}`) and the narration to stderr, so a download pipes into whatever opens it. `lotics file download record <record_id> <field_alias> [-o <dir>]` (the field named by the alias `get_record` shows it under) spreads every file on a record's file field over a DIRECTORY \u2014 there is no single file for N files to be. |\n| `lotics file list [--limit <n>] [--cursor <token>]` | The workspace's files, newest first \u2014 id, upload time, bytes, MIME type, filename on stdout, one per line (`--json` for the object). `GET /v1/files` with no `file_ids`. A file holding the content of a knowledge doc or template you cannot use is left out. **A page, not a dump**: the store only ever grows, so the last line prints the command for the next page and `next_cursor` is null on the last one. The cursor is opaque and keyset \u2014 pass it back as given \u2014 so an upload landing mid-sweep cannot make a walk skip or repeat a row. Every other file verb takes an id, so this is the only answer to \"what is in here\" short of reading Postgres. |\n| `lotics file delete <file_id>` | Archive a stored file, over the `delete_file` tool. Deleting an original deletes every version of it; a file's current version cannot be deleted on its own, and a document template's file, or a version of it, only by a member who manages the template. **Refused while a record cell, a comment, a knowledge doc, a document template, a voice session or a pending change request still references the file or a version it takes along** \u2014 the refusal names the referents, so this is safe to try. The bytes are left in object storage; the row no longer serves them, which is what \"deleted\" means here. There is no `lotics delete`: the verb needs its noun. |\n| `lotics knowledge list [--include-hidden \\| --deleted]` | `GET /v1/knowledge_docs` \u2014 a table of id, name, tags, description (`--json` for the docs). **REST, not `get_workspace` kind `knowledge`**: the tool answers what the ASSISTANT may browse, and a hidden doc is out of that corpus by definition, so a tool-backed listing could never show one and the person who hid it would have no way back to it. Hidden docs are left out unless `--include-hidden` asks; those rows are marked `(hidden)`. **`--deleted` is the one tool-backed form**: it calls `get_workspace` with `kind: \"knowledge\"` and `include_archived: true`, page by page, and prints the deleted docs you can manage (id, name, deleted time, description); it cannot be combined with `--include-hidden`. |\n| `lotics knowledge create --name <n> [--description <d>] [--tags <a,b>] (--from <file.md> \\| --content <str>)` | Read the body client-side (a file XOR an inline string \u2014 exactly one required), then call `set_knowledge` with `{ name, description, content, tags? }` and no `knowledge_doc_id`, which makes a doc (description defaults to `\"\"`). `--tags` files the doc as it is made, which is the only moment a corpus reliably gets labelled. Prints the new id to stdout. Large files ride the POST body fine. |\n| `lotics knowledge get <id> [-o <file.md>]` | `GET /v1/knowledge_docs/{id}` (`getKnowledgeDoc`) \u2192 the doc with its **hydrated `content`** (the one whole-content read for a non-sandbox client; the tool `read_knowledge` reads a line range). `-o` writes the body via `writeFileAtomic`; else the body goes to stdout. `--json` prints the full doc instead. |\n| `lotics knowledge update <id> [--from <file.md> \\| --content <str>] [--name <n>] [--description <d>] [--tags <a,b>]` | Call `set_knowledge` with a `knowledge_doc_id` and **only** the provided fields (a body from --from/--content becomes `content`; the tool diffs + CASes the content change internally, so the CLI passes no `expected_content_file_id`). `--tags` REPLACES the doc's label set \u2014 the single-doc form, where the caller is looking at one doc and can state what it should carry. At least one field required; --from and --content are mutually exclusive. |\n| `lotics knowledge tag <id...> [--add <a,b>] [--remove <c,d>]` | `PATCH /v1/knowledge_docs` with `{ knowledge_doc_ids, add_tags?, remove_tags? }` \u2014 one transaction over the whole set. A **DIFF applied to each doc's own labels**, never a replacement: the docs named on one command line carry different labels, so one array across them would strip whatever the others were filed under. Removal matches case-insensitively; adding a label a doc already carries writes nothing. Ids may be separate arguments or comma-separated. At least one of --add/--remove required. |\n| `lotics knowledge hide <id...>` / `lotics knowledge unhide <id...>` | `PATCH /v1/knowledge_docs` with `{ knowledge_doc_ids, hidden }`. Hiding takes docs out of every **listing** \u2014 the Library's list, and the corpus `get_workspace` lists and its `search` searches \u2014 while leaving IAM untouched and keeping them readable **by id** (`read_knowledge` with an id, a code run staging one, an app agent's declared set). So it can never silently break an app that depends on a doc, and unhiding costs nothing. Refuses the no-argument form rather than reading it as \"everything\". |\n| `lotics knowledge rm <id...>` | Delete docs via `delete_knowledge` (`{ knowledge_doc_ids }`), in one call: every id must be a live doc you manage that no app or workflow names, or nothing is deleted. Sharing and history are kept. The REST execute path does not gate `needsApproval`, so this runs unattended. |\n| `lotics knowledge restore <id...>` | Bring deleted docs back via `restore_knowledge` (`{ knowledge_doc_ids }`), in one call: every id must be a deleted doc you manage, or nothing is restored. Refuses the no-argument form. |\n| `lotics app create <name> --custom [path]` | **A custom-code app**: creates the app (`POST /v1/apps`), scaffolds a Vite + React + TypeScript project into `[path]` (default `./<name>`, refused when not empty \u2014 before the app row exists) that depends on `@lotics/app-sdk` alone and draws with plain React, and installs it (`npm install --ignore-scripts`), then writes the declarations of the app's live bindings (`GET /v1/apps/{id}/types`) into `.lotics/`, which the project's `tsconfig.json` includes. `package.json#lotics` names the app and its workspace, which is how `app deploy` in that directory finds both. The app has no version until the first `lotics app deploy`. `--custom` is required: a JSON app is made by `lotics app apply` (the tool `set_app`). The SDK's reference is `node_modules/@lotics/app-sdk/AGENTS.md` inside the project. |\n| `lotics app pull [app_id] [path]` | **An app's live definition, pulled.** The app is read with `get_app`: its author is shown a body, a JSON **tree** (with the addresses of its nodes) pulled as that tree and a custom-code app (the identity alone) as its source; an app whose stored tree cannot be read is refused with the reason `get_app` gives, and so is an app that shows the caller no body (the caller does not author it, or it has no version). **A JSON app**: the tree is written as JSON to `[path]` (a directory receives `./<name>.app.json`; default `./<name>.app.json`), and the app's id and current version beside it in `<file>.lotics.json` (`{ \"app_id\": \"\u2026\", \"version_id\": \"\u2026\", \"digest\": \"\u2026\" }`, the digest being the file as the CLI last left it; a record without all three is refused by `app apply`, naming `lotics app pull`) \u2014 a custom-code project records both in `package.json#lotics`, a single file has no such place \u2014 so the next `lotics app apply <file>` edits that app, and is refused if the app has since moved past that version. An existing file is overwritten only when its `.lotics.json` names this same app and the file is unchanged since it was last pulled or applied; a file with edits, or whose record names the app but no digest, is refused, naming `--force`, which overwrites it. `--force` is a flag of `app pull` alone. `--json` prints `{workspace_id, app_id, path, outcome}`. **A custom-code app**: its live source, as a project ready to deploy on it. The target is `[path]`, else this directory when it is the app's project (or no app is named), else `./<name>`. **The app's own project is brought up to date in place** \u2014 but only when it holds no edit since the version `package.json#lotics.current_version_id` names: its source (packed as a deploy packs it) is compared with that version's archive, ignoring `.lotics/` and `package.json#lotics`, and any difference refuses the pull, naming the changed files; a pull never merges, so local work is never lost. Already at the live version is a no-op that says so. **An empty or new directory receives the source whole**; any other directory is refused. Downloads come from `GET /v1/apps/{id}/versions/{version_id}/source`. `package.json#lotics` is then set to exactly the app, its workspace and the pulled version, dropping every other key an older CLI wrote there, so the next `lotics app deploy` builds on the live version; then `.lotics/` is written (`GET /v1/apps/{id}/types`) and dependencies installed (`npm ci` with a lockfile, else `npm install`, both `--ignore-scripts`). |\n| `lotics app apply <file> [--app <app_id>] [--check] [--json]` | **A JSON app, one at a time, through the tool `set_app`.** The file is an app's tree (`lotics docs model`: the App node with the nodes under it, its tables and templates named by alias). **No app known** (no `--app`, no `<file>.lotics.json` beside it): the file is sent as `{ \"body\": \u2026 }` to make a new app, named by the body's `name`, and the id made is printed, then recorded with its version in `<file>.lotics.json`. **An app known**: its current tree is read (`get_app`) and the **RFC 7396 merge patch** that turns it into the file is sent as `body` with the `app_id` and the version the patch was computed against as `base_version_id` \u2014 a key the file drops is `null` in the patch, a list is replaced whole, what the file leaves alone is left alone, and a tree the app already has sends nothing and says so. **A file pulled at a version the app has since left is refused** before anything is sent, naming both versions and `lotics app pull <app_id> <path>`: pull the current tree into another path, carry the edits over, apply that file. `--app` naming an app other than the sibling file's is refused too. A write that races the apply is refused by the server, which names the app's current version. A file that sets an object key to `null` is refused naming the key, since a merge patch reads `null` as a removal. `--app` names the app, else the sibling file does; an app whose `get_app` shows no body (it is not yours) or whose stored tree cannot be read is refused, and a custom-code app takes its identity (name, description, icon, colour) only, and no `<file>.lotics.json` records it, since an identity write makes no version \u2014 its source deploys with `app deploy`. **The file is never rewritten**: a node the tree leaves out draws as the rows call for it and is stored nowhere (`lotics docs design`). It prints what `set_app` answered and reports its `outcome`: its notes and findings, every finding at once, and the saves, adds and Deletes the write grants and removes. **`--check` sends `dry_run`** (a flag of `app apply` alone, as is `--app`) \u2014 what would refuse the tree, and what it would grant and remove \u2014 and writes nothing, the file and its sibling included; an `outcome` of `refused` exits 1. A tree the app already has, or one `set_app` answers `unchanged`, records the version the app stays at in the sibling. **To go back to an earlier version, apply the earlier tree again.** Resolves and ANNOUNCES its workspace first. `--json` prints `{workspace_id, app_id, outcome, write}`, `outcome` being `set_app`'s: `created`, `updated`, `unchanged` or `refused` (with `--check`, what it would be). |\n| `lotics app deploy [-m <message>]` | **Build this directory and upload it as a new version of the live app.** Rewrites `.lotics/` with the declarations of the app's live bindings (`GET /v1/apps/{id}/types`, replacing each file there), then runs the project's `npm run typecheck` (warned about when absent; a type error in the app's own files stops the deploy, one inside an installed package is reported and does not) and `npm run build`, tars the source (without `node_modules`, `dist`, `.git`, `*.tsbuildinfo`) and `dist/`, and posts both to `POST /v1/apps/{id}/versions` on the version `package.json#lotics.current_version_id` names, then stamps the new one there. The version carries the app's queries, workflows, agents and capabilities forward unchanged \u2014 those are written through their tools. A project with no `build` script, or whose `package.json#lotics` still declares `queries`, `workflows`, `agents` or `capabilities`, is refused before anything is built; the refusal names the tool that sets each. **A 409 because another version went live since this directory's last deploy** (a deploy from elsewhere, a rollback) prints the server's sentence and the version that is live, and names `lotics app pull`: run in this directory, it brings an unedited project up to date, and lists the files a project with edits changed, to carry over into a fresh pull. `-m` (or a bare positional) is the version's message, optional. The workspace comes from `package.json#lotics.workspace_id` unless `--workspace` / `LOTICS_WORKSPACE` names another. |\n| `lotics docs` \\| `lotics docs <area>[/<section>]` \\| `lotics docs [<area>[/<section>]] --grep <text>` | **This CLI's own references, carried inside the binary** \u2014 the model reference, this index, and every doc under `docs/` \u2014 so the doc a reader opens describes the binary answering, listed by the job a reader comes to do. Capped at ONE PAGE: a doc that does not fit prints its opening and the addresses of what it holds (`lotics docs <area>/<section>`, each section's size beside it, or a table's row names), and every address prints within a page. **A reference the binary's copy lacks, or a part of one the server serves, is read from the server's `docs` tool** with this machine's credential, after the copy's refusal \u2014 a page the server added since this binary was built, which a server refusal can cite. A name matching more than one reference, or a part missing from a guide to this CLI, is answered by the copy alone. **`--grep` searches the references the server serves** (not this CLI's own guides, which it refuses), with this machine's credential, narrowed to the area or section named: literal text unless `--regex`; a literal search that finds nothing while the pattern read as an expression matches (`a|b`) returns those lines under a note saying so, and `--literal` keeps the literal zero; with `--case-sensitive`, `--diacritic-insensitive`, `--context-lines <n>` and `--limit <n>` \u2014 the dialect the knowledge lines of `get_workspace` with `search` are read in \u2014 each hit under the address that opens its page; any of those options without `--grep` is refused. A custom-code app's SDK reference ships inside `@lotics/app-sdk` in the app's `node_modules`. |\n| `lotics upgrade` | Update this CLI in place. Runs the same installer a person would, chosen by how THIS copy arrived: an npm install upgrades through npm, a script install re-runs the script \u2014 the runtime knows which (the executable is compiled, the npm bin runs under node), so nobody has to. It downloads nothing itself; resolving a version, verifying the checksum and replacing a running executable already exist in the installers, and a second copy of that inside the binary would be a second thing to get right. Replacing the binary while it runs is safe \u2014 a rename leaves the running image mapped on unix, and on Windows the installer moves the old aside precisely because the file is in use. Already current is a no-op that says so. Needs no auth. |\n| `lotics docs model` \\| `lotics docs model/<section>[/\u2026]` | **The reference for tables and apps, from inside the binary** \u2014 at this CLI's version; `lotics docs` lists it under \"Build an app\", after `design`. Its first page is what the documents compose with, the working order (jobs \u2192 tables and fields \u2192 `records` \u2192 one app per job \u2192 one `set_tables` call with every table, then every `set_app` call) and the section addresses; every page of it is whole. Every key of a table document and an app body, every field `type` with the config each one needs, the option / view / inline-template shapes, `records` (how a row of a table is recognised), `write_rules`, the apps (each register, record, act and check), the row format (relative dates `@today` / `@month-start` with whole-day offsets; links as `\"<table-ref>:<row-ref>\"`), the rules, the caps of one `set_tables` call, and a worked example; it points at `https://lotics.ai/presets/index.json` for example files of several trades, which an agent reads and passes to `set_tables`. **Offline, no account.** |\n| `lotics report '<json>'` \\| `lotics report @report.json` | File a report with the Lotics team about what got in your way. **Covers the classes telemetry structurally cannot see**: a capability that does not exist (no command ran, so nothing was recorded), a command that exited 0 having done the wrong thing, an error whose message did not name the remedy, and anything that made authoring slower than it should be. **A frame, not a paragraph** \u2014 `{goal, actual, expected?, tried?, wanted?}`, `goal` and `actual` required, unknown keys dropped rather than refused. **No severity or category.** Ingest is inline JSON, `@file`, or `-` for stdin. A bare sentence is refused with the frame printed beside it, so the fix is one step; a bare invocation prints the frame BEFORE asking for a credential, since someone whose key will not resolve is exactly who has something to report. **Not spooled**: unlike telemetry it posts inline, prints whether it landed, and exits non-zero if it did not, echoing the report back so a failed send never loses it. Runs regardless of `LOTICS_TELEMETRY` \u2014 invoking it IS the consent that passive collection needs an opt-in for \u2014 but with telemetry off there are no recorded commands to attach, and it says so rather than implying context it does not have. Requires auth. Never paste records, file contents, or credentials. **Prints the id of each frame filed** \u2014 a filing nobody can cite cannot be answered about. The ids come from the server, so an instance that only logs the frames prints the count alone; the CLI never mints one of its own, which would hand back a token that resolves to nothing. |\n\n";
|
|
1780
1781
|
|
|
1781
1782
|
// docs/data_model.md
|
|
1782
1783
|
var data_model_default = '# The data model \u2014 tables, their fields, and how they relate\n\nThe decisions here outlive any one app, and most become expensive the moment a second screen depends\non them. They stand apart from building an app on purpose: **every workspace starts with tables\nand many never get an app**, so schema design is not a chapter of app building.\n\nThe rules below share one signature. **Both sides read correctly on their own**, so nothing reports\nthe problem \u2014 no error, no empty column, no failing query. Each is found by looking for it. Then,\nunder **Fields**, what each field type takes.\n\n## One fact, one column\n\n**Read the table\'s existing fields first (`get_table`), and add nothing that stores a fact the table already\nstores.** A value written as text and the same value held as a `link` are one fact in\ntwo columns \u2014 a customer\'s city typed into a text box beside a link to the city record, a status\nword beside the select that decides it, a total beside the formula that computes it.\n\nTwo columns for one fact do not stay equal. Some writer sets only one of them, and nothing reports\nthe divergence: both rows still look correct on their own. A reader that then matches on the text\nhalf treats "Acme" and "Acme Ltd" as different records, so an import creates a duplicate every time\nit runs.\n\n- **Prefer the link, the select, or the formula.** Text is a RENDERING of a record; compose it when\n you read, rather than storing it a second time.\n- **Renaming, retyping or re-pointing the existing field beats adding another.** A field\'s key is\n stable, so a rename breaks nothing that addresses it by key.\n- **Superseding a field means DELETING it**, not leaving it beside its replacement with a\n description that says which one is real.\n- **Empty is not the same as redundant.** A field nothing fills may still be the only home for a\n real distinction \u2014 read what it MEANS before removing it.\n- **A stored field becomes required only once every row holds a value**, so it always holds one: fill the\n empty cells first, then set it. A computed field\'s flag is checked as each row is written.\n\n## One entity, one table \u2014 and the test is measurable\n\nVariation belongs in a column \u2014 a multi-select role, a kind, a stage \u2014 not in a second table. Two\ntables for one kind of thing give the same real-world entity two rows, two ids and two halves of its\nhistory, and each screen shows whichever half it happens to link to.\n\nSplit tables are often right. A supplier book beside a customer book is a normal shape, and merging\non suspicion is a large repoint bought for nothing. So do not argue it in the abstract:\n\n> **List both tables\' names and look for one that appears in both.**\n\nNone means the split is holding. One means it has broken \u2014 the usual cause is a party you begin to\ninvoice as well as buy from \u2014 and the fix is to merge before a second screen depends on the copy.\n\n## One vocabulary wherever values are COPIED between tables\n\nTwo `select` fields for one concept carry different option keys even when their labels match. Copy an option by its\nalias or label: the record tools match an option by alias or label, so a value copied by alias lands\non the option of that alias on the other field. Prefer a link to a shared reference table where the set is open or\ngrowing. Where one side holds more values than the other, the wider side must **refuse** what the narrower one cannot\nexpress rather than quietly picking the nearest value.\n\nA value read through a link or a lookup reads exactly as the same field would on its own row: an option written by its alias in\na body and read as its key, a member or a link by its id. A value a check matches\nacross tables is a link to one table: two selects never compare, and a save that compares them is refused.\n\nA lookup reads as one flat list of the values it looks up, in a check and in every body: `first(record.x)` is the one value through a one-row link and `includes(record.x, v)` asks whether any row holds `v`; two selects never compare, so a value matched across tables is a link to one table. A single member, a single select, a number or a text reads as the value itself, and `first(...)` of one is refused when the body is saved. A rollup that adds up (a sum, a count) over no rows is 0, never empty, as `sum` over an empty list is; a min, a max or an average over no rows is empty.\n\n## A copy boundary accounts for EVERY source field\n\nEach field on the source gets a column on the destination, a deliberate drop with the reason written\ndown, or a refusal.\n\nA field with nowhere to land is data destroyed at the boundary, and it is invisible afterwards: the\ndestination is not empty and not obviously wrong \u2014 just a number that no longer agrees with where it\ncame from.\n\n## Provenance is a LINK, not a flag and not a copy\n\nA row created BY another row carries a link to it.\n\nThat link is what makes "is this the estimate or the actual", "where did this come from" and "have we\nalready imported this" answerable at all. A boolean records that something was true once; a link\nstays true, survives a rename, and lets the next write UPDATE the original instead of adding a second\nrow beside it.\n\n## Say what makes two rows the SAME row\n\nState it as the table\'s `natural_key` (`model/write-rules`); a text key is also `unique`.\n\nAnything that imports, reconciles or de-duplicates has to decide identity, and with no declared key it falls back to\ncomparing displayed text \u2014 which is how one company arrives three times under three spellings. Name the key: a reference\nnumber, a tax id, a link plus a period. Then match on record ids and option aliases, never on rendered labels.\n\n## A stage\'s moment is a date on the row\n\nWho changed what, and when, is the platform\'s audit trail, which an agent already reads (`query_audit_logs`) under\nits own access to the table \u2014 so it is never a table of yours, and an app shows none of it: a history an app shows is\ntable-backed (`records.status.dates`, `records.status.history`, an event table an act makes).\n\nWhen the business reports, filters or prints the moment a row reached a stage (completed on, cancelled on), that\nmoment is a `date` field of the row, as `completed_at` and `canceled_at` are in SQL. State it as `records.status.dates`\n(`model/status`): option \u2192 date. The record write path stamps it as a row is made at the option or moved into it,\nwhoever writes, so no one has to remember; a value the same write states wins, which loads past data as it happened. The\nplatform never clears a stamp: each date is when the row last entered the option, and leaving keeps it, and putting a\nchange back restamps nothing. A date range is a span, never a moment, so it is refused. It is the one way a status is\ntimed: a stage\'s `within` runs from its date, a straight-line process is one select whose options are its steps, each\ndated, and the stage is never read off a run of dates.\n\n`records.status.history` is for a business that reports on every move \u2014 moves per person, or time in a stage a row\nre-enters \u2014 and the platform appends one row per move. An event that carries facts of its own (a reason, an amount, a\nsignature, an inspection\'s findings) is its own table with those fields, made by the act that records it.\n\n## Keep derived chains shallow\n\nFormulas and rollups are computed and STORED when a row is written, and one that reads another recomputes with it. A\nrollup over a formula over a formula is paid three times on every touch, and again for every row upstream of it.\n**Depth costs more than row count**: chain depth is yours to keep down, row scanning is the platform\'s.\n\n## Fields\n\nA field is stated inside its table\'s document in a `set_tables` call, keyed by alias. Every field `type`, the\nkeys each one takes (notation, defaults, options, links, formulas, rollups, lookups, copies) and a worked\nexample are in `docs path="model/field"`; this page holds the choices around them.\n\n### Types and formats\n\n`type` is one of `text`, `number`, `date`, `boolean`, `select`, `member`,\n`link`, `files`, `formula`, `rollup`, `lookup`, `autonumber`.\n\nA URL, an email, markdown, a checkbox, a datetime, money, a quantity or a percentage is a `format`\nor a `notation` on another type, never a `type`:\n\n| Wanted | Field |\n|---|---|\n| URL or external link | `{ type: "text", format: "link" }` |\n| Email or phone | `{ type: "text" }` |\n| Markdown | `{ type: "text", format: "markdown" }` |\n| Checkbox | `{ type: "boolean" }` |\n| Money | `{ type: "number", notation: { style: "currency", currency: "USD" } }` |\n| Quantity | `{ type: "number", notation: { style: "unit", unit: "kg" } }` |\n| Percentage | `{ type: "number", notation: { style: "unit", unit: "percent" } }` |\n| Datetime | `{ type: "date", format: "datetime" }` |\n| Date range | `{ type: "date", format: "date_range" }` |\n\n### Changing a field\n\nRead the table with `get_table`, which answers its document exactly as `set_tables` takes it, then send `set_tables` with\nthe `table_id` and only what changes. The table is edited by an RFC 7396 merge patch of its document, so what the patch\nleaves alone is left alone and `null` deletes the field it names. Every key a table and a field take is in\n`docs path="model/tables"` and `model/field`.\n\nA table is named by its alias wherever a call names one (`table_id`, a link\'s `table`, a lookup, `records.history`), and a new table in the call is named by its `ref`, which becomes its alias. The alias is kept while the table exists, archived or not: asking for one an archived table holds is refused, offering to restore that table or to choose another alias. A filter in a table document (a rollup\'s `filter`, `options_where`, a colour\'s condition, a check\'s lookup) takes the record tools\' operators and values (`is`, `is_any_of`, `gte`, a plain date as a day). An `autonumber` field takes `next` (`"next": 232`): the number its next row gets, refused below the highest already issued.\n\n- **A list is replaced whole**, so an option cannot be nulled. To rename, recolour, add or remove an option, restate the\n field\'s whole `options` list, each option with its alias (`model/field/select`). A rename changes the label and every\n record keeps its value; an alias left out is removed, which needs `force` while rows hold it.\n- **A number field becomes an `autonumber`** when every row holds its own whole number of 1 or more: the numbers are kept\n and the next row takes the one after the highest (a `next` must be above it). Otherwise the call is refused, saying how\n many rows are blank, repeated or not whole, with up to 5 row ids. No other type converts to or from an autonumber; add\n one as a new field, which numbers the rows already there from 1 (or from `next`) in creation order.\n- **Splitting an option is a rename plus an add.** The old option keeps its alias and takes the first part\'s label, so its\n rows and every app reference to it keep working; the second part is a new option. Rows that belong to the second part\n are then moved by a filtered `update_records`.\n- **A status history holds every option its status ever had** and follows them in the same write: an option added or renamed\n on the status is added or renamed on the history, which a call never restates. Removing an option on the status leaves it\n on the history, because the moves already written hold it. A call that changes nothing of the status\'s options leaves the\n history alone, and options only the history holds stay. Deleting a row deletes its history rows with it, and restoring\n the row restores them; they never hold a delete back.\n- A link names its table by id, or by the `ref` of another table of the same call, and states its `multi`. A two-way link\n states `pair` on one table, as `true` or as a free alias, and the platform makes the partner on the other table with the\n inverse cardinality; a many-to-many link states both sides, since the inverse of many is not decided.\n A link already holding rows is paired the same way, with its partner stated beside it in the call: the partner is filled from\n the links the rows hold and no cell of the link is rewritten. `pair: null` lets a pair go, both fields staying one-way links\n with their values; a field that holds one row, the partner or the link itself, refuses a pair two rows would crowd, naming the rows.\n The partner may stand on a table the call does not name: `pair: "<alias>"` makes it there, or pairs the one-way link that table\n already holds under that alias, and the answer lists that table among the call\'s. Only someone who manages that table may.\n- A formula\'s `output_type` is inferred from its expression; state it only to override.\n- **A call\'s removals are judged together, on what the call leaves.** A table and the links and lookups naming it, removed in one call, pass; every reader left outside the call is named in one answer, not one per retry. An app reading a field or an option that leaves is rebuilt in the same call, and the answer (a dry run too) lists each node of its tree that changed: a column, a filter, a sort key or a field of a part that only drew the field is dropped, and an option leaves every list of options it stood in. What has no meaning without it refuses the call, naming the node: an act that sets a removed option, a part left with nothing to draw, a list that held only the removed option. An app drawing a field that is retyped, or a link pointed at another table, is rebuilt too, its tree unchanged: where the field no longer lets the tree draw what it drew, the call refuses, naming the node, and a field that became a formula, rollup, lookup or autonumber leaves the app\'s allowlist of saves. A workflow body its author wrote is never rewritten: it is checked again against the tables as the call leaves them, and the call refuses, naming the workflow, only if it no longer checks. A table\'s check or workflow that reads a retyped field or a link pointed elsewhere is judged the same way and refuses, naming it, only if it no longer holds. Deleting one field through the table\'s field route drops no node: an app whose tree draws the field refuses it, naming the node.\n- **A field something reads cannot go.** A query, a workflow, a check, an app only its owner can rebuild, or another\n table\'s formula, rollup, lookup or copy that reads the field refuses its deletion, naming the reader. Change or remove\n the reader first, or in the same call.\n- **`force` consents to losing values:** deleting a field rows hold, removing an option rows hold, and a change of type\n that does not convert every value.\n- A money formula is proven by its numbers. `aggregate_records` the affected totals before the change and after, and\n compare. A new field in a formula is tested for the value you want, never for differing from an empty cell, which reads\n `""` and differs from every option.\n- A check\'s expressions are written in the language of `workflows/table-checks`.\n- `views` are stated when the table is created; afterwards they change in chat.\n- A refused edit writes nothing and lists everything wrong; `dry_run` is for reading the grants it would make or deciding `force`.\n\nA worked call: add the option `cancelled` to `stage` (the whole list restated), add a check, and consent to losing\nwhat rows hold.\n\n```json5\n{\n "tables": [\n {\n "table_id": "<id of the table>",\n "fields": {\n "stage": {\n "options": [\n { "alias": "draft", "label": "Draft" },\n { "alias": "confirmed", "label": "Confirmed" },\n { "alias": "cancelled", "label": "Cancelled", "color": "red" }\n ]\n }\n },\n "checks": {\n "returns_after_leaves": {\n "on": ["create", "update"],\n "when": "!isNull(record.returns)",\n "fail_when": "record.returns < record.leaves",\n "message": "\\"Return before departure\\"",\n "field": "returns"\n }\n }\n }\n ],\n "force": true,\n "dry_run": true\n}\n```\n\nSplitting `queued` into waiting and underway is one call of the same shape:\n\n```json5\n{ "tables": [{ "table_id": "<id of the table>", "fields": { "stage": { "options": [\n { "alias": "draft", "label": "Draft" },\n { "alias": "queued", "label": "Waiting" },\n { "alias": "underway", "label": "Underway", "color": "green" }\n] } } }] }\n```\n\n`table_id` picks the table patched; `fields` merges by alias, so only `stage` changes; `checks` adds one under its alias\n(`null` removes it); `force` consents to losing values (leave it out unless rows hold a removed option); `dry_run`\nchecks and writes nothing, for reading the grants or deciding `force` before the call that writes.\n';
|
|
@@ -1822,7 +1823,7 @@ var JOBS = [
|
|
|
1822
1823
|
{ area: "design", file: "docs/design.md", covers: "the treatment drawing each thing a job reads, the visual bar, looking at what a write built", readers: ["cli", "tool"] },
|
|
1823
1824
|
{ area: "model", file: "src/model_reference.md", covers: "every key of a table document and an app body: tables, fields, write rules, records, apps", readers: ["cli", "tool"] },
|
|
1824
1825
|
{ area: "examples", file: "docs/examples.md", covers: "each treatment worked through in a complete set of tables and apps", readers: ["cli", "tool"] },
|
|
1825
|
-
{ area: "agent_surface", file: "docs/agent_surface.md", covers: "the queries and workflows every app generates, how an agent calls them, and declaring more", readers: ["cli", "tool"] }
|
|
1826
|
+
{ area: "agent_surface", file: "docs/agent_surface.md", covers: "the queries and workflows every app generates, how an agent calls them, the file an app's Export makes, and declaring more", readers: ["cli", "tool"] }
|
|
1826
1827
|
]
|
|
1827
1828
|
},
|
|
1828
1829
|
{
|
package/docs/agent_surface.md
CHANGED
|
@@ -138,6 +138,39 @@ number condition names the unit its value is in as `unit_option`, an option of t
|
|
|
138
138
|
or key. Each tab of a register is a query of its own, taking the list's params and the period its rows are
|
|
139
139
|
read over.
|
|
140
140
|
|
|
141
|
+
## Exporting a file
|
|
142
|
+
|
|
143
|
+
```
|
|
144
|
+
run_app_query {"app_id":"app_…","export":{"key":"by_carrier","per":"…","tab":"in_yard","status":["…"]}}
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
`export` makes the file the register's Export button makes, from what the caller reads, and answers it under `files` as a workspace
|
|
148
|
+
file like any other a tool makes: `file_id`, `url`, `filename`, `mime_type`. `key` is the
|
|
149
|
+
key of an Export under the register List in `get_app`'s tree; it is sent without `alias` or any other argument of a
|
|
150
|
+
read. An option is named by its name or its id, as a read's filters name it; a member by its id or its name, for a member
|
|
151
|
+
of the app's organization; a row, by its id. The other keys
|
|
152
|
+
narrow the file as the register's own controls do, each absent one leaving the register's opening view:
|
|
153
|
+
|
|
154
|
+
| Key | Takes |
|
|
155
|
+
|---|---|
|
|
156
|
+
| `per` | The option, row id or member id the report is made for — required by an Export that states `per`, refused by one that does not; a member, here and in a member chip, is an id or a name, found as a filter on a member column finds one |
|
|
157
|
+
| `tab`, `period`, `window` | A Tab's key (the first when absent); `{ "from", "to" }` days `yyyy-MM-dd` (`yyyy-MM-ddTHH:mm` on a date-and-time field) the tabs read over; a calendar's or board's days |
|
|
158
|
+
| `status` | Options of the register's status that are kept |
|
|
159
|
+
| `filters` | `{ "<field>": <value> }` by the field's alias, a value `null` clearing the register's opening chip; a value is one of the shapes below |
|
|
160
|
+
| `search`, `overdue`, `waiting` | Words searched, one line each of a pasted list; `true`: only the rows past a deadline; `true`: only the rows waiting on the caller |
|
|
161
|
+
| `sort`, `group`, `folder` | `{ "field", "dir" }`; a field alias; a folder id |
|
|
162
|
+
|
|
163
|
+
| A `filters` value | For a field that is |
|
|
164
|
+
|---|---|
|
|
165
|
+
| `{ "kind": "ids", "ids": [...], "unset"?: true }` | a select (options) or a link to rows (row ids); `unset` also keeps rows holding none |
|
|
166
|
+
| `{ "kind": "members", "mine": false, "ids": [...], "unset"?: true }` | a member; `mine` is the caller |
|
|
167
|
+
| `{ "kind": "dates", "from": "yyyy-MM-dd" \| null, "to": … \| null, "unset"?: true }` | a date; `null` leaves an end open; `unset` also keeps rows holding none |
|
|
168
|
+
| `{ "kind": "yes_no", "value": true }` | a yes/no |
|
|
169
|
+
| `{ "kind": "number", "min": 1 \| null, "max": … \| null, "unit"?: "…" }` | a number; `unit` the option of a unit or currency field |
|
|
170
|
+
| `{ "kind": "bands", "bands": [{ "min", "max" }] }` | a number the register cuts into tiers |
|
|
171
|
+
|
|
172
|
+
A key the register lacks, a tab it does not hold or a value its field cannot take is refused, naming it.
|
|
173
|
+
|
|
141
174
|
## Calling a workflow
|
|
142
175
|
|
|
143
176
|
```
|
package/docs/cli_reference.md
CHANGED
|
@@ -29,7 +29,7 @@ Per-command syntax, flags, contracts, and gotchas for the public `lotics` CLI. S
|
|
|
29
29
|
| `lotics run <tool> --json '<json>'` | Execute a tool (full JSON output) |
|
|
30
30
|
| `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. |
|
|
31
31
|
| — | **Every tool is invoked here, including the ones that RUN something** (`run_app_workflow`, `run_app_query`) and every one that builds: `set_tables` (a list of table documents), `set_app` (one app), `set_app_queries`, `set_app_workflow`. A command exists only for work that touches a local file: `app apply`, `app create --custom`, `app pull`, `app deploy`. |
|
|
32
|
-
| — | **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 `dry_run: true` — a rehearsal on the real inputs, nothing written or sent; the answer is what the run would return, with the reads it made under `dry_run.reads`, the calls it would make under `dry_run.writes` and the papers it rendered under `dry_run.papers` and the parts of the answer it could not say under `dry_run.unpredicted`; a workflow no dry run 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
|
+
| — | **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. `export: {"key", …narrowing}` instead of the read arguments makes the file an app's Export makes (`lotics docs agent_surface`). `run_app_workflow '{"app_id","alias","inputs"}'` also takes `dry_run: true` — a rehearsal on the real inputs, nothing written or sent; the answer is what the run would return, with the reads it made under `dry_run.reads`, the calls it would make under `dry_run.writes` and the papers it rendered under `dry_run.papers` and the parts of the answer it could not say under `dry_run.unpredicted`; a workflow no dry run 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. |
|
|
33
33
|
| — | **The exit code reports the WORK, not just the call — for the tool that RUNS one.** `run_app_workflow` whose envelope carries a failed `status` (`error`/`failed`/`cancelled`, or `not_previewed` — a dry run 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. |
|
|
34
34
|
| `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`. |
|
|
35
35
|
| `lotics run set_member '{"members": {"<email or member id>": {"role": "member", "name": "…"}}}'` | Invite, rename, remove (`null`) or restore members, admin only: the whole map is checked before an email is sent. A member's `lotics tools` does not list it, nor `set_group` or `set_workspace`. View-as is the web app's preview alone; the CLI acts as the signed-in member. |
|