@lotics/app-sdk 0.77.2 → 0.77.4

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/AGENTS.md CHANGED
@@ -14,7 +14,7 @@ signature; open the file.**
14
14
 
15
15
  | Doc | Read it for |
16
16
  |---|---|
17
- | [docs/recipes.md](./docs/recipes.md) | Task-shaped how-tos for the actions whose mechanism is not guessable from the hooks — returning a generated file, returning structured data, parameterized lookups, composable optional filters, cell decoding, testing an AI action without spending credits. The templates a generated file is filled from are registered through the CLI, not the app — `lotics docs document_templates`. |
17
+ | [docs/recipes.md](./docs/recipes.md) | Task-shaped how-tos for the actions whose mechanism is not guessable from the hooks — returning a generated file, returning structured data, parameterized lookups, composable optional filters, cell decoding, testing an AI action without spending credits. |
18
18
  | [docs/queries.md](./docs/queries.md) | **The query engine authoring reference** — AST node kinds, per-field-type operator support, filters/params/pruning, free-text search, combining tables (join/union/link/`unnest`/`record_id`), shaping (aggregates, date buckets, windows), runtime refinement bounds, limits & the efficiency playbook. |
19
19
  | [docs/data_fetching.md](./docs/data_fetching.md) | The three read hooks (`useQuery`/`useInfiniteQuery`/`usePaginatedQuery`), the `QueryRow` shape (projected columns `unknown`; `__source_record_id`/`__source_table_id` typed but optional), cell readers (`row.*`, `readSelect`, `readMembers`, `readLinks`, `readFiles`, `readLocked`), `useFieldOptions`, caching — **arrival revalidates** (a re-mount renders cache *and* refreshes it in the background, `loading` never flips) — data discipline, the pagination count as a second full execution (and `total` to suppress it), the search-as-you-type + record-picker patterns. |
20
20
  | [docs/mutations.md](./docs/mutations.md) | `useWorkflow` (the ONLY write path), the `WorkflowResult` resolve-never-throw contract, typed inputs, diff-before-update, locked records, `useOptimistic`, `useNewRecord` (client-minted `rec_*` id so a new-record surface never remounts on its first save), read-after-write ordering (a re-read must not overtake an in-flight write). |
@@ -22,7 +22,7 @@ signature; open the file.**
22
22
  | [docs/files.md](./docs/files.md) | Files end to end — `useFileUpload`, `useAttachments`, `readFiles`/presigned URLs (**a bearer credential for the bytes** — never logged, reported, or persisted), workflow-generated files, **naming a zip's entries** (`{ id, name }` per file — a file name, never a path), preview pairing, filter operators, the server-side delivery bounds. **Uploads declare a `fidelity`** (`standard` / `high` / `original`) — the app picks how much of the image survives storage; use `high` whenever text must stay legible. |
23
23
  | [docs/members_and_options.md](./docs/members_and_options.md) | People + select options + comments — `useMembers`, `useFieldOptions`, `useViewer`, `useComments`, and the `@lotics/ui` components they feed. |
24
24
  | [docs/navigation_and_state.md](./docs/navigation_and_state.md) | `AppRouter` (embedded/standalone URL model), `useUrlState` + `urlParam` codecs, `useRecents`. |
25
- | [docs/ai.md](./docs/ai.md) | `useAgentRun` (structured vs free-text, streaming ai-sdk `parts` → `AgentRun`, the agent's ask-back — `pendingChoice`/`answerChoice` over the parked `awaiting_input` state), `askAi` — plus the fields-vs-file razor for choosing between them — and `useAiContext` (push the current screen's view state to the member's ambient chat agent; caps, push-only semantics, auto query-refetch on chat mutation). **A `file` input carries its own content** — images/PDFs are perceived natively, Word/Excel/CSV/text are materialized into the run; no reader tool to declare. **An agent reaches record DATA only through its declared `query_aliases` / `workflow_aliases`** (`run_app_query` / `run_app_workflow`); the raw record read/write tools are rejected. A leg resolves an `AgentRunLanding` (`settled`/`parked`/`failed`/`aborted` — never a bare `undefined`). Also **what the member's own chat agent can do with your app while it is open** — the alias catalog it reads (your app `description` heads it as `about:`, and an input's `description` is the only place a default is visible), and how to shape a mutating alias for it. |
25
+ | [docs/ai.md](./docs/ai.md) | `useAgentRun` (structured vs free-text, streaming ai-sdk `parts` → `AgentRun`, the agent's ask-back — `pendingChoice`/`answerChoice` over the parked `awaiting_input` state — and the `AgentRunLanding` every leg resolves), `askAi` — plus the fields-vs-file razor for choosing between them — and `useAiContext` (push the current screen's view state to the member's ambient chat agent; caps, push-only semantics, auto query-refetch on chat mutation). **A `file` input carries its own content** — no reader tool to declare. **An agent reaches record DATA only through its declared `query_aliases` / `workflow_aliases`.** Also **what the member's own chat agent can do with your app while it is open** — the alias catalog it reads and how to shape a mutating alias for it. |
26
26
  | [docs/security.md](./docs/security.md) | **Read before shipping** — the owner-principal model, `is_current_member` scoping, write attribution, group gates, public-app bounds, what runtime refinement cannot widen, and why a per-input bound is a tenancy floor rather than an authorization check (a caller-supplied id must be intersected with the record server-side). |
27
27
  | [docs/runtime.md](./docs/runtime.md) | `mount()`, the two transports, `rpc()`, `openExternal`/`downloadFile`, geofencing, analytics, `useConfig` (App-Packages installation config), `getAppBinding` (package apps' runtime `F`/`OPT`/`ROLE` resolution via the generated `.lotics/app_fields.ts`), and the publish chain for package contributors. |
28
28
 
package/docs/ai.md CHANGED
@@ -26,9 +26,9 @@ A declaration carries:
26
26
  | `workflow_aliases` | The app's own workflows the agent may invoke via `run_app_workflow` — its **entire write surface** |
27
27
  | `model_tier` | Optional model tier — `haiku` \| `sonnet` \| `opus`. Omit (preferred) to follow the platform default tier, resolved at run time. A tier names capability, not a version, so the agent tracks model generations with no rewrite. Pin only a deliberate, tested choice |
28
28
  | `effort_level` | Optional reasoning depth for adaptive-thinking tiers. Requires an explicit `model_tier` pin — effort is tuned per tier |
29
- | `prefix_cache_ttl` | Optional prompt-cache window for the agent's stable prefix (tools + system). **Omit it** — the default (`"5m"`, Anthropic's own) is right for essentially every agent. `"1h"` is a leveraged bet: it doubles the write price (2x the input rate against 1.25x, both reading back at 0.1x) to buy only the five-minute-to-one-hour band, and an entry nothing re-reads inside the hour was paid for twice over. Declining it is never "uncached" — the same prefix stays cached at the default window. Set `"1h"` only with measured cadence showing runs reliably land in that band, such as a scheduled sweep |
29
+ | `prefix_cache_ttl` | Optional prompt-cache window for the agent's stable prefix (tools + system). **Omit it** — the default (`"5m"`, Anthropic's own) is right for essentially every agent. `"1h"` is a leveraged bet: it doubles the write price (2x the input rate against 1.25x, both reading back at 0.1x) to buy only the five-minute-to-one-hour band. Declining it is never "uncached" — the same prefix stays cached at the default window. Set `"1h"` only with measured cadence showing runs reliably land in that band, such as a scheduled sweep |
30
30
  | `inputs` | Optional typed input schema for one run — the same vocabulary as workflow inputs (`text`, `number`, `file`, `member`, `record_link`, `select`, …). The server validates every run payload against it. **Every field defaults to `required: true`**, exactly as `outputs` does — the two extend the same base — so an input the caller may legitimately omit needs `"required": false`, or the run is rejected before the agent sees it |
31
- | `outputs` | Optional typed output schema. Declared → **structured agent** (the run must emit a matching result); omitted → **free-text agent** (the answer is the final prose). **Every field defaults to `required: true`** (the same base schema as `inputs` above) — mark `"required": false` on anything the source may legitimately not carry. It matters most for `number`, which has no blank: text can answer `""`, but a required number leaves only a wrong value or a rejected submission, and an agent handed that dilemma spends the run resolving it instead of doing the rest of its instructions |
31
+ | `outputs` | Optional typed output schema. Declared → **structured agent** (the run must emit a matching result); omitted → **free-text agent** (the answer is the final prose). **Every field defaults to `required: true`** (the same base schema as `inputs` above) — mark `"required": false` on anything the source may legitimately not carry. It matters most for `number`, which has no blank: text can answer `""`, but a required number leaves only a wrong value or a rejected submission |
32
32
 
33
33
  **Structured vs free-text is the load-bearing split.** A structured agent's result arrives in `run.output` (the server strictly validates the submitted result against the declared schema — see the `output` typing section for the exact guarantee); a free-text agent's answer is the transcript's prose (`run.text`) and its `output` stays `undefined` — never a stray string, so a consumer reading `output.<field>` can't crash on a free-text answer.
34
34
 
@@ -198,7 +198,7 @@ On recovery, `output` adopts the row's output **only when it is an object** (a s
198
198
 
199
199
  - **The app owns the state.** Always pass the authoritative current state in `input` — the session context is memory, not the source of truth.
200
200
  - **Mint a new `sessionId` to clear context.** There is no reset call; a fresh key is a fresh session.
201
- - **One-shot agents get a fresh `sessionId` per run.** Session context only pays for conversational follow-ups. If every run is self-contained (the app passes the complete input each time — e.g. a document-extraction agent), reusing a key replays dead context into every request — every replayed run still bills its input tokens, so a one-shot agent on a shared key pays for history it never uses. Append a per-run nonce to the key instead. (Media itself replays as a provider file reference — uploaded once, referenced by id — so a long session no longer grows toward the provider's request-size cap; the cost of dead context is tokens, not payload.)
201
+ - **One-shot agents get a fresh `sessionId` per run.** Session context only pays for conversational follow-ups. If every run is self-contained (the app passes the complete input each time — e.g. a document-extraction agent), reusing a key replays dead context into every request — every replayed run still bills its input tokens, so a one-shot agent on a shared key pays for history it never uses. Append a per-run nonce to the key instead. (Media itself replays as a provider file reference — uploaded once, referenced by id — so the cost of dead context is tokens, not payload.)
202
202
 
203
203
  Sessions are scoped to the authenticated member who ran them: two members using the same `sessionId` string share nothing, and a member can never read or extend another member's thread.
204
204
 
@@ -211,15 +211,14 @@ Sessions are scoped to the authenticated member who ran them: two members using
211
211
  | **Perceived natively** | `image/*`, `application/pdf` | A vision / document part — the agent literally sees it |
212
212
  | **Materialized** | Word (`.docx`/`.doc`), Excel (`.xlsx`/`.xls`), CSV, and text (`.txt`, `.md`, `.json`, `.eml`, `.html`, `.xml`, `.yaml`) | Read server-side by the same engines `view_files` uses and inlined into the run's message, truncated at 40,000 characters with the agent told when that happened |
213
213
 
214
- **Pictures inside a Word document are delivered as images**, so a scanned page, an ID card photographed into a `.docx`, or a screenshot pasted into one is READ, not merely described. A document whose content is entirely pictures carries no text at all — it used to arrive as empty paragraphs plus a size in centimetres, and the agent correctly reported it could not read it. Any picture that is skipped or unresolvable is counted and stated in the text, so a partially-read document never reads as a complete one.
214
+ **Pictures inside a Word document are delivered as images**, so a scanned page, an ID card photographed into a `.docx`, or a screenshot pasted into one is READ, not merely described. Any picture that is skipped or unresolvable is counted and stated in the text, so a partially-read document never reads as a complete one.
215
215
  | **Unreadable** | Archives, audio, video | The run fails immediately, naming the file — a fact about the upload, not a gap in your configuration |
216
216
 
217
217
  Every file in a `multi` input is materialized — nothing is collapsed to the first. Get the ids from [`useFileUpload` / `useAttachments`](./files.md).
218
218
 
219
219
  **So do not dispatch on the file type.** Every tier above reads, which means the format tells you
220
220
  nothing about what a document IS — the same claim form arrives as `.xlsx` from a laptop, as a PDF
221
- through a chat app, and as a photo of a printout; an invoice arrives as PDF, as the XML beside it,
222
- or as a screenshot. An intake that routes on the extension is reading the envelope, and it fails
221
+ through a chat app, and as a photo of a printout. An intake that routes on the extension is reading the envelope, and it fails
223
222
  SILENTLY: the wrong extractor runs, returns something correctly shaped, and the user is handed a
224
223
  confident wrong answer. Let one agent read the drop and say what each document is, keyed on what
225
224
  only the content carries. Prefer separate output slots (`don_hang`, `hoa_don`, plus a named
@@ -333,7 +332,7 @@ Declarative and lifecycle-bound: mounting or changing `context` pushes it; unmou
333
332
  | `data` | must JSON-serialize to ≤ 2000 chars, else it is **replaced by an `omitted` marker** the agent reads (the description is kept) |
334
333
  | slots per app | ≤ 8 (a 9th evicts the least-recently-updated) |
335
334
 
336
- Keep `description` tight and human-readable — it is the line the agent reads. Put anything structured (filter/sort/form values) in `data`, and remember it is replaced whole past its size cap: the agent is told the state existed but not what it was, so don't hide load-bearing facts there that aren't also in `description`. It is replaced rather than silently dropped so the agent cannot mistake "too big to send" for "there was nothing".
335
+ Keep `description` tight and human-readable — it is the line the agent reads. Put anything structured (filter/sort/form values) in `data`, and remember it is replaced whole past its size cap: the agent is told the state existed but not what it was, so don't hide load-bearing facts there that aren't also in `description`.
337
336
 
338
337
  ### Security — push-only, a snapshot of what was already rendered
339
338
 
@@ -415,8 +414,7 @@ from what the workflow returned and nothing else, so a batch that matched nine o
415
414
  eleven and returns only success tells them all eleven were done. Return the
416
415
  misses in `data`.
417
416
 
418
- Nothing about your app changes to get this — declare a workflow as you already
419
- do, and it becomes reachable. If a workflow should NOT be agent-reachable, it
417
+ If a workflow should NOT be agent-reachable, it
420
418
  does not belong in the manifest at all, since the member can already fire it
421
419
  from the app's own buttons.
422
420
 
@@ -170,8 +170,7 @@ const { rows, loadMore, hasMore, loadingMore } = useInfiniteQuery("feed", {}, {
170
170
  ```
171
171
 
172
172
  The first render loads one page; `loadMore()` appends the next, accumulating into `rows`. Paging is
173
- **keyset (seek)** — appended pages never skip or duplicate a row as the set shifts (the pagination
174
- models above).
173
+ **keyset (seek)** (the pagination models above).
175
174
 
176
175
  - `hasMore` is `true` while the last page came back **full** (a non-null cursor came back) — so a
177
176
  total that is an exact multiple of `pageSize` costs one final short fetch before `hasMore` turns
@@ -398,26 +397,40 @@ pieces. Compose these — don't hand-roll search:
398
397
  popover listbox with rich rows (`renderOptionContent`), keyboard navigation, `recentOptions`,
399
398
  `allowCustom`. (For a known small list with no search box, `Picker`.)
400
399
  - **A parameterized `search` query** — a `from_table` with `search: "{{params.q}}"` over the
401
- maintained search document: **diacritics- and case-insensitive** (`"da nang"` matches
402
- `"Đà Nẵng"`), trigram-indexed so it stays fast on large tables, and AND-ed with the template's
403
- `filter` (search within a scope). The full `search` contract is in
400
+ maintained search document: **diacritics- and case-insensitive**, trigram-indexed, and AND-ed
401
+ with the template's `filter` (search within a scope). The full `search` contract is in
404
402
  [./queries.md](./queries.md). Reserve an OR-group of per-field `contains` only when you must
405
- bound exactly which fields match — `contains` is case-insensitive but **accent-sensitive** and
406
- **unindexed**, so a zero-match keystroke forces a full table-partition scan that hangs the
407
- picker. **Search-as-you-type uses `search`, never a `contains` OR-group.**
403
+ bound exactly which fields match — `contains` is **unindexed**, so a zero-match keystroke
404
+ forces a full table-partition scan. **Search-as-you-type uses `search`, never a `contains`
405
+ OR-group.**
408
406
  - **`useQuery(alias, { q }, { enabled })`** — gate on a non-empty term: an empty term applies no
409
407
  search constraint and would dump the table on first paint. `enabled` makes "nothing loads until
410
408
  you type" true. Add `revalidateOnFocus: false` — re-running an ephemeral search on refocus is
411
409
  wasted work.
410
+ - **Debounce the term — `enabled` is not a substitute.** `enabled` decides *whether* to ask, not
411
+ *how often*: wire an input's own state into `params` and every keystroke past the first is a
412
+ fresh cache key and a fresh request. A six-letter name costs six, and on a list screen each one
413
+ is several — `usePaginatedQuery` re-counts whenever `params` change, and any sibling query
414
+ taking the same term goes with it. Keep the input's value in one state and debounce the COMMIT
415
+ into a second (`useDebouncedCallback` from `@lotics/ui/use_debounced_callback`, ~250 ms). The
416
+ two values are genuinely different — what is being typed, and what the rows on screen answer —
417
+ and anything reporting on the results (an empty state, a count, a "showing N for X" line) reads
418
+ the committed one, or it describes a set the server was never asked for. `Combobox` already
419
+ debounces its own `onSearchChange`; this is for a search box you built yourself.
412
420
  - **`useRecents(key, { max })`** — persist the picked option locally; pass its list as
413
421
  `recentOptions` ([./navigation_and_state.md](./navigation_and_state.md)).
414
422
 
415
423
  ```tsx
416
- const [term, setTerm] = useState(""); // live input stays local
424
+ const [typed, setTyped] = useState(""); // the input's own value, every keystroke
425
+ const [term, setTerm] = useState(""); // what the server is asked, once typing settles
426
+ const commit = useDebouncedCallback(setTerm, 250);
427
+
428
+ <SearchInput value={typed} onChangeText={(v) => { setTyped(v); commit(v.trim()); }} />;
429
+
417
430
  const { rows, loading } = useQuery(
418
431
  "searchCustomers",
419
432
  { q: term },
420
- { enabled: term.trim().length > 0, revalidateOnFocus: false },
433
+ { enabled: term.length > 0, revalidateOnFocus: false },
421
434
  );
422
435
  ```
423
436
 
package/docs/files.md CHANGED
@@ -44,17 +44,16 @@ Signature: `dist/src/hooks.d.ts`. Returns `{ upload, uploading, error }`:
44
44
  `{ id, filename, mime_type, url?, thumbnail_url? }` — `url`/`thumbnail_url` are presigned
45
45
  (24 h) and load directly in the sandboxed iframe, so a just-uploaded image previews without a
46
46
  round-trip.
47
- - `fidelity` — **how faithful the stored image must be.** An ordinal scale, so the value tells
48
- you what it costs without looking up a mapping. The platform owns the pixels behind each step
49
- and re-tunes them centrally as model tiers change.
47
+ - `fidelity` — **how faithful the stored image must be.** The platform owns the pixels behind
48
+ each step and re-tunes them centrally as model tiers change.
50
49
 
51
50
  Defaults to `"high"` — enough to resolve the text of a photographed document. Drop to
52
51
  `"standard"` when collecting in volume.
53
52
 
54
53
  **This only affects photographs.** A PDF, Word, Excel or CSV file is stored untouched at every
55
- step, so `"original"` is not what keeps a document intact — it already is. Reach for it when
56
- the PIXELS are the evidence. Note too that HEIC (what an iPhone camera produces) is transcoded
57
- to JPEG server-side whatever you choose, because no browser can render it.
54
+ step, so `"original"` is not what keeps a document intact — it already is. Note too that HEIC
55
+ (what an iPhone camera produces) is transcoded to JPEG server-side whatever you choose, because
56
+ no browser can render it.
58
57
 
59
58
  | `fidelity` | Use it when | Stored at |
60
59
  |---|---|---|
package/docs/mutations.md CHANGED
@@ -302,10 +302,7 @@ await update_records({
302
302
  ```
303
303
 
304
304
  `set` still writes every key it carries — `null` in `set` **clears** the field; `null`/absent in
305
- `set_skip_null` **skips** it. That is the per-field "clear vs. leave unchanged" choice, so a field
306
- may appear in **at most one** of `set` / `set_skip_null` (naming it in both is rejected at run
307
- time). An all-absent `set_skip_null` with no other write surface is a no-op — no records touched,
308
- no `before_update` hooks. The full write surface (`set`, `set_skip_null`, the surgical
305
+ `set_skip_null` **skips** it. The full write surface (`set`, `set_skip_null`, the surgical
309
306
  `add_to`/`remove_from`/`replace` ops, `field_edits`, and the exact value shape per field type)
310
307
  is [workflows](./workflows.md#writing-records).
311
308
 
@@ -22,8 +22,7 @@ mechanism:
22
22
  They compose: a screen is a path; the screen's filters ride as query params.
23
23
  Neither is server state — `useUrlState` values are *client* state that you feed
24
24
  into a query's *server* params (see [named-query params](./queries.md) and the
25
- [data-fetching hooks](./data_fetching.md) they drive). Nothing here is
26
- persisted server-side; the address bar is the only store.
25
+ [data-fetching hooks](./data_fetching.md) they drive).
27
26
 
28
27
  The embedded-vs-standalone distinction below is the runtime's: embedded means
29
28
  the app runs in an iframe inside the Lotics host, standalone means it runs on
package/docs/queries.md CHANGED
@@ -6,8 +6,7 @@ Every read a custom-code app performs is a **named query**: a fixed AST template
6
6
  for the hook-side contract). This document is the authoring reference for the query engine
7
7
  itself: the node kinds, the source expressions, the per-field-type operator support, filters and
8
8
  params, search, cross-table composition, server-side shaping, runtime refinement, and the
9
- engine's hard limits. Read it before writing any non-trivial query — the engine is deliberately
10
- a small closed surface, and knowing what composes (and what doesn't) up front saves hours.
9
+ engine's hard limits.
11
10
 
12
11
  ---
13
12
 
@@ -204,13 +203,12 @@ indexes live (§10). `sort` entries are `{ field_key, order: "asc"|"desc", blank
204
203
  collection.** `{ "type": "files", "output": "photo", "source": "fld_…", "limit": 1 }` returns
205
204
  the first entry per cell, in stored order, cut in SQL so the rest is never read or signed.
206
205
  Without it the cell yields the whole array and the response pays to resolve every entry it
207
- will never show: a register over containers averaging 33 gate photos each resolved ~832 files
208
- to display 25 thumbnails, within 2.4× of the response-wide presign ceiling (§10) that would
209
- reject the query outright. The bound is per CELL and caps at `FILES_PROJECTION_MAX_LIMIT`
210
- (10) a row wanting more than a handful is asking for the collection, which belongs to the
211
- record surface that opens it. It is a different axis from the query's own `limit`, which
212
- counts ROWS; `limit` on any non-`files` column — or on a computed source, which has no cell
213
- to bound — is rejected at deploy rather than ignored. **On a UNION, set it on every arm.**
206
+ will never show at scale, into the response-wide presign ceiling (§10). The bound is per
207
+ CELL and caps at `FILES_PROJECTION_MAX_LIMIT` (10) a row wanting more than a handful is
208
+ asking for the collection, which belongs to the record surface that opens it. It is a
209
+ different axis from the query's own `limit`, which counts ROWS; `limit` on any non-`files`
210
+ column or on a computed source, which has no cell to bound is rejected at deploy rather
211
+ than ignored. **On a UNION, set it on every arm.**
214
212
  Arms align by column name and type, and `limit` is neither, so an arm that omits it still
215
213
  yields whole cells — the query stays correct and silently costs what the bound was there to
216
214
  avoid.
@@ -622,11 +620,10 @@ non-empty term client-side (`enabled`), or first paint dumps the table.
622
620
  search document indexes select option labels, member names, dates in three formats, and each
623
621
  linked record's cached display text — including the `Field=True/False` pill a boolean renders
624
622
  into. A box captioned "find a person" then matches rows that merely share a stage label or an
625
- owner, and folding makes near-homographs collide (`tiến` ≡ `tiền`). Measured on a real 88-row
626
- customer book, one such term returned **every row**. When the box means *find this person*, bound
627
- it: an OR-group of `contains` over the two or three identity fields (name, national id, phone),
628
- with the term param `required: false` so an empty box prunes the group to match-all. Use `search`
629
- when the box genuinely means *find this anywhere in the record*.
623
+ owner, and folding makes near-homographs collide (`tiến` ≡ `tiền`). When the box means *find
624
+ this person*, bound it: an OR-group of `contains` over the two or three identity fields (name,
625
+ national id, phone), with the term param `required: false` so an empty box prunes the group to
626
+ match-all. Use `search` when the box genuinely means *find this anywhere in the record*.
630
627
 
631
628
  ---
632
629
 
@@ -815,33 +812,18 @@ facet-aware, but it ships every matching row, so it holds only where the scope i
815
812
  (one member's own records, a single project). A workspace-wide total that must follow a runtime
816
813
  facet has no server-side shape today.
817
814
 
818
- The SDK hooks map onto this directly (`dist/src/hooks.d.ts` for exact signatures): `useQuery`
819
- sends `limit: pageSize, offset: 0` (a cap, not pagination) plus `opts.sort`/`opts.filter`;
820
- `useInfiniteQuery` pages by **keyset** (`keyset: true` + the prior `next_cursor`), so its scroll
821
- never skips or duplicates a row; `usePaginatedQuery` owns the page cursor, pages by `offset`, and
822
- issues a `count` keyed on `(alias, params, filter)` — independent of page and sort, so paging and
823
- re-sorting never recount, while changing `(params, filter)` resets to page 0 and recounts. Given
824
- its `total` option it issues none at all: a screen that already renders that figure from an
825
- aggregate hands it over rather than buying it twice
826
- ([data_fetching](./data_fetching.md) → `usePaginatedQuery`).
815
+ The SDK hooks map onto this directly the hook-side contract is
816
+ [data_fetching](./data_fetching.md).
827
817
 
828
818
  Build `filter` from UI column-filters with `columnFilterToConditions` (`@lotics/ui`); prefer
829
819
  `useFieldOptions` for a select filter's option set.
830
820
 
831
821
  **Warning (transport gaps):** the embedded product host and the `lotics app dev` forwarder pass
832
822
  `sort`/`filter`/`count` through. The **standalone public transport** (`<slug>.lotics.app`)
833
- currently forwards only `alias`/`params`/`limit`/`offset` — runtime `sort`/`filter` are
834
- silently ignored there and a `count` never resolves, so a standalone pager has no "of N" unless the
835
- app supplies one through `usePaginatedQuery`'s `total` (a plain aggregate query survives the thin
836
- transport). A standalone app must bake ordering/scoping into the template (or params) rather than
837
- rely on runtime refinement.
838
-
839
- **Pagination semantics.** `useQuery` / `usePaginatedQuery` and manual `rpc("query", { limit,
840
- offset })` are **offset** — a deep page costs the server the full skipped prefix (page 400 of a
841
- 25-row pager scans ~10,000 rows before returning 25), and pages can shift under concurrent writes
842
- (a row inserted before your offset repeats or skips a row across pages). `useInfiniteQuery` is
843
- **keyset (seek)** — deep scrolls stay O(page) and never skip/duplicate rows. Keep offset browses
844
- filtered and sorted by a stable key, and don't build numbered UX that walks thousands of pages.
823
+ forwards only `alias`/`params`/`limit`/`offset` — runtime `sort`/`filter` are silently ignored
824
+ there and a `count` never resolves ([data_fetching](./data_fetching.md) Standalone transport).
825
+ A standalone app must bake ordering/scoping into the template (or params) rather than rely on
826
+ runtime refinement.
845
827
 
846
828
  ---
847
829
 
@@ -1062,8 +1044,6 @@ above, with `region` replaced by whatever the lookup pointed at.
1062
1044
 
1063
1045
  ## 12. Current limitations
1064
1046
 
1065
- Consolidated from the sections above — these describe present engine behavior:
1066
-
1067
1047
  - **No pivot/crosstab node.** Row-values-to-columns happens client-side over grouped results
1068
1048
  (§8).
1069
1049
  - **No `first_value`/`last_value`/`nth_value` navigation** — the `functions` set is `row_number`,
package/docs/recipes.md CHANGED
@@ -1,8 +1,7 @@
1
1
  # Recipes — the app actions that are not obvious
2
2
 
3
3
  The other area docs describe contracts. This one is task-shaped: **"how do I do X"**, for the
4
- handful of actions whose mechanism is not guessable from the hooks alone. Each was worked out
5
- against the running platform once — the point of writing them down is that nobody re-derives them.
4
+ handful of actions whose mechanism is not guessable from the hooks alone.
6
5
 
7
6
  ---
8
7
 
@@ -11,7 +10,7 @@ against the running platform once — the point of writing them down is that nob
11
10
  A button that produces a file — a quotation, a debit note, a label — and hands it over, touching
12
11
  **no** record.
13
12
 
14
- **The mechanism is the return channel, and it is not the one you would guess.** Any workflow tool
13
+ **The mechanism is the return channel.** Any workflow tool
15
14
  whose step output carries a `file_id` (`generate_pdf_from_template`, `generate_excel_from_template`,
16
15
  `generate_word_from_template`) is auto-collected by the execute endpoint and comes back in
17
16
  **`result.files[]`**, each with a servable `url`.
package/docs/runtime.md CHANGED
@@ -134,9 +134,7 @@ clear the gate: `<slug>.lotics.app` is served by the app-host Worker, which
134
134
  withholds every byte of the bundle and serves a password page instead. On
135
135
  success it sets `lotics_app_session` on the app's origin; the SDK reads that
136
136
  cookie and forwards it as `X-Lotics-App-Session` on data calls. The SDK owns no
137
- password UI a gate is a property of *serving* an app, not of fetching data,
138
- so it lives at the serving layer where one styled, localized page covers every
139
- public app. A `401` with error code `PASSWORD_REQUIRED` (the owner rotated or
137
+ password UI. A `401` with error code `PASSWORD_REQUIRED` (the owner rotated or
140
138
  cleared the password) reloads the page, which puts the visitor back through the
141
139
  gate. What the password does and doesn't protect: [security](./security.md).
142
140
 
@@ -173,9 +171,7 @@ surfaces:
173
171
  | `agentRuns` (session history) | **no** — the product host doesn't implement it either (`"Unknown RPC op: agentRuns"`) | **no** | yes |
174
172
  | `askAi` | yes | **no** — `"Unknown RPC op: askAi"` | rejects — `"askAi is only available when the app runs inside Lotics"` |
175
173
 
176
- **Limitation:** agent-run **history** (`agentRuns`) is not implemented by the
177
- embedded product host *or* the dev forwarder — both error `"Unknown RPC op:
178
- agentRuns"`. Don't build a session-log UI on `useAgentRuns`; keep the log in app
174
+ **Limitation:** Don't build a session-log UI on `useAgentRuns`; keep the log in app
179
175
  state from live `useAgentRun` results (see [ai](./ai.md)). Server-side *cancel*
180
176
  also errors in the dev loop (the local abort of a live stream still works) —
181
177
  verify cancel on a deployed app. The standalone `query` transport forwards only
@@ -355,8 +351,7 @@ install `posthog-js` or call any analytics API from app code.
355
351
  replay are off (autocapture is disabled at the project level — a client flag
356
352
  could not re-enable it). System signals are deliberately not events: data
357
353
  reads (`useQuery` fetches) and workflow/agent run outcomes. The backend
358
- already logs and persists every run (`workflow_executions`, `app_agent_runs`),
359
- so a client run event would be redundant system telemetry, not a gesture.
354
+ already logs and persists every run (`workflow_executions`, `app_agent_runs`).
360
355
  - Events the SDK emits automatically:
361
356
 
362
357
  | Event | Fired when | Properties |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/app-sdk",
3
- "version": "0.77.2",
3
+ "version": "0.77.4",
4
4
  "description": "Runtime SDK for Lotics custom-code apps — typed hooks, postMessage bridge, mount entry point",
5
5
  "type": "module",
6
6
  "exports": {