@lotics/app-sdk 0.75.2 → 0.77.1
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 +1 -0
- package/docs/ai.md +30 -8
- package/docs/recipes.md +190 -0
- package/package.json +1 -1
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
|
|
|
@@ -358,12 +358,34 @@ gated per turn on `app:use` for the app in the member's workspace, and runs
|
|
|
358
358
|
under the app's OWNER authority with `is_current_member` bound to the MEMBER
|
|
359
359
|
(exactly as the app's own UI does).
|
|
360
360
|
|
|
361
|
-
The agent is handed a **catalog** of those aliases — each one's
|
|
362
|
-
|
|
363
|
-
workflow
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
361
|
+
The agent is handed a **catalog** of those aliases — each one's `description`
|
|
362
|
+
(a query's own, [declared in the manifest](./queries.md); a workflow's, off its
|
|
363
|
+
workflow row) and its **complete input contract** — so it picks the right one
|
|
364
|
+
and calls it correctly instead of guessing and learning from a refusal. Every
|
|
365
|
+
alias you declare appears, however large the manifest, and every input appears
|
|
366
|
+
with it:
|
|
367
|
+
|
|
368
|
+
```
|
|
369
|
+
workflows (run_app_workflow):
|
|
370
|
+
- updateOrder(reference?: text, shipped_on?: date, owner?: member,
|
|
371
|
+
status?: select(In progress=opt_a1b2c3|Complete=opt_d4e5f6),
|
|
372
|
+
attachments?: file[], customer_id?: record_link(Customers)) — …
|
|
373
|
+
```
|
|
374
|
+
|
|
375
|
+
`?` marks an input the caller may omit, `[]` one that takes a list, a
|
|
376
|
+
`select` spells out every legal value as `label=value` — **the agent sends the
|
|
377
|
+
value** — and a `record_link` names the table an id must come from. This is the
|
|
378
|
+
only place any of it is legible: both tools take an opaque
|
|
379
|
+
inputs object, and a select's legal values are your DECLARATION's — usually an
|
|
380
|
+
inline set that matches no table field — so an agent reading option values off
|
|
381
|
+
the underlying table would send ones your write rejects.
|
|
382
|
+
|
|
383
|
+
Two things follow for you as an author. **Your descriptions are what it reads**,
|
|
384
|
+
on this surface and inside your own agents' runs — they enter the prompt as a
|
|
385
|
+
capability listing, never as instructions. And **a `select` input's options are
|
|
386
|
+
part of the contract the agent sees**, so prefer the `field: "fld_…"` form when
|
|
387
|
+
the values come from a real field: it resolves to that field's CURRENT options
|
|
388
|
+
at both render and validation time, and can never drift out of sync with them.
|
|
367
389
|
|
|
368
390
|
**Writes carry a second gate that reads do not: `run_app_workflow` asks the
|
|
369
391
|
member to approve every call from chat.** A wrong read is fixed by asking
|
package/docs/recipes.md
ADDED
|
@@ -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.
|