@lotics/app-sdk 0.34.0 → 0.36.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,270 @@
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
+ - **Composer attachments (with instant preview)** — **`useAttachments()`** → `{ files, add, remove,
47
+ clear, uploading, fileIds }`. The chat's optimistic-preview UX in one hook: `add(File[])` shows a
48
+ local object-URL preview AT ONCE and uploads in the background; each `files[]` entry carries
49
+ `{ id, filename, mime_type, preview_url, status, file_id? }`; previews are revoked on remove/clear.
50
+ Picking is yours (button → `@lotics/ui` `pickFiles`, or paste/drop) — pass the `File[]` to `add`.
51
+ Wire to `@lotics/ui` `Composer`: `actionsButton` triggers the pick, `files` maps to
52
+ `<FileThumbnail … uploading={f.status === "uploading"} />`, `sendDisabled` gates on `uploading`,
53
+ and the send payload is `fileIds`. Don't hand-roll `createObjectURL`/upload/revoke per app.
54
+ - **List members** — **`useMembers(opts?)`** → `{ members: {id,name,email,image}[], loading, error }`
55
+ — the candidate set for an assign/picker. Gated: the app must declare a `member`-typed input
56
+ (`opts.group` restricts to a declared group). Render with `@lotics/ui` `MemberSelect` / `MemberChip`.
57
+ - **Comments on a record** — **`useComments({ record_id })`** / **`useCommentCounts({ table_id })`**.
58
+ Capability-gated (`"capabilities": { "comments": true }` in the manifest) AND members-only — check
59
+ `available` before showing a composer. Runs under the VIEWING member's authority (author = the
60
+ viewer), unlike queries/workflows (owner authority). Render with `@lotics/ui` `comments_thread`.
61
+ - **Who's viewing** — **`useViewer()`** → the signed-in member (the *viewed* member under "View as").
62
+ Display-only — personalize or attribute; **never** scope rows by it (use an `is_current_member`
63
+ query filter server-side). Returns null for an anonymous public visitor.
64
+ - **Device location** — **`requestGeofencedLocation(zones)`** → a structured outcome
65
+ `{ ok:true, coords } | { ok:false, reason:"denied"|"unavailable"|"outside" }`; **`isWithinZone`**
66
+ is the pure check. Read directly in the iframe (host delegates the permission); the gate is
67
+ client-side advisory — pass `coords` to a workflow to record where an action happened.
68
+ - **Recents** — **`useRecents(key, max)`** → most-recent-first, deduped, capped, `localStorage`-backed
69
+ (web-only, which is why it's here, not in `@lotics/ui`). Feeds a `Combobox`'s `recentOptions`.
70
+ - **Optimistic mutation glue** — **`useOptimistic()`** → reconcile a workflow mutation against the
71
+ query cache for an interactive (calendar/kanban/grid) app. See the data-bound recipe.
72
+ - **Infra** — **`mount(opts?)`** boots the app + analytics (call once at entry; PostHog is automatic,
73
+ no per-app wiring). **`rpc(op, payload)`** is the raw bridge (escape hatch; prefer the hooks).
74
+ **`openExternal(url)`** opens a link in a new tab (scheme-validated; host-mediated in the embed).
75
+ **`downloadFile(name, data, mime?)`** saves browser-built bytes (a `Uint8Array | Blob | string`) —
76
+ call it synchronously from the click that produced them.
77
+
78
+ ### Decoding query cells — never hand-roll the serialization contract
79
+
80
+ `useQuery` rows are `Record<string, unknown>`. Coerce each cell with a typed reader (`row.ts` /
81
+ `select.ts` / `members.ts`); never re-implement `firstOpt`/`linkId`/`linkDisplay` — they rot.
82
+
83
+ - **`row.opt/text/num/bool/date/datetime/link`** — scalar coercion. `row.date` = calendar day
84
+ (LOCAL midnight, time stripped); **`row.datetime`** keeps the wall-clock — use it (and project the
85
+ column `type:"datetime"`) when you need the time, or it prints `00:00`.
86
+ - **`readSelect(cell)`** → `ResolvedOption[]` `{ key, label, color? }` (a cell carries key+label; the
87
+ `color` is populated by `useFieldOptions`, not the cell). **`readMembers(cell)`** → `{ id, name,
88
+ email? }[]`. **`readLinks(cell)` / `row.link`** → `{ id, display }`. **`readFiles(cell)`** →
89
+ `AppFile[]` with presigned `url` + `thumbnail_url` (24 h). **`readLocked(row)`** → `boolean` from the
90
+ `__source_locked` addressing column.
91
+ - **`project` only what you render** and `filter` server-side: a bare `from_table` ships every column
92
+ — incl. `files` with storage keys — to the client (over-exposure + presign-500 at scale). A code
93
+ lookup is a parameterized `filter`, not load-all-then-filter-in-JS.
94
+
95
+ ---
96
+
97
+ ## Data discipline (every app, prevents whole bug classes)
98
+
99
+ - **Server data is never copied into `useState`.** `useQuery` / `useWorkflow` results are the source
100
+ of truth — derive everything else with `useMemo`. A second copy drifts and serves stale values.
101
+ - **Prefer derivation + callbacks over `useEffect`.** A derived value is `useMemo`; "state A changed
102
+ → set state B" is both set in the one triggering callback. `useEffect` is for genuine external
103
+ subscriptions (timers, DOM listeners, storage) — fetching is `useQuery`, not an effect.
104
+ - **No layout shift on load or paging — the UX bar, not a nicety.** Reserve space while data loads:
105
+ (1) first load renders `Skeleton` placeholders that mirror the final layout, not a bare spinner;
106
+ (2) a page change KEEPS the previous rows (`usePaginatedQuery` does this via `keepPreviousData`) —
107
+ gate the skeleton on `loading && rows.length === 0`, never `loading` alone, or every "next page"
108
+ collapses the table; (3) a view↔edit toggle reserves the input's height so pressing Edit never
109
+ reflows.
110
+ - **Diff before update.** An edit form snapshots the record at load and sends only the CHANGED fields
111
+ to its update workflow (declare those inputs optional; an omitted input = not written). A full-form
112
+ snapshot re-writes locked fields (blocked even if unchanged), fires `before_update` autofills
113
+ spuriously, and clobbers concurrent edits. For a locked record (`readLocked`), the save becomes one
114
+ `request_locked_record_change` carrying the diffed `changes` + a reason.
115
+ - **Reuse the kit's utilities** — `@lotics/ui` ships the formatters (`formatMoney`, `formatDate`/
116
+ `parseDate`/`toISODate`); never hand-roll `dd/MM` or `₫`. Grep `@lotics/ui` exports before writing one.
117
+
118
+ ---
119
+
120
+ ## Search-as-you-type
121
+
122
+ A search-first picker (type → ranked results → pick) is one `@lotics/ui` component + three SDK pieces;
123
+ compose these, don't hand-roll search:
124
+
125
+ - **`Combobox`** (`@lotics/ui`) owns the interaction — debounced `onSearchChange`, a popover listbox
126
+ with rich rows (`renderOptionContent` reading `PickerOption.data`), keyboard nav, `recentOptions`,
127
+ `allowCustom`. (For a known small list with no search box, `Picker`.)
128
+ - **A parameterized `search` query** — a `from_table` with `search: "{{params.q}}"` over the maintained
129
+ `search_document`: **diacritics- & case-insensitive** (`"da nang"` matches `"Đà Nẵng"`), trigram-
130
+ indexed (scales), privacy-aware. AND-s with `filter` (search within a scope). Reserve an OR-group of
131
+ per-field `contains` (accent-*sensitive*, unindexed) only when you must bound exactly which fields
132
+ match. **Search-as-you-type uses `search`, never a `contains` OR-group** — a zero-match keystroke on
133
+ `contains` forces a full-table scan that hangs the picker.
134
+ - **`useQuery(alias, params, { enabled })`** — gate on a non-empty term; an empty term matches
135
+ everything (`ILIKE '%%'`) and dumps the table on first paint. `enabled` makes "nothing loads until
136
+ you type" true.
137
+ - **`useRecents`** — persist the picked option; pass its list as `recentOptions`.
138
+
139
+ Fetch detail on select with a SECOND parameterized query (a unique-code `equals`, or a link
140
+ `has_any_of [record_id]`) — never a bare full-table load. Filtering a record by its *own* id isn't a
141
+ compilable AST shape; filter by a unique field value.
142
+
143
+ ## Browse + sort + filter (the record picker)
144
+
145
+ When the user doesn't know the term — "show me everything, let me narrow it" — a modal table you can
146
+ browse (numbered pages), search, sort, and filter:
147
+
148
+ - **`TablePicker`** (`@lotics/ui`) is the data-agnostic base (a `Dialog` over `SearchInput` + filter
149
+ pills + `Table` + `Pagination`); it owns nothing about records — the consumer passes `columns` + one
150
+ page of `rows` and owns the search/sort/filter/page state. Filter pills are `ColumnFilter` +
151
+ `columnFilterToConditions`.
152
+ - **A records wrapper lives in the app** (e.g. `record_picker.tsx`) — it needs BOTH `@lotics/ui` and
153
+ the SDK (which is UI-free), so it can't be a package. It runs a named query via `usePaginatedQuery`
154
+ and feeds the page into `TablePicker`. Reuse it for any table by passing a different `alias` + `columns`.
155
+
156
+ The pieces that make it work:
157
+ - **Runtime `sort`/`filter` are server-bounded to the query's OUTPUT columns** (an un-projected
158
+ `field_key` → 400) — the picker can't widen exposure. Build `filter` with `columnFilterToConditions`;
159
+ map sort `{key,order}` → `[{field_key, order}]`.
160
+ - **The total comes from `count: true`** — a single-row COUNT over the filtered set (ignores
161
+ sort/limit/offset), driving "Page 1 of N".
162
+ - **Browse needs an unbounded query** — a baked-in AST `limit` caps the *total*; drop it and let
163
+ `pageSize` drive.
164
+ - **Select filter options:** prefer `useFieldOptions` for the COMPLETE set (not options derived from
165
+ loaded rows, which are incomplete until every page loads).
166
+
167
+ ## Rendering table primitives — select colors & members
168
+
169
+ Two of the most common cells render the platform way with zero hand-rolling; hand the value to its
170
+ `@lotics/ui` component — never re-derive an option→color map or a hand-built avatar (both rot):
171
+
172
+ - **Select value** → `OptionBadge` (`@lotics/ui`) with its CONFIGURED color. Stored value:
173
+ `byKey(readSelect(cell)[0]?.key)` from `useFieldOptions`; picker option: a `useFieldOptions` option.
174
+ Multi wraps; a missing/unknown color degrades to neutral.
175
+ - **Member** → `MemberChip` (avatar + name; the universal person render) and `MemberSelect` (the ready
176
+ picker — a `Picker` of `MemberChip`s). Feed from `useMembers`. (Avatars stay in the bounded, cached
177
+ roster — presigned files, never fattened onto every query cell; select color is a cheap token, so it
178
+ rides in `useFieldOptions`.) Full props: `@lotics/ui/AGENTS.md`.
179
+
180
+ ## Previewing files
181
+
182
+ Render any uploaded file inline — image, PDF, video, audio, Word, Excel, CSV — with `@lotics/ui`; never
183
+ hand-roll per-type rendering.
184
+
185
+ - **`FileGalleryModal`** (full-screen viewer) delegates to **`FilePreview`** (single-file), dispatching
186
+ by MIME; the frontend gallery uses the same renderer.
187
+ - File cells already carry presigned URLs — decode with **`readFiles`** → `AppFile[]`, map to
188
+ `DisplayFile` (`mime_type`→`mimeType`, `thumbnail_url`→`thumbnailUrl`). No round-trip, no URL
189
+ derivation, no server-side doc→PDF (rendering is client-side).
190
+ - Word/Excel preview lazy-imports `@lotics/docx` + `@lotics/xlsx` (optional peer deps) — an image/PDF
191
+ app installs neither. `@lotics/ui` is i18n/analytics-free: pass `labels` + an `onError`.
192
+
193
+ ## Composable optional filters (one query, many scopes)
194
+
195
+ Expose several INDEPENDENT filter axes the caller mixes freely from ONE named query — don't shard into
196
+ a query-per-combination. Mark each scoping param `required: false`; the server **prunes every filter
197
+ condition whose `{{params.x}}` the caller didn't pass** (then collapses empty groups), so an unset axis
198
+ stops constraining instead of erroring (no-op for all-required queries).
199
+
200
+ ```jsonc
201
+ "search": {
202
+ "ast": { "kind": "project", "from": { "kind": "from_table", "table_id": "tbl_items", "filter": {
203
+ "node_type": "group", "logic": "and", "children": [
204
+ { "node_type": "condition", "field_key": "status", "operator": "has_any_of", "value": ["{{params.status}}"] },
205
+ // keyword over two fields — the whole OR-group prunes when `keyword` is absent
206
+ { "node_type": "group", "logic": "or", "children": [
207
+ { "node_type": "condition", "field_key": "title", "operator": "contains", "value": "{{params.keyword}}" },
208
+ { "node_type": "condition", "field_key": "notes", "operator": "contains", "value": "{{params.keyword}}" } ] },
209
+ // date range — each bound is its OWN single-param condition, so an open-ended range prunes one side
210
+ { "node_type": "condition", "field_key": "created", "operator": "on_or_after",
211
+ "value": { "type": "exact", "date": "{{params.from}}", "time": null } },
212
+ { "node_type": "condition", "field_key": "created", "operator": "on_or_before",
213
+ "value": { "type": "exact", "date": "{{params.to}}", "time": null } } ] } }, "columns": [ /* … */ ] },
214
+ "params": {
215
+ "status": { "type": "select", "options": [ /* … */ ], "required": false },
216
+ "keyword": { "type": "text", "required": false },
217
+ "from": { "type": "date", "required": false },
218
+ "to": { "type": "date", "required": false } }
219
+ }
220
+ ```
221
+
222
+ `useQuery("search", { keyword })` filters by keyword only; `{}` returns everything. **Dates:** there's no
223
+ `date_range` param — embed a `date`/`text` param in a hand-built `DateTimePoint`
224
+ (`{type:"exact", date:"{{params.from}}", time:null}`), one condition per bound so each prunes
225
+ independently. Keep a scope that must always apply `required` (a missing required param 400s). Typos
226
+ can't widen — deploy rejects a `{{params.x}}` with no declared param.
227
+
228
+ ---
229
+
230
+ ## Recipes (app actions beyond the hooks)
231
+
232
+ Reverse-engineered once; full code in the `/app` skill's `references/recipes.md`.
233
+
234
+ - **Generate a document → download** — the file returns in `WorkflowResult.files[]` (auto-extracted from
235
+ any step's `file_id`); open via `openExternal(files[0].url)`. Don't hand back a `url`/`file_id` from
236
+ `return()`.
237
+ - **Export displayed data → .xlsx (client-side)** — `buildDataWorkbook({columns, rows})` (`@lotics/xlsx`)
238
+ → `exportWorkbook` → `downloadFile`. To export the WHOLE result (not one page), page the query
239
+ imperatively with `rpc("query", { alias, params, limit, offset })` to exhaustion (embedded-app only —
240
+ the public transport drops `sort`/`filter`).
241
+ - **Workflow returns data → fill the UI** — `return({ data })` hands back any structured result, read as
242
+ a typed `result.data` (no schema needed; narrow before use — a fall-through completion returns none).
243
+ - **Look up one record by a typed code** — a parameterized `{{params.x}}` filter + `useQuery(alias, {...})`,
244
+ so the client never receives other rows. Autonumber fields filter as text. (Public-app IDOR caveat:
245
+ `docs/apps.md`.)
246
+ - **Interactive (read + mutate) app** — `row.*` to coerce + `useOptimistic` to reconcile; `@lotics/ui`
247
+ provides the drag-enabled calendar/gantt/kanban/grid.
248
+
249
+ ---
250
+
251
+ ## Authority & scoping (the one-paragraph rule — full model in `docs/apps.md`)
252
+
253
+ App data ops run under the **app owner's** principal, not the viewer's — so who-is-acting and per-user
254
+ scoping are the author's job, never inferred. **Reads:** scope "my X" with the `is_current_member`
255
+ operator in the query TEMPLATE (the server binds the signed-in viewer / the view-as subject) — never a
256
+ client-supplied `member_id` (that's an IDOR; per-user data must not ship in a *public* app). **Writes:**
257
+ derive the actor server-side — read `runtime.triggered_by_member_id` in the workflow body; a client
258
+ `member` input is spoofable. **Privileged writes** (a role/group gate) authorize the caller in the
259
+ workflow with `current_member_in_any_group(...)`, paired with a manager-only read. `useViewer()` is
260
+ display-only, not an authorization fact. The full IAM model, public-access bounds, and the security
261
+ rationale: **`docs/apps.md`**.
262
+
263
+ ---
264
+
265
+ ## Keeping this file current
266
+
267
+ This is the published SDK reference — agents read `node_modules/@lotics/app-sdk/AGENTS.md` when building
268
+ apps. **Codify every new hook, reader, and load-bearing pattern here** in the same change that adds it
269
+ (and bump the package version so it ships). Keep it tight: the catalog points at the `.d.ts` for exact
270
+ 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,6 +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";
3
4
  import type { ResolvedOption } from "./select.js";
5
+ export type { AgentRunStep, AgentRunState } from "./agent_stream.js";
4
6
  /** Fields shared by every query hook's return value. */
5
7
  interface QueryStateBase {
6
8
  /**
@@ -312,6 +314,52 @@ interface FileUploadState {
312
314
  * ```
313
315
  */
314
316
  export declare function useFileUpload(): FileUploadState;
317
+ /** A file attached to a composer — its local preview is available immediately,
318
+ * its stored `file_id` once the background upload completes. */
319
+ export interface AttachedFile {
320
+ /** Stable local id — the React key and the `remove(id)` handle. */
321
+ id: string;
322
+ filename: string;
323
+ mime_type: string;
324
+ /** Local object-URL preview, available the INSTANT the file is added (before upload). */
325
+ preview_url: string;
326
+ status: "uploading" | "ready" | "error";
327
+ /** The stored file id, set once `status` is `"ready"` — pass to a workflow/agent. */
328
+ file_id?: string;
329
+ }
330
+ interface AttachmentsState {
331
+ /** The current attachments, in the order added. */
332
+ files: AttachedFile[];
333
+ /** Add picked/pasted/dropped files — each shows its local preview at once and
334
+ * uploads in the background. Picking is the app's choice: wire a button to
335
+ * `@lotics/ui` `pickFiles`, or a paste/drop handler, then call this. */
336
+ add: (files: File[]) => void;
337
+ /** Remove one attachment and revoke its preview URL. */
338
+ remove: (id: string) => void;
339
+ /** Remove all attachments and revoke their preview URLs. */
340
+ clear: () => void;
341
+ /** True while any attachment is still uploading — gate Send on it. */
342
+ uploading: boolean;
343
+ /** Stored file ids of the completed uploads — the workflow/agent payload. */
344
+ fileIds: string[];
345
+ }
346
+ /**
347
+ * Composer attachments with the chat's optimistic-preview UX: a local object-URL
348
+ * preview shows the INSTANT a file is added, the upload runs in the background,
349
+ * and the stored `file_id` lands in `files` when it completes. Previews are
350
+ * revoked on remove/clear. Picking is the app's (button / paste / drop) — pass
351
+ * the resulting `File[]` to `add`; the hook owns the preview → upload → id →
352
+ * revoke lifecycle so apps don't re-implement it.
353
+ *
354
+ * ```tsx
355
+ * const { files, add, remove, clear, uploading, fileIds } = useAttachments();
356
+ * const design = useWorkflow("design");
357
+ * // attach: <Button icon="paperclip" onPress={() => pickFiles({ accept: "image/*" }).then(add)} />
358
+ * // preview: files.map((f) => <FileThumbnail file={f} uploading={f.status === "uploading"} onRemove={() => remove(f.id)} />)
359
+ * // send: design({ photo: fileIds[0] }); clear();
360
+ * ```
361
+ */
362
+ export declare function useAttachments(): AttachmentsState;
315
363
  interface MembersState {
316
364
  /** Members of the app's organization, for assign / member-picker UIs. */
317
365
  members: ResolvedMember[];
@@ -347,4 +395,72 @@ export interface MembersOptions {
347
395
  * ```
348
396
  */
349
397
  export declare function useMembers(opts?: MembersOptions): MembersState;
350
- export {};
398
+ type AgentOutputOf<K extends string> = K extends keyof AppAgentResults ? AppAgentResults[K] : unknown;
399
+ /** Options for one `run(...)` call — the app-owned session key it belongs to. */
400
+ export interface AgentRunOptions {
401
+ /** Groups this run with prior runs in the same working session; the agent
402
+ * re-reads them for context. Mint a new id to "clear context". */
403
+ sessionId: string;
404
+ }
405
+ /** The live state + controls returned by `useAgentRun`. */
406
+ export interface UseAgentRun<TInput, TOutput> {
407
+ /** Start a run — streams progress into this hook's state and resolves to the
408
+ * structured output (undefined for a free-text or failed run). Calling again
409
+ * aborts any run still in flight. */
410
+ run: (input: TInput, opts: AgentRunOptions) => Promise<TOutput | undefined>;
411
+ abort: () => void;
412
+ status: "idle" | "streaming" | "completed" | "error";
413
+ /** The agent's streamed reasoning / answer text, accumulating live. */
414
+ text: string;
415
+ /** The agent's tool calls so far — bind to `@lotics/ui` `AgentRun`/`AgentProgress`. */
416
+ steps: AgentRunStep[];
417
+ /** The validated structured result, once the run completes. */
418
+ output?: TOutput;
419
+ error?: string;
420
+ }
421
+ /**
422
+ * Run a streaming agent declared in `package.json` lotics.agents and invoked by
423
+ * alias. `run(input, { sessionId })` starts it; progress streams into `status` /
424
+ * `text` / `steps` (feed those straight into `@lotics/ui` `AgentRun` or
425
+ * `AgentProgress`) and the structured result lands in `output`. Read the
426
+ * session's history with `useAgentRuns`.
427
+ *
428
+ * ```tsx
429
+ * const recognize = useAgentRun("recognize");
430
+ * await recognize.run({ image_file_id }, { sessionId });
431
+ * // recognize.status / recognize.steps / recognize.output
432
+ * ```
433
+ */
434
+ export declare function useAgentRun<K extends keyof AppAgents & string>(alias: K): UseAgentRun<AppAgents[K], AgentOutputOf<K>>;
435
+ export declare function useAgentRun(alias: string): UseAgentRun<Record<string, unknown>, unknown>;
436
+ /** One persisted run in a session's history (the `/agent-runs` row shape). */
437
+ export interface AgentRunRecord {
438
+ id: string;
439
+ agent_alias: string;
440
+ session_id: string;
441
+ status: string;
442
+ input: Record<string, unknown> | null;
443
+ output: unknown;
444
+ error_message: string | null;
445
+ started_at: string;
446
+ completed_at: string | null;
447
+ }
448
+ interface AgentRunsState {
449
+ runs: AgentRunRecord[];
450
+ loading: boolean;
451
+ error: string | null;
452
+ refetch: () => void;
453
+ }
454
+ /**
455
+ * The run history of a session, oldest-first — the persisted outputs the app
456
+ * renders as a session log (NOT a chat: a flat list of past runs). Refetch
457
+ * after a `run(...)` completes to pull in the new one.
458
+ *
459
+ * ```tsx
460
+ * const { runs } = useAgentRuns(sessionId);
461
+ * ```
462
+ */
463
+ export declare function useAgentRuns(sessionId: string, opts?: {
464
+ enabled?: boolean;
465
+ revalidateOnFocus?: boolean;
466
+ }): 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, useMemo, 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) {
@@ -245,6 +246,58 @@ export function useFileUpload() {
245
246
  }, []);
246
247
  return { upload, uploading: inFlight > 0, error };
247
248
  }
249
+ let attachSeq = 0;
250
+ /**
251
+ * Composer attachments with the chat's optimistic-preview UX: a local object-URL
252
+ * preview shows the INSTANT a file is added, the upload runs in the background,
253
+ * and the stored `file_id` lands in `files` when it completes. Previews are
254
+ * revoked on remove/clear. Picking is the app's (button / paste / drop) — pass
255
+ * the resulting `File[]` to `add`; the hook owns the preview → upload → id →
256
+ * revoke lifecycle so apps don't re-implement it.
257
+ *
258
+ * ```tsx
259
+ * const { files, add, remove, clear, uploading, fileIds } = useAttachments();
260
+ * const design = useWorkflow("design");
261
+ * // attach: <Button icon="paperclip" onPress={() => pickFiles({ accept: "image/*" }).then(add)} />
262
+ * // preview: files.map((f) => <FileThumbnail file={f} uploading={f.status === "uploading"} onRemove={() => remove(f.id)} />)
263
+ * // send: design({ photo: fileIds[0] }); clear();
264
+ * ```
265
+ */
266
+ export function useAttachments() {
267
+ const { upload } = useFileUpload();
268
+ const [files, setFiles] = useState([]);
269
+ const add = useCallback((incoming) => {
270
+ for (const file of incoming) {
271
+ const id = `att_${(attachSeq += 1)}`;
272
+ const previewUrl = URL.createObjectURL(file);
273
+ setFiles((prev) => [
274
+ ...prev,
275
+ { id, filename: file.name, mime_type: file.type, preview_url: previewUrl, status: "uploading" },
276
+ ]);
277
+ upload(file)
278
+ .then((uploaded) => setFiles((prev) => prev.map((f) => (f.id === id ? { ...f, status: "ready", file_id: uploaded.id } : f))))
279
+ .catch(() => setFiles((prev) => prev.map((f) => (f.id === id ? { ...f, status: "error" } : f))));
280
+ }
281
+ }, [upload]);
282
+ const remove = useCallback((id) => {
283
+ setFiles((prev) => {
284
+ const target = prev.find((f) => f.id === id);
285
+ if (target)
286
+ URL.revokeObjectURL(target.preview_url);
287
+ return prev.filter((f) => f.id !== id);
288
+ });
289
+ }, []);
290
+ const clear = useCallback(() => {
291
+ setFiles((prev) => {
292
+ for (const f of prev)
293
+ URL.revokeObjectURL(f.preview_url);
294
+ return [];
295
+ });
296
+ }, []);
297
+ const uploading = files.some((f) => f.status === "uploading");
298
+ const fileIds = files.flatMap((f) => (f.status === "ready" && f.file_id ? [f.file_id] : []));
299
+ return { files, add, remove, clear, uploading, fileIds };
300
+ }
248
301
  /**
249
302
  * List the members of the app's organization — the candidate set for an
250
303
  * "assign to a member" picker. Each member is `{ id, name, email, image }`
@@ -289,3 +342,105 @@ export function useMembers(opts) {
289
342
  }, [group]);
290
343
  return state;
291
344
  }
345
+ export function useAgentRun(alias) {
346
+ const [state, setState] = useState(null);
347
+ const handleRef = useRef(null);
348
+ // Guards setState after unmount and aborts any in-flight run on unmount, so a
349
+ // stream never keeps writing to a dead component (or leaks the transport).
350
+ const mountedRef = useRef(true);
351
+ useEffect(() => {
352
+ mountedRef.current = true;
353
+ return () => {
354
+ mountedRef.current = false;
355
+ handleRef.current?.abort();
356
+ };
357
+ }, []);
358
+ const safeSetState = useCallback((s) => {
359
+ if (mountedRef.current)
360
+ setState(s);
361
+ }, []);
362
+ const run = useCallback((input, opts) => {
363
+ handleRef.current?.abort();
364
+ let acc = initialAgentRunState();
365
+ let buffer = "";
366
+ let aborted = false;
367
+ safeSetState(acc);
368
+ const handle = rpcAgentRun({ alias, session_id: opts.sessionId, input: input ?? {} }, (textChunk) => {
369
+ if (aborted)
370
+ return;
371
+ buffer += textChunk;
372
+ const { chunks, rest } = parseSseChunks(buffer);
373
+ buffer = rest;
374
+ if (chunks.length === 0)
375
+ return;
376
+ for (const c of chunks)
377
+ acc = reduceAgentChunk(acc, c);
378
+ safeSetState({ ...acc });
379
+ });
380
+ // Wrap abort so a stop (user or unmount) marks the run cancelled and clears
381
+ // the partial state back to idle — `done` resolves cleanly, never an error.
382
+ handleRef.current = {
383
+ abort: () => {
384
+ if (aborted)
385
+ return;
386
+ aborted = true;
387
+ handle.abort();
388
+ safeSetState(null);
389
+ },
390
+ };
391
+ return handle.done
392
+ .then(() => {
393
+ if (aborted)
394
+ return undefined;
395
+ // `finish` normally settles status; guard a stream that ended without one.
396
+ if (acc.status === "streaming") {
397
+ acc = { ...acc, status: "completed", output: acc.output ?? (acc.text.trim() || undefined) };
398
+ safeSetState(acc);
399
+ }
400
+ captureAppEvent("app_agent_run", { alias, ok: acc.status !== "error" });
401
+ return acc.output;
402
+ })
403
+ .catch((err) => {
404
+ if (aborted)
405
+ return undefined;
406
+ acc = { ...acc, status: "error", error: err.message };
407
+ safeSetState(acc);
408
+ captureAppEvent("app_agent_run", { alias, ok: false });
409
+ throw err;
410
+ });
411
+ }, [alias, safeSetState]);
412
+ const abort = useCallback(() => handleRef.current?.abort(), []);
413
+ return {
414
+ run,
415
+ abort,
416
+ status: state?.status ?? "idle",
417
+ text: state?.text ?? "",
418
+ steps: state?.steps ?? [],
419
+ output: state?.output,
420
+ error: state?.error,
421
+ };
422
+ }
423
+ /**
424
+ * The run history of a session, oldest-first — the persisted outputs the app
425
+ * renders as a session log (NOT a chat: a flat list of past runs). Refetch
426
+ * after a `run(...)` completes to pull in the new one.
427
+ *
428
+ * ```tsx
429
+ * const { runs } = useAgentRuns(sessionId);
430
+ * ```
431
+ */
432
+ export function useAgentRuns(sessionId, opts) {
433
+ const enabled = opts?.enabled ?? true;
434
+ const revalidateOnFocus = opts?.revalidateOnFocus ?? true;
435
+ const key = enabled && sessionId ? ["app-agent-runs", sessionId] : null;
436
+ const swr = useSWR(key, () => rpc("agentRuns", { session_id: sessionId }), swrConfig(revalidateOnFocus));
437
+ const refetch = useCallback(() => {
438
+ void swr.mutate();
439
+ }, [swr]);
440
+ return {
441
+ runs: swr.data?.runs ?? [],
442
+ loading: swr.isLoading,
443
+ error: swr.error?.message ?? null,
444
+ refetch,
445
+ };
446
+ }
@@ -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, useFieldOptions, useFileUpload, useMembers, } from "./hooks.js";
20
- export type { UploadedFile, BaseQueryOptions, QueryOptions, InfiniteQueryOptions, PaginatedQueryOptions, QuerySortKey, QueryFilter, QueryFilterCondition, QueryFilterGroup, WorkflowResult, MembersOptions, FieldOptions, FieldOptionsState, FieldOptionsOptions, } from "./hooks.js";
19
+ export { useWorkflow, useQuery, useInfiniteQuery, usePaginatedQuery, useFieldOptions, useFileUpload, useAttachments, useMembers, useAgentRun, useAgentRuns, } from "./hooks.js";
20
+ export type { UploadedFile, AttachedFile, 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, useFieldOptions, useFileUpload, useMembers, } from "./hooks.js";
18
+ export { useWorkflow, useQuery, useInfiniteQuery, usePaginatedQuery, useFieldOptions, useFileUpload, useAttachments, 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" | "field_options" | "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.
@@ -217,6 +305,8 @@ function rpcStandalone(op, payload) {
217
305
  return standaloneFieldOptions(payload);
218
306
  case "workflow":
219
307
  return standaloneWorkflow(payload);
308
+ case "agentRuns":
309
+ return standaloneAgentRuns(payload);
220
310
  case "upload":
221
311
  return standaloneUpload(payload.file);
222
312
  case "members":
@@ -300,6 +390,18 @@ async function standaloneWorkflow(p) {
300
390
  const { app_id } = await boot();
301
391
  return apiCall("POST", `/v1/apps/${app_id}/workflows/${encodeURIComponent(p.alias)}/execute`, { inputs: p.inputs }, { appId: app_id });
302
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
+ }
303
405
  async function standaloneUpload(file) {
304
406
  if (!(file instanceof File)) {
305
407
  throw new Error("upload payload must include a File");
@@ -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.34.0",
3
+ "version": "0.36.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",