@lotics/app-sdk 0.33.0 → 0.35.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 ADDED
@@ -0,0 +1,262 @@
1
+ # @lotics/app-sdk — the SDK reference
2
+
3
+ The **data + RPC** runtime for Lotics custom-code apps: typed React hooks over an
4
+ origin-locked postMessage bridge, plus cell decoders and the `mount()` entry point. This
5
+ file is the SDK reference — the "reach for what" guide + the load-bearing patterns. The
6
+ **exact signature** of any hook or reader is its source, which ships with the package: read
7
+ `node_modules/@lotics/app-sdk/dist/src/<name>.d.ts`. **Never guess a signature — open the
8
+ file.**
9
+
10
+ - Import from the package root: `import { useQuery, useWorkflow, row } from "@lotics/app-sdk"`.
11
+ - **Data + RPC only — no UI.** The SDK ships zero components (keeps it off the React-Native-Web
12
+ tree). Render with **`@lotics/ui`** (read its `node_modules/@lotics/ui/AGENTS.md`); the two
13
+ pair — SDK fetches/mutates, `@lotics/ui` draws.
14
+ - **Two transports, picked automatically.** Embedded (the authenticated product host bridges
15
+ RPC over postMessage) vs. standalone (`<slug>.lotics.app`, public, calls the API directly).
16
+ App code never branches on it; the SDK does.
17
+ - **The app holds no credentials and writes nothing directly.** Every read is a declared named
18
+ query; every write is a declared workflow. The model, the IAM/auth rules, and the security
19
+ rationale live in **`docs/apps.md`** — read it for *why*; this file is *how*.
20
+
21
+ ---
22
+
23
+ ## Reach by role — the surface
24
+
25
+ Pick by intent. (→ open the `.d.ts` for the exact signature.)
26
+
27
+ - **Read rows** — three hooks, one job each; don't overload one:
28
+ - **`useQuery(alias, params?, opts?)`** — a single fetch (`pageSize` is a cap, not pagination).
29
+ For a detail read or a combobox's top-N. `opts.enabled` gates the fetch (nothing loads until
30
+ true); `opts.sort` / `opts.filter` refine over the query's OUTPUT columns.
31
+ - **`useInfiniteQuery`** — append / load-more (`loadMore`, accumulating `rows`). Infinite scroll.
32
+ - **`usePaginatedQuery(alias, params?, { pageSize, sort, filter })`** — page-model with a
33
+ `total`, behind `Pagination`. Owns the page cursor; fetches the page AND a `count` keyed on
34
+ `(alias, params, filter)` (independent of page+sort, so paging/re-sorting never recount).
35
+ `(params, filter)` is the result-set identity — changing it resets to page 0 and recounts.
36
+ - **A select field's options** — **`useFieldOptions(alias)`** → per select OUTPUT column, its FULL
37
+ `{ key, label, color }` list (incl. options not in any current row) + a `byKey` lookup. Populates
38
+ + colors pickers and stored values; tracks the table (a newly added/renamed option just renders).
39
+ - **Mutate (the ONLY write path)** — **`useWorkflow(alias)`** → an async fn returning a typed
40
+ `WorkflowResult` `{ status, message?, files?, data? }`. `data` is whatever the workflow
41
+ `return({ data })`'d (typed automatically from the alias contract). After a known mutation,
42
+ call the owning query's `refetch()`.
43
+ - **Upload a file** — **`useFileUpload()`** → `{ upload, uploading, error }`. Mints a presigned URL
44
+ and PUTs the bytes straight to storage; the file is inert until a workflow attaches it to a
45
+ `files` field. Emits `app_file_uploaded`.
46
+ - **List members** — **`useMembers(opts?)`** → `{ members: {id,name,email,image}[], loading, error }`
47
+ — the candidate set for an assign/picker. Gated: the app must declare a `member`-typed input
48
+ (`opts.group` restricts to a declared group). Render with `@lotics/ui` `MemberSelect` / `MemberChip`.
49
+ - **Comments on a record** — **`useComments({ record_id })`** / **`useCommentCounts({ table_id })`**.
50
+ Capability-gated (`"capabilities": { "comments": true }` in the manifest) AND members-only — check
51
+ `available` before showing a composer. Runs under the VIEWING member's authority (author = the
52
+ viewer), unlike queries/workflows (owner authority). Render with `@lotics/ui` `comments_thread`.
53
+ - **Who's viewing** — **`useViewer()`** → the signed-in member (the *viewed* member under "View as").
54
+ Display-only — personalize or attribute; **never** scope rows by it (use an `is_current_member`
55
+ query filter server-side). Returns null for an anonymous public visitor.
56
+ - **Device location** — **`requestGeofencedLocation(zones)`** → a structured outcome
57
+ `{ ok:true, coords } | { ok:false, reason:"denied"|"unavailable"|"outside" }`; **`isWithinZone`**
58
+ is the pure check. Read directly in the iframe (host delegates the permission); the gate is
59
+ client-side advisory — pass `coords` to a workflow to record where an action happened.
60
+ - **Recents** — **`useRecents(key, max)`** → most-recent-first, deduped, capped, `localStorage`-backed
61
+ (web-only, which is why it's here, not in `@lotics/ui`). Feeds a `Combobox`'s `recentOptions`.
62
+ - **Optimistic mutation glue** — **`useOptimistic()`** → reconcile a workflow mutation against the
63
+ query cache for an interactive (calendar/kanban/grid) app. See the data-bound recipe.
64
+ - **Infra** — **`mount(opts?)`** boots the app + analytics (call once at entry; PostHog is automatic,
65
+ no per-app wiring). **`rpc(op, payload)`** is the raw bridge (escape hatch; prefer the hooks).
66
+ **`openExternal(url)`** opens a link in a new tab (scheme-validated; host-mediated in the embed).
67
+ **`downloadFile(name, data, mime?)`** saves browser-built bytes (a `Uint8Array | Blob | string`) —
68
+ call it synchronously from the click that produced them.
69
+
70
+ ### Decoding query cells — never hand-roll the serialization contract
71
+
72
+ `useQuery` rows are `Record<string, unknown>`. Coerce each cell with a typed reader (`row.ts` /
73
+ `select.ts` / `members.ts`); never re-implement `firstOpt`/`linkId`/`linkDisplay` — they rot.
74
+
75
+ - **`row.opt/text/num/bool/date/datetime/link`** — scalar coercion. `row.date` = calendar day
76
+ (LOCAL midnight, time stripped); **`row.datetime`** keeps the wall-clock — use it (and project the
77
+ column `type:"datetime"`) when you need the time, or it prints `00:00`.
78
+ - **`readSelect(cell)`** → `ResolvedOption[]` `{ key, label, color? }` (a cell carries key+label; the
79
+ `color` is populated by `useFieldOptions`, not the cell). **`readMembers(cell)`** → `{ id, name,
80
+ email? }[]`. **`readLinks(cell)` / `row.link`** → `{ id, display }`. **`readFiles(cell)`** →
81
+ `AppFile[]` with presigned `url` + `thumbnail_url` (24 h). **`readLocked(row)`** → `boolean` from the
82
+ `__source_locked` addressing column.
83
+ - **`project` only what you render** and `filter` server-side: a bare `from_table` ships every column
84
+ — incl. `files` with storage keys — to the client (over-exposure + presign-500 at scale). A code
85
+ lookup is a parameterized `filter`, not load-all-then-filter-in-JS.
86
+
87
+ ---
88
+
89
+ ## Data discipline (every app, prevents whole bug classes)
90
+
91
+ - **Server data is never copied into `useState`.** `useQuery` / `useWorkflow` results are the source
92
+ of truth — derive everything else with `useMemo`. A second copy drifts and serves stale values.
93
+ - **Prefer derivation + callbacks over `useEffect`.** A derived value is `useMemo`; "state A changed
94
+ → set state B" is both set in the one triggering callback. `useEffect` is for genuine external
95
+ subscriptions (timers, DOM listeners, storage) — fetching is `useQuery`, not an effect.
96
+ - **No layout shift on load or paging — the UX bar, not a nicety.** Reserve space while data loads:
97
+ (1) first load renders `Skeleton` placeholders that mirror the final layout, not a bare spinner;
98
+ (2) a page change KEEPS the previous rows (`usePaginatedQuery` does this via `keepPreviousData`) —
99
+ gate the skeleton on `loading && rows.length === 0`, never `loading` alone, or every "next page"
100
+ collapses the table; (3) a view↔edit toggle reserves the input's height so pressing Edit never
101
+ reflows.
102
+ - **Diff before update.** An edit form snapshots the record at load and sends only the CHANGED fields
103
+ to its update workflow (declare those inputs optional; an omitted input = not written). A full-form
104
+ snapshot re-writes locked fields (blocked even if unchanged), fires `before_update` autofills
105
+ spuriously, and clobbers concurrent edits. For a locked record (`readLocked`), the save becomes one
106
+ `request_locked_record_change` carrying the diffed `changes` + a reason.
107
+ - **Reuse the kit's utilities** — `@lotics/ui` ships the formatters (`formatMoney`, `formatDate`/
108
+ `parseDate`/`toISODate`); never hand-roll `dd/MM` or `₫`. Grep `@lotics/ui` exports before writing one.
109
+
110
+ ---
111
+
112
+ ## Search-as-you-type
113
+
114
+ A search-first picker (type → ranked results → pick) is one `@lotics/ui` component + three SDK pieces;
115
+ compose these, don't hand-roll search:
116
+
117
+ - **`Combobox`** (`@lotics/ui`) owns the interaction — debounced `onSearchChange`, a popover listbox
118
+ with rich rows (`renderOptionContent` reading `PickerOption.data`), keyboard nav, `recentOptions`,
119
+ `allowCustom`. (For a known small list with no search box, `Picker`.)
120
+ - **A parameterized `search` query** — a `from_table` with `search: "{{params.q}}"` over the maintained
121
+ `search_document`: **diacritics- & case-insensitive** (`"da nang"` matches `"Đà Nẵng"`), trigram-
122
+ indexed (scales), privacy-aware. AND-s with `filter` (search within a scope). Reserve an OR-group of
123
+ per-field `contains` (accent-*sensitive*, unindexed) only when you must bound exactly which fields
124
+ match. **Search-as-you-type uses `search`, never a `contains` OR-group** — a zero-match keystroke on
125
+ `contains` forces a full-table scan that hangs the picker.
126
+ - **`useQuery(alias, params, { enabled })`** — gate on a non-empty term; an empty term matches
127
+ everything (`ILIKE '%%'`) and dumps the table on first paint. `enabled` makes "nothing loads until
128
+ you type" true.
129
+ - **`useRecents`** — persist the picked option; pass its list as `recentOptions`.
130
+
131
+ Fetch detail on select with a SECOND parameterized query (a unique-code `equals`, or a link
132
+ `has_any_of [record_id]`) — never a bare full-table load. Filtering a record by its *own* id isn't a
133
+ compilable AST shape; filter by a unique field value.
134
+
135
+ ## Browse + sort + filter (the record picker)
136
+
137
+ When the user doesn't know the term — "show me everything, let me narrow it" — a modal table you can
138
+ browse (numbered pages), search, sort, and filter:
139
+
140
+ - **`TablePicker`** (`@lotics/ui`) is the data-agnostic base (a `Dialog` over `SearchInput` + filter
141
+ pills + `Table` + `Pagination`); it owns nothing about records — the consumer passes `columns` + one
142
+ page of `rows` and owns the search/sort/filter/page state. Filter pills are `ColumnFilter` +
143
+ `columnFilterToConditions`.
144
+ - **A records wrapper lives in the app** (e.g. `record_picker.tsx`) — it needs BOTH `@lotics/ui` and
145
+ the SDK (which is UI-free), so it can't be a package. It runs a named query via `usePaginatedQuery`
146
+ and feeds the page into `TablePicker`. Reuse it for any table by passing a different `alias` + `columns`.
147
+
148
+ The pieces that make it work:
149
+ - **Runtime `sort`/`filter` are server-bounded to the query's OUTPUT columns** (an un-projected
150
+ `field_key` → 400) — the picker can't widen exposure. Build `filter` with `columnFilterToConditions`;
151
+ map sort `{key,order}` → `[{field_key, order}]`.
152
+ - **The total comes from `count: true`** — a single-row COUNT over the filtered set (ignores
153
+ sort/limit/offset), driving "Page 1 of N".
154
+ - **Browse needs an unbounded query** — a baked-in AST `limit` caps the *total*; drop it and let
155
+ `pageSize` drive.
156
+ - **Select filter options:** prefer `useFieldOptions` for the COMPLETE set (not options derived from
157
+ loaded rows, which are incomplete until every page loads).
158
+
159
+ ## Rendering table primitives — select colors & members
160
+
161
+ Two of the most common cells render the platform way with zero hand-rolling; hand the value to its
162
+ `@lotics/ui` component — never re-derive an option→color map or a hand-built avatar (both rot):
163
+
164
+ - **Select value** → `OptionBadge` (`@lotics/ui`) with its CONFIGURED color. Stored value:
165
+ `byKey(readSelect(cell)[0]?.key)` from `useFieldOptions`; picker option: a `useFieldOptions` option.
166
+ Multi wraps; a missing/unknown color degrades to neutral.
167
+ - **Member** → `MemberChip` (avatar + name; the universal person render) and `MemberSelect` (the ready
168
+ picker — a `Picker` of `MemberChip`s). Feed from `useMembers`. (Avatars stay in the bounded, cached
169
+ roster — presigned files, never fattened onto every query cell; select color is a cheap token, so it
170
+ rides in `useFieldOptions`.) Full props: `@lotics/ui/AGENTS.md`.
171
+
172
+ ## Previewing files
173
+
174
+ Render any uploaded file inline — image, PDF, video, audio, Word, Excel, CSV — with `@lotics/ui`; never
175
+ hand-roll per-type rendering.
176
+
177
+ - **`FileGalleryModal`** (full-screen viewer) delegates to **`FilePreview`** (single-file), dispatching
178
+ by MIME; the frontend gallery uses the same renderer.
179
+ - File cells already carry presigned URLs — decode with **`readFiles`** → `AppFile[]`, map to
180
+ `DisplayFile` (`mime_type`→`mimeType`, `thumbnail_url`→`thumbnailUrl`). No round-trip, no URL
181
+ derivation, no server-side doc→PDF (rendering is client-side).
182
+ - Word/Excel preview lazy-imports `@lotics/docx` + `@lotics/xlsx` (optional peer deps) — an image/PDF
183
+ app installs neither. `@lotics/ui` is i18n/analytics-free: pass `labels` + an `onError`.
184
+
185
+ ## Composable optional filters (one query, many scopes)
186
+
187
+ Expose several INDEPENDENT filter axes the caller mixes freely from ONE named query — don't shard into
188
+ a query-per-combination. Mark each scoping param `required: false`; the server **prunes every filter
189
+ condition whose `{{params.x}}` the caller didn't pass** (then collapses empty groups), so an unset axis
190
+ stops constraining instead of erroring (no-op for all-required queries).
191
+
192
+ ```jsonc
193
+ "search": {
194
+ "ast": { "kind": "project", "from": { "kind": "from_table", "table_id": "tbl_items", "filter": {
195
+ "node_type": "group", "logic": "and", "children": [
196
+ { "node_type": "condition", "field_key": "status", "operator": "has_any_of", "value": ["{{params.status}}"] },
197
+ // keyword over two fields — the whole OR-group prunes when `keyword` is absent
198
+ { "node_type": "group", "logic": "or", "children": [
199
+ { "node_type": "condition", "field_key": "title", "operator": "contains", "value": "{{params.keyword}}" },
200
+ { "node_type": "condition", "field_key": "notes", "operator": "contains", "value": "{{params.keyword}}" } ] },
201
+ // date range — each bound is its OWN single-param condition, so an open-ended range prunes one side
202
+ { "node_type": "condition", "field_key": "created", "operator": "on_or_after",
203
+ "value": { "type": "exact", "date": "{{params.from}}", "time": null } },
204
+ { "node_type": "condition", "field_key": "created", "operator": "on_or_before",
205
+ "value": { "type": "exact", "date": "{{params.to}}", "time": null } } ] } }, "columns": [ /* … */ ] },
206
+ "params": {
207
+ "status": { "type": "select", "options": [ /* … */ ], "required": false },
208
+ "keyword": { "type": "text", "required": false },
209
+ "from": { "type": "date", "required": false },
210
+ "to": { "type": "date", "required": false } }
211
+ }
212
+ ```
213
+
214
+ `useQuery("search", { keyword })` filters by keyword only; `{}` returns everything. **Dates:** there's no
215
+ `date_range` param — embed a `date`/`text` param in a hand-built `DateTimePoint`
216
+ (`{type:"exact", date:"{{params.from}}", time:null}`), one condition per bound so each prunes
217
+ independently. Keep a scope that must always apply `required` (a missing required param 400s). Typos
218
+ can't widen — deploy rejects a `{{params.x}}` with no declared param.
219
+
220
+ ---
221
+
222
+ ## Recipes (app actions beyond the hooks)
223
+
224
+ Reverse-engineered once; full code in the `/app` skill's `references/recipes.md`.
225
+
226
+ - **Generate a document → download** — the file returns in `WorkflowResult.files[]` (auto-extracted from
227
+ any step's `file_id`); open via `openExternal(files[0].url)`. Don't hand back a `url`/`file_id` from
228
+ `return()`.
229
+ - **Export displayed data → .xlsx (client-side)** — `buildDataWorkbook({columns, rows})` (`@lotics/xlsx`)
230
+ → `exportWorkbook` → `downloadFile`. To export the WHOLE result (not one page), page the query
231
+ imperatively with `rpc("query", { alias, params, limit, offset })` to exhaustion (embedded-app only —
232
+ the public transport drops `sort`/`filter`).
233
+ - **Workflow returns data → fill the UI** — `return({ data })` hands back any structured result, read as
234
+ a typed `result.data` (no schema needed; narrow before use — a fall-through completion returns none).
235
+ - **Look up one record by a typed code** — a parameterized `{{params.x}}` filter + `useQuery(alias, {...})`,
236
+ so the client never receives other rows. Autonumber fields filter as text. (Public-app IDOR caveat:
237
+ `docs/apps.md`.)
238
+ - **Interactive (read + mutate) app** — `row.*` to coerce + `useOptimistic` to reconcile; `@lotics/ui`
239
+ provides the drag-enabled calendar/gantt/kanban/grid.
240
+
241
+ ---
242
+
243
+ ## Authority & scoping (the one-paragraph rule — full model in `docs/apps.md`)
244
+
245
+ App data ops run under the **app owner's** principal, not the viewer's — so who-is-acting and per-user
246
+ scoping are the author's job, never inferred. **Reads:** scope "my X" with the `is_current_member`
247
+ operator in the query TEMPLATE (the server binds the signed-in viewer / the view-as subject) — never a
248
+ client-supplied `member_id` (that's an IDOR; per-user data must not ship in a *public* app). **Writes:**
249
+ derive the actor server-side — read `runtime.triggered_by_member_id` in the workflow body; a client
250
+ `member` input is spoofable. **Privileged writes** (a role/group gate) authorize the caller in the
251
+ workflow with `current_member_in_any_group(...)`, paired with a manager-only read. `useViewer()` is
252
+ display-only, not an authorization fact. The full IAM model, public-access bounds, and the security
253
+ rationale: **`docs/apps.md`**.
254
+
255
+ ---
256
+
257
+ ## Keeping this file current
258
+
259
+ This is the published SDK reference — agents read `node_modules/@lotics/app-sdk/AGENTS.md` when building
260
+ apps. **Codify every new hook, reader, and load-bearing pattern here** in the same change that adds it
261
+ (and bump the package version so it ships). Keep it tight: the catalog points at the `.d.ts` for exact
262
+ signatures; the model/IAM/security rationale stays in `docs/apps.md` — link, don't duplicate.
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Parse a backend app-agent run stream into the app's agent-run model.
3
+ *
4
+ * The backend streams the AI-SDK "UI message" SSE protocol (the same wire format
5
+ * the chat uses) — `data: <json>\n\n` frames, each a typed chunk, terminated by
6
+ * `data: [DONE]`. The app SDK consumes it WITHOUT depending on the `ai` package
7
+ * (the sandboxed app bundle stays lean): we read the handful of chunk types that
8
+ * matter for a run feed and fold them into `AgentRunState`. Unknown chunk types
9
+ * are ignored, so an AI-SDK version that adds chunk kinds never breaks the SDK.
10
+ *
11
+ * The structured result is the input of the agent's `submit_result` tool call
12
+ * (the backend injects that tool when the agent declares `outputs`); a free-text
13
+ * agent's result is the accumulated text. Pure + reducer-shaped so it is fully
14
+ * unit-testable against recorded frames — no live model needed.
15
+ */
16
+ export interface AgentRunStep {
17
+ id: string;
18
+ label: string;
19
+ detail?: string;
20
+ status: "running" | "done" | "error";
21
+ kind?: "step" | "tool";
22
+ }
23
+ export interface AgentRunState {
24
+ status: "streaming" | "completed" | "error";
25
+ /** The agent's streamed reasoning / answer text, accumulated. */
26
+ text: string;
27
+ /** Tool calls the agent made, in order (excludes the internal `submit_result`). */
28
+ steps: AgentRunStep[];
29
+ /** The structured result — `submit_result`'s input, or (free-text) the final text. */
30
+ output?: unknown;
31
+ error?: string;
32
+ }
33
+ export declare function initialAgentRunState(): AgentRunState;
34
+ /** A parsed UI-message chunk — only the fields we read, all optional. */
35
+ interface Chunk {
36
+ type?: string;
37
+ delta?: string;
38
+ toolName?: string;
39
+ toolCallId?: string;
40
+ input?: unknown;
41
+ errorText?: string;
42
+ finishReason?: string;
43
+ }
44
+ /** Fold one chunk into the run state. Returns a new state (immutable). */
45
+ export declare function reduceAgentChunk(state: AgentRunState, chunk: Chunk): AgentRunState;
46
+ /**
47
+ * Incremental SSE frame splitter. Feed it raw text as it arrives; it returns the
48
+ * complete chunks parsed so far and the leftover partial frame to carry forward.
49
+ * `[DONE]` is dropped (the `finish` chunk already settles the state).
50
+ */
51
+ export declare function parseSseChunks(buffer: string): {
52
+ chunks: Chunk[];
53
+ rest: string;
54
+ };
55
+ export {};
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Parse a backend app-agent run stream into the app's agent-run model.
3
+ *
4
+ * The backend streams the AI-SDK "UI message" SSE protocol (the same wire format
5
+ * the chat uses) — `data: <json>\n\n` frames, each a typed chunk, terminated by
6
+ * `data: [DONE]`. The app SDK consumes it WITHOUT depending on the `ai` package
7
+ * (the sandboxed app bundle stays lean): we read the handful of chunk types that
8
+ * matter for a run feed and fold them into `AgentRunState`. Unknown chunk types
9
+ * are ignored, so an AI-SDK version that adds chunk kinds never breaks the SDK.
10
+ *
11
+ * The structured result is the input of the agent's `submit_result` tool call
12
+ * (the backend injects that tool when the agent declares `outputs`); a free-text
13
+ * agent's result is the accumulated text. Pure + reducer-shaped so it is fully
14
+ * unit-testable against recorded frames — no live model needed.
15
+ */
16
+ /** The tool the backend injects to carry a typed structured result. */
17
+ const SUBMIT_TOOL = "submit_result";
18
+ export function initialAgentRunState() {
19
+ return { status: "streaming", text: "", steps: [] };
20
+ }
21
+ /** Fold one chunk into the run state. Returns a new state (immutable). */
22
+ export function reduceAgentChunk(state, chunk) {
23
+ switch (chunk.type) {
24
+ case "text-delta":
25
+ case "reasoning-delta": {
26
+ const piece = chunk.delta ?? "";
27
+ return piece ? { ...state, text: state.text + piece } : state;
28
+ }
29
+ case "tool-input-available": {
30
+ const name = chunk.toolName ?? "tool";
31
+ if (name === SUBMIT_TOOL) {
32
+ // The agent emitted its structured result.
33
+ return { ...state, output: chunk.input };
34
+ }
35
+ return {
36
+ ...state,
37
+ steps: [
38
+ ...state.steps,
39
+ { id: chunk.toolCallId ?? `${name}-${state.steps.length}`, label: name, kind: "tool", status: "done" },
40
+ ],
41
+ };
42
+ }
43
+ case "error":
44
+ return { ...state, status: "error", error: chunk.errorText ?? "The run failed." };
45
+ case "abort":
46
+ return { ...state, status: state.status === "streaming" ? "error" : state.status, error: state.error ?? "The run was stopped." };
47
+ case "finish": {
48
+ const output = state.output ?? (state.text.trim() ? state.text.trim() : undefined);
49
+ return { ...state, status: state.status === "error" ? "error" : "completed", output };
50
+ }
51
+ default:
52
+ return state;
53
+ }
54
+ }
55
+ /**
56
+ * Incremental SSE frame splitter. Feed it raw text as it arrives; it returns the
57
+ * complete chunks parsed so far and the leftover partial frame to carry forward.
58
+ * `[DONE]` is dropped (the `finish` chunk already settles the state).
59
+ */
60
+ export function parseSseChunks(buffer) {
61
+ const chunks = [];
62
+ const frames = buffer.split("\n\n");
63
+ const rest = frames.pop() ?? ""; // last element is the incomplete frame
64
+ for (const frame of frames) {
65
+ for (const line of frame.split("\n")) {
66
+ const trimmed = line.startsWith("data:") ? line.slice(5).trim() : "";
67
+ if (!trimmed || trimmed === "[DONE]")
68
+ continue;
69
+ try {
70
+ chunks.push(JSON.parse(trimmed));
71
+ }
72
+ catch {
73
+ // a non-JSON data line — skip it, never throw on the stream
74
+ }
75
+ }
76
+ }
77
+ return { chunks, rest };
78
+ }
@@ -1,5 +1,8 @@
1
- import type { AppWorkflows, AppWorkflowResults, AppQueries } from "./types.js";
1
+ import { type AgentRunStep } from "./agent_stream.js";
2
+ import type { AppWorkflows, AppWorkflowResults, AppQueries, AppAgents, AppAgentResults } from "./types.js";
2
3
  import type { ResolvedMember } from "./members.js";
4
+ import type { ResolvedOption } from "./select.js";
5
+ export type { AgentRunStep, AgentRunState } from "./agent_stream.js";
3
6
  /** Fields shared by every query hook's return value. */
4
7
  interface QueryStateBase {
5
8
  /**
@@ -189,6 +192,68 @@ type QueryArgs<K extends keyof AppQueries & string, O> = AppQueries[K] extends R
189
192
  */
190
193
  export declare function useQuery<K extends keyof AppQueries & string>(alias: K, ...args: QueryArgs<K, QueryOptions>): QueryState<Record<string, unknown>>;
191
194
  export declare function useQuery(alias: string, params?: Record<string, unknown>, opts?: QueryOptions): QueryState<Record<string, unknown>>;
195
+ /** The resolved option set of one select column, plus an index for value
196
+ * rendering. The companion to a query row, for select fields. */
197
+ export interface FieldOptions {
198
+ /** The source field's display name — e.g. a picker/section label. */
199
+ label: string;
200
+ /**
201
+ * Every option of the field — `{ key, label, color }`. Includes options not
202
+ * present in any current row, so a freshly-added option appears in a picker
203
+ * without an app change, and a removed one drops out.
204
+ */
205
+ options: ResolvedOption[];
206
+ /**
207
+ * Resolve one option by key — for COLORING A STORED VALUE: pair with
208
+ * `readSelect(cell)[0]?.key`. `undefined` for an unknown key (option removed
209
+ * after the cell was written); render the cell's own label with a neutral
210
+ * badge in that case.
211
+ */
212
+ byKey: (key: string) => ResolvedOption | undefined;
213
+ }
214
+ /** Return value of `useFieldOptions`. */
215
+ export interface FieldOptionsState {
216
+ /**
217
+ * Resolved option sets keyed by the query's OUTPUT column name. A select
218
+ * column the server couldn't resolve to a source field (a UNION output, a
219
+ * computed column) is simply absent — read defensively (`fields.status?`).
220
+ */
221
+ fields: Record<string, FieldOptions>;
222
+ loading: boolean;
223
+ isValidating: boolean;
224
+ error: string | null;
225
+ /** Re-fetch — after a known field-config change (rare). */
226
+ refetch: () => void;
227
+ }
228
+ /** Options for `useFieldOptions`. */
229
+ export interface FieldOptionsOptions {
230
+ /** Defer the fetch until true — e.g. a picker that only needs options once an
231
+ * edit drawer opens. Defaults to true. */
232
+ enabled?: boolean;
233
+ }
234
+ /**
235
+ * Resolve the full option set (key, label, color) of a named query's `select`
236
+ * columns — the picker companion to `useQuery`. Where a query CELL carries only
237
+ * the options a record actually holds (key + label, no color), this returns each
238
+ * select column's COMPLETE option list with colors, straight from the field
239
+ * config — so it populates a dropdown AND colors a stored value, and a freshly
240
+ * added/removed option flows through with no app change.
241
+ *
242
+ * Addressed by the same alias you query: the option sets resolve from the named
243
+ * query's output columns, scoped exactly like running it. A column the server
244
+ * can't map to a source select field (UNION output, computed column) is absent.
245
+ *
246
+ * ```tsx
247
+ * const { fields } = useFieldOptions("records");
248
+ * // populate + color a picker:
249
+ * <Picker options={fields.status?.options ?? []}
250
+ * renderOptionContent={(o) => <OptionBadge value={o} />} />
251
+ * // color a stored value:
252
+ * <OptionBadge value={fields.status?.byKey(readSelect(r.status)[0]?.key ?? "")} />
253
+ * ```
254
+ */
255
+ export declare function useFieldOptions<K extends keyof AppQueries & string>(alias: K, opts?: FieldOptionsOptions): FieldOptionsState;
256
+ export declare function useFieldOptions(alias: string, opts?: FieldOptionsOptions): FieldOptionsState;
192
257
  /**
193
258
  * Like `useQuery` but append/load-more: the first render loads one page and
194
259
  * `loadMore()` appends the next, accumulating into `rows` (infinite scroll).
@@ -284,4 +349,72 @@ export interface MembersOptions {
284
349
  * ```
285
350
  */
286
351
  export declare function useMembers(opts?: MembersOptions): MembersState;
287
- export {};
352
+ type AgentOutputOf<K extends string> = K extends keyof AppAgentResults ? AppAgentResults[K] : unknown;
353
+ /** Options for one `run(...)` call — the app-owned session key it belongs to. */
354
+ export interface AgentRunOptions {
355
+ /** Groups this run with prior runs in the same working session; the agent
356
+ * re-reads them for context. Mint a new id to "clear context". */
357
+ sessionId: string;
358
+ }
359
+ /** The live state + controls returned by `useAgentRun`. */
360
+ export interface UseAgentRun<TInput, TOutput> {
361
+ /** Start a run — streams progress into this hook's state and resolves to the
362
+ * structured output (undefined for a free-text or failed run). Calling again
363
+ * aborts any run still in flight. */
364
+ run: (input: TInput, opts: AgentRunOptions) => Promise<TOutput | undefined>;
365
+ abort: () => void;
366
+ status: "idle" | "streaming" | "completed" | "error";
367
+ /** The agent's streamed reasoning / answer text, accumulating live. */
368
+ text: string;
369
+ /** The agent's tool calls so far — bind to `@lotics/ui` `AgentRun`/`AgentProgress`. */
370
+ steps: AgentRunStep[];
371
+ /** The validated structured result, once the run completes. */
372
+ output?: TOutput;
373
+ error?: string;
374
+ }
375
+ /**
376
+ * Run a streaming agent declared in `package.json` lotics.agents and invoked by
377
+ * alias. `run(input, { sessionId })` starts it; progress streams into `status` /
378
+ * `text` / `steps` (feed those straight into `@lotics/ui` `AgentRun` or
379
+ * `AgentProgress`) and the structured result lands in `output`. Read the
380
+ * session's history with `useAgentRuns`.
381
+ *
382
+ * ```tsx
383
+ * const recognize = useAgentRun("recognize");
384
+ * await recognize.run({ image_file_id }, { sessionId });
385
+ * // recognize.status / recognize.steps / recognize.output
386
+ * ```
387
+ */
388
+ export declare function useAgentRun<K extends keyof AppAgents & string>(alias: K): UseAgentRun<AppAgents[K], AgentOutputOf<K>>;
389
+ export declare function useAgentRun(alias: string): UseAgentRun<Record<string, unknown>, unknown>;
390
+ /** One persisted run in a session's history (the `/agent-runs` row shape). */
391
+ export interface AgentRunRecord {
392
+ id: string;
393
+ agent_alias: string;
394
+ session_id: string;
395
+ status: string;
396
+ input: Record<string, unknown> | null;
397
+ output: unknown;
398
+ error_message: string | null;
399
+ started_at: string;
400
+ completed_at: string | null;
401
+ }
402
+ interface AgentRunsState {
403
+ runs: AgentRunRecord[];
404
+ loading: boolean;
405
+ error: string | null;
406
+ refetch: () => void;
407
+ }
408
+ /**
409
+ * The run history of a session, oldest-first — the persisted outputs the app
410
+ * renders as a session log (NOT a chat: a flat list of past runs). Refetch
411
+ * after a `run(...)` completes to pull in the new one.
412
+ *
413
+ * ```tsx
414
+ * const { runs } = useAgentRuns(sessionId);
415
+ * ```
416
+ */
417
+ export declare function useAgentRuns(sessionId: string, opts?: {
418
+ enabled?: boolean;
419
+ revalidateOnFocus?: boolean;
420
+ }): AgentRunsState;
package/dist/src/hooks.js CHANGED
@@ -15,10 +15,11 @@
15
15
  * cache survives unmount/remount. Mutation / member hooks keep their own local
16
16
  * `useState` — they have nothing to share.
17
17
  */
18
- import { useCallback, useEffect, useState } from "react";
18
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
19
19
  import useSWR from "swr";
20
20
  import useSWRInfinite from "swr/infinite";
21
- import { rpc } from "./rpc.js";
21
+ import { rpc, rpcAgentRun } from "./rpc.js";
22
+ import { initialAgentRunState, reduceAgentChunk, parseSseChunks, } from "./agent_stream.js";
22
23
  import { getMockRows } from "./mock.js";
23
24
  import { captureAppEvent } from "./analytics.js";
24
25
  export function useWorkflow(alias) {
@@ -76,6 +77,33 @@ export function useQuery(alias, params, opts) {
76
77
  refetch,
77
78
  };
78
79
  }
80
+ export function useFieldOptions(alias, opts) {
81
+ const enabled = opts?.enabled ?? true;
82
+ // Field config is slow-changing, so no focus/reconnect revalidation — the app
83
+ // calls `refetch()` after a known change. A null key defers the fetch.
84
+ const key = enabled ? ["app-field-options", alias] : null;
85
+ const swr = useSWR(key, () => rpc("field_options", { alias }), swrConfig(false));
86
+ const fields = useMemo(() => {
87
+ const raw = swr.data?.fields ?? {};
88
+ const out = {};
89
+ for (const [col, def] of Object.entries(raw)) {
90
+ const options = def.options ?? [];
91
+ const index = new Map(options.map((o) => [o.key, o]));
92
+ out[col] = { label: def.label, options, byKey: (k) => index.get(k) };
93
+ }
94
+ return out;
95
+ }, [swr.data]);
96
+ const refetch = useCallback(() => {
97
+ void swr.mutate();
98
+ }, [swr]);
99
+ return {
100
+ fields,
101
+ loading: swr.isLoading,
102
+ isValidating: swr.isValidating,
103
+ error: swr.error ? swr.error.message : null,
104
+ refetch,
105
+ };
106
+ }
79
107
  export function useInfiniteQuery(alias, params, opts) {
80
108
  const pageSize = opts?.pageSize ?? 30;
81
109
  const enabled = opts?.enabled ?? true;
@@ -262,3 +290,105 @@ export function useMembers(opts) {
262
290
  }, [group]);
263
291
  return state;
264
292
  }
293
+ export function useAgentRun(alias) {
294
+ const [state, setState] = useState(null);
295
+ const handleRef = useRef(null);
296
+ // Guards setState after unmount and aborts any in-flight run on unmount, so a
297
+ // stream never keeps writing to a dead component (or leaks the transport).
298
+ const mountedRef = useRef(true);
299
+ useEffect(() => {
300
+ mountedRef.current = true;
301
+ return () => {
302
+ mountedRef.current = false;
303
+ handleRef.current?.abort();
304
+ };
305
+ }, []);
306
+ const safeSetState = useCallback((s) => {
307
+ if (mountedRef.current)
308
+ setState(s);
309
+ }, []);
310
+ const run = useCallback((input, opts) => {
311
+ handleRef.current?.abort();
312
+ let acc = initialAgentRunState();
313
+ let buffer = "";
314
+ let aborted = false;
315
+ safeSetState(acc);
316
+ const handle = rpcAgentRun({ alias, session_id: opts.sessionId, input: input ?? {} }, (textChunk) => {
317
+ if (aborted)
318
+ return;
319
+ buffer += textChunk;
320
+ const { chunks, rest } = parseSseChunks(buffer);
321
+ buffer = rest;
322
+ if (chunks.length === 0)
323
+ return;
324
+ for (const c of chunks)
325
+ acc = reduceAgentChunk(acc, c);
326
+ safeSetState({ ...acc });
327
+ });
328
+ // Wrap abort so a stop (user or unmount) marks the run cancelled and clears
329
+ // the partial state back to idle — `done` resolves cleanly, never an error.
330
+ handleRef.current = {
331
+ abort: () => {
332
+ if (aborted)
333
+ return;
334
+ aborted = true;
335
+ handle.abort();
336
+ safeSetState(null);
337
+ },
338
+ };
339
+ return handle.done
340
+ .then(() => {
341
+ if (aborted)
342
+ return undefined;
343
+ // `finish` normally settles status; guard a stream that ended without one.
344
+ if (acc.status === "streaming") {
345
+ acc = { ...acc, status: "completed", output: acc.output ?? (acc.text.trim() || undefined) };
346
+ safeSetState(acc);
347
+ }
348
+ captureAppEvent("app_agent_run", { alias, ok: acc.status !== "error" });
349
+ return acc.output;
350
+ })
351
+ .catch((err) => {
352
+ if (aborted)
353
+ return undefined;
354
+ acc = { ...acc, status: "error", error: err.message };
355
+ safeSetState(acc);
356
+ captureAppEvent("app_agent_run", { alias, ok: false });
357
+ throw err;
358
+ });
359
+ }, [alias, safeSetState]);
360
+ const abort = useCallback(() => handleRef.current?.abort(), []);
361
+ return {
362
+ run,
363
+ abort,
364
+ status: state?.status ?? "idle",
365
+ text: state?.text ?? "",
366
+ steps: state?.steps ?? [],
367
+ output: state?.output,
368
+ error: state?.error,
369
+ };
370
+ }
371
+ /**
372
+ * The run history of a session, oldest-first — the persisted outputs the app
373
+ * renders as a session log (NOT a chat: a flat list of past runs). Refetch
374
+ * after a `run(...)` completes to pull in the new one.
375
+ *
376
+ * ```tsx
377
+ * const { runs } = useAgentRuns(sessionId);
378
+ * ```
379
+ */
380
+ export function useAgentRuns(sessionId, opts) {
381
+ const enabled = opts?.enabled ?? true;
382
+ const revalidateOnFocus = opts?.revalidateOnFocus ?? true;
383
+ const key = enabled && sessionId ? ["app-agent-runs", sessionId] : null;
384
+ const swr = useSWR(key, () => rpc("agentRuns", { session_id: sessionId }), swrConfig(revalidateOnFocus));
385
+ const refetch = useCallback(() => {
386
+ void swr.mutate();
387
+ }, [swr]);
388
+ return {
389
+ runs: swr.data?.runs ?? [],
390
+ loading: swr.isLoading,
391
+ error: swr.error?.message ?? null,
392
+ refetch,
393
+ };
394
+ }
@@ -16,8 +16,8 @@
16
16
  */
17
17
  export { mount } from "./mount.js";
18
18
  export type { MountOptions } from "./mount.js";
19
- export { useWorkflow, useQuery, useInfiniteQuery, usePaginatedQuery, useFileUpload, useMembers, } from "./hooks.js";
20
- export type { UploadedFile, BaseQueryOptions, QueryOptions, InfiniteQueryOptions, PaginatedQueryOptions, QuerySortKey, QueryFilter, QueryFilterCondition, QueryFilterGroup, WorkflowResult, MembersOptions, } from "./hooks.js";
19
+ export { useWorkflow, useQuery, useInfiniteQuery, usePaginatedQuery, useFieldOptions, useFileUpload, useMembers, useAgentRun, useAgentRuns, } from "./hooks.js";
20
+ export type { UploadedFile, BaseQueryOptions, QueryOptions, InfiniteQueryOptions, PaginatedQueryOptions, QuerySortKey, QueryFilter, QueryFilterCondition, QueryFilterGroup, WorkflowResult, MembersOptions, AgentRunOptions, UseAgentRun, AgentRunRecord, AgentRunState, AgentRunStep, FieldOptions, FieldOptionsState, FieldOptionsOptions, } from "./hooks.js";
21
21
  export { useComments, useCommentCounts } from "./comments.js";
22
22
  export type { AppComment, AppCommentFile, CommentsState, UseCommentsArgs, CommentCountsState, UseCommentCountsArgs, } from "./comments.js";
23
23
  export { useViewer } from "./viewer.js";
@@ -32,7 +32,7 @@ export type { ResolvedMember } from "./members.js";
32
32
  export { readSelect } from "./select.js";
33
33
  export type { ResolvedOption } from "./select.js";
34
34
  export type { AppFixture } from "./mock.js";
35
- export type { AppWorkflows, AppQueries } from "./types.js";
35
+ export type { AppWorkflows, AppQueries, AppAgents, AppAgentResults } from "./types.js";
36
36
  export { row, readLinks, readFiles, readLocked } from "./row.js";
37
37
  export type { ResolvedLink, AppFile } from "./row.js";
38
38
  export { useOptimistic } from "./use_optimistic.js";
package/dist/src/index.js CHANGED
@@ -15,7 +15,7 @@
15
15
  * not raw HTML/CSS. See `docs/apps.md` → "Styling & components".
16
16
  */
17
17
  export { mount } from "./mount.js";
18
- export { useWorkflow, useQuery, useInfiniteQuery, usePaginatedQuery, useFileUpload, useMembers, } from "./hooks.js";
18
+ export { useWorkflow, useQuery, useInfiniteQuery, usePaginatedQuery, useFieldOptions, useFileUpload, useMembers, useAgentRun, useAgentRuns, } from "./hooks.js";
19
19
  export { useComments, useCommentCounts } from "./comments.js";
20
20
  export { useViewer } from "./viewer.js";
21
21
  export { requestGeofencedLocation, isWithinZone } from "./geolocation.js";
package/dist/src/rpc.d.ts CHANGED
@@ -18,7 +18,19 @@
18
18
  * app → host: { id, op, payload }
19
19
  * host → app: { id, type: "result", data } | { id, type: "error", message }
20
20
  */
21
- export type RpcOp = "query" | "workflow" | "upload" | "members" | "context" | "openExternal" | "comments.list" | "comments.create" | "comments.update" | "comments.delete" | "comments.counts";
21
+ export type RpcOp = "query" | "field_options" | "workflow" | "agentRuns" | "upload" | "members" | "context" | "openExternal" | "comments.list" | "comments.create" | "comments.update" | "comments.delete" | "comments.counts";
22
+ /** Payload for starting a streaming agent run. */
23
+ export interface AgentRunPayload {
24
+ alias: string;
25
+ session_id: string;
26
+ input: Record<string, unknown>;
27
+ }
28
+ /** Handle to a running stream — `done` settles at the end (or rejects on
29
+ * error); `abort` stops it. The caller feeds each text chunk to the parser. */
30
+ export interface AgentRunHandle {
31
+ done: Promise<void>;
32
+ abort: () => void;
33
+ }
22
34
  /**
23
35
  * The app's identity, resolved once at startup to tag PostHog events.
24
36
  * Assembled by whichever transport is active:
@@ -46,3 +58,10 @@ export interface AppContext {
46
58
  comments_enabled: boolean;
47
59
  }
48
60
  export declare function rpc<T = unknown>(op: RpcOp, payload: unknown): Promise<T>;
61
+ /**
62
+ * Start a streaming agent run. Each raw SSE text chunk is handed to `onText`
63
+ * (the caller parses it via `agent_stream`); `done` settles when the stream
64
+ * ends. Bridged: the host opens the SSE with its session and forwards chunks;
65
+ * standalone: the SDK reads the public endpoint's body directly.
66
+ */
67
+ export declare function rpcAgentRun(payload: AgentRunPayload, onText: (chunk: string) => void): AgentRunHandle;
package/dist/src/rpc.js CHANGED
@@ -24,6 +24,7 @@ export function rpc(op, payload) {
24
24
  : rpcStandalone(op, payload);
25
25
  }
26
26
  const pending = new Map();
27
+ const streaming = new Map();
27
28
  let nextRpcId = 0;
28
29
  let listenerInstalled = false;
29
30
  function ensureListener() {
@@ -37,15 +38,31 @@ function ensureListener() {
37
38
  const msg = event.data;
38
39
  if (!msg || typeof msg.id !== "number")
39
40
  return;
41
+ // Single-response ops.
40
42
  const handler = pending.get(msg.id);
41
- if (!handler)
43
+ if (handler) {
44
+ pending.delete(msg.id);
45
+ if (msg.type === "result")
46
+ handler.resolve(msg.data);
47
+ else
48
+ handler.reject(new Error(msg.message ?? "RPC failed"));
42
49
  return;
43
- pending.delete(msg.id);
44
- if (msg.type === "result") {
45
- handler.resolve(msg.data);
46
50
  }
47
- else {
48
- handler.reject(new Error(msg.message ?? "RPC failed"));
51
+ // Streaming op (agentRun): chunk* → end | error.
52
+ const stream = streaming.get(msg.id);
53
+ if (!stream)
54
+ return;
55
+ if (msg.type === "stream-chunk") {
56
+ if (typeof msg.chunk === "string")
57
+ stream.onText(msg.chunk);
58
+ }
59
+ else if (msg.type === "stream-end") {
60
+ streaming.delete(msg.id);
61
+ stream.resolve();
62
+ }
63
+ else if (msg.type === "error") {
64
+ streaming.delete(msg.id);
65
+ stream.reject(new Error(msg.message ?? "Run failed"));
49
66
  }
50
67
  });
51
68
  }
@@ -57,6 +74,77 @@ function rpcBridged(op, payload, host) {
57
74
  window.parent.postMessage({ id, op, payload }, host);
58
75
  });
59
76
  }
77
+ // ── Streaming transport (agent runs) ─────────────────────────────────────────
78
+ /**
79
+ * Start a streaming agent run. Each raw SSE text chunk is handed to `onText`
80
+ * (the caller parses it via `agent_stream`); `done` settles when the stream
81
+ * ends. Bridged: the host opens the SSE with its session and forwards chunks;
82
+ * standalone: the SDK reads the public endpoint's body directly.
83
+ */
84
+ export function rpcAgentRun(payload, onText) {
85
+ const hostOrigin = getHostOrigin();
86
+ return hostOrigin ? agentRunBridged(payload, onText, hostOrigin) : agentRunStandalone(payload, onText);
87
+ }
88
+ function agentRunBridged(payload, onText, host) {
89
+ ensureListener();
90
+ const id = nextRpcId++;
91
+ let settleDone = () => { };
92
+ const done = new Promise((resolve, reject) => {
93
+ settleDone = resolve;
94
+ streaming.set(id, { onText, resolve, reject });
95
+ window.parent.postMessage({ id, op: "agentRun", payload }, host);
96
+ });
97
+ return {
98
+ done,
99
+ abort: () => {
100
+ if (!streaming.has(id))
101
+ return;
102
+ streaming.delete(id);
103
+ window.parent.postMessage({ id, type: "abort" }, host);
104
+ // The caller stopped the run — resolve `done` cleanly so it never hangs
105
+ // (an abort is not a failure). The host tears down the SSE.
106
+ settleDone();
107
+ },
108
+ };
109
+ }
110
+ function agentRunStandalone(payload, onText) {
111
+ const controller = new AbortController();
112
+ const done = (async () => {
113
+ const { app_id } = await boot();
114
+ const headers = { "content-type": "application/json" };
115
+ if (sessionToken)
116
+ headers["authorization"] = `Bearer ${sessionToken}`;
117
+ const res = await fetch(`${API_BASE}/v1/apps/${app_id}/agents/${encodeURIComponent(payload.alias)}/runs`, {
118
+ method: "POST",
119
+ headers,
120
+ body: JSON.stringify({ session_id: payload.session_id, input: payload.input }),
121
+ signal: controller.signal,
122
+ });
123
+ if (!res.ok || !res.body) {
124
+ const text = await res.text().catch(() => "");
125
+ throw new Error(text || `HTTP ${res.status}`);
126
+ }
127
+ const reader = res.body.getReader();
128
+ const decoder = new TextDecoder();
129
+ try {
130
+ for (;;) {
131
+ const { value, done: streamDone } = await reader.read();
132
+ if (streamDone)
133
+ break;
134
+ onText(decoder.decode(value, { stream: true }));
135
+ }
136
+ const tail = decoder.decode(); // flush any bytes held across the final read
137
+ if (tail)
138
+ onText(tail);
139
+ }
140
+ catch (err) {
141
+ if (controller.signal.aborted)
142
+ return; // the caller stopped the run — clean, not an error
143
+ throw err;
144
+ }
145
+ })();
146
+ return { done, abort: () => controller.abort() };
147
+ }
60
148
  // ── Standalone transport ────────────────────────────────────────────────────
61
149
  // Standalone apps run only at `<slug>.lotics.app` (production); dev apps are
62
150
  // always bridged by the `lotics app dev` wrapper.
@@ -213,8 +301,12 @@ function rpcStandalone(op, payload) {
213
301
  switch (op) {
214
302
  case "query":
215
303
  return standaloneQuery(payload);
304
+ case "field_options":
305
+ return standaloneFieldOptions(payload);
216
306
  case "workflow":
217
307
  return standaloneWorkflow(payload);
308
+ case "agentRuns":
309
+ return standaloneAgentRuns(payload);
218
310
  case "upload":
219
311
  return standaloneUpload(payload.file);
220
312
  case "members":
@@ -289,10 +381,27 @@ async function standaloneQuery(p) {
289
381
  const r = (await apiCall("POST", `/v1/apps/${app_id}/query`, { alias: p.alias, params: p.params, limit: p.limit, offset: p.offset }, { appId: app_id }));
290
382
  return { rows: r.rows ?? [] };
291
383
  }
384
+ async function standaloneFieldOptions(p) {
385
+ const { app_id } = await boot();
386
+ const r = (await apiCall("POST", `/v1/apps/${app_id}/field-options`, { alias: p.alias }, { appId: app_id }));
387
+ return { fields: r.fields ?? {} };
388
+ }
292
389
  async function standaloneWorkflow(p) {
293
390
  const { app_id } = await boot();
294
391
  return apiCall("POST", `/v1/apps/${app_id}/workflows/${encodeURIComponent(p.alias)}/execute`, { inputs: p.inputs }, { appId: app_id });
295
392
  }
393
+ async function standaloneAgentRuns(p) {
394
+ const { app_id } = await boot();
395
+ const qs = new URLSearchParams({ session_id: p.session_id });
396
+ if (p.limit != null)
397
+ qs.set("limit", String(p.limit));
398
+ if (p.offset != null)
399
+ qs.set("offset", String(p.offset));
400
+ const r = (await apiCall("GET", `/v1/apps/${app_id}/agent-runs?${qs.toString()}`, undefined, {
401
+ appId: app_id,
402
+ }));
403
+ return { runs: r.runs ?? [] };
404
+ }
296
405
  async function standaloneUpload(file) {
297
406
  if (!(file instanceof File)) {
298
407
  throw new Error("upload payload must include a File");
@@ -14,6 +14,14 @@ export interface ResolvedOption {
14
14
  /** Option display name. Falls back to the key when the option was deleted
15
15
  * after the cell was written — surfaces the stale state explicitly. */
16
16
  label: string;
17
+ /**
18
+ * Named palette color token (e.g. `"blue"`, `"emerald"`). Populated by
19
+ * `useFieldOptions` (which reads the field config); absent on options read
20
+ * back from a query CELL via `readSelect` — a cell carries only key + label.
21
+ * Pass the resolved option straight to `@lotics/ui`'s `OptionBadge`, which
22
+ * degrades a missing/unknown token to a neutral badge.
23
+ */
24
+ color?: string;
17
25
  }
18
26
  /**
19
27
  * Parse a `useQuery` cell value into `ResolvedOption[]`. Returns `[]` for
@@ -68,3 +68,28 @@ export interface AppWorkflowResults {
68
68
  */
69
69
  export interface AppQueries {
70
70
  }
71
+ /**
72
+ * App-specific AGENT augmentation point — same pattern as `AppWorkflows`, for
73
+ * the streaming agents declared in `package.json` lotics.agents and invoked via
74
+ * `useAgentRun(alias)`. Per-app codegen maps each alias to its declared input
75
+ * shape, so an undeclared alias is a compile-time error and the run input is
76
+ * typed:
77
+ *
78
+ * ```ts
79
+ * declare module "@lotics/app-sdk" {
80
+ * interface AppAgents {
81
+ * "recognize": { image_file_id: string };
82
+ * "edit": { current: Record<string, unknown>; prompt: string };
83
+ * }
84
+ * }
85
+ * ```
86
+ */
87
+ export interface AppAgents {
88
+ }
89
+ /**
90
+ * App-specific agent-RESULT augmentation point — maps each agent alias to the
91
+ * type of its declared `outputs` (the structured result the run emits). Absent
92
+ * ⇒ `run.output` is `unknown`. Same pattern as `AppWorkflowResults`.
93
+ */
94
+ export interface AppAgentResults {
95
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/app-sdk",
3
- "version": "0.33.0",
3
+ "version": "0.35.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": {
@@ -9,7 +9,7 @@
9
9
  "types": "./dist/src/index.d.ts",
10
10
  "files": [
11
11
  "dist",
12
- "README.md"
12
+ "AGENTS.md"
13
13
  ],
14
14
  "scripts": {
15
15
  "build": "tsgo",