@lotics/cli 0.316.26 → 0.316.27

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/src/cli.js CHANGED
@@ -1776,7 +1776,7 @@ async function saveSignedLinks(links, dir) {
1776
1776
  }
1777
1777
 
1778
1778
  // src/version.ts
1779
- var VERSION = "0.316.26";
1779
+ var VERSION = "0.316.27";
1780
1780
 
1781
1781
  // src/timezone.ts
1782
1782
  function machineTimezone() {
@@ -1825,7 +1825,7 @@ var building_an_app_default = '# Building an app, end to end\n\nThe other refere
1825
1825
  var cli_reference_default = "# @lotics/cli \u2014 CLI Command Reference\n\nPer-command syntax, flags, contracts, and gotchas for the public `lotics` CLI. Start at [AGENTS.md](../AGENTS.md) for the model this reference assumes; `lotics --help` is the authoritative, always-current verb list.\n\n| Command | What it does |\n|---|---|\n| `lotics` / `lotics --help` | Show full help: capabilities, the verb list (\xA7 COMMANDS), flags, config. `lotics <verb> --help` prints that verb's entries alone (`lotics app --help`, `lotics file download --help`); `lotics report --help` prints the report frame. |\n| `lotics auth signup <email>` | Create account + org + API key, sends magic link email. Registers the new org as a profile; `--local` pins this directory to it (pointer) instead of setting the global default. |\n| `lotics auth login <email>` | Sign in an account that already exists, on a machine holding no key. **Two steps, and it does not wait for the person.** The first prints the page to open \u2014 `https://lotics.ai/cli_login/<request_id>`, also mailed \u2014 and the code that page must show, records the request, and exits 0. They sign in there if asked, check the code and press Confirm. **Then the next command that needs a credential collects the key** before it does its own work, so the second step is just re-running whatever was wanted; a command run before Confirm exits 1 naming the page and the code again, and once the 15 minutes are up it says to ask again. The handful that run WITHOUT a credential \u2014 `docs` among them \u2014 claim nothing, so one of those run after Confirm still answers as though signed out. `--wait` keeps one command instead, holding the terminal until Confirm; `--local` pins this directory to that org rather than setting the global default, and implies `--wait` (a pin names THIS directory, so only the terminal that stays in it can write one). `--json` prints `organization_id`, `workspace_id` and `organization_name` when it finishes signed in, and `request_id`, `confirm_url`, `code`, `email`, `expires_at` when it is the first step. The request's secret is never printed and the org's key never leaves the store. |\n| \u2014 | **A flag is refused on a verb that does not read it**, naming the verbs that do, before any request or credential: `--check` on `lotics run` would otherwise be parsed and ignored, and the write it was meant to stop would run. `--json`, `--workspace`, `--api-key` and `--timeout` are read by every verb. |\n| `lotics auth api-key [key]` | `whoami` \u2192 **upsert** the key's org as a profile in the global store (never overwrites). The profile records the instance the key was verified against (`LOTICS_API_URL`, default `https://api.lotics.ai`), and every later command for that org goes there. `--local` additionally pins this directory to it (pointer) instead of setting the global default. |\n| `lotics auth web` | Send a magic link email to access the web app (requires auth) |\n| `lotics auth whoami` | Print active account name, email, org, resolved workspace, the instance the credential belongs to, which **kind** of credential this machine holds (a sign-in from `auth login`, or an API key \u2014 read from the saved profile, and from the server when the profile does not say, which covers `--api-key`/`LOTICS_API_KEY` and a profile saved before the field existed; unknown only when neither can say), and the resolution **source** (flag/env/local/app-manifest/global). `--json` adds `workspace_id`, `api_url`, `credential_kind` + `source`. |\n| `lotics auth logout [<name\\|id>]` | In a pinned dir: delete the local pin. Else: remove the profile (default the active org), `--all` for every one. What happens server-side depends on which KIND of credential it is. A **sign-in** (`auth login` / `auth signup`) is revoked \u2014 logging that terminal out ends its credential rather than leaving a live one behind; a server that cannot be reached, or a credential already dead, never blocks the local forget, and one line names the org and Settings \u2192 Security \u2192 *Keys and terminals*. An **API key** (`auth api-key`) is only forgotten here \u2014 an admin issued it and it is routinely on a server and on other machines, so one terminal signing out must not kill it for everyone; the line says it is still active and names both pages, because Settings \u2192 API keys is admin-only and the credential may well be the holder's own sign-in, which they revoke themselves at Settings \u2192 Security \u2192 *Keys and terminals*. A profile saved before the kind was recorded states nothing, so the SERVER is asked (`auth whoami`) and it is revoked only if the answer is a sign-in: an older server, a credential minted before the column, and a request that fails all leave it alone. |\n| \u2014 | **A refused credential says which of three ways it is dead, and names the remedy that ends its kind.** `This credential expired.` / `was revoked.` / `belongs to a member who is no longer active in this organization.` carries `Run \\`lotics auth login <email>\\` to sign in again.` for a sign-in and `Ask an admin for a new API key (Settings \u2192 API keys).` for an issued key. A credential minted before that was recorded still gets BOTH in one sentence, because nothing on the row tells them apart \u2014 so a headless box is never sent looking for a browser alone. A key the server does not recognize at all gets one flat `Invalid or disabled API key.` \u2014 deliberately, so a guessed key learns nothing, not even that it named a row. The body carries `reason` for a script to branch on, since the code stays `unauthorized` for every 401. |\n| `lotics org` | List saved orgs (profiles) from the global store with the instance each belongs to, marks active for this directory (a local pin wins over the global default). |\n| `LOTICS_ORG=<name\\|id>` | Scope every command in this shell to one saved org. **Resolved once, before any command dispatches**, so a value matching no saved credential refuses every verb with one sentence \u2014 a read, a write, and a local check that needs no credential alike \u2014 and refuses it before the first byte is written. It refuses even when a credential arrives another way, because `--api-key` / `LOTICS_API_KEY` outrank it in the precedence chain and a write must never fall through to whatever THOSE name while the variable says otherwise; when the variable resolves and a key is also given, the key decides and the command says so. The refusal lists the orgs this machine holds, so it is answerable without another command (`lotics org` is refused by the same rule). A name is whatever the credential was SAVED under \u2014 a server-side rename never moves it, and the new name resolves too, so both keep working and `lotics org` prints the pair. |\n| `lotics org use <name\\|id> [--local]` | Switch the active org by org name (case-insensitive, ambiguous \u2192 error) or id. No flag \u2192 global `active_org`; `--local` \u2192 a `.lotics/config.json` pointer in the current dir. |\n| `lotics workspace` | List workspaces in the active org, marks current with `(current)` |\n| `lotics workspace select <id>` | Set the workspace in the **active scope** \u2014 a local pin if the dir has one, else the active org's global profile. Records the workspace's NAME beside its id, which is what the `lotics \u2192 <org> / <workspace>` echo prints; `workspace list`, `workspace create`, `workspace rename` and `org use` record it too, so a target is named rather than identified. Until one command has listed it, the echo prints the id and says the name is not known yet. |\n| `lotics workspace create <name> [--timezone <Area/City>] [--currency <ISO>]` | Create a new workspace (admin only), auto-switches to it. Neither flag is defaulted from THIS machine, unlike signup: an extra workspace is routinely created by an operator for somebody else. Without `--timezone` the new workspace inherits the zone of the org's OLDEST workspace; without `--currency` it takes the org's default. Both ride the create, so the workspace is never briefly denominated in a currency nobody asked for. `--currency` takes an ISO-4217 code (case-insensitive; anything else is refused). |\n| `lotics workspace rename <name>` | Rename the **current** workspace (admin only) \u2014 the endpoint takes its target from the request's workspace, never a path id, so switch with `workspace select <id>` first and read the `lotics \u2192 <org> / <workspace>` echo before trusting it \u2014 both halves are names, and the rename moves the cached one in the same act. Carries the workspace's existing `default_currency` and `timezone` through unchanged: the endpoint takes the whole settings triple, so sending only a name would blank the other two. |\n| `lotics workspace settings [--name <n>] [--currency <ISO>] [--timezone <Area/City>]` | Change the CURRENT workspace's name, default currency or timezone \u2014 `PATCH /v1/workspace`, admin only. Only what you name changes; the endpoint takes the whole triple, so the CLI carries the two you did not. `rename` is this verb with the name alone, which is why it can never forget the other two. Both values are invisible once they are wrong: the currency decides how every money field RENDERS and the zone decides how every date BUCKETS, on a workspace whose whole purpose may be to look like the customer's own. `--json` prints the updated workspace. |\n| `lotics workspace delete <id> --yes` | Delete a workspace by id (admin only). **Soft delete** \u2014 `archived_at` is set, so it drops out of listings, can no longer be selected, and its tables/records go dark, while the data is retained and recoverable. Its **apps are cascade-archived** too \u2014 every app entry point (embedded, public link, standalone subdomain, incl. anonymous public links) stops serving. Refuses the org's **only** active workspace (400) and any workspace outside the caller's org (404). Requires `--yes` to confirm (destructive; the CLI is used non-interactively). |\n| `lotics workspace doctor` | Report workspace-wide dangling schema references via `GET /v1/workspaces/dangling-references` \u2014 every active app/workflow artifact whose prefixed schema id no longer resolves, printed as `<referent.kind> \"<name>\" (<id>) \u2192 <namespace> <id> (missing)`; healthy prints a one-line all-clear. **Exits non-zero (exit 1) on findings** so scripts can gate on it. Resolves the first workspace like every data command (runs before the global workspace resolution). Admin-only. |\n| `lotics tools` | List tools by category with descriptions |\n| `lotics tools <name>` | Full description, input JSON Schema, and the output schema where the tool answers a shape, for one tool; a workflow step says to call it from a workflow body, not `lotics run` |\n| `lotics run <tool> '<json>'` | Execute a tool (text output via toModelOutput). Args may also come from a file (`lotics run <tool> @args.json`) or stdin behind the `-` sentinel (`cat args.json \\| lotics run <tool> -`) \u2014 both bypass the OS `ARG_MAX` limit for large payloads (a knowledge-doc `content`, a bulk update). A leading `@` on the args is unambiguously a file path (JSON args start with `{`). **Stdin is asked for, never guessed.** `lotics run <tool>` with no payload runs the tool with no arguments and returns at once. `lotics report` takes the same sentinel. In PowerShell use `@file`: quotes inside an inline argument are consumed by the shell, and the CLI reports the JSON it received with its quotes gone \u2014 the error names both escapes. |\n| `lotics run <tool> --json '<json>'` | Execute a tool (full JSON output) |\n| `lotics run <tool>` \u2014 **file cells** | A file in a tool's result carries its `fil_\u2026` id and metadata and **no `url`**, on every tool and in both output modes. That is not a broken file \u2014 this surface resolves no URL for a cell. Reach the bytes with `lotics file download <file_id>`, which takes the id straight from the cell; the text output says so whenever a result carries one. |\n| \u2014 | **Every tool is invoked here, including the ones that RUN something** (`run_app_workflow`, `run_app_query`) and every one that builds: `set_tables` (a list of table documents), `set_app` (one app), `set_app_queries`, `set_workflow`. A command exists only for work that touches a local file: `app apply`, `app create --custom`, `app pull`, `app deploy`. |\n| \u2014 | **An app's queries and workflows take more than their params and inputs** (`lotics docs agent_surface`). `run_app_query '{\"app_id\",\"alias\",\"params\"}'` also takes `filters` and `sort` over the query's output columns, `fields` (the columns to return), `count` with `count_by` or `count_where`, `aggregate`, `limit` (200 rows at most; 50 where none is stated) and `cursor`, and `runs` \u2014 up to 20 reads in one call, each `{\"alias\", \u2026}`, a refused one answered with its `error`; one call's rows total at most 262,144 bytes. `export: {\"key\", \u2026narrowing}` instead of the read arguments makes the file an app's Export makes (`lotics docs agent_surface`). `run_app_workflow '{\"app_id\",\"alias\",\"inputs\"}'` also takes `dry_run: true` \u2014 a rehearsal on the real inputs, nothing written or sent; the answer is what the run would return, with the reads it made under `dry_run.reads`, the calls it would make under `dry_run.writes` and the papers it rendered under `dry_run.papers` and the parts of the answer it could not say under `dry_run.unpredicted`; a workflow no dry run is offered of answers `status: \"not_previewed\"` \u2014 and `runs` \u2014 up to 50 runs in order, the first refused, failed or not previewed ending the call, an input `{\"from_run\": <index>, \"path\": \"data.<key>\"}` reading an earlier run's answer. |\n| \u2014 | **The exit code reports the WORK, not just the call \u2014 for the tool that RUNS one.** `run_app_workflow` whose envelope carries a failed `status` (`error`/`failed`/`cancelled`, or `not_previewed` \u2014 a dry run asked for and not given) exit non-zero and print `<tool> \u2192 <status>: <message>` to stderr, so `lotics run \u2026 && next-step` cannot walk past a refused run. The rule is an allowlist of FAILURE \u2014 an unrecognized status exits 0, so a status added later never turns a working script red. A parked run (`awaiting_input`) is not a failure: it is waiting for an answer and the work is still live. Only a TOP-LEVEL `status` counts; one inside the data belongs to the data. Any OTHER tool's `status` is data, and exits 0. |\n| `lotics run <tool> '<json>' -o <dir>` | Also save every **signed link** the answer carries \u2014 an object with a `url`, a `filename` and the `expires_at` its signature ends at, at any depth, such as `screenshot_app`'s images. Each is fetched into `<dir>` (created if missing) under its `filename`, taking a free spelling beside a file of that name already there, and a link's `snapshot` text is written beside it as `<name>.txt`. The written paths follow the answer on **stdout**, one per line (on stderr under `--json`, whose stdout stays the one object). A link is signed for an hour; one the store no longer serves exits non-zero. A workspace file states no `expires_at` and is left to `lotics file download`. |\n| `lotics run set_members '{\"members\": {\"<email or member id>\": {\"role\": \"member\", \"name\": \"\u2026\"}}}'` | Invite, rename, change a role, remove (`null`) or restore members, or give a managed member a `password` (8 to 128 characters); `groups` keeps the member groups in the same call. Admin only: the whole input is checked before an email is sent. A member's `lotics tools` does not list it, nor `set_workspace`. View-as is the web app's preview alone; the CLI acts as the signed-in member. |\n| `lotics run <tool> --print-created` | Report the records the call created, grouped by table, with a paste-ready `delete_records` per table and the mandatory caveat naming what cannot be auto-undone (external integrations, notifications, possible sub-workflows). Works for any tool that returns a `side_effects` block, not workflows alone. |\n| `lotics run <tool> --cleanup` | Implies `--print-created`, then runs those deletes \u2014 harvested records **only**, never files / external calls / notifications. **Not a rollback**; a rollback is structurally impossible here. A partial cleanup exits non-zero so a script cannot read it as success. |\n| `lotics file upload <file\\|dir...>` (alias `lotics upload`) \xB7 `--stdin` \xB7 `--base64` \xB7 `--url <url>` | Upload files/directories. **The transport is chosen by size and is not a flag**: under 8 MiB the file is POSTed to `/v1/files`, several such files to a request up to half that route's 100 MiB cap; at or above it the CLI takes presigned part URLs and PUTs the bytes straight to object storage, so they never pass through the API. That threshold matches the AWS CLI's own `multipart_threshold`, and the number matters less than there being nothing to choose \u2014 one verb, any size, up to the 2 GiB a workspace may store. A large upload reads one part at a time, so memory stays flat regardless of file size, and a failure part-way abandons the parts already sent rather than leaving them billable and invisible. A directory expands to its immediate files; `--as <name>` renames a single upload. **Three alternative byte sources, for a caller that never had the bytes on disk** \u2014 an attachment decoded in memory, a generated document, a signed download link \u2014 each mutually exclusive with the others and with a path argument: `--stdin` takes raw bytes on stdin, `--base64` takes base64 on stdin (the shape attachments arrive in), `--url <url>` fetches the URL first. `--stdin`/`--base64` REQUIRE `--as`, because stdin carries no filename and the mime type is derived from it; `--url` falls back to `Content-Disposition` then the URL's last path segment. `--base64` decodes STRICTLY \u2014 `Buffer.from(s, \"base64\")` silently skips invalid characters and truncates on bad padding, so a corrupted pipe would otherwise store a short file that only fails when a human opens it. The `--url` fetch happens in the CLI, not the server: the URL comes from the operator running the command, so routing it through the backend would add an SSRF surface to buy what `curl` already does. |\n| `lotics file download <file_id> [<path>]` \xB7 `-o <dir>` | (alias `lotics download`) Download a stored file: `GET /v1/files/{id}/signed_url` \u2192 fetch the presigned URL and write it where you asked. **The two spellings mean two different things, and neither is read by shape: the positional `<path>` is the FILE to write, `-o <dir>` is the DIRECTORY to save into.** That is `cp` and `curl -o`, so nothing here consults an extension. A named file is written as named, its parent created, overwriting what is there \u2014 the point of naming it is that the next command opens that exact path. A directory is created if missing and written into under the stored filename (the response's `Content-Disposition`), taking a free spelling beside a file of that name already there so a repeat download never clobbers the first; with no destination at all, that filename lands in cwd. Give the destination once \u2014 a positional and `-o` together is refused, as is a positional that names an existing directory or ends in a separator (`a directory goes in -o`). The first argument is a **file id**, so a path in that slot is refused rather than sent as an id. The written path goes to **stdout** (under `--json`, `{file_id, path, filename, stored_filename}`) and the narration to stderr, so a download pipes into whatever opens it. `lotics file download record <record_id> <field_alias> [-o <dir>]` (the field named by the alias `query_records` shows it under) spreads every file on a record's file field over a DIRECTORY \u2014 there is no single file for N files to be. |\n| `lotics file list [--limit <n>] [--cursor <token>]` | The workspace's files, newest first \u2014 id, upload time, bytes, MIME type, filename on stdout, one per line (`--json` for the object). `GET /v1/files` with no `file_ids`. A file holding the content of a knowledge doc or template you cannot use is left out. **A page, not a dump**: the store only ever grows, so the last line prints the command for the next page and `next_cursor` is null on the last one. The cursor is opaque and keyset \u2014 pass it back as given \u2014 so an upload landing mid-sweep cannot make a walk skip or repeat a row. Every other file verb takes an id, so this is the only answer to \"what is in here\" short of reading Postgres. |\n| `lotics knowledge list [--include-hidden \\| --deleted]` | `GET /v1/knowledge_docs` \u2014 a table of id, name, tags, description (`--json` for the docs). **REST, not `get_workspace` kind `knowledge`**: the tool answers what the ASSISTANT may browse, and a hidden doc is out of that corpus by definition, so a tool-backed listing could never show one and the person who hid it would have no way back to it. Hidden docs are left out unless `--include-hidden` asks; those rows are marked `(hidden)`. **`--deleted` is the one tool-backed form**: it calls `get_workspace` with `kind: \"knowledge\"` and `include_archived: true`, page by page, and prints the deleted docs you can manage (id, name, deleted time, description); it cannot be combined with `--include-hidden`. |\n| `lotics knowledge create --name <n> [--description <d>] [--tags <a,b>] (--from <file.md> \\| --content <str>)` | Read the body client-side (a file XOR an inline string \u2014 exactly one required), then call `set_knowledge` with `{ name, description, content, tags? }` and no `knowledge_doc_id`, which makes a doc (description defaults to `\"\"`). `--tags` files the doc as it is made, which is the only moment a corpus reliably gets labelled. Prints the new id to stdout. Large files ride the POST body fine. |\n| `lotics knowledge get <id> [-o <file.md>]` | `GET /v1/knowledge_docs/{id}` (`getKnowledgeDoc`) \u2192 the doc with its **hydrated `content`** (the one whole-content read for a non-sandbox client; the tool `read_knowledge` reads a line range). `-o` writes the body via `writeFileAtomic`; else the body goes to stdout. `--json` prints the full doc instead. |\n| `lotics knowledge update <id> [--from <file.md> \\| --content <str>] [--name <n>] [--description <d>] [--tags <a,b>]` | Call `set_knowledge` with a `knowledge_doc_id` and **only** the provided fields (a body from --from/--content becomes `content`; the tool diffs + CASes the content change internally, so the CLI passes no `expected_content_file_id`). `--tags` REPLACES the doc's label set \u2014 the single-doc form, where the caller is looking at one doc and can state what it should carry. At least one field required; --from and --content are mutually exclusive. |\n| `lotics knowledge tag <id...> [--add <a,b>] [--remove <c,d>]` | `PATCH /v1/knowledge_docs` with `{ knowledge_doc_ids, add_tags?, remove_tags? }` \u2014 one transaction over the whole set. A **DIFF applied to each doc's own labels**, never a replacement: the docs named on one command line carry different labels, so one array across them would strip whatever the others were filed under. Removal matches case-insensitively; adding a label a doc already carries writes nothing. Ids may be separate arguments or comma-separated. At least one of --add/--remove required. |\n| `lotics knowledge hide <id...>` / `lotics knowledge unhide <id...>` | `PATCH /v1/knowledge_docs` with `{ knowledge_doc_ids, hidden }`. Hiding takes docs out of every **listing** \u2014 the Library's list, and the corpus `get_workspace` lists and its `search` searches \u2014 while leaving IAM untouched and keeping them readable **by id** (`read_knowledge` with an id, a code run staging one, an app agent's declared set). So it can never silently break an app that depends on a doc, and unhiding costs nothing. Refuses the no-argument form rather than reading it as \"everything\". |\n| `lotics knowledge rm <id...>` | Delete docs via `set_knowledge` (`{ knowledge_doc_id, delete: true }`), one call per id: each must be a live doc you manage that no app or workflow names, and the command reports which succeeded and which failed. 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 `set_knowledge` (`{ knowledge_doc_id, restore: true }`), one call per id: each must be a deleted doc you manage, and the command reports which succeeded and which failed. Refuses the no-argument form. |\n| `lotics app create <name> --custom [path]` | **A custom-code app**: creates the app (`POST /v1/apps`) and links `[path]` (default `./<name>`, made when absent) to it by writing `package.json#lotics` (the app and its workspace, which is how `app deploy` in that directory finds both) \u2014 into the project's own `package.json` when it has one, every other key kept, otherwise a `package.json` holding the link alone. Nothing else is written and nothing is installed: the project is yours. `app deploy` needs `@lotics/app-sdk` (React 19) and an `npm run build` that writes a static `dist/` with an `index.html`. A directory already linked to an app is refused before the app row exists. The app has no version until the first `lotics app deploy`. `--custom` is required: a JSON app is made by `lotics app apply` (the tool `set_app`). The SDK's reference is `node_modules/@lotics/app-sdk/AGENTS.md` once the project installs it. |\n| `lotics app pull [app_id] [path]` | **An app's live definition, pulled.** The app is read with `get_app`: its author is shown a body, a JSON **tree** (with the addresses of its nodes) pulled as that tree and a custom-code app (the identity alone) as its source; an app whose stored tree cannot be read is refused with the reason `get_app` gives, and so is an app that shows the caller no body (the caller does not author it, or it has no version). **A JSON app**: the tree is written as JSON to `[path]` (a directory receives `./<name>.app.json`; default `./<name>.app.json`), and the app's id and current version beside it in `<file>.lotics.json` (`{ \"app_id\": \"\u2026\", \"version_id\": \"\u2026\", \"digest\": \"\u2026\" }`, the digest being the file as the CLI last left it; a record without all three is refused by `app apply`, naming `lotics app pull`) \u2014 a custom-code project records both in `package.json#lotics`, a single file has no such place \u2014 so the next `lotics app apply <file>` edits that app, and is refused if the app has since moved past that version. An existing file is overwritten only when its `.lotics.json` names this same app and the file is unchanged since it was last pulled or applied; a file with edits, or whose record names the app but no digest, is refused, naming `--force`, which overwrites it. `--force` is a flag of `app pull` alone. `--json` prints `{workspace_id, app_id, path, outcome}`. **A custom-code app**: its live source, as a project ready to deploy on it. The target is `[path]`, else this directory when it is the app's project (or no app is named), else `./<name>`. **The app's own project is brought up to date in place** \u2014 but only when it holds no edit since the version `package.json#lotics.current_version_id` names: its source (packed as a deploy packs it) is compared with that version's archive, ignoring `.lotics/` and `package.json#lotics`, and any difference refuses the pull, naming the changed files; a pull never merges, so local work is never lost. Already at the live version is a no-op that says so. **An empty or new directory receives the source whole**; any other directory is refused. Downloads come from `GET /v1/apps/{id}/versions/{version_id}/source`. `package.json#lotics` is then set to exactly the app, its workspace and the pulled version, dropping every other key an older CLI wrote there, so the next `lotics app deploy` builds on the live version; then `.lotics/` is written (`GET /v1/apps/{id}/types`) and dependencies installed (`npm ci` with a lockfile, else `npm install`, both `--ignore-scripts`). |\n| `lotics app apply <file> [--app <app_id>] [--check] [--json]` | **A JSON app, one at a time, through the tool `set_app`.** The file is an app's tree (`lotics docs reference`: the App node with the nodes under it, its tables and templates named by alias). **No app known** (no `--app`, no `<file>.lotics.json` beside it): the file is sent as `{ \"body\": \u2026 }` to make a new app, named by the body's `name`, and the id made is printed, then recorded with its version in `<file>.lotics.json`. **An app known**: its current tree is read (`get_app`) and the **RFC 7396 merge patch** that turns it into the file is sent as `body` with the `app_id` and the version the patch was computed against as `base_version_id` \u2014 a key the file drops is `null` in the patch, a list is replaced whole, what the file leaves alone is left alone, and a tree the app already has sends nothing and says so. **A file pulled at a version the app has since left is refused** before anything is sent, naming both versions and `lotics app pull <app_id> <path>`: pull the current tree into another path, carry the edits over, apply that file. `--app` naming an app other than the sibling file's is refused too. A write that races the apply is refused by the server, which names the app's current version. A file that sets an object key to `null` is refused naming the key, since a merge patch reads `null` as a removal. `--app` names the app, else the sibling file does; an app whose `get_app` shows no body (it is not yours) or whose stored tree cannot be read is refused, and a custom-code app takes its identity (name, description, icon, colour) only, and no `<file>.lotics.json` records it, since an identity write makes no version \u2014 its source deploys with `app deploy`. **The file is never rewritten**: a node the tree leaves out draws as the rows call for it and is stored nowhere (`lotics docs design`). It prints what `set_app` answered and reports its `outcome`: its notes and findings, every finding at once, and the saves, adds and Deletes the write grants and removes. **`--check` sends `dry_run`** (a flag of `app apply` alone, as is `--app`) \u2014 what would refuse the tree, and what it would grant and remove \u2014 and writes nothing, the file and its sibling included; an `outcome` of `refused` exits 1. A tree the app already has, or one `set_app` answers `unchanged`, records the version the app stays at in the sibling. **To go back to an earlier version, apply the earlier tree again.** Resolves and ANNOUNCES its workspace first. `--json` prints `{workspace_id, app_id, outcome, write}`, `outcome` being `set_app`'s: `created`, `updated`, `unchanged` or `refused` (with `--check`, what it would be). |\n| `lotics app deploy [-m <message>]` | **Build this directory and upload it as a new version of the live app.** Rewrites `.lotics/` with the declarations of the app's live bindings (`GET /v1/apps/{id}/types`, replacing each file there), then runs the project's `npm run typecheck` (warned about when absent; a type error in the app's own files stops the deploy, one inside an installed package is reported and does not) and `npm run build`, tars the source (without `node_modules`, `dist`, `.git`, `*.tsbuildinfo`) and `dist/`, and posts both to `POST /v1/apps/{id}/versions` on the version `package.json#lotics.current_version_id` names, then stamps the new one there. The version carries the app's queries, workflows, agents and capabilities forward unchanged \u2014 those are written through their tools. A project with no `build` script, or whose `package.json#lotics` still declares `queries`, `workflows`, `agents` or `capabilities`, is refused before anything is built; the refusal names the tool that sets each. **A 409 because another version went live since this directory's last deploy** (a deploy from elsewhere, a rollback) prints the server's sentence and the version that is live, and names `lotics app pull`: run in this directory, it brings an unedited project up to date, and lists the files a project with edits changed, to carry over into a fresh pull. `-m` (or a bare positional) is the version's message, optional. The workspace comes from `package.json#lotics.workspace_id` unless `--workspace` / `LOTICS_WORKSPACE` names another. |\n| `lotics docs` \\| `lotics docs <area>[/<section>]` \\| `lotics docs [<area>[/<section>]] --grep <text>` | **This CLI's own references, carried inside the binary** \u2014 the model reference, this index, and every doc under `docs/` \u2014 so the doc a reader opens describes the binary answering, listed by the job a reader comes to do. Capped at ONE PAGE: a doc that does not fit prints its opening and the addresses of what it holds (`lotics docs <area>/<section>`, each section's size beside it, or a table's row names), and every address prints within a page. **A reference the binary's copy lacks, or a part of one the server serves, is read from the server's `docs` tool** with this machine's credential, after the copy's refusal \u2014 a page the server added since this binary was built, which a server refusal can cite. A name matching more than one reference, or a part missing from a guide to this CLI, is answered by the copy alone. **`--grep` searches the references the server serves** (not this CLI's own guides, which it refuses), with this machine's credential, narrowed to the area or section named: literal text unless `--regex`; a literal search that finds nothing while the pattern read as an expression matches (`a|b`) returns those lines under a note saying so, and `--literal` keeps the literal zero; with `--case-sensitive`, `--diacritic-insensitive`, `--context-lines <n>` and `--limit <n>` \u2014 the dialect the knowledge lines of `get_workspace` with `search` are read in \u2014 each hit under the address that opens its page; any of those options without `--grep` is refused. A custom-code app's SDK reference ships inside `@lotics/app-sdk` in the app's `node_modules`. |\n| `lotics upgrade` | Update this CLI in place. Runs the same installer a person would, chosen by how THIS copy arrived: an npm install upgrades through npm, a script install re-runs the script \u2014 the runtime knows which (the executable is compiled, the npm bin runs under node), so nobody has to. It downloads nothing itself; resolving a version, verifying the checksum and replacing a running executable already exist in the installers, and a second copy of that inside the binary would be a second thing to get right. Replacing the binary while it runs is safe \u2014 a rename leaves the running image mapped on unix, and on Windows the installer moves the old aside precisely because the file is in use. Already current is a no-op that says so. Needs no auth. |\n| `lotics docs reference` \\| `lotics docs reference/<section>[/\u2026]` | **The reference for tables and apps, from inside the binary** \u2014 at this CLI's version; `lotics docs` lists it under \"Build an app\", after `design`. Its first page is what the documents compose with, the working order (jobs \u2192 tables and fields \u2192 `records` \u2192 one app per job \u2192 one `set_tables` call with every table, then every `set_app` call) and the section addresses; every page of it is whole. Every key of a table document and an app body, every field `type` with the config each one needs, the option / view / inline-template shapes, `records` (how a row of a table is recognised), `write_rules`, the apps (each register, record, act and check), the row format (relative dates `@today` / `@month-start` with whole-day offsets; links as `\"<table-ref>:<row-ref>\"`), the rules, the caps of one `set_tables` call, and a worked example; **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";
1826
1826
 
1827
1827
  // docs/data_model.md
1828
- var data_model_default = '# The data model \u2014 tables, their fields, and how they relate\n\nThe decisions here outlive any one app, and most become expensive the moment a second screen depends\non them. They stand apart from building an app on purpose: **every workspace starts with tables\nand many never get an app**, so schema design is not a chapter of app building.\n\nThe rules below share one signature. **Both sides read correctly on their own**, so nothing reports\nthe problem \u2014 no error, no empty column, no failing query. Each is found by looking for it. Then,\nunder **Fields**, what each field type takes.\n\n## One fact, one column\n\n**Read the table\'s existing fields first (`get_table`), and add nothing that stores a fact the table already\nstores.** A value written as text and the same value held as a `link` are one fact in\ntwo columns \u2014 a customer\'s city typed into a text box beside a link to the city record, a status\nword beside the select that decides it, a total beside the formula that computes it.\n\nTwo columns for one fact do not stay equal. Some writer sets only one of them, and nothing reports\nthe divergence: both rows still look correct on their own. A reader that then matches on the text\nhalf treats "Acme" and "Acme Ltd" as different records, so an import creates a duplicate every time\nit runs.\n\n- **Prefer the link, the select, or the formula.** Text is a RENDERING of a record; compose it when\n you read, rather than storing it a second time.\n- **Renaming, retyping or re-pointing the existing field beats adding another.** A field\'s key is\n stable, so a rename breaks nothing that addresses it by key.\n- **Superseding a field means DELETING it**, not leaving it beside its replacement with a\n description that says which one is real.\n- **Empty is not the same as redundant.** A field nothing fills may still be the only home for a\n real distinction \u2014 read what it MEANS before removing it.\n- **A stored field becomes required only once every row holds a value**, so it always holds one: fill the\n empty cells first, then set it. A computed field\'s flag is checked as each row is written.\n\n## One entity, one table \u2014 and the test is measurable\n\nVariation belongs in a column \u2014 a multi-select role, a kind, a stage \u2014 not in a second table. Two\ntables for one kind of thing give the same real-world entity two rows, two ids and two halves of its\nhistory, and each screen shows whichever half it happens to link to.\n\nSplit tables are often right. A supplier book beside a customer book is a normal shape, and merging\non suspicion is a large repoint bought for nothing. So do not argue it in the abstract:\n\n> **List both tables\' names and look for one that appears in both.**\n\nNone means the split is holding. One means it has broken \u2014 the usual cause is a party you begin to\ninvoice as well as buy from \u2014 and the fix is to merge before a second screen depends on the copy.\n\n## One vocabulary wherever values are COPIED between tables\n\nTwo `select` fields for one concept carry different option keys even when their labels match. Copy an option by its\nalias or label: the record tools match an option by alias or label, so a value copied by alias lands\non the option of that alias on the other field. Prefer a link to a shared reference table where the set is open or\ngrowing. Where one side holds more values than the other, the wider side must **refuse** what the narrower one cannot\nexpress rather than quietly picking the nearest value.\n\nA value read through a link or a lookup reads exactly as the same field would on its own row: an option written by its alias in\na body and read as its key, a member or a link by its id. A value a check matches\nacross tables is a link to one table: two selects never compare, and a save that compares them is refused.\n\nA lookup reads as one flat list of the values it looks up, in a check and in every body: `first(record.x)` is the one value through a one-row link and `includes(record.x, v)` asks whether any row holds `v`; two selects never compare, so a value matched across tables is a link to one table. A single member, a single select, a number or a text reads as the value itself, and `first(...)` of one is refused when the body is saved. A rollup that adds up (a sum, a count) over no rows is 0, never empty, as `sum` over an empty list is; a min, a max or an average over no rows is empty.\n\n## A copy boundary accounts for EVERY source field\n\nEach field on the source gets a column on the destination, a deliberate drop with the reason written\ndown, or a refusal.\n\nA field with nowhere to land is data destroyed at the boundary, and it is invisible afterwards: the\ndestination is not empty and not obviously wrong \u2014 just a number that no longer agrees with where it\ncame from.\n\n## Provenance is a LINK, not a flag and not a copy\n\nA row created BY another row carries a link to it.\n\nThat link is what makes "is this the estimate or the actual", "where did this come from" and "have we\nalready imported this" answerable at all. A boolean records that something was true once; a link\nstays true, survives a rename, and lets the next write UPDATE the original instead of adding a second\nrow beside it.\n\n## Say what makes two rows the SAME row\n\nState it as the table\'s `natural_key` (`reference/write-rules`); a text key is also `unique`.\n\nAnything that imports, reconciles or de-duplicates has to decide identity, and with no declared key it falls back to\ncomparing displayed text \u2014 which is how one company arrives three times under three spellings. Name the key: a reference\nnumber, a tax id, a link plus a period. Then match on record ids and option aliases, never on rendered labels.\n\n## A stage\'s moment is a date on the row\n\nWho changed what, and when, is the platform\'s audit trail, which an agent already reads (`query_audit_logs`) under\nits own access to the table \u2014 so it is never a table of yours, and an app shows none of it: a history an app shows is\ntable-backed (`records.status.dates`, `records.status.history`, an event table an act makes).\n\nWhen the business reports, filters or prints the moment a row reached a stage (completed on, cancelled on), that\nmoment is a `date` field of the row, as `completed_at` and `canceled_at` are in SQL. State it as `records.status.dates`\n(`reference/status`): option \u2192 date. The record write path stamps it as a row is made at the option or moved into it,\nwhoever writes, so no one has to remember; a value the same write states wins, which loads past data as it happened. The\nplatform never clears a stamp: each date is when the row last entered the option, and leaving keeps it, and putting a\nchange back restamps nothing. A date range is a span, never a moment, so it is refused. It is the one way a status is\ntimed: a stage\'s `within` runs from its date, a straight-line process is one select whose options are its steps, each\ndated, and the stage is never read off a run of dates.\n\n`records.status.history` is for a business that reports on every move \u2014 moves per person, or time in a stage a row\nre-enters \u2014 and the platform appends one row per move. An event that carries facts of its own (a reason, an amount, a\nsignature, an inspection\'s findings) is its own table with those fields, made by the act that records it.\n\n## Keep derived chains shallow\n\nFormulas and rollups are computed and STORED when a row is written, and one that reads another recomputes with it. A\nrollup over a formula over a formula is paid three times on every touch, and again for every row upstream of it.\n**Depth costs more than row count**: chain depth is yours to keep down, row scanning is the platform\'s.\n\n## Fields\n\nA field is stated inside its table\'s document in a `set_tables` call, keyed by alias. Every field `type`, the\nkeys each one takes (notation, defaults, options, links, formulas, rollups, lookups, copies) and a worked\nexample are in `docs path="reference/field"`; this page holds the choices around them.\n\n### Types and formats\n\n`type` is one of `text`, `number`, `date`, `boolean`, `select`, `member`,\n`link`, `files`, `formula`, `rollup`, `lookup`, `autonumber`.\n\nA URL, an email, markdown, a checkbox, a datetime, money, a quantity or a percentage is a `format`\nor a `notation` on another type, never a `type`:\n\n| Wanted | Field |\n|---|---|\n| URL or external link | `{ type: "text", format: "link" }` |\n| Email or phone | `{ type: "text" }` |\n| Markdown | `{ type: "text", format: "markdown" }` |\n| Checkbox | `{ type: "boolean" }` |\n| Money | `{ type: "number", notation: { style: "currency", currency: "USD" } }` |\n| Quantity | `{ type: "number", notation: { style: "unit", unit: "kg" } }` |\n| Percentage | `{ type: "number", notation: { style: "unit", unit: "percent" } }` |\n| Datetime | `{ type: "date", format: "datetime" }` |\n| Date range | `{ type: "date", format: "date_range" }` |\n\n### Changing a field\n\nRead the table with `get_table`, which answers its document exactly as `set_tables` takes it, then send `set_tables` with\nthe `table_id` and only what changes. The table is edited by an RFC 7396 merge patch of its document, so what the patch\nleaves alone is left alone and `null` deletes the field it names. Every key a table and a field take is in\n`docs path="reference/tables"` and `reference/field`.\n\nA table is named by its alias wherever a call names one (`table_id`, a link\'s `table`, a lookup, `records.history`), and a new table in the call is named by its `ref`, which becomes its alias. The alias is kept while the table exists, archived or not: asking for one an archived table holds is refused, offering to restore that table or to choose another alias. A filter in a table document (a rollup\'s `filter`, `options_where`, a colour\'s condition, a check\'s lookup) takes the record tools\' operators and values (`is`, `is_any_of`, `gte`, a plain date as a day). An `autonumber` field takes `next` (`"next": 232`): the number its next row gets, refused below the highest already issued.\n\n- **A list is replaced whole**, so an option cannot be nulled. To rename, recolour, add or remove an option, restate the\n field\'s whole `options` list, each option with its alias (`reference/field/select`). A rename changes the label and every\n record keeps its value; an alias left out is removed, which needs `force` while rows hold it.\n- **A number field becomes an `autonumber`** when every row holds its own whole number of 1 or more: the numbers are kept\n and the next row takes the one after the highest (a `next` must be above it). Otherwise the call is refused, saying how\n many rows are blank, repeated or not whole, with up to 5 row ids. No other type converts to or from an autonumber; add\n one as a new field, which numbers the rows already there from 1 (or from `next`) in creation order.\n- **Splitting an option is a rename plus an add.** The old option keeps its alias and takes the first part\'s label, so its\n rows and every app reference to it keep working; the second part is a new option. Rows that belong to the second part\n are then moved by a filtered `update_records`.\n- **A status history holds every option its status ever had** and follows them in the same write: an option added or renamed\n on the status is added or renamed on the history, which a call never restates. Removing an option on the status leaves it\n on the history, because the moves already written hold it. A call that changes nothing of the status\'s options leaves the\n history alone, and options only the history holds stay. Deleting a row deletes its history rows with it, and restoring\n the row restores them; they never hold a delete back.\n- A link names its table by id, or by the `ref` of another table of the same call, and states its `multi`. A two-way link\n states `pair` on one table, as `true` or as a free alias, and the platform makes the partner on the other table with the\n inverse cardinality; a many-to-many link states both sides, since the inverse of many is not decided.\n A link to its own table pairs with itself when `pair` is `true`, and with a second field of that table when `pair` names it\n (a parent and its children).\n A link already holding rows is paired the same way, with its partner stated beside it in the call: the partner is filled from\n the links the rows hold and no cell of the link is rewritten. `pair: null` lets a pair go, both fields staying one-way links\n with their values; a field that holds one row, the partner or the link itself, refuses a pair two rows would crowd, naming the rows.\n The partner may stand on a table the call does not name: `pair: "<alias>"` makes it there, or pairs the one-way link that table\n already holds under that alias, and the answer lists that table among the call\'s. Only someone who manages that table may.\n- A formula\'s `output_type` is inferred from its expression; state it only to override.\n- **A call\'s removals are judged together, on what the call leaves.** A table and the links and lookups naming it, removed in one call, pass; every reader left outside the call is named in one answer, not one per retry. An app reading a field or an option that leaves is rebuilt in the same call, and the answer (a dry run too) lists each node of its tree that changed: a column, a filter, a sort key or a field of a part that only drew the field is dropped, and an option leaves every list of options it stood in. What has no meaning without it refuses the call, naming the node: an act that sets a removed option, a part left with nothing to draw, a list that held only the removed option. An app drawing a field that is retyped, or a link pointed at another table, is rebuilt too, its tree unchanged: where the field no longer lets the tree draw what it drew, the call refuses, naming the node, and a field that became a formula, rollup, lookup or autonumber leaves the app\'s allowlist of saves. A workflow body its author wrote is never rewritten: it is checked again against the tables as the call leaves them, and the call refuses, naming the workflow, only if it no longer checks. A table\'s check or workflow that reads a retyped field or a link pointed elsewhere is judged the same way and refuses, naming it, only if it no longer holds. Deleting one field through the table\'s field route drops no node: an app whose tree draws the field refuses it, naming the node.\n- **A field something reads cannot go.** A query, a workflow, a check, an app only its owner can rebuild, or another\n table\'s formula, rollup, lookup or copy that reads the field refuses its deletion, naming the reader. Change or remove\n the reader first, or in the same call.\n- **`force` consents to losing values:** deleting a field rows hold, removing an option rows hold, and a change of type\n that does not convert every value.\n- A money formula is proven by its numbers. `query_records` with `aggregate` the affected totals before the change and after, and\n compare. A new field in a formula is tested for the value you want, never for differing from an empty cell, which reads\n `""` and differs from every option.\n- A check\'s expressions are written in the language of `workflows/table-checks`.\n- Views are written with `set_table_views`, not `set_tables`: `views` is keyed by a view\'s id (patch; `null` deletes) or a new key (create), and `order` lists view ids or new keys. `get_table` lists each view\'s id and name and returns the definitions with `views: true`.\n- A refused edit writes nothing and lists everything wrong; `dry_run` is for reading the grants it would make or deciding `force`.\n\nA worked call: add the option `cancelled` to `stage` (the whole list restated), add a check, and consent to losing\nwhat rows hold.\n\n```json5\n{\n "tables": [\n {\n "table_id": "<id of the table>",\n "fields": {\n "stage": {\n "options": [\n { "alias": "draft", "label": "Draft" },\n { "alias": "confirmed", "label": "Confirmed" },\n { "alias": "cancelled", "label": "Cancelled", "color": "red" }\n ]\n }\n },\n "checks": {\n "returns_after_leaves": {\n "on": ["create", "update"],\n "when": "!isNull(record.returns)",\n "fail_when": "record.returns < record.leaves",\n "message": "\\"Return before departure\\"",\n "field": "returns"\n }\n }\n }\n ],\n "force": true,\n "dry_run": true\n}\n```\n\nSplitting `queued` into waiting and underway is one call of the same shape:\n\n```json5\n{ "tables": [{ "table_id": "<id of the table>", "fields": { "stage": { "options": [\n { "alias": "draft", "label": "Draft" },\n { "alias": "queued", "label": "Waiting" },\n { "alias": "underway", "label": "Underway", "color": "green" }\n] } } }] }\n```\n\n`table_id` picks the table patched; `fields` merges by alias, so only `stage` changes; `checks` adds one under its alias\n(`null` removes it); `force` consents to losing values (leave it out unless rows hold a removed option); `dry_run`\nchecks and writes nothing, for reading the grants or deciding `force` before the call that writes.\n';
1828
+ var data_model_default = '# The data model \u2014 tables, their fields, and how they relate\n\nThe decisions here outlive any one app, and most become expensive the moment a second screen depends\non them. They stand apart from building an app on purpose: **every workspace starts with tables\nand many never get an app**, so schema design is not a chapter of app building.\n\nThe rules below share one signature. **Both sides read correctly on their own**, so nothing reports\nthe problem \u2014 no error, no empty column, no failing query. Each is found by looking for it. Then,\nunder **Fields**, what each field type takes.\n\n## One fact, one column\n\n**Read the table\'s existing fields first (`get_table`), and add nothing that stores a fact the table already\nstores.** A value written as text and the same value held as a `link` are one fact in\ntwo columns \u2014 a customer\'s city typed into a text box beside a link to the city record, a status\nword beside the select that decides it, a total beside the formula that computes it.\n\nTwo columns for one fact do not stay equal. Some writer sets only one of them, and nothing reports\nthe divergence: both rows still look correct on their own. A reader that then matches on the text\nhalf treats "Acme" and "Acme Ltd" as different records, so an import creates a duplicate every time\nit runs.\n\n- **Prefer the link, the select, or the formula.** Text is a RENDERING of a record; compose it when\n you read, rather than storing it a second time.\n- **Renaming, retyping or re-pointing the existing field beats adding another.** A field\'s key is\n stable, so a rename breaks nothing that addresses it by key.\n- **Superseding a field means DELETING it**, not leaving it beside its replacement with a\n description that says which one is real.\n- **Empty is not the same as redundant.** A field nothing fills may still be the only home for a\n real distinction \u2014 read what it MEANS before removing it.\n- **A stored field becomes required only once every row holds a value**, so it always holds one: fill the\n empty cells first, then set it. A computed field\'s flag is checked as each row is written.\n\n## One entity, one table \u2014 and the test is measurable\n\nVariation belongs in a column \u2014 a multi-select role, a kind, a stage \u2014 not in a second table. Two\ntables for one kind of thing give the same real-world entity two rows, two ids and two halves of its\nhistory, and each screen shows whichever half it happens to link to.\n\nSplit tables are often right. A supplier book beside a customer book is a normal shape, and merging\non suspicion is a large repoint bought for nothing. So do not argue it in the abstract:\n\n> **List both tables\' names and look for one that appears in both.**\n\nNone means the split is holding. One means it has broken \u2014 the usual cause is a party you begin to\ninvoice as well as buy from \u2014 and the fix is to merge before a second screen depends on the copy.\n\n## One vocabulary wherever values are COPIED between tables\n\nTwo `select` fields for one concept carry different option keys even when their labels match. Copy an option by its\nalias or label: the record tools match an option by alias or label, so a value copied by alias lands\non the option of that alias on the other field. Prefer a link to a shared reference table where the set is open or\ngrowing. Where one side holds more values than the other, the wider side must **refuse** what the narrower one cannot\nexpress rather than quietly picking the nearest value.\n\nA value read through a link or a lookup reads exactly as the same field would on its own row: an option written by its alias in\na body and read as its key, a member or a link by its id. A value a check matches\nacross tables is a link to one table: two selects never compare, and a save that compares them is refused.\n\nA lookup reads as one flat list of the values it looks up, in a check and in every body: `first(record.x)` is the one value through a one-row link and `includes(record.x, v)` asks whether any row holds `v`; two selects never compare, so a value matched across tables is a link to one table. A single member, a single select, a number or a text reads as the value itself, and `first(...)` of one is refused when the body is saved. A rollup that adds up (a sum, a count) over no rows is 0, never empty, as `sum` over an empty list is; a min, a max or an average over no rows is empty.\n\n## A copy boundary accounts for EVERY source field\n\nEach field on the source gets a column on the destination, a deliberate drop with the reason written\ndown, or a refusal.\n\nA field with nowhere to land is data destroyed at the boundary, and it is invisible afterwards: the\ndestination is not empty and not obviously wrong \u2014 just a number that no longer agrees with where it\ncame from.\n\n## Provenance is a LINK, not a flag and not a copy\n\nA row created BY another row carries a link to it.\n\nThat link is what makes "is this the estimate or the actual", "where did this come from" and "have we\nalready imported this" answerable at all. A boolean records that something was true once; a link\nstays true, survives a rename, and lets the next write UPDATE the original instead of adding a second\nrow beside it.\n\n## Say what makes two rows the SAME row\n\nState it as the table\'s `natural_key` (`reference/write-rules`); a text key is also `unique`.\n\nAnything that imports, reconciles or de-duplicates has to decide identity, and with no declared key it falls back to\ncomparing displayed text \u2014 which is how one company arrives three times under three spellings. Name the key: a reference\nnumber, a tax id, a link plus a period. Then match on record ids and option aliases, never on rendered labels.\n\n## A stage\'s moment is a date on the row\n\nWho changed what, and when, is the platform\'s audit trail, which an agent already reads (`query_audit_logs`) under\nits own access to the table \u2014 so it is never a table of yours, and an app shows none of it: a history an app shows is\ntable-backed (`records.status.dates`, `records.status.history`, an event table an act makes).\n\nWhen the business reports, filters or prints the moment a row reached a stage (completed on, cancelled on), that\nmoment is a `date` field of the row, as `completed_at` and `canceled_at` are in SQL. State it as `records.status.dates`\n(`reference/status`): option \u2192 date. The record write path stamps it as a row is made at the option or moved into it,\nwhoever writes, so no one has to remember; a value the same write states wins, which loads past data as it happened. The\nplatform never clears a stamp: each date is when the row last entered the option, and leaving keeps it, and putting a\nchange back restamps nothing. A date range is a span, never a moment, so it is refused. It is the one way a status is\ntimed: a stage\'s `within` runs from its date, a straight-line process is one select whose options are its steps, each\ndated, and the stage is never read off a run of dates.\n\n`records.status.history` is for a business that reports on every move \u2014 moves per person, or time in a stage a row\nre-enters \u2014 and the platform appends one row per move. An event that carries facts of its own (a reason, an amount, a\nsignature, an inspection\'s findings) is its own table with those fields, made by the act that records it.\n\n## Keep derived chains shallow\n\nFormulas and rollups are computed and STORED when a row is written, and one that reads another recomputes with it. A\nrollup over a formula over a formula is paid three times on every touch, and again for every row upstream of it.\n**Depth costs more than row count**: chain depth is yours to keep down, row scanning is the platform\'s.\n\n## Fields\n\nA field is stated inside its table\'s document in a `set_tables` call, keyed by alias. A name people read (a\ntable\'s, a field\'s, an option\'s) is in the owner\'s language; an alias stays English. Every field `type`, the\nkeys each one takes (notation, defaults, options, links, formulas, rollups, lookups, copies) and a worked\nexample are in `docs path="reference/field"`; this page holds the choices around them.\n\n### Types and formats\n\n`type` is one of `text`, `number`, `date`, `boolean`, `select`, `member`,\n`link`, `files`, `formula`, `rollup`, `lookup`, `autonumber`.\n\nA URL, an email, markdown, a checkbox, a datetime, money, a quantity or a percentage is a `format`\nor a `notation` on another type, never a `type`:\n\n| Wanted | Field |\n|---|---|\n| URL or external link | `{ type: "text", format: "link" }` |\n| Email or phone | `{ type: "text" }` |\n| Markdown | `{ type: "text", format: "markdown" }` |\n| Checkbox | `{ type: "boolean" }` |\n| Money | `{ type: "number", notation: { style: "currency", currency: "USD" } }` |\n| Quantity | `{ type: "number", notation: { style: "unit", unit: "kg" } }` |\n| Percentage | `{ type: "number", notation: { style: "unit", unit: "percent" } }` |\n| Datetime | `{ type: "date", format: "datetime" }` |\n| Date range | `{ type: "date", format: "date_range" }` |\n\n### Changing a field\n\nRead the table with `get_table`, which answers its document exactly as `set_tables` takes it, then send `set_tables` with\nthe `table_id` and only what changes. The table is edited by an RFC 7396 merge patch of its document, so what the patch\nleaves alone is left alone and `null` deletes the field it names. Every key a table and a field take is in\n`docs path="reference/tables"` and `reference/field`.\n\nA table is named by its alias wherever a call names one (`table_id`, a link\'s `table`, a lookup, `records.history`), and a new table in the call is named by its `ref`, which becomes its alias. The alias is kept while the table exists, archived or not: asking for one an archived table holds is refused, offering to restore that table or to choose another alias. A filter in a table document (a rollup\'s `filter`, `options_where`, a colour\'s condition, a check\'s lookup) takes the record tools\' operators and values (`is`, `is_any_of`, `gte`, a plain date as a day). An `autonumber` field takes `next` (`"next": 232`): the number its next row gets, refused below the highest already issued.\n\n- **A list is replaced whole**, so an option cannot be nulled. To rename, recolour, add or remove an option, restate the\n field\'s whole `options` list, each option with its alias (`reference/field/select`). A rename changes the label and every\n record keeps its value; an alias left out is removed, which needs `force` while rows hold it.\n- **A number field becomes an `autonumber`** when every row holds its own whole number of 1 or more: the numbers are kept\n and the next row takes the one after the highest (a `next` must be above it). Otherwise the call is refused, saying how\n many rows are blank, repeated or not whole, with up to 5 row ids. No other type converts to or from an autonumber; add\n one as a new field, which numbers the rows already there from 1 (or from `next`) in creation order.\n- **Splitting an option is a rename plus an add.** The old option keeps its alias and takes the first part\'s label, so its\n rows and every app reference to it keep working; the second part is a new option. Rows that belong to the second part\n are then moved by a filtered `update_records`.\n- **A status history holds every option its status ever had** and follows them in the same write: an option added or renamed\n on the status is added or renamed on the history, which a call never restates. Removing an option on the status leaves it\n on the history, because the moves already written hold it. A call that changes nothing of the status\'s options leaves the\n history alone, and options only the history holds stay. Deleting a row deletes its history rows with it, and restoring\n the row restores them; they never hold a delete back.\n- A link names its table by id, or by the `ref` of another table of the same call, and states its `multi`. A two-way link\n states `pair` on one table, as `true` or as a free alias, and the platform makes the partner on the other table with the\n inverse cardinality; a many-to-many link states both sides, since the inverse of many is not decided.\n A link to its own table pairs with itself when `pair` is `true`, and with a second field of that table when `pair` names it\n (a parent and its children).\n A link already holding rows is paired the same way, with its partner stated beside it in the call: the partner is filled from\n the links the rows hold and no cell of the link is rewritten. `pair: null` lets a pair go, both fields staying one-way links\n with their values; a field that holds one row, the partner or the link itself, refuses a pair two rows would crowd, naming the rows.\n The partner may stand on a table the call does not name: `pair: "<alias>"` makes it there, or pairs the one-way link that table\n already holds under that alias, and the answer lists that table among the call\'s. Only someone who manages that table may.\n- A formula\'s `output_type` is inferred from its expression; state it only to override.\n- **A call\'s removals are judged together, on what the call leaves.** A table and the links and lookups naming it, removed in one call, pass; every reader left outside the call is named in one answer, not one per retry. An app reading a field or an option that leaves is rebuilt in the same call, and the answer (a dry run too) lists each node of its tree that changed: a column, a filter, a sort key or a field of a part that only drew the field is dropped, and an option leaves every list of options it stood in. What has no meaning without it refuses the call, naming the node: an act that sets a removed option, a part left with nothing to draw, a list that held only the removed option. An app drawing a field that is retyped, or a link pointed at another table, is rebuilt too, its tree unchanged: where the field no longer lets the tree draw what it drew, the call refuses, naming the node, and a field that became a formula, rollup, lookup or autonumber leaves the app\'s allowlist of saves. A workflow body its author wrote is never rewritten: it is checked again against the tables as the call leaves them, and the call refuses, naming the workflow, only if it no longer checks. A table\'s check or workflow that reads a retyped field or a link pointed elsewhere is judged the same way and refuses, naming it, only if it no longer holds. Deleting one field through the table\'s field route drops no node: an app whose tree draws the field refuses it, naming the node.\n- **A field something reads cannot go.** A query, a workflow, a check, an app only its owner can rebuild, or another\n table\'s formula, rollup, lookup or copy that reads the field refuses its deletion, naming the reader. Change or remove\n the reader first, or in the same call.\n- **`force` consents to losing values:** deleting a field rows hold, removing an option rows hold, and a change of type\n that does not convert every value.\n- A money formula is proven by its numbers. `query_records` with `aggregate` the affected totals before the change and after, and\n compare. A new field in a formula is tested for the value you want, never for differing from an empty cell, which reads\n `""` and differs from every option.\n- A check\'s expressions are written in the language of `workflows/table-checks`.\n- Views are written with `set_table_views`, not `set_tables`: `views` is keyed by a view\'s id (patch; `null` deletes) or a new key (create), and `order` lists view ids or new keys. `get_table` lists each view\'s id and name and returns the definitions with `views: true`.\n- A refused edit writes nothing and lists everything wrong; `dry_run` is for reading the grants it would make or deciding `force`.\n\nA worked call: add the option `cancelled` to `stage` (the whole list restated), add a check, and consent to losing\nwhat rows hold.\n\n```json5\n{\n "tables": [\n {\n "table_id": "<id of the table>",\n "fields": {\n "stage": {\n "options": [\n { "alias": "draft", "label": "Draft" },\n { "alias": "confirmed", "label": "Confirmed" },\n { "alias": "cancelled", "label": "Cancelled", "color": "red" }\n ]\n }\n },\n "checks": {\n "returns_after_leaves": {\n "on": ["create", "update"],\n "when": "!isNull(record.returns)",\n "fail_when": "record.returns < record.leaves",\n "message": "\\"Return before departure\\"",\n "field": "returns"\n }\n }\n }\n ],\n "force": true,\n "dry_run": true\n}\n```\n\nSplitting `queued` into waiting and underway is one call of the same shape:\n\n```json5\n{ "tables": [{ "table_id": "<id of the table>", "fields": { "stage": { "options": [\n { "alias": "draft", "label": "Draft" },\n { "alias": "queued", "label": "Waiting" },\n { "alias": "underway", "label": "Underway", "color": "green" }\n] } } }] }\n```\n\n`table_id` picks the table patched; `fields` merges by alias, so only `stage` changes; `checks` adds one under its alias\n(`null` removes it); `force` consents to losing values (leave it out unless rows hold a removed option); `dry_run`\nchecks and writes nothing, for reading the grants or deciding `force` before the call that writes.\n';
1829
1829
 
1830
1830
  // docs/filters.md
1831
1831
  var filters_default = '# Filters \u2014 choosing records by their fields\n\nOne grammar for every tool that takes a filter: `query_records` (rows and `aggregate`), `update_records`\nand `delete_records` by filter, a view\'s filter, a rollup\'s filter, and the filters a caller passes\nto an app\'s query.\n\n## The shape\n\nA filter is a single condition, or a group of conditions.\n\n```\n{ "node_type": "condition", "field_key": "status", "operator": "has_any_of", "value": ["done"] }\n{ "node_type": "group", "logic": "and" | "or", "children": [ \u2026conditions or groups ] }\n```\n\nGroups nest up to 4 levels. AND(OR(status = A, status = B), age > 40):\n\n```\n{ "node_type": "group", "logic": "and", "children": [\n { "node_type": "group", "logic": "or", "children": [cond1, cond2] },\n cond3\n] }\n```\n\nEvery `field_key` resolves against the table being filtered \u2014 read each table\'s fields (`get_table`)\nand carry no name across tables: sibling tables holding the same concept routinely name it\ndifferently. A field is named by its alias (`"status"`), as `get_table` shows it, and a select option the same way\n(`"done"`). A traversal\'s `path` and the field of its condition are named the same way, each on the table the\npath has reached.\n\n## One family of operators\n\nA condition may state these operators; each reads as the type\'s own below. Which field kinds take each:\n\n| Operators | Field kinds | Meaning |\n|---|---|---|\n| `is`, `is_not` | a field holding one value: text, number, date, yes/no (`is` only, or `is_not` with a yes/no value), a select or member that is not multi, a link of cardinality one | equal to one value; on a select, member or link, to the one option, member or record |\n| `is_any_of`, `is_none_of` | text, number, date, select, member, link | equal to any of a list; on a select, member or link, `has_any_of` and `has_none_of` |\n| `lt`, `lte`, `gt`, `gte` | number, date | below, at most, above, at least |\n| `is_empty`, `is_not_empty` | every kind but yes/no | no value, or one |\n\n`is` and `is_not` on a field that holds several options, members or records (a multi select, a many-link) are refused:\nstate `has_any_of`, `has_none_of` or `has_all_of`, which say whether the row must hold exactly them or just some.\nA date is `"2026-10-15"` for that whole day, whatever time a value holds. An `is_any_of` or `is_none_of` on a number\nor a date, and an `is_not` on a date, stand for a group of conditions, which a traversal\'s single condition cannot hold;\na list that a workflow computes is expanded when the step runs, so a saved workflow states it as it states any list.\nThe depth a filter may nest is the depth you write: the groups an operator stands for do not count.\n\n```\n{ "node_type": "condition", "field_key": "due", "operator": "gte", "value": "2026-10-01" }\n{ "node_type": "condition", "field_key": "status", "operator": "is_any_of", "value": ["done", "shipped"] }\n```\n\n## Text\n\n| Operators | Value |\n|---|---|\n| `equals`, `not_equals`, `contains`, `does_not_contain`, `starts_with`, `ends_with` | a string |\n| `is_any_of`, `is_none_of` | a string array \u2014 exact, case-insensitive |\n| `contains_any_of` | a string array \u2014 the cell contains any of them, case- and accent-insensitive |\n| `is_empty`, `is_not_empty` | none |\n\n```\n{ "node_type": "condition", "field_key": "title", "operator": "is_any_of", "value": ["hello", "world"] }\n```\n\nA text value is a string, never an array; `is_any_of` matches several. Its array has no length cap \u2014\npass the whole list in one condition rather than splitting it across calls.\n\n## Number\n\n| Operators | Value |\n|---|---|\n| `equals`, `not_equals`, `greater_than`, `less_than`, `greater_than_or_equal_to`, `less_than_or_equal_to` | a number |\n| `is_empty`, `is_not_empty` | none |\n\nA number whose `notation` reads its unit or currency on each row (`{ "per_row": <select> }`) is\nread in each row\'s own, so a comparison on it states `unit_option`: the option of that select its\nvalue is in.\nRows holding another option are out; measured units of one dimension convert.\n\n```\n{ "node_type": "condition", "field_key": "amount", "operator": "greater_than", "value": 100, "unit_option": "USD" }\n```\n\n## Boolean\n\n`equals` with `true` or `false`.\n\n## Select\n\n| Operators | Value |\n|---|---|\n| `has_any_of` (any of them \u2014 "A or B"), `has_none_of`, `has_all_of` (a multi-select only) | an ARRAY of option aliases, even for one |\n| `is_empty`, `is_not_empty` | none |\n\n```\n{ "node_type": "condition", "field_key": "status", "operator": "has_any_of", "value": ["done", "in_progress"] }\n```\n\n## Member\n\n| Operators | Value |\n|---|---|\n| `has_any_of`, `has_none_of`, `has_all_of` | an array of member ids |\n| `is_current_member`, `is_not_current_member`, `is_empty`, `is_not_empty` | none |\n\n## Date\n\n| Operators | Value |\n|---|---|\n| `before`, `after`, `on_or_before`, `on_or_after`, `on`, `starts_before`, `starts_after`, `ends_before`, `ends_after` | a point |\n| `between`, `overlaps`, `contains`, `within` | `{ start: point \\| null, end: point \\| null }` |\n| `time_of_day` | `{ start_time: "HH:mm" \\| null, end_time: "HH:mm" \\| null }` |\n| `duration_equals`, `duration_greater_than`, `duration_less_than` | `{ amount, unit: "minutes" \\| "hours" \\| "days" }` |\n| `is_empty`, `is_not_empty` | none |\n\nA date value is a point. A bare `"YYYY-MM-DD"` is the point of that whole day (`"YYYY-MM-DDTHH:mm"` of that minute),\nand a stated date is compared as written, with no timezone shift: `on "2026-10-15"` matches every value from 00:00 to\n23:59 of that day, on a date or a datetime field. `before` the day is earlier than its start, `after` later than its end,\n`on_or_before` and `on_or_after` include it. A relative or period point counts today in the field\'s timezone. The\nobjects below say the same, and add a time, an offset or a period:\n\n```\n{ "type": "exact", "date": "2025-03-14", "time": "09:00" } time optional\n{ "type": "relative", "offset": -7, "unit": "minutes" | "hours" | "days" | "weeks" | "months" | "years" }\n{ "type": "period", "period": "day" | "week" | "month" | "quarter" | "year", "boundary": "start" | "end", "offset": 0 }\n```\n\n## Link\n\n| Operators | Value |\n|---|---|\n| `has_any_of` (linked to any \u2014 filters by record identity), `has_none_of`, `has_all_of` (a multi-link only) | an array of record ids (`rec_\u2026`) |\n| `contains`, `not_contains`, `starts_with`, `ends_with` \u2014 deprecated: the text a link stores changes with every rename; filter by record, and by a linked record\'s field through a lookup of it | a string, matched against the linked record\'s display text |\n| `is_empty`, `is_not_empty` | none |\n\n```\n{ "node_type": "condition", "field_key": "customer", "operator": "has_any_of", "value": ["rec_abc123"] }\n```\n\n## Through a link\n\nA traversal chooses records by a field of the records they link to. `path` lists link fields, each of the table\nbefore it (up to 3), and `condition` names a field of the last table reached:\n\n```\n{ "node_type": "traversal", "path": ["customer"], "condition": { "field_key": "tier", "operator": "has_any_of", "value": ["gold"] } }\n```\n\n## Sort\n\nA view\'s sort is a list, applied in order: `[{ "field_key": "due", "order": "asc" | "desc" }]`.\n\n## Counting, aggregating and reading by id\n\n`query_records` takes `filters` in one table and answers rows, or, with `aggregate`, statistics over them: `count`, or an\noperation by the field\'s type, optionally broken down by `group_by`. Records are read one table at a time. A question over\nseveral tables is one number from `tables`, a list of entries that each state their own `table_id` and filter, since each\ntable spells its fields its own way; it combines with no top-level `table_id`, `view_id`, `search` or `filters`.\n\n- **Values held in another table.** A filter value (a customer\'s name, a code) is not looked up for you. Find where it\n lives with `get_workspace` and `search`, which names the tables and docs that hold the words, then state it in that\n table\'s own filter. When `is_any_of` on a text field names values, the answer lists the ones no row of the table\n matched, so a total that misses part of what you asked for says so; pass `tables` to count them across the tables\n that hold them.\n- **By id.** `record_ids` reads those records whole, deleted ones included, with their `display`, `links`, `archived_at`,\n `created_at` and `updated_at`; `table_id` may be left out. It takes at most 50 ids on every origin (an agent, a workflow\n step, an app) and combines with `field_keys` and `comments` only. `update_records` takes the same 50 ids, and with\n `restore: true` brings deleted records back.\n';
@@ -1834,7 +1834,7 @@ var filters_default = '# Filters \u2014 choosing records by their fields\n\nOne
1834
1834
  var field_values_default = '# Field values \u2014 what a write takes for each field type\n\nThe value `create_records` and `update_records` take for a field. A field is named by its alias, as `get_table` shows it, and a select option the same way. `dry_run: true` on `create_records`, `update_records` and `delete_records` runs the write in a transaction that is rolled back: every check, rule and stage owner is judged, no number is consumed, and the answer says what would be written or why it is refused. Every write meets the table\'s rules (`reference/write-rules`): a refusal lists every broken rule at once, and a locked record answers under `blocked` in `update_records`\' result.\n\n## Values by type\n\n| Type | Value |\n|---|---|\n| `text` | `"hello"` |\n| `number` | `42` |\n| `boolean` | `true` |\n| `date` | `"2025-03-14"`, or a month `"2025-03"` or a year `"2025"`, stored as written and compared as its first day |\n| `datetime` | a `date` of this format: `"2025-03-14T09:00"`, never a month or a year |\n| `date_range` | a `date` of this format: `"2025-03-01/2025-03-15"`, both halves full dates |\n| `datetime_range` | a `date` of this format: `"2025-03-01T09:00/2025-03-15T17:00"`, both halves full |\n| `select` | `["open"]`, the option\'s alias |\n| `member` | `["mbr_\u2026"]` |\n| `link` | `["rec_\u2026"]` |\n| `files` | `["fil_\u2026"]`, ids of uploaded files |\n| `formula`, `rollup`, `lookup`, `autonumber`, a `date` with `derive_from` | none \u2014 the platform writes them, and a write naming one is refused |\n\n## Lists\n\n`select`, `member`, `link` and `files` store an ARRAY, even where the field holds\none value.\n\n- A single-select is a ONE-element array; a bare `"open"` is accepted and wrapped. A single\n `member` takes a bare `"mbr_\u2026"` the same way.\n- A single `link` takes a bare `"rec_\u2026"` the same way; a multi `link` and `files` take an array only.\n- A `files` id the write adds must name a live file of this workspace, or the whole write is refused;\n an id that cell already holds stays, though its file was archived since.\n- A file previews by its kind: an image zooms; a PDF scrolls, zooms sharp and turns by page, its text selectable; a DXF\n drawing shows read-only in 2D with a toggle per layer. A DWG stays a stored file with no preview; attach its plotted\n PDF beside it.\n- Two options on a single-select, two members on a single `member`, or two records on a single `link`, are refused.\n- `update_records`\' `add_to`, `remove_from` and `replace` take arrays of the same items: option aliases\n for a select, member ids for a `member`, record ids for a `link`, file ids for\n `files`.\n';
1835
1835
 
1836
1836
  // docs/workflows.md
1837
- var workflows_default = '# Workflows \u2014 the steps a workflow runs\n\nA workflow is written as a strict subset of JavaScript. The source is never run as JavaScript: a\nsave parses it into steps, type-checks it against what its trigger supplies, and stores the steps.\nOnly the forms below are accepted; anything else is refused at save with the line, the column and\nwhat to write instead. A save that succeeds may still return `warnings` \u2014 advisory hints such as a\nloop that may never end. Read them.\n\nA workflow changes records, calls integrations, notifies and makes papers. A tool for anything else \u2014 views, history,\ntables and apps, members, knowledge, skills, editing a file \u2014 is an agent\'s, and a body that\ncalls one is refused at save with that reason. One saved before is refused at run, before its first step.\n\nThe same grammar serves a table\'s workflow, an automation and an app\'s workflow, which `set_workflow` saves, and the\nexpressions of a table check.\n\n## Triggers\n\nEvery workflow\'s source opens with `on({ ... })`, which states what starts it. `set_workflow` saves the source, and\n`get_workflow` reads it back with whether it is on and how its latest runs ended. `get_workflow` without a\n`workflow_id` lists the workflows you may read, a page at a time (`limit` 1 to 100, `offset`, `total`), each with its\n`kind`, its trigger in a line, whether it is on and its failed runs of the last 7 days; `table_id` and `app_id` keep the\nones that follow or name them.\n\n| kind | header |\n|---|---|\n| table | `on({ type: "create" \\| "update" \\| "delete", table: "<alias>", changed?: ["<alias>", ...], when?: "<expression>" })` |\n| automation | `on({ type: "recurring_schedule", cron_expression })`, `receive_webhook`, `receive_gmail_email` or `receive_outlook_email` \u2014 see **Automations** |\n| app | `on({ type: "app", app_id, alias, inputs?, outputs? })` \u2014 `inputs` and `outputs` only for a custom-code app |\n\nWhat starts a workflow is fixed once it is made: a table workflow keeps its trigger type and its table, an app workflow its\napp and alias. To change either, delete the workflow and make another. A replace states the whole trigger, so a key the\nheader leaves out is removed (an app workflow keeps the inputs it leaves out, and derives the outputs it leaves out from\nwhat the body returns). `expected_version` refuses a replace when the workflow has moved since the `version` you read.\n`dry_run` checks the source and stores nothing; given the trial inputs its trigger supplies, it also walks the steps once\nand returns the calls planned, the return value, the failures and the papers rendered. A table trigger takes `record_id`,\n`prev_data` and `changes`; a webhook or email trigger `trigger_payload`; an app trigger `record_id` and `preview_inputs`; a\ntable or automation trigger also `live_reads` (an app\'s rehearsal always reads real data). A trial input its trigger does\nnot supply is refused, naming the ones it takes. A rehearsal needs write scope: a key that reads data alone cannot make one.\n\n`delete: true` with a `workflow_id` removes a workflow, and is the one call that asks for approval; a replace asks for none,\na step it drops included. `even_if_invoked` and `acknowledge_breaking_api_change` belong to an app workflow and are\nrefused for another kind.\n\nAn app workflow reads its call as `trigger.app_workflow.inputs.<name>`, or `inputs.<name>`, which is the same read. A Workflow node receives the inputs it declares;\nan Act\'s `workflow` receives `record_id` (`record_ids` on an act over several rows) and what the Act asks.\n\n### Automations\n\nAn automation answers an event outside the records: a schedule, a webhook, an inbound email. `set_workflow` saves one\nfrom its source \u2014 the `on({...})` declaration, then its steps \u2014 and, given its `workflow_id`, replaces it: the\ntrigger config is the one the header states, a key it leaves out removed, and steps left as it runs them are checked again\nonly when the call turns it on (`enabled`), so one a later rule refuses can still be switched off. An admin names the owner at creation with\n`owner_member_id`. It runs with its owner\'s current permissions, a connected service it names is its owner\'s account, and only its\nowner or an admin reads, changes or deletes it.\n\n```\non({ type: "recurring_schedule", cron_expression: "0 9 * * MON" });\n```\n\nEvery source below is typed, so a misspelled member is refused at save rather than read as nothing.\nAll three carry `trigger.trigger_id`.\n\n| trigger | required config | what the body reads |\n|---|---|---|\n| `recurring_schedule` | `cron_expression` | `runtime.timezone`; the current time is `now()` |\n| `receive_webhook` | `secret`? | `trigger.method`, `trigger.headers[...]`, `trigger.query[...]`, `trigger.body` (any JSON, or raw text when the request is not JSON), `trigger.received_at` |\n| `receive_gmail_email` / `receive_outlook_email` | `connected_account_id`, `filter` | `trigger.email_id`, `trigger.from`, `trigger.to`, `trigger.cc`, `trigger.bcc`, `trigger.subject`, `trigger.date`, `trigger.body` (plain text), `trigger.reply_to`, `trigger.in_reply_to`, `trigger.attachments` (file refs \u2014 assign them straight to a files field). One type serves both providers, so `labels`, `thread_id`, `importance` and `conversation_id` are not readable. |\n\n## Steps\n\n| step | syntax |\n|---|---|\n| tool call, kept | `const <id> = await <tool>({ ...inputs });` \u2014 also `let x = await \u2026` and `x = await \u2026` |\n| tool call | `await <tool>({ ...inputs });` \u2014 the platform\'s own steps (`send_notification`, `generate_document`, \u2026) and a connection\'s tools, each a workflow step (`bkav_submit_invoices`, `kiotviet_create_order`, \u2026); a step that fails ends the run with its reason unless a `try`/`catch` around it handles it |\n| agent | `const <id> = await agent({ instructions, input, model, output });` \u2014 see **An agent step** |\n| app agent | `const <id> = await app_agent({ alias: "<agent alias>", input: { ... } });` \u2014 runs one of the app\'s declared agents, in an app workflow only, and resolves to its declared outputs or its final text. `wait: false` only starts the run and resolves to `{ run_id }`; the run\'s failure is then its own, not the workflow\'s. Refused in a run an agent started. |\n| bind | `const <id> = <expression>;` \u2014 evaluated once; later steps read `<id>`. Bind any expression used twice. |\n| if / else | `if (<expr>) { ... } else { ... }` \u2014 `else` optional, `else if` chains allowed |\n| switch | `switch (<expr>) { case "X": { ... } default: { ... } }` \u2014 string cases only, no fallthrough |\n| for | `for (const <name> of <expr>) { ... }` |\n| wait | `await wait({ duration_in_minutes: 5 });` |\n| wait for an event | `await wait_for_event({ event_type: "webhook", event_ref: <expr>, timeout_in_minutes: 60 });` |\n| wait for an approval | `await wait_for_approval({ approvers: <expr>, prompt: <expr> });` \u2014 see **Waiting for an approval** |\n| return | `return({ status: "success" \\| "error", message: <expr>, field_errors: { ... }?, data: { ... }? });` \u2014 `data` holds the outputs a Workflow node declares |\n| validate | `validate({ checks: [{ fail_when: <expr>, field_key: "...", message: <expr> }, ...] });` |\n\nA delayed run is never cancelled from a workflow: after a `wait`, it reads the row\'s state and acts only if it still holds.\n\nA tool\'s inputs and answer are in its own description; the steps only a body runs \u2014 a connection\'s, a mail\'s \u2014 are each\nan entry of `workflow_steps`. A person connects a service in Lotics under Settings \u2192 Connections; a step names that\nconnection by its `connected_account_id`, which `get_workspace` with `kind: "connection"` lists for the connections the\ncaller can use. The app\'s owner and whoever saves the body must both be able to use that connection.\n\nIn a workflow, email reaches a member of the workspace through the step `send_notification`: Lotics shows it to them and\nmails it to their own address, with no connection. Its recipients are members by `member_id` (`get_workspace` with\n`kind: "member"` lists them), member groups or the whole organization. Mail to anyone else goes from a person\'s\nconnected Gmail or Outlook (`gmail_send_email`, `outlook_send_email`), which leaves an unsent draft unless `draft` is\n`false`.\n\n`return` is a call, not a JavaScript `return` statement. `validate` ends the workflow with an error\nwhen a check\'s `fail_when` is truthy; the check\'s `field_key` names the control its message lands on \u2014\nin an app workflow a declared input, or the alias of a field on a table the body names; in an\nautomation it is not checked.\n\nThe name in `const x = await tool({...})` is the step\'s id; later expressions read its output as\n`x.records`, `x.id` and so on. A `// id: my_step` comment directly above a statement sets an explicit\nid; any other `//` comment there becomes the step\'s description.\n\n**`await` inside a statement** runs the call as its own step first, then reads its output where it is\nwritten: `size((await query_records({...})).records)`, an `if` test, a `return` value, a `for-of`\niterable, a tool input. `const x = c ? await t({...}) : null;` is stored as the `if` it means. An\n`await` that would run on some paths only \u2014 inside `&&`, `||`, `??`, `?.`, a nested ternary, a loop\ntest, a `validate` check \u2014 is refused; write the `if`.\n\n**Names are block-scoped, as in JavaScript** \u2014 `const`, `let`, loop items and `catch (e)`: sibling\nblocks may reuse a name and an inner block may shadow an outer one. A local may take a helper\'s name\n(`const size = 3;`), but calling `size(...)` while it is in scope is refused. The names of the tools a\nworkflow calls, the reserved roots, `linked` and `_s` followed by digits cannot be bound.\n\n### Waiting for an approval\n\n`wait_for_approval` is the wait worth binding \u2014 `wait` and `wait_for_event` carry nothing to read.\nBind it to branch on the decision:\n\n```\nconst approval = await wait_for_approval({\n approvers: trigger.app_workflow.inputs.approvers,\n prompt: `Approve this quote?`,\n});\n\nif (approval.status == "approved") {\n // the approved path\n} else {\n // the rejected or timed-out path; approval.decision_comment says why\n}\n```\n\nIt resolves to `{ status: "approved" | "rejected" | "timed_out", decided_by: MemberId | null,\ndecided_at: ISO string, decision_comment: string | null }`.\n\n`approvers` takes a bare member id (`"mbr_\u2026"`), a bare group id (`"grp_\u2026"`) or a full principal\n(`{ type: "member_group", id: "grp_x" }`), alone or in a list \u2014 so a member field\'s value passes as it\nis.\n\n### An agent step\n\n`const x = await agent({ instructions, input, model, output });` runs a model as one step \u2014 read a paper, classify,\ndraft text. It calls no tool: the workflow\'s own steps write, send and notify with what it returns, and a step that\nstates `tools` is refused.\n\n- `instructions` \u2014 a fixed string.\n- `input` \u2014 an object literal; its fields are expressions, evaluated and handed to the model (`{}` for\n none). It is the ONLY workflow data the agent sees: read the trigger, prior steps, `runtime` or the loop\n item here, never inside `instructions`, which is a fixed literal and reads no binding. A field holding a file, or a\n list of files (a files field, a mail\'s attachments), hands the agent the files themselves: a picture or a PDF it\n sees, any other paper read into text.\n- `model` \u2014 optional; omit it to follow the platform\'s default model.\n- `output` \u2014 `{ mode: "text" }` resolves to a string; `{ mode: "object", schema }` to a typed object.\n\nOnly `x` comes back: in text mode `x` is the string itself, in object mode `x.field` reads a field the\n`schema` declares.\n\n## Naming tables and templates\n\nA body names a table or document template by its alias (`query_records({ table_id: "orders" })`); ids still work. `get_workflow` shows aliases. An app\'s workflow reaches only the tables the app uses.\n\n## Fields and options\n\n1. **A body names a table\'s fields and select options by alias**, the names the table\'s document states: a field reads\n as `record["status"]`, an option as `"done"`, a linked field as `linked(record["customer"])[0]["name"]`, in the\n body of a Workflow node, of an Act\'s own `workflow`, of an automation and of a table\'s workflow (`set_workflow`). It is stored by key and `get_workflow` shows it by alias\n again. A field\'s or option\'s key is accepted where its alias is and is kept as written; a display name is refused with the\n aliases there are, and a row that names one field by both its alias and its key is refused. A row written to a table is\n stored by field key wherever the save can follow it to the record tool that writes it: a row or list written in the call,\n a conditional of them, a name holding them, pushed to a `let`, and through `map`, `flatMap`, `concat`, `filter`, `sortBy`,\n `slice` and `reverse`. A field of such rows is read by alias as well (`picks.toSorted((a, b) => a.quantity - b.quantity)`,\n `sortBy(picks, "quantity")`, `rows[0].note`) and stored by key. A body that cannot be stored by key is refused at save,\n naming the statement.\n2. **An Act\'s own `workflow`** has the app\'s guards placed at the top of the body, between `<lotics:guards>` tags,\n replaced on every save: a save that leaves them out or edits them gets the app\'s again, at the top wherever the tags\n sat, so the act\'s row rule, `when`, `requires`, checks and locks always run first.\n3. **A value is read as the record tools store it.** A single select reads as its option and a member as its id; an\n option is written by its alias wherever it meets a select \u2014 compared, listed, switched on, written \u2014 also beside a\n name set from the field (`const s = record.status; s == "done"`), and a select\n value handed on whole (returned, written to a text field) is its key. An option\'s alias searched for in a list the\n save cannot place it against (`includes(filter(record.tags, \u2026), "hot")`) is refused: search the field itself. A link reads as the list of its row ids, a\n one-row link too: `first(record.customer)` is the one id, and `record.customer` used as one id is refused at save,\n naming `first(record.customer)`. A one-row link is written with its one id or a list of one; a many-row link with a list. A lookup reads as one flat list of the values it looks up: `includes(record.owner,\n me)`. A select\'s value is one of another select\'s options only where that table shares its keys, by having been\n duplicated from it, not where an option merely shares its alias. A row that takes its parent\'s option gets it from\n `default_from`, and a lookup shows it.\n4. **What an app sends takes an option by alias or key.** An input or output drawn from a field (`field`, written\n `<table id>.<alias>`) compares and returns by alias in the body, and the run hands it on as the option\'s key. A\n refusal\'s `field_errors` and a `validate` check\'s `field_key` name a declared input or a field\'s alias.\n5. **`==` against a multi-select means "includes".** `record["tags"] == "urgent"` on a multi-select is stored as\n `includes(record["tags"], "urgent")`; either spelling works.\n6. **Text renders labels.** Inside a template or a text tool input, a select, member or link value renders its display\n text: `` `${record["status"]}` `` writes "Done", not `done`.\n\n## What an expression reads\n\n- `record`, `prev_record`, `changes` \u2014 in a table check, the write it checks (see **Table checks**).\n `changes` is a PARTIAL map: `changes["x"]?.next_value` and `?.prev_value`, the `?.` required.\n- `trigger` \u2014 what the trigger carries (see **Triggers**).\n- a loop\'s item name, and `index`, the iteration\'s 0-based index.\n- `<step id>` \u2014 an earlier step\'s output (`dup.records`).\n- `runtime.timezone`, `runtime.workflow_id`, `runtime.execution_id`, `runtime.workspace_id`,\n `runtime.organization_id`, `runtime.change_origin`, `runtime.triggered_by_member_id`. The current\n time is `now()`.\n\n## Paths\n\n- `record.x` and `record["x"]` read a field; `[n]` reads a list\'s n-th item.\n- A path may start at any expression: an awaited call `(await query_records({ table_id: "orders", record_ids: [id] })).records[0].data["x"]`, a helper\'s\n result `first(found.records).id`, a filter `rows.filter((r) => r.ok)[0].n`, a `(a ?? b).c`. `?.` reads\n through a null as usual, except on a value drawn at random (`sample(xs)?.id` is refused: bind it first).\n- A record reads in its declared shape wherever it comes from \u2014 the trigger, a tool result, a `let`, a\n callback parameter: a single select as its option alias or null, a member as its id, a link as its ids. (The record\n tools called outside a workflow return the stored arrays instead.)\n- `linked(record["link"])[n]["x"]` fetches the n-th linked row and reads a field of it; bare\n `linked(record["link"])` is every linked row, one fetch per row. Its argument is a path ending on\n a link field and carries the guard: `linked(record?.["link"])[0]`.\n- `changes["link"]?.next_value` is the same list of ids; `linked()` over a change is refused \u2014\n read `linked(record["link"])` or `linked(prev_record["link"])`.\n\n## Operators and statements\n\nOperators in JavaScript precedence: `? :`, `||`, `??`, `&&`, `== != === !==`, `< <= > >=`, `+ -`, `* / %`,\nprefix `! -`.\n\n- Arithmetic is exact decimal (`0.1 + 0.2` is `0.3`). A result computed from a division that does not end\n is the short decimal it reads as (`1 / 3 * 3` is `1`), and stands as computed where it reads long\n (`1 / 3 * 100` is `33.33333333333333`). A comparison between two numbers reads both at 15 significant\n digits. `round` and `formatNumber` round the decimal, a half away from zero: `round(-2.5)` is `-3`. A\n table filter compares stored values exactly.\n- `??` is `coalesce(left, right)`; `?.` reads through a null (`a?.b`, `a?.[0]`, and `s?.trim()` for the\n method names listed under **Helpers**). `x?.()` is refused: helpers and tools are not values.\n- Templates use backticks and `${...}`.\n- Destructuring \u2014 `const { status, name } = record;` binds each name. Renames\n (`{ status: s }`), quoted keys (`{ "ref": ref }`), array patterns with holes\n (`const [first, , third] = xs;`) and defaults (`{ note = "" }`, which apply on null too) work on\n `const`, `let` and `for (const { id } of rows)`. A tool result destructures directly:\n `const { records } = await query_records({...});`. `const a = 1, b = 2;` declares both. Nested\n patterns and rest are refused.\n- Spread \u2014 `[...a, b]` and `{ ...a, b: 1 }` (later keys win). Refused inside a tool input: bind the\n merged value first and pass the binding.\n- Shorthand \u2014 `{ table_id }` is `{ table_id: table_id }`.\n- `xs.push(a, b);` appends, also on a key (`o.items.push(v)`); `o.a.b = v;` sets a nested key;\n `x ??= v`, `x ||= v` and `x &&= v` assign. A `const` may be pushed to or have a key set, as in\n JavaScript; a loop item is read-only.\n- `undefined` is the same value as `null`, so `x === undefined` is `isNull(x)` and `x !== undefined` is\n `isNotNull(x)`; a key set to null and a key never sent are not told apart (`"k" in o` tells a key that\n is there).\n- `typeof x` answers JavaScript\'s words, and a null (a missing value too) is `"object"`. `k in o` tests an\n object\'s own keys, or a list\'s indexes and `length`.\n- A filter node may leave out `node_type` when its keys say which it is: `field_key` / `operator` /\n `value` a condition, `logic` / `children` a group, `path` / `condition` a traversal.\n- `function name(p = <default>) { return <expr>; }` \u2014 top level only, expanded at every call (a call\n may come before it). Every parameter needs a default, which gives it its type; the body is one\n `return <expr>;` reading only its parameters, helpers and the reserved roots \u2014 never the caller\'s\n names, nor `index`. No recursion; a function never called is refused. A body read back shows the\n expression at each call, not the `function`.\n\n**try / catch.** `try { ... } catch (e) { ... }` catches a tool error or an expression error inside the\nbody; `e` is `{ message, type, step_id?, detail? }`. A failed `validate` and a `return` are not\nerrors \u2014 they end the workflow \u2014 and a step that runs after a wait inside the `try` is outside it.\n`finally` is refused: put always-run steps after the `try`.\n\n**Loops.** `for-of`, `while (cond) { ... }`, `do { ... } while (cond);` and\n`for (let i = 0; i < n; i++) { ... }`, each capped at 10,000 iterations. `break;` and `continue;` act\non the innermost loop; labels and `for-in` are refused.\n\n**`let`.** `let x = <expr>;` declares a block-scoped variable; the initializer is required\n(`let x = null;`). Reassign with `=`, `+=`, `-=`, `*=`, `/=`, `%=`, `??=`, `++` and `--`. Re-declaring in\nthe same block is refused; shadowing in a nested block is allowed. A `let` keeps its value across a\n`wait`, `wait_for_event` or `wait_for_approval`.\n\n**Refused, each because a save must know what runs and a run must give the same answer twice:** regex\n(unbounded matching: use the string methods), `Math.random`, `Date.now()` and `new Date()` (use `now()` and\n`parseDate`), a tool or helper as a value or a computed callee (callees are named), `new` of anything,\n`instanceof`, `delete`, `void`, rest elements, computed keys, classes, `throw`, `finally`, `import`,\n`export`, `eval`, labels, and a function as a value (`const f = (x) => ...`: pass the arrow to the helper\nthat takes it). `xs.sort(...)` on a name or path is refused too, since a value is never changed in place:\nwrite `xs.toSorted(...)` or `[...xs].sort(...)`.\n\n## Typing\n\n- The body is type-checked as TypeScript before it is read: annotations, `as`, `satisfies` and a non-null `!`\n are accepted and erased, and the checker is strict about null. The stored body keeps none of them and is checked again as\n stored, by field key, as every later change to its app or tables checks it: a body that types only through one of them is\n refused at save.\n- Narrow what is not typed (a connector\'s JSON is `unknown`) with `typeof`, `in`, `Array.isArray` and `isNull`.\n- `first(xs)` may be undefined; `requireFirst(xs)` is the list\'s first item or an error.\n- `obj[key]` needs `key` to be a key of `obj`, or `obj` to be a record with a string index.\n\n## Helpers\n\nOrdinary JavaScript works \u2014 the standard library below, the array and string methods, `Math`, `JSON`,\n`Object`, `Array`, `Number`, `parseInt` \u2014 and means what it means in JavaScript, with the differences\nlisted here. Where JavaScript answers NaN, Infinity or undefined, a workflow has none of them and the\nanswer is `null`.\n\n- Standard library: `Math.abs/ceil/floor/trunc/sign/min/max/pow/sqrt`, `Number(x)`, `String(x)`,\n `Boolean(x)`, `parseInt(s, radix?)`, `parseFloat(s)`, `Number.isFinite/isInteger`,\n `Object.keys/values/entries/fromEntries`, `Array.isArray/from` (a list, text, or `{ length: n }`),\n `JSON.parse/stringify`.\n- Arrays: `length, map, filter, find, findIndex, findLast, some, every, reduce, includes, indexOf, join,\n slice, concat, flat, flatMap, at, reverse, toReversed, toSorted` and `[...xs].sort(compare?)`.\n- Strings: `length, toLowerCase, toUpperCase, trim, trimStart, trimEnd, startsWith, endsWith, includes,\n indexOf, slice, substring, split, replace, replaceAll, padStart, padEnd, repeat, charAt, at,\n localeCompare` (en-US order, wherever it runs). Numbers: `toFixed, toString`.\n- Differences from JavaScript: `Math.round` is the engine\'s `round` (a half goes away from zero:\n `Math.round(-2.5)` is `-3`); `Number(x)` and `parseFloat(x)` are `toNumber`, which refuses text that is no\n number; `String(null)` is `""`; `join` writes a null item as `null`; `concat` skips a null; `toFixed`\n rounds the decimal, not the binary double; `replaceAll` writes the replacement as text\n (`$&` stays as typed), while `replace` expands `$&`, `$$`, `` $` `` and `$\'` in it as JavaScript does; `split` takes no limit; `sort` without a comparator orders by text, as JavaScript does. `==`, `!=`, `===` and `!==` between two list values (files, a multi-select, a link of any cardinality, a lookup) are refused at save, since a list compares by identity, not by its items: `!isNull(changes.<field>)` asks whether one changed.\n\nThe same work can be written with the helpers below, which are additions to JavaScript. They are called as\n`size(arr)`; the method form works only where the name is also a JavaScript method \u2014\n`x.trim()`, `arr.includes(v)`, `arr.at(-1)`, `arr.map(fn)`, `s.split(",")`; `arr.size()` is refused.\n\n- **Null and type**: `isNull`, `isNotNull`, `isEmpty`, `isString`, `isNumber`, `isBoolean`, `isArray`,\n `isObject`, `coalesce`, `get`, `toNumber`, `toFigure`, `toString` (a text, number, yes/no or date; a record, file or list is refused at save, an object at run), `typeOf`, `parseJson`, `toJson`. `isNull` is strict (`null` and `undefined` only), but on a list field of the record (a link, files, a multi-select) it asks whether the list holds nothing, since a cleared list is `[]`: `!isNull(record.vehicle)` reads as `!isEmpty(record.vehicle)`. `isNull(changes.<field>)` stays strict: null there means unchanged\n- **Lists**: `size`, `first`, `requireFirst` (the first item, refusing an empty list \u2014 after a\n `validate` on the size it saves an `if`), `last`, `nth`, `at` (`at(arr, -1)` counts from the end),\n `includes`, `filter`, `find`, `some`, `every`, `pluck`, `sortBy`, `groupBy`, `countBy`, `unique`,\n `uniqueBy`, `compact` (drops falsy items), `flatten`, `reverse`, `slice`, `concat`, `difference`,\n `differenceBy`, `intersection`, `intersectionBy`, `list`, `reduce(arr, (acc, x) => ..., initial)`,\n `range(end)` / `range(start, end)`\n- **Math**: `sum`, `sumBy`, `mean`, `meanBy`, `minBy`, `maxBy`, `round`, `ceil`, `floor`, `min`, `max`,\n `abs`, `mod`, `pow`, `sqrt`, `clamp`, `percentage`\n- **Text**: `upper`, `lower`, `capitalize`, `trim`, `contains`, `startsWith`, `endsWith`, `replace`,\n `replaceAll`, `removeChars(str, chars)` (without every character that occurs in `chars`),\n `removeSpaces(str)` (without any whitespace; a Word template command calls both too), `substring`, `length`, `split`, `join`, `padStart(str, length, char)`,\n `padEnd(str, length, char)`, `formatNumber(x, decimals)` (ungrouped; rounds the decimal, a half away\n from zero: `formatNumber(1.005, 2)` is `1.01`),\n `formatDecimal(x, decimals, locale)` (grouped: `formatDecimal(151000, 0, "vi-VN")` is `151.000`),\n `numberToWords(x, lang?)` (an integer in words: Vietnamese, or English with `"en"`),\n `uuidFrom(text)` (the same UUID for the same text, refusing null and empty text: `uuidFrom(record.id)`)\n- **Objects**: `keys`, `values`, `entries`, `nonNullKeys`, `pick`, `omit`, `merge`\n- **Dates**, in the workspace\'s timezone: `now`, `formatDate`, `parseDate`, `addDays`, `subDays`,\n `addHours`, `subHours`, `addMinutes`, `subMinutes`, `startOfDay`, `endOfDay`,\n `differenceInCalendarDays`, `differenceInHours`, `differenceInMinutes`, `isBefore`, `isAfter`,\n `isSameDay`, `isToday`, `isWithinRange`, `meetsBounds`\n- **Other**: `formatCurrency(amount, locale, currency)`, `randomNumber(len)`,\n `randomAlphaNumeric(len)`, `sample(items)` (a random item)\n\nOver a null or empty list `mean`, `meanBy`, `min`, `max`, `minBy` and `maxBy` answer null, while `sum`\nand `sumBy` answer 0: `maxBy(pieces, "reach")?.Name` prints a blank for a month with no pieces.\n\nA date helper given a null or empty date returns null, so guard its result before comparing or\nwriting it: `differenceInCalendarDays(a, b) ?? 0`.\n\n`filter`, `find`, `some`, `every`, `sortBy`, `pluck`, `sumBy`, `meanBy`, `minBy`, `maxBy`, `groupBy`,\n`countBy`, `uniqueBy`, `differenceBy` and `intersectionBy` take a path string\n(`filter(record.items, "Status", "open")`) or a callback\n(`filter(record.items, x => x.Status == "open" && x.Amount > 100)`); `reduce` takes a callback and an\ninitial value. A callback gets `(item, idx)` (`reduce`: `(acc, item, idx)`) and reads `record`,\n`runtime`, the names in scope, loop items and an enclosing callback\'s parameters. Its body is one\nexpression \u2014 `(x) => <expr>`, `(x) => { return <expr>; }` or `function (x) { return <expr>; }`;\nseveral statements, `async` and named function expressions are refused.\n\nLook a row up by key rather than filtering for it: a `filter` inside a callback walks its whole list again\nfor every item, so a thousand items against a few hundred rows is hundreds of thousands of calls. Group the\nrows once, then read each group by its key. `groupBy` answers an object of lists, and `byCode[code] ?? []`\nis the group a code names, or an empty list:\n\n```\nconst parties = await query_records({ table_id: "parties" });\nconst lines = await query_records({ table_id: "sheet_lines" });\nconst byCode = groupBy(parties.records, (p) => p.data["code"] ?? "");\nconst holders = lines.records.map((line) => ({\n line: line.id,\n parties: (byCode[line.data["code"] ?? ""] ?? []).map((p) => p.id),\n}));\n```\n\nA key is read exactly: a code holding a dot, a slash or a bracket reads its own group, and a name every\nobject inherits (`constructor`) reads nothing unless a row made a group of that name. `get(o, "a.b")`\nfollows a dotted path instead, so a key that holds a dot is read with `o[key]`.\n\n## Writing a field: null and undefined\n\nIn `update_records`\' `set` and `create_records`\' `records`:\n\n- `null` clears the field.\n- `undefined`, or leaving the key out, leaves the field as it is.\n\nSo passing a read that may be null (`record["x"]`) keeps a value when there is one and clears the\nfield when there is none. Use `coalesce(x, fallback)` only for a real fallback value.\n\n## Examples\n\nCompare a link by id. Display text is not unique, and a link reads as a list of ids \u2014 `==` against a\nstring is a type error:\n\n```\nconst found = await query_records({\n table_id: "customers",\n filters: { node_type: "condition", field_key: "name", operator: "equals", value: "ACME Corp" },\n});\nconst customer = first(found.records);\nconst order = (await query_records({ table_id: "orders", record_ids: [trigger.app_workflow.inputs.order_id] })).records[0];\n\nif (customer && includes(order.data["customer"], customer.id)) {\n // \u2026\n}\n```\n\nRead an uploaded sheet and add the rows a table lacks. `excel_get_rows`, a workflow step, answers every\nrow of an `.xlsx`, `.xls` or `.csv` sheet in one call, each an object keyed by the header row\'s text. A cell is a number,\n`true` or `false`, text, a date as ISO text, or null when blank, so `row["Header"]` is typed\n`string | number | boolean | null` and a helper narrows it: `toString` for text, `toNumber` for a number,\n`isString` before a date. Every cell of a `.csv` is text. `columns` keeps only the ones the body reads.\nA sheet past what one call reads is refused with its size, never cut short: the step\'s entry in\n`workflow_steps` states the bounds.\n\n`import_rows`, a workflow step, matches those rows to a table by a key and writes what differs. `match`\nnames the fields that identify a row, compared as trimmed text with case ignored: a row matching no live\nrow is added, one matching a row changes only the cells that differ, and one that changes nothing is\ncounted and not written, so the same file run twice writes nothing the second time. `values` holds a\nrow\'s cells by field alias, as `create_records` takes them; a key the row omits is left as the table holds\nit, and `null` is an empty cell, which differs from a filled one as any other value does. `dry_run: true` judges the import as a real one would be, rolls it back and answers what it would write or why it is refused.\n\nA `match` field may also be a link field holding one row, compared by the row linked: lines keyed by\ntheir parent and a line number state `match: ["invoice", "line"]`, the parent given as its\nrecord id or as `{ lookup: "<text>" }`.\n\nWhat the step is not certain of is held \u2014 answered with why, and not written: a row carrying `hold`\n(its text is the message); a row whose key several live rows carry, or an earlier row of the same call;\na row the table\'s checks refuse, or whose row is locked; with `create: false`, a row matching none. A\nheld row never fails the step, and the rows beside it are written. A row whose key names a row outside\nthe rows the run may write is held `refused` with nothing of that row: no `record_id`, no `changes`.\n\nThe step removes no row, and a row the source leaves out is never touched. A row carrying\n`remove: true`, with its key in `values`, proposes moving the row its key names to the trash: it is\nalways held, whatever `create` and `conflicts` say, and a key naming no row is counted unchanged.\n\n`conflicts` states what a matched row does where a live cell holds another value: `"hold"` writes the\nrow\'s empty cells and holds the differing ones, `"overwrite"` writes over them, `"fill_empty"` leaves\nthem. `conflicts.fields` gives one field its own. A number and the field its unit or currency is read\nfrom on each row are one cell, under the number\'s policy: both are written, or both held.\n\nA link cell takes `{ lookup: "<text>" }` in place of record ids: the linked row is the one row holding\nthat text in the linked table\'s unique or autonumber field, else the one titled it, among the rows the\nrun reads of that table. A text naming no row, or several, holds the row, and the rows it could be are\nanswered beside it. A link holding many rows takes a list, `[{ lookup: "<text>" }, \u2026]`: each text is\nresolved by itself.\n\nThe step keeps nothing of its own: its answer is everything it did. `written` lists each record it\nwrote \u2014 a changed one with each field from old to new as text, a created one by its id alone \u2014 and\n`held_rows` each row it held: its `kind`, `reason` and `message`, the `record_id` of the row it matched,\nits `key`, the `changes` it would make, the `links` it could not resolve with their `candidates`, and\n`values` \u2014 its key and the cells it would write, as the call gave them. A later call given a held row\'s\n`values` under `conflicts: { default: "overwrite" }` writes it. Both lists are read under the run\'s own\nreach of the table, and a workflow hands its caller only what it returns. Two calls matching one table\nby the same fields take turns, so the second matches the rows the first added. The step\'s entry in\n`workflow_steps` states its bounds.\n\nA workflow does not put an import back: that reads the history of what the run wrote, which a member\'s agent reads\nunder the member\'s own access to the table and no workflow does. A member\'s agent puts an import back with\n`update_records`: `query_audit_logs` on the table shows what the run wrote \u2014 the run is what `runtime.execution_id` held in\nit, which a body keeps on a row of its own \u2014 and each field\'s value before it, `update_records` writes that value back as\nthe member\'s own write, `update_records` with `restore: true` brings back a row the run moved to the trash, and\n`delete_records` removes a row the run created. Write a field back only where the row still holds what the run wrote.\n\nWhat a business keeps of an import \u2014 the file, its counts, the lines waiting for a person \u2014 is rows of\nits own tables, written by the workflow that ran the step. A garden centre brings its grower\'s price\nsheet into its plants, and keeps each sheet and its lines. A line names its plant through a link\nholding many rows: one holding a single row would make the line that plant\'s own, and a plant its\nlines belong to is not moved to the trash.\n\nThese tables are shown beside the plants, in the same app: the sheets as a log of the plants\' register\n(a further List at the root, `reference/app-node`), a sheet\'s lines on the page it opens. A line keeping what a cell held\nand what it holds now in two fields reads one against the other (`compare`, `reference/list-node`).\n\n```\nconst sheet = await excel_get_rows({ // a workflow step\n file_id: trigger.app_workflow.inputs.file,\n columns: ["Code", "Name", "Price"],\n});\n\nconst imported = await import_rows({ // a workflow step\n table_id: "plants",\n match: ["code"],\n rows: sheet.rows.map((row, at) => ({\n line: at + 2,\n hold: isNull(row["Price"]) ? "The sheet states no price" : null,\n values: {\n code: toString(row["Code"]),\n name: toString(row["Name"]),\n price: toNumber(row["Price"]),\n },\n })),\n});\n\nconst kept = await create_records({\n table_id: "price_sheets",\n records: [{\n file: [trigger.app_workflow.inputs.file],\n run: runtime.execution_id,\n read: imported.read,\n created: imported.created,\n changed: imported.changed,\n held: imported.held,\n }],\n});\nconst sheet_id = kept.record_ids[0];\n\nfor (let start = 0; start < size(imported.written); start += 50) {\n await create_records({\n table_id: "sheet_lines",\n records: slice(imported.written, start, start + 50).map((one) => ({\n sheet: [sheet_id],\n plant: [one.record_id],\n line: one.line,\n outcome: one.kind,\n changes: join(one.changes.map((change) => `${change.label}: ${change.before ?? "\u2014"} \u2192 ${change.after ?? "\u2014"}`), "; "),\n })),\n });\n}\nfor (let start = 0; start < size(imported.held_rows); start += 50) {\n await create_records({\n table_id: "sheet_lines",\n records: slice(imported.held_rows, start, start + 50).map((one) => ({\n sheet: [sheet_id],\n plant: isNull(one.record_id) ? [] : [one.record_id],\n line: one.line,\n outcome: "held",\n why: one.message,\n code: toString(sheet.rows[coalesce(one.line, 2) - 2]["Code"]),\n name: toString(sheet.rows[coalesce(one.line, 2) - 2]["Name"]),\n price: toNumber(sheet.rows[coalesce(one.line, 2) - 2]["Price"]),\n changes: join(one.changes.map((change) => `${change.label}: ${change.before ?? "\u2014"} \u2192 ${change.after ?? "\u2014"}`), "; "),\n })),\n });\n}\n\nreturn({ status: "success", message: `${imported.created} added, ${imported.changed} changed, ${imported.held} held` });\n```\n\nA held line keeps the cells the sheet states for it, found by the `line` the step answered, in\nfields of its own. A held row\'s `key` and `values` name fields by their keys, as every record a step answers a body does, so\nthe body reads the sheet again rather than those names. It is applied by the act on it, whose workflow hands the step those values again and\nlets it write over what the plant holds. A cell the line holds none of is left out, so a held line\nnever empties a cell of the plant:\n\n```\nconst line = (await query_records({ table_id: "sheet_lines", record_ids: [trigger.app_workflow.inputs.record_id] })).records[0];\nconst values = {\n code: line.data["code"],\n ...(isEmpty(line.data["name"]) ? {} : { name: line.data["name"] }),\n ...(isNull(line.data["price"]) ? {} : { price: line.data["price"] }),\n};\n\nconst applied = await import_rows({ // a workflow step\n table_id: "plants",\n match: ["code"],\n conflicts: { default: "overwrite" },\n rows: [{ values }],\n});\nif (applied.held > 0) {\n return({ status: "error", message: applied.held_rows[0].message });\n}\n\nawait update_records({ table_id: "sheet_lines", record_ids: [line.id], set: { outcome: "applied" } });\nreturn({ status: "success", message: "Applied." });\n```\n\nA sheet is put back by a member\'s agent with `update_records` over the plants its lines name, 50 at a time, from the values\n`query_audit_logs` shows for the run the sheet kept.\n\nA shop matches a bank statement to the notes it is owed by an act that states `review`. That act\'s workflow only\nproposes: its body calls steps that read and no other, and runs no agent \u2014 either is refused when the body is saved \u2014\nand returns in `data.review` one changeset of the act\'s table, which the platform writes the approved rows of. Called\nwith what the act asks, it answers the proposal and nothing is written; called again with\n`approved: { updates: [{ record_id, sha }], creates: [{ key, sha }] }`, the body runs again and the platform writes\nthose rows as this second run proposes them, so the body never reads `approved`. The platform adds a `sha` to every row\nof the proposal it answers \u2014 the body states none, and one it states is dropped \u2014 and the caller echoes the `sha` of\neach row it approves. A row the second run proposes with other values than the `sha` was read from is left unwritten\n(`changed`), so the body must propose the same for the same inputs and the rows it reads.\nAn update is of a record the act is run over (`record_id`, or one of `record_ids`): one of any other record is left\nunwritten. An act over every row in view is handed no rows: its body reads the rows it may change, and any of them may\nbe proposed. A create carries a `key`, a text of the body\'s own that names the same line on every run\n(`key: toString(line["Reference"])`), unique among the creates; where a confirm repeated must not add a second row,\nthe body also writes the key into a field the table keeps unique, so the repeat\'s create is left as a `conflict`.\nEach row\'s `data` holds only stored fields that change \u2014 never one the platform computes or stamps, nor a file \u2014 with\na `note` and a `ticked` the reader starts from, and `unplaced` lists what was read and matched nothing. Every approved\nrow is checked against its fields before any is written, and they are written in one transaction, so a flawed one\nwrites none. The status history of every row written that moved, or was made holding a status, is recorded in the\nsame run. The answer\'s `applied.previous` holds what each updated record\'s written fields held before, so one `update_records` of it puts the values back, and `delete_records` of a `created` record takes that row away. A body that stops proposing a row once it is\nwritten \u2014 it matches against what the rows hold now \u2014 makes approving twice write once.\n\n```\nconst inputs = trigger.app_workflow.inputs;\n\nconst sheet = await excel_get_rows({ // a workflow step\n file_id: inputs.statement,\n columns: ["Reference", "Amount", "Paid on"],\n});\nconst unpaid = await query_records({\n table_id: "notes",\n filters: { field_key: "paid_on", operator: "is_empty" },\n pagination: { offset: 0, limit: 500 },\n});\nconst acted = groupBy(inputs.record_ids, (id) => toString(id));\nconst owed = filter(unpaid.records, (one) => size(acted[toString(one.id)] ?? []) > 0);\nconst byRef = groupBy(owed, (one) => toString(one.data["ref"]));\n\nconst lines = sheet.rows.map((line) => ({\n line,\n note: first(byRef[toString(line["Reference"])] ?? []),\n}));\nconst updates = filter(lines, (one) => !isNull(one.note)).map((one) => ({\n record_id: toString(one.note?.id),\n note: `Transfer of ${toString(one.line["Amount"])}`,\n ticked: toNumber(one.line["Amount"]) == sumBy(filter(owed, (owing) => owing.id == one.note?.id), (owing) => coalesce(owing.data["amount"], 0)),\n data: { paid_on: toString(one.line["Paid on"]) },\n}));\nconst unplaced = filter(lines, (one) => isNull(one.note)).map((one) => ({\n title: `${toString(one.line["Reference"])}: ${toString(one.line["Amount"])}`,\n note: "Matches no unpaid note",\n}));\n\nreturn({\n status: "success",\n message: `${size(updates)} of ${size(lines)} transfers match a note`,\n data: { review: { version: 1, table_id: "notes", creates: [], updates, unplaced } },\n});\n```\n\n### Saving a record with its lines\n\n`save_record`, a workflow step, saves one record and the rows of other tables that hang off it, all or\nnothing, and recognises what exists by the identity the input carries \u2014 so a call sent twice updates and\nadds nothing. `match` is the record\'s identity, `{ field_key: value }`, and must be exactly a uniqueness\nits table declares: one field marked `unique`, or the fields of one of the table\'s `unique` sets together\n(text, number, date, single select and one-row link fields). A `match` that is no declared uniqueness is\nrefused, naming what the table does declare. A live record carrying the match is found and only the cells\nof `set` that differ are written; none is found and it is added with `match` and `set`. `add_to` appends\nto a files, multi-select or many-link field and keeps what it holds.\n\n`lines` saves rows of other tables that link the record. Each entry names the child `table_id`, its\n`link` \u2014 a field of the child holding one row of this table, which the step fills and no row states \u2014\nthe child fields that, with `link`, are one uniqueness of the child table (`match`), and `rows`, each\nstating every `match` field. A line found by its match changes only the cells that differ, one not found\nis added, and a line the call does not list is left as it is: the step deletes nothing. Two rows of one\nentry cannot share a match.\n\nEverything is checked before the first write, and everything is written in one transaction: a table check\nthat refuses one line, a locked record or line the call would change, a value a field does not take, or a\ntable the run may not write refuses the call and saves nothing, naming the row. Two calls of one identity\ntake turns, so the second finds what the first saved. The answer is `record_id`, `created`,\n`updated_field_keys` (the cells changed on a record that existed) and, per entry of `lines`, the ids it\nadded, changed and left unchanged; these ids are typed for their tables, so a declared `record_link`\noutput or a follow-up write links straight to them.\n\n```\nconst inputs = trigger.app_workflow.inputs;\n\nconst sheet = await excel_get_rows({ // a workflow step\n file_id: inputs.release_order,\n columns: ["Size", "Quantity"],\n});\n\nconst saved = await save_record({ // a workflow step\n table_id: "release_orders",\n match: { number: inputs.number },\n set: { note: "Read from the paper" },\n lines: [{\n table_id: "release_lines",\n link: "order",\n match: ["size"],\n rows: sheet.rows.map((row) => ({\n size: toString(row["Size"]),\n quantity: toNumber(row["Quantity"]),\n })),\n }],\n});\n\nreturn({\n status: "success",\n message: `${size(saved.lines[0].created_ids)} lines added`,\n data: { record_id: saved.record_id },\n});\n```\n\n## Reaching a connected service\n\nA service is reached only by a workflow step, and a step is called inside an app\'s workflow or an automation. From\nnothing to an app\'s run:\n\n1. `get_workspace` with `kind: "connection"` lists each `connected_account_id`.\n2. `set_app` declares a Workflow node that states what a call takes and hands back:\n\n```json\n{\n "type": "Workflow",\n "key": "invoice_order",\n "description": "Issues the KiotViet invoice for a confirmed order and hands back its code.",\n "inputs": { "order": { "type": "record_link", "entity": "order" } },\n "outputs": { "invoice_code": { "type": "text" } }\n}\n```\n\n3. `set_workflow` with a `source` that opens with `on({ type: "app", app_id: "app_\u2026", alias: "invoice_order" });` and this body (the step\'s entry is in `workflow_steps`):\n\n```\nconst order = (await query_records({ table_id: "orders", record_ids: [trigger.app_workflow.inputs.order] })).records[0];\nconst made = await kiotviet_create_invoice({ // a workflow step\n connected_account_id: "cac_\u2026",\n branch_id: 1,\n sold_by_id: 7,\n lines: [{ product_id: 42, quantity: toNumber(order.data["quantity"]), price: toNumber(order.data["price"]) }],\n});\nreturn({ status: "success", message: "Invoiced", data: { invoice_code: made.invoice.code } });\n```\n\n4. `run_app_workflow` with `dry_run: true`, then the same call without it.\n\nA step wants the provider\'s own ids (`branch_id`, `sold_by_id`, `product_id`) and no tool lists them: declare a Workflow\nwhose body calls the provider\'s `list_*` workflow step (`kiotviet_list_branches`), run it, and read the ids from its answer.\nNo step lists KiotViet\'s users: read `sold_by_id` off a row `kiotviet_list_invoices` returns (KiotViet\'s `soldById`), or ask the owner.\n\n## Table checks\n\nA table check is one condition its table\'s record writes must not meet, declared beside the table\'s\n`unique`. It is not a workflow: it reads the write and a few lookups and answers yes or no, so it\ncannot change data. It runs inside the write, on every create, update or delete it is `on`, whichever\nsurface makes it \u2014 a person, the API, the CLI, an agent, a workflow. A refusal writes nothing and\nanswers with the check\'s message, under its field when the check names one, and names the check.\n\nA check is stated in its table\'s document, under `checks`, each under an alias of its own, and\n`set_tables` writes it with the rest of the table: a merge patch edits one, `null` removes it, and a\ncall that makes a table can state its checks, a lookup naming another table of the call by its `ref`.\n`get_table` states a table\'s checks in its document.\n\n| part | what it holds |\n|---|---|\n| `on` | the writes it checks: any of `create`, `update`, `delete` |\n| `when` | optional expression; the check runs only on a write where it is truthy. It cannot read lookups. |\n| `lookups` | optional, by name, the rows to read: `{ table, filter, limit }`, `table` an id or the `ref` of a table of the call, the filter as `query_records` takes it with fields and select options named by alias (`id` matches a row by its own record id), `limit` from 1 to 50 (50 when left out). A filter value that reads the record is `{ "$expr": "record.code" }`. |\n| `fail_when` | expression; truthy refuses the write |\n| `message` | expression for the refusal\'s text \u2014 a fixed text is a quoted string |\n| `field` | optional; the alias of the field the refusal is shown under |\n| `enabled` | `false` keeps the check without deciding writes; absent, it decides |\n\nEvery expression is written in the grammar above, names a table\'s fields and select options by\nalias (`record.status == "closed"`), and reads:\n\n- `record` \u2014 the record as it will be stored; on a delete, the record being deleted.\n- `prev_record` \u2014 on an update or a delete, the record before.\n- `changes` \u2014 on an update, the fields it changes. `!isNull(changes.photos)` asks whether a field changed.\n- `lookups.<name>` \u2014 in `fail_when` and `message`, the rows that lookup read. A row reads as `record` does: its fields by alias beside `id` (`row.start_on`), and a lookup field as the flat list of what it looks up (`first(row.booking_start)` through a one-row link). A lookup over the written table never returns the row being checked, and finds its other rows as the write leaves them, this call\'s other rows included. A field given to a helper as text names it by alias too (`sumBy(lookups.paid, "amount")`); text that names no field of the rows\' table is refused when the check is saved.\n- `runtime.timezone`, `runtime.workspace_id`, `runtime.organization_id`, `runtime.change_origin` (the\n write\'s origin), `runtime.triggered_by_member_id` (the member writing; null for a write no member\n made), `now()`, and `member_of("grp_\u2026")` \u2014 true when the member writing belongs to the group.\n\nIn a check a select reads as its one value (`record.kind == "retail"`), and a link (a one-row link too) and a lookup read as lists (`first(record.customer)`).\n\nA check on several operations reads only what all of them carry, so one on creates and updates reads\nneither `prev_record` nor `changes`.\n\nA write that recomputes another table\'s rows (a rollup over lines, a lookup or a formula over a parent) judges each recomputed row as an update, by the checks `on` `update` that read a field the recompute changed; `changes` is what the recompute changed. A check that refuses on `changes` or on a status the row holds can therefore refuse the write that moved the parent, so it is gated on the field it means (`!isNull(changes.status)`).\n\nA lookup reads with the access of the member who last saved the check, so saving one requires reading\nevery table its lookups read. It reads committed rows: two writes at the same moment can each pass a\ncheck the other would fail, so values no two rows may share are the table\'s `unique`, not a check.\n\nA `when`, a lookup or a `fail_when` that cannot decide \u2014 it fails, or the write\'s time runs out \u2014\nrefuses the write, naming the part it was reading and, for an expression or a filter, what it met\n(`lookups.clash: Filter on "start_on": date \'before\' value \u2026 is not a date`). A draft is checked when it is submitted, as a create; its edits before that are\nnot. A restored record is checked as a create. Cloning a table copies its checks to the member who\nclones it, and is refused when that member cannot read a table a check\'s lookup reads.\n\nOne open order per reference, a row never counting against itself:\n\n```json\n{\n "tables": [\n {\n "ref": "orders",\n "label": "Orders",\n "fields": {\n "reference": { "type": "text", "label": "Reference" },\n "closed_at": { "type": "date", "label": "Closed at" }\n },\n "checks": {\n "one_open_order_per_reference": {\n "on": ["create", "update"],\n "when": "isNull(record.closed_at)",\n "lookups": {\n "same_ref": {\n "table": "orders",\n "filter": {\n "node_type": "group",\n "logic": "and",\n "children": [\n { "node_type": "condition", "field_key": "reference", "operator": "equals", "value": { "$expr": "record.reference" } },\n { "node_type": "condition", "field_key": "closed_at", "operator": "is_empty" }\n ]\n },\n "limit": 2\n }\n },\n "fail_when": "some(lookups.same_ref, (r) => r.id != record.id)",\n "message": "\\"An open order already has this reference.\\"",\n "field": "reference"\n }\n }\n }\n ]\n}\n```\n\n## Table workflows\n\nA table workflow is what follows every committed write of a table\'s rows, whoever makes it: the grid, an import, an agent or\nany app. An effect that must hold for all of them belongs to the table. A button on one screen is that app\'s act, a value fixed\nwhen a row is made is `default_from`, and a rule that refuses a write is a check; a table workflow runs after the write has\ncommitted and cannot refuse it.\n\n`set_workflow` writes it: the `source` opens with `on({ type, table, ... })` and carries the steps, and `enabled: false`\nkeeps the workflow without running it (absent, it runs). `get_table` lists a table\'s workflows to whoever manages the\ntable \u2014 `workflow_id`, name, trigger, whether it is on and its failed runs of the last 7 days \u2014 and `get_workflow` reads one\nback. The table and the trigger type (`create`, `update` or `delete`) are fixed once the workflow is made: to change\neither, delete it and make another. A replace that changes only the gate, the trigger config or `enabled` moves the\nworkflow\'s version. `dry_run` with a `record_id` (and `prev_data` and `changes` for an update) says what the workflow would\nwrite on that row; reads are real and nothing is written.\n\n| part of the header | what it holds |\n|---|---|\n| `type` | the write it follows: `create`, `update` or `delete` |\n| `table` | the table\'s alias |\n| `when` | optional expression over `record`, `prev_record` and `changes`; the workflow runs only after a write where it is truthy |\n| `changed` | optional, with `type: "update"`: field aliases; the workflow runs only after an update that changes any one of them |\n\n```\non({ type: "update", table: "repairs", changed: ["status"], when: "record.status == \\"fixed\\"" });\n\nawait update_records({ table_id: "repairs", record_ids: [trigger.record_id], set: { fixed_on: formatDate(now(), "yyyy-MM-dd") } });\n```\n\nThe body names the table\'s fields, select options and other tables by alias, and reads the write as `record`,\n`prev_record` and `changes` and the row\'s id as `trigger.record_id`. It operates on records and integrations: a body\nthat calls a tool changing a table, an app or any other structure is refused when it is saved. It runs as the table\'s owner, so\nit reaches the tables the owner reaches, and its writes meet the table\'s checks like any other write; a write refused by a\ncheck fails that run and leaves the row as it was. A body names a field by alias where it can tell which table\'s field it is: a\nread of `record`, `prev_record` or `changes`, a row from `query_records` called with a literal `table_id`, and a\nloop over such rows. A name it cannot tie to a table, and an option compared to a value of unknown type (not a read of a field, and\nnot a text, number or other value the type proves holds no option), are refused with what to change. A link reads as the list of the row ids it holds, a one-row link too: compare `first(record.customer) == id`. A lookup reads as one flat list of what it looks up: `record.x != prev_record.x` on one is refused, so compare `first(record.x)` with `first(prev_record.x)`. A single member, a single select, a number or a text reads as the value itself: `first(record.technician)` is refused at save, naming the read.\n\nA write the workflow itself makes sets off the workflows of its table in turn, and a chain of them stops after five. A workflow\nthat writes the row it follows is gated by a `changed` or a `when` that its own write does not satisfy. One run is queued for a\nrow and a workflow at a time, so a burst of writes to one row runs it once more after the run in flight. A field a workflow\nreads cannot be deleted or retyped until the workflow is changed or deleted with `set_workflow` before. Cloning a table copies its\nworkflows for whoever manages it (`include_workflows: false` leaves them), each with a body of its own, and is refused when a\nbody names a table the cloner cannot reach. A workflow runs as the table\'s owner, so whoever saves or rehearses one must manage the\ntable and read every table it reads in full: the body\'s, the gate\'s links\', the written table. An option is written where\nit is compared to, listed against or set on its field; a constant holding one is refused.\n\nA lock is the table\'s: a table workflow locks a row with `update_records` and `locked: true` (an app\'s act may too), and every door then refuses\nthe row\'s writes until whoever manages the table unlocks it with `update_records` and `locked: false` \u2014 in an app\'s run, judged as the member who pressed.\nA locked row reads locked in every app. An app\'s own Check or `edit: false` only makes a row read-only in that app.\n\n## Runs\n\nEach start of a workflow is a run: an app\'s workflow or act, a table workflow, a button, an automation. An agent finds,\nreads, retries and stops runs. A run is read by whoever manages what its workflow belongs to: its\nautomation or its table, and for an app\'s workflow the app\'s owner or an admin, never a manager of the app.\n\n- **Find.** `query_workflow_executions` lists one workflow\'s runs (`workflow_id`), every run started for one row\n (`record_id`) \u2014 a table workflow\'s written row, a pressed button\'s row, an app run\'s `record_id` input \u2014 or both, newest\n first, a page at a time (`next_cursor`). Each run names its workflow and kind, what started it, its row and how it ended:\n for a failure, the step that threw, its tool and the error; for a waiting run, when it wakes, or the approval and the\n approvers it waits on, or the payment. A run started before runs recorded their row carries no `record_id`.\n- **Read.** `get_workflow_execution` gives one run\'s steps in order with each one\'s tool, input, output and error, what it\n returned, how many rows it wrote (a row\'s own history is in `query_audit_logs`) and the runs its writes started. Ninety days\n after a run starts, its trigger and its steps\' input and output are pruned; the read says so and gives the rest.\n `run_app_workflow` answers each run it makes with its `execution_id`.\n- **Retry.** `set_workflow_execution` with `action: "retry"` runs a failed, refused or cancelled run again as a new run linked to the first\n (`retry_of`): the first run\'s trigger and row, the workflow\'s current steps and its owner\'s current access, with the\n member who retries it as the one who started it. A run is retried once; a retry that fails is retried in turn. It asks\n first when the run, or a run it retried, already wrote rows or called a service no delete reverses. An automation\'s or a\n table workflow\'s retry runs in the background, as its runs do, and answers `queued`; a button\'s and an app\'s run while\n the call waits. An app\'s call is checked again against the workflow\'s current inputs. A pruned run, a run of a workflow\n that is off, a reviewed act\'s run (call the act again) and a recording\'s filing (the recording retries its delivery)\n are not run again.\n- **Stop.** `set_workflow_execution` with `action: "cancel"` stops a waiting run and withdraws its pending approvals. Both answer\n `execution_id`, `action`, `status`, `retry_of`, `error` and, for a retry, `original_wrote`: the rows and the irreversible\n calls the first run wrote, which the retry makes again.\n\nOff means off: a waiting run whose workflow was turned off, deleted, or taken off its automation, table, button or app ends\n`cancelled` when it would resume, saying why, and runs no further step.\n\nA waiting run keeps its version: when it resumes, it runs the steps of the version it started with, so an edit saved while it\nwaited changes later runs only. When that version is gone, the run ends failed and says why.\n';
1837
+ var workflows_default = '# Workflows \u2014 the steps a workflow runs\n\nA workflow is written as a strict subset of JavaScript. The source is never run as JavaScript: a\nsave parses it into steps, type-checks it against what its trigger supplies, and stores the steps.\nOnly the forms below are accepted; anything else is refused at save with the line, the column and\nwhat to write instead. A save that succeeds may still return `warnings` \u2014 advisory hints such as a\nloop that may never end. Read them.\n\nA workflow changes records, calls integrations, notifies and makes papers. A tool for anything else \u2014 views, history,\ntables and apps, members, knowledge, skills, editing a file \u2014 is an agent\'s, and a body that\ncalls one is refused at save with that reason. One saved before is refused at run, before its first step.\n\nThe same grammar serves a table\'s workflow, an automation and an app\'s workflow, which `set_workflow` saves, and the\nexpressions of a table check.\n\n## Triggers\n\nEvery workflow\'s source opens with `on({ ... })`, which states what starts it. `set_workflow` saves the source, and\n`get_workflow` reads it back with whether it is on and how its latest runs ended. `get_workflow` without a\n`workflow_id` lists the workflows you may read, a page at a time (`limit` 1 to 100, `offset`, `total`), each with its\n`kind`, its trigger in a line, whether it is on and its failed runs of the last 7 days; `table_id` and `app_id` keep the\nones that follow or name them.\n\n| kind | header |\n|---|---|\n| table | `on({ type: "create" \\| "update" \\| "delete", table: "<alias>", changed?: ["<alias>", ...], when?: "<expression>" })` |\n| automation | `on({ type: "recurring_schedule", cron_expression })`, `receive_webhook`, `receive_gmail_email` or `receive_outlook_email` \u2014 see **Automations** |\n| app | `on({ type: "app", app_id, alias, inputs?, outputs? })` \u2014 `inputs` and `outputs` only for a custom-code app |\n\nWhat starts a workflow is fixed once it is made: a table workflow keeps its trigger type and its table, an app workflow its\napp and alias. To change either, delete the workflow and make another. A replace states the whole trigger, so a key the\nheader leaves out is removed (an app workflow keeps the inputs it leaves out, and derives the outputs it leaves out from\nwhat the body returns). `expected_version` refuses a replace when the workflow has moved since the `version` you read.\n`dry_run` checks the source and stores nothing; given the trial inputs its trigger supplies, it also walks the steps once\nand returns the calls planned, the return value, the failures and the papers rendered. A table trigger takes `record_id`,\n`prev_data` and `changes`; a webhook or email trigger `trigger_payload`; an app trigger `record_id` and `preview_inputs`; a\ntable or automation trigger also `live_reads` (an app\'s rehearsal always reads real data). A trial input its trigger does\nnot supply is refused, naming the ones it takes. A rehearsal needs write scope: a key that reads data alone cannot make one.\n\n`delete: true` with a `workflow_id` removes a workflow, and is the one call that asks for approval; a replace asks for none,\na step it drops included. `even_if_invoked` and `acknowledge_breaking_api_change` belong to an app workflow and are\nrefused for another kind.\n\nAn app workflow reads its call as `trigger.app_workflow.inputs.<name>`, or `inputs.<name>`, which is the same read. A Workflow node receives the inputs it declares;\nan Act\'s `workflow` receives `record_id` (`record_ids` on an act over several rows) and what the Act asks.\n\n### Automations\n\nAn automation answers an event outside the records: a schedule, a webhook, an inbound email. `set_workflow` saves one\nfrom its source \u2014 the `on({...})` declaration, then its steps \u2014 and, given its `workflow_id`, replaces it: the\ntrigger config is the one the header states, a key it leaves out removed, and steps left as it runs them are checked again\nonly when the call turns it on (`enabled`), so one a later rule refuses can still be switched off. An admin names the owner at creation with\n`owner_member_id`. It runs with its owner\'s current permissions, a connected service it names is its owner\'s account, and only its\nowner or an admin reads, changes or deletes it.\n\n```\non({ type: "recurring_schedule", cron_expression: "0 9 * * MON" });\n```\n\nEvery source below is typed, so a misspelled member is refused at save rather than read as nothing.\nAll three carry `trigger.trigger_id`.\n\n| trigger | required config | what the body reads |\n|---|---|---|\n| `recurring_schedule` | `cron_expression` | `runtime.timezone`; the current time is `now()` |\n| `receive_webhook` | `secret`? | `trigger.method`, `trigger.headers[...]`, `trigger.query[...]`, `trigger.body` (any JSON, or raw text when the request is not JSON), `trigger.received_at` |\n| `receive_gmail_email` / `receive_outlook_email` | `connected_account_id`, `filter` | `trigger.email_id`, `trigger.from`, `trigger.to`, `trigger.cc`, `trigger.bcc`, `trigger.subject`, `trigger.date`, `trigger.body` (plain text), `trigger.reply_to`, `trigger.in_reply_to`, `trigger.attachments` (file refs \u2014 assign them straight to a files field). One type serves both providers, so `labels`, `thread_id`, `importance` and `conversation_id` are not readable. |\n\n## Steps\n\n| step | syntax |\n|---|---|\n| tool call, kept | `const <id> = await <tool>({ ...inputs });` \u2014 also `let x = await \u2026` and `x = await \u2026` |\n| tool call | `await <tool>({ ...inputs });` \u2014 the platform\'s own steps (`send_notification`, `generate_document`, \u2026) and a connection\'s tools, each a workflow step (`bkav_submit_invoices`, `kiotviet_create_order`, \u2026); a step that fails ends the run with its reason unless a `try`/`catch` around it handles it |\n| agent | `const <id> = await agent({ instructions, input, model, output });` \u2014 see **An agent step** |\n| app agent | `const <id> = await app_agent({ alias: "<agent alias>", input: { ... } });` \u2014 runs one of the app\'s declared agents, in an app workflow only, and resolves to its declared outputs or its final text. `wait: false` only starts the run and resolves to `{ run_id }`; the run\'s failure is then its own, not the workflow\'s. Refused in a run an agent started. |\n| bind | `const <id> = <expression>;` \u2014 evaluated once; later steps read `<id>`. Bind any expression used twice. |\n| if / else | `if (<expr>) { ... } else { ... }` \u2014 `else` optional, `else if` chains allowed |\n| switch | `switch (<expr>) { case "X": { ... } default: { ... } }` \u2014 string cases only, no fallthrough |\n| for | `for (const <name> of <expr>) { ... }` |\n| wait | `await wait({ duration_in_minutes: 5 });` |\n| wait for an event | `await wait_for_event({ event_type: "webhook", event_ref: <expr>, timeout_in_minutes: 60 });` |\n| wait for an approval | `await wait_for_approval({ approvers: <expr>, prompt: <expr> });` \u2014 see **Waiting for an approval** |\n| return | `return({ status: "success" \\| "error", message: <expr>, field_errors: { ... }?, data: { ... }? });` \u2014 `data` holds the outputs a Workflow node declares \u2014 a return of `error` undoes no write a step before it made, so every refusal stands before the body\'s first write |\n| validate | `validate({ checks: [{ fail_when: <expr>, field_key: "...", message: <expr> }, ...] });` |\n\nA delayed run is never cancelled from a workflow: after a `wait`, it reads the row\'s state and acts only if it still holds.\n\nA tool\'s inputs and answer are in its own description; the steps only a body runs \u2014 a connection\'s, a mail\'s \u2014 are each\nan entry of `workflow_steps`. A person connects a service in Lotics under Settings \u2192 Connections; a step names that\nconnection by its `connected_account_id`, which `get_workspace` with `kind: "connection"` lists for the connections the\ncaller can use. The app\'s owner and whoever saves the body must both be able to use that connection.\n\nIn a workflow, email reaches a member of the workspace through the step `send_notification`: Lotics shows it to them and\nmails it to their own address, with no connection. Its recipients are members by `member_id` (`get_workspace` with\n`kind: "member"` lists them), member groups or the whole organization. Mail to anyone else goes from a person\'s\nconnected Gmail or Outlook (`gmail_send_email`, `outlook_send_email`), which leaves an unsent draft unless `draft` is\n`false`.\n\n`return` is a call, not a JavaScript `return` statement. `validate` ends the workflow with an error\nwhen a check\'s `fail_when` is truthy; the check\'s `field_key` names the control its message lands on \u2014\nin an app workflow a declared input, or the alias of a field on a table the body names; in an\nautomation it is not checked.\n\nThe name in `const x = await tool({...})` is the step\'s id; later expressions read its output as\n`x.records`, `x.id` and so on. A `// id: my_step` comment directly above a statement sets an explicit\nid; any other `//` comment there becomes the step\'s description.\n\n**`await` inside a statement** runs the call as its own step first, then reads its output where it is\nwritten: `size((await query_records({...})).records)`, an `if` test, a `return` value, a `for-of`\niterable, a tool input. `const x = c ? await t({...}) : null;` is stored as the `if` it means. An\n`await` that would run on some paths only \u2014 inside `&&`, `||`, `??`, `?.`, a nested ternary, a loop\ntest, a `validate` check \u2014 is refused; write the `if`.\n\n**Names are block-scoped, as in JavaScript** \u2014 `const`, `let`, loop items and `catch (e)`: sibling\nblocks may reuse a name and an inner block may shadow an outer one. A local may take a helper\'s name\n(`const size = 3;`), but calling `size(...)` while it is in scope is refused. The names of the tools a\nworkflow calls, the reserved roots, `linked` and `_s` followed by digits cannot be bound.\n\n### Waiting for an approval\n\n`wait_for_approval` is the wait worth binding \u2014 `wait` and `wait_for_event` carry nothing to read.\nBind it to branch on the decision:\n\n```\nconst approval = await wait_for_approval({\n approvers: trigger.app_workflow.inputs.approvers,\n prompt: `Approve this quote?`,\n});\n\nif (approval.status == "approved") {\n // the approved path\n} else {\n // the rejected or timed-out path; approval.decision_comment says why\n}\n```\n\nIt resolves to `{ status: "approved" | "rejected" | "timed_out", decided_by: MemberId | null,\ndecided_at: ISO string, decision_comment: string | null }`.\n\n`approvers` takes a bare member id (`"mbr_\u2026"`), a bare group id (`"grp_\u2026"`) or a full principal\n(`{ type: "member_group", id: "grp_x" }`), alone or in a list \u2014 so a member field\'s value passes as it\nis.\n\n### An agent step\n\n`const x = await agent({ instructions, input, model, output });` runs a model as one step \u2014 read a paper, classify,\ndraft text. It calls no tool: the workflow\'s own steps write, send and notify with what it returns, and a step that\nstates `tools` is refused.\n\n- `instructions` \u2014 a fixed string.\n- `input` \u2014 an object literal; its fields are expressions, evaluated and handed to the model (`{}` for\n none). It is the ONLY workflow data the agent sees: read the trigger, prior steps, `runtime` or the loop\n item here, never inside `instructions`, which is a fixed literal and reads no binding. A field holding a file, or a\n list of files (a files field, a mail\'s attachments), hands the agent the files themselves: a picture or a PDF it\n sees, any other paper read into text.\n- `model` \u2014 optional; omit it to follow the platform\'s default model.\n- `output` \u2014 `{ mode: "text" }` resolves to a string; `{ mode: "object", schema }` to a typed object.\n\nOnly `x` comes back: in text mode `x` is the string itself, in object mode `x.field` reads a field the\n`schema` declares.\n\n## Naming tables and templates\n\nA body names a table or document template by its alias (`query_records({ table_id: "orders" })`); ids still work. `get_workflow` shows aliases. An app\'s workflow reaches only the tables the app uses.\n\n## Fields and options\n\n1. **A body names a table\'s fields and select options by alias**, the names the table\'s document states: a field reads\n as `record["status"]`, an option as `"done"`, a linked field as `linked(record["customer"])[0]["name"]`, in the\n body of a Workflow node, of an Act\'s own `workflow`, of an automation and of a table\'s workflow (`set_workflow`). It is stored by key and `get_workflow` shows it by alias\n again. A field\'s or option\'s key is accepted where its alias is and is kept as written; a display name is refused with the\n aliases there are, and a row that names one field by both its alias and its key is refused. A row written to a table is\n stored by field key wherever the save can follow it to the record tool that writes it: a row or list written in the call,\n a conditional of them, a name holding them, pushed to a `let`, and through `map`, `flatMap`, `concat`, `filter`, `sortBy`,\n `slice` and `reverse`. A field of such rows is read by alias as well (`picks.toSorted((a, b) => a.quantity - b.quantity)`,\n `sortBy(picks, "quantity")`, `rows[0].note`) and stored by key. A body that cannot be stored by key is refused at save,\n naming the statement.\n2. **An Act\'s own `workflow`** has the app\'s guards placed at the top of the body, between `<lotics:guards>` tags,\n replaced on every save: a save that leaves them out or edits them gets the app\'s again, at the top wherever the tags\n sat, so the act\'s row rule, `when`, `requires`, checks and locks always run first.\n3. **A value is read as the record tools store it.** A single select reads as its option and a member as its id; an\n option is written by its alias wherever it meets a select \u2014 compared, listed, switched on, written \u2014 also beside a\n name set from the field (`const s = record.status; s == "done"`), and a select\n value handed on whole (returned, written to a text field) is its key. An option\'s alias searched for in a list the\n save cannot place it against (`includes(filter(record.tags, \u2026), "hot")`) is refused: search the field itself. A link reads as the list of its row ids, a\n one-row link too: `first(record.customer)` is the one id, and `record.customer` used as one id is refused at save,\n naming `first(record.customer)`. A one-row link is written with its one id or a list of one; a many-row link with a list. A lookup reads as one flat list of the values it looks up: `includes(record.owner,\n me)`. A select\'s value is one of another select\'s options only where that table shares its keys, by having been\n duplicated from it, not where an option merely shares its alias. A row that takes its parent\'s option gets it from\n `default_from`, and a lookup shows it.\n4. **What an app sends takes an option by alias or key.** An input or output drawn from a field (`field`, written\n `<table id>.<alias>`) compares and returns by alias in the body, and the run hands it on as the option\'s key. A\n refusal\'s `field_errors` and a `validate` check\'s `field_key` name a declared input or a field\'s alias.\n5. **`==` against a multi-select means "includes".** `record["tags"] == "urgent"` on a multi-select is stored as\n `includes(record["tags"], "urgent")`; either spelling works.\n6. **Text renders labels.** Inside a template or a text tool input, a select, member or link value renders its display\n text: `` `${record["status"]}` `` writes "Done", not `done`.\n\n## What an expression reads\n\n- `record`, `prev_record`, `changes` \u2014 in a table check, the write it checks (see **Table checks**).\n `changes` is a PARTIAL map: `changes["x"]?.next_value` and `?.prev_value`, the `?.` required.\n- `trigger` \u2014 what the trigger carries (see **Triggers**).\n- a loop\'s item name, and `index`, the iteration\'s 0-based index.\n- `<step id>` \u2014 an earlier step\'s output (`dup.records`).\n- `runtime.timezone`, `runtime.workflow_id`, `runtime.execution_id`, `runtime.workspace_id`,\n `runtime.organization_id`, `runtime.change_origin`, `runtime.triggered_by_member_id`. The current\n time is `now()`.\n\n## Paths\n\n- `record.x` and `record["x"]` read a field; `[n]` reads a list\'s n-th item.\n- A path may start at any expression: an awaited call `(await query_records({ table_id: "orders", record_ids: [id] })).records[0].data["x"]`, a helper\'s\n result `first(found.records).id`, a filter `rows.filter((r) => r.ok)[0].n`, a `(a ?? b).c`. `?.` reads\n through a null as usual, except on a value drawn at random (`sample(xs)?.id` is refused: bind it first).\n- A record reads in its declared shape wherever it comes from \u2014 the trigger, a tool result, a `let`, a\n callback parameter: a single select as its option alias or null, a member as its id, a link as its ids. (The record\n tools called outside a workflow return the stored arrays instead.)\n- `linked(record["link"])[n]["x"]` fetches the n-th linked row and reads a field of it; bare\n `linked(record["link"])` is every linked row, one fetch per row. Its argument is a path ending on\n a link field and carries the guard: `linked(record?.["link"])[0]`.\n- `changes["link"]?.next_value` is the same list of ids; `linked()` over a change is refused \u2014\n read `linked(record["link"])` or `linked(prev_record["link"])`.\n\n## Operators and statements\n\nOperators in JavaScript precedence: `? :`, `||`, `??`, `&&`, `== != === !==`, `< <= > >=`, `+ -`, `* / %`,\nprefix `! -`.\n\n- Arithmetic is exact decimal (`0.1 + 0.2` is `0.3`). A result computed from a division that does not end\n is the short decimal it reads as (`1 / 3 * 3` is `1`), and stands as computed where it reads long\n (`1 / 3 * 100` is `33.33333333333333`). A comparison between two numbers reads both at 15 significant\n digits. `round` and `formatNumber` round the decimal, a half away from zero: `round(-2.5)` is `-3`. A\n table filter compares stored values exactly.\n- `??` is `coalesce(left, right)`; `?.` reads through a null (`a?.b`, `a?.[0]`, and `s?.trim()` for the\n method names listed under **Helpers**). `x?.()` is refused: helpers and tools are not values.\n- Templates use backticks and `${...}`.\n- Destructuring \u2014 `const { status, name } = record;` binds each name. Renames\n (`{ status: s }`), quoted keys (`{ "ref": ref }`), array patterns with holes\n (`const [first, , third] = xs;`) and defaults (`{ note = "" }`, which apply on null too) work on\n `const`, `let` and `for (const { id } of rows)`. A tool result destructures directly:\n `const { records } = await query_records({...});`. `const a = 1, b = 2;` declares both. Nested\n patterns and rest are refused.\n- Spread \u2014 `[...a, b]` and `{ ...a, b: 1 }` (later keys win). Refused inside a tool input: bind the\n merged value first and pass the binding.\n- Shorthand \u2014 `{ table_id }` is `{ table_id: table_id }`.\n- `xs.push(a, b);` appends, also on a key (`o.items.push(v)`); `o.a.b = v;` sets a nested key;\n `x ??= v`, `x ||= v` and `x &&= v` assign. A `const` may be pushed to or have a key set, as in\n JavaScript; a loop item is read-only.\n- `undefined` is the same value as `null`, so `x === undefined` is `isNull(x)` and `x !== undefined` is\n `isNotNull(x)`; a key set to null and a key never sent are not told apart (`"k" in o` tells a key that\n is there).\n- `typeof x` answers JavaScript\'s words, and a null (a missing value too) is `"object"`. `k in o` tests an\n object\'s own keys, or a list\'s indexes and `length`.\n- A filter node may leave out `node_type` when its keys say which it is: `field_key` / `operator` /\n `value` a condition, `logic` / `children` a group, `path` / `condition` a traversal.\n- `function name(p = <default>) { return <expr>; }` \u2014 top level only, expanded at every call (a call\n may come before it). Every parameter needs a default, which gives it its type; the body is one\n `return <expr>;` reading only its parameters, helpers and the reserved roots \u2014 never the caller\'s\n names, nor `index`. No recursion; a function never called is refused. A body read back shows the\n expression at each call, not the `function`.\n\n**try / catch.** `try { ... } catch (e) { ... }` catches a tool error or an expression error inside the\nbody; `e` is `{ message, type, step_id?, detail? }`. A failed `validate` and a `return` are not\nerrors \u2014 they end the workflow \u2014 and a step that runs after a wait inside the `try` is outside it.\n`finally` is refused: put always-run steps after the `try`.\n\n**Loops.** `for-of`, `while (cond) { ... }`, `do { ... } while (cond);` and\n`for (let i = 0; i < n; i++) { ... }`, each capped at 10,000 iterations. `break;` and `continue;` act\non the innermost loop; labels and `for-in` are refused.\n\n**`let`.** `let x = <expr>;` declares a block-scoped variable; the initializer is required\n(`let x = null;`). Reassign with `=`, `+=`, `-=`, `*=`, `/=`, `%=`, `??=`, `++` and `--`. Re-declaring in\nthe same block is refused; shadowing in a nested block is allowed. A `let` keeps its value across a\n`wait`, `wait_for_event` or `wait_for_approval`.\n\n**Refused, each because a save must know what runs and a run must give the same answer twice:** regex\n(unbounded matching: use the string methods), `Math.random`, `Date.now()` and `new Date()` (use `now()` and\n`parseDate`), a tool or helper as a value or a computed callee (callees are named), `new` of anything,\n`instanceof`, `delete`, `void`, rest elements, computed keys, classes, `throw`, `finally`, `import`,\n`export`, `eval`, labels, and a function as a value (`const f = (x) => ...`: pass the arrow to the helper\nthat takes it). `xs.sort(...)` on a name or path is refused too, since a value is never changed in place:\nwrite `xs.toSorted(...)` or `[...xs].sort(...)`.\n\n## Typing\n\n- The body is type-checked as TypeScript before it is read: annotations, `as`, `satisfies` and a non-null `!`\n are accepted and erased, and the checker is strict about null. The stored body keeps none of them and is checked again as\n stored, by field key, as every later change to its app or tables checks it: a body that types only through one of them is\n refused at save.\n- Narrow what is not typed (a connector\'s JSON is `unknown`) with `typeof`, `in`, `Array.isArray` and `isNull`.\n- `first(xs)` may be undefined; `requireFirst(xs)` is the list\'s first item or an error.\n- `obj[key]` needs `key` to be a key of `obj`, or `obj` to be a record with a string index.\n\n## Helpers\n\nOrdinary JavaScript works \u2014 the standard library below, the array and string methods, `Math`, `JSON`,\n`Object`, `Array`, `Number`, `parseInt` \u2014 and means what it means in JavaScript, with the differences\nlisted here. Where JavaScript answers NaN, Infinity or undefined, a workflow has none of them and the\nanswer is `null`.\n\n- Standard library: `Math.abs/ceil/floor/trunc/sign/min/max/pow/sqrt`, `Number(x)`, `String(x)`,\n `Boolean(x)`, `parseInt(s, radix?)`, `parseFloat(s)`, `Number.isFinite/isInteger`,\n `Object.keys/values/entries/fromEntries`, `Array.isArray/from` (a list, text, or `{ length: n }`),\n `JSON.parse/stringify`.\n- Arrays: `length, map, filter, find, findIndex, findLast, some, every, reduce, includes, indexOf, join,\n slice, concat, flat, flatMap, at, reverse, toReversed, toSorted` and `[...xs].sort(compare?)`.\n- Strings: `length, toLowerCase, toUpperCase, trim, trimStart, trimEnd, startsWith, endsWith, includes,\n indexOf, slice, substring, split, replace, replaceAll, padStart, padEnd, repeat, charAt, at,\n localeCompare` (en-US order, wherever it runs). Numbers: `toFixed, toString`.\n- Differences from JavaScript: `Math.round` is the engine\'s `round` (a half goes away from zero:\n `Math.round(-2.5)` is `-3`); `Number(x)` and `parseFloat(x)` are `toNumber`, which refuses text that is no\n number; `String(null)` is `""`; `join` writes a null item as `null`; `concat` skips a null; `toFixed`\n rounds the decimal, not the binary double; `replaceAll` writes the replacement as text\n (`$&` stays as typed), while `replace` expands `$&`, `$$`, `` $` `` and `$\'` in it as JavaScript does; `split` takes no limit; `sort` without a comparator orders by text, as JavaScript does. `==`, `!=`, `===` and `!==` between two list values (files, a multi-select, a link of any cardinality, a lookup) are refused at save, since a list compares by identity, not by its items: `!isNull(changes.<field>)` asks whether one changed.\n\nThe same work can be written with the helpers below, which are additions to JavaScript. They are called as\n`size(arr)`; the method form works only where the name is also a JavaScript method \u2014\n`x.trim()`, `arr.includes(v)`, `arr.at(-1)`, `arr.map(fn)`, `s.split(",")`; `arr.size()` is refused.\n\n- **Null and type**: `isNull`, `isNotNull`, `isEmpty`, `isString`, `isNumber`, `isBoolean`, `isArray`,\n `isObject`, `coalesce`, `get`, `toNumber`, `toFigure`, `toString` (a text, number, yes/no or date; a record, file or list is refused at save, an object at run), `typeOf`, `parseJson`, `toJson`. `isNull` is strict (`null` and `undefined` only), but on a list field of the record (a link, files, a multi-select) it asks whether the list holds nothing, since a cleared list is `[]`: `!isNull(record.vehicle)` reads as `!isEmpty(record.vehicle)`. `isNull(changes.<field>)` stays strict: null there means unchanged\n- **Lists**: `size`, `first`, `requireFirst` (the first item, refusing an empty list \u2014 after a\n `validate` on the size it saves an `if`), `last`, `nth`, `at` (`at(arr, -1)` counts from the end),\n `includes`, `filter`, `find`, `some`, `every`, `pluck`, `sortBy`, `groupBy`, `countBy`, `unique`,\n `uniqueBy`, `compact` (drops falsy items), `flatten`, `reverse`, `slice`, `concat`, `difference`,\n `differenceBy`, `intersection`, `intersectionBy`, `list`, `reduce(arr, (acc, x) => ..., initial)`,\n `range(end)` / `range(start, end)`\n- **Math**: `sum`, `sumBy`, `mean`, `meanBy`, `minBy`, `maxBy`, `round`, `ceil`, `floor`, `min`, `max`,\n `abs`, `mod`, `pow`, `sqrt`, `clamp`, `percentage`\n- **Text**: `upper`, `lower`, `capitalize`, `trim`, `contains`, `startsWith`, `endsWith`, `replace`,\n `replaceAll`, `removeChars(str, chars)` (without every character that occurs in `chars`),\n `removeSpaces(str)` (without any whitespace; a Word template command calls both too), `substring`, `length`, `split`, `join`, `padStart(str, length, char)`,\n `padEnd(str, length, char)`, `formatNumber(x, decimals)` (ungrouped; rounds the decimal, a half away\n from zero: `formatNumber(1.005, 2)` is `1.01`),\n `formatDecimal(x, decimals, locale)` (grouped: `formatDecimal(151000, 0, "vi-VN")` is `151.000`),\n `numberToWords(x, lang?)` (an integer in words: Vietnamese, or English with `"en"`),\n `uuidFrom(text)` (the same UUID for the same text, refusing null and empty text: `uuidFrom(record.id)`)\n- **Objects**: `keys`, `values`, `entries`, `nonNullKeys`, `pick`, `omit`, `merge`\n- **Dates**, in the workspace\'s timezone: `now`, `formatDate`, `parseDate`, `addDays`, `subDays`,\n `addHours`, `subHours`, `addMinutes`, `subMinutes`, `startOfDay`, `endOfDay`,\n `differenceInCalendarDays`, `differenceInHours`, `differenceInMinutes`, `isBefore`, `isAfter`,\n `isSameDay`, `isToday`, `isWithinRange`, `meetsBounds`\n- **Other**: `formatCurrency(amount, locale, currency)`, `randomNumber(len)`,\n `randomAlphaNumeric(len)`, `sample(items)` (a random item)\n\nOver a null or empty list `mean`, `meanBy`, `min`, `max`, `minBy` and `maxBy` answer null, while `sum`\nand `sumBy` answer 0: `maxBy(pieces, "reach")?.Name` prints a blank for a month with no pieces.\n\nA date helper given a null or empty date returns null, so guard its result before comparing or\nwriting it: `differenceInCalendarDays(a, b) ?? 0`.\n\n`filter`, `find`, `some`, `every`, `sortBy`, `pluck`, `sumBy`, `meanBy`, `minBy`, `maxBy`, `groupBy`,\n`countBy`, `uniqueBy`, `differenceBy` and `intersectionBy` take a path string\n(`filter(record.items, "Status", "open")`) or a callback\n(`filter(record.items, x => x.Status == "open" && x.Amount > 100)`); `reduce` takes a callback and an\ninitial value. A callback gets `(item, idx)` (`reduce`: `(acc, item, idx)`) and reads `record`,\n`runtime`, the names in scope, loop items and an enclosing callback\'s parameters. Its body is one\nexpression \u2014 `(x) => <expr>`, `(x) => { return <expr>; }` or `function (x) { return <expr>; }`;\nseveral statements, `async` and named function expressions are refused.\n\nLook a row up by key rather than filtering for it: a `filter` inside a callback walks its whole list again\nfor every item, so a thousand items against a few hundred rows is hundreds of thousands of calls. Group the\nrows once, then read each group by its key. `groupBy` answers an object of lists, and `byCode[code] ?? []`\nis the group a code names, or an empty list:\n\n```\nconst parties = await query_records({ table_id: "parties" });\nconst lines = await query_records({ table_id: "sheet_lines" });\nconst byCode = groupBy(parties.records, (p) => p.data["code"] ?? "");\nconst holders = lines.records.map((line) => ({\n line: line.id,\n parties: (byCode[line.data["code"] ?? ""] ?? []).map((p) => p.id),\n}));\n```\n\nA key is read exactly: a code holding a dot, a slash or a bracket reads its own group, and a name every\nobject inherits (`constructor`) reads nothing unless a row made a group of that name. `get(o, "a.b")`\nfollows a dotted path instead, so a key that holds a dot is read with `o[key]`.\n\n## Writing a field: null and undefined\n\nIn `update_records`\' `set` and `create_records`\' `records`:\n\n- `null` clears the field.\n- `undefined`, or leaving the key out, leaves the field as it is.\n\nSo passing a read that may be null (`record["x"]`) keeps a value when there is one and clears the\nfield when there is none. Use `coalesce(x, fallback)` only for a real fallback value.\n\n## Examples\n\nCompare a link by id. Display text is not unique, and a link reads as a list of ids \u2014 `==` against a\nstring is a type error:\n\n```\nconst found = await query_records({\n table_id: "customers",\n filters: { node_type: "condition", field_key: "name", operator: "equals", value: "ACME Corp" },\n});\nconst customer = first(found.records);\nconst order = (await query_records({ table_id: "orders", record_ids: [trigger.app_workflow.inputs.order_id] })).records[0];\n\nif (customer && includes(order.data["customer"], customer.id)) {\n // \u2026\n}\n```\n\nRead an uploaded sheet and add the rows a table lacks. `excel_get_rows`, a workflow step, answers every\nrow of an `.xlsx`, `.xls` or `.csv` sheet in one call, each an object keyed by the header row\'s text. A cell is a number,\n`true` or `false`, text, a date as ISO text, or null when blank, so `row["Header"]` is typed\n`string | number | boolean | null` and a helper narrows it: `toString` for text, `toNumber` for a number,\n`isString` before a date. Every cell of a `.csv` is text. `columns` keeps only the ones the body reads.\nA sheet past what one call reads is refused with its size, never cut short: the step\'s entry in\n`workflow_steps` states the bounds.\n\n`import_rows`, a workflow step, matches those rows to a table by a key and writes what differs. `match`\nnames the fields that identify a row, compared as trimmed text with case ignored: a row matching no live\nrow is added, one matching a row changes only the cells that differ, and one that changes nothing is\ncounted and not written, so the same file run twice writes nothing the second time. `values` holds a\nrow\'s cells by field alias, as `create_records` takes them; a key the row omits is left as the table holds\nit, and `null` is an empty cell, which differs from a filled one as any other value does. `dry_run: true` judges the import as a real one would be, rolls it back and answers what it would write or why it is refused.\n\nA `match` field may also be a link field holding one row, compared by the row linked: lines keyed by\ntheir parent and a line number state `match: ["invoice", "line"]`, the parent given as its\nrecord id or as `{ lookup: "<text>" }`.\n\nWhat the step is not certain of is held \u2014 answered with why, and not written: a row carrying `hold`\n(its text is the message); a row whose key several live rows carry, or an earlier row of the same call;\na row the table\'s checks refuse, or whose row is locked; with `create: false`, a row matching none. A\nheld row never fails the step, and the rows beside it are written. A row whose key names a row outside\nthe rows the run may write is held `refused` with nothing of that row: no `record_id`, no `changes`.\n\nThe step removes no row, and a row the source leaves out is never touched. A row carrying\n`remove: true`, with its key in `values`, proposes moving the row its key names to the trash: it is\nalways held, whatever `create` and `conflicts` say, and a key naming no row is counted unchanged.\n\n`conflicts` states what a matched row does where a live cell holds another value: `"hold"` writes the\nrow\'s empty cells and holds the differing ones, `"overwrite"` writes over them, `"fill_empty"` leaves\nthem. `conflicts.fields` gives one field its own. A number and the field its unit or currency is read\nfrom on each row are one cell, under the number\'s policy: both are written, or both held.\n\nA link cell takes `{ lookup: "<text>" }` in place of record ids: the linked row is the one row holding\nthat text in the linked table\'s unique or autonumber field, else the one titled it, among the rows the\nrun reads of that table. A text naming no row, or several, holds the row, and the rows it could be are\nanswered beside it. A link holding many rows takes a list, `[{ lookup: "<text>" }, \u2026]`: each text is\nresolved by itself.\n\nThe step keeps nothing of its own: its answer is everything it did. `written` lists each record it\nwrote \u2014 a changed one with each field from old to new as text, a created one by its id alone \u2014 and\n`held_rows` each row it held: its `kind`, `reason` and `message`, the `record_id` of the row it matched,\nits `key`, the `changes` it would make, the `links` it could not resolve with their `candidates`, and\n`values` \u2014 its key and the cells it would write, as the call gave them. A later call given a held row\'s\n`values` under `conflicts: { default: "overwrite" }` writes it. Both lists are read under the run\'s own\nreach of the table, and a workflow hands its caller only what it returns. Two calls matching one table\nby the same fields take turns, so the second matches the rows the first added. The step\'s entry in\n`workflow_steps` states its bounds.\n\nA workflow does not put an import back: that reads the history of what the run wrote, which a member\'s agent reads\nunder the member\'s own access to the table and no workflow does. A member\'s agent puts an import back with\n`update_records`: `query_audit_logs` on the table shows what the run wrote \u2014 the run is what `runtime.execution_id` held in\nit, which a body keeps on a row of its own \u2014 and each field\'s value before it, `update_records` writes that value back as\nthe member\'s own write, `update_records` with `restore: true` brings back a row the run moved to the trash, and\n`delete_records` removes a row the run created. Write a field back only where the row still holds what the run wrote.\n\nWhat a business keeps of an import \u2014 the file, its counts, the lines waiting for a person \u2014 is rows of\nits own tables, written by the workflow that ran the step. A garden centre brings its grower\'s price\nsheet into its plants, and keeps each sheet and its lines. A line names its plant through a link\nholding many rows: one holding a single row would make the line that plant\'s own, and a plant its\nlines belong to is not moved to the trash.\n\nThese tables are shown beside the plants, in the same app: the sheets as a log of the plants\' register\n(a further List at the root, `reference/app-node`), a sheet\'s lines on the page it opens. A line keeping what a cell held\nand what it holds now in two fields reads one against the other (`compare`, `reference/list-node`).\n\n```\nconst sheet = await excel_get_rows({ // a workflow step\n file_id: trigger.app_workflow.inputs.file,\n columns: ["Code", "Name", "Price"],\n});\n\nconst imported = await import_rows({ // a workflow step\n table_id: "plants",\n match: ["code"],\n rows: sheet.rows.map((row, at) => ({\n line: at + 2,\n hold: isNull(row["Price"]) ? "The sheet states no price" : null,\n values: {\n code: toString(row["Code"]),\n name: toString(row["Name"]),\n price: toNumber(row["Price"]),\n },\n })),\n});\n\nconst kept = await create_records({\n table_id: "price_sheets",\n records: [{\n file: [trigger.app_workflow.inputs.file],\n run: runtime.execution_id,\n read: imported.read,\n created: imported.created,\n changed: imported.changed,\n held: imported.held,\n }],\n});\nconst sheet_id = kept.record_ids[0];\n\nfor (let start = 0; start < size(imported.written); start += 50) {\n await create_records({\n table_id: "sheet_lines",\n records: slice(imported.written, start, start + 50).map((one) => ({\n sheet: [sheet_id],\n plant: [one.record_id],\n line: one.line,\n outcome: one.kind,\n changes: join(one.changes.map((change) => `${change.label}: ${change.before ?? "\u2014"} \u2192 ${change.after ?? "\u2014"}`), "; "),\n })),\n });\n}\nfor (let start = 0; start < size(imported.held_rows); start += 50) {\n await create_records({\n table_id: "sheet_lines",\n records: slice(imported.held_rows, start, start + 50).map((one) => ({\n sheet: [sheet_id],\n plant: isNull(one.record_id) ? [] : [one.record_id],\n line: one.line,\n outcome: "held",\n why: one.message,\n code: toString(sheet.rows[coalesce(one.line, 2) - 2]["Code"]),\n name: toString(sheet.rows[coalesce(one.line, 2) - 2]["Name"]),\n price: toNumber(sheet.rows[coalesce(one.line, 2) - 2]["Price"]),\n changes: join(one.changes.map((change) => `${change.label}: ${change.before ?? "\u2014"} \u2192 ${change.after ?? "\u2014"}`), "; "),\n })),\n });\n}\n\nreturn({ status: "success", message: `${imported.created} added, ${imported.changed} changed, ${imported.held} held` });\n```\n\nA held line keeps the cells the sheet states for it, found by the `line` the step answered, in\nfields of its own. A held row\'s `key` and `values` name fields by their keys, as every record a step answers a body does, so\nthe body reads the sheet again rather than those names. It is applied by the act on it, whose workflow hands the step those values again and\nlets it write over what the plant holds. A cell the line holds none of is left out, so a held line\nnever empties a cell of the plant:\n\n```\nconst line = (await query_records({ table_id: "sheet_lines", record_ids: [trigger.app_workflow.inputs.record_id] })).records[0];\nconst values = {\n code: line.data["code"],\n ...(isEmpty(line.data["name"]) ? {} : { name: line.data["name"] }),\n ...(isNull(line.data["price"]) ? {} : { price: line.data["price"] }),\n};\n\nconst applied = await import_rows({ // a workflow step\n table_id: "plants",\n match: ["code"],\n conflicts: { default: "overwrite" },\n rows: [{ values }],\n});\nif (applied.held > 0) {\n return({ status: "error", message: applied.held_rows[0].message });\n}\n\nawait update_records({ table_id: "sheet_lines", record_ids: [line.id], set: { outcome: "applied" } });\nreturn({ status: "success", message: "Applied." });\n```\n\nA sheet is put back by a member\'s agent with `update_records` over the plants its lines name, 50 at a time, from the values\n`query_audit_logs` shows for the run the sheet kept.\n\nA shop matches a bank statement to the notes it is owed by an act that states `review`. That act\'s workflow only\nproposes: its body calls steps that read and no other, and runs no agent \u2014 either is refused when the body is saved \u2014\nand returns in `data.review` one changeset of the act\'s table, which the platform writes the approved rows of. Called\nwith what the act asks, it answers the proposal and nothing is written; called again with\n`approved: { updates: [{ record_id, sha }], creates: [{ key, sha }] }`, the body runs again and the platform writes\nthose rows as this second run proposes them, so the body never reads `approved`. The platform adds a `sha` to every row\nof the proposal it answers \u2014 the body states none, and one it states is dropped \u2014 and the caller echoes the `sha` of\neach row it approves. A row the second run proposes with other values than the `sha` was read from is left unwritten\n(`changed`), so the body must propose the same for the same inputs and the rows it reads.\nAn update is of a record the act is run over (`record_id`, or one of `record_ids`): one of any other record is left\nunwritten. An act over every row in view is handed no rows: its body reads the rows it may change, and any of them may\nbe proposed. A create carries a `key`, a text of the body\'s own that names the same line on every run\n(`key: toString(line["Reference"])`), unique among the creates; where a confirm repeated must not add a second row,\nthe body also writes the key into a field the table keeps unique, so the repeat\'s create is left as a `conflict`.\nEach row\'s `data` holds only stored fields that change \u2014 never one the platform computes or stamps, nor a file \u2014 with\na `note` and a `ticked` the reader starts from, and `unplaced` lists what was read and matched nothing. Every approved\nrow is checked against its fields before any is written, and they are written in one transaction, so a flawed one\nwrites none. The status history of every row written that moved, or was made holding a status, is recorded in the\nsame run. The answer\'s `applied.previous` holds what each updated record\'s written fields held before, so one `update_records` of it puts the values back, and `delete_records` of a `created` record takes that row away. A body that stops proposing a row once it is\nwritten \u2014 it matches against what the rows hold now \u2014 makes approving twice write once.\n\n```\nconst inputs = trigger.app_workflow.inputs;\n\nconst sheet = await excel_get_rows({ // a workflow step\n file_id: inputs.statement,\n columns: ["Reference", "Amount", "Paid on"],\n});\nconst unpaid = await query_records({\n table_id: "notes",\n filters: { field_key: "paid_on", operator: "is_empty" },\n pagination: { offset: 0, limit: 500 },\n});\nconst acted = groupBy(inputs.record_ids, (id) => toString(id));\nconst owed = filter(unpaid.records, (one) => size(acted[toString(one.id)] ?? []) > 0);\nconst byRef = groupBy(owed, (one) => toString(one.data["ref"]));\n\nconst lines = sheet.rows.map((line) => ({\n line,\n note: first(byRef[toString(line["Reference"])] ?? []),\n}));\nconst updates = filter(lines, (one) => !isNull(one.note)).map((one) => ({\n record_id: toString(one.note?.id),\n note: `Transfer of ${toString(one.line["Amount"])}`,\n ticked: toNumber(one.line["Amount"]) == sumBy(filter(owed, (owing) => owing.id == one.note?.id), (owing) => coalesce(owing.data["amount"], 0)),\n data: { paid_on: toString(one.line["Paid on"]) },\n}));\nconst unplaced = filter(lines, (one) => isNull(one.note)).map((one) => ({\n title: `${toString(one.line["Reference"])}: ${toString(one.line["Amount"])}`,\n note: "Matches no unpaid note",\n}));\n\nreturn({\n status: "success",\n message: `${size(updates)} of ${size(lines)} transfers match a note`,\n data: { review: { version: 1, table_id: "notes", creates: [], updates, unplaced } },\n});\n```\n\n### Saving a record with its lines\n\n`save_record`, a workflow step, saves one record and the rows of other tables that hang off it, all or\nnothing, and recognises what exists by the identity the input carries \u2014 so a call sent twice updates and\nadds nothing. `match` is the record\'s identity, `{ field_key: value }`, and must be exactly a uniqueness\nits table declares: one field marked `unique`, or the fields of one of the table\'s `unique` sets together\n(text, number, date, single select and one-row link fields). A `match` that is no declared uniqueness is\nrefused, naming what the table does declare. A live record carrying the match is found and only the cells\nof `set` that differ are written; none is found and it is added with `match` and `set`. `add_to` appends\nto a files, multi-select or many-link field and keeps what it holds.\n\n`lines` saves rows of other tables that link the record. Each entry names the child `table_id`, its\n`link` \u2014 a field of the child holding one row of this table, which the step fills and no row states \u2014\nthe child fields that, with `link`, are one uniqueness of the child table (`match`), and `rows`, each\nstating every `match` field. A line found by its match changes only the cells that differ, one not found\nis added, and a line the call does not list is left as it is: the step deletes nothing. Two rows of one\nentry cannot share a match.\n\nEverything is checked before the first write, and everything is written in one transaction: a table check\nthat refuses one line, a locked record or line the call would change, a value a field does not take, or a\ntable the run may not write refuses the call and saves nothing, naming the row. Two calls of one identity\ntake turns, so the second finds what the first saved. The answer is `record_id`, `created`,\n`updated_field_keys` (the cells changed on a record that existed) and, per entry of `lines`, the ids it\nadded, changed and left unchanged; these ids are typed for their tables, so a declared `record_link`\noutput or a follow-up write links straight to them.\n\n```\nconst inputs = trigger.app_workflow.inputs;\n\nconst sheet = await excel_get_rows({ // a workflow step\n file_id: inputs.release_order,\n columns: ["Size", "Quantity"],\n});\n\nconst saved = await save_record({ // a workflow step\n table_id: "release_orders",\n match: { number: inputs.number },\n set: { note: "Read from the paper" },\n lines: [{\n table_id: "release_lines",\n link: "order",\n match: ["size"],\n rows: sheet.rows.map((row) => ({\n size: toString(row["Size"]),\n quantity: toNumber(row["Quantity"]),\n })),\n }],\n});\n\nreturn({\n status: "success",\n message: `${size(saved.lines[0].created_ids)} lines added`,\n data: { record_id: saved.record_id },\n});\n```\n\n## Reaching a connected service\n\nA service is reached only by a workflow step, and a step is called inside an app\'s workflow or an automation. From\nnothing to an app\'s run:\n\n1. `get_workspace` with `kind: "connection"` lists each `connected_account_id`.\n2. `set_app` declares a Workflow node that states what a call takes and hands back:\n\n```json\n{\n "type": "Workflow",\n "key": "invoice_order",\n "description": "Issues the KiotViet invoice for a confirmed order and hands back its code.",\n "inputs": { "order": { "type": "record_link", "entity": "order" } },\n "outputs": { "invoice_code": { "type": "text" } }\n}\n```\n\n3. `set_workflow` with a `source` that opens with `on({ type: "app", app_id: "app_\u2026", alias: "invoice_order" });` and this body (the step\'s entry is in `workflow_steps`):\n\n```\nconst order = (await query_records({ table_id: "orders", record_ids: [trigger.app_workflow.inputs.order] })).records[0];\nconst made = await kiotviet_create_invoice({ // a workflow step\n connected_account_id: "cac_\u2026",\n branch_id: 1,\n sold_by_id: 7,\n lines: [{ product_id: 42, quantity: toNumber(order.data["quantity"]), price: toNumber(order.data["price"]) }],\n});\nreturn({ status: "success", message: "Invoiced", data: { invoice_code: made.invoice.code } });\n```\n\n4. `run_app_workflow` with `dry_run: true`, then the same call without it.\n\nA step wants the provider\'s own ids (`branch_id`, `sold_by_id`, `product_id`) and no tool lists them: declare a Workflow\nwhose body calls the provider\'s `list_*` workflow step (`kiotviet_list_branches`), run it, and read the ids from its answer.\nNo step lists KiotViet\'s users: read `sold_by_id` off a row `kiotviet_list_invoices` returns (KiotViet\'s `soldById`), or ask the owner.\n\n## Table checks\n\nA table check is one condition its table\'s record writes must not meet, declared beside the table\'s\n`unique`. It is not a workflow: it reads the write and a few lookups and answers yes or no, so it\ncannot change data. It runs inside the write, on every create, update or delete it is `on`, whichever\nsurface makes it \u2014 a person, the API, the CLI, an agent, a workflow. A refusal writes nothing and\nanswers with the check\'s message, under its field when the check names one, and names the check.\n\nA check is stated in its table\'s document, under `checks`, each under an alias of its own, and\n`set_tables` writes it with the rest of the table: a merge patch edits one, `null` removes it, and a\ncall that makes a table can state its checks, a lookup naming another table of the call by its `ref`.\n`get_table` states a table\'s checks in its document.\n\n| part | what it holds |\n|---|---|\n| `on` | the writes it checks: any of `create`, `update`, `delete` |\n| `when` | optional expression; the check runs only on a write where it is truthy. It cannot read lookups. |\n| `lookups` | optional, by name, the rows to read: `{ table, filter, limit }`, `table` an id or the `ref` of a table of the call, the filter as `query_records` takes it with fields and select options named by alias (`id` matches a row by its own record id), `limit` from 1 to 50 (50 when left out). A filter value that reads the record is `{ "$expr": "record.code" }`. |\n| `fail_when` | expression; truthy refuses the write |\n| `message` | expression for the refusal\'s text \u2014 a fixed text is a quoted string |\n| `field` | optional; the alias of the field the refusal is shown under |\n| `enabled` | `false` keeps the check without deciding writes; absent, it decides |\n\nEvery expression is written in the grammar above, names a table\'s fields and select options by\nalias (`record.status == "closed"`), and reads:\n\n- `record` \u2014 the record as it will be stored; on a delete, the record being deleted.\n- `prev_record` \u2014 on an update or a delete, the record before.\n- `changes` \u2014 on an update, the fields it changes. `!isNull(changes.photos)` asks whether a field changed.\n- `lookups.<name>` \u2014 in `fail_when` and `message`, the rows that lookup read. A row reads as `record` does: its fields by alias beside `id` (`row.start_on`), and a lookup field as the flat list of what it looks up (`first(row.booking_start)` through a one-row link). A lookup over the written table never returns the row being checked, and finds its other rows as the write leaves them, this call\'s other rows included. A field given to a helper as text names it by alias too (`sumBy(lookups.paid, "amount")`); text that names no field of the rows\' table is refused when the check is saved.\n- `runtime.timezone`, `runtime.workspace_id`, `runtime.organization_id`, `runtime.change_origin` (the\n write\'s origin), `runtime.triggered_by_member_id` (the member writing; null for a write no member\n made), `now()`, and `member_of("grp_\u2026")` \u2014 true when the member writing belongs to the group.\n\nIn a check a select reads as its one value (`record.kind == "retail"`), and a link (a one-row link too) and a lookup read as lists (`first(record.customer)`).\n\nA check on several operations reads only what all of them carry, so one on creates and updates reads\nneither `prev_record` nor `changes`.\n\nA write that recomputes another table\'s rows (a rollup over lines, a lookup or a formula over a parent) judges each recomputed row as an update, by the checks `on` `update` that read a field the recompute changed; `changes` is what the recompute changed. A check that refuses on `changes` or on a status the row holds can therefore refuse the write that moved the parent, so it is gated on the field it means (`!isNull(changes.status)`).\n\nA lookup reads with the access of the member who last saved the check, so saving one requires reading\nevery table its lookups read. It reads committed rows: two writes at the same moment can each pass a\ncheck the other would fail, so values no two rows may share are the table\'s `unique`, not a check.\n\nA `when`, a lookup or a `fail_when` that cannot decide \u2014 it fails, or the write\'s time runs out \u2014\nrefuses the write, naming the part it was reading and, for an expression or a filter, what it met\n(`lookups.clash: Filter on "start_on": date \'before\' value \u2026 is not a date`). A draft is checked when it is submitted, as a create; its edits before that are\nnot. A restored record is checked as a create. Cloning a table copies its checks to the member who\nclones it, and is refused when that member cannot read a table a check\'s lookup reads.\n\nOne open order per reference, a row never counting against itself:\n\n```json\n{\n "tables": [\n {\n "ref": "orders",\n "label": "Orders",\n "fields": {\n "reference": { "type": "text", "label": "Reference" },\n "closed_at": { "type": "date", "label": "Closed at" }\n },\n "checks": {\n "one_open_order_per_reference": {\n "on": ["create", "update"],\n "when": "isNull(record.closed_at)",\n "lookups": {\n "same_ref": {\n "table": "orders",\n "filter": {\n "node_type": "group",\n "logic": "and",\n "children": [\n { "node_type": "condition", "field_key": "reference", "operator": "equals", "value": { "$expr": "record.reference" } },\n { "node_type": "condition", "field_key": "closed_at", "operator": "is_empty" }\n ]\n },\n "limit": 2\n }\n },\n "fail_when": "some(lookups.same_ref, (r) => r.id != record.id)",\n "message": "\\"An open order already has this reference.\\"",\n "field": "reference"\n }\n }\n }\n ]\n}\n```\n\n## Table workflows\n\nA table workflow is what follows every committed write of a table\'s rows, whoever makes it: the grid, an import, an agent or\nany app. An effect that must hold for all of them belongs to the table. A button on one screen is that app\'s act, a value fixed\nwhen a row is made is `default_from`, and a rule that refuses a write is a check; a table workflow runs after the write has\ncommitted and cannot refuse it.\n\n`set_workflow` writes it: the `source` opens with `on({ type, table, ... })` and carries the steps, and `enabled: false`\nkeeps the workflow without running it (absent, it runs). `get_table` lists a table\'s workflows to whoever manages the\ntable \u2014 `workflow_id`, name, trigger, whether it is on and its failed runs of the last 7 days \u2014 and `get_workflow` reads one\nback. The table and the trigger type (`create`, `update` or `delete`) are fixed once the workflow is made: to change\neither, delete it and make another. A replace that changes only the gate, the trigger config or `enabled` moves the\nworkflow\'s version. `dry_run` with a `record_id` (and `prev_data` and `changes` for an update) says what the workflow would\nwrite on that row; reads are real and nothing is written.\n\n| part of the header | what it holds |\n|---|---|\n| `type` | the write it follows: `create`, `update` or `delete` |\n| `table` | the table\'s alias |\n| `when` | optional expression over `record`, `prev_record` and `changes`; the workflow runs only after a write where it is truthy |\n| `changed` | optional, with `type: "update"`: field aliases; the workflow runs only after an update that changes any one of them |\n\n```\non({ type: "update", table: "repairs", changed: ["status"], when: "record.status == \\"fixed\\"" });\n\nawait update_records({ table_id: "repairs", record_ids: [trigger.record_id], set: { fixed_on: formatDate(now(), "yyyy-MM-dd") } });\n```\n\nThe body names the table\'s fields, select options and other tables by alias, and reads the write as `record`,\n`prev_record` and `changes` and the row\'s id as `trigger.record_id`. It operates on records and integrations: a body\nthat calls a tool changing a table, an app or any other structure is refused when it is saved. It runs as the table\'s owner, so\nit reaches the tables the owner reaches, and its writes meet the table\'s checks like any other write; a write refused by a\ncheck fails that run and leaves the row as it was. A body names a field by alias where it can tell which table\'s field it is: a\nread of `record`, `prev_record` or `changes`, a row from `query_records` called with a literal `table_id`, and a\nloop over such rows. A name it cannot tie to a table, and an option compared to a value of unknown type (not a read of a field, and\nnot a text, number or other value the type proves holds no option), are refused with what to change. A link reads as the list of the row ids it holds, a one-row link too: compare `first(record.customer) == id`. A lookup reads as one flat list of what it looks up: `record.x != prev_record.x` on one is refused, so compare `first(record.x)` with `first(prev_record.x)`. A single member, a single select, a number or a text reads as the value itself: `first(record.technician)` is refused at save, naming the read.\n\nA write the workflow itself makes sets off the workflows of its table in turn, and a chain of them stops after five. A workflow\nthat writes the row it follows is gated by a `changed` or a `when` that its own write does not satisfy. One run is queued for a\nrow and a workflow at a time, so a burst of writes to one row runs it once more after the run in flight. A field a workflow\nreads cannot be deleted or retyped until the workflow is changed or deleted with `set_workflow` before. Cloning a table copies its\nworkflows for whoever manages it (`include_workflows: false` leaves them), each with a body of its own, and is refused when a\nbody names a table the cloner cannot reach. A workflow runs as the table\'s owner, so whoever saves or rehearses one must manage the\ntable and read every table it reads in full: the body\'s, the gate\'s links\', the written table. An option is written where\nit is compared to, listed against or set on its field; a constant holding one is refused.\n\nA lock is the table\'s: a table workflow locks a row with `update_records` and `locked: true` (an app\'s act may too), and every door then refuses\nthe row\'s writes until whoever manages the table unlocks it with `update_records` and `locked: false` \u2014 in an app\'s run, judged as the member who pressed.\nA locked row reads locked in every app. An app\'s own Check or `edit: false` only makes a row read-only in that app.\n\n## Runs\n\nEach start of a workflow is a run: an app\'s workflow or act, a table workflow, a button, an automation. An agent finds,\nreads, retries and stops runs. A run is read by whoever manages what its workflow belongs to: its\nautomation or its table, and for an app\'s workflow the app\'s owner or an admin, never a manager of the app.\n\n- **Find.** `query_workflow_executions` lists one workflow\'s runs (`workflow_id`), every run started for one row\n (`record_id`) \u2014 a table workflow\'s written row, a pressed button\'s row, an app run\'s `record_id` input \u2014 or both, newest\n first, a page at a time (`next_cursor`). Each run names its workflow and kind, what started it, its row and how it ended:\n for a failure, the step that threw, its tool and the error; for a waiting run, when it wakes, or the approval and the\n approvers it waits on, or the payment. A run started before runs recorded their row carries no `record_id`.\n- **Read.** `get_workflow_execution` gives one run\'s steps in order with each one\'s tool, input, output and error, what it\n returned, how many rows it wrote (a row\'s own history is in `query_audit_logs`) and the runs its writes started. Ninety days\n after a run starts, its trigger and its steps\' input and output are pruned; the read says so and gives the rest.\n `run_app_workflow` answers each run it makes with its `execution_id`.\n- **Retry.** `set_workflow_execution` with `action: "retry"` runs a failed, refused or cancelled run again as a new run linked to the first\n (`retry_of`): the first run\'s trigger and row, the workflow\'s current steps and its owner\'s current access, with the\n member who retries it as the one who started it. A run is retried once; a retry that fails is retried in turn. It asks\n first when the run, or a run it retried, already wrote rows or called a service no delete reverses. An automation\'s or a\n table workflow\'s retry runs in the background, as its runs do, and answers `queued`; a button\'s and an app\'s run while\n the call waits. An app\'s call is checked again against the workflow\'s current inputs. A pruned run, a run of a workflow\n that is off, a reviewed act\'s run (call the act again) and a recording\'s filing (the recording retries its delivery)\n are not run again.\n- **Stop.** `set_workflow_execution` with `action: "cancel"` stops a waiting run and withdraws its pending approvals. Both answer\n `execution_id`, `action`, `status`, `retry_of`, `error` and, for a retry, `original_wrote`: the rows and the irreversible\n calls the first run wrote, which the retry makes again.\n\nOff means off: a waiting run whose workflow was turned off, deleted, or taken off its automation, table, button or app ends\n`cancelled` when it would resume, saying why, and runs no further step.\n\nA waiting run keeps its version: when it resumes, it runs the steps of the version it started with, so an edit saved while it\nwaited changes later runs only. When that version is gone, the run ends failed and says why.\n';
1838
1838
 
1839
1839
  // docs/workflow_steps.md
1840
1840
  var workflow_steps_default = '<!-- Generated by backend/scripts/generate_workflow_steps_doc.ts from each step\'s schemas \u2014 never edit by hand. -->\n\n# Workflow steps\n\nThe steps only a workflow body runs: a connection\'s (an invoice issuer, a mailbox, \u2026) and the platform\'s own. Each is\ncalled as `await <step>({ ...inputs })` (see `workflows`); a step\'s inputs and answer are its entry here. A connection\nstep takes the `connected_account_id` that `get_workspace` lists under kind `connection`, and the app\'s owner and\nwhoever saves the body must both be able to use that connection.\n\n**How a step fails.** A step that is refused \u2014 an input the schema rejects, a connection the workspace lacks, an\nanswer the provider refuses \u2014 ends the run, and the run\'s error reads `[<step>] <reason>`. Inside `try { \u2026 } catch\n(e) { \u2026 }` the body handles it instead: `e` is `{ message, type, step_id?, detail? }`, `message` the reason as\nabove. A provider\'s refusal carries the provider\'s own words.\n\n## aggregate\n\n### aggregate_records (workflow step)\n\n- Compute aggregate statistics over records.\n- Fields and select options are named by their alias, as get_table shows them. (chat, MCP, CLI)\n- A workspace admin\'s `as_member` computes over what that member would see.\n- Pass either `aggregate_option` for a single statistic, or `aggregations` to compute several in one call.\n- Example: { "table_id": "...", "aggregate_option": { "operation": "sum", "field_key": "amount" } } Operations by field type: No field: count \u2014 the rows matching `filters`.\n- `filled` counts populated CELLS, which is a different number the moment any are empty.\n- All fields: empty, filled, percent_empty, percent_filled, unique, percent_unique Number: sum, avg, median, min, max, range Date: earliest, latest, date_range Boolean: checked, unchecked, percent_checked, percent_unchecked `group_by` breaks every requested statistic down per distinct value of one field, computed over the same filters \u2014 one call in place of a call per bucket.\n- Example: { "table_id": "...", "group_by": "status", "filters": {...}, "aggregations": [{ "operation": "filled", "field_key": "created_on" }, { "operation": "sum", "field_key": "amount" }] } Restrict the period through `filters` on a date field rather than grouping by that date \u2014 a raw date/datetime groups per distinct stored value.\n- Filters: Operators: `is`, `is_not` on a field holding one value; `is_any_of`, `is_none_of` on text, number, date, select, member and link fields; `lt`, `lte`, `gt`, `gte` on number and date; `is_empty`, `is_not_empty` on every field but yes/no.\n- A date is "YYYY-MM-DD" for that whole day.\n- `docs path="filters"`. (chat, MCP, CLI)\n\n**Inputs**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `table_id` | string | yes | |\n| `as_member` | string | | For a workspace admin: reads as this member would \u2014 their row rules and access. Anyone else is refused. |\n| `filters` | any | | |\n| `aggregate_option` | any | | |\n| `aggregations` | any[] | | At most 25 entries. |\n| `group_by` | string | | Field alias to break the statistics down by. Rows with no value in it are reported separately, not folded into a group. |\n\n**Answer**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `result` | number \\\\| string | | Result of a single `aggregate_option` call \u2014 a number, or a string for dates. |\n| `_units` | object[] | | One answer per unit or currency the rows hold, most rows first; unit null for rows naming none. `result` is the first. |\n| `_units[].unit` | string \\\\| null | yes | |\n| `_units[].value` | number | yes | |\n| `results` | object[] | | One entry per aggregation when `aggregations` is used. |\n| `results[].operation` | string | yes | |\n| `results[].field_key` | string | yes | |\n| `results[].field_name` | string | yes | |\n| `results[].result` | number \\\\| string | yes | |\n| `results[].units` | object[] | | One answer per unit or currency the rows hold, most rows first; unit null for rows naming none. `result` is the first. |\n| `results[].units[].unit` | string \\\\| null | yes | |\n| `results[].units[].value` | number | yes | |\n| `groups` | object[] | | One entry per distinct value of `group_by`, each carrying every requested statistic. |\n| `groups[].key` | string | yes | The group\'s identity. `label` is a spelling of it, and two rows can spell one identity differently. |\n| `groups[].label` | string | | Readable name for `key` where one is known: an option\'s name, a linked record\'s display text, a filename. Absent means no name was available, never that the group is empty. |\n| `groups[].results` | object[] | yes | |\n| `groups[].results[].operation` | string | yes | |\n| `groups[].results[].field_key` | string | yes | |\n| `groups[].results[].field_name` | string | yes | |\n| `groups[].results[].result` | number \\\\| string | yes | |\n| `groups[].results[].units` | object[] | | One answer per unit or currency the rows hold, most rows first; unit null for rows naming none. `result` is the first. |\n| `groups[].results[].units[].unit` | string \\\\| null | yes | |\n| `groups[].results[].units[].value` | number | yes | |\n| `_ungrouped_rows` | number | | Matching rows with no value in the `group_by` field \u2014 counted in no group. |\n| `_group_field_name` | string | | |\n| `_operation` | string | | |\n| `_field_key` | string | | |\n| `_field_name` | string | | |\n| `_value_coverage` | object | | |\n| `_value_coverage.requested` | number | yes | |\n| `_value_coverage.field_name` | string | yes | |\n| `_value_coverage.composite` | boolean | | |\n| `_value_coverage.per_value` | object | | |\n| `_value_coverage.per_value.matched` | number | yes | |\n| `_value_coverage.per_value.missing` | string[] | yes | |\n\n## bkav\n\n### bkav_attach_files (workflow step)\n\n- Attach workspace files (a bi\xEAn b\u1EA3n, a b\u1EA3ng k\xEA) to an invoice on Bkav.\n- Bkav accepts PDF and Excel, at most 1 MB each.\n\n**Inputs**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `connected_account_id` | string | yes | Bkav connected account |\n| `invoice_guid` | string | | |\n| `partner_invoice_id` | string | | Your PartnerInvoiceStringID, when invoice_guid is not set |\n| `file_ids` | string[] | yes | |\n\n**Answer**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `results` | object[] | yes | |\n| `results[].PartnerInvoiceID` | number | yes | |\n| `results[].PartnerInvoiceStringID` | string \\\\| null | yes | Null on a delete\'s answer |\n| `results[].InvoiceGUID` | string | yes | |\n| `results[].InvoiceForm` | string \\\\| null | yes | Null on a draft\'s link answer |\n| `results[].InvoiceSerial` | string \\\\| null | yes | Null on a draft\'s link answer |\n| `results[].InvoiceNo` | number | yes | Assigned by a numbered kind; 0 on a draft |\n| `results[].MTC` | string \\\\| null | yes | M\xE3 tra c\u1EE9u the buyer looks the invoice up with; null on a draft\'s link answer |\n| `results[].MaCuaCQT` | string \\\\| null | | |\n| `results[].Status` | number | yes | 0 succeeded; otherwise MessLog says why |\n| `results[].MessLog` | string | yes | |\n| `failed` | number | yes | Invoices whose own Status is not 0 |\n\n### bkav_cancel_invoices (workflow step)\n\n- Cancel issued invoices.\n- A cancelled invoice still reports to the tax authority; correcting one instead is a replacement or adjustment through bkav_submit_invoices. (workflow step, chat)\n\n**Inputs**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `connected_account_id` | string | yes | Bkav connected account |\n| `invoice_guids` | string[] | | InvoiceGUIDs Bkav returned |\n| `partner_invoice_ids` | string[] | | Your PartnerInvoiceStringIDs |\n| `reason` | string | yes | |\n\n**Answer**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `results` | object[] | yes | |\n| `results[].PartnerInvoiceID` | number | yes | |\n| `results[].PartnerInvoiceStringID` | string \\\\| null | yes | Null on a delete\'s answer |\n| `results[].InvoiceGUID` | string | yes | |\n| `results[].InvoiceForm` | string \\\\| null | yes | Null on a draft\'s link answer |\n| `results[].InvoiceSerial` | string \\\\| null | yes | Null on a draft\'s link answer |\n| `results[].InvoiceNo` | number | yes | Assigned by a numbered kind; 0 on a draft |\n| `results[].MTC` | string \\\\| null | yes | M\xE3 tra c\u1EE9u the buyer looks the invoice up with; null on a draft\'s link answer |\n| `results[].MaCuaCQT` | string \\\\| null | | |\n| `results[].Status` | number | yes | 0 succeeded; otherwise MessLog says why |\n| `results[].MessLog` | string | yes | |\n| `failed` | number | yes | Invoices whose own Status is not 0 |\n\n### bkav_create_bill (workflow step)\n\n- Record a cash-register bill on Bkav.\n- Field names are Bkav\'s.\n\n**Inputs**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `connected_account_id` | string | yes | Bkav connected account |\n| `bill` | object | yes | |\n| `bill.Bill` | object | yes | |\n| `bill.Bill.BillCode` | number | yes | |\n| `bill.Bill.OriginalBillCode` | number | | |\n| `bill.Bill.BillDate` | string | yes | |\n| `bill.Bill.BillStatusID` | number | yes | |\n| `bill.Bill.PayMethod` | string | | |\n| `bill.Bill.Note` | string | | |\n| `bill.ListBillDetails` | object[] | yes | |\n| `bill.ListBillDetails[].ItemTypeID` | number | | 0 goods, 4 description line, 15 promotional goods, \u2026 |\n| `bill.ListBillDetails[].IsDiscount` | boolean | | |\n| `bill.ListBillDetails[].ItemCode` | string | | |\n| `bill.ListBillDetails[].ItemName` | string | yes | |\n| `bill.ListBillDetails[].UnitName` | string | | |\n| `bill.ListBillDetails[].Qty` | number | | |\n| `bill.ListBillDetails[].Price` | number | | |\n| `bill.ListBillDetails[].Amount` | number | | |\n| `bill.ListBillDetails[].TaxRateID` | number | | 1 0%, 2 5%, 3 10%, 4 kh\xF4ng ch\u1ECBu thu\u1EBF, 9 8%, \u2026 |\n| `bill.ListBillDetails[].TaxRate` | number | | |\n| `bill.ListBillDetails[].TaxAmount` | number | | |\n| `bill.ListBillDetails[].OtherAmount` | number | | |\n| `bill.ListBillDetails[].DiscountRate` | number | | |\n| `bill.ListBillDetails[].DiscountAmount` | number | | |\n| `bill.ListBillDetails[].UserDefineDetails` | string | | |\n| `bill.ListBillDetails[].IsIncrease` | boolean \\\\| null | | On an adjustment: true raises the amount, false lowers it, absent adjusts information |\n| `bill.ListBillDetails[].SpecialtyItems` | string | | |\n\n**Answer**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `created` | true | yes | |\n\n### bkav_create_invoice_from_bills (workflow step)\n\n- Raise one invoice from cash-register bills, by their BillGUIDs.\n- Field names are Bkav\'s.\n\n**Inputs**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `connected_account_id` | string | yes | Bkav connected account |\n| `input` | object | yes | |\n| `input.Invoice` | object | yes | |\n| `input.Invoice.InvoiceTypeID` | number | yes | 1 GTGT, 2 b\xE1n h\xE0ng, 5 PXK ki\xEAm VCNB, 6 PXK g\u1EEDi b\xE1n \u0111\u1EA1i l\xFD |\n| `input.Invoice.InvoiceDate` | string | yes | ISO date |\n| `input.Invoice.BuyerCode` | string | | |\n| `input.Invoice.BuyerName` | string | | |\n| `input.Invoice.BuyerTaxCode` | string | | |\n| `input.Invoice.BuyerUnitName` | string | | |\n| `input.Invoice.BuyerAddress` | string | | |\n| `input.Invoice.BuyerBankAccount` | string | | Sent empty when absent: Bkav refuses it missing |\n| `input.Invoice.PayMethodID` | number | | 1 TM, 2 CK, 3 TM/CK, \u2026 |\n| `input.Invoice.ReceiveTypeID` | number | | 1 email, 2 SMS, 3 both, 4 courier |\n| `input.Invoice.ReceiverEmail` | string | | |\n| `input.Invoice.ReceiverMobile` | string | | |\n| `input.Invoice.ReceiverAddress` | string | | Sent empty when absent: Bkav refuses it missing |\n| `input.Invoice.ReceiverName` | string | | Sent empty when absent: Bkav refuses it missing |\n| `input.Invoice.Note` | string | | |\n| `input.Invoice.BillCode` | string | | Sent empty when absent: Bkav refuses it missing |\n| `input.Invoice.CurrencyID` | string | | |\n| `input.Invoice.ExchangeRate` | number | | |\n| `input.Invoice.InvoiceForm` | string | | M\u1EABu s\u1ED1 |\n| `input.Invoice.InvoiceSerial` | string | | K\xFD hi\u1EC7u |\n| `input.Invoice.InvoiceNo` | number | | S\u1ED1; 0 when Bkav assigns it |\n| `input.Invoice.MaCuaCQT` | string | | Set only by a cash-register flow that already holds the authority\'s code |\n| `input.Invoice.UserDefine` | string | | JSON-encoded template fields agreed with Bkav |\n| `input.Invoice.UIDefine` | string | | JSON-encoded delivery-note fields (InvoiceTypeID 5 and 6) |\n| `input.Invoice.isBTH` | boolean | | |\n| `input.Invoice.CCCD` | string | | |\n| `input.Invoice.PassportNumber` | string | | |\n| `input.Invoice.FiscalCodes` | string | | |\n| `input.Invoice.Reason` | string | | Why \u2014 required on a replacement or adjustment |\n| `input.Invoice.OriginalInvoiceIdentify` | string | | `[m\u1EABu s\u1ED1]_[k\xFD hi\u1EC7u]_[s\u1ED1]` of the invoice a replacement or adjustment corrects |\n| `input.Invoice.InvoiceGUID` | string | | Addresses the invoice for an update by guid |\n| `input.BillGUIDs` | string[] | yes | |\n\n**Answer**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `results` | object[] | yes | |\n| `results[].PartnerInvoiceID` | number | yes | |\n| `results[].PartnerInvoiceStringID` | string \\\\| null | yes | Null on a delete\'s answer |\n| `results[].InvoiceGUID` | string | yes | |\n| `results[].InvoiceForm` | string \\\\| null | yes | Null on a draft\'s link answer |\n| `results[].InvoiceSerial` | string \\\\| null | yes | Null on a draft\'s link answer |\n| `results[].InvoiceNo` | number | yes | Assigned by a numbered kind; 0 on a draft |\n| `results[].MTC` | string \\\\| null | yes | M\xE3 tra c\u1EE9u the buyer looks the invoice up with; null on a draft\'s link answer |\n| `results[].MaCuaCQT` | string \\\\| null | | |\n| `results[].Status` | number | yes | 0 succeeded; otherwise MessLog says why |\n| `results[].MessLog` | string | yes | |\n| `failed` | number | yes | Invoices whose own Status is not 0 |\n\n### bkav_delete_invoices (workflow step)\n\n- Delete invoices that were never issued.\n- A numbered one can be deleted only if its number is the highest in its range.\n\n**Inputs**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `connected_account_id` | string | yes | Bkav connected account |\n| `invoice_guids` | string[] | | InvoiceGUIDs Bkav returned |\n| `partner_invoice_ids` | string[] | | Your PartnerInvoiceStringIDs |\n| `reason` | string | | |\n\n**Answer**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `results` | object[] | yes | |\n| `results[].PartnerInvoiceID` | number | yes | |\n| `results[].PartnerInvoiceStringID` | string \\\\| null | yes | Null on a delete\'s answer |\n| `results[].InvoiceGUID` | string | yes | |\n| `results[].InvoiceForm` | string \\\\| null | yes | Null on a draft\'s link answer |\n| `results[].InvoiceSerial` | string \\\\| null | yes | Null on a draft\'s link answer |\n| `results[].InvoiceNo` | number | yes | Assigned by a numbered kind; 0 on a draft |\n| `results[].MTC` | string \\\\| null | yes | M\xE3 tra c\u1EE9u the buyer looks the invoice up with; null on a draft\'s link answer |\n| `results[].MaCuaCQT` | string \\\\| null | | |\n| `results[].Status` | number | yes | 0 succeeded; otherwise MessLog says why |\n| `results[].MessLog` | string | yes | |\n| `failed` | number | yes | Invoices whose own Status is not 0 |\n\n### bkav_explain_invoice_error (workflow step)\n\n- Explain an erroneous invoice to the tax authority without replacing or adjusting it, optionally answering one of its notices.\n\n**Inputs**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `connected_account_id` | string | yes | Bkav connected account |\n| `invoice_guid` | string | yes | |\n| `reason` | string | yes | |\n| `notice_number` | string | | S\u1ED1 of the authority\'s notice being answered |\n| `notice_date` | string | | yyyy-mm-dd; with notice_number |\n\n**Answer**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `results` | object[] | yes | |\n| `results[].InvoiceGUID` | string | yes | |\n| `results[].Status` | number | yes | 0 succeeded; otherwise Msg says why |\n| `results[].Msg` | string | yes | |\n| `failed` | number | yes | Invoices whose own Status is not 0 |\n\n### bkav_explain_replaced_invoice (workflow step)\n\n- Explain to the tax authority an invoice that was replaced or adjusted.\n\n**Inputs**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `connected_account_id` | string | yes | Bkav connected account |\n| `invoice_guid` | string | yes | |\n| `reason` | string | yes | |\n\n**Answer**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `results` | object[] | yes | |\n| `results[].InvoiceGUID` | string | yes | |\n| `results[].Status` | number | yes | 0 succeeded; otherwise Msg says why |\n| `results[].Msg` | string | yes | |\n| `failed` | number | yes | Invoices whose own Status is not 0 |\n\n### bkav_get_bill (workflow step)\n\n- Read a cash-register bill.\n\n**Inputs**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `connected_account_id` | string | yes | Bkav connected account |\n| `seller_tax_code` | string | yes | |\n| `organization_code` | string | yes | |\n| `bill_code` | number | yes | |\n\n**Answer**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `bill` | object | yes | |\n\n### bkav_get_error_notice_pdf (workflow step)\n\n- Save the PDF of an invoice\'s 04/SS error notice as a workspace file.\n\n**Inputs**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `connected_account_id` | string | yes | Bkav connected account |\n| `partner_invoice_id` | string | yes | Your PartnerInvoiceStringID |\n\n**Answer**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `file_id` | string | yes | The ID of the generated file. |\n| `url` | string | yes | The download URL of the generated file. |\n| `filename` | string | yes | The filename of the generated file, including extension. |\n| `mime_type` | string | yes | The MIME type of the generated file. |\n\n### bkav_get_file_by_lookup_code (workflow step)\n\n- Save an invoice\'s display copy, conversion copy or XML as a workspace file, found by the m\xE3 tra c\u1EE9u the buyer was sent.\n\n**Inputs**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `connected_account_id` | string | yes | Bkav connected account |\n| `lookup_code` | string | yes | M\xE3 tra c\u1EE9u |\n| `kind` | "display" \\\\| "converted" \\\\| "xml" | yes | |\n\n**Answer**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `file_id` | string | yes | The ID of the generated file. |\n| `url` | string | yes | The download URL of the generated file. |\n| `filename` | string | yes | The filename of the generated file, including extension. |\n| `mime_type` | string | yes | The MIME type of the generated file. |\n\n### bkav_get_invoice (workflow step)\n\n- Read one invoice as Bkav holds it \u2014 header, lines, status and m\xE3 tra c\u1EE9u.\n\n**Inputs**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `connected_account_id` | string | yes | Bkav connected account |\n| `id` | string | yes | Your PartnerInvoiceStringID or Bkav\'s InvoiceGUID |\n\n**Answer**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `invoice` | object | yes | |\n\n### bkav_get_invoice_file (workflow step)\n\n- Save an invoice\'s PDF or signed XML as a workspace file.\n\n**Inputs**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `connected_account_id` | string | yes | Bkav connected account |\n| `partner_invoice_id` | string | yes | Your PartnerInvoiceStringID |\n| `format` | "pdf" \\\\| "xml" | yes | |\n\n**Answer**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `file_id` | string | yes | The ID of the generated file. |\n| `url` | string | yes | The download URL of the generated file. |\n| `filename` | string | yes | The filename of the generated file, including extension. |\n| `mime_type` | string | yes | The MIME type of the generated file. |\n\n### bkav_get_invoice_history (workflow step)\n\n- Read everything that happened to an invoice on Bkav, oldest first.\n\n**Inputs**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `connected_account_id` | string | yes | Bkav connected account |\n| `invoice_guid` | string | yes | |\n\n**Answer**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `entries` | object[] | yes | |\n\n### bkav_get_invoice_link (workflow step)\n\n- Get a download link for an invoice\'s display copy, conversion copy, or its 04/SS error notice.\n\n**Inputs**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `connected_account_id` | string | yes | Bkav connected account |\n| `partner_invoice_id` | string | yes | Your PartnerInvoiceStringID |\n| `kind` | "display" \\\\| "converted" \\\\| "notice_04ss" | yes | |\n\n**Answer**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `url` | string | yes | |\n\n### bkav_get_invoice_status (workflow step)\n\n- Read an invoice\'s status on Bkav.\n\n**Inputs**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `connected_account_id` | string | yes | Bkav connected account |\n| `invoice_guid` | string | yes | |\n\n**Answer**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `invoice_status_id` | number | yes | 1 m\u1EDBi t\u1EA1o, 2 \u0111\xE3 ph\xE1t h\xE0nh, 3 \u0111\xE3 hu\u1EF7, 5\u20138 ch\u1EDD/\u0111\xE3 thay th\u1EBF\u2013\u0111i\u1EC1u ch\u1EC9nh, 11 \u0111\xE3 c\u1EA5p s\u1ED1 ch\u1EDD k\xFD |\n\n### bkav_get_listing (workflow step)\n\n- Save the b\u1EA3ng k\xEA of one invoice type over a window as a workspace spreadsheet.\n\n**Inputs**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `connected_account_id` | string | yes | Bkav connected account |\n| `invoice_type_id` | number | yes | 1 GTGT, 2 b\xE1n h\xE0ng, \u2026 |\n| `from_date` | string | yes | yyyy-mm-dd |\n| `to_date` | string | yes | yyyy-mm-dd |\n\n**Answer**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `file_id` | string | yes | The ID of the generated file. |\n| `url` | string | yes | The download URL of the generated file. |\n| `filename` | string | yes | The filename of the generated file, including extension. |\n| `mime_type` | string | yes | The MIME type of the generated file. |\n\n### bkav_get_report_bc26 (workflow step)\n\n- Save the BC26/AC invoice usage report for a window as a workspace XML file.\n\n**Inputs**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `connected_account_id` | string | yes | Bkav connected account |\n| `from_date` | string | yes | yyyy-mm-dd |\n| `to_date` | string | yes | yyyy-mm-dd |\n\n**Answer**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `file_id` | string | yes | The ID of the generated file. |\n| `url` | string | yes | The download URL of the generated file. |\n| `filename` | string | yes | The filename of the generated file, including extension. |\n| `mime_type` | string | yes | The MIME type of the generated file. |\n\n### bkav_get_tax_status (workflow step)\n\n- Read the tax authority\'s status for an invoice and its m\xE3 c\u1EE7a CQT once granted.\n\n**Inputs**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `connected_account_id` | string | yes | Bkav connected account |\n| `invoice_guid` | string | yes | |\n\n**Answer**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `statuses` | object[] | yes | |\n| `statuses[].BkavStatus` | number | yes | InvoiceStatusID: 1 m\u1EDBi t\u1EA1o, 2 \u0111\xE3 ph\xE1t h\xE0nh, 3 \u0111\xE3 hu\u1EF7, \u2026 |\n| `statuses[].TaxStatus` | number | yes | 32 waiting, 33 accepted, 34\u201339 returned or failed |\n| `statuses[].TaxAuthorityCode` | string \\\\| null | yes | M\xE3 c\u1EE7a C\u01A1 quan thu\u1EBF, once granted |\n| `statuses[].ErrorContent` | string \\\\| null | yes | |\n\n### bkav_list_invoices_by_date (workflow step)\n\n- List invoices dated within a window, one page at a time.\n\n**Inputs**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `connected_account_id` | string | yes | Bkav connected account |\n| `from_date` | string | yes | yyyy-mm-dd |\n| `to_date` | string | yes | yyyy-mm-dd |\n| `page` | number | | 1-based |\n\n**Answer**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `invoices` | object[] | yes | |\n\n### bkav_list_invoices_by_number (workflow step)\n\n- List invoices of one m\u1EABu s\u1ED1 and k\xFD hi\u1EC7u by number range.\n\n**Inputs**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `connected_account_id` | string | yes | Bkav connected account |\n| `form` | string | yes | M\u1EABu s\u1ED1 |\n| `serial` | string | yes | K\xFD hi\u1EC7u |\n| `from_number` | number | yes | |\n| `to_number` | number | yes | |\n\n**Answer**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `invoices` | object[] | yes | |\n\n### bkav_lookup_company (workflow step)\n\n- Look up a company in the tax registry by M\xE3 s\u1ED1 thu\u1EBF, through the Bkav account.\n\n**Inputs**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `connected_account_id` | string | yes | Bkav connected account |\n| `tax_code` | string | yes | |\n\n**Answer**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `company` | object | yes | |\n| `company.MaSoThue` | string | yes | |\n| `company.TenChinhThuc` | string | yes | |\n| `company.DiaChiGiaoDichChinh` | string | yes | |\n| `company.DiaChiGiaoDichPhu` | string | yes | |\n| `company.TrangThaiHoatDong` | string | yes | |\n| `company.ChuDoanhNghiep` | string | | |\n| `company.SoDienThoai` | string \\\\| null | | |\n| `company.LastUpdate` | string | | |\n\n### bkav_send_lookup_email (workflow step)\n\n- Send an invoice\'s lookup email to the buyer again, or to another email or phone.\n\n**Inputs**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `connected_account_id` | string | yes | Bkav connected account |\n| `invoice_guid` | string | yes | |\n| `email` | string | | |\n| `mobile` | string | | |\n\n**Answer**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `sent` | true | yes | |\n\n### bkav_sign_invoices (workflow step)\n\n- Sign \u2014 issue \u2014 invoices with the account\'s HSM signature, making them tax-registered documents.\n- An account that signs with a USB token cannot sign this way.\n\n**Inputs**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `connected_account_id` | string | yes | Bkav connected account |\n| `invoice_guids` | string[] | yes | |\n\n**Answer**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `signed` | string[] | yes | |\n\n### bkav_submit_invoices (workflow step)\n\n- Create, replace or adjust invoices on Bkav, in Bkav\'s field names.\n- Each carries PartnerInvoiceStringID, your own id for it, which Bkav uses to refuse a duplicate.\n- Attachments are not accepted here.\n\n**Inputs**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `connected_account_id` | string | yes | Bkav connected account |\n| `kind` | "create_draft" \\\\| "create" \\\\| "create_draft_own_serial" \\\\| "create_own_number" \\\\| "create_own_serial" \\\\| "replace_draft" \\\\| "adjust_draft" \\\\| "adjust_discount_draft" \\\\| "replace" \\\\| "adjust" \\\\| "adjust_external_draft" \\\\| "adjust_discount" \\\\| "adjust_external" \\\\| "replace_external" \\\\| "replace_external_draft" | yes | create_draft: no number, deletable. create: Bkav numbers it. *_own_*: your form/serial. replace_* / adjust_*: each invoice names its original in Invoice.OriginalInvoiceIdentify and gives Invoice.Reason. *_external*: corrects an invoice issued outside Bkav; its first line is an ItemTypeID 4 description of it. *_draft kinds stay unnumbered. |\n| `invoices` | object[] | yes | |\n| `invoices[].Invoice` | object | yes | |\n| `invoices[].Invoice.InvoiceTypeID` | number | yes | 1 GTGT, 2 b\xE1n h\xE0ng, 5 PXK ki\xEAm VCNB, 6 PXK g\u1EEDi b\xE1n \u0111\u1EA1i l\xFD |\n| `invoices[].Invoice.InvoiceDate` | string | yes | ISO date |\n| `invoices[].Invoice.BuyerCode` | string | | |\n| `invoices[].Invoice.BuyerName` | string | | |\n| `invoices[].Invoice.BuyerTaxCode` | string | | |\n| `invoices[].Invoice.BuyerUnitName` | string | | |\n| `invoices[].Invoice.BuyerAddress` | string | | |\n| `invoices[].Invoice.BuyerBankAccount` | string | | Sent empty when absent: Bkav refuses it missing |\n| `invoices[].Invoice.PayMethodID` | number | | 1 TM, 2 CK, 3 TM/CK, \u2026 |\n| `invoices[].Invoice.ReceiveTypeID` | number | | 1 email, 2 SMS, 3 both, 4 courier |\n| `invoices[].Invoice.ReceiverEmail` | string | | |\n| `invoices[].Invoice.ReceiverMobile` | string | | |\n| `invoices[].Invoice.ReceiverAddress` | string | | Sent empty when absent: Bkav refuses it missing |\n| `invoices[].Invoice.ReceiverName` | string | | Sent empty when absent: Bkav refuses it missing |\n| `invoices[].Invoice.Note` | string | | |\n| `invoices[].Invoice.BillCode` | string | | Sent empty when absent: Bkav refuses it missing |\n| `invoices[].Invoice.CurrencyID` | string | | |\n| `invoices[].Invoice.ExchangeRate` | number | | |\n| `invoices[].Invoice.InvoiceForm` | string | | M\u1EABu s\u1ED1 |\n| `invoices[].Invoice.InvoiceSerial` | string | | K\xFD hi\u1EC7u |\n| `invoices[].Invoice.InvoiceNo` | number | | S\u1ED1; 0 when Bkav assigns it |\n| `invoices[].Invoice.MaCuaCQT` | string | | Set only by a cash-register flow that already holds the authority\'s code |\n| `invoices[].Invoice.UserDefine` | string | | JSON-encoded template fields agreed with Bkav |\n| `invoices[].Invoice.UIDefine` | string | | JSON-encoded delivery-note fields (InvoiceTypeID 5 and 6) |\n| `invoices[].Invoice.isBTH` | boolean | | |\n| `invoices[].Invoice.CCCD` | string | | |\n| `invoices[].Invoice.PassportNumber` | string | | |\n| `invoices[].Invoice.FiscalCodes` | string | | |\n| `invoices[].Invoice.Reason` | string | | Why \u2014 required on a replacement or adjustment |\n| `invoices[].Invoice.OriginalInvoiceIdentify` | string | | `[m\u1EABu s\u1ED1]_[k\xFD hi\u1EC7u]_[s\u1ED1]` of the invoice a replacement or adjustment corrects |\n| `invoices[].Invoice.InvoiceGUID` | string | | Addresses the invoice for an update by guid |\n| `invoices[].ListInvoiceDetailsWS` | object[] | yes | |\n| `invoices[].ListInvoiceDetailsWS[].ItemTypeID` | number | | 0 goods, 4 description line, 15 promotional goods, \u2026 |\n| `invoices[].ListInvoiceDetailsWS[].IsDiscount` | boolean | | |\n| `invoices[].ListInvoiceDetailsWS[].ItemCode` | string | | |\n| `invoices[].ListInvoiceDetailsWS[].ItemName` | string | yes | |\n| `invoices[].ListInvoiceDetailsWS[].UnitName` | string | | |\n| `invoices[].ListInvoiceDetailsWS[].Qty` | number | | |\n| `invoices[].ListInvoiceDetailsWS[].Price` | number | | |\n| `invoices[].ListInvoiceDetailsWS[].Amount` | number | | |\n| `invoices[].ListInvoiceDetailsWS[].TaxRateID` | number | | 1 0%, 2 5%, 3 10%, 4 kh\xF4ng ch\u1ECBu thu\u1EBF, 9 8%, \u2026 |\n| `invoices[].ListInvoiceDetailsWS[].TaxRate` | number | | |\n| `invoices[].ListInvoiceDetailsWS[].TaxAmount` | number | | |\n| `invoices[].ListInvoiceDetailsWS[].OtherAmount` | number | | |\n| `invoices[].ListInvoiceDetailsWS[].DiscountRate` | number | | |\n| `invoices[].ListInvoiceDetailsWS[].DiscountAmount` | number | | |\n| `invoices[].ListInvoiceDetailsWS[].UserDefineDetails` | string | | |\n| `invoices[].ListInvoiceDetailsWS[].IsIncrease` | boolean \\\\| null | | On an adjustment: true raises the amount, false lowers it, absent adjusts information |\n| `invoices[].ListInvoiceDetailsWS[].SpecialtyItems` | string | | |\n| `invoices[].PartnerInvoiceStringID` | string | yes | |\n| `invoices[].AttachListNumber` | string | | S\u1ED1 of a b\u1EA3ng k\xEA the invoice refers to |\n| `invoices[].AttachListDate` | string | | |\n\n**Answer**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `results` | object[] | yes | |\n| `results[].PartnerInvoiceID` | number | yes | |\n| `results[].PartnerInvoiceStringID` | string \\\\| null | yes | Null on a delete\'s answer |\n| `results[].InvoiceGUID` | string | yes | |\n| `results[].InvoiceForm` | string \\\\| null | yes | Null on a draft\'s link answer |\n| `results[].InvoiceSerial` | string \\\\| null | yes | Null on a draft\'s link answer |\n| `results[].InvoiceNo` | number | yes | Assigned by a numbered kind; 0 on a draft |\n| `results[].MTC` | string \\\\| null | yes | M\xE3 tra c\u1EE9u the buyer looks the invoice up with; null on a draft\'s link answer |\n| `results[].MaCuaCQT` | string \\\\| null | | |\n| `results[].Status` | number | yes | 0 succeeded; otherwise MessLog says why |\n| `results[].MessLog` | string | yes | |\n| `failed` | number | yes | Invoices whose own Status is not 0 |\n\n### bkav_update_invoices (workflow step)\n\n- Change invoices not yet issued. by names how each invoice is found: partner (its partner id), number (Invoice.InvoiceForm + InvoiceSerial + InvoiceNo) or guid (Invoice.InvoiceGUID).\n\n**Inputs**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `connected_account_id` | string | yes | Bkav connected account |\n| `by` | "partner" \\\\| "number" \\\\| "guid" | yes | |\n| `invoices` | object[] | yes | |\n| `invoices[].Invoice` | object | yes | |\n| `invoices[].Invoice.InvoiceTypeID` | number | yes | 1 GTGT, 2 b\xE1n h\xE0ng, 5 PXK ki\xEAm VCNB, 6 PXK g\u1EEDi b\xE1n \u0111\u1EA1i l\xFD |\n| `invoices[].Invoice.InvoiceDate` | string | yes | ISO date |\n| `invoices[].Invoice.BuyerCode` | string | | |\n| `invoices[].Invoice.BuyerName` | string | | |\n| `invoices[].Invoice.BuyerTaxCode` | string | | |\n| `invoices[].Invoice.BuyerUnitName` | string | | |\n| `invoices[].Invoice.BuyerAddress` | string | | |\n| `invoices[].Invoice.BuyerBankAccount` | string | | Sent empty when absent: Bkav refuses it missing |\n| `invoices[].Invoice.PayMethodID` | number | | 1 TM, 2 CK, 3 TM/CK, \u2026 |\n| `invoices[].Invoice.ReceiveTypeID` | number | | 1 email, 2 SMS, 3 both, 4 courier |\n| `invoices[].Invoice.ReceiverEmail` | string | | |\n| `invoices[].Invoice.ReceiverMobile` | string | | |\n| `invoices[].Invoice.ReceiverAddress` | string | | Sent empty when absent: Bkav refuses it missing |\n| `invoices[].Invoice.ReceiverName` | string | | Sent empty when absent: Bkav refuses it missing |\n| `invoices[].Invoice.Note` | string | | |\n| `invoices[].Invoice.BillCode` | string | | Sent empty when absent: Bkav refuses it missing |\n| `invoices[].Invoice.CurrencyID` | string | | |\n| `invoices[].Invoice.ExchangeRate` | number | | |\n| `invoices[].Invoice.InvoiceForm` | string | | M\u1EABu s\u1ED1 |\n| `invoices[].Invoice.InvoiceSerial` | string | | K\xFD hi\u1EC7u |\n| `invoices[].Invoice.InvoiceNo` | number | | S\u1ED1; 0 when Bkav assigns it |\n| `invoices[].Invoice.MaCuaCQT` | string | | Set only by a cash-register flow that already holds the authority\'s code |\n| `invoices[].Invoice.UserDefine` | string | | JSON-encoded template fields agreed with Bkav |\n| `invoices[].Invoice.UIDefine` | string | | JSON-encoded delivery-note fields (InvoiceTypeID 5 and 6) |\n| `invoices[].Invoice.isBTH` | boolean | | |\n| `invoices[].Invoice.CCCD` | string | | |\n| `invoices[].Invoice.PassportNumber` | string | | |\n| `invoices[].Invoice.FiscalCodes` | string | | |\n| `invoices[].Invoice.Reason` | string | | Why \u2014 required on a replacement or adjustment |\n| `invoices[].Invoice.OriginalInvoiceIdentify` | string | | `[m\u1EABu s\u1ED1]_[k\xFD hi\u1EC7u]_[s\u1ED1]` of the invoice a replacement or adjustment corrects |\n| `invoices[].Invoice.InvoiceGUID` | string | | Addresses the invoice for an update by guid |\n| `invoices[].ListInvoiceDetailsWS` | object[] | yes | |\n| `invoices[].ListInvoiceDetailsWS[].ItemTypeID` | number | | 0 goods, 4 description line, 15 promotional goods, \u2026 |\n| `invoices[].ListInvoiceDetailsWS[].IsDiscount` | boolean | | |\n| `invoices[].ListInvoiceDetailsWS[].ItemCode` | string | | |\n| `invoices[].ListInvoiceDetailsWS[].ItemName` | string | yes | |\n| `invoices[].ListInvoiceDetailsWS[].UnitName` | string | | |\n| `invoices[].ListInvoiceDetailsWS[].Qty` | number | | |\n| `invoices[].ListInvoiceDetailsWS[].Price` | number | | |\n| `invoices[].ListInvoiceDetailsWS[].Amount` | number | | |\n| `invoices[].ListInvoiceDetailsWS[].TaxRateID` | number | | 1 0%, 2 5%, 3 10%, 4 kh\xF4ng ch\u1ECBu thu\u1EBF, 9 8%, \u2026 |\n| `invoices[].ListInvoiceDetailsWS[].TaxRate` | number | | |\n| `invoices[].ListInvoiceDetailsWS[].TaxAmount` | number | | |\n| `invoices[].ListInvoiceDetailsWS[].OtherAmount` | number | | |\n| `invoices[].ListInvoiceDetailsWS[].DiscountRate` | number | | |\n| `invoices[].ListInvoiceDetailsWS[].DiscountAmount` | number | | |\n| `invoices[].ListInvoiceDetailsWS[].UserDefineDetails` | string | | |\n| `invoices[].ListInvoiceDetailsWS[].IsIncrease` | boolean \\\\| null | | On an adjustment: true raises the amount, false lowers it, absent adjusts information |\n| `invoices[].ListInvoiceDetailsWS[].SpecialtyItems` | string | | |\n| `invoices[].PartnerInvoiceStringID` | string | yes | |\n| `invoices[].AttachListNumber` | string | | S\u1ED1 of a b\u1EA3ng k\xEA the invoice refers to |\n| `invoices[].AttachListDate` | string | | |\n\n**Answer**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `results` | object[] | yes | |\n| `results[].PartnerInvoiceID` | number | yes | |\n| `results[].PartnerInvoiceStringID` | string \\\\| null | yes | Null on a delete\'s answer |\n| `results[].InvoiceGUID` | string | yes | |\n| `results[].InvoiceForm` | string \\\\| null | yes | Null on a draft\'s link answer |\n| `results[].InvoiceSerial` | string \\\\| null | yes | Null on a draft\'s link answer |\n| `results[].InvoiceNo` | number | yes | Assigned by a numbered kind; 0 on a draft |\n| `results[].MTC` | string \\\\| null | yes | M\xE3 tra c\u1EE9u the buyer looks the invoice up with; null on a draft\'s link answer |\n| `results[].MaCuaCQT` | string \\\\| null | | |\n| `results[].Status` | number | yes | 0 succeeded; otherwise MessLog says why |\n| `results[].MessLog` | string | yes | |\n| `failed` | number | yes | Invoices whose own Status is not 0 |\n\n## delete\n\n### delete_file (workflow step)\n\n- Soft-delete a file by its file_id \u2014 archives it (never a hard delete).\n- Deleting an original file deletes every version of it; a file\'s current version cannot be deleted on its own.\n- Refuses while the file or a version it takes along is still referenced by a record, comment, knowledge doc, document template, voice session, or pending change request, and names the referents so you can detach them first.\n\n**Inputs**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `file_id` | string | yes | |\n\n**Answer**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `id` | string | yes | |\n| `filename` | string | yes | |\n| `archived` | boolean | yes | |\n\n## drive\n\n### drive_export_file (workflow step)\n\n- Upload a Lotics file to the member\'s Google Drive.\n- The file is uploaded as-is \u2014 a .docx stays a .docx, a .xlsx stays a .xlsx.\n- It is deliberately NOT converted into a Google Doc or Sheet: conversion re-flows the layout of a generated document, and a native Google file is live, so its figures would drift away from the record they came from.\n- Use this to archive a finished document, the way you would attach it to an email.\n\n**Inputs**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `connected_account_id` | string | yes | The ID of the connected Google Drive account to use |\n| `file_id` | string | yes | Lotics file ID to upload |\n| `folder_id` | string | | Drive folder ID to place the file in; omit for the member\'s Drive root |\n\n**Answer**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `drive_file_id` | string | yes | Drive file ID of the uploaded copy |\n| `web_view_link` | string \\\\| null | yes | URL to open the uploaded file in Drive |\n\n### drive_import_file (workflow step)\n\n- Import a Google Drive file into the workspace as a Lotics file, and return its file id.\n- Google Docs arrive as .docx, Sheets as .xlsx, Slides as .pptx; everything else transfers unchanged. **This is also how you READ a Drive file.** There is no separate Drive read tool: import the file, then use `view_files` on the returned id. (chat, MCP, CLI)\n- That path handles scanned documents \u2014 a photographed B/L or a stamped invoice inside a .docx reaches the model as a picture.\n- Exporting Drive content as plain text would return an empty document for exactly those files.\n- Folders, Forms, shortcuts and other Drive items with no file form cannot be imported.\n\n**Inputs**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `connected_account_id` | string | yes | The ID of the connected Google Drive account to use |\n| `file_id` | string | yes | Drive file ID, from drive_query_files (workflow step, chat) |\n\n**Answer**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `id` | string | yes | Lotics file ID \u2014 pass this to view_files to read the contents (chat, MCP, CLI) |\n| `filename` | string | yes | |\n| `mime_type` | string | yes | |\n| `url` | string | yes | |\n\n### drive_query_files (workflow step)\n\n- Search the member\'s Google Drive using Drive query syntax. ## Query syntax Terms are `field operator value`; combine with `and` / `or` / `not`.\n- String values are single-quoted. - Name: `name = \'Rate card\'`, `name contains \'invoice\'` - Full text (body + title): `fullText contains \'CMA CGM\'` - Type: `mimeType = \'application/vnd.google-apps.spreadsheet\'`, `mimeType contains \'pdf\'` - Folder: `\'<folder_id>\' in parents` - Time (RFC 3339): `modifiedTime > \'2026-01-01T00:00:00\'` - Exclude deleted: `trashed = false` (always worth adding) - Starred / shared: `starred = true`, `sharedWithMe = true` Google-native types: `application/vnd.google-apps.document` (Docs), `.spreadsheet` (Sheets), `.presentation` (Slides), `.folder`.\n- Examples: - `name contains \'packing list\' and trashed = false` - `\'1a2b3c\' in parents and modifiedTime > \'2026-08-01T00:00:00\'` - `fullText contains \'demurrage\' and mimeType = \'application/vnd.google-apps.document\'` Results carry enough metadata (name, type, modified time, size, owner) to pick a file WITHOUT importing it.\n- Import only the one you actually need \u2014 `drive_import_file` persists a copy in the workspace. (workflow step, chat)\n\n**Inputs**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `connected_account_id` | string | yes | The ID of the connected Google Drive account to use |\n| `query` | string | yes | Drive query syntax expression |\n| `max_result` | number | yes | Maximum number of files to retrieve (1-50) |\n| `order_by` | string | | Sort order, e.g. \'modifiedTime desc\', \'name\' |\n\n**Answer**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `files` | object[] | yes | List of matching Drive files |\n| `files[].id` | string | yes | Drive file ID \u2014 pass this to drive_import_file (workflow step, chat) |\n| `files[].name` | string | yes | File name as it appears in Drive |\n| `files[].mime_type` | string | yes | Drive MIME type; \'application/vnd.google-apps.*\' means Google-native |\n| `files[].modified_time` | string \\\\| null | yes | RFC 3339 timestamp of the last modification |\n| `files[].size_bytes` | number \\\\| null | yes | Size in bytes; null for Google-native files, which have no stored size until exported |\n| `files[].owner` | string \\\\| null | yes | Email address of the file\'s owner |\n| `files[].web_view_link` | string \\\\| null | yes | URL to open the file in Drive |\n\n## evaluate\n\n### evaluate_formula (workflow step)\n\n- Compute a formula over one record as its table\'s own formula fields compute: fields referenced as `{alias}`, lookups and rollups read as the row holds them.\n- Writes nothing.\n\n**Inputs**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `table_id` | string | yes | |\n| `record_id` | string | yes | |\n| `expression` | string | yes | |\n\n**Answer**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `value` | number \\\\| string \\\\| boolean \\\\| null | yes | |\n\n## excel\n\n### excel_get_rows (workflow step)\n\n- Read every row of a sheet in an .xlsx, .xls or .csv file as a list of objects keyed by the header row\'s text.\n- A cell is its typed value: a number, true or false, text, a date as ISO text ("2026-03-02", "2026-03-02T09:30:00", or "09:30:00" for a time alone), null when blank.\n- A formula cell is the value the file last computed; an error cell is its code as text ("#N/A").\n- Every CSV cell is text or null.\n- A header cell inside a merge takes the merge\'s text; a blank header is keyed by its column letter ("C"), and a header text that repeats gets its column letter added ("Amount (F)"). headers lists the keys in sheet order.\n- Rows above header_row and rows blank in every column are left out.\n- One call reads a sheet of at most 10000 rows, its header counted, and returns at most 250000 cells (rows \xD7 kept columns); a sheet past either is refused with its row count, never cut short.\n- Read-only.\n\n**Inputs**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `file_id` | string | yes | |\n| `worksheet` | string \\\\| number | | Sheet name, or 0-based index. |\n| `header_row` | number | | 1-based row holding the headers. |\n| `columns` | string[] | | Keys to keep, as headers lists them. |\n\n**Answer**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `file_id` | string | yes | |\n| `worksheet_name` | string | yes | |\n| `header_row` | number | yes | |\n| `headers` | string[] | yes | The key of each returned column, in sheet order |\n| `row_count` | number | yes | |\n| `rows` | object[] | yes | |\n\n## facebook\n\n### facebook_create_post (workflow step)\n\n- Publish a post to a Facebook Page feed.\n- Requires a Facebook connected account whose page allows content management.\n\n**Inputs**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `connected_account_id` | string | yes | |\n| `page_id` | string | yes | Facebook Page to post to \u2014 one of the connected account\'s managed pages. |\n| `message` | string | yes | |\n| `link` | string | | A post carries either a link or media. |\n| `media_file_ids` | string[] | | Photos, or one video on its own. |\n\n**Answer**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `post_id` | string | yes | |\n| `url` | string | yes | |\n\n### facebook_list_comments (workflow step)\n\n- Read the most recent comments left on a Facebook Page\'s posts.\n- Reply with facebook_reply_to_comment, passing the comment_id. (workflow step, chat)\n\n**Inputs**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `connected_account_id` | string | yes | |\n| `page_id` | string | yes | One of the connected account\'s managed pages. |\n| `limit` | integer | | |\n\n**Answer**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `comments` | object[] | yes | |\n| `comments[].comment_id` | string | yes | |\n| `comments[].post_id` | string | yes | |\n| `comments[].author_id` | string | yes | |\n| `comments[].author_name` | string | yes | |\n| `comments[].message` | string | yes | |\n| `comments[].created_time` | string | yes | |\n\n### facebook_list_conversations (workflow step)\n\n- Read a Facebook Page\'s Messenger conversations with their recent messages.\n- Answer someone with facebook_send_message, passing the person_id. (workflow step, chat)\n\n**Inputs**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `connected_account_id` | string | yes | |\n| `page_id` | string | yes | One of the connected account\'s managed pages. |\n| `limit` | integer | | |\n\n**Answer**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `conversations` | object[] | yes | |\n| `conversations[].conversation_id` | string | yes | |\n| `conversations[].person_id` | string \\\\| null | yes | |\n| `conversations[].person_name` | string \\\\| null | yes | |\n| `conversations[].messages` | object[] | yes | |\n| `conversations[].messages[].from_id` | string | yes | |\n| `conversations[].messages[].from_name` | string | yes | |\n| `conversations[].messages[].message` | string | yes | |\n| `conversations[].messages[].created_time` | string | yes | |\n\n### facebook_reply_to_comment (workflow step)\n\n- Reply publicly to a comment on a Facebook Page post, as the page.\n- The reply is visible to everyone who can see the comment.\n\n**Inputs**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `connected_account_id` | string | yes | |\n| `page_id` | string | yes | The page that owns the post the comment is on. |\n| `comment_id` | string | yes | From facebook_list_comments. (workflow step, chat) |\n| `message` | string | yes | |\n\n**Answer**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `comment_id` | string | yes | The reply\'s own id. |\n\n### facebook_send_message (workflow step)\n\n- Answer someone who messaged a Facebook Page, as the page.\n- Meta accepts a reply only within 24 hours of that person\'s last message.\n\n**Inputs**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `connected_account_id` | string | yes | |\n| `page_id` | string | yes | The page the person wrote to. |\n| `person_id` | string | yes | From facebook_list_conversations. Page-scoped to this page. (workflow step, chat) |\n| `message` | string | yes | |\n\n**Answer**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `message_id` | string | yes | |\n\n## fedex\n\n### fedex_cancel_draft (workflow step)\n\n- Cancel (delete) an unconfirmed FedEx draft (Open Ship) booking by its reference.\n\n**Inputs**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `connected_account_id` | string | yes | FedEx connected account |\n| `reference` | string | yes | The draft\'s reference (Open Ship index) |\n\n**Answer**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `cancelled` | boolean | yes | |\n\n### fedex_confirm_shipment (workflow step)\n\n- Confirm a FedEx draft (Open Ship) booking by its reference, generating the label / AWB.\n- Returns the master tracking number and any piece tracking numbers + label URLs.\n\n**Inputs**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `connected_account_id` | string | yes | FedEx connected account |\n| `reference` | string | yes | The draft\'s reference (Open Ship index) from fedex_create_draft_shipment (workflow step, chat) |\n| `master_tracking_number` | string | | The master_tracking_number returned by fedex_create_draft_shipment \u2014 required to finalize the label (workflow step, chat) |\n\n**Answer**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `master_tracking_number` | string | | |\n| `tracking_numbers` | string[] | yes | |\n| `label_urls` | string[] | yes | |\n\n### fedex_create_draft_shipment (workflow step)\n\n- Create a FedEx draft (Open Ship) booking \u2014 no label yet.\n- Confirm later with fedex_confirm_shipment. reference is a unique key you choose (e.g. the shipment order number) used to confirm or cancel. (workflow step, chat)\n- Supports multiple packages, special services, and (international) customs commodities.\n\n**Inputs**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `connected_account_id` | string | yes | FedEx connected account |\n| `reference` | string | yes | Unique key for this draft (the Open Ship index) |\n| `shipper` | object | yes | |\n| `shipper.contact` | object | | |\n| `shipper.contact.person_name` | string | | |\n| `shipper.contact.company_name` | string | | |\n| `shipper.contact.phone_number` | string | | |\n| `shipper.address` | object | yes | |\n| `shipper.address.postal_code` | string | yes | |\n| `shipper.address.country_code` | string | yes | ISO 3166-1 alpha-2 country code, e.g. VN, US, SG |\n| `shipper.address.city` | string | | |\n| `shipper.address.state_or_province_code` | string | | |\n| `shipper.address.street_lines` | string[] | | |\n| `shipper.address.residential` | boolean | | |\n| `recipient` | object | yes | |\n| `recipient.contact` | object | | |\n| `recipient.contact.person_name` | string | | |\n| `recipient.contact.company_name` | string | | |\n| `recipient.contact.phone_number` | string | | |\n| `recipient.address` | object | yes | |\n| `recipient.address.postal_code` | string | yes | |\n| `recipient.address.country_code` | string | yes | ISO 3166-1 alpha-2 country code, e.g. VN, US, SG |\n| `recipient.address.city` | string | | |\n| `recipient.address.state_or_province_code` | string | | |\n| `recipient.address.street_lines` | string[] | | |\n| `recipient.address.residential` | boolean | | |\n| `service_type` | string | yes | FedEx service type e.g. INTERNATIONAL_PRIORITY |\n| `packaging_type` | string | | FedEx packaging type e.g. YOUR_PACKAGING |\n| `packages` | object[] | yes | One entry per package; weight in kg |\n| `packages[].weight` | number | yes | |\n| `packages[].weight_units` | "KG" \\\\| "LB" | | Weight units; FedEx requires LB for US domestic express |\n| `packages[].length` | number | | |\n| `packages[].width` | number | | |\n| `packages[].height` | number | | |\n| `packages[].dimension_units` | "CM" \\\\| "IN" | | |\n| `ship_datestamp` | string | | Ship date YYYY-MM-DD |\n| `pickup_type` | string | | FedEx pickup type e.g. USE_SCHEDULED_PICKUP |\n| `special_services` | string[] | | FedEx special service types e.g. SATURDAY_DELIVERY, FEDEX_ONE_RATE |\n| `commodities` | object[] | | Customs commodities for international shipments |\n| `commodities[].description` | string | yes | |\n| `commodities[].quantity` | number | yes | |\n| `commodities[].customs_value` | number | yes | |\n| `commodities[].currency` | string | yes | |\n| `commodities[].weight` | number | yes | |\n| `commodities[].weight_units` | "KG" \\\\| "LB" | | |\n| `commodities[].country_of_manufacture` | string | | ISO 3166-1 alpha-2 |\n\n**Answer**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `reference` | string | yes | |\n| `status` | "draft" | yes | |\n| `master_tracking_number` | string | | Pass this to fedex_confirm_shipment to finalize the draft (workflow step, chat) |\n\n### fedex_create_shipment (workflow step)\n\n- Create a FedEx shipment and generate the label / AWB in one call.\n- Returns the master tracking number, piece tracking numbers, and label URLs.\n- Use the Open Ship draft flow instead when you need to stage a booking before committing.\n\n**Inputs**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `connected_account_id` | string | yes | FedEx connected account |\n| `shipper` | object | yes | |\n| `shipper.contact` | object | | |\n| `shipper.contact.person_name` | string | | |\n| `shipper.contact.company_name` | string | | |\n| `shipper.contact.phone_number` | string | | |\n| `shipper.address` | object | yes | |\n| `shipper.address.postal_code` | string | yes | |\n| `shipper.address.country_code` | string | yes | ISO 3166-1 alpha-2 country code, e.g. VN, US, SG |\n| `shipper.address.city` | string | | |\n| `shipper.address.state_or_province_code` | string | | |\n| `shipper.address.street_lines` | string[] | | |\n| `shipper.address.residential` | boolean | | |\n| `recipient` | object | yes | |\n| `recipient.contact` | object | | |\n| `recipient.contact.person_name` | string | | |\n| `recipient.contact.company_name` | string | | |\n| `recipient.contact.phone_number` | string | | |\n| `recipient.address` | object | yes | |\n| `recipient.address.postal_code` | string | yes | |\n| `recipient.address.country_code` | string | yes | ISO 3166-1 alpha-2 country code, e.g. VN, US, SG |\n| `recipient.address.city` | string | | |\n| `recipient.address.state_or_province_code` | string | | |\n| `recipient.address.street_lines` | string[] | | |\n| `recipient.address.residential` | boolean | | |\n| `service_type` | string | yes | FedEx service type e.g. INTERNATIONAL_PRIORITY |\n| `packaging_type` | string | | FedEx packaging type e.g. YOUR_PACKAGING |\n| `packages` | object[] | yes | One entry per package; weight in kg |\n| `packages[].weight` | number | yes | |\n| `packages[].weight_units` | "KG" \\\\| "LB" | | Weight units; FedEx requires LB for US domestic express |\n| `packages[].length` | number | | |\n| `packages[].width` | number | | |\n| `packages[].height` | number | | |\n| `packages[].dimension_units` | "CM" \\\\| "IN" | | |\n| `ship_datestamp` | string | | Ship date YYYY-MM-DD |\n| `pickup_type` | string | | FedEx pickup type e.g. USE_SCHEDULED_PICKUP |\n| `special_services` | string[] | | FedEx special service types |\n| `commodities` | object[] | | Customs commodities for international shipments |\n| `commodities[].description` | string | yes | |\n| `commodities[].quantity` | number | yes | |\n| `commodities[].customs_value` | number | yes | |\n| `commodities[].currency` | string | yes | |\n| `commodities[].weight` | number | yes | |\n| `commodities[].weight_units` | "KG" \\\\| "LB" | | |\n| `commodities[].country_of_manufacture` | string | | ISO 3166-1 alpha-2 |\n\n**Answer**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `master_tracking_number` | string | | |\n| `tracking_numbers` | string[] | yes | |\n| `label_urls` | string[] | yes | |\n\n### fedex_rate (workflow step)\n\n- Get FedEx rate quotes for a shipment.\n- Returns one priced row per available service (or just the requested service_type).\n- Use before booking to compare costs.\n\n**Inputs**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `connected_account_id` | string | yes | FedEx connected account |\n| `shipper` | object | yes | |\n| `shipper.postal_code` | string | yes | |\n| `shipper.country_code` | string | yes | ISO 3166-1 alpha-2 country code, e.g. VN, US, SG |\n| `shipper.city` | string | | |\n| `shipper.state_or_province_code` | string | | |\n| `shipper.street_lines` | string[] | | |\n| `shipper.residential` | boolean | | |\n| `recipient` | object | yes | |\n| `recipient.postal_code` | string | yes | |\n| `recipient.country_code` | string | yes | ISO 3166-1 alpha-2 country code, e.g. VN, US, SG |\n| `recipient.city` | string | | |\n| `recipient.state_or_province_code` | string | | |\n| `recipient.street_lines` | string[] | | |\n| `recipient.residential` | boolean | | |\n| `packages` | object[] | yes | One entry per package; weight in kg |\n| `packages[].weight` | number | yes | |\n| `packages[].weight_units` | "KG" \\\\| "LB" | | Weight units; FedEx requires LB for US domestic express |\n| `packages[].length` | number | | |\n| `packages[].width` | number | | |\n| `packages[].height` | number | | |\n| `packages[].dimension_units` | "CM" \\\\| "IN" | | |\n| `service_type` | string | | FedEx service type e.g. INTERNATIONAL_PRIORITY. Omit to rate all available services. |\n| `packaging_type` | string | | FedEx packaging type e.g. YOUR_PACKAGING |\n| `pickup_type` | string | | FedEx pickup type e.g. USE_SCHEDULED_PICKUP |\n| `ship_datestamp` | string | | Ship date YYYY-MM-DD |\n| `special_services` | string[] | | FedEx special service types e.g. SATURDAY_DELIVERY |\n\n**Answer**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `services` | object[] | yes | |\n| `services[].service_type` | string | yes | |\n| `services[].service_name` | string | | |\n| `services[].total_charge` | number | | |\n| `services[].currency` | string | | |\n\n### fedex_service_availability (workflow step)\n\n- Check what FedEx services serve a lane. query "transit_times" returns delivery commitments per service; "package_and_service_options" returns valid service/packaging combinations; "special_service_options" returns available special services.\n- Use before rating/booking to learn the valid service types for an origin \u2192 destination.\n\n**Inputs**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `connected_account_id` | string | yes | FedEx connected account |\n| `query` | "transit_times" \\\\| "special_service_options" \\\\| "package_and_service_options" | yes | |\n| `shipper` | object | yes | |\n| `shipper.postal_code` | string | yes | |\n| `shipper.country_code` | string | yes | ISO 3166-1 alpha-2 country code, e.g. VN, US, SG |\n| `shipper.city` | string | | |\n| `shipper.state_or_province_code` | string | | |\n| `shipper.street_lines` | string[] | | |\n| `shipper.residential` | boolean | | |\n| `recipient` | object | yes | |\n| `recipient.postal_code` | string | yes | |\n| `recipient.country_code` | string | yes | ISO 3166-1 alpha-2 country code, e.g. VN, US, SG |\n| `recipient.city` | string | | |\n| `recipient.state_or_province_code` | string | | |\n| `recipient.street_lines` | string[] | | |\n| `recipient.residential` | boolean | | |\n| `packages` | object[] | yes | One entry per package; weight in kg unless weight_units set |\n| `packages[].weight` | number | yes | |\n| `packages[].weight_units` | "KG" \\\\| "LB" | | Weight units; FedEx requires LB for US domestic express |\n| `packages[].length` | number | | |\n| `packages[].width` | number | | |\n| `packages[].height` | number | | |\n| `packages[].dimension_units` | "CM" \\\\| "IN" | | |\n| `ship_datestamp` | string | | Ship date YYYY-MM-DD |\n| `carrier_codes` | string[] | yes | FedEx carrier codes \u2014 at least one, e.g. FDXE (Express), FDXG (Ground) |\n\n**Answer**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `result` | object | yes | |\n\n### fedex_validate_address (workflow step)\n\n- Validate one or more addresses with FedEx.\n- Returns each resolved address plus its classification (e.g.\n- BUSINESS / RESIDENTIAL) and deliverability attributes.\n\n**Inputs**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `connected_account_id` | string | yes | FedEx connected account |\n| `addresses` | object[] | yes | |\n| `addresses[].postal_code` | string | yes | |\n| `addresses[].country_code` | string | yes | ISO 3166-1 alpha-2 country code, e.g. VN, US, SG |\n| `addresses[].city` | string | | |\n| `addresses[].state_or_province_code` | string | | |\n| `addresses[].street_lines` | string[] | | |\n| `addresses[].residential` | boolean | | |\n\n**Answer**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `resolved_addresses` | object[] | yes | |\n| `resolved_addresses[].city` | string | | |\n| `resolved_addresses[].state_or_province_code` | string | | |\n| `resolved_addresses[].postal_code` | string | | |\n| `resolved_addresses[].country_code` | string | | |\n| `resolved_addresses[].classification` | string | | |\n| `resolved_addresses[].attributes` | object | | |\n\n## generate\n\n### generate_bank_qr_code (workflow step)\n\n- Generate a bank-payment QR code (VietQR for Vietnam) and save it as a PNG to the workspace.\n- The QR encodes the recipient bank, account number, and optional amount/description so any banking app can scan and pre-fill the transfer.\n- Call list_banks first to discover valid bank codes. (workflow step, chat)\n\n**Inputs**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `country` | "VN" | yes | Country whose bank-payment QR standard to use. Currently only \'VN\' (Vietnam / VietQR) is supported. |\n| `bank_code` | "ICB" \\\\| "VCB" \\\\| "BIDV" \\\\| "VBA" \\\\| "OCB" \\\\| "MB" \\\\| "TCB" \\\\| "ACB" \\\\| "VPB" \\\\| "TPB" \\\\| "STB" \\\\| "HDB" \\\\| "VCCB" \\\\| "SCB" \\\\| "VIB" \\\\| "SHB" \\\\| "EIB" \\\\| "MSB" \\\\| "CAKE" \\\\| "Ubank" \\\\| "TIMO" \\\\| "SGICB" \\\\| "BAB" \\\\| "momo" \\\\| "PVDB" \\\\| "PVCB" \\\\| "MBV" \\\\| "NCB" \\\\| "SHBVN" \\\\| "ABB" \\\\| "VAB" \\\\| "NAB" \\\\| "PGB" \\\\| "VIETBANK" \\\\| "BVB" \\\\| "SEAB" \\\\| "COOPBANK" \\\\| "LPB" \\\\| "KLB" \\\\| "KBank" \\\\| "CIMB" \\\\| "WVN" | yes | Bank code from list_banks. Must belong to the specified country. (workflow step, chat) |\n| `account_number` | string | yes | Beneficiary account number at the bank. |\n| `amount` | number | | Payment amount in the local currency (VND for Vietnam). Omit for a static QR with no fixed amount. |\n| `description` | string | | Transfer description / memo. Max 50 bytes; Vietnamese diacritics use 2\u20133 bytes per character. |\n| `account_name` | string | | Beneficiary name shown on the payer\'s banking app confirmation screen. Max 25 bytes \u2014 banks normalize Vietnamese names to ASCII uppercase, pass the normalized form (e.g. \'NGUYEN VAN A\'). |\n| `filename` | string | | Output filename. Defaults to \'bank_qr_<bank_code>.png\'. |\n\n**Answer**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `id` | string | yes | |\n| `filename` | string | yes | |\n| `mime_type` | string | yes | |\n| `url` | string | yes | |\n| `bank` | object | yes | |\n| `bank.country` | string | yes | |\n| `bank.code` | string | yes | |\n| `bank.short_name` | string | yes | |\n| `bank.full_name` | string | yes | |\n\n### generate_qr_code (workflow step)\n\n- Generate a QR code PNG from text or a URL and save it to the workspace.\n- For bank payment QR codes, use generate_bank_qr_code instead \u2014 it builds the correct EMVCo payload from a bank code and account number. (workflow step, chat)\n\n**Inputs**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `data` | string | yes | Text or URL to encode into the QR code. |\n| `filename` | string | | Output filename. Defaults to \'qr_code.png\'. |\n\n**Answer**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `id` | string | yes | |\n| `filename` | string | yes | |\n| `mime_type` | string | yes | |\n| `url` | string | yes | |\n\n## get\n\n### get_record (workflow step)\n\n- Fetch a single record with all field values.\n- Returns data (raw values) and display (human-readable labels).\n- A workspace admin\'s `as_member` fetches it as that member would see it.\n- Cell shapes in `data`, keyed by field alias: select / member / link, and a formula with options, read back as arrays \u2014 a single-select as ["open"], the option\'s alias; one member as ["mbr_x"].\n- A workflow body reads the same single-select, single-member or option-formula cell as the bare value.\n\n**Inputs**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `record_id` | string | yes | |\n| `table_id` | string | | |\n| `as_member` | string | | For a workspace admin: reads as this member would \u2014 their row rules and access. Anyone else is refused. |\n| `field_keys` | string[] | | Field aliases to return. Omit to return all fields. |\n| `comments` | boolean | | Return the record\'s newest comments, 20 at most; `comment_count` is the whole thread. |\n\n**Answer**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `id` | string | yes | |\n| `data` | object | yes | |\n| `display` | object | yes | Human-readable display values for select, member, and record link fields. |\n| `links` | object | | Each link field\'s linked rows with the title the cell carries, by field alias; `data` holds their ids. |\n| `locked` | boolean | | |\n| `comment_count` | number | yes | How many comments the record holds. |\n| `comments` | object[] | | With `comments: true`: the newest first. |\n| `comments[].id` | string | yes | Unique identifier for the comment |\n| `comments[].member_id` | string | yes | ID of the member who created this comment |\n| `comments[].content` | string | yes | Comment text content (0-10,000 characters) |\n| `comments[].files` | object[] \\\\| null | | Array of files attached to this comment |\n| `comments[].created_at` | string | yes | Timestamp when comment was created |\n| `comments[].updated_at` | string | yes | Timestamp of last update |\n| `archived_at` | string \\\\| null | | |\n| `created_at` | string | | |\n| `updated_at` | string | | |\n| `_field_names` | object | | |\n| `units` | object | | The unit or currency this row reads a figure in, by the figure\'s field alias, where each row states its own. |\n\n## gmail\n\n### gmail_get_thread (workflow step)\n\n- Retrieve all messages in a Gmail thread.\n\n**Inputs**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `connected_account_id` | string | yes | The ID of the connected Gmail account to use |\n| `thread_id` | string | yes | Gmail thread ID |\n\n**Answer**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `messages` | object[] | yes | |\n| `messages[].id` | string | yes | Gmail message ID |\n| `messages[].threadId` | string | yes | Gmail thread ID |\n| `messages[].labelIds` | string[] | yes | Gmail label IDs (e.g. INBOX, UNREAD) |\n| `messages[].subject` | string | yes | Email subject |\n| `messages[].from` | string | yes | Sender email address |\n| `messages[].to` | string | yes | Recipient email address |\n| `messages[].date` | string | yes | Email date in the workspace timezone, with its offset |\n| `messages[].snippet` | string | yes | Short plain-text preview of the email body |\n| `messages[].cc` | string \\\\| null | | CC recipients |\n| `messages[].bcc` | string \\\\| null | | BCC recipients |\n\n### gmail_query_emails (workflow step)\n\n- Search Gmail messages using Gmail search syntax. ## Query syntax Operators (no space after colon): - People: from:, to:, cc:, bcc:, deliveredto: - Content: subject:, "exact phrase", +exact_word, AROUND N (proximity) - Status: is:unread, is:read, is:starred, is:important, is:muted - Labels: label:name, in:inbox, in:sent, in:drafts, in:spam, in:trash, in:anywhere - Category: category:primary, category:social, category:promotions, category:updates, category:forums - Attachments: has:attachment, has:drive, filename:pdf, filename:report.xlsx (has:attachment matches non-inline attachments only \u2014 it misses inline-embedded PDFs, common in receipts/invoices; open the message with gmail_read_email to detect those) - Size: larger:5M, smaller:2M, size:1000000 - Dates (YYYY/MM/DD): after:2024/01/01, before:2024/12/31 - Relative dates: newer_than:7d, older_than:3m, newer_than:1y (d=days, m=months, y=years) Combining: space = AND, OR (uppercase), - (exclude), () for grouping, {} alternative OR syntax. (workflow step, chat)\n- Examples: - from:alice is:unread has:attachment - subject:"quarterly review" newer_than:2d - {from:alice from:bob} subject:project -in:drafts - larger:5M filename:pdf older_than:6m - in:anywhere deliveredto:billing@company.com\n\n**Inputs**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `connected_account_id` | string | yes | The ID of the connected Gmail account to use |\n| `query` | string | yes | Gmail search syntax expression |\n| `max_result` | number | yes | Maximum number of emails to retrieve (1-50) |\n\n**Answer**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `messages` | object[] | yes | List of matching Gmail messages |\n| `messages[].id` | string | yes | Gmail message ID |\n| `messages[].threadId` | string | yes | Gmail thread ID |\n| `messages[].labelIds` | string[] | yes | Gmail label IDs (e.g. INBOX, UNREAD) |\n| `messages[].subject` | string | yes | Email subject |\n| `messages[].from` | string | yes | Sender email address |\n| `messages[].to` | string | yes | Recipient email address |\n| `messages[].date` | string | yes | Email date in the workspace timezone, with its offset |\n| `messages[].snippet` | string | yes | Short plain-text preview of the email body |\n| `messages[].cc` | string \\\\| null | | CC recipients |\n| `messages[].bcc` | string \\\\| null | | BCC recipients |\n\n### gmail_query_labels (workflow step)\n\n- List Gmail labels with message and thread counts.\n\n**Inputs**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `connected_account_id` | string | yes | The ID of the connected Gmail account to use |\n| `name` | string | | Match labels by name prefix. |\n\n**Answer**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `labels` | object[] | yes | |\n| `labels[].id` | string | yes | |\n| `labels[].name` | string | yes | |\n| `labels[].type` | string | yes | system or user |\n| `labels[].messagesTotal` | number | yes | |\n| `labels[].messagesUnread` | number | yes | |\n| `labels[].threadsTotal` | number | yes | |\n| `labels[].threadsUnread` | number | yes | |\n\n### gmail_read_email (workflow step)\n\n- Fetch a Gmail message\'s body and the list of its attachments.\n- Attachments are stored as workspace files \u2014 the result names each one\'s file_id; read one with view_files, attach one to a record with update_records. (chat, MCP, CLI)\n\n**Inputs**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `connected_account_id` | string | yes | The ID of the connected account |\n| `message_id` | string | yes | The ID of the Gmail message to retrieve |\n\n**Answer**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `message` | object | yes | The full Gmail message |\n| `message.id` | string | | Gmail message ID |\n| `message.threadId` | string | | Gmail thread ID |\n| `message.historyId` | string | | Gmail history ID |\n| `message.labelIds` | string[] | | Gmail label IDs |\n| `message.subject` | string | yes | Email subject |\n| `message.from` | string | yes | Sender email address |\n| `message.to` | string | yes | Recipient email address |\n| `message.date` | string | yes | Email date in the workspace timezone, with its offset |\n| `message.body` | object | yes | Email body |\n| `message.body.type` | "text" \\\\| "html" | yes | Body content type |\n| `message.body.content` | string | yes | Body content |\n| `message.cc` | string \\\\| null | | CC recipients |\n| `message.bcc` | string \\\\| null | | BCC recipients |\n| `message.inReplyTo` | string \\\\| null | | In-Reply-To header |\n| `message.replyTo` | string \\\\| null | | Reply-To header |\n| `message.references` | string[] \\\\| null | | References header |\n| `message.attachments` | object[] | yes | Email attachments |\n| `message.attachments[].partId` | string | yes | MIME part ID |\n| `message.attachments[].filename` | string | yes | Attachment filename as stored, which may differ from the sender\'s |\n| `message.attachments[].mimeType` | string | yes | MIME type as stored, recovered from the filename when the sender declared a generic one |\n| `message.attachments[].attachmentId` | string | yes | Gmail attachment ID |\n| `message.attachments[].file_id` | string | | Stored file ID \u2014 pass to view_files to read it, or to update_records to attach it (chat, MCP, CLI) |\n| `message.attachments[].size` | number | | Stored size in bytes |\n\n### gmail_reply_email (workflow step)\n\n- Draft or send a reply to a Gmail message.\n- Use reply_body (markdown) or template_id + template_data \u2014 not both.\n- Attach stored files by passing their file_ids.\n- Leaves an unsent draft unless draft is false.\n\n**Inputs**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `connected_account_id` | string | yes | |\n| `original_message_id` | string | yes | |\n| `reply_body` | string | | Reply body (markdown). Mutually exclusive with template_id. |\n| `template_id` | string | | |\n| `template_data` | object | | |\n| `cc` | string | | CC recipients, comma-separated |\n| `bcc` | string | | BCC recipients, comma-separated |\n| `draft` | boolean | | Leave true to save an unsent draft the member reviews. Pass false only to deliver the message now \u2014 delivery is irreversible. |\n| `file_ids` | string[] | yes | |\n\n**Answer**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `message_id` | string | yes | The ID of the sent message or saved draft |\n| `subject` | string | yes | The subject as sent |\n| `html` | string | yes | The body as sent, rendered to HTML |\n| `markdown` | string | | The body as written in markdown; absent when a template rendered it |\n| `to` | string | yes | The original sender the reply went to |\n\n### gmail_send_email (workflow step)\n\n- Draft or send a Gmail email.\n- Use content (markdown) or template_id + template_data \u2014 not both.\n- Attach stored files by passing their file_ids.\n- Leaves an unsent draft unless draft is false.\n\n**Inputs**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `connected_account_id` | string | yes | |\n| `to` | string | | Recipient email(s), comma-separated. Required unless using a template. |\n| `subject` | string | | |\n| `cc` | string | | CC recipients, comma-separated |\n| `bcc` | string | | BCC recipients, comma-separated |\n| `content` | string | | Email body (markdown). Mutually exclusive with template_id. |\n| `template_id` | string | | |\n| `template_data` | object | | |\n| `draft` | boolean | | Leave true to save an unsent draft the member reviews. Pass false only to deliver the message now \u2014 delivery is irreversible. |\n| `file_ids` | string[] | yes | |\n\n**Answer**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `message_id` | string | yes | The ID of the sent message or saved draft |\n| `subject` | string | yes | The subject as sent |\n| `html` | string | yes | The body as sent, rendered to HTML |\n| `markdown` | string | | The body as written in markdown; absent when a template rendered it |\n| `to` | string | yes | The recipients as sent, comma-separated |\n\n## import\n\n### import_rows (workflow step)\n\n- Match a source\'s rows to a table by a key and write what differs: a row whose match fields equal no live row\'s is added, one equalling a row\'s changes the cells that differ, one that changes nothing is counted and not written.\n- It keeps nothing of its own: what it wrote and what it held are its answer, read under the run\'s own reach of the table \u2014 a workflow hands its caller only what it returns. match names the fields that identify a row: text, number or date fields a row is written with, compared as trimmed text, case ignored, and link fields holding one row, compared by the row linked. values holds a row\'s cells by field key, as create_records takes them; a link cell also takes { "lookup": "<text>" }, the linked row found by that text in its table\'s unique or autonumber field, else its title, among the rows the run reads of that table, and a link holding many rows a list of them. (workflow step, chat, MCP, CLI)\n- A row is held, never written, when: it carries hold (its text is the message); several live rows or an earlier row of the call carry its key; a link\'s text names no row, or several; the table refuses it or its row is locked; create is false and no row carries its key; or, under conflicts "hold", a live cell holds another value \u2014 the row\'s empty cells are still written. conflicts "overwrite" writes over a differing cell, "fill_empty" leaves it. conflicts.fields sets one field\'s own.\n- A number and the field its unit or currency is read from are one cell, under the number\'s policy: both written, or both held.\n- A row whose key names a row outside the rows the run may write is held "refused" with nothing of that row: no record_id, no changes.\n- A row carrying remove: true proposes moving the row its key names to the trash: always held, whatever create and conflicts say, and counted unchanged where no row carries its key.\n- A row the source leaves out is never touched.\n- One call takes at most 10000 rows; more is refused with the count, never cut short.\n- Returns { read, created, changed, unchanged, held, written, written_omitted, held_rows, held_omitted }: the rows the call took, the rows it added, the rows it changed a cell of, the rows that changed nothing, and the rows held \u2014 a row can be both changed and held. written lists each record written: record_id, kind ("created" or "changed"), line, and changes \u2014 each field changed, with its field_key, its label and its before and after as text, as a read of the table shows them (an option\'s label, a linked row\'s display, a member\'s name; null where empty).\n- A created row lists no changes: its values are the row\'s own. held_rows lists each row held: kind ("create", "update", "remove", or "unmatched" where no row carries its key and create is false), reason, message, record_id where one row carries its key, key (each match field as text), the changes it would make, links (each link text naming no one row, with the rows it could be as candidates { id, display }), and values \u2014 its key and the cells it would write, as the call gave them.\n- A later call given those values under conflicts { "default": "overwrite" } writes it.\n- The two lists hold at most 10000 entries and 8388608 characters of JSON between them, each in the rows\' order, what was written before what was held; written_omitted and held_omitted count what was left out.\n- A row gives each list at most one entry. dry_run: true runs the whole import, judged as a real one, and rolls it back: the answer says what it would write and hold, with dry_run true, and no row, number, event or workflow run is left behind.\n\n**Inputs**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `table_id` | string | yes | |\n| `match` | string[] | yes | |\n| `rows` | object[] | yes | |\n| `rows[].values` | object | yes | |\n| `rows[].hold` | string \\\\| null | | |\n| `rows[].remove` | boolean \\\\| null | | |\n| `rows[].line` | number \\\\| null | | Its line in the source, 1-based. |\n| `create` | boolean \\\\| null | | |\n| `conflicts` | object \\\\| null | | |\n| `dry_run` | boolean \\\\| null | | |\n\n**Answer**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `dry_run` | true | | The import was judged and rolled back: the lists name what it would write, and no record_id in them exists. |\n| `read` | number | yes | |\n| `created` | number | yes | |\n| `changed` | number | yes | |\n| `unchanged` | number | yes | |\n| `held` | number | yes | |\n| `written` | object[] | yes | |\n| `written[].record_id` | string | yes | |\n| `written[].kind` | "created" \\\\| "changed" | yes | |\n| `written[].line` | number | | |\n| `written[].changes` | object[] | yes | |\n| `written[].changes[].field_key` | string | yes | |\n| `written[].changes[].label` | string | yes | |\n| `written[].changes[].before` | string \\\\| null | yes | |\n| `written[].changes[].after` | string \\\\| null | yes | |\n| `written_omitted` | number | yes | |\n| `held_rows` | object[] | yes | |\n| `held_rows[].line` | number | | |\n| `held_rows[].kind` | "create" \\\\| "update" \\\\| "remove" \\\\| "unmatched" | yes | |\n| `held_rows[].reason` | "stated" \\\\| "removal" \\\\| "conflict" \\\\| "no_match" \\\\| "ambiguous_key" \\\\| "unresolved_link" \\\\| "ambiguous_link" \\\\| "locked" \\\\| "refused" | yes | |\n| `held_rows[].message` | string | yes | |\n| `held_rows[].record_id` | string | | |\n| `held_rows[].key` | object | yes | |\n| `held_rows[].values` | object | yes | |\n| `held_rows[].changes` | object[] | yes | |\n| `held_rows[].changes[].field_key` | string | yes | |\n| `held_rows[].changes[].label` | string | yes | |\n| `held_rows[].changes[].before` | string \\\\| null | yes | |\n| `held_rows[].changes[].after` | string \\\\| null | yes | |\n| `held_rows[].links` | object[] | yes | |\n| `held_rows[].links[].field_key` | string | yes | |\n| `held_rows[].links[].text` | string | yes | |\n| `held_rows[].links[].candidates` | object[] | yes | |\n| `held_rows[].links[].candidates[].id` | string | yes | |\n| `held_rows[].links[].candidates[].display` | string | yes | |\n| `held_omitted` | number | yes | |\n\n## instagram\n\n### instagram_create_post (workflow step)\n\n- Publish to the connected Instagram account.\n- Instagram has no text-only posts.\n\n**Inputs**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `connected_account_id` | string | yes | |\n| `caption` | string | yes | |\n| `media_file_ids` | string[] | yes | One image, one video (published as a reel), or 2\u201310 images and videos as a carousel. |\n\n**Answer**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `post_id` | string | yes | |\n| `url` | string | yes | |\n\n## kiotviet\n\n### kiotviet_cancel_invoice (workflow step)\n\n- Cancel a KiotViet sales invoice by id, returning its stock.\n\n**Inputs**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `connected_account_id` | string | yes | KiotViet connected account |\n| `id` | number | yes | |\n| `void_payment` | boolean | | Also void payments recorded on the invoice |\n\n**Answer**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `cancelled` | true | yes | |\n\n### kiotviet_cancel_order (workflow step)\n\n- Cancel a KiotViet sales order by id, releasing the stock it reserved.\n\n**Inputs**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `connected_account_id` | string | yes | KiotViet connected account |\n| `id` | number | yes | |\n| `void_payment` | boolean | | Also void payments recorded on the order |\n\n**Answer**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `cancelled` | true | yes | |\n\n### kiotviet_create_category (workflow step)\n\n- Create a KiotViet product group (nh\xF3m h\xE0ng), optionally under a parent group.\n\n**Inputs**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `connected_account_id` | string | yes | KiotViet connected account |\n| `name` | string | yes | |\n| `parent_id` | number | | |\n\n**Answer**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `category` | object | yes | |\n\n### kiotviet_create_customer (workflow step)\n\n- Create a KiotViet customer (kh\xE1ch h\xE0ng).\n- Returns the customer as KiotViet stores it, with its id and code.\n\n**Inputs**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `connected_account_id` | string | yes | KiotViet connected account |\n| `name` | string | yes | |\n| `code` | string | | Customer code (m\xE3 kh\xE1ch h\xE0ng) |\n| `contact_number` | string | | |\n| `email` | string | | |\n| `address` | string | | |\n| `location_name` | string | | Province/district as KiotViet names it |\n| `ward_name` | string | | |\n| `gender` | boolean | | true for male, false for female |\n| `birth_date` | string | | yyyy-mm-dd |\n| `group_ids` | number[] | | Customer group ids |\n| `comments` | string | | |\n| `branch_id` | number | | |\n\n**Answer**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `customer` | object | yes | |\n\n### kiotviet_create_invoice (workflow step)\n\n- Create a KiotViet sales invoice (h\xF3a \u0111\u01A1n), which deducts stock.\n- Pass order_id to invoice an existing order.\n- Returns the invoice as KiotViet stores it, with its id and code.\n\n**Inputs**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `connected_account_id` | string | yes | KiotViet connected account |\n| `branch_id` | number | yes | |\n| `customer_id` | number | | |\n| `sold_by_id` | number | yes | KiotViet user id of the seller |\n| `purchase_date` | string | | ISO datetime |\n| `description` | string | | |\n| `discount` | number | | Discount amount on the whole document |\n| `lines` | object[] | yes | |\n| `lines[].product_id` | number | yes | |\n| `lines[].quantity` | number | yes | |\n| `lines[].price` | number | | Unit price |\n| `lines[].discount` | number | | Discount amount on the line |\n| `lines[].note` | string | | |\n| `order_id` | number | | The sales order this invoice settles |\n\n**Answer**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `invoice` | object | yes | |\n\n### kiotviet_create_order (workflow step)\n\n- Create a KiotViet sales order (\u0111\u1EB7t h\xE0ng) \u2014 the pre-invoice stage, which reserves stock.\n- Returns the order as KiotViet stores it, with its id and code.\n\n**Inputs**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `connected_account_id` | string | yes | KiotViet connected account |\n| `branch_id` | number | yes | |\n| `customer_id` | number | | |\n| `sold_by_id` | number | | KiotViet user id of the seller |\n| `purchase_date` | string | | ISO datetime |\n| `description` | string | | |\n| `discount` | number | | Discount amount on the whole document |\n| `lines` | object[] | yes | |\n| `lines[].product_id` | number | yes | |\n| `lines[].quantity` | number | yes | |\n| `lines[].price` | number | | Unit price |\n| `lines[].discount` | number | | Discount amount on the line |\n| `lines[].note` | string | | |\n| `make_invoice` | boolean | | Also issue the invoice at once |\n\n**Answer**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `order` | object | yes | |\n\n### kiotviet_create_product (workflow step)\n\n- Create a KiotViet product (h\xE0ng h\xF3a), optionally with opening stock per branch.\n- Returns the product as KiotViet stores it, with its id and code.\n\n**Inputs**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `connected_account_id` | string | yes | KiotViet connected account |\n| `name` | string | yes | |\n| `code` | string | | Product code (m\xE3 h\xE0ng) |\n| `category_id` | number | yes | Product group id |\n| `base_price` | number | | Selling price |\n| `unit` | string | | |\n| `allows_sale` | boolean | | |\n| `is_active` | boolean | | |\n| `description` | string | | |\n| `weight` | number | | Grams |\n| `inventories` | object[] | | Stock per branch |\n| `inventories[].branch_id` | number | yes | |\n| `inventories[].on_hand` | number | yes | |\n| `inventories[].cost` | number | | Unit cost at this branch |\n\n**Answer**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `product` | object | yes | |\n\n### kiotviet_delete_product (workflow step)\n\n- Delete a KiotViet product by id.\n- KiotViet refuses one that already appears on invoices.\n\n**Inputs**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `connected_account_id` | string | yes | KiotViet connected account |\n| `id` | number | yes | |\n\n**Answer**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `deleted` | true | yes | |\n\n### kiotviet_get_customer (workflow step)\n\n- Get one KiotViet customer by id or by customer code (m\xE3 kh\xE1ch h\xE0ng).\n\n**Inputs**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `connected_account_id` | string | yes | KiotViet connected account |\n| `id` | number | | |\n| `code` | string | | Customer code (m\xE3 kh\xE1ch h\xE0ng), e.g. KH000123 |\n\n**Answer**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `customer` | object | yes | |\n\n### kiotviet_get_invoice (workflow step)\n\n- Get one KiotViet sales invoice by id or by invoice code (m\xE3 h\xF3a \u0111\u01A1n), with its line items and payments.\n\n**Inputs**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `connected_account_id` | string | yes | KiotViet connected account |\n| `id` | number | | |\n| `code` | string | | Invoice code (m\xE3 h\xF3a \u0111\u01A1n), e.g. HD000123 |\n\n**Answer**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `invoice` | object | yes | |\n\n### kiotviet_get_product (workflow step)\n\n- Get one KiotViet product by id or by product code (m\xE3 h\xE0ng), with stock per branch in inventories[].\n\n**Inputs**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `connected_account_id` | string | yes | KiotViet connected account |\n| `id` | number | | |\n| `code` | string | | Product code (m\xE3 h\xE0ng), e.g. SP000123 |\n\n**Answer**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `product` | object | yes | |\n\n### kiotviet_list_branches (workflow step)\n\n- List the store\'s KiotViet branches (chi nh\xE1nh).\n- Branch ids scope inventory, invoices and orders.\n\n**Inputs**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `connected_account_id` | string | yes | KiotViet connected account |\n| `page_size` | number | | Rows per page, up to 100 |\n| `current_item` | number | | Zero-based offset of the first row |\n| `last_modified_from` | string | | ISO datetime; only rows modified at or after it |\n| `include_removed_ids` | boolean | | Also return removed_ids \u2014 rows deleted since last_modified_from |\n| `order_by` | string | | KiotViet field name to sort by |\n| `order_direction` | "Asc" \\\\| "Desc" | | |\n\n**Answer**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `total` | number | | Rows matching the query across all pages |\n| `page_size` | number | | |\n| `items` | object[] | yes | Rows as KiotViet returns them |\n| `removed_ids` | number[] | | |\n\n### kiotviet_list_categories (workflow step)\n\n- List KiotViet product groups (nh\xF3m h\xE0ng).\n- Each row carries its parentId, so the tree can be rebuilt from the flat list.\n\n**Inputs**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `connected_account_id` | string | yes | KiotViet connected account |\n| `page_size` | number | | Rows per page, up to 100 |\n| `current_item` | number | | Zero-based offset of the first row |\n| `last_modified_from` | string | | ISO datetime; only rows modified at or after it |\n| `include_removed_ids` | boolean | | Also return removed_ids \u2014 rows deleted since last_modified_from |\n| `order_by` | string | | KiotViet field name to sort by |\n| `order_direction` | "Asc" \\\\| "Desc" | | |\n\n**Answer**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `total` | number | | Rows matching the query across all pages |\n| `page_size` | number | | |\n| `items` | object[] | yes | Rows as KiotViet returns them |\n| `removed_ids` | number[] | | |\n\n### kiotviet_list_customers (workflow step)\n\n- List KiotViet customers (kh\xE1ch h\xE0ng) with their lifetime totals \u2014 totalInvoiced, totalRevenue, debt, totalPoint.\n\n**Inputs**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `connected_account_id` | string | yes | KiotViet connected account |\n| `page_size` | number | | Rows per page, up to 100 |\n| `current_item` | number | | Zero-based offset of the first row |\n| `last_modified_from` | string | | ISO datetime; only rows modified at or after it |\n| `include_removed_ids` | boolean | | Also return removed_ids \u2014 rows deleted since last_modified_from |\n| `order_by` | string | | KiotViet field name to sort by |\n| `order_direction` | "Asc" \\\\| "Desc" | | |\n| `code` | string | | Customer code (m\xE3 kh\xE1ch h\xE0ng) |\n| `name` | string | | Name search |\n| `contact_number` | string | | Phone number |\n| `group_id` | number | | Only customers in this customer group |\n\n**Answer**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `total` | number | | Rows matching the query across all pages |\n| `page_size` | number | | |\n| `items` | object[] | yes | Rows as KiotViet returns them |\n| `removed_ids` | number[] | | |\n\n### kiotviet_list_invoices (workflow step)\n\n- List KiotViet sales invoices (h\xF3a \u0111\u01A1n) with their line items in invoiceDetails[] \u2014 productCode, productName, quantity, price, discount, subTotal.\n\n**Inputs**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `connected_account_id` | string | yes | KiotViet connected account |\n| `page_size` | number | | Rows per page, up to 100 |\n| `current_item` | number | | Zero-based offset of the first row |\n| `last_modified_from` | string | | ISO datetime; only rows modified at or after it |\n| `include_removed_ids` | boolean | | Also return removed_ids \u2014 rows deleted since last_modified_from |\n| `order_by` | string | | KiotViet field name to sort by |\n| `order_direction` | "Asc" \\\\| "Desc" | | |\n| `branch_ids` | number[] | | |\n| `customer_ids` | number[] | | |\n| `customer_code` | string | | Customer code (m\xE3 kh\xE1ch h\xE0ng) |\n| `status` | number[] | | KiotViet status codes; each row carries statusValue with the label |\n| `from_purchase_date` | string | | Date, yyyy-mm-dd |\n| `to_purchase_date` | string | | Date, yyyy-mm-dd |\n| `include_payment` | boolean | | Include payments[] on each row |\n\n**Answer**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `total` | number | | Rows matching the query across all pages |\n| `page_size` | number | | |\n| `items` | object[] | yes | Rows as KiotViet returns them |\n| `removed_ids` | number[] | | |\n\n### kiotviet_list_orders (workflow step)\n\n- List KiotViet sales orders (\u0111\u1EB7t h\xE0ng) \u2014 the pre-invoice stage \u2014 with line items in orderDetails[].\n\n**Inputs**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `connected_account_id` | string | yes | KiotViet connected account |\n| `page_size` | number | | Rows per page, up to 100 |\n| `current_item` | number | | Zero-based offset of the first row |\n| `last_modified_from` | string | | ISO datetime; only rows modified at or after it |\n| `include_removed_ids` | boolean | | Also return removed_ids \u2014 rows deleted since last_modified_from |\n| `order_by` | string | | KiotViet field name to sort by |\n| `order_direction` | "Asc" \\\\| "Desc" | | |\n| `branch_ids` | number[] | | |\n| `customer_ids` | number[] | | |\n| `customer_code` | string | | Customer code (m\xE3 kh\xE1ch h\xE0ng) |\n| `status` | number[] | | KiotViet status codes; each row carries statusValue with the label |\n| `from_purchase_date` | string | | Date, yyyy-mm-dd |\n| `to_purchase_date` | string | | Date, yyyy-mm-dd |\n| `include_payment` | boolean | | Include payments[] on each row |\n\n**Answer**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `total` | number | | Rows matching the query across all pages |\n| `page_size` | number | | |\n| `items` | object[] | yes | Rows as KiotViet returns them |\n| `removed_ids` | number[] | | |\n\n### kiotviet_list_products (workflow step)\n\n- List KiotViet products (h\xE0ng h\xF3a) with stock per branch.\n- Each row\'s inventories[] carries branchId, onHand (t\u1ED3n kho), reserved (\u0111\u1EB7t h\xE0ng) and cost.\n\n**Inputs**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `connected_account_id` | string | yes | KiotViet connected account |\n| `page_size` | number | | Rows per page, up to 100 |\n| `current_item` | number | | Zero-based offset of the first row |\n| `last_modified_from` | string | | ISO datetime; only rows modified at or after it |\n| `include_removed_ids` | boolean | | Also return removed_ids \u2014 rows deleted since last_modified_from |\n| `order_by` | string | | KiotViet field name to sort by |\n| `order_direction` | "Asc" \\\\| "Desc" | | |\n| `category_id` | number | | Only products in this product group |\n| `is_active` | boolean | | Only products still on sale (true) or discontinued (false) |\n\n**Answer**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `total` | number | | Rows matching the query across all pages |\n| `page_size` | number | | |\n| `items` | object[] | yes | Rows as KiotViet returns them |\n| `removed_ids` | number[] | | |\n\n### kiotviet_list_purchase_orders (workflow step)\n\n- List KiotViet purchase orders (phi\u1EBFu nh\u1EADp h\xE0ng) \u2014 goods received from suppliers \u2014 with line items in purchaseOrderDetails[].\n\n**Inputs**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `connected_account_id` | string | yes | KiotViet connected account |\n| `page_size` | number | | Rows per page, up to 100 |\n| `current_item` | number | | Zero-based offset of the first row |\n| `last_modified_from` | string | | ISO datetime; only rows modified at or after it |\n| `include_removed_ids` | boolean | | Also return removed_ids \u2014 rows deleted since last_modified_from |\n| `order_by` | string | | KiotViet field name to sort by |\n| `order_direction` | "Asc" \\\\| "Desc" | | |\n| `branch_ids` | number[] | | |\n| `status` | number[] | | KiotViet status codes; each row carries statusValue with the label |\n| `from_purchase_date` | string | | Date, yyyy-mm-dd |\n| `to_purchase_date` | string | | Date, yyyy-mm-dd |\n| `include_payment` | boolean | | Include payments[] on each row |\n\n**Answer**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `total` | number | | Rows matching the query across all pages |\n| `page_size` | number | | |\n| `items` | object[] | yes | Rows as KiotViet returns them |\n| `removed_ids` | number[] | | |\n\n### kiotviet_update_category (workflow step)\n\n- Rename a KiotViet product group or move it under another parent.\n\n**Inputs**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `connected_account_id` | string | yes | KiotViet connected account |\n| `id` | number | yes | |\n| `name` | string | | |\n| `parent_id` | number | | |\n\n**Answer**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `category` | object | yes | |\n\n### kiotviet_update_customer (workflow step)\n\n- Update a KiotViet customer by id.\n\n**Inputs**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `connected_account_id` | string | yes | KiotViet connected account |\n| `id` | number | yes | |\n| `name` | string | | |\n| `code` | string | | Customer code (m\xE3 kh\xE1ch h\xE0ng) |\n| `contact_number` | string | | |\n| `email` | string | | |\n| `address` | string | | |\n| `location_name` | string | | Province/district as KiotViet names it |\n| `ward_name` | string | | |\n| `gender` | boolean | | true for male, false for female |\n| `birth_date` | string | | yyyy-mm-dd |\n| `group_ids` | number[] | | Customer group ids |\n| `comments` | string | | |\n| `branch_id` | number | | |\n\n**Answer**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `customer` | object | yes | |\n\n### kiotviet_update_invoice (workflow step)\n\n- Update a KiotViet sales invoice by id.\n\n**Inputs**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `connected_account_id` | string | yes | KiotViet connected account |\n| `id` | number | yes | |\n| `branch_id` | number | | |\n| `customer_id` | number | | |\n| `sold_by_id` | number | | KiotViet user id of the seller |\n| `purchase_date` | string | | ISO datetime |\n| `description` | string | | |\n| `discount` | number | | Discount amount on the whole document |\n| `lines` | object[] | | |\n| `lines[].product_id` | number | yes | |\n| `lines[].quantity` | number | yes | |\n| `lines[].price` | number | | Unit price |\n| `lines[].discount` | number | | Discount amount on the line |\n| `lines[].note` | string | | |\n\n**Answer**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `invoice` | object | yes | |\n\n### kiotviet_update_order (workflow step)\n\n- Update a KiotViet sales order by id.\n\n**Inputs**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `connected_account_id` | string | yes | KiotViet connected account |\n| `id` | number | yes | |\n| `branch_id` | number | | |\n| `customer_id` | number | | |\n| `sold_by_id` | number | | KiotViet user id of the seller |\n| `purchase_date` | string | | ISO datetime |\n| `description` | string | | |\n| `discount` | number | | Discount amount on the whole document |\n| `lines` | object[] | | |\n| `lines[].product_id` | number | yes | |\n| `lines[].quantity` | number | yes | |\n| `lines[].price` | number | | Unit price |\n| `lines[].discount` | number | | Discount amount on the line |\n| `lines[].note` | string | | |\n\n**Answer**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `order` | object | yes | |\n\n### kiotviet_update_product (workflow step)\n\n- Update a KiotViet product by id; inventories set the stock level per branch.\n\n**Inputs**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `connected_account_id` | string | yes | KiotViet connected account |\n| `id` | number | yes | |\n| `name` | string | | |\n| `code` | string | | Product code (m\xE3 h\xE0ng) |\n| `category_id` | number | | Product group id |\n| `base_price` | number | | Selling price |\n| `unit` | string | | |\n| `allows_sale` | boolean | | |\n| `is_active` | boolean | | |\n| `description` | string | | |\n| `weight` | number | | Grams |\n| `inventories` | object[] | | Stock per branch |\n| `inventories[].branch_id` | number | yes | |\n| `inventories[].on_hand` | number | yes | |\n| `inventories[].cost` | number | | Unit cost at this branch |\n\n**Answer**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `product` | object | yes | |\n\n## lark\n\n### lark_create_records (workflow step)\n\n- Create one or many records in a Lark Base (Bitable) table (max 500 per call). fields are keyed by Lark field name.\n- Returns the new record ids.\n\n**Inputs**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `connected_account_id` | string | yes | Lark connected account |\n| `app_token` | string | yes | Lark Base app_token (the Base id) |\n| `table_id` | string | yes | Lark Bitable table id |\n| `records` | object[] | yes | |\n| `records[].fields` | object | yes | |\n\n**Answer**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `created` | number | yes | |\n| `records` | object[] | yes | |\n| `records[].record_id` | string | yes | |\n| `records[].fields` | object | yes | |\n\n### lark_delete_records (workflow step)\n\n- Delete one or many records from a Lark Base (Bitable) table by record_id (max 500 per call).\n\n**Inputs**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `connected_account_id` | string | yes | Lark connected account |\n| `app_token` | string | yes | Lark Base app_token (the Base id) |\n| `table_id` | string | yes | Lark Bitable table id |\n| `record_ids` | string[] | yes | |\n\n**Answer**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `deleted` | number | yes | |\n| `record_ids` | string[] | yes | |\n\n### lark_get_record (workflow step)\n\n- Get one record from a Lark Base (Bitable) table by its record_id.\n\n**Inputs**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `connected_account_id` | string | yes | Lark connected account |\n| `app_token` | string | yes | Lark Base app_token (the Base id) |\n| `table_id` | string | yes | Lark Bitable table id |\n| `record_id` | string | yes | |\n\n**Answer**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `record_id` | string | yes | |\n| `fields` | object | yes | |\n\n### lark_list_fields (workflow step)\n\n- List the fields (columns) of a Lark Base (Bitable) table \u2014 name, id, and type.\n- Use to discover the schema before reading or writing records.\n\n**Inputs**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `connected_account_id` | string | yes | Lark connected account |\n| `app_token` | string | yes | Lark Base app_token (the Base id) |\n| `table_id` | string | yes | Lark Bitable table id |\n| `page_size` | number | | |\n| `page_token` | string | | |\n\n**Answer**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `fields` | object[] | yes | |\n| `fields[].field_id` | string | | |\n| `fields[].field_name` | string | | |\n| `fields[].type` | number | | |\n| `fields[].ui_type` | string | | |\n| `fields[].is_primary` | boolean | | |\n| `has_more` | boolean | yes | |\n| `page_token` | string | | |\n\n### lark_list_records (workflow step)\n\n- List records from a Lark Base (Bitable) table.\n- Returns each record\'s id and fields (keyed by Lark field name).\n- Optionally narrow with filter / sort / field_names / view_id; paginate with page_token.\n\n**Inputs**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `connected_account_id` | string | yes | Lark connected account |\n| `app_token` | string | yes | Lark Base app_token (the Base id) |\n| `table_id` | string | yes | Lark Bitable table id |\n| `filter` | object | | |\n| `filter.conjunction` | "and" \\\\| "or" | yes | |\n| `filter.conditions` | object[] | yes | |\n| `filter.conditions[].field_name` | string | yes | |\n| `filter.conditions[].operator` | string | yes | e.g. is, isNot, contains, isGreater, isEmpty |\n| `filter.conditions[].value` | string[] | | |\n| `sort` | object[] | | |\n| `sort[].field_name` | string | yes | |\n| `sort[].desc` | boolean | | |\n| `field_names` | string[] | | Restrict returned fields by name |\n| `view_id` | string | | |\n| `page_size` | number | | |\n| `page_token` | string | | |\n\n**Answer**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `records` | object[] | yes | |\n| `records[].record_id` | string | yes | |\n| `records[].fields` | object | yes | |\n| `has_more` | boolean | yes | |\n| `page_token` | string | | |\n| `total` | number | | |\n\n### lark_list_tables (workflow step)\n\n- List the tables in a Lark Base (Bitable) \u2014 id and name.\n- Use to discover which table_id to read or write.\n\n**Inputs**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `connected_account_id` | string | yes | Lark connected account |\n| `app_token` | string | yes | Lark Base app_token (the Base id) |\n| `page_size` | number | | |\n| `page_token` | string | | |\n\n**Answer**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `tables` | object[] | yes | |\n| `tables[].table_id` | string | | |\n| `tables[].name` | string | | |\n| `has_more` | boolean | yes | |\n| `page_token` | string | | |\n\n### lark_update_records (workflow step)\n\n- Update one or many existing records in a Lark Base (Bitable) table by record_id (max 500 per call). fields are keyed by Lark field name; only provided fields change.\n\n**Inputs**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `connected_account_id` | string | yes | Lark connected account |\n| `app_token` | string | yes | Lark Base app_token (the Base id) |\n| `table_id` | string | yes | Lark Bitable table id |\n| `records` | object[] | yes | |\n| `records[].record_id` | string | yes | |\n| `records[].fields` | object | yes | |\n\n**Answer**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `updated` | number | yes | |\n| `records` | object[] | yes | |\n| `records[].record_id` | string | yes | |\n| `records[].fields` | object | yes | |\n\n## linkedin\n\n### linkedin_create_post (workflow step)\n\n- Publish a post to the connected LinkedIn member\'s profile.\n\n**Inputs**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `connected_account_id` | string | yes | |\n| `text` | string | yes | |\n| `link` | string | | Shown as an article card; needs link_title. A post carries either a link or media. |\n| `link_title` | string | | |\n| `media_file_ids` | string[] | | Up to 20 images, or one video. |\n\n**Answer**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `post_id` | string | yes | |\n| `url` | string | yes | |\n\n## list\n\n### list_banks (workflow step)\n\n- List banks supported by generate_bank_qr_code. (workflow step, chat)\n- Returns the bank code (used as input to the generator), display name, and country.\n- Call this before generate_bank_qr_code to discover valid codes. (workflow step, chat)\n\n**Inputs**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `country` | "VN" | | Filter to banks from a specific country. Omit to list all supported banks. |\n\n**Answer**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `banks` | object[] | yes | |\n| `banks[].country` | string | yes | |\n| `banks[].code` | string | yes | |\n| `banks[].bin` | string | yes | |\n| `banks[].short_name` | string | yes | |\n| `banks[].full_name` | string | yes | |\n\n## lock\n\n### lock_records (workflow step)\n\n- Lock records to prevent editing.\n- Returns `{ locked, record_ids }`.\n\n**Inputs**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `table_id` | string | yes | |\n| `record_ids` | string[] | | |\n| `filters` | any | | |\n\n**Answer**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `locked` | number | yes | |\n| `record_ids` | string[] | yes | |\n\n## lookup\n\n### lookup_business (workflow step)\n\n- Look up business registration info by tax / registration code.\n- Defaults to Vietnam (country=\'VN\').\n\n**Inputs**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `tax_code` | string | yes | Business tax / registration code. Format varies by country; for VN this is a 10\u201313 digit m\xE3 s\u1ED1 thu\u1EBF. |\n| `country` | string | | ISO country code. Defaults to \'VN\' (Vietnam). Only \'VN\' is supported today. |\n\n**Answer**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `country` | string | yes | |\n| `tax_code` | string | yes | |\n| `name` | string | yes | |\n| `address` | string | yes | |\n| `representative` | string \\\\| null | yes | |\n| `status` | string \\\\| null | yes | |\n| `international_name` | string \\\\| null | yes | |\n| `short_name` | string \\\\| null | yes | |\n| `operating_since` | string \\\\| null | yes | |\n| `managed_by` | string \\\\| null | yes | |\n| `company_type` | string \\\\| null | yes | |\n| `primary_industry` | string \\\\| null | yes | |\n\n## misa\n\n### misa_get_dictionary (workflow step)\n\n- Read master-data records from MISA AMIS to look up codes (customer code, item code, warehouse code, etc.) before pushing a voucher.\n\n**Inputs**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `connected_account_id` | string | yes | MISA connected account to read from |\n| `data_type` | 1 \\\\| 2 \\\\| 3 \\\\| 4 \\\\| 5 \\\\| 6 \\\\| 8 \\\\| 9 \\\\| 10 \\\\| 11 \\\\| 12 | yes | 1 objects (customers/suppliers/employees), 2 inventory items, 3 stocks, 4 units, 5 chart of accounts, 6 organization units, 8 bank accounts, 9 projects, 10 cost objects, 11 payment terms, 12 banks |\n| `skip` | integer | | |\n| `take` | integer | | Max 100 per page |\n| `last_sync_time` | string | | ISO timestamp \u2014 only return records changed since this time |\n\n**Answer**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `success` | boolean | yes | |\n| `data` | object[] | yes | The dictionary records, each as MISA names its fields |\n| `last_sync_time` | string | | Cursor \u2014 pass as last_sync_time on the next call to fetch only newer changes |\n| `error_code` | string | | |\n| `error_message` | string | | |\n\n### misa_save_bank_voucher (workflow step)\n\n- Push a bank voucher (deposit / withdrawal / transfer) into MISA AMIS. voucher_type: 1 deposit (b\xE1o c\xF3), 3 withdrawal/debit-advice (b\xE1o n\u1EE3), 2 internal transfer. bank_account_id is the company-side bank account GUID \u2014 look it up with misa_get_dictionary (data_type 8). (workflow step, chat)\n\n**Inputs**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `voucher_type` | 1 \\\\| 2 \\\\| 3 | yes | |\n| `reftype` | integer | | |\n| `org_refid` | string | yes | a UUID that stays the same for the document \u2014 `uuidFrom(record.id)` |\n| `org_refno` | string | | Source-system document number |\n| `refdate` | string | yes | Document date, ISO 8601 |\n| `posted_date` | string | yes | Accounting posting date, ISO 8601 |\n| `branch_id` | string | | MISA branch GUID, optional for single-branch tenants |\n| `currency_id` | string | | Currency code, default VND |\n| `exchange_rate` | number | | FX rate, default 1.0 |\n| `journal_memo` | string | | Voucher memo/notes |\n| `custom_field1` | string | | |\n| `custom_field2` | string | | |\n| `custom_field3` | string | | |\n| `custom_field4` | string | | |\n| `custom_field5` | string | | |\n| `bank_account_id` | string | yes | Bank account GUID in MISA \u2014 look up via misa_get_dictionary (workflow step, chat) |\n| `account_object_code` | string | | |\n| `account_object_name` | string | | |\n| `total_amount_oc` | number | yes | |\n| `total_amount` | number | yes | |\n| `detail` | object[] | yes | |\n| `detail[].description` | string | yes | |\n| `detail[].amount_oc` | number | yes | |\n| `detail[].amount` | number | yes | |\n| `detail[].debit_account` | string | yes | |\n| `detail[].credit_account` | string | yes | |\n| `detail[].sort_order` | integer | | |\n| `connected_account_id` | string | yes | MISA connected account to push to |\n\n**Answer**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `queued` | boolean | yes | |\n| `org_refid` | string | yes | |\n| `error_code` | string | | |\n| `error_message` | string | | |\n\n### misa_save_cash_voucher (workflow step)\n\n- Push a cash voucher (cash receipt or cash payment) into MISA AMIS. voucher_type: 5 receipt (thu ti\u1EC1n), 4 payment (chi ti\u1EC1n). detail array carries the journal lines with debit_account/credit_account per Vietnamese chart-of-accounts.\n\n**Inputs**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `voucher_type` | 4 \\\\| 5 | yes | |\n| `reftype` | integer | | |\n| `org_refid` | string | yes | a UUID that stays the same for the document \u2014 `uuidFrom(record.id)` |\n| `org_refno` | string | | Source-system document number |\n| `refdate` | string | yes | Document date, ISO 8601 |\n| `posted_date` | string | yes | Accounting posting date, ISO 8601 |\n| `branch_id` | string | | MISA branch GUID, optional for single-branch tenants |\n| `currency_id` | string | | Currency code, default VND |\n| `exchange_rate` | number | | FX rate, default 1.0 |\n| `journal_memo` | string | | Voucher memo/notes |\n| `custom_field1` | string | | |\n| `custom_field2` | string | | |\n| `custom_field3` | string | | |\n| `custom_field4` | string | | |\n| `custom_field5` | string | | |\n| `account_object_code` | string | | Counterparty code (customer/supplier/employee) |\n| `account_object_name` | string | | |\n| `total_amount_oc` | number | yes | |\n| `total_amount` | number | yes | |\n| `detail` | object[] | yes | |\n| `detail[].description` | string | yes | |\n| `detail[].amount_oc` | number | yes | |\n| `detail[].amount` | number | yes | |\n| `detail[].debit_account` | string | yes | |\n| `detail[].credit_account` | string | yes | |\n| `detail[].sort_order` | integer | | |\n| `connected_account_id` | string | yes | MISA connected account to push to |\n\n**Answer**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `queued` | boolean | yes | |\n| `org_refid` | string | yes | |\n| `error_code` | string | | |\n| `error_message` | string | | |\n\n### misa_save_dictionary (workflow step)\n\n- Create or update master-data records in MISA AMIS (customers, suppliers, items, warehouses, etc.).\n- Use this before pushing a voucher that references a code MISA doesn\'t yet know about.\n\n**Inputs**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `connected_account_id` | string | yes | MISA connected account to push to |\n| `dictionary_type` | 1 \\\\| 2 \\\\| 3 \\\\| 4 \\\\| 5 \\\\| 6 \\\\| 7 \\\\| 8 \\\\| 9 \\\\| 10 \\\\| 12 | yes | 1 customers/suppliers/employees, 2 object groups, 3 inventory items, 4 item categories, 5 warehouses, 6 units, 7 bank accounts, 8 banks, 9 expense items, 10 revenue/expense categories, 12 cost allocation |\n| `items` | object[] | yes | Master-data records to create or update. 1\u2013200 per call. Each item\'s required fields depend on dictionary_type \u2014 see MISA Open API docs (chat, MCP, CLI) |\n\n**Answer**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `queued` | boolean | yes | |\n| `count` | integer | yes | |\n| `error_code` | string | | |\n| `error_message` | string | | |\n\n### misa_save_purchase_voucher (workflow step)\n\n- Push a purchase voucher (purchase invoice / return / discount-debit-note-to-supplier / service / general purchase / purchase order) into MISA AMIS. voucher_type: 15 invoice, 18 voucher, 17 service, 16 return, 14 discount (debit note to supplier), 21 purchase order. account_object_code identifies the supplier in MISA \u2014 must already exist.\n- MISA sync ack only confirms queueing; final accept/reject arrives async.\n\n**Inputs**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `voucher_type` | 14 \\\\| 15 \\\\| 16 \\\\| 17 \\\\| 18 \\\\| 21 | yes | |\n| `reftype` | integer | | |\n| `org_refid` | string | yes | a UUID that stays the same for the document \u2014 `uuidFrom(record.id)` |\n| `org_refno` | string | | Source-system document number |\n| `refdate` | string | yes | Document date, ISO 8601 |\n| `posted_date` | string | yes | Accounting posting date, ISO 8601 |\n| `branch_id` | string | | MISA branch GUID, optional for single-branch tenants |\n| `currency_id` | string | | Currency code, default VND |\n| `exchange_rate` | number | | FX rate, default 1.0 |\n| `journal_memo` | string | | Voucher memo/notes |\n| `custom_field1` | string | | |\n| `custom_field2` | string | | |\n| `custom_field3` | string | | |\n| `custom_field4` | string | | |\n| `custom_field5` | string | | |\n| `account_object_code` | string | yes | Supplier code in MISA |\n| `account_object_name` | string | | |\n| `account_object_tax_code` | string | | |\n| `account_object_address` | string | | |\n| `total_amount_oc` | number | yes | |\n| `total_amount` | number | yes | |\n| `total_vat_amount_oc` | number | yes | |\n| `total_vat_amount` | number | yes | |\n| `detail` | object[] | yes | |\n| `detail[].inventory_item_code` | string | | Item/product code in MISA. Skip when is_description=true |\n| `detail[].inventory_item_name` | string | yes | Item/product display name |\n| `detail[].inventory_item_type` | 0 \\\\| 1 \\\\| 2 \\\\| 3 | | Item type \u2014 0 goods, 1 finished, 2 service, 3 raw material |\n| `detail[].description` | string | | Line description |\n| `detail[].is_description` | boolean | | Description-only line \u2014 when true, only `description` is required |\n| `detail[].stock_code` | string | | Warehouse code in MISA |\n| `detail[].unit_name` | string | | Unit of measure name |\n| `detail[].quantity` | number | yes | Line quantity |\n| `detail[].unit_price` | number | yes | Unit price (in currency_id of voucher) |\n| `detail[].amount_oc` | number | yes | Line total in original currency |\n| `detail[].amount` | number | yes | Line total in accounting currency (= amount_oc when VND) |\n| `detail[].discount_rate` | number | | Line discount percent |\n| `detail[].discount_amount_oc` | number | | Discount amount, original currency |\n| `detail[].discount_amount` | number | | Discount amount, accounting currency |\n| `detail[].vat_rate` | number | yes | VAT percent. 0/5/8/10 for standard, -1 exempt, -2 not creditable, -3 other (set other_vat_rate) |\n| `detail[].other_vat_rate` | number | | Custom VAT percent \u2014 required when vat_rate=-3 |\n| `detail[].vat_amount_oc` | number | yes | VAT amount, original currency |\n| `detail[].vat_amount` | number | yes | VAT amount, accounting currency |\n| `detail[].debit_account` | string | yes | Chart-of-accounts code on debit side, e.g. 131 |\n| `detail[].credit_account` | string | yes | Chart-of-accounts code on credit side, e.g. 511 |\n| `detail[].sale_account` | string | | |\n| `detail[].vat_account` | string | | |\n| `detail[].inventory_account` | string | | |\n| `detail[].sort_order` | integer | | |\n| `detail[].lot_no` | string | | |\n| `detail[].expiry_date` | string | | ISO date |\n| `detail[].organization_unit_code` | string | | Department/branch code |\n| `connected_account_id` | string | yes | MISA connected account to push to |\n\n**Answer**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `queued` | boolean | yes | |\n| `org_refid` | string | yes | |\n| `error_code` | string | | |\n| `error_message` | string | | |\n\n### misa_save_sales_voucher (workflow step)\n\n- Push a sales voucher (invoice / sales return / discount-credit-note / sales order) into MISA AMIS. voucher_type: 11 invoice, 13 sales voucher, 12 return, 10 discount/credit note, 20 sales order. reftype refines the sub-type (e.g. 3530 unpaid sale, 3560 sales invoice, 3571 service cash). account_object_code identifies the customer in MISA \u2014 must already exist (use misa_save_dictionary first if not). (workflow step, chat)\n- MISA sync ack only confirms queueing; final accept/reject arrives async on the registered callback URL.\n\n**Inputs**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `voucher_type` | 10 \\\\| 11 \\\\| 12 \\\\| 13 \\\\| 20 | yes | |\n| `reftype` | integer | | Voucher sub-type code \u2014 MISA assigns a default when omitted |\n| `org_refid` | string | yes | a UUID that stays the same for the document \u2014 `uuidFrom(record.id)` |\n| `org_refno` | string | | Source-system document number |\n| `refdate` | string | yes | Document date, ISO 8601 |\n| `posted_date` | string | yes | Accounting posting date, ISO 8601 |\n| `branch_id` | string | | MISA branch GUID, optional for single-branch tenants |\n| `currency_id` | string | | Currency code, default VND |\n| `exchange_rate` | number | | FX rate, default 1.0 |\n| `journal_memo` | string | | Voucher memo/notes |\n| `custom_field1` | string | | |\n| `custom_field2` | string | | |\n| `custom_field3` | string | | |\n| `custom_field4` | string | | |\n| `custom_field5` | string | | |\n| `account_object_code` | string | yes | Customer code in MISA |\n| `account_object_name` | string | | |\n| `account_object_tax_code` | string | | |\n| `account_object_address` | string | | |\n| `employee_code` | string | | Salesperson code |\n| `employee_name` | string | | |\n| `total_sale_amount_oc` | number | yes | |\n| `total_sale_amount` | number | yes | |\n| `total_discount_amount_oc` | number | | |\n| `total_discount_amount` | number | | |\n| `total_vat_amount_oc` | number | yes | |\n| `total_vat_amount` | number | yes | |\n| `total_amount_oc` | number | yes | |\n| `total_amount` | number | yes | |\n| `is_sale_with_outward` | boolean | | Auto-generate stock-out slip |\n| `detail` | object[] | yes | |\n| `detail[].inventory_item_code` | string | | Item/product code in MISA. Skip when is_description=true |\n| `detail[].inventory_item_name` | string | yes | Item/product display name |\n| `detail[].inventory_item_type` | 0 \\\\| 1 \\\\| 2 \\\\| 3 | | Item type \u2014 0 goods, 1 finished, 2 service, 3 raw material |\n| `detail[].description` | string | | Line description |\n| `detail[].is_description` | boolean | | Description-only line \u2014 when true, only `description` is required |\n| `detail[].stock_code` | string | | Warehouse code in MISA |\n| `detail[].unit_name` | string | | Unit of measure name |\n| `detail[].quantity` | number | yes | Line quantity |\n| `detail[].unit_price` | number | yes | Unit price (in currency_id of voucher) |\n| `detail[].amount_oc` | number | yes | Line total in original currency |\n| `detail[].amount` | number | yes | Line total in accounting currency (= amount_oc when VND) |\n| `detail[].discount_rate` | number | | Line discount percent |\n| `detail[].discount_amount_oc` | number | | Discount amount, original currency |\n| `detail[].discount_amount` | number | | Discount amount, accounting currency |\n| `detail[].vat_rate` | number | yes | VAT percent. 0/5/8/10 for standard, -1 exempt, -2 not creditable, -3 other (set other_vat_rate) |\n| `detail[].other_vat_rate` | number | | Custom VAT percent \u2014 required when vat_rate=-3 |\n| `detail[].vat_amount_oc` | number | yes | VAT amount, original currency |\n| `detail[].vat_amount` | number | yes | VAT amount, accounting currency |\n| `detail[].debit_account` | string | yes | Chart-of-accounts code on debit side, e.g. 131 |\n| `detail[].credit_account` | string | yes | Chart-of-accounts code on credit side, e.g. 511 |\n| `detail[].sale_account` | string | | |\n| `detail[].vat_account` | string | | |\n| `detail[].inventory_account` | string | | |\n| `detail[].sort_order` | integer | | |\n| `detail[].lot_no` | string | | |\n| `detail[].expiry_date` | string | | ISO date |\n| `detail[].organization_unit_code` | string | | Department/branch code |\n| `connected_account_id` | string | yes | MISA connected account to push to |\n\n**Answer**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `queued` | boolean | yes | |\n| `org_refid` | string | yes | |\n| `error_code` | string | | |\n| `error_message` | string | | |\n\n## outlook\n\n### outlook_count_emails (workflow step)\n\n- Count Outlook emails matching a filter or search query.\n\n**Inputs**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `connected_account_id` | string | yes | The Lotics connected account ID (cac_xxx), not the Microsoft user ID |\n| `search` | string | | KQL expression |\n| `filter` | string | | OData expression |\n| `folder` | string | | Mail folder: well-known name or folder ID |\n| `user_id` | string | | Mailbox owner user ID. |\n\n**Answer**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `count` | number | yes | Number of matching emails |\n\n### outlook_query_emails (workflow step)\n\n- Search and filter Outlook emails.\n- Two modes: search (KQL keywords) or structured filters (from, to, subject, dates, etc.).\n- When search is provided, structured filters are ignored. ## KQL search syntax (for the search field) Bare keywords search across from, subject, and body.\n- Property restrictions (no space after colon): - People: from:, to:, cc:, bcc:, participants: (all people fields), recipients: (to+cc+bcc) - Content: subject:, body:, "exact phrase" - Attachments: hasAttachments:true, attachment:filename* - Dates: received:2024-01-01, received:2024-01-01..2024-01-31, received>=2024-01-01, received:today, received:"this week", sent:2024-06-01 - Size (bytes): size>1048576, size:1..10485760 - Status: importance:high, isRead:false, kind:email Combining (UPPERCASE required): AND, OR, NOT, - (exclude), () for grouping.\n- Prefix wildcard: subject:set* (end of word only, no *prefix or *mid*dle).\n- Examples: - from:john@contoso.com AND subject:"quarterly report" - participants:alice importance:high hasAttachments:true - body:contract AND received>=2024-01-01 AND received<=2024-06-30 - (from:legal OR from:compliance) AND subject:contract - attachment:annual* received:"this month"\n\n**Inputs**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `connected_account_id` | string | yes | The Lotics connected account ID (cac_xxx), not the Microsoft user ID |\n| `search` | string | | KQL search query \u2014 overrides all structured filter fields when provided |\n| `from` | string | | Sender email address exact match |\n| `to` | string | | Recipient email address exact match |\n| `subject` | string | | Subject starts with this text |\n| `after` | string | | ISO date \u2014 received on or after |\n| `before` | string | | ISO date \u2014 received before |\n| `is_read` | boolean | | |\n| `is_draft` | boolean | | |\n| `has_attachments` | boolean | | |\n| `importance` | "low" \\\\| "normal" \\\\| "high" | | |\n| `is_flagged` | boolean | | Whether the email is flagged for follow-up |\n| `classification` | "focused" \\\\| "other" | | Focused Inbox classification |\n| `category` | string | | Color category name |\n| `conversation_id` | string | | Outlook conversation ID \u2014 retrieves all messages in a thread |\n| `folder` | string | | Mail folder: well-known name (inbox, sentitems, drafts, deleteditems, junkemail, archive) or folder ID |\n| `skip` | number | | Number of results to skip for pagination. |\n| `max_result` | number | yes | Maximum number of emails to retrieve (1-50) |\n| `user_id` | string | | The ID of the user whose mailbox the email belongs to. If not provided, the connected account\'s user ID (me) will be used. |\n\n**Answer**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `messages` | object[] | yes | List of matching Outlook messages |\n| `messages[].id` | string | yes | Outlook message ID |\n| `messages[].date` | string | yes | Email date in the workspace timezone, with its offset |\n| `messages[].from` | string | yes | Sender email address |\n| `messages[].subject` | string | yes | Email subject |\n| `messages[].to` | string | yes | Recipient email address |\n| `messages[].cc` | string \\\\| null | yes | CC recipients |\n| `messages[].bcc` | string \\\\| null | yes | BCC recipients |\n| `messages[].snippet` | string | yes | Short plain-text preview of the email body (first ~255 chars) |\n| `messages[].conversationId` | string | | Outlook conversation ID |\n| `messages[].receivedDateTime` | string | | Received date-time in ISO format |\n| `messages[].hasAttachments` | boolean | | Whether the email has attachments |\n| `messages[].importance` | string | | Email importance level |\n| `messages[].isRead` | boolean | | Whether the email has been read |\n| `messages[].isDraft` | boolean | | Whether the email is a draft |\n| `messages[].flagStatus` | string | | Flag status: notFlagged, complete, or flagged |\n| `messages[].classification` | string | | Focused Inbox classification: focused or other |\n| `messages[].categories` | string[] | | Color categories assigned to the email |\n\n### outlook_query_folders (workflow step)\n\n- List mail folders in an Outlook mailbox.\n\n**Inputs**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `connected_account_id` | string | yes | The Lotics connected account ID (cac_xxx), not the Microsoft user ID |\n| `parent_folder_id` | string | | Parent folder ID to list child folders. Omit for top-level folders. |\n| `name` | string | | Match folders by display name prefix. |\n| `include_hidden` | boolean | | Include hidden system folders |\n| `max_result` | number | | Maximum number of folders to return (1-100) |\n| `skip` | number | | Number of results to skip for pagination |\n| `user_id` | string | | The ID of the user whose mailbox to query. Defaults to the connected account owner. |\n\n**Answer**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `folders` | object[] | yes | List of mail folders |\n| `folders[].id` | string | yes | Folder ID |\n| `folders[].displayName` | string | yes | Folder display name |\n| `folders[].parentFolderId` | string | | Parent folder ID |\n| `folders[].childFolderCount` | number | yes | Number of child folders |\n| `folders[].totalItemCount` | number | yes | Total number of items in the folder |\n| `folders[].unreadItemCount` | number | yes | Number of unread items |\n| `folders[].isHidden` | boolean | yes | Whether the folder is hidden |\n\n### outlook_read_email (workflow step)\n\n- Fetch an Outlook email\'s body and the list of its attachments.\n- Attachments are stored as workspace files \u2014 the result names each one\'s file_id; read one with view_files, attach one to a record with update_records. (chat, MCP, CLI)\n\n**Inputs**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `connected_account_id` | string | yes | The Lotics connected account ID (cac_xxx), not the Microsoft user ID |\n| `message_id` | string | yes | The ID of the Outlook message to retrieve |\n| `user_id` | string | | The ID of the user whose mailbox the email belongs to. If not provided, the connected account\'s user ID (me) will be used. |\n\n**Answer**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `message` | object | yes | The full Outlook message |\n| `message.id` | string | | Outlook message ID |\n| `message.date` | string | yes | Email date in the workspace timezone, with its offset |\n| `message.from` | string | yes | Sender email address |\n| `message.subject` | string | yes | Email subject |\n| `message.to` | string | yes | Recipient email address |\n| `message.cc` | string \\\\| null | yes | CC recipients |\n| `message.bcc` | string \\\\| null | yes | BCC recipients |\n| `message.inReplyTo` | string \\\\| null | yes | In-Reply-To header |\n| `message.replyTo` | string \\\\| null | yes | Reply-To header |\n| `message.references` | string[] \\\\| null | yes | References header |\n| `message.conversationId` | string | | Outlook conversation ID |\n| `message.receivedDateTime` | string | | Received date-time in ISO format |\n| `message.hasAttachments` | boolean | | Whether the email has attachments |\n| `message.importance` | string | | Email importance level |\n| `message.isRead` | boolean | | Whether the email has been read |\n| `message.body` | object | yes | Email body |\n| `message.body.content` | string | yes | Body content |\n| `message.body.contentType` | "text" \\\\| "html" | yes | Body content type |\n| `message.attachments` | object[] | | Email attachments |\n| `message.attachments[].id` | string | yes | Outlook attachment ID |\n| `message.attachments[].fileName` | string | | Attachment filename as stored, which may differ from the sender\'s |\n| `message.attachments[].contentType` | string | | MIME type as stored, recovered from the filename when Graph declared a generic one |\n| `message.attachments[].size` | number | | Stored size in bytes |\n| `message.attachments[].file_id` | string | | Stored file ID \u2014 pass to view_files to read it, or to update_records to attach it (chat, MCP, CLI) |\n\n### outlook_reply_email (workflow step)\n\n- Draft or send a reply to an Outlook email.\n- Use reply_body (markdown) or template_id + template_data \u2014 not both.\n- Attach stored files by passing their file_ids.\n- Leaves an unsent draft unless draft is false.\n\n**Inputs**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `connected_account_id` | string | yes | The Lotics connected account ID (cac_xxx), not the Microsoft user ID |\n| `original_message_id` | string | yes | |\n| `reply_body` | string | | Reply body (markdown). Mutually exclusive with template_id. |\n| `template_id` | string | | |\n| `template_data` | object | | |\n| `cc` | string | | CC recipients, comma-separated |\n| `bcc` | string | | BCC recipients, comma-separated |\n| `draft` | boolean | | Leave true to save an unsent draft the member reviews. Pass false only to deliver the message now \u2014 delivery is irreversible. |\n| `file_ids` | string[] | yes | |\n| `user_id` | string | | Mailbox user ID |\n\n**Answer**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `message_id` | string | yes | The ID of the sent message or saved draft |\n| `subject` | string | yes | The subject as sent |\n| `html` | string | yes | The body as sent, rendered to HTML |\n| `markdown` | string | | The body as written in markdown; absent when a template rendered it |\n| `to` | string | yes | The original sender the reply went to |\n\n### outlook_send_email (workflow step)\n\n- Draft or send an Outlook email.\n- Use content (markdown) or template_id + template_data \u2014 not both.\n- Attach stored files by passing their file_ids.\n- Leaves an unsent draft unless draft is false, and while drafting \'to\' may be omitted to leave the recipient line for the member to fill in.\n\n**Inputs**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `connected_account_id` | string | yes | The Lotics connected account ID (cac_xxx), not the Microsoft user ID |\n| `to` | string | | Recipient email(s), comma-separated. Required unless using a template. |\n| `subject` | string | | |\n| `cc` | string | | CC recipients, comma-separated |\n| `bcc` | string | | BCC recipients, comma-separated |\n| `content` | string | | Email body (markdown). Mutually exclusive with template_id. |\n| `template_id` | string | | |\n| `template_data` | object | | |\n| `draft` | boolean | | Leave true to save an unsent draft the member reviews. Pass false only to deliver the message now \u2014 delivery is irreversible. |\n| `file_ids` | string[] | yes | |\n| `user_id` | string | | Mailbox user ID |\n\n**Answer**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `message_id` | string | yes | The ID of the sent message or saved draft |\n| `subject` | string | yes | The subject as sent |\n| `html` | string | yes | The body as sent, rendered to HTML |\n| `markdown` | string | | The body as written in markdown; absent when a template rendered it |\n| `to` | string \\\\| null | yes | The recipients as sent, comma-separated; null on a draft saved without recipients |\n\n## press\n\n### press_button (workflow step)\n\n- Run a button field\'s action against a record \u2014 equivalent to a user clicking the button in the UI.\n- Buttons are a legacy field type; the action must already be wired.\n\n**Inputs**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `table_id` | string | yes | |\n| `record_id` | string | yes | |\n| `field_key` | string | yes | |\n\n**Answer**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `execution_id` | string | | |\n| `status` | "success" \\\\| "error" | | |\n| `message` | string | | |\n\n## rename\n\n### rename_file (workflow step)\n\n- Get a file under another name: a new file over the same bytes.\n- Write its file_id into the files field in place of the old one; every other place holding the old file keeps its name.\n\n**Inputs**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `file_id` | string | yes | |\n| `filename` | string | yes | The whole new name, keeping the file\'s extension exactly. |\n\n**Answer**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `file_id` | string | yes | The ID of the generated file. |\n| `url` | string | yes | The download URL of the generated file. |\n| `filename` | string | yes | The filename of the generated file, including extension. |\n| `mime_type` | string | yes | The MIME type of the generated file. |\n\n## request\n\n### request_locked_record_change (workflow step)\n\n- Submit one approval request to change locked field values on a record.\n- Include every blocked field in the same call \u2014 the approver accepts or rejects the change set as a whole.\n- Values are NOT changed immediately; an approver must accept the request first.\n- Returns `{ approval_request_id, status }`.\n\n**Inputs**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `table_id` | string | yes | |\n| `record_id` | string | yes | |\n| `changes` | object | yes | Map of field_key to proposed new value. Every key must be locked on this record. |\n| `reason` | string | yes | |\n\n**Answer**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `approval_request_id` | string | yes | |\n| `status` | "pending" | yes | |\n\n## save\n\n### save_file (workflow step)\n\n- Save a file from a URL to the workspace.\n- Max 20MB.\n\n**Inputs**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `url` | string | yes | Public URL to download the file from (https:// or http://). |\n| `filename` | string | | Filename with extension, e.g. \'report.pdf\'. Derived from URL if omitted. |\n| `mime_type` | string | | MIME type, e.g. \'application/pdf\'. Derived from URL response if omitted. |\n\n**Answer**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `id` | string | yes | File ID. |\n| `filename` | string | yes | |\n| `mime_type` | string | yes | |\n| `url` | string | yes | |\n\n### save_record (workflow step)\n\n- Save a record found by its identity, with its lines, all or nothing.\n- A call whose match is carried by no live record adds it; one carried by a record changes the cells of set that differ and appends add_to, so sending the same call twice writes nothing the second time. match holds the record\'s identity: field keys with their values, exactly the fields of one uniqueness its table declares (a unique field, or fields unique together). set holds its other cells. add_to lists items to append to a files, multi-select or many-link field, keeping what it holds. lines saves rows of other tables that link the record.\n- Each entry names the child table_id, its one-row link field to this table, match (the child fields that with link are one uniqueness of the child table) and rows, each stating every match field. link is set by the step and no row states it.\n- A line found by its match changes the cells that differ; one not found is added; a line not listed is left as it is.\n- Any refusal \u2014 a table check, a locked record, a value a field does not take, no write access to a table \u2014 saves nothing.\n- Returns { record_id, created, updated_field_keys, lines }: the cells changed on a record that existed, and per entry of lines the ids it added, changed and left unchanged.\n\n**Inputs**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `table_id` | string | yes | |\n| `match` | object | yes | |\n| `set` | object \\\\| null | | |\n| `add_to` | object \\\\| null | | |\n| `lines` | object[] \\\\| null | | |\n\n**Answer**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `record_id` | string | yes | |\n| `created` | boolean | yes | |\n| `updated_field_keys` | string[] | yes | |\n| `lines` | object[] | yes | |\n| `lines[].table_id` | string | yes | |\n| `lines[].created_ids` | string[] | yes | |\n| `lines[].updated_ids` | string[] | yes | |\n| `lines[].unchanged_ids` | string[] | yes | |\n\n## send\n\n### send_notification (workflow step)\n\n- Notify workspace members.\n- Lotics shows it to each recipient and emails it to their own address, through no connection; a member who signs in with an identifier an admin issued has no mailbox and sees it in Lotics alone.\n- A member is named by the member_id get_workspace kind member lists. (chat, MCP, CLI)\n- In a workflow body, every member is { type: "organization", id: runtime.organization_id } and the member who started the run { type: "member", id: runtime.triggered_by_member_id }; a run a schedule, a webhook or an email starts was started by no member.\n\n**Inputs**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `recipients` | any[] | yes | List of recipients. Each entry is a principal: { type: \'member\', id } \\| { type: \'member_group\', id } \\| { type: \'organization\', id } |\n| `title` | string | yes | |\n| `body` | string | yes | |\n| `level` | "urgent" \\\\| "warning" \\\\| "info" \\\\| "success" | | Notification level: urgent, warning, info, success |\n\n**Answer**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `message` | string | yes | |\n| `recipients_count` | number | yes | |\n\n## telegram\n\n### telegram_list_chats (workflow step)\n\n- List the chats that messaged a Telegram bot, or added it, in the last 24 hours \u2014 where a chat id comes from.\n\n**Inputs**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `connected_account_id` | string | yes | Telegram connected account |\n\n**Answer**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `chats` | object[] | yes | |\n| `chats[].chat_id` | string | yes | |\n| `chats[].type` | string | yes | private, group, supergroup or channel |\n| `chats[].name` | string | yes | |\n| `chats[].username` | string | | |\n| `chats[].last_seen_at` | string | yes | ISO timestamp of the chat\'s latest update |\n\n### telegram_send_document (workflow step)\n\n- Send a stored file from a Telegram bot to a chat, as a document with an optional caption.\n- The chat must have messaged the bot first.\n\n**Inputs**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `connected_account_id` | string | yes | Telegram connected account |\n| `chat_id` | string | yes | Numeric chat id, or the @username of a public channel or group |\n| `file_id` | string | yes | |\n| `caption` | string | | |\n| `parse_mode` | "HTML" \\\\| "MarkdownV2" | | How the text is formatted: Telegram\'s HTML tags or its MarkdownV2 syntax |\n| `disable_notification` | boolean | | Deliver without a notification sound |\n\n**Answer**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `message_id` | number | yes | |\n| `chat_id` | string | yes | |\n| `sent_at` | string | yes | ISO timestamp |\n\n### telegram_send_message (workflow step)\n\n- Send a text message from a Telegram bot to a chat.\n- The chat must have messaged the bot first.\n\n**Inputs**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `connected_account_id` | string | yes | Telegram connected account |\n| `chat_id` | string | yes | Numeric chat id, or the @username of a public channel or group |\n| `text` | string | yes | |\n| `parse_mode` | "HTML" \\\\| "MarkdownV2" | | How the text is formatted: Telegram\'s HTML tags or its MarkdownV2 syntax |\n| `disable_notification` | boolean | | Deliver without a notification sound |\n\n**Answer**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `message_id` | number | yes | |\n| `chat_id` | string | yes | |\n| `sent_at` | string | yes | ISO timestamp |\n\n## threads\n\n### threads_create_post (workflow step)\n\n- Publish a post to the connected Threads account.\n\n**Inputs**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `connected_account_id` | string | yes | |\n| `text` | string | yes | |\n| `link` | string | | Shown as a link card. A post carries either a link or media. |\n| `media_file_ids` | string[] | | One image or video, or 2\u201320 images and videos as a carousel. |\n\n**Answer**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `post_id` | string | yes | |\n| `url` | string | yes | |\n\n## vietcombank\n\n### vietcombank_convert_currency (workflow step)\n\n- Convert between currencies using live Vietcombank exchange rates.\n\n**Inputs**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `from_currency` | string | yes | Source currency code (e.g., \'USD\', \'EUR\', \'JPY\') |\n| `to_currency` | string | yes | Target currency code (e.g., \'USD\', \'EUR\', \'JPY\') |\n| `amount` | number | yes | The amount to convert (must be positive) |\n\n**Answer**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `converted_amount` | number | yes | The converted amount |\n| `exchange_rate` | number | yes | The exchange rate |\n\n## wininvoice\n\n### wininvoice_create_or_update_invoice (workflow step)\n\n- Create or update a Vietnamese e-invoice in WinInvoice. invRef identifies the invoice: a value that already has one resolves to that invoice rather than issuing a new one, and isExisting: 1 means it came back unchanged with nothing issued.\n- Derive invRef from what is being billed rather than from a record that may later describe something else.\n\n**Inputs**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `invName` | string | yes | Invoice sample name, ex: 1,2,6 |\n| `invSerial` | string | yes | Invoice sign, ex: C24TAA, K24TXX |\n| `invNumber` | string | yes | Invoice number |\n| `invDate` | string | yes | Invoice date (format: yyyy/mm/dd) |\n| `invCustomer` | 0 \\\\| 1 | | Customer is not company? Value 1\\|0 |\n| `invRef` | string | yes | 3rd party Bill-ID. Identifies the invoice: a value that already has an invoice resolves to that invoice instead of issuing a new one |\n| `invRefDate` | string | | Bill date (format: yyyy/mm/dd) |\n| `billNumber` | string | | Bill number - extend |\n| `buyerTax` | string | | Buyer tax-number |\n| `buyerCode` | string | | Buyer-id |\n| `buyerName` | string | yes | Buyer full name |\n| `buyerCompany` | string | | Buyer company/ organization name |\n| `buyerAddress` | string | | Buyer full address |\n| `buyerAcc` | string | | Buyer bank account number |\n| `buyerBank` | string | | Buyer bank name |\n| `buyerEmail` | string | | Buyer email |\n| `buyerPhone` | string | | Buyer phone number |\n| `buyerFax` | string | | Buyer fax number |\n| `buyerCitizenIDNumber` | string | | Citizen Identification Number |\n| `buyerPassportNumber` | string | | Passport Number |\n| `govUnitCode` | string | | Budget-related Unit Code |\n| `invSubTotal` | number | yes | Invoice Subtotal (not include VAT, not include discount) |\n| `invVatRate` | number | yes | VAT Rate: 0,5,8,10 (0%,5%,8%,10%), -1 (Not taxable), -2 (Not declare and pay taxes) |\n| `invVatAmount` | number | yes | VAT Amount |\n| `invTotalAmount` | number | yes | Invoice Total |\n| `invPayment` | string | | Payment method |\n| `invExchangeRate` | number | | Exchange rate (to VietNam \u0111\u1ED3ng) |\n| `invCurrency` | string | | Payment currency |\n| `note` | string | | Note for discount |\n| `invAutoSign` | 0 \\\\| 1 | | Request sign invoice immediately after create successful? Value 1\\|0 |\n| `privateCode` | string | | The UID string (10-12 chars), the buyer will use this code to lookup invoice (after invoice signed). If blank, WinInvoice will generate automatically |\n| `option` | 0 \\\\| 1 \\\\| 2 \\\\| 3 \\\\| 4 | | Invoice type: 0 (Normal invoice), 1 (Information-adjustment invoice), 2 (Increases-adjustment invoice), 3 (Decreases-adjustment invoice), 4 (Replacement invoice - original will be marked as deleted when signed) |\n| `invCodeOld` | string | | Original invoice number (required for adjustment/replacement invoices) |\n| `invNameOld` | string | | Original invoice sample name (required for adjustment/replacement invoices) |\n| `invSignOld` | string | | Original invoice sign (required for adjustment/replacement invoices) |\n| `create04SSHDDT` | 0 \\\\| 1 | | Auto create 04/SS-H\u0110\u0110T? Value 1\\|0 (default 1) |\n| `reason04SSHDDT` | string | | 04/SS-H\u0110\u0110T Reason |\n| `isDscnForSaleInv` | 0 \\\\| 1 | | Apply an 8% tax reduction to the sales invoice? Value 1\\|0 (default 0) |\n| `saleInvVATRate` | number | | Tax rate based on revenue (Only applicable when isDscnForSaleInv = 1) |\n| `saleInvDscnAmnt` | number | | The reduction amount determined based on revenue and corresponding tax rate (Only applicable when isDscnForSaleInv = 1) |\n| `privateNote` | string | | Used for internal notes |\n| `items` | object[] | yes | Array of item/product in this bill |\n| `items[].itemNo` | string | | Item number |\n| `items[].itemCode` | string | | Product/Item code |\n| `items[].itemName` | string | yes | Name of product |\n| `items[].itemPromo` | 0 \\\\| 1 | | Is promotional product (gift)? Value 1\\|0 (default 0) |\n| `items[].isDscnItem` | 0 \\\\| 1 | | Is the discount line? Value 1\\|0 (default 0) |\n| `items[].itemUnit` | string | | Product Unit |\n| `items[].itemQuantity` | number | yes | Product quantity |\n| `items[].itemPrice` | number | yes | Product unit price (not include VAT) |\n| `items[].itemVatRate` | number | | Product VAT rate |\n| `items[].itemVatAmnt` | number | | Product VAT Amount |\n| `items[].itemDscnAmnt` | number | | Product Discount Amount |\n| `items[].itemAmountNoVat` | number | | Product Amount (not include VAT)(not include item discount) |\n| `items[].adjustType` | "PRICE" \\\\| "QTTY" | | Type of adjustment: PRICE (Adjust unit price of item in original invoice), QTTY (Adjust quantity of item in original invoice). This field has effect when value of option field is 2 or 3 |\n| `items[].itemPack` | string | | Product Lot |\n| `items[].itemDate` | string | | Product expiration date |\n| `items[].itemNote` | string | | Product note |\n| `items[].specialGoodsType` | 0 \\\\| 1 \\\\| 2 \\\\| 3 | | Special category of goods: 0 (Normal item/good), 1 (Cars or motorcycles), 2 (Transportation service), 3 (Transportation services on digital and e-commerce platforms) |\n| `items[].specialGoodsInfo` | object | | Data requirements for specialized goods based on specialGoodsType |\n| `items[].specialGoodsInfo.chassisNumb` | string | | Chassis Number (for cars/motorcycles - specialGoodsType = 1) |\n| `items[].specialGoodsInfo.engineNumb` | string | | Engine Number (for cars/motorcycles - specialGoodsType = 1) |\n| `items[].specialGoodsInfo.licensePlate` | string | | License plate of the transport vehicle (for transportation service - specialGoodsType = 2) |\n| `items[].specialGoodsInfo.senderName` | string | | Sender\'s Name (for shipping service - specialGoodsType = 3) |\n| `items[].specialGoodsInfo.senderAddress` | string | | Sender\'s Address (for shipping service - specialGoodsType = 3) |\n| `items[].specialGoodsInfo.senderTaxCode` | string | | Sender\'s Tax Code (for shipping service - specialGoodsType = 3) |\n| `items[].specialGoodsInfo.senderCitizenIDNumber` | string | | Sender\'s Citizen Identification Number (for shipping service - specialGoodsType = 3) |\n| `items[].isNoteItem` | 0 \\\\| 1 | | Is the note line? Value 1\\|0 (default 0) |\n| `connected_account_id` | string | yes | The ID of the connected account |\n\n**Answer**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `oid` | string | yes | WinInvoice\'s UID |\n| `invCode` | string | yes | Invoice number (is \'0000000\' if has not signed) |\n| `invRef` | string | yes | 3rd party Bill-ID |\n| `invSign` | string | yes | Invoice sign, (as invSerial) |\n| `invDate` | string | yes | Invoice date, format yyyy-mm-dd |\n| `invName` | string | yes | Full invoice sample (invName + invSign) |\n| `privateCode` | string | yes | Private lookup code for buyer |\n| `itemTotal` | number | | Count of products in invoice |\n| `itemError` | number | | Count of error-products in invoice |\n| `govTranfer` | number | yes | Invoice has transferred to GOV? 1\\|0 |\n| `govTranID` | string | yes | Transaction ID used to transfer invoice to GOV |\n| `govTranferErr` | number | yes | ansferred to GOV has FAIL? 1\\|0 |\n| `govTranText` | string | yes | Error message if transfer to GOV has FAIL |\n| `govCode` | string | yes | The UID string issue by GOV for this invoice |\n| `autoSign` | number | | Has request sign immediately ? 1\\|0 |\n| `isExisting` | 0 \\\\| 1 | | 1 when invRef already had an invoice and this call returned it unchanged \u2014 no invoice was issued |\n| `link` | string | yes | Link to view the invoice |\n\n## x\n\n### x_create_post (workflow step)\n\n- Publish a post to the connected X account.\n\n**Inputs**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `connected_account_id` | string | yes | |\n| `text` | string | yes | |\n| `media_file_ids` | string[] | | Up to 4 images, or one GIF, or one video. |\n\n**Answer**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `post_id` | string | yes | |\n| `url` | string | yes | |\n\n## zip\n\n### zip_files (workflow step)\n\n- Bundle multiple files into a zip archive.\n- Max 100 files.\n- An item in file_ids is a file id \u2014 or `{ id, name }` to name that entry in the archive: a file name, never a path, carrying the stored extension.\n- A bare id keeps the stored filename.\n\n**Inputs**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `file_ids` | string \\\\| object[] | yes | |\n| `filename` | string | | Name for the zip file, without the .zip extension. |\n\n**Answer**\n\n| Name | Type | Required | What it is |\n| --- | --- | --- | --- |\n| `file_id` | string | yes | The ID of the generated file. |\n| `url` | string | yes | The download URL of the generated file. |\n| `filename` | string | yes | The filename of the generated file, including extension. |\n| `mime_type` | string | yes | The MIME type of the generated file. |\n';
@@ -112,7 +112,8 @@ rollup over a formula over a formula is paid three times on every touch, and aga
112
112
 
113
113
  ## Fields
114
114
 
115
- A field is stated inside its table's document in a `set_tables` call, keyed by alias. Every field `type`, the
115
+ A field is stated inside its table's document in a `set_tables` call, keyed by alias. A name people read (a
116
+ table's, a field's, an option's) is in the owner's language; an alias stays English. Every field `type`, the
116
117
  keys each one takes (notation, defaults, options, links, formulas, rollups, lookups, copies) and a worked
117
118
  example are in `docs path="reference/field"`; this page holds the choices around them.
118
119
 
package/docs/workflows.md CHANGED
@@ -81,7 +81,7 @@ All three carry `trigger.trigger_id`.
81
81
  | wait | `await wait({ duration_in_minutes: 5 });` |
82
82
  | wait for an event | `await wait_for_event({ event_type: "webhook", event_ref: <expr>, timeout_in_minutes: 60 });` |
83
83
  | wait for an approval | `await wait_for_approval({ approvers: <expr>, prompt: <expr> });` — see **Waiting for an approval** |
84
- | return | `return({ status: "success" \| "error", message: <expr>, field_errors: { ... }?, data: { ... }? });` — `data` holds the outputs a Workflow node declares |
84
+ | return | `return({ status: "success" \| "error", message: <expr>, field_errors: { ... }?, data: { ... }? });` — `data` holds the outputs a Workflow node declares — a return of `error` undoes no write a step before it made, so every refusal stands before the body's first write |
85
85
  | validate | `validate({ checks: [{ fail_when: <expr>, field_key: "...", message: <expr> }, ...] });` |
86
86
 
87
87
  A delayed run is never cancelled from a workflow: after a `wait`, it reads the row's state and acts only if it still holds.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/cli",
3
- "version": "0.316.26",
3
+ "version": "0.316.27",
4
4
  "description": "Lotics SDK and CLI for AI agents",
5
5
  "type": "module",
6
6
  "bin": {