@lotics/cli 0.315.16 → 0.315.17

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/README.md CHANGED
@@ -293,7 +293,9 @@ lotics knowledge create --name "Shipping tariffs" \
293
293
  --from ./tariffs.md # or --content '<inline>'; prints the new id
294
294
  lotics knowledge get kdc_... -o ./tariffs.md # body → file (omit -o for stdout; --json = full doc)
295
295
  lotics knowledge update kdc_... --from ./tariffs.md # send only what changed (--name / --description too)
296
- lotics knowledge rm kdc_... # archive
296
+ lotics knowledge rm kdc_... [kdc_...] # delete; sharing and history are kept
297
+ lotics knowledge list --deleted # deleted docs you can restore
298
+ lotics knowledge restore kdc_... [kdc_...] # bring them back, all or none
297
299
  ```
298
300
 
299
301
  `create` / `update` send the body inline; the server mints and version-chains the content file (and `update` diffs + resolves concurrency internally — no token to pass). `get` is the one content-read path, hydrating the body server-side.
package/dist/src/cli.js CHANGED
@@ -46877,7 +46877,7 @@ async function saveSignedLinks(links, dir) {
46877
46877
  }
46878
46878
 
46879
46879
  // src/version.ts
46880
- var VERSION = "0.315.16";
46880
+ var VERSION = "0.315.17";
46881
46881
  var APP_SDK_VERSION = "0.123.10";
46882
46882
 
46883
46883
  // src/timezone.ts
@@ -46912,7 +46912,7 @@ var AGENTS_default = "# @lotics/cli \u2014 agent index\n\nThe model an agent nee
46912
46912
  var building_an_app_default = '# Building an app, end to end\n\nThe other references here describe **contracts** \u2014 what a model 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 model, deploying a\nbuild, setting one query or workflow \u2014 mints a new version of it, and rolling back to an earlier\nversion is the undo. Nothing about an app lives in a local directory the platform reads back.\n\n**Rolling back restores the app, not the data.** A table change the model made, and every row a\nworkflow wrote while you 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 in a model** \u2014 the default, and the right one for almost every job. `model.json`\n says how a row of each entity is recognised (`records`) and, in `apps`, one working register over\n one entity and the record its rows open, with the logs that stand beside it (`logs`, read by the organization\'s own members alone): its columns and filters, its sections in the order its work\n reaches them, its acts and the checks that guard them (`lotics docs model/register`,\n `model/record-page`, `model/acts`, `model/checks`). 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 key changes it. Every write the app makes is a\n generated workflow that re-checks on the server what the model states.\n- **A custom-code app** \u2014 React you write, for a surface the model\'s vocabulary 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 modelling 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 entity 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 entities 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 entity 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**One fact, one column \u2014 and check before you add one.** Read the table\'s existing fields before\nadding any, because the fact is often already there in another shape: a place written as text\nbeside a link to the place record, a status word beside the select that decides it, a total beside\nthe formula that computes it. Two columns for one fact never stay equal. Prefer the link, the\nselect, or the formula, and compose the text when you READ.\n\n**Match on ids and option keys, never on rendered text.** Resolve a name to its `rec_\u2026` or `opt_\u2026`\nonce, at the boundary, and compare those. Treat an unresolved name as UNKNOWN, never as a wildcard.\n\n**How the tables RELATE is `lotics docs data_model`** \u2014 read it before designing a schema; those\ndecisions outlive any one app.\n\n**Empty is not the same as redundant.** A field nothing fills may still be the only home for a real\ndistinction. Read what a field MEANS before you remove it.\n\n## 4 \u2014 An app stated in a model\n\n```\nlotics docs model # how to write model.json, with a worked example\nlotics docs design # how to design each app: the method, a treatment per kind of row\nlotics model apply model.json # check it, apply the tables, mint a version of every app\nlotics model apply model.json --app orders # only the apps named; the tables are applied whole\nlotics model check model.json # what the apply would change, writing nothing\nlotics model pull -o model.json # the workspace\'s model, as the file apply reads\n```\n\n`model apply` checks the whole file first \u2014 every problem in one run, before anything is uploaded\nor written. It then adopts or creates each table (the table this workspace bound it to, else one of the same\nlabel, is adopted and given what it lacks; no stored value changes), writes the first rows only where every bound\ntable is empty, and mints one version per app, printing each app\'s id, the version minted\n(`unchanged` when there was nothing to mint) and its address. Applying the same file again mints nothing.\nA table change is not undone by rolling an app back, so `lotics model check` first says what the apply would\ncreate or change in the tables and which apps it would create, update or refuse \u2014 writing nothing.\n\n**An app leaving out a treatment its rows call for is refused** \u2014 readings over its rows, a record\'s\nsections, an act per status move, a picture where its rows hold photos (`lotics docs design`). Each\nrefusal names the rule, and beside them comes each refused app\'s patch adopting its decisions: merge\nthe patches into the file in the order given, or state why the app stays as it is under the\n`declines` key the refusal names (`lotics docs model/declines`), then apply again. `lotics model check` reads the\ndecisions beside the workspace\'s rows, and with `--json` gives each app as the patches leave it, its\n`draft`.\n\n**The model changes after the app exists, and applying it again is how it lands.** Edit the file,\napply it. An act whose write the model cannot say names its own `workflow`; that workflow\'s body 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 the app declares in `workflows`\n(`lotics docs model/workflows`): the model states what it takes and hands back, `lotics run\nset_app_workflow` sets its body with `inputs` and `outputs` left out, and every apply keeps that\nbody. A workflow set on the app that its model declares nowhere is archived by the next apply; a query set on it that its model declares nowhere (`queries`) is removed by the next apply, which says so in its plan and its answer. An act that reads papers runs an agent\napply owns: made, replaced and removed with the act, and an agent of that alias apply did not make\nis refused, never rewritten.\n\n**What a person reads and does in the app, an agent reads and does through it.** The apply generates a\nquery for every list, record, child block and figure the screen reads, and a workflow for every add, edit,\nremoval and act \u2014 `lotics run get_app_capabilities` lists them, and `lotics docs agent_surface` names each\nfamily and how it is called. A question the screen never asks is a query the app declares in `queries`\n(`lotics docs model/queries`), beside its `workflows`. Both are part of the design: step 5 of\n`lotics docs design`.\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": "fld_\u2026", "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 dry_run_workflow \'{"trigger_type":"app_workflow","trigger_payload":{\u2026},"live_reads":true}\'\n```\n\nIt walks the real step tree and returns the resolved plan plus expression and tool-input errors,\ndispatching no write. **`live_reads: true` matters whenever the body reads anything**: without it\nevery read returns a stub, and every data-gated branch takes the empty path.\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 run rollback_app` away from the one before.\n\nFor an app a model applied, `verdict` comes beside the images: what a check of the workspace\'s\nmodel finds of that app now, beside its rows \u2014 what the next apply would refuse it for and the notes\non it, the `patch` adopting the decisions among them, and the app\'s body as the patch\nleaves it (`draft`). `verdict_unread` says why none was read.\n\nThe app\'s `name`, `icon`, `theme` and `description` are keys of the app in the model, and each apply\nwrites them onto the app; an `icon` or `description` the model leaves out is cleared, and an app\nstating no `theme` is refused (`app.theme`). `lotics run update_app` changes them on\nthe app, and `lotics model pull` reads them back as the app holds them. 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';
46913
46913
 
46914
46914
  // docs/cli_reference.md
46915
- 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 model --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| `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_agent`, `run_app_query`) and every one that changes an app (`set_app_queries`, `set_app_workflow`, `set_app_agent`, `update_app`, `rollback_app`). A command exists only for work that touches a local file: `model apply`, `model check`, `model pull`, `app create --custom`, `app pull`, `app deploy`. |\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 `preview: true` \u2014 nothing is written; the answer is what the run would return, with the calls it would make under `preview.writes` and the parts of the answer it could not say under `preview.unpredicted`; a workflow no preview is offered of answers `status: \"not_previewed\"` \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 two tools that RUN one.** `run_app_workflow` and `run_app_agent` whose envelope carries a failed `status` (`error`/`failed`/`cancelled`, or `not_previewed` \u2014 a preview 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 <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_key> [-o <dir>]` 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]` | `GET /v1/knowledge_docs` \u2014 a table of id, name, tags, description (`--json` for the docs). **REST, not the `list_knowledge` tool**: 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)`. |\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 `create_knowledge` with `{ name, description, content, tags? }` (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 content-read path for a non-sandbox client). `-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 `update_knowledge` with **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, `list_knowledge`, and the corpus `grep_knowledge` 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>` | Archive the doc via `delete_knowledge` (`{ knowledge_doc_id }`). The REST execute path does not gate `needsApproval`, so this runs unattended. |\n| `lotics setup <model.json> [--email <addr>] [--json]` | **The whole first run, in one command.** Creates an account when this machine has no credential (the same call `auth signup` makes \u2014 `--name` and `--timezone` apply), then applies the model to its workspace, then prints the one-time sign-in link. **When that email already has an account it hands over to the `lotics auth login` flow** \u2014 it prints the sign-in page to open and the code it must show, and **exits 1 having created nothing**; the person presses Confirm and runs the same command again, which collects the key and carries on into the model. (`--wait` holds the terminal through the Confirm instead, finishing in one command.) The re-run is not refused for naming an `--email` it is now signed in as \u2014 that address IS the account it holds, not a second one. The file is a workspace MODEL, and it is read and checked before an account is created \u2014 the design decisions an apply would refuse it for too (`lotics docs design`), since a new workspace holds no rows to change them \u2014 because a file with a typo in it must not leave an organization behind. Then it is `lotics model apply` run on the new workspace: its tables, rows and apps; the sign-in link lands on its app when it has one, else on the workspace's app list. It exists because the two-command form has a seam where the FIRST command exists only to produce a credential for the second, and a caller pasting a prompt has to get both right. **`--email` is only for creating an account**: with a credential already resolvable it is REFUSED rather than obeyed, because the two can name different organizations and preferring either one silently writes into an org the caller did not name \u2014 the message says how to do each thing on purpose. Without it, `setup` applies the model to the account you already have. A path positional after the file is accepted and IGNORED with a warning \u2014 `setup` writes nothing to disk \u2014 so a prompt that passes one still runs. **`--json` prints one object on stdout and nothing else** \u2014 what `lotics model apply --json` does (`tables`; `apps`, each with `alias`, `app_id`, `version_id`, `origin`, `address` and `findings`; and `findings`) plus `organization_id`, `workspace_id` and `signin_url`, and a `warnings` array carrying everything the prose form would have said out of band, such as a sign-in link that could not be minted. A warning is never merely silenced: when the command fails with an error before it can emit, the ones it had collected go to stderr alongside it. Reachable with no install: `npx -y @lotics/cli setup \u2026`. |\n| `lotics model apply <model.json> [--app <alias> ...] [--json]` | **The model, applied to this workspace, through the `apply_model` tool.** The file is read and checked against the model's own rules with the validator the server runs, every problem in one run, before anything is uploaded. **The apply refuses an app leaving out a treatment its rows call for** (`lotics docs design`) \u2014 judged by the server beside the workspace's rows, before it writes anything \u2014 until the app adopts it or states why not under the `declines` key the refusal names; the refusal carries each refused app's patch adopting its decisions, to merge in the order printed. Where the model's own rules pass, one refusal carries everything decidable before the first write \u2014 those decisions, what compiling each app finds, a field that cannot leave its table and what the caller may not change \u2014 so one pass fixes them. Documents a row attaches by a path beside the file are uploaded first and the rows sent with their `fil_` ids; a path this workspace already recorded keeps its id, so a re-apply uploads nothing twice. **Where the file was pulled (`-o`) or applied in this workspace before, only what it changed since is sent**, as a `patch`: what changed in the workspace since and the file does not touch stays. An item named by its `alias` that the file puts in a new place \u2014 an act among an app's acts, a field between two others \u2014 is sent as a move beside the patch: inside an app it is built into the app, an option of a select takes its new place among that select's options on the table, and a table's fields and views, the roles, the templates and the apps themselves keep the order the workspace holds, said as a note. A list whose items carry no `alias` \u2014 an app's columns, a section's fields, the sections \u2014 is sent whole in its new order. What the file takes out is sent as a removal: it goes where an apply rebuilds it (an app's acts and columns, a write rule), a field leaves its table, and it is refused, naming the tool that deletes it, where an apply never deletes it (a table, a view, an app); an option leaves every app and stays on its table, said in the notes. **A field the file no longer states leaves its table** \u2014 a field an earlier apply stated, or one the pulled file stated \u2014 only where no live row holds a value in it and nothing outside the model's own apps reads it: no formula, rollup or lookup, no figure's unit or currency, no `default_from` copy, no unique set, no query, agent, table check or workflow body of the workspace. A row in the trash does not count, since no write can clear it: its value goes with the field, and the notes say how many rows it went for. Otherwise nothing is applied and the refusal names the field, how many live rows hold a value and what reads it; a workflow body the author wrote on an app the apply rebuilds that still names the field keeps it on the table, said in the apply's findings, and the next apply sends its removal again until nothing names it. A field such an app names leaves only once the app is rebuilt, with each leaving field it reads, so where one of them reads a field the file changes, nothing is applied and the refusal names both: apply the file without that change first. A field of the table no model ever stated is left as it is. **A label the file changes is a rename**: a field's and a select option's label, and a table's description, are written onto the table as the file states them, each named in the notes. **A declared workflow whose `inputs` or `outputs` the file changes** keeps the body set on it where the body still meets them; where it does not, the apply lands and the workflow stands on no body again \u2014 every call refused \u2014 until `set_app_workflow` sets one that does, said on the app's line. A file stating a `null` a patch would read as a removal sends the whole model, said on stderr, and so does a reorder sent to a server that takes no move. A patch builds the apps it states or moves an item of, each other app whose build it changes and each app whose live version names a field or option its tables no longer hold, `--app` naming others; one whose every key and move lies inside one app's body leaves the tables as they stand \u2014 its `tables` is empty \u2014 and checks and builds that app alone, beside any other app whose build reads what it changed. The copy each change is read against is kept per workspace and file under `~/.lotics/model_bases`: an apply narrowed by `--app` leaves in it the other apps as they were, so the next apply sends their edits again, and an apply that fails \u2014 refused, or cut off \u2014 leaves none, so the next sends the whole model. Then the tool adopts or creates every table (the table this workspace bound the entity to, else an existing table of the same label, is ADOPTED and given the fields, options and views it lacks; no stored value changes), writes first rows only where every bound table is empty, and mints a new version of each app the model declares, or the patch builds \u2014 `--app` (repeatable, comma-separated) narrows which apps, while the tables are applied whole. Prints one line per app it built \u2014 alias, `app_id`, `created`/`updated` with the version minted or `unchanged`, and the address it is served at \u2014 then the model's notes once, as the server states them. **What it would change, writing nothing, is `lotics model check`.** **A rollback restores an app's earlier version** (`lotics run rollback_app`); table changes and data writes stay. Resolves and ANNOUNCES its workspace first. `--json` prints `{workspace_id, tables, apps, findings}` on stdout, or `{ok: false, findings}` when the file does not check. |\n| `lotics model check <model.json> [--app <alias> ...] [--json]` | **What applying the model would change, through the `check_model` tool, writing nothing.** The file is read and checked as `model apply` checks it, then sent the same way \u2014 whole, or as what it changed since its base \u2014 and nothing is uploaded or kept. It prints what the apply would do to the schema (what it would add, each kind counted \u2014 tables, fields, options, constraints \u2014 what it leaves as the workspace has it, what the two disagree about), which apps it would create or update, what it would refuse an app for \u2014 the decisions among its findings, read beside the workspace's rows, with each refused app's patch \u2014 and what it would refuse of the model beside its apps (a field that cannot leave its table, a template nothing stores), all in the one run, exiting 1 when it would refuse anything; its notes name each field it would take off a table and each label it would rename; workflow bodies are checked only at apply, since they name fields a check has not created. `check_model` only reads, so a key holding read access alone runs this where `model apply` is refused, and so does a private instance whose licence admits reads only: it needs `schema:read`, and `data:read` when the file states an app or is sent as what it changed. Resolves and ANNOUNCES its workspace first. `--json` prints `{workspace_id, tables, apps, findings}` \u2014 each table and app what the apply would do, an app the patches change carrying its body as they leave it as `draft` \u2014 and `designs`, each refused app's patch in merge order, or `{ok: false, findings}` when the file does not check. |\n| `lotics model pull [-o <model.json>]` | **This workspace's model, rebuilt from what owns each part** \u2014 the tables, fields, options, templates and roles the workspace holds, how rows are recognised, and each app's body from its current version \u2014 through the `get_model` tool, as the file `model apply` reads: to stdout, or to the file `-o` names. What the workspace holds that a model cannot state is printed on stderr, never written into the file. Applying what it wrote changes nothing. With `-o`, what it wrote is the copy the next `model apply` of that file reads its changes against. |\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_app_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: an app the runtime draws from a model is made by `lotics model apply`. The SDK's reference is `node_modules/@lotics/app-sdk/AGENTS.md` inside the project. |\n| `lotics app pull [app_id] [path]` | **A custom-code app's 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_app_types`) and dependencies installed (`npm ci` with a lockfile, else `npm install`, both `--ignore-scripts`). A JSON app has no source tree: the server's refusal names `get_model`, and `lotics model pull` is its pull. |\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_app_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`, with `--case-sensitive`, `--diacritic-insensitive`, `--context-lines <n>` and `--limit <n>` \u2014 the dialect `grep_knowledge` reads \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 model reference, from inside the binary** \u2014 it describes this CLI's own model checker, at this CLI's version; `lotics docs` lists it under \"Build an app\", after `design`. Its first page is what a model composes with, the working order (jobs \u2192 entities and fields \u2192 `records` \u2192 one app per job \u2192 `model apply`) and the section addresses; every page of it is whole. Every top-level key of a `model.json`, every field `type` the contract admits with the config each one needs, the option / view / role / inline-template shapes, `records` (how a row of each entity is recognised), `write_rules`, `apps` (each register, record, act and check), the row format (relative dates `@today` / `@month-start` with whole-day offsets; links as `\"<entity-alias>:<ref>\"`), the rules, and one complete worked example; it points at `https://lotics.ai/presets/index.json` for complete example models of several trades. **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";
46915
+ 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 model --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| `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_agent`, `run_app_query`) and every one that changes an app (`set_app_queries`, `set_app_workflow`, `set_app_agent`, `update_app`, `rollback_app`). A command exists only for work that touches a local file: `model apply`, `model check`, `model pull`, `app create --custom`, `app pull`, `app deploy`. |\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 `preview: true` \u2014 nothing is written; the answer is what the run would return, with the calls it would make under `preview.writes` and the parts of the answer it could not say under `preview.unpredicted`; a workflow no preview is offered of answers `status: \"not_previewed\"` \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 two tools that RUN one.** `run_app_workflow` and `run_app_agent` whose envelope carries a failed `status` (`error`/`failed`/`cancelled`, or `not_previewed` \u2014 a preview 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 <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_key> [-o <dir>]` 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 the `list_knowledge` tool**: 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 `list_knowledge` with `deleted: true` 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 `create_knowledge` with `{ name, description, content, tags? }` (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 content-read path for a non-sandbox client). `-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 `update_knowledge` with **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, `list_knowledge`, and the corpus `grep_knowledge` 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 setup <model.json> [--email <addr>] [--json]` | **The whole first run, in one command.** Creates an account when this machine has no credential (the same call `auth signup` makes \u2014 `--name` and `--timezone` apply), then applies the model to its workspace, then prints the one-time sign-in link. **When that email already has an account it hands over to the `lotics auth login` flow** \u2014 it prints the sign-in page to open and the code it must show, and **exits 1 having created nothing**; the person presses Confirm and runs the same command again, which collects the key and carries on into the model. (`--wait` holds the terminal through the Confirm instead, finishing in one command.) The re-run is not refused for naming an `--email` it is now signed in as \u2014 that address IS the account it holds, not a second one. The file is a workspace MODEL, and it is read and checked before an account is created \u2014 the design decisions an apply would refuse it for too (`lotics docs design`), since a new workspace holds no rows to change them \u2014 because a file with a typo in it must not leave an organization behind. Then it is `lotics model apply` run on the new workspace: its tables, rows and apps; the sign-in link lands on its app when it has one, else on the workspace's app list. It exists because the two-command form has a seam where the FIRST command exists only to produce a credential for the second, and a caller pasting a prompt has to get both right. **`--email` is only for creating an account**: with a credential already resolvable it is REFUSED rather than obeyed, because the two can name different organizations and preferring either one silently writes into an org the caller did not name \u2014 the message says how to do each thing on purpose. Without it, `setup` applies the model to the account you already have. A path positional after the file is accepted and IGNORED with a warning \u2014 `setup` writes nothing to disk \u2014 so a prompt that passes one still runs. **`--json` prints one object on stdout and nothing else** \u2014 what `lotics model apply --json` does (`tables`; `apps`, each with `alias`, `app_id`, `version_id`, `origin`, `address` and `findings`; and `findings`) plus `organization_id`, `workspace_id` and `signin_url`, and a `warnings` array carrying everything the prose form would have said out of band, such as a sign-in link that could not be minted. A warning is never merely silenced: when the command fails with an error before it can emit, the ones it had collected go to stderr alongside it. Reachable with no install: `npx -y @lotics/cli setup \u2026`. |\n| `lotics model apply <model.json> [--app <alias> ...] [--json]` | **The model, applied to this workspace, through the `apply_model` tool.** The file is read and checked against the model's own rules with the validator the server runs, every problem in one run, before anything is uploaded. **The apply refuses an app leaving out a treatment its rows call for** (`lotics docs design`) \u2014 judged by the server beside the workspace's rows, before it writes anything \u2014 until the app adopts it or states why not under the `declines` key the refusal names; the refusal carries each refused app's patch adopting its decisions, to merge in the order printed. Where the model's own rules pass, one refusal carries everything decidable before the first write \u2014 those decisions, what compiling each app finds, a field that cannot leave its table and what the caller may not change \u2014 so one pass fixes them. Documents a row attaches by a path beside the file are uploaded first and the rows sent with their `fil_` ids; a path this workspace already recorded keeps its id, so a re-apply uploads nothing twice. **Where the file was pulled (`-o`) or applied in this workspace before, only what it changed since is sent**, as a `patch`: what changed in the workspace since and the file does not touch stays. An item named by its `alias` that the file puts in a new place \u2014 an act among an app's acts, a field between two others \u2014 is sent as a move beside the patch: inside an app it is built into the app, an option of a select takes its new place among that select's options on the table, and a table's fields and views, the roles, the templates and the apps themselves keep the order the workspace holds, said as a note. A list whose items carry no `alias` \u2014 an app's columns, a section's fields, the sections \u2014 is sent whole in its new order. What the file takes out is sent as a removal: it goes where an apply rebuilds it (an app's acts and columns, a write rule), a field leaves its table, and it is refused, naming the tool that deletes it, where an apply never deletes it (a table, a view, an app); an option leaves every app and stays on its table, said in the notes. **A field the file no longer states leaves its table** \u2014 a field an earlier apply stated, or one the pulled file stated \u2014 only where no live row holds a value in it and nothing outside the model's own apps reads it: no formula, rollup or lookup, no figure's unit or currency, no `default_from` copy, no unique set, no query, agent, table check or workflow body of the workspace. A row in the trash does not count, since no write can clear it: its value goes with the field, and the notes say how many rows it went for. Otherwise nothing is applied and the refusal names the field, how many live rows hold a value and what reads it; a workflow body the author wrote on an app the apply rebuilds that still names the field keeps it on the table, said in the apply's findings, and the next apply sends its removal again until nothing names it. A field such an app names leaves only once the app is rebuilt, with each leaving field it reads, so where one of them reads a field the file changes, nothing is applied and the refusal names both: apply the file without that change first. A field of the table no model ever stated is left as it is. **A label the file changes is a rename**: a field's and a select option's label, and a table's description, are written onto the table as the file states them, each named in the notes. **A declared workflow whose `inputs` or `outputs` the file changes** keeps the body set on it where the body still meets them; where it does not, the apply lands and the workflow stands on no body again \u2014 every call refused \u2014 until `set_app_workflow` sets one that does, said on the app's line. A file stating a `null` a patch would read as a removal sends the whole model, said on stderr, and so does a reorder sent to a server that takes no move. A patch builds the apps it states or moves an item of, each other app whose build it changes and each app whose live version names a field or option its tables no longer hold, `--app` naming others; one whose every key and move lies inside one app's body leaves the tables as they stand \u2014 its `tables` is empty \u2014 and checks and builds that app alone, beside any other app whose build reads what it changed. The copy each change is read against is kept per workspace and file under `~/.lotics/model_bases`: an apply narrowed by `--app` leaves in it the other apps as they were, so the next apply sends their edits again, and an apply that fails \u2014 refused, or cut off \u2014 leaves none, so the next sends the whole model. Then the tool adopts or creates every table (the table this workspace bound the entity to, else an existing table of the same label, is ADOPTED and given the fields, options and views it lacks; no stored value changes), writes first rows only where every bound table is empty, and mints a new version of each app the model declares, or the patch builds \u2014 `--app` (repeatable, comma-separated) narrows which apps, while the tables are applied whole. Prints one line per app it built \u2014 alias, `app_id`, `created`/`updated` with the version minted or `unchanged`, and the address it is served at \u2014 then the model's notes once, as the server states them. **What it would change, writing nothing, is `lotics model check`.** **A rollback restores an app's earlier version** (`lotics run rollback_app`); table changes and data writes stay. Resolves and ANNOUNCES its workspace first. `--json` prints `{workspace_id, tables, apps, findings}` on stdout, or `{ok: false, findings}` when the file does not check. |\n| `lotics model check <model.json> [--app <alias> ...] [--json]` | **What applying the model would change, through the `check_model` tool, writing nothing.** The file is read and checked as `model apply` checks it, then sent the same way \u2014 whole, or as what it changed since its base \u2014 and nothing is uploaded or kept. It prints what the apply would do to the schema (what it would add, each kind counted \u2014 tables, fields, options, constraints \u2014 what it leaves as the workspace has it, what the two disagree about), which apps it would create or update, what it would refuse an app for \u2014 the decisions among its findings, read beside the workspace's rows, with each refused app's patch \u2014 and what it would refuse of the model beside its apps (a field that cannot leave its table, a template nothing stores), all in the one run, exiting 1 when it would refuse anything; its notes name each field it would take off a table and each label it would rename; workflow bodies are checked only at apply, since they name fields a check has not created. `check_model` only reads, so a key holding read access alone runs this where `model apply` is refused, and so does a private instance whose licence admits reads only: it needs `schema:read`, and `data:read` when the file states an app or is sent as what it changed. Resolves and ANNOUNCES its workspace first. `--json` prints `{workspace_id, tables, apps, findings}` \u2014 each table and app what the apply would do, an app the patches change carrying its body as they leave it as `draft` \u2014 and `designs`, each refused app's patch in merge order, or `{ok: false, findings}` when the file does not check. |\n| `lotics model pull [-o <model.json>]` | **This workspace's model, rebuilt from what owns each part** \u2014 the tables, fields, options, templates and roles the workspace holds, how rows are recognised, and each app's body from its current version \u2014 through the `get_model` tool, as the file `model apply` reads: to stdout, or to the file `-o` names. What the workspace holds that a model cannot state is printed on stderr, never written into the file. Applying what it wrote changes nothing. With `-o`, what it wrote is the copy the next `model apply` of that file reads its changes against. |\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_app_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: an app the runtime draws from a model is made by `lotics model apply`. The SDK's reference is `node_modules/@lotics/app-sdk/AGENTS.md` inside the project. |\n| `lotics app pull [app_id] [path]` | **A custom-code app's 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_app_types`) and dependencies installed (`npm ci` with a lockfile, else `npm install`, both `--ignore-scripts`). A JSON app has no source tree: the server's refusal names `get_model`, and `lotics model pull` is its pull. |\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_app_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`, with `--case-sensitive`, `--diacritic-insensitive`, `--context-lines <n>` and `--limit <n>` \u2014 the dialect `grep_knowledge` reads \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 model reference, from inside the binary** \u2014 it describes this CLI's own model checker, at this CLI's version; `lotics docs` lists it under \"Build an app\", after `design`. Its first page is what a model composes with, the working order (jobs \u2192 entities and fields \u2192 `records` \u2192 one app per job \u2192 `model apply`) and the section addresses; every page of it is whole. Every top-level key of a `model.json`, every field `type` the contract admits with the config each one needs, the option / view / role / inline-template shapes, `records` (how a row of each entity is recognised), `write_rules`, `apps` (each register, record, act and check), the row format (relative dates `@today` / `@month-start` with whole-day offsets; links as `\"<entity-alias>:<ref>\"`), the rules, and one complete worked example; it points at `https://lotics.ai/presets/index.json` for complete example models of several trades. **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";
46916
46916
 
46917
46917
  // docs/data_model.md
46918
46918
  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, and add nothing that stores a fact the table already\nstores.** A value written as text and the same value held as a `select_record_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\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\nWorth writing as a test rather than a note, because a note about a condition nobody re-checks goes\nstale in silence.\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 \u2014 keys\nare minted per field. So anything moving a value between them needs a hand-written key map.\n\nThat map is code. Put it in one named module with a test; written inline at the copy it is invisible,\nuntested, and silently wrong the first time somebody renames an option, because a rename leaves the\nkey intact and the map still compiling. Prefer a link to a shared reference table where the set is\nopen or growing; keep a map only for a small closed set.\n\nWhere one side genuinely holds MORE values than the other, that is not drift \u2014 it is the model\ntelling the truth. The wider side must **refuse** what the narrower one cannot express rather than\nquietly picking the nearest value.\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\nDeclare the natural key in the table\'s description.\n\nAnything that imports, reconciles or de-duplicates has to decide identity, and with no declared key\nit falls back to comparing displayed text \u2014 which is how one company arrives three times under three\nspellings. Name the key: a reference number, a tax id, a link plus a period. Then match on `rec_\u2026`\nand `opt_\u2026`, never on rendered labels.\n\n## A state\'s HISTORY is rows, not columns\n\nA `changed at` column says only how long a row has been where it is now \u2014 the next move overwrites\nit \u2014 and a date column per state holds until something re-enters a state it already left.\n\nMeasuring time-in-state or conversion needs one ROW per move: a link to the subject, the state left,\nthe state entered, when. Hold those states as the source field\'s own `opt_` keys so the log carries\nno second vocabulary, and write the row in the workflow that makes the move.\n\n## Keep derived chains shallow\n\nFormulas and rollups are computed and STORED when a row is written, and one that reads another\nrecomputes with it. A rollup over a formula over a formula is paid three times on every touch, and\nagain for every row upstream of it.\n\n**Depth costs more than row count.** This is the optimisation lever that actually exists here; row\nscanning is the platform\'s problem, chain depth is yours.\n\n## Changing a money formula on a live table\n\nFormula edits recompute every row, so the only honest proof that one changed nothing it should not is\nthe numbers themselves:\n\n1. Snapshot the affected totals to a file.\n2. Make the change.\n3. Diff. Identical is the pass.\n\nAnd when a formula gains a new field, **test that the value IS the one you want, never that it\ndiffers from it** \u2014 an empty cell reads as `""`, which differs from every option key, so the inverted\nspelling silently zeroes every row written before the field existed.\n\n## Fields\n\nWhat `create_table` (on the CLI or in chat) and `update_table` take in `add_fields`, and `update_table` in `update_fields`.\n\n### Types and formats\n\n`type` is one of `text`, `number`, `date`, `boolean`, `select`, `select_member`,\n`select_record_link`, `files`, `formula`, `rollup`, `lookup`, `autonumber`. `button` is retired: an\nexisting button field keeps running, but none is created, converted to or edited.\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### Properties\n\nA type\'s properties sit **directly on the field object** \u2014 there is no `config` wrapper. A property\nmay itself hold an object (`formula`, `aggregate_option`, `filter`, `order_by`); its inner keys stay\ninside it. Each type takes only its own:\n\n- **text** \u2014 `format?` (`"text"` | `"link"` | `"markdown"`), `unique?`, `default_value?` (a string).\n- **number** \u2014 `notation?`, `default_value?` (a number). `notation` is how the figure reads, one\n of three; on `update_fields` a stated one replaces the field\'s whole.\n - `{ style: "decimal" }` \u2014 a plain number, as a field stating none reads.\n - `{ style: "currency", currency: "VND" }` \u2014 money in an ISO 4217 code.\n - `{ style: "unit", unit }` \u2014 a quantity: `"percent"` (10 reads "10%", never multiplied), a\n measured code \u2014 g, kg, t, l, m3, cbm, mm, cm, m, km, m2, min, h, day \u2014 or a counted noun such\n as `ki\u1EC7n`. A change between two units of one dimension converts every stored figure; any other\n change relabels.\n - A currency or unit read on each row is `{ per_row: "\u0110VT" }` in its place: a single select on\n the same row, or a lookup of one through a one-link, named by name or key, whose chosen option\n is that row\'s currency or unit \u2014 every option label an ISO 4217 code, or a unit as `unit`\n takes one.\n- **date** \u2014 `format?` (`"date"` | `"datetime"` | `"date_range"` | `"datetime_range"`), `timezone?`,\n `derive_from?`, `default_value?` (a date string). `derive_from: "created_at"` stamps the row\'s\n creation once, `"updated_at"` re-stamps on every update; the field is then read-only, takes no\n `default_value`, and holds only the `date` and `datetime` formats.\n- **boolean** \u2014 `default_value?` (`true` | `false`).\n- **select** \u2014 `options` `[{ name, color?, mark? }]`, `multi?`, `default_value?`. An option stated without a `color` gets one no sibling option holds.\n- **select_member** \u2014 `multi?`, `default_value?` (member ids).\n- **select_record_link** \u2014 `table_id`, `sync_both_ways?`, `cardinality?`, `paired_field_name?`. A\n link shows the row it names by the one field its table names its rows by \u2014 the table\'s\n `title_field_key`, stated when the table is made or changed, or by `records.<entity>.title` in the\n model; a link states no display fields of its own, and a combined name is a formula field named there.\n `sync_both_ways: true` on an existing one-way link makes it two-way \u2014 never add a second link for\n that. `cardinality: "one"` holds a single row, paired or not; a paired partner of a `"one"` side\n lists that side\'s rows as its children. Two paired sides cannot both be `"one"`. `paired_field_name`\n names the back-reference the target gets.\n- **files** \u2014 no properties.\n- **autonumber** \u2014 `template` (`"INV-{YEAR}-{N:4}"`; tokens `{N}`, `{N:W}` zero-padded to W,\n `{YEAR}`, `{YEAR:2}`, `{MONTH}`, `{DAY}`), or `prefix` + `padding` (1\u201320) for `PREFIX-0001`.\n- **any type**, at create \u2014 `confirm_before_update?`: a person confirms each later edit.\n\n`default_value` fills the field on a NEW record that states no value; existing records are never\nbackfilled, and null clears it. A select\'s or a member field\'s default is an ARRAY even when the\nfield holds one (`["Unread"]`, never `"Unread"`) \u2014 option names in `add_fields`, where the keys do\nnot exist yet, and option keys (`opt_\u2026`) in `update_fields`.\n\n`default_from` (text, number, date, boolean, select, select_member, select_record_link) copies a\nvalue onto a NEW record off the row one of its links names: `{ link_field_key, field_key,\noptions? }`, the link of cardinality `"one"` and not itself copied, the source a field of the linked\ntable of this field\'s own type, date format and notation (a link one to the same table) \u2014 a formula,\na rollup, or a lookup through a one-row link (or an ordered one) read as the value it holds; several\nvalues only into a field holding several; never into a date `derive_from` stamps. It is\ncopied when the record is made and never re-read, so a later change to the source leaves the record\nalone (a line\'s price as it was when the line was booked). A value the write states wins, then the\ncopy, then `default_value`. A select source\'s `options` maps its option keys onto this field\'s\n(`update_fields`, once the keys exist); an option left unmapped is not copied. null clears it. Deleting the link a\ncopy reads through, or the field it copies off, is refused even with `force` until the copy is\ncleared; the dependency report lists the copying field as `copy_link` or `copy_source` under\n`requires_manual_action`. Any other schema change that leaves a copy unable to stand \u2014 its source\nretyped, its link made to hold many \u2014 is refused, naming the copy.\n\n### Computed fields\n\nRead-only in records. `formula` is one key holding an object; a rollup\'s and a lookup\'s properties\nare separate keys on the field \u2014 `rollup: {\u2026}` and `lookup: {\u2026}` are refused as unrecognized.\n\nformula: { expression, notation?, link? }\n\n- `{Field Name}` or `{fld_key}` names a field of the same table, stored as its key, so a rename never\n breaks the formula. A reference to no field, or a call to something that is no helper, is refused.\n- A select reads as an ARRAY of option keys: `{Status}[0]` for a single select,\n `includes({Tags}, "opt_\u2026")` for a multi.\n- The operators, the helpers and how an empty cell reads are the `model` reference, section `formula` \u2014 a model\n names fields by alias where a table tool names them by name or key; the language is the same.\n- `notation` is how a number result reads, as a number field\'s; `link: true` says the result is a\n URL, drawn as a link that opens.\n- On `update_fields` a key left out keeps its stored value, and a stated `notation` replaces the\n stored one whole. `get_table` marks a formula that is null when every field it reads is empty.\n\nrollup \u2014 `source_field_key`, `aggregate_option: { field_key?, operation }`, `filter?`\n\n- `source_field_key` is a `select_record_link` of this table; `aggregate_option.field_key` is a\n field of the linked table, required by every operation but `count`, which counts linked records.\n- Operations by the aggregated field\'s type \u2014 none: `count`; any: `empty`, `filled`,\n `percent_empty`, `percent_filled`, `unique`, `percent_unique`; number: `sum`, `avg`, `median`,\n `min`, `max`, `range`; date: `earliest`, `latest`, `date_range`.\n- The cell\'s type comes from the OPERATION: `earliest` and `latest` hold a date, every other\n operation a number (`date_range` counts days). A "most recent linked date" is `latest` \u2014 `min` and\n `max` are numeric and refuse a date. A rollup takes no notation of its own: `sum` over money\n reads in the aggregated field\'s currency, over a weight in its unit, a count as a plain number\n and a `percent_*` in the unit `percent`.\n- Over a figure read in each row\'s own unit or currency (a notation\'s `per_row`), `sum`,\n `avg`, `median`, `min`, `max` and `range` hold only where that select is a lookup, through the\n link paired with `source_field_key`, of a single select on this table \u2014 the total reads in this\n row\'s option of it. A lookup of such a field stands only where its own table also looks up that select through the same one-link, and reads in that lookup: a line looks up the item\'s unit through its link to the item, then may look up the item\'s stock through the same link.\n- `filter` aggregates only the linked records it matches: a full group over the LINKED table\'s\n fields \u2014 `{ node_type: "group", logic, children: [{ node_type: "condition", type, field_key,\n operator, value }] }`, a select condition using `has_any_of` with an ARRAY value.\n\nlookup \u2014 `source_field_key`, `lookup_field_key`, `order_by?`\n\n- `lookup_field_key` is a field of the linked table. Without `order_by` the cell holds every linked\n record\'s value; with `order_by: { field_key, direction }` (`desc` latest, `asc` earliest) it holds\n the picked field of the single extreme record \u2014 a "latest linked X" that maintains itself.\n Ordered lookups sharing one `order_by` resolve to the SAME record.\n\n### Examples\n\n```\n{ name: "Gi\xE1 b\xE1n", type: "number", notation: { style: "currency", currency: "VND" } }\n{ name: "Tr\u1ECDng l\u01B0\u1EE3ng", type: "number", notation: { style: "unit", unit: "kg" } }\n{ name: "S\u1ED1 l\u01B0\u1EE3ng", type: "number", notation: { style: "unit", unit: { per_row: "\u0110VT" } } }\n{ name: "Chi\u1EBFt kh\u1EA5u", type: "number", notation: { style: "unit", unit: "percent" } }\n{ name: "Tr\u1EA1ng th\xE1i", type: "select", options: [{ name: "M\u1EDBi" }, { name: "Xong" }] }\n{ name: "M\xE3 \u0111\u01A1n", type: "autonumber", template: "SR-{YEAR}-{N:4}" }\n{ name: "Kh\xE1ch h\xE0ng", type: "select_record_link", table_id: "tbl_x", sync_both_ways: true }\n{ name: "Total", type: "formula", formula: { expression: "{Price} * {Qty}", notation: { style: "currency", currency: "VND" } } }\n{ name: "SL \u0111\xE3 giao", type: "rollup", source_field_key: "Giao h\xE0ng", aggregate_option: { operation: "sum", field_key: "S\u1ED1 l\u01B0\u1EE3ng" } }\n{ name: "Customer Name", type: "lookup", source_field_key: "Customer", lookup_field_key: "Name" }\n{ name: "Latest note", type: "lookup", source_field_key: "Calls", lookup_field_key: "Note", order_by: { field_key: "At", direction: "desc" } }\n```\n\n### Changing a field\n\nA field added by `update_table` shows in a view only when `add_to_views` names it, at the view\'s far\nright; on the CLI or in chat, `update_view` with `move_field` places it beside the columns it belongs with.\n\nAn `update_fields` entry is `{ field_key, name?, description?, convert_to?, \u2026 }` with the same flat\nproperties as `add_fields`, plus what only an update does:\n\n- A select\'s options change through `add_options` `[{ name, color?, mark? }]`, `update_options`\n `[{ key, name?, color?, mark? }]`, `remove_option_keys` and `reorder_options` (every existing\n option once, in order; options added in the same call follow). An option is named by its `opt_\u2026`\n key (its name also resolves); a rename keeps every record\'s value. `mark` is the option\'s brand\n (`{ kind: "brand", name: "tiktok" }`) or kit glyph (`{ kind: "icon", name: "wrench" }`), drawn in\n place of its colour dot \u2014 every option of a field has one or none does, so marking sets them all\n in one call and `mark: null` on each clears them. `distinct_option_colors: true` re-colours a\n select so every option holds its own: the first option of each colour keeps it, the rest take\n colours no option holds.\n- A link\'s `sync_both_ways: false` disconnects the pair; a new `table_id` re-points it and clears its\n record data; `cardinality` is `"one"` for the child side of a parent-child pair, `"many"` for peers.\n';
@@ -46936,7 +46936,7 @@ var app_bindings_default = "# App bindings \u2014 the queries, workflows and age
46936
46936
  var document_templates_default = '# Document Templates\n\nTurn workspace data into finished documents \u2014 invoices, contracts, reports, letters,\nlabels, emails \u2014 by filling a reusable template instead of writing a file from scratch.\nYou register a template once, then generate a filled file (or a rendered email) from it as\nmany times as you like, feeding it data from records, a workflow, or an agent run.\n\nThis is a **capability + usage** guide. For the exact input schema of any tool named here,\nrun `lotics tools <tool_name>` \u2014 that is always the source of truth for arguments.\n\n## The five template types\n\n| Type | What it fills | Output | Create with |\n|---|---|---|---|\n| `pdf-form` | fields overlaid on an **uploaded PDF** at fixed positions | PDF | `create_pdf_template` (mode `form`), on the CLI or in chat |\n| `html` (a PDF) | an **HTML + Handlebars** layout you write from scratch | PDF | `create_pdf_template` (mode `html`), on the CLI or in chat |\n| `excel` | an **uploaded `.xlsx`** with `{{marker}}` cells | `.xlsx` | `create_excel_template` |\n| `word` | an **uploaded `.docx`** with `{{marker}}`s | `.docx` | `create_word_template` |\n| `email` | an **inline HTML + Handlebars** body, rendered when the email is sent | email | `create_email_template`, on the CLI or in chat |\n\nTwo shapes underneath: **file-backed** (`pdf-form`, `excel`, `word`) clone an uploaded\noffice file and mark it up; **inline** (`html`, `email`) store the markup you author directly.\n\n## The lifecycle: create \u2192 generate \u2192 chain\n\n1. **Create** a template \u2014 register the file (or inline markup) and declare a `variables`\n map (the named slots the template fills). This returns a template id (`dtl_\u2026`).\n2. **Generate** a filled file \u2014 call `generate_document` with the\n template id, a `filename` (no extension), and a `data` map keyed by your variable names.\n It substitutes the markers and returns a **generated file** (with a `file_id`).\n3. **Chain** the result \u2014 generation only *produces* the file. Attaching it to a record,\n sending it, or saving it locally is a separate step: inside a workflow, pass the returned\n `file_id` to the next step; from the CLI, `lotics download <file_id>` fetches it.\n\nEmail is the exception: `generate_document` refuses an email template. An email template is\nrendered to a subject + body **at send time** from the template and the data the send step\nsupplies \u2014 so you author it with `create_email_template` (on the CLI or in chat) and a workflow/app send step (or the\nweb composer) does the rendering and delivery. External agents send their own email directly.\n\n## Creating each type\n\n### PDF from HTML\n\nWrite a full HTML document with inline CSS and `{{variable}}` placeholders, as `create_pdf_template`\nwith mode `html` on the CLI or in chat; it renders to PDF via a headless browser. Good for invoices, reports, letters, certificates \u2014 anything whose\nlayout you control. Declare `variables` (plain text, rich `html`, an auto-rendered `table`,\nor a `list`), and optionally `format` (page size) and `orientation`. See\n`lotics tools create_pdf_template` for the exact variable shapes.\n\n### PDF form\n\nFor an existing PDF you must fill in place (a government form, a printed contract), on the CLI or in chat:\n\n1. `lotics upload ./form.pdf` \u2192 a `file_id`.\n2. `create_pdf_template` with mode `form` and that `file_id`.\n3. `analyze_pdf_template` \u2014 extracts the labels and their positions off the page.\n4. `update_pdf_template` \u2014 define the positioned fields (`text` / `checkbox` / `image`, each\n with a page + x/y/size) in `variables`.\n\nForm-mode data is scalar-only (text, numbers, checkboxes) \u2014 one value per positioned field.\n\n### Excel\n\n1. Build a `.xlsx` in Excel (or with any spreadsheet library) and put `{{marker}}`s in the cells that\n should be filled. `lotics upload ./template.xlsx` \u2192 a `file_id`.\n2. `create_excel_template` with that `template_file_id` and a `variables` map. Markers are\n **validated at create time** \u2014 a structural error (mismatched loop, unknown helper, bad\n placement) blocks the save, and declared variables with no matching marker come back in\n `unmarked_variables` (skipped, not filled).\n\nOn the CLI, `validate_excel_template` checks an uploaded file\'s markers without creating a template. Pass\nraw numbers / ISO dates / booleans at generate time \u2014 the cell\'s number format handles display.\n\n### Word\n\n1. Build a `.docx` with `{{marker}}`s where values go. `lotics upload ./template.docx`.\n2. `create_word_template` with the `template_file_id`. `variables` is optional \u2014 every\n marker in the document is derived automatically (`{{name}}` \u2192 string,\n `{{FOR \u2026 IN name}}` \u2192 array, `{{IF name}}` \u2192 boolean). When you do declare, an unmarked\n declaration comes back in `unmarked_variables` and is skipped, and markers you didn\'t\n declare are still auto-added (`derived_variables`) \u2014 the stored contract always covers\n every marker in the document.\n\nAt generate time, `data` must provide a key for **every** marker \u2014 pass `""` for fields\nthat should render blank; a missing key fails with the full list of missing markers.\n\n### Email\n\nInline HTML + Handlebars, no uploaded file \u2014 `create_email_template`, on the CLI or in chat. Declare `variables`, and optionally a default\n`subject` (which itself supports `{{variable}}` expressions) and default to/cc/bcc. The body\nand subject render from the data at send time.\n\n## Markers, at a usage level\n\nEvery type supports three shapes of substitution. The exact syntax differs per engine \u2014 read\nthe create-tool description for the reference; here is the capability:\n\n- **Scalar** \u2014 a single value in one spot: `{{name}}`, `{{total}}`. Whole-cell scalars in\n Excel preserve their type via the cell\'s number format.\n- **Repeating rows / line items** \u2014 one template row rendered once per item in a list, for\n invoice lines, order rows, tables. Excel and the HTML/email engines use a Handlebars-style\n `{{#each items}}\u2026{{/each}}`; Word uses `{{FOR item IN items}}\u2026{{$item.field}}\u2026{{END-FOR item}}`. The HTML and email types\n also offer an auto-rendered `table` variable \u2014 declare its columns and pass an array, no\n hand-written loop. (Excel always uses the `{{#each}}` marker rows \u2014 it has no auto-rendered\n `table` type; its variable types are string/number/date/boolean/array.)\n- **Conditional sections** \u2014 a block shown only when a condition holds (a "paid" stamp, an\n optional notes block). Excel/HTML/email use `{{#if}}\u2026{{else}}\u2026{{/if}}`; Word uses `{{IF name}}\u2026{{END-IF name}}`.\n\nThe `html` and `email` types format a raw value with a helper, in the template\'s `locale` (`vi` or `en`, set at\ncreate or update; none prints in the organization\'s language); an empty value prints `""`:\n`{{money total "VND"}}` \u2192 `28.000.000 \u20AB` (`en`: `\u20AB28,000,000`), `{{number qty}}` \u2192 grouped, up to 2 decimals,\n`{{date issued_on}}` \u2192 `14/09/2026` (a moment on the workspace\'s timezone; a date-fns pattern as the second argument,\nliteral text quoted: `"\'Ng\xE0y\' dd"`), and `{{words total "USD"}}` \u2192 `M\u1ED9t ngh\xECn \u0111\xF4 la M\u1EF9 n\u0103m m\u01B0\u01A1i xu` (`en`: `One\nthousand US dollars and fifty cents`; no code reads VND); `{{date (add_days issued_on -1)}}` prints the day before, `add_days`\nshifting a date by whole days. A value the helper cannot read fails the render.\n\nRun `lotics tools create_excel_template`, `create_word_template`, `create_pdf_template`, or\n`create_email_template` for each engine\'s exact marker grammar and helper list \u2014 don\'t guess it.\n\n## Generating a filled file\n\n```bash\n# See what a template expects, then fill it\nlotics tools generate_document\nlotics run get_template \'{"template_id":"dtl_..."}\' # its declared variables\nlotics run generate_document \'{"document_template_id":"dtl_...","filename":"invoice-1042","data":{"company_name":"Acme","total":1042,"items":[...]}}\'\nlotics download <file_id> -o ./out/\n```\n\n- `data` is a map keyed by your variable names. Scalars for scalar/form fields; arrays for\n `table`/`list`/loop variables. `filename` excludes the extension (the type sets it).\n- `generate_document` renders the template\'s own type and returns a generated file object; take\n its `file_id` onward.\n- In a workflow, a **generate step** calls the same tool and hands the `file_id` to the next\n step (attach to a record, send as an attachment, etc.).\n\n## Discovering and managing templates\n\nFour unified tools work across all five types:\n\n| Tool | Does |\n|---|---|\n| `query_templates` | list templates (optionally filter by type: excel/word/pdf/email) |\n| `get_template` | one template\'s full definition, incl. the `variables` it expects |\n| `clone_template` | copy a template to iterate on, on the CLI or in chat |\n| `delete_template` | remove a template, on the CLI or in chat |\n\nCall `get_template` before generating when you don\'t already know a template\'s variable names.\n\n## Reaching the tools\n\n```bash\nlotics tools # all categories\nlotics tools create_word_template # one tool: full description + input schema\nlotics run <tool> \'<json-args>\' # execute (inline JSON, @file.json, or `-` for piped stdin)\n```\n\nThe template tools live in the categories **PDF Templates**, **Excel Templates**,\n**Word Templates**, **Email Templates**, and **Templates** (the unified query/get/clone/delete).\nUploading the source file for a file-backed template is `lotics upload <file>`; fetching a\ngenerated file is `lotics download <file_id>`.\n';
46937
46937
 
46938
46938
  // docs/knowledge_docs.md
46939
- var knowledge_docs_default = '# Knowledge Docs\n\nKnowledge docs are the AI\'s **rulebook layer** \u2014 the workspace-specific facts and rules an\nagent can\'t know on its own: your price list, your product codes, your SOPs, your shipping\ntariffs, your glossary of in-house terms. You write them once; the agent **searches and reads\nthem on demand** while it works, pulling in only the lines it needs.\n\nThey are deliberately **not** injected into the agent wholesale. Instead the agent retrieves\nfrom them line by line \u2014 grep, then read \u2014 so a 10,000-line tariff book costs nothing until a\nquestion actually touches it, and a 10MB one is no different.\n\nThis is a **capability + usage** guide. For the exact input schema of any tool named here, run\n`lotics tools <tool_name>`.\n\n## Rulebook, not methodology\n\nPut in a knowledge doc the things the model **cannot guess**: your specific numbers, codes,\nnames, exceptions, and policies. Do **not** put in it general skills the model already has\n("how to write a polite email", "how to summarize"). If the agent would get it right without\nthe doc, the doc is noise.\n\n## Creating a doc \u2014 `lotics knowledge create`\n\n```bash\nlotics knowledge create --name "Shipping tariffs" \\\n --description "HS-coded rates; searchable by lane and code" \\\n --tags "HS,nh\u1EADp kh\u1EA9u" \\ # file it as you make it \u2014 see Classifying, below\n --from ./tariffs.md # the body is a Markdown file; or --content \'<inline>\'\n```\n\nThis reads the body from your filesystem and creates the doc through `create_knowledge`\n(prints the new id). A raw `lotics run create_knowledge @doc.json` works too \u2014 read a large\n`content` from a file or stdin. `create_knowledge` takes five fields, three of them required:\n\n- `name` \u2014 what it is. Just the name: **grouping belongs in `tags`, not in a prefix baked into\n every title.**\n- `description` \u2014 what the doc is **and what to grep it for**: its vocabulary and the synonyms\n an ambiguous query would use. Surfaced in the tree before any body is read, and truncated at\n 200 characters. See *Write for grep* below.\n- `content` \u2014 Markdown.\n- `tags` *(optional)* \u2014 the labels someone would filter by, e.g. `["H\u1ED3 s\u01A1 NOXH", "Kho\u1EA3n 13"]`.\n Give **every** one that is true of the doc, not just the main one \u2014 a corpus is narrowed by\n intersecting tags, so a doc carrying one label can only ever be found down one path. On\n `update_knowledge` the array **replaces** what is stored, so resend the labels it should keep.\n- `files` *(optional)* \u2014 file ids of the source `.docx`/`.pdf`/`.xlsx` the doc was built from,\n stored alongside it so a reader can open the original. The body stays the searchable text.\n\nA new doc is created **owned by you, active in your own agent context, and private** \u2014 no one\nelse can see it yet. Changing a doc\'s default-active state is done through the web app / REST\nsurface, not this tool.\n\n## Access\n\nA knowledge doc reaches an agent only when the member can `use` the doc. New docs are private\nto the owner; share them with other members or groups using the IAM tool `share_resource` on the CLI or in chat\n(category **Admin**; `unshare_resource` to revoke). An organization admin who is a person is no\nexception: they read a doc only once it is shared with them, or once they share it with\nthemselves, which the audit log records. An API key acting as itself keeps the access set on the\nkey.\n\n## How an agent uses a doc \u2014 ls, grep, cat\n\nDocs are not injected wholesale. The agent works the corpus like a filesystem, and all three\nverbs respect access, so only docs the caller may use ever surface.\n\n1. **`list_knowledge`** \u2014 `ls`. The corpus as a flat list: id, name, tags, and a truncated\n description; `tag` narrows it. No bodies.\n2. **`grep_knowledge`** \u2014 `grep -rn`. Match across every readable doc (or one doc, or one\n tag), returning **doc, line number, and the matching line**. It runs inside Postgres,\n so only matching lines cross the wire.\n3. **`read_knowledge`** \u2014 `cat` / `sed -n \'X,Yp\'`. Read a doc whole or by line range, to see\n the context around a hit.\n\nMatching is **not ranked** \u2014 there is no index and no tokenizer, which is why a corpus in any\nscript works and why nothing goes stale. The consequence is that the *agent* does the\nnarrowing: a distinctive phrase is sharply selective, a whole question matches everything.\n`grep_knowledge` always reports `total_matches`, so "too broad" is visible and cheap to fix.\n\n### Literal by default, expression on request\n\n`pattern` is **literal text** \u2014 every character matches itself. Set `regex: true` to read it as\na POSIX regular expression instead.\n\n```\ngrep_knowledge({ pattern: "C/O (Form E)" })\ngrep_knowledge({ pattern: "\\\\yNK\\\\y", regex: true, case_sensitive: true })\n```\n\nThe default is the safe one rather than the conventional one, and the asymmetry is worth\nknowing. As an expression, `C/O (Form E)` searches for `C/O Form E` \u2014 not in the document \u2014 and\nreturns a confident zero; `[C\u0168]` becomes a one-character class and matches every `C` in the\ncorpus. Both look like ordinary answers. A literal search has no such failure: the worst case\nis an expression sent without the flag, which finds nothing and says so, naming the search that\nran and what to pass instead. That last part is **measured, not guessed** \u2014 an empty result tells\nyou whether reading the pattern as an expression would have matched, so an alternation sent without\nthe flag (`phrase A|phrase B`) comes back naming `regex: true` rather than advising you to shorten a\npattern that was never too broad.\n\n### Matching options\n\n- **Tone marks match exactly by default.** `diacritic_insensitive: true` folds them, so `ca phe`\n matches `c\xE0 ph\xEA` \u2014 reach for it when the pattern was typed without them, not by habit: folding\n strips the pattern to ASCII, where a short Vietnamese word matches inside unrelated words\n (`m\xE3` finds `manifest`). An empty result tells you when folding would have matched.\n- **Case folds by default**, independently of diacritics. `case_sensitive: true` matches case\n exactly \u2014 useful for an acronym (`NK` vs `nk`) that a folded search would blur.\n- **Whitespace is normalized on both sides.** A body converted from PDF, Word or Excel carries\n non-breaking spaces, soft hyphens, zero-width marks and padded runs that nobody types into a\n query; those fold to ordinary single spaces before matching, so a correct search does not return\n a silent zero on text that is present. A literal pattern is normalized the same way; an\n expression is left byte-exact, since collapsing its whitespace would rewrite it.\n- **What `regex: true` supports**: quantifiers, character classes, alternation, anchors,\n backreferences, non-greedy forms, `(?i)`, and both lookahead and lookbehind. A word boundary\n is **`\\y`**, not `\\b` \u2014 Postgres spells it differently, and `\\b` is rewritten for you rather\n than silently matching nothing. Named groups and `\\p{\u2026}` are likewise rewritten to their POSIX\n equivalents. A `\\p{\u2026}` with no POSIX equivalent, and a malformed expression, are both refused\n with the reason.\n\nWhere an expression cannot express the question at all \u2014 a value that must be computed, a\nlayout matching cannot address \u2014 stage the doc into a code run instead (`code_exec`, in chat or an app agent, with\n`knowledge_doc_ids` puts it at `inputs/knowledge/<id>.md` as a real file).\n\n### What a result is bounded by\n\nA result carries at most 40.000 characters. Matched lines are admitted first and context fills\nwhat remains, so an answer is never dropped to make room for a neighbouring line. Anything the\nbudget cut is REPORTED \u2014 `chars_elided_matches` (an answer was dropped) and\n`context_omitted_matches` (an answer was kept without the context you asked for) mean different\nthings and call for different fixes: narrow the pattern, or ask for fewer `context_lines`.\nIndividual lines longer than 600 characters are clipped and marked, with `read_knowledge` giving\nthe rest.\n\n`total_matches` is always the true total, even when fewer are shown.\n\n## Write for grep \u2014 the rules that decide whether an answer is findable\n\nRetrieval addresses **lines**. Every rule below follows from that one fact.\n\n- **One self-contained fact per line.** A grep hit returns *that line*. For dense or tabular\n data \u2014 a price row, a tariff code, a charge entry \u2014 put the whole record on one line.\n- **Repeat the searchable terms on every line; headings do not carry down.** A line reading\n `Rate: 15%` under a heading `Roasted coffee` will never match a search for `coffee`. Restate\n the identifying terms inline, even when it reads redundantly to a human. This is the single\n rule most often got wrong.\n- **Keep a line under ~600 characters.** Past that the line is truncated in the agent\'s view\n and marked as cut; the agent can still `read_knowledge` for the rest, but it costs a round\n trip. Put the identifying terms early and the long tail late.\n- **Put synonyms and translations on the line itself.** A bilingual row (`C\xE0 ph\xEA, \u0111\xE3 rang /\n Coffee, roasted`) matches queries in either language for free. The same trick carries\n colloquial terms next to official ones.\n- **For prose, keep a rule and its exception close.** A hit returns one line, so a rule on line\n 40 and its exception on line 90 can be retrieved apart. Use `context_lines` when reading, and\n keep related clauses adjacent when writing.\n\nMarkdown headings are ordinary text \u2014 useful for a human (a doc\'s `##` headings outside its\nblocks, two or more, are listed on its page as its sections) and for orienting a\n`read_knowledge`, but they carry no retrieval weight of their own.\n\n**The description is the discovery hint.** It is what the agent sees in the tree before it has\nread a byte of the body, so it is the only clue for *what term to grep for*. Write it to name\nthe doc\'s vocabulary \u2014 the words that actually appear inside it. Keep it to a sentence: it is\ntruncated at 200 characters, and a keyword-stuffed description is cut, not rewarded.\n\n## Updating a doc \u2014 `lotics knowledge update`\n\n```bash\nlotics knowledge update kdc_... --from ./tariffs.md # replace the body (--name / --description too)\n```\n\nSend only the fields you\'re changing. `--from` / `--content` replaces the body; `--name` /\n`--description` / `--tags` change metadata. `update_knowledge` resolves concurrency **internally** \u2014 it\nre-reads the current content pointer and version-chains the new body \u2014 so there is no version\ntoken to pass from the CLI. (The chat agent may instead send an `edits` array \u2014 anchored\nreplace / insert / append \u2014 for a surgical change; see `lotics tools update_knowledge`.) Refine\nstructure as you learn what users actually ask: add the synonym that failed to match, split a\nline that was too coarse, restate a term the heading was carrying.\n\n## Classifying a corpus \u2014 tags\n\nA doc carries a SET of labels, not a folder. Order does not matter, a doc can wear several, and\nthe vocabulary is whatever the docs themselves use \u2014 a tag stops existing the moment nothing\ncarries it.\n\n```bash\nlotics knowledge update kdc_... --tags "HS,nh\u1EADp kh\u1EA9u" # ONE doc: state its complete set\nlotics knowledge tag kdc_a kdc_b --add HS --remove draft # MANY docs: a diff applied to each\n```\n\nThe split is deliberate. Looking at one doc you know what it should carry, so `--tags` replaces.\nAcross a set you do not \u2014 the docs carry different labels \u2014 so `tag` adds and removes against\neach doc\'s own set, and never states one classification for all of them. Removal ignores case;\nadding a label a doc already carries changes nothing.\n\nAn agent narrows by tag rather than reading the whole corpus:\n\n```bash\nlotics tools list_knowledge # takes an optional tag\nlotics tools grep_knowledge # same\n```\n\n## Taking a doc out of circulation \u2014 `hide`\n\n```bash\nlotics knowledge hide kdc_... # superseded, draft, source material\nlotics knowledge list --include-hidden # find it again\nlotics knowledge unhide kdc_... # put it back\n```\n\nA hidden doc is out of every **listing**: it is gone from the Library\'s list, from\n`list_knowledge`, and from the corpus `grep_knowledge` searches, so nothing finds it by browsing.\nIt is not deleted, not unshared, and still **reads when addressed by id** \u2014 `read_knowledge` with\nits id, a code run staging it, an app agent that declares it. That is the point: hiding is a\nstatement about discovery, so it cannot silently break an app that depends on a doc by name.\n\nReach for it instead of `rm` whenever the material still matters: last year\'s tariff schedule, a\nhandbook a newer one replaced, the raw source a curated doc was written from.\n\n## Formatting \u2014 what a doc draws\n\nA doc is markdown with GFM tables. Beyond that it draws a few blocks, each in syntax you already write; the platform draws them in its own style, so every doc looks alike.\n\n| Write | Draws |\n|---|---|\n| `> [!NOTE]`, `[!TIP]`, `[!IMPORTANT]`, `[!WARNING]` or `[!CAUTION]` opening a blockquote | a callout in that tone |\n| `<Note>`, `<Info>`, `<Tip>`, `<Check>`, `<Warning>`, `<Danger>` | the same callouts |\n| a ```mermaid fence | a diagram: flowchart, sequence, timeline, gantt, `pie`, `xychart-beta`, `quadrantChart` (a 2x2; quadrant-1, top right, is drawn as the target), in the platform\'s theme; a flowchart\'s or a 2x2\'s own colour statements are dropped |\n| `<Steps>` holding `<Step title="\u2026">` | a numbered walk |\n| `<Tabs>` holding `<Tab title="\u2026">` | switchable panels |\n| `<Columns cols={2}>` (or `<CardGroup>`) holding `<Card title="\u2026" href="\u2026" icon="\u2026">` | side-by-side cards that stack on a phone; `icon` is a glyph from the model reference (a select option\'s mark), and a name it lacks is refused with the nearest |\n| a table whose header cells are all empty | a table with no header row (a key/value table) |\n| `<Accordion title="\u2026">`, `<AccordionGroup>`, or `<details>` with a one-line `<summary>\u2026</summary>` | a section that opens on press |\n\nEach tag stands on a line of its own, and a tag line inside a fenced code block is code. Attributes are text in double quotes or a number in braces; nothing in a tag runs. A write that adds a tag that does not close, nests wrongly, carries an attribute the tag does not take, or names a component this list lacks (`<Frame>`, `<CodeGroup>`) is refused with the lines to fix; lowercase HTML (`<br>`) stays text.\n\nA chart\'s numbers live in its mermaid source, which agents read and grep like any text: write them there once, not again in a table.\n\n## Reaching the tools\n\n```bash\nlotics knowledge list # catalog: id, name, tags, description\nlotics knowledge get kdc_... -o doc.md # read a body to a file (omit -o for stdout)\nlotics tools update_knowledge # full input schema for any knowledge tool\n```\n\nThe Knowledge category covers `list_knowledge`, `create_knowledge`, `update_knowledge`, and\n`delete_knowledge` \u2014 fronted by the `lotics knowledge list | create | get | update | tag | hide |\nunhide | rm` commands, and all but `delete_knowledge` reachable over MCP as well, so a corpus can be\nauthored from whichever surface you already work in. `list`, `tag`, `hide` and `unhide` go over REST rather than a tool, because\nthey are a person\'s view of the corpus: the tools answer what the ASSISTANT may browse, and a\nhidden doc is out of that set by definition. Sharing a doc to other members is `share_resource` / `unshare_resource` on the\nCLI or in chat (category **Admin**).\n';
46939
+ var knowledge_docs_default = '# Knowledge Docs\n\nKnowledge docs are the AI\'s **rulebook layer** \u2014 the workspace-specific facts and rules an\nagent can\'t know on its own: your price list, your product codes, your SOPs, your shipping\ntariffs, your glossary of in-house terms. You write them once; the agent **searches and reads\nthem on demand** while it works, pulling in only the lines it needs.\n\nThey are deliberately **not** injected into the agent wholesale. Instead the agent retrieves\nfrom them line by line \u2014 grep, then read \u2014 so a 10,000-line tariff book costs nothing until a\nquestion actually touches it, and a 10MB one is no different.\n\nThis is a **capability + usage** guide. For the exact input schema of any tool named here, run\n`lotics tools <tool_name>`.\n\n## Rulebook, not methodology\n\nPut in a knowledge doc the things the model **cannot guess**: your specific numbers, codes,\nnames, exceptions, and policies. Do **not** put in it general skills the model already has\n("how to write a polite email", "how to summarize"). If the agent would get it right without\nthe doc, the doc is noise.\n\n## Creating a doc \u2014 `lotics knowledge create`\n\n```bash\nlotics knowledge create --name "Shipping tariffs" \\\n --description "HS-coded rates; searchable by lane and code" \\\n --tags "HS,nh\u1EADp kh\u1EA9u" \\ # file it as you make it \u2014 see Classifying, below\n --from ./tariffs.md # the body is a Markdown file; or --content \'<inline>\'\n```\n\nThis reads the body from your filesystem and creates the doc through `create_knowledge`\n(prints the new id). A raw `lotics run create_knowledge @doc.json` works too \u2014 read a large\n`content` from a file or stdin. `create_knowledge` takes five fields, three of them required:\n\n- `name` \u2014 what it is. Just the name: **grouping belongs in `tags`, not in a prefix baked into\n every title.**\n- `description` \u2014 what the doc is **and what to grep it for**: its vocabulary and the synonyms\n an ambiguous query would use. Surfaced in the tree before any body is read, and truncated at\n 200 characters. See *Write for grep* below.\n- `content` \u2014 Markdown.\n- `tags` *(optional)* \u2014 the labels someone would filter by, e.g. `["H\u1ED3 s\u01A1 NOXH", "Kho\u1EA3n 13"]`.\n Give **every** one that is true of the doc, not just the main one \u2014 a corpus is narrowed by\n intersecting tags, so a doc carrying one label can only ever be found down one path. On\n `update_knowledge` the array **replaces** what is stored, so resend the labels it should keep.\n- `files` *(optional)* \u2014 file ids of the source `.docx`/`.pdf`/`.xlsx` the doc was built from,\n stored alongside it so a reader can open the original. The body stays the searchable text.\n\nA new doc is created **owned by you, active in your own agent context, and private** \u2014 no one\nelse can see it yet. Changing a doc\'s default-active state is done through the web app / REST\nsurface, not this tool.\n\n## Access\n\nA knowledge doc reaches an agent only when the member can `use` the doc. New docs are private\nto the owner; share them with other members or groups using the IAM tool `share_resource` on the CLI or in chat\n(category **Admin**; `unshare_resource` to revoke). An organization admin who is a person is no\nexception: they read a doc only once it is shared with them, or once they share it with\nthemselves, which the audit log records. An API key acting as itself keeps the access set on the\nkey.\n\n## How an agent uses a doc \u2014 ls, grep, cat\n\nDocs are not injected wholesale. The agent works the corpus like a filesystem, and all three\nverbs respect access, so only docs the caller may use ever surface.\n\n1. **`list_knowledge`** \u2014 `ls`. The corpus as a flat list: id, name, tags, and a truncated\n description; `tag` narrows it. No bodies.\n2. **`grep_knowledge`** \u2014 `grep -rn`. Match across every readable doc (or one doc, or one\n tag), returning **doc, line number, and the matching line**. It runs inside Postgres,\n so only matching lines cross the wire.\n3. **`read_knowledge`** \u2014 `cat` / `sed -n \'X,Yp\'`. Read a doc whole or by line range, to see\n the context around a hit.\n\nMatching is **not ranked** \u2014 there is no index and no tokenizer, which is why a corpus in any\nscript works and why nothing goes stale. The consequence is that the *agent* does the\nnarrowing: a distinctive phrase is sharply selective, a whole question matches everything.\n`grep_knowledge` always reports `total_matches`, so "too broad" is visible and cheap to fix.\n\n### Literal by default, expression on request\n\n`pattern` is **literal text** \u2014 every character matches itself. Set `regex: true` to read it as\na POSIX regular expression instead.\n\n```\ngrep_knowledge({ pattern: "C/O (Form E)" })\ngrep_knowledge({ pattern: "\\\\yNK\\\\y", regex: true, case_sensitive: true })\n```\n\nThe default is the safe one rather than the conventional one, and the asymmetry is worth\nknowing. As an expression, `C/O (Form E)` searches for `C/O Form E` \u2014 not in the document \u2014 and\nreturns a confident zero; `[C\u0168]` becomes a one-character class and matches every `C` in the\ncorpus. Both look like ordinary answers. A literal search has no such failure: the worst case\nis an expression sent without the flag, which finds nothing and says so, naming the search that\nran and what to pass instead. That last part is **measured, not guessed** \u2014 an empty result tells\nyou whether reading the pattern as an expression would have matched, so an alternation sent without\nthe flag (`phrase A|phrase B`) comes back naming `regex: true` rather than advising you to shorten a\npattern that was never too broad.\n\n### Matching options\n\n- **Tone marks match exactly by default.** `diacritic_insensitive: true` folds them, so `ca phe`\n matches `c\xE0 ph\xEA` \u2014 reach for it when the pattern was typed without them, not by habit: folding\n strips the pattern to ASCII, where a short Vietnamese word matches inside unrelated words\n (`m\xE3` finds `manifest`). An empty result tells you when folding would have matched.\n- **Case folds by default**, independently of diacritics. `case_sensitive: true` matches case\n exactly \u2014 useful for an acronym (`NK` vs `nk`) that a folded search would blur.\n- **Whitespace is normalized on both sides.** A body converted from PDF, Word or Excel carries\n non-breaking spaces, soft hyphens, zero-width marks and padded runs that nobody types into a\n query; those fold to ordinary single spaces before matching, so a correct search does not return\n a silent zero on text that is present. A literal pattern is normalized the same way; an\n expression is left byte-exact, since collapsing its whitespace would rewrite it.\n- **What `regex: true` supports**: quantifiers, character classes, alternation, anchors,\n backreferences, non-greedy forms, `(?i)`, and both lookahead and lookbehind. A word boundary\n is **`\\y`**, not `\\b` \u2014 Postgres spells it differently, and `\\b` is rewritten for you rather\n than silently matching nothing. Named groups and `\\p{\u2026}` are likewise rewritten to their POSIX\n equivalents. A `\\p{\u2026}` with no POSIX equivalent, and a malformed expression, are both refused\n with the reason.\n\nWhere an expression cannot express the question at all \u2014 a value that must be computed, a\nlayout matching cannot address \u2014 stage the doc into a code run instead (`code_exec`, in chat or an app agent, with\n`knowledge_doc_ids` puts it at `inputs/knowledge/<id>.md` as a real file).\n\n### What a result is bounded by\n\nA result carries at most 40.000 characters. Matched lines are admitted first and context fills\nwhat remains, so an answer is never dropped to make room for a neighbouring line. Anything the\nbudget cut is REPORTED \u2014 `chars_elided_matches` (an answer was dropped) and\n`context_omitted_matches` (an answer was kept without the context you asked for) mean different\nthings and call for different fixes: narrow the pattern, or ask for fewer `context_lines`.\nIndividual lines longer than 600 characters are clipped and marked, with `read_knowledge` giving\nthe rest.\n\n`total_matches` is always the true total, even when fewer are shown.\n\n## Write for grep \u2014 the rules that decide whether an answer is findable\n\nRetrieval addresses **lines**. Every rule below follows from that one fact.\n\n- **One self-contained fact per line.** A grep hit returns *that line*. For dense or tabular\n data \u2014 a price row, a tariff code, a charge entry \u2014 put the whole record on one line.\n- **Repeat the searchable terms on every line; headings do not carry down.** A line reading\n `Rate: 15%` under a heading `Roasted coffee` will never match a search for `coffee`. Restate\n the identifying terms inline, even when it reads redundantly to a human. This is the single\n rule most often got wrong.\n- **Keep a line under ~600 characters.** Past that the line is truncated in the agent\'s view\n and marked as cut; the agent can still `read_knowledge` for the rest, but it costs a round\n trip. Put the identifying terms early and the long tail late.\n- **Put synonyms and translations on the line itself.** A bilingual row (`C\xE0 ph\xEA, \u0111\xE3 rang /\n Coffee, roasted`) matches queries in either language for free. The same trick carries\n colloquial terms next to official ones.\n- **For prose, keep a rule and its exception close.** A hit returns one line, so a rule on line\n 40 and its exception on line 90 can be retrieved apart. Use `context_lines` when reading, and\n keep related clauses adjacent when writing.\n\nMarkdown headings are ordinary text \u2014 useful for a human (a doc\'s `##` headings outside its\nblocks, two or more, are listed on its page as its sections) and for orienting a\n`read_knowledge`, but they carry no retrieval weight of their own.\n\n**The description is the discovery hint.** It is what the agent sees in the tree before it has\nread a byte of the body, so it is the only clue for *what term to grep for*. Write it to name\nthe doc\'s vocabulary \u2014 the words that actually appear inside it. Keep it to a sentence: it is\ntruncated at 200 characters, and a keyword-stuffed description is cut, not rewarded.\n\n## Updating a doc \u2014 `lotics knowledge update`\n\n```bash\nlotics knowledge update kdc_... --from ./tariffs.md # replace the body (--name / --description too)\n```\n\nSend only the fields you\'re changing. `--from` / `--content` replaces the body; `--name` /\n`--description` / `--tags` change metadata. `update_knowledge` resolves concurrency **internally** \u2014 it\nre-reads the current content pointer and version-chains the new body \u2014 so there is no version\ntoken to pass from the CLI. (The chat agent may instead send an `edits` array \u2014 anchored\nreplace / insert / append \u2014 for a surgical change; see `lotics tools update_knowledge`.) Refine\nstructure as you learn what users actually ask: add the synonym that failed to match, split a\nline that was too coarse, restate a term the heading was carrying.\n\n## Classifying a corpus \u2014 tags\n\nA doc carries a SET of labels, not a folder. Order does not matter, a doc can wear several, and\nthe vocabulary is whatever the docs themselves use \u2014 a tag stops existing the moment nothing\ncarries it.\n\n```bash\nlotics knowledge update kdc_... --tags "HS,nh\u1EADp kh\u1EA9u" # ONE doc: state its complete set\nlotics knowledge tag kdc_a kdc_b --add HS --remove draft # MANY docs: a diff applied to each\n```\n\nThe split is deliberate. Looking at one doc you know what it should carry, so `--tags` replaces.\nAcross a set you do not \u2014 the docs carry different labels \u2014 so `tag` adds and removes against\neach doc\'s own set, and never states one classification for all of them. Removal ignores case;\nadding a label a doc already carries changes nothing.\n\nAn agent narrows by tag rather than reading the whole corpus:\n\n```bash\nlotics tools list_knowledge # takes an optional tag\nlotics tools grep_knowledge # same\n```\n\n## Taking a doc out of circulation \u2014 `hide`\n\n```bash\nlotics knowledge hide kdc_... # superseded, draft, source material\nlotics knowledge list --include-hidden # find it again\nlotics knowledge unhide kdc_... # put it back\n```\n\nA hidden doc is out of every **listing**: it is gone from the Library\'s list, from\n`list_knowledge`, and from the corpus `grep_knowledge` searches, so nothing finds it by browsing.\nIt is not deleted, not unshared, and still **reads when addressed by id** \u2014 `read_knowledge` with\nits id, a code run staging it, an app agent that declares it. That is the point: hiding is a\nstatement about discovery, so it cannot silently break an app that depends on a doc by name.\n\nReach for it instead of `rm` whenever the material still matters: last year\'s tariff schedule, a\nhandbook a newer one replaced, the raw source a curated doc was written from.\n\n## Deleting and restoring\n\n`lotics knowledge rm <id...>` deletes docs: they leave every listing, search and read. **Their sharing\nand version history are kept**, so a deleted doc is recoverable. `lotics knowledge list --deleted`\nshows the deleted docs you manage, and `lotics knowledge restore <id...>` brings them back with the\naccess they had (`restore_knowledge` in chat). A restore is all or none: every id must be a deleted\ndoc you manage, or nothing changes. A doc with no owner is restored only by an admin, who becomes its\nowner. A doc that an app or workflow still names cannot be deleted.\n\n## Formatting \u2014 what a doc draws\n\nA doc is markdown with GFM tables. Beyond that it draws a few blocks, each in syntax you already write; the platform draws them in its own style, so every doc looks alike.\n\n| Write | Draws |\n|---|---|\n| `> [!NOTE]`, `[!TIP]`, `[!IMPORTANT]`, `[!WARNING]` or `[!CAUTION]` opening a blockquote | a callout in that tone |\n| `<Note>`, `<Info>`, `<Tip>`, `<Check>`, `<Warning>`, `<Danger>` | the same callouts |\n| a ```mermaid fence | a diagram: flowchart, sequence, timeline, gantt, `pie`, `xychart-beta`, `quadrantChart` (a 2x2; quadrant-1, top right, is drawn as the target), in the platform\'s theme; a flowchart\'s or a 2x2\'s own colour statements are dropped |\n| `<Steps>` holding `<Step title="\u2026">` | a numbered walk |\n| `<Tabs>` holding `<Tab title="\u2026">` | switchable panels |\n| `<Columns cols={2}>` (or `<CardGroup>`) holding `<Card title="\u2026" href="\u2026" icon="\u2026">` | side-by-side cards that stack on a phone; `icon` is a glyph from the model reference (a select option\'s mark), and a name it lacks is refused with the nearest |\n| a table whose header cells are all empty | a table with no header row (a key/value table) |\n| `<Accordion title="\u2026">`, `<AccordionGroup>`, or `<details>` with a one-line `<summary>\u2026</summary>` | a section that opens on press |\n\nEach tag stands on a line of its own, and a tag line inside a fenced code block is code. Attributes are text in double quotes or a number in braces; nothing in a tag runs. A write that adds a tag that does not close, nests wrongly, carries an attribute the tag does not take, or names a component this list lacks (`<Frame>`, `<CodeGroup>`) is refused with the lines to fix; lowercase HTML (`<br>`) stays text.\n\nA chart\'s numbers live in its mermaid source, which agents read and grep like any text: write them there once, not again in a table.\n\n## Reaching the tools\n\n```bash\nlotics knowledge list # catalog: id, name, tags, description\nlotics knowledge get kdc_... -o doc.md # read a body to a file (omit -o for stdout)\nlotics tools update_knowledge # full input schema for any knowledge tool\n```\n\nThe Knowledge category covers `list_knowledge`, `create_knowledge`, `update_knowledge`,\n`delete_knowledge` and `restore_knowledge` \u2014 fronted by the `lotics knowledge list | create | get |\nupdate | tag | hide | unhide | rm | restore` commands, and all but `delete_knowledge` and\n`restore_knowledge` reachable over MCP as well, so a corpus can be\nauthored from whichever surface you already work in. `list` (without `--deleted`), `tag`, `hide` and `unhide` go over REST rather than a tool, because\nthey are a person\'s view of the corpus: the tools answer what the ASSISTANT may browse, and a\nhidden doc is out of that set by definition. Sharing a doc to other members is `share_resource` / `unshare_resource` on the\nCLI or in chat (category **Admin**).\n';
46940
46940
 
46941
46941
  // docs/migration.md
46942
46942
  var migration_default = "# @lotics/cli \u2014 migration notes\n\nWhat to DO when a release changes the shape of something the CLI once wrote to disk. Every other\ncontract is `docs/cli_reference.md`; this file is the one a reader opens once, because something\nalready on disk no longer matches what the CLI does.\n\n## Local app projects are gone\n\nAn app built from a model no longer lives in a directory. The live app is the only edit surface:\neach change mints a new version of it, and rolling back to an earlier version is the undo. What\nthe app is \u2014 its screens (`app.json`), its queries, workflows, agents and capabilities, and the\nmodel body it was compiled from \u2014 is held by that version, and the model's tables, fields, options,\ntemplates and roles are the workspace's own.\n\n| What you ran | What replaces it |\n|---|---|\n| `lotics scaffold check` + `scaffold apply` | `lotics model apply <model.json>` \u2014 checks the file, applies the tables and rows, then mints a version of every app it declares |\n| `lotics workspace build <model.json>` | `lotics model apply <model.json>` |\n| `lotics app create --from <model.json>#<app>` | `lotics model apply <model.json> --app <app>` |\n| `lotics app regenerate` | change the model file, then `lotics model apply` |\n| `lotics scaffold diff` | `lotics model check <model.json>` \u2014 what the apply would change, writing nothing |\n| `lotics scaffold export` | `lotics model pull [-o <model.json>]` \u2014 the workspace's model, rebuilt from what owns each part |\n| `lotics app codegen`, `app check`, `app dev`, `app preview` | nothing local: read an app with `lotics run get_app`, and change it through its tools |\n| `lotics app workflow set` / `app query set` / `app agent set` | `lotics run set_app_workflow` / `set_app_queries` / `set_app_agent` \u2014 each mints a version |\n| `lotics app versions` + a redeploy of an old tree | `lotics run query_app_versions`, then `lotics run rollback_app` \u2014 restores the app's earlier version; table changes and data writes stay |\n| `lotics app rename`, `app subdomain`, `package.json#lotics.capabilities` | `lotics run update_app` (`name`, `public_subdomain`, `capabilities`) |\n| `lotics app api publish` / `unpublish` | `lotics run publish_app_api` / `unpublish_app_api` |\n| `lotics field rename` | `lotics run update_table`, then the same label in the model file |\n| `lotics app kit` | nothing: a custom-code app depends on `@lotics/app-sdk` (below) |\n| `@lotics/xlsx` / `@lotics/docx` to build a template's file | any `.xlsx` / `.docx` library, then `lotics upload` (`lotics docs document_templates`) |\n| `lotics file preview` | `lotics file download <fil_id>`, then open it |\n| `model.json#apply` (`[{package, bind}]`) | state the tables under `entities` \u2014 a model carrying `apply` is refused |\n| `lotics library list` / `library show <slug>`, `lotics preset list` / `preset show <slug>` | the example models at `https://lotics.ai/presets/index.json`, each a complete `model.json` to adapt |\n| `model.json#from` (with `variants`, `rename`) | state every table under `entities` \u2014 a model carrying `from` is refused |\n| `lotics library init <apg_id>`, `lotics setup <apg_id>`, `lotics app upgrade`, `lotics library fixtures` | nothing copies a package any more: write a `model.json` (`lotics docs model`), then `lotics setup model.json` or `lotics model apply model.json` |\n\n`lotics tools` lists every tool, and `lotics tools <name>` prints its input.\n\n**An existing JSON app moves on the first `lotics model apply`** of the model it came from: that\napply binds each app alias to the app it already became and mints its next version from the file.\nA directory an earlier CLI wrote (`app.json`, `src/workflows/`, `.lotics/`, `package.json#lotics`\nwith `plan`, `queries` or `workflows`) is no longer read by anything; keep the model file, delete the\nrest.\n\n**Authored workflow bodies** an act names (`workflow` on an act) are the live workflow's own body:\nthe apply keeps it, and `lotics run set_app_workflow` changes it.\n\n**A model written before `party`** draws its people and organisations as things \u2014 a row with no\npicture shows no initials: state `party` (`person` or `organization`) on those entities' `records`.\n\n## Custom-code apps use `@lotics/app-sdk`\n\nA hand-written app depends on `@lotics/app-sdk` alone \u2014 the hooks (`useQuery`, `useWorkflow`, \u2026),\n`mount`, and `AppRouter` from `@lotics/app-sdk/router` \u2014 and draws with its own React.\n`@lotics/app-runtime` and `@lotics/ui` are no longer published: the runtime is what the platform\ndraws a model's app with, and it ships with the platform.\n\nAn app that listed `@lotics/app-runtime`:\n\n1. **The dependency.** `@lotics/app-sdk` replaces `@lotics/app-runtime` in `package.json`.\n2. **The import specifiers.** `@lotics/app-runtime/sdk` becomes `@lotics/app-sdk`, and\n `@lotics/app-runtime/router` becomes `@lotics/app-sdk/router` \u2014 in `src/` and in\n `vite.config.ts`.\n3. **The kit.** An app that imports `@lotics/ui` keeps the version it already installed; no newer one\n is published. Its screens can stay on it, or move to plain React (or any library) one screen at a\n time.\n4. **`package.json#lotics`** needs only `app_id`, `workspace_id` and `current_version_id`. The live\n app owns its `queries`, `workflows`, `agents` and `capabilities`, and `lotics app deploy` refuses\n a directory that still declares any of them; delete them, and the unread `synced` block.\n5. `lotics app deploy` builds the directory and uploads it as a new version.\n\nA deployed app keeps running on the bundle it shipped until it is deployed again.\n";
@@ -47504,8 +47504,9 @@ var COMMANDS = [
47504
47504
  {
47505
47505
  verbs: ["knowledge"],
47506
47506
  help: [
47507
- " lotics knowledge list [--include-hidden]",
47508
- " List knowledge docs (id, name, tags, description)",
47507
+ " lotics knowledge list [--include-hidden | --deleted]",
47508
+ " List knowledge docs (id, name, tags, description);",
47509
+ " --deleted lists the deleted ones you can manage",
47509
47510
  " lotics knowledge create --name <n> [--description <d>] [--tags <a,b>] --from <file.md>",
47510
47511
  " Create a doc from a file (or --content <str>);",
47511
47512
  " prints the new id",
@@ -47522,7 +47523,8 @@ var COMMANDS = [
47522
47523
  " Library list and from what the assistant browses",
47523
47524
  " and searches, still readable by id",
47524
47525
  " lotics knowledge unhide <id...> Put them back",
47525
- " lotics knowledge rm <id> Archive a doc"
47526
+ " lotics knowledge rm <id...> Delete docs; sharing and history are kept",
47527
+ " lotics knowledge restore <id...> Bring deleted docs back, all or none"
47526
47528
  ]
47527
47529
  },
47528
47530
  {
@@ -48658,6 +48660,7 @@ function parseArgs(argv2) {
48658
48660
  stdin: false,
48659
48661
  base64: false,
48660
48662
  includeHidden: false,
48663
+ deleted: false,
48661
48664
  local: false,
48662
48665
  wait: false,
48663
48666
  all: false,
@@ -48755,6 +48758,9 @@ function parseArgs(argv2) {
48755
48758
  case "--include-hidden":
48756
48759
  flags.includeHidden = true;
48757
48760
  break;
48761
+ case "--deleted":
48762
+ flags.deleted = true;
48763
+ break;
48758
48764
  case "--timezone":
48759
48765
  flags.timezone = takeValue(arg, "<Area/City>");
48760
48766
  break;
@@ -50052,6 +50058,10 @@ function unwrap(res) {
50052
50058
  return res.result;
50053
50059
  }
50054
50060
  async function knowledgeList(client, flags) {
50061
+ if (flags.deleted) {
50062
+ if (flags.includeHidden) fail("--deleted and --include-hidden cannot be combined.");
50063
+ return knowledgeListDeleted(client, flags);
50064
+ }
50055
50065
  const docs = await client.listKnowledgeDocs({ includeHidden: flags.includeHidden });
50056
50066
  if (flags.json) {
50057
50067
  console.log(JSON.stringify(docs, null, 2));
@@ -50085,6 +50095,39 @@ async function knowledgeList(client, flags) {
50085
50095
  console.log(`${pad(r.id, wId)} ${pad(r.name, wName)} ${pad(r.tags, wTags)} ${description}`.trimEnd());
50086
50096
  }
50087
50097
  }
50098
+ function isDeletedDoc(value) {
50099
+ if (typeof value !== "object" || value === null) return false;
50100
+ return "id" in value && typeof value.id === "string" && "name" in value && typeof value.name === "string" && "deleted_at" in value && typeof value.deleted_at === "string";
50101
+ }
50102
+ async function knowledgeListDeleted(client, flags) {
50103
+ const result = unwrap(await client.execute("list_knowledge", { deleted: true }, { format: "json" }));
50104
+ const listed = typeof result === "object" && result !== null && "results" in result && Array.isArray(result.results) ? result.results : null;
50105
+ if (listed === null) fail("list_knowledge returned no results list.");
50106
+ const docs = listed.filter(isDeletedDoc);
50107
+ if (docs.length !== listed.length) fail("list_knowledge returned a deleted doc without its id, name or deleted_at.");
50108
+ if (flags.json) {
50109
+ console.log(JSON.stringify(docs, null, 2));
50110
+ return;
50111
+ }
50112
+ if (docs.length === 0) {
50113
+ console.error("No deleted knowledge docs you can restore.");
50114
+ return;
50115
+ }
50116
+ const rows = docs.map((doc) => ({
50117
+ id: doc.id,
50118
+ name: doc.name,
50119
+ deleted: doc.deleted_at,
50120
+ description: (doc.description ?? "").replace(/\s+/g, " ").trim()
50121
+ }));
50122
+ const wId = Math.max("ID".length, ...rows.map((r) => r.id.length));
50123
+ const wName = Math.max("NAME".length, ...rows.map((r) => r.name.length));
50124
+ const wDeleted = Math.max("DELETED".length, ...rows.map((r) => r.deleted.length));
50125
+ const pad = (s, n) => s.padEnd(n);
50126
+ console.log(`${pad("ID", wId)} ${pad("NAME", wName)} ${pad("DELETED", wDeleted)} DESCRIPTION`);
50127
+ for (const r of rows) {
50128
+ console.log(`${pad(r.id, wId)} ${pad(r.name, wName)} ${pad(r.deleted, wDeleted)} ${r.description}`.trimEnd());
50129
+ }
50130
+ }
50088
50131
  async function knowledgeCreate(client, flags) {
50089
50132
  const name = flags.name;
50090
50133
  if (!name) {
@@ -50150,14 +50193,23 @@ async function knowledgeUpdate(client, id, flags) {
50150
50193
  }
50151
50194
  console.error(`Updated knowledge doc: ${id}`);
50152
50195
  }
50153
- async function knowledgeRm(client, id, flags) {
50154
- if (!id) fail("Usage: lotics knowledge rm <id>");
50155
- const result = unwrap(await client.execute("delete_knowledge", { knowledge_doc_id: id }));
50196
+ async function knowledgeRm(client, ids, flags) {
50197
+ const knowledge_doc_ids = requireIds(ids, "Usage: lotics knowledge rm <id...>");
50198
+ const result = unwrap(await client.execute("delete_knowledge", { knowledge_doc_ids }));
50199
+ if (flags.json) {
50200
+ console.log(JSON.stringify(result, null, 2));
50201
+ return;
50202
+ }
50203
+ console.error(`Deleted ${knowledge_doc_ids.length} knowledge doc(s): ${knowledge_doc_ids.join(", ")}. lotics knowledge restore brings them back.`);
50204
+ }
50205
+ async function knowledgeRestore(client, ids, flags) {
50206
+ const knowledge_doc_ids = requireIds(ids, "Usage: lotics knowledge restore <id...>");
50207
+ const result = unwrap(await client.execute("restore_knowledge", { knowledge_doc_ids }));
50156
50208
  if (flags.json) {
50157
50209
  console.log(JSON.stringify(result, null, 2));
50158
50210
  return;
50159
50211
  }
50160
- console.error(`Deleted knowledge doc: ${id}`);
50212
+ console.error(`Restored ${knowledge_doc_ids.length} knowledge doc(s): ${knowledge_doc_ids.join(", ")}.`);
50161
50213
  }
50162
50214
  function requireIds(ids, usage) {
50163
50215
  const parsed = ids.filter((id) => id !== void 0).flatMap((id) => id.split(",")).map((id) => id.trim()).filter((id) => id !== "");
@@ -50197,8 +50249,9 @@ async function knowledgeSetHidden(client, ids, hidden, flags) {
50197
50249
  function printKnowledgeHelp() {
50198
50250
  console.error(`Lotics knowledge commands \u2014 manage the AI's rulebook docs.
50199
50251
 
50200
- lotics knowledge list [--include-hidden] [--json]
50201
- Catalog every doc you can use (id, name, tags, description).
50252
+ lotics knowledge list [--include-hidden | --deleted] [--json]
50253
+ Catalog every doc you can use (id, name, tags, description). --deleted
50254
+ lists the deleted docs you can manage instead.
50202
50255
  lotics knowledge create --name <n> [--description <d>] [--tags <a,b>] (--from <file.md> | --content <str>) [--json]
50203
50256
  Create a doc; the body comes from a file or an inline string. Prints the new id.
50204
50257
  lotics knowledge get <id> [-o <file.md>] [--json]
@@ -50213,8 +50266,10 @@ function printKnowledgeHelp() {
50213
50266
  the assistant browses and searches, still readable by id, nothing deleted.
50214
50267
  lotics knowledge unhide <id...> [--json]
50215
50268
  Put them back.
50216
- lotics knowledge rm <id> [--json]
50217
- Archive a doc.`);
50269
+ lotics knowledge rm <id...> [--json]
50270
+ Delete docs. Their sharing and history are kept.
50271
+ lotics knowledge restore <id...> [--json]
50272
+ Bring deleted docs back, all or none.`);
50218
50273
  }
50219
50274
  async function runKnowledgeCommand(client, subcommand, toolArgs, flags, restArgs = []) {
50220
50275
  switch (subcommand) {
@@ -50233,7 +50288,9 @@ async function runKnowledgeCommand(client, subcommand, toolArgs, flags, restArgs
50233
50288
  case "unhide":
50234
50289
  return knowledgeSetHidden(client, [toolArgs, ...restArgs], false, flags);
50235
50290
  case "rm":
50236
- return knowledgeRm(client, toolArgs, flags);
50291
+ return knowledgeRm(client, [toolArgs, ...restArgs], flags);
50292
+ case "restore":
50293
+ return knowledgeRestore(client, [toolArgs, ...restArgs], flags);
50237
50294
  default:
50238
50295
  if (subcommand) fail(`Unknown knowledge subcommand: ${subcommand}`);
50239
50296
  printKnowledgeHelp();
@@ -37,13 +37,14 @@ Per-command syntax, flags, contracts, and gotchas for the public `lotics` CLI. S
37
37
  | `lotics file download <file_id> [<path>]` · `-o <dir>` | (alias `lotics download`) Download a stored file: `GET /v1/files/{id}/signed_url` → 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 — 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 — 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_key> [-o <dir>]` spreads every file on a record's file field over a DIRECTORY — there is no single file for N files to be. |
38
38
  | `lotics file list [--limit <n>] [--cursor <token>]` | The workspace's files, newest first — 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 — pass it back as given — 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. |
39
39
  | `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** — 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. |
40
- | `lotics knowledge list [--include-hidden]` | `GET /v1/knowledge_docs` — a table of id, name, tags, description (`--json` for the docs). **REST, not the `list_knowledge` tool**: 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)`. |
40
+ | `lotics knowledge list [--include-hidden \| --deleted]` | `GET /v1/knowledge_docs` — a table of id, name, tags, description (`--json` for the docs). **REST, not the `list_knowledge` tool**: 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 `list_knowledge` with `deleted: true` and prints the deleted docs you can manage (id, name, deleted time, description); it cannot be combined with `--include-hidden`. |
41
41
  | `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 — exactly one required), then call `create_knowledge` with `{ name, description, content, tags? }` (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. |
42
42
  | `lotics knowledge get <id> [-o <file.md>]` | `GET /v1/knowledge_docs/{id}` (`getKnowledgeDoc`) → the doc with its **hydrated `content`** (the one content-read path for a non-sandbox client). `-o` writes the body via `writeFileAtomic`; else the body goes to stdout. `--json` prints the full doc instead. |
43
43
  | `lotics knowledge update <id> [--from <file.md> \| --content <str>] [--name <n>] [--description <d>] [--tags <a,b>]` | Call `update_knowledge` with **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 — 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. |
44
44
  | `lotics knowledge tag <id...> [--add <a,b>] [--remove <c,d>]` | `PATCH /v1/knowledge_docs` with `{ knowledge_doc_ids, add_tags?, remove_tags? }` — 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. |
45
45
  | `lotics knowledge hide <id...>` / `lotics knowledge unhide <id...>` | `PATCH /v1/knowledge_docs` with `{ knowledge_doc_ids, hidden }`. Hiding takes docs out of every **listing** — the Library's list, `list_knowledge`, and the corpus `grep_knowledge` searches — 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". |
46
- | `lotics knowledge rm <id>` | Archive the doc via `delete_knowledge` (`{ knowledge_doc_id }`). The REST execute path does not gate `needsApproval`, so this runs unattended. |
46
+ | `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. |
47
+ | `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. |
47
48
  | `lotics setup <model.json> [--email <addr>] [--json]` | **The whole first run, in one command.** Creates an account when this machine has no credential (the same call `auth signup` makes — `--name` and `--timezone` apply), then applies the model to its workspace, then prints the one-time sign-in link. **When that email already has an account it hands over to the `lotics auth login` flow** — it prints the sign-in page to open and the code it must show, and **exits 1 having created nothing**; the person presses Confirm and runs the same command again, which collects the key and carries on into the model. (`--wait` holds the terminal through the Confirm instead, finishing in one command.) The re-run is not refused for naming an `--email` it is now signed in as — that address IS the account it holds, not a second one. The file is a workspace MODEL, and it is read and checked before an account is created — the design decisions an apply would refuse it for too (`lotics docs design`), since a new workspace holds no rows to change them — because a file with a typo in it must not leave an organization behind. Then it is `lotics model apply` run on the new workspace: its tables, rows and apps; the sign-in link lands on its app when it has one, else on the workspace's app list. It exists because the two-command form has a seam where the FIRST command exists only to produce a credential for the second, and a caller pasting a prompt has to get both right. **`--email` is only for creating an account**: with a credential already resolvable it is REFUSED rather than obeyed, because the two can name different organizations and preferring either one silently writes into an org the caller did not name — the message says how to do each thing on purpose. Without it, `setup` applies the model to the account you already have. A path positional after the file is accepted and IGNORED with a warning — `setup` writes nothing to disk — so a prompt that passes one still runs. **`--json` prints one object on stdout and nothing else** — what `lotics model apply --json` does (`tables`; `apps`, each with `alias`, `app_id`, `version_id`, `origin`, `address` and `findings`; and `findings`) plus `organization_id`, `workspace_id` and `signin_url`, and a `warnings` array carrying everything the prose form would have said out of band, such as a sign-in link that could not be minted. A warning is never merely silenced: when the command fails with an error before it can emit, the ones it had collected go to stderr alongside it. Reachable with no install: `npx -y @lotics/cli setup …`. |
48
49
  | `lotics model apply <model.json> [--app <alias> ...] [--json]` | **The model, applied to this workspace, through the `apply_model` tool.** The file is read and checked against the model's own rules with the validator the server runs, every problem in one run, before anything is uploaded. **The apply refuses an app leaving out a treatment its rows call for** (`lotics docs design`) — judged by the server beside the workspace's rows, before it writes anything — until the app adopts it or states why not under the `declines` key the refusal names; the refusal carries each refused app's patch adopting its decisions, to merge in the order printed. Where the model's own rules pass, one refusal carries everything decidable before the first write — those decisions, what compiling each app finds, a field that cannot leave its table and what the caller may not change — so one pass fixes them. Documents a row attaches by a path beside the file are uploaded first and the rows sent with their `fil_` ids; a path this workspace already recorded keeps its id, so a re-apply uploads nothing twice. **Where the file was pulled (`-o`) or applied in this workspace before, only what it changed since is sent**, as a `patch`: what changed in the workspace since and the file does not touch stays. An item named by its `alias` that the file puts in a new place — an act among an app's acts, a field between two others — is sent as a move beside the patch: inside an app it is built into the app, an option of a select takes its new place among that select's options on the table, and a table's fields and views, the roles, the templates and the apps themselves keep the order the workspace holds, said as a note. A list whose items carry no `alias` — an app's columns, a section's fields, the sections — is sent whole in its new order. What the file takes out is sent as a removal: it goes where an apply rebuilds it (an app's acts and columns, a write rule), a field leaves its table, and it is refused, naming the tool that deletes it, where an apply never deletes it (a table, a view, an app); an option leaves every app and stays on its table, said in the notes. **A field the file no longer states leaves its table** — a field an earlier apply stated, or one the pulled file stated — only where no live row holds a value in it and nothing outside the model's own apps reads it: no formula, rollup or lookup, no figure's unit or currency, no `default_from` copy, no unique set, no query, agent, table check or workflow body of the workspace. A row in the trash does not count, since no write can clear it: its value goes with the field, and the notes say how many rows it went for. Otherwise nothing is applied and the refusal names the field, how many live rows hold a value and what reads it; a workflow body the author wrote on an app the apply rebuilds that still names the field keeps it on the table, said in the apply's findings, and the next apply sends its removal again until nothing names it. A field such an app names leaves only once the app is rebuilt, with each leaving field it reads, so where one of them reads a field the file changes, nothing is applied and the refusal names both: apply the file without that change first. A field of the table no model ever stated is left as it is. **A label the file changes is a rename**: a field's and a select option's label, and a table's description, are written onto the table as the file states them, each named in the notes. **A declared workflow whose `inputs` or `outputs` the file changes** keeps the body set on it where the body still meets them; where it does not, the apply lands and the workflow stands on no body again — every call refused — until `set_app_workflow` sets one that does, said on the app's line. A file stating a `null` a patch would read as a removal sends the whole model, said on stderr, and so does a reorder sent to a server that takes no move. A patch builds the apps it states or moves an item of, each other app whose build it changes and each app whose live version names a field or option its tables no longer hold, `--app` naming others; one whose every key and move lies inside one app's body leaves the tables as they stand — its `tables` is empty — and checks and builds that app alone, beside any other app whose build reads what it changed. The copy each change is read against is kept per workspace and file under `~/.lotics/model_bases`: an apply narrowed by `--app` leaves in it the other apps as they were, so the next apply sends their edits again, and an apply that fails — refused, or cut off — leaves none, so the next sends the whole model. Then the tool adopts or creates every table (the table this workspace bound the entity to, else an existing table of the same label, is ADOPTED and given the fields, options and views it lacks; no stored value changes), writes first rows only where every bound table is empty, and mints a new version of each app the model declares, or the patch builds — `--app` (repeatable, comma-separated) narrows which apps, while the tables are applied whole. Prints one line per app it built — alias, `app_id`, `created`/`updated` with the version minted or `unchanged`, and the address it is served at — then the model's notes once, as the server states them. **What it would change, writing nothing, is `lotics model check`.** **A rollback restores an app's earlier version** (`lotics run rollback_app`); table changes and data writes stay. Resolves and ANNOUNCES its workspace first. `--json` prints `{workspace_id, tables, apps, findings}` on stdout, or `{ok: false, findings}` when the file does not check. |
49
50
  | `lotics model check <model.json> [--app <alias> ...] [--json]` | **What applying the model would change, through the `check_model` tool, writing nothing.** The file is read and checked as `model apply` checks it, then sent the same way — whole, or as what it changed since its base — and nothing is uploaded or kept. It prints what the apply would do to the schema (what it would add, each kind counted — tables, fields, options, constraints — what it leaves as the workspace has it, what the two disagree about), which apps it would create or update, what it would refuse an app for — the decisions among its findings, read beside the workspace's rows, with each refused app's patch — and what it would refuse of the model beside its apps (a field that cannot leave its table, a template nothing stores), all in the one run, exiting 1 when it would refuse anything; its notes name each field it would take off a table and each label it would rename; workflow bodies are checked only at apply, since they name fields a check has not created. `check_model` only reads, so a key holding read access alone runs this where `model apply` is refused, and so does a private instance whose licence admits reads only: it needs `schema:read`, and `data:read` when the file states an app or is sent as what it changed. Resolves and ANNOUNCES its workspace first. `--json` prints `{workspace_id, tables, apps, findings}` — each table and app what the apply would do, an app the patches change carrying its body as they leave it as `draft` — and `designs`, each refused app's patch in merge order, or `{ok: false, findings}` when the file does not check. |
@@ -215,6 +215,15 @@ statement about discovery, so it cannot silently break an app that depends on a
215
215
  Reach for it instead of `rm` whenever the material still matters: last year's tariff schedule, a
216
216
  handbook a newer one replaced, the raw source a curated doc was written from.
217
217
 
218
+ ## Deleting and restoring
219
+
220
+ `lotics knowledge rm <id...>` deletes docs: they leave every listing, search and read. **Their sharing
221
+ and version history are kept**, so a deleted doc is recoverable. `lotics knowledge list --deleted`
222
+ shows the deleted docs you manage, and `lotics knowledge restore <id...>` brings them back with the
223
+ access they had (`restore_knowledge` in chat). A restore is all or none: every id must be a deleted
224
+ doc you manage, or nothing changes. A doc with no owner is restored only by an admin, who becomes its
225
+ owner. A doc that an app or workflow still names cannot be deleted.
226
+
218
227
  ## Formatting — what a doc draws
219
228
 
220
229
  A doc is markdown with GFM tables. Beyond that it draws a few blocks, each in syntax you already write; the platform draws them in its own style, so every doc looks alike.
@@ -242,10 +251,11 @@ lotics knowledge get kdc_... -o doc.md # read a body to a file (omit -o for std
242
251
  lotics tools update_knowledge # full input schema for any knowledge tool
243
252
  ```
244
253
 
245
- The Knowledge category covers `list_knowledge`, `create_knowledge`, `update_knowledge`, and
246
- `delete_knowledge` — fronted by the `lotics knowledge list | create | get | update | tag | hide |
247
- unhide | rm` commands, and all but `delete_knowledge` reachable over MCP as well, so a corpus can be
248
- authored from whichever surface you already work in. `list`, `tag`, `hide` and `unhide` go over REST rather than a tool, because
254
+ The Knowledge category covers `list_knowledge`, `create_knowledge`, `update_knowledge`,
255
+ `delete_knowledge` and `restore_knowledge` — fronted by the `lotics knowledge list | create | get |
256
+ update | tag | hide | unhide | rm | restore` commands, and all but `delete_knowledge` and
257
+ `restore_knowledge` reachable over MCP as well, so a corpus can be
258
+ authored from whichever surface you already work in. `list` (without `--deleted`), `tag`, `hide` and `unhide` go over REST rather than a tool, because
249
259
  they are a person's view of the corpus: the tools answer what the ASSISTANT may browse, and a
250
260
  hidden doc is out of that set by definition. Sharing a doc to other members is `share_resource` / `unshare_resource` on the
251
261
  CLI or in chat (category **Admin**).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/cli",
3
- "version": "0.315.16",
3
+ "version": "0.315.17",
4
4
  "description": "Lotics SDK and CLI for AI agents",
5
5
  "type": "module",
6
6
  "bin": {