@lotics/app-sdk 0.75.2 → 0.77.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md CHANGED
@@ -14,6 +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
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. |
18
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. |
19
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). |
package/docs/ai.md CHANGED
@@ -27,8 +27,8 @@ A declaration carries:
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
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 |
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 |
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) |
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 |
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
 
@@ -0,0 +1,190 @@
1
+ # Recipes — the app actions that are not obvious
2
+
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.
6
+
7
+ ---
8
+
9
+ ## Generate a document and let the user download it
10
+
11
+ A button that produces a file — a quotation, a debit note, a label — and hands it over, touching
12
+ **no** record.
13
+
14
+ **The mechanism is the return channel, and it is not the one you would guess.** Any workflow tool
15
+ whose step output carries a `file_id` (`generate_pdf_from_template`, `generate_excel_from_template`,
16
+ `generate_word_from_template`) is auto-collected by the execute endpoint and comes back in
17
+ **`result.files[]`**, each with a servable `url`.
18
+
19
+ So the workflow only *generates*. It does not `update_records` — attaching the file to a record is a
20
+ separate, optional step. And you cannot hand the file back through `return({...})`; that channel is
21
+ for values, not files.
22
+
23
+ ```js
24
+ // workflow body — alias `genDebit`, input `record_id`
25
+ const rid = trigger.app_workflow.inputs.record_id;
26
+ const rec = await get_record({ table_id: "tbl_x", record_id: rid });
27
+ await generate_excel_from_template({
28
+ data: { /* … */ }, filename: `Debit_${code}`, document_template_id: "dtl_x",
29
+ });
30
+ return({ status: "success", message: `Đã tạo ${code}` }); // the file is extracted, not returned
31
+ ```
32
+
33
+ ```tsx
34
+ import { useWorkflow, openExternal } from "@lotics/app-sdk";
35
+
36
+ const gen = useWorkflow("genDebit");
37
+ const { files } = await gen({ record_id: rid });
38
+ const url = files?.[0]?.url;
39
+ if (url) await openExternal(url);
40
+ ```
41
+
42
+ The `dtl_…` is a template you registered **once**, ahead of the app, through the CLI — see
43
+ `lotics docs document_templates` for the five types and how each is created. An app fills
44
+ templates; it never authors them.
45
+
46
+ **`openExternal` is the download primitive, not `window.open`.** The app runs in a sandboxed iframe
47
+ with no `allow-popups`, so a bare `window.open` is dropped — no error, no navigation, nothing to
48
+ debug. `openExternal` routes the open through the host frame.
49
+
50
+ ## Return structured data from a workflow
51
+
52
+ A workflow can hand back a computed total, a list of rows, a status object — read by the app as a
53
+ typed `result.data`, validated server-side against the alias's `outputs`.
54
+
55
+ **You usually declare nothing.** `outputs` are derived at save time from the inferred type of your
56
+ `return({ data })`, and `lotics app workflow set` writes that derived schema back into the manifest
57
+ and refreshes the types in place — so `result.data` is typed immediately, with no hand-copy and no
58
+ second `codegen`. Declare an explicit `outputs` only to narrow beyond what is inferred; it is then
59
+ authoritative and never overwritten.
60
+
61
+ ```js
62
+ const rows = await query_records({ table_id: "tbl_x", filters: { /* … */ } });
63
+ const items = rows.map((r) => ({ id: r["fld_id"], amount: r["fld_amount"] }));
64
+ return({ status: "success", message: "ok", data: { total: items.length, items } });
65
+ ```
66
+
67
+ ```tsx
68
+ const result = await summarize({ /* inputs */ });
69
+ if (result.status === "success") {
70
+ result.data.total; // number
71
+ result.data.items[0].amount; // number
72
+ }
73
+ ```
74
+
75
+ A `data` expression the checker cannot pin down degrades to untyped `json`, so keep the shape
76
+ literal enough to infer. Files still travel via `result.files`.
77
+
78
+ ## Look up one record by a code the user types
79
+
80
+ Filter **server-side** so the app never receives another row. A full-table query loaded and filtered
81
+ in JS ships every record to the client.
82
+
83
+ ```jsonc
84
+ "lookupShipment": {
85
+ "ast": {
86
+ "kind": "from_table", "table_id": "tbl_x",
87
+ "filter": { "node_type": "condition", "field_key": "fld_code",
88
+ "operator": "equals", "value": "{{params.code}}" }
89
+ },
90
+ "params": { "code": { "type": "text", "required": true } }
91
+ }
92
+ ```
93
+
94
+ - `{{params.<name>}}` resolves **only in value positions**. A token in `table_id` or `field_key` is
95
+ not a real identifier and fails the deploy.
96
+ - `useQuery("lookupShipment", { code })` is reactive on `code`. Gate on a *submitted* code — an
97
+ empty one matches nothing, so render a prompt rather than an empty table.
98
+ - An **autonumber** field stores the composed string (`DL-2026-0006`), so `equals` / `contains`
99
+ filter it directly as text. No reverse mapping.
100
+
101
+ **A code lookup carries no viewer identity, so it is an enumerable IDOR.** Fine for an internal or
102
+ embedded app; never ship it as a public per-user portal — see `lotics docs security`.
103
+
104
+ ## Compose many optional filter axes in one query
105
+
106
+ Filtering by several independent criteria in any combination does **not** need a query per
107
+ combination. Declare each axis gated on a `required: false` param: the server prunes every condition
108
+ whose `{{params.x}}` the caller did not pass, and collapses emptied groups to match-all.
109
+
110
+ So `useQuery("search", { keyword })` filters by keyword alone, and `{}` returns everything. A
111
+ missing *required* param still fails.
112
+
113
+ Full pattern, including the date-param trick for range filters: `lotics docs queries` →
114
+ "Composable optional filters".
115
+
116
+ ## Decode cells with the accessors, never by hand
117
+
118
+ A query cell is `unknown` with a per-type serialized shape:
119
+
120
+ - `row.opt` / `row.text` / `row.num` / `row.bool` / `row.date` — scalars and the first value of a
121
+ select. `row.date` keeps only the calendar day; use **`row.datetime`** when the time matters.
122
+ - `readSelect` — the full `{ key, label }[]` of a multi-select.
123
+ - `readMembers` — `select_member` cells.
124
+ - `row.link` / `readLinks` — `select_record_link` → `{ id, display }`. Read `.display` to render,
125
+ `.id` to correlate or filter.
126
+
127
+ A hand-rolled `firstOpt` / `linkDisplay` is the most common drift in app code: it re-implements the
128
+ serialization contract and rots silently when that changes. A select cell is `[{key,label}]`, so a
129
+ reader that grabs the wrong half is *plausible and wrong* rather than broken.
130
+
131
+ **Project what you render.** A bare `{ kind: "from_table", table_id }` with no `project` ships every
132
+ column of every row — including `files` fields and any cost or PII column the UI never shows.
133
+ Projecting file fields also mass-presigns, which 500s on a large result and survives only on tiny
134
+ tables.
135
+
136
+ ## Set the icon and colour
137
+
138
+ Two **separate** layers; set both.
139
+
140
+ **Launcher chrome** — `lotics run update_app '{"app_id":"…","icon":"truck","theme":{"color":"blue"}}'`.
141
+ `icon` is any Lucide name (the full library, validated at write time — a wrong name is rejected, not
142
+ silently boxed). Don't guess the kebab spelling:
143
+
144
+ ```
145
+ lotics run search_app_icons '{"query":"shipping"}' → ["truck","ship","package",…]
146
+ ```
147
+
148
+ `theme.color` is a named palette colour: `red orange amber yellow lime green emerald teal cyan sky
149
+ blue indigo violet purple fuchsia pink rose slate gray zinc neutral stone`. `null` clears either.
150
+ There is no `lotics app icon` subcommand; a deploy warns while they are unset.
151
+
152
+ **In-app palette** — the app's own `src/theme.ts`, exact hex. This is what the app *renders* with;
153
+ the launcher colour does not feed it. Pick the named colour closest to your brand hex.
154
+
155
+ ## Test an AI action without spending credits
156
+
157
+ An app's `useAgentRun` runs the **real** agent even under `lotics app dev` — the dev harness proxies
158
+ to the production endpoint, so every dev click spends the app org's credits. The SDK's fixture mode
159
+ (`?__mock=1`) covers `useQuery` only; it does not mock an agent run. Iterating on a review screen
160
+ would re-bill on every reload.
161
+
162
+ **Replay one captured run.** Capture a real `run.output` once, paste it as a constant, and gate on
163
+ the same `?__mock=1` flag — so mocked rows and a mocked run activate together and dev is one
164
+ consistent state.
165
+
166
+ ```tsx
167
+ const MOCK = new URLSearchParams(window.location.search).get("__mock") === "1";
168
+ const OUTPUT_FIXTURE = { /* …one real run's structured result… */ } as ReviewProposal;
169
+
170
+ function Action({ input }: { input: AgentInput }) {
171
+ const agent = useAgentRun("proposeChanges");
172
+ const [proposal, setProposal] = useState<ReviewProposal>();
173
+
174
+ async function propose() {
175
+ if (MOCK) { setProposal(OUTPUT_FIXTURE); return; } // no run, no credits
176
+ const output = await agent.run(input, { sessionId: mySessionKey });
177
+ if (agent.status === "error" || output === undefined) return;
178
+ setProposal(output);
179
+ }
180
+ return proposal ? <ChangeReview /* … */ /> : /* trigger propose */;
181
+ }
182
+ ```
183
+
184
+ Hold the proposal in local state rather than reading `agent.output`, since `run()` never fires in
185
+ mock mode. Capture the fixture with
186
+ `run(input, { sessionId }).then((o) => console.log(JSON.stringify(o)))`.
187
+
188
+ **This is not a substitute for one real end-to-end run before shipping.** The fixture exercises the
189
+ UI downstream of the model and nothing else — not the agent, not its tools, not the server-side
190
+ validation of its output.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/app-sdk",
3
- "version": "0.75.2",
3
+ "version": "0.77.0",
4
4
  "description": "Runtime SDK for Lotics custom-code apps — typed hooks, postMessage bridge, mount entry point",
5
5
  "type": "module",
6
6
  "exports": {