@lotics/app-sdk 0.102.0 → 0.102.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md CHANGED
@@ -25,7 +25,7 @@ This file is the index. The **exact type** of anything is its shipped declaratio
25
25
  | [docs/navigation_and_state.md](./docs/navigation_and_state.md) | `AppRouter` (embedded/standalone URL model), `useUrlState` + `urlParam` codecs, `useRecents`, `useFolderPick`. |
26
26
  | [docs/ai.md](./docs/ai.md) | `useAgentRun` (structured vs free-text, streaming parts, the agent's ask-back), `askAi`, `useAiContext`, and what the member's own chat agent can do with the app while it is open. |
27
27
  | [docs/security.md](./docs/security.md) | **Read before shipping** — the owner-principal model, `is_current_member` scoping, write attribution, group gates, public-app bounds, why a per-input bound is a tenancy floor rather than an authorization check. |
28
- | [docs/runtime.md](./docs/runtime.md) | `mount()` and the errors it reports to the host (`reportAppError`), the two transports, the API address a standalone bundle reads out of its own page (`<meta name="lotics-api-base">`), `rpc()`, the mock harness (`fixture` + `?__mock=1`), `openExternal`/`openApp`/`downloadFile`, geofencing, the peer dependencies. |
28
+ | [docs/runtime.md](./docs/runtime.md) | `mount()` and the errors it reports to the host (`reportAppError`), the two transports, the API address a standalone bundle reads out of its own page (`<meta name="lotics-api-base">`), `rpc()`, the mock harness (`fixture` + `?__mock=1` — queries, workflows and agents, a mocked agent's run played line by line), `openExternal`/`openApp`/`downloadFile`, geofencing, the peer dependencies. |
29
29
 
30
30
  ## Non-negotiables (each detailed in its doc)
31
31
 
@@ -2,7 +2,8 @@
2
2
  * Folds the AI-SDK UI-message SSE stream into the `UIMessagePart[]` shape
3
3
  * `@lotics/ui` `AgentRun` renders. `ai` is imported for types only, so no `ai`
4
4
  * runtime enters the app bundle; unknown chunk types are ignored. A structured
5
- * agent's `submit_result` input becomes `output`, never a part.
5
+ * agent's `output` is the result the server accepted (`data-run-output`, sent
6
+ * before `finish`); until it arrives, `submit_result`'s input — never a part.
6
7
  */
7
8
  import type { UIMessagePart, UIDataTypes, UITools } from "ai";
8
9
  /** An ai-sdk message part — the render model, tool-set-agnostic. */
@@ -12,7 +13,7 @@ export declare const INTERACTIVE_TOOLS: ReadonlySet<string>;
12
13
  export interface AgentRunState {
13
14
  status: "streaming" | "awaiting_input" | "completed" | "error";
14
15
  parts: AgentUIPart[];
15
- /** `submit_result`'s input, never the free text — a consumer reading `output.<field>` would crash on a string. */
16
+ /** The structured result — `submit_result`'s input as it streams, the server's accepted one from `data-run-output` on; never the free text — a consumer reading `output.<field>` would crash on a string. */
16
17
  output?: unknown;
17
18
  error?: string;
18
19
  }
@@ -92,6 +93,7 @@ interface Chunk {
92
93
  output?: unknown;
93
94
  errorText?: string;
94
95
  finishReason?: string;
96
+ data?: unknown;
95
97
  }
96
98
  export declare function reduceAgentChunk(state: AgentRunState, chunk: Chunk): AgentRunState;
97
99
  /** Complete chunks so far and the partial frame to carry forward; `[DONE]` is dropped. */
package/dist/index.d.ts CHANGED
@@ -28,6 +28,7 @@ export type { ResolvedMember } from "./members.js";
28
28
  export { readSelect } from "./select.js";
29
29
  export type { ResolvedOption } from "./select.js";
30
30
  export type { AppFixture, MockExport, MockQuery, MockQueryCall, MockWorkflow } from "./mock.js";
31
+ export type { MockAgent, MockAgentRun } from "./mock_agent_run.js";
31
32
  export type { AppWorkflows, AppWorkflowResults, AppQueries, AppQueryColumns, AppAgents, AppAgentResults } from "./types.js";
32
33
  export { row, readLinks, readFiles, readLocked, readCreatedAt, readUpdatedAt, PENDING } from "./row.js";
33
34
  export type { ResolvedLink, AppFile } from "./row.js";
package/dist/index.js CHANGED
@@ -59,6 +59,10 @@ function getMockWorkflow(alias) {
59
59
  if (!isMockMode()) return null;
60
60
  return registeredFixture?.workflows?.[alias] ?? null;
61
61
  }
62
+ function getMockAgent(alias) {
63
+ if (!isMockMode()) return null;
64
+ return registeredFixture?.agents?.[alias] ?? null;
65
+ }
62
66
  function getMockExport(alias) {
63
67
  if (!isMockMode()) return null;
64
68
  return registeredFixture?.exports?.[alias] ?? null;
@@ -91,8 +95,8 @@ function reportAppError(kind, error51) {
91
95
  function firstFrame(error51, stack) {
92
96
  const header = String(error51);
93
97
  const frames = stack.startsWith(header) ? stack.slice(header.length) : stack;
94
- const frame = frames.split("\n").find((line) => line.trim() !== "");
95
- return frame === void 0 ? null : withoutOrigins(frame.trim()).slice(0, MESSAGE_LIMIT);
98
+ const frame2 = frames.split("\n").find((line) => line.trim() !== "");
99
+ return frame2 === void 0 ? null : withoutOrigins(frame2.trim()).slice(0, MESSAGE_LIMIT);
96
100
  }
97
101
  function withoutOrigins(text2) {
98
102
  return text2.replace(/\b[a-z][a-z0-9+.-]*:\/\/[^/\s)]+/gi, "").replace(/[?#][^\s)]*?(?=(?::\d+){1,2}\)?(?:\s|$)|[\s)]|$)/g, "");
@@ -387,6 +391,8 @@ function reduceAgentChunk(state, chunk) {
387
391
  return { ...state, parts: updateTool(state.parts, chunk.toolCallId, (p) => ({ type: "dynamic-tool", toolName: p.toolName, toolCallId: p.toolCallId, state: "output-available", input: p.input, output: chunk.output })) };
388
392
  case "tool-output-error":
389
393
  return { ...state, parts: updateTool(state.parts, chunk.toolCallId, (p) => ({ type: "dynamic-tool", toolName: p.toolName, toolCallId: p.toolCallId, state: "output-error", input: p.input, errorText: chunk.errorText ?? "The tool failed." })) };
394
+ case "data-run-output":
395
+ return chunk.data !== null && typeof chunk.data === "object" ? { ...state, output: chunk.data } : state;
390
396
  case "error":
391
397
  return { ...state, status: "error", error: chunk.errorText ?? "The run failed." };
392
398
  case "abort":
@@ -415,8 +421,8 @@ function parseSseChunks(buffer) {
415
421
  const chunks = [];
416
422
  const frames = buffer.split("\n\n");
417
423
  const rest = frames.pop() ?? "";
418
- for (const frame of frames) {
419
- for (const line of frame.split("\n")) {
424
+ for (const frame2 of frames) {
425
+ for (const line of frame2.split("\n")) {
420
426
  const trimmed = line.startsWith("data:") ? line.slice(5).trim() : "";
421
427
  if (!trimmed || trimmed === "[DONE]") continue;
422
428
  try {
@@ -444,6 +450,39 @@ function landingOf(state) {
444
450
  }
445
451
  }
446
452
 
453
+ // src/mock_agent_run.ts
454
+ var BEAT_MS = 700;
455
+ var frame = (chunk) => `data: ${JSON.stringify(chunk)}
456
+
457
+ `;
458
+ function playMockRun(run2, onText, beat = BEAT_MS) {
459
+ const verdict = run2.error !== void 0 ? frame({ type: "error", errorText: run2.error }) : frame({ type: "data-run-output", data: run2.output ?? {} });
460
+ const beats = [...(run2.steps ?? []).map((line) => frame({ type: "text-delta", delta: `${line}
461
+
462
+ ` })), `${verdict}${frame({ type: "finish" })}`];
463
+ let timer;
464
+ let stop = () => {
465
+ };
466
+ const done = new Promise((resolve) => {
467
+ stop = resolve;
468
+ const play = (at2) => {
469
+ timer = setTimeout(() => {
470
+ onText(beats[at2] ?? "");
471
+ if (at2 === beats.length - 1) resolve();
472
+ else play(at2 + 1);
473
+ }, beat);
474
+ };
475
+ play(0);
476
+ });
477
+ return {
478
+ done,
479
+ abort: () => {
480
+ clearTimeout(timer);
481
+ stop();
482
+ }
483
+ };
484
+ }
485
+
447
486
  // src/store.ts
448
487
  import { useCallback as useCallback2, useEffect as useEffect2, useRef, useSyncExternalStore } from "react";
449
488
  var slots = /* @__PURE__ */ new Map();
@@ -25313,12 +25352,12 @@ async function evaluateWorkflowExpression(expr2, ctx) {
25313
25352
  }
25314
25353
  function evaluateLambda(params, body, ctx, items) {
25315
25354
  return async (...args) => {
25316
- const frame = {};
25355
+ const frame2 = {};
25317
25356
  for (let i = 0; i < params.length; i++) {
25318
- frame[params[i]] = args[i];
25357
+ frame2[params[i]] = args[i];
25319
25358
  }
25320
25359
  const stack = ctx.lambda_params ?? [];
25321
- const newStack = [...stack, frame];
25360
+ const newStack = [...stack, frame2];
25322
25361
  const innerCtx = {
25323
25362
  ...ctx,
25324
25363
  lambda_params: newStack,
@@ -25401,8 +25440,8 @@ function resolveReadRoot(source, ctx) {
25401
25440
  const stack = ctx.lambda_params;
25402
25441
  if (stack !== void 0) {
25403
25442
  for (let i = stack.length - 1; i >= 0; i--) {
25404
- const frame = stack[i];
25405
- if (source.name in frame) return frame[source.name];
25443
+ const frame2 = stack[i];
25444
+ if (source.name in frame2) return frame2[source.name];
25406
25445
  }
25407
25446
  }
25408
25447
  throw new WorkflowEvalError(
@@ -25414,8 +25453,8 @@ function resolveReadRoot(source, ctx) {
25414
25453
  const stack = ctx.lexical_bindings;
25415
25454
  if (stack !== void 0) {
25416
25455
  for (let i = stack.length - 1; i >= 0; i--) {
25417
- const frame = stack[i];
25418
- if (source.name in frame) return frame[source.name];
25456
+ const frame2 = stack[i];
25457
+ if (source.name in frame2) return frame2[source.name];
25419
25458
  }
25420
25459
  }
25421
25460
  throw new WorkflowEvalError(
@@ -25453,8 +25492,8 @@ function resolveForeachFrame(ctx, name, at2) {
25453
25492
  at: at2
25454
25493
  });
25455
25494
  }
25456
- const frame = resolveNamedForeachFrame(stack, name);
25457
- if (frame !== void 0) return frame;
25495
+ const frame2 = resolveNamedForeachFrame(stack, name);
25496
+ if (frame2 !== void 0) return frame2;
25458
25497
  const known = stack.map((f) => f.name).filter((n) => n !== void 0);
25459
25498
  throw new WorkflowEvalError(
25460
25499
  `Read from ${at2} "${name}", but no enclosing foreach binds that name` + (known.length > 0 ? ` (in scope: ${known.join(", ")})` : ""),
@@ -27104,15 +27143,15 @@ async function predictRead(walk, step) {
27104
27143
  if (typeof id !== "string" || record2 === void 0) return unknownAt(walk.ctx.step_outputs, step.id);
27105
27144
  walk.ctx.step_outputs[step.id] = { output: modelled(step.id, { id, table_id: record2.table_id, data: knownCells(walk.input.tables, record2.table_id, record2.cells) }) };
27106
27145
  }
27107
- function bindLexical(frame, name, held2) {
27108
- if (held2 === UNKNOWN) return unknownAt(frame, name);
27109
- Object.defineProperty(frame, name, { enumerable: true, configurable: true, writable: true, value: held2 });
27146
+ function bindLexical(frame2, name, held2) {
27147
+ if (held2 === UNKNOWN) return unknownAt(frame2, name);
27148
+ Object.defineProperty(frame2, name, { enumerable: true, configurable: true, writable: true, value: held2 });
27110
27149
  }
27111
27150
  function setLexical(walk, name, held2) {
27112
27151
  const stack = walk.ctx.lexical_bindings ?? [];
27113
27152
  for (let at2 = stack.length - 1; at2 >= 0; at2--) {
27114
- const frame = stack[at2];
27115
- if (frame !== void 0 && Object.prototype.hasOwnProperty.call(frame, name)) return bindLexical(frame, name, held2);
27153
+ const frame2 = stack[at2];
27154
+ if (frame2 !== void 0 && Object.prototype.hasOwnProperty.call(frame2, name)) return bindLexical(frame2, name, held2);
27116
27155
  }
27117
27156
  }
27118
27157
  async function block(walk, steps) {
@@ -27179,8 +27218,8 @@ async function run(walk, step) {
27179
27218
  return;
27180
27219
  }
27181
27220
  case "let_declare": {
27182
- const frame = walk.ctx.lexical_bindings?.at(-1);
27183
- if (frame !== void 0) bindLexical(frame, step.name, await attempt(walk, expr(step.initial)));
27221
+ const frame2 = walk.ctx.lexical_bindings?.at(-1);
27222
+ if (frame2 !== void 0) bindLexical(frame2, step.name, await attempt(walk, expr(step.initial)));
27184
27223
  return;
27185
27224
  }
27186
27225
  case "assign":
@@ -28136,10 +28175,11 @@ function useAgentRun(alias) {
28136
28175
  }
28137
28176
  handleRef.current?.abort();
28138
28177
  runIdRef.current = null;
28178
+ const mock = getMockAgent(alias);
28139
28179
  const inflight = streamLeg(
28140
- (onText) => rpcAgentRun({ alias, session_id: opts.sessionId, input: input ?? {} }, onText, (runId) => {
28180
+ (onText) => mock === null ? rpcAgentRun({ alias, session_id: opts.sessionId, input: input ?? {} }, onText, (runId) => {
28141
28181
  runIdRef.current = runId;
28142
- }),
28182
+ }) : playMockRun(typeof mock === "function" ? mock(input ?? {}) : mock, onText),
28143
28183
  initialAgentRunState()
28144
28184
  );
28145
28185
  const tracked = inflight.finally(() => {
package/dist/mock.d.ts CHANGED
@@ -2,11 +2,13 @@
2
2
  * Design-time fixtures, active only with BOTH `mount(<App />, { fixture })`
3
3
  * and the `?__mock=1` URL param, so demo data in a bundle never answers real
4
4
  * traffic. A mocked workflow never runs, so it writes nothing and nothing is
5
- * drawn ahead of it; a success re-reads every mounted read.
5
+ * drawn ahead of it; a success re-reads every mounted read. A mocked agent is
6
+ * played, never run: its lines, then its result or its error.
6
7
  * `useFileUpload` is not mocked.
7
8
  */
8
9
  import type { QueryAggregate } from "./shared_types.js";
9
10
  import type { UploadedFile, WorkflowResult } from "./hooks.js";
11
+ import type { MockAgent } from "./mock_agent_run.js";
10
12
  import type { ExportCall, QuerySortKey } from "./queries.js";
11
13
  import type { RecordingState } from "./recording_state.js";
12
14
  /** A result, or a function of the inputs — return a slow promise to review the pending state. */
@@ -29,6 +31,8 @@ export type MockExport = (call: ExportCall & {
29
31
  export interface AppFixture {
30
32
  queries?: Record<string, MockQuery>;
31
33
  workflows?: Record<string, MockWorkflow>;
34
+ /** `useAgentRun(alias)` plays the run — or the one made of its input — and never parks. */
35
+ agents?: Record<string, MockAgent>;
32
36
  /** By the query exported. */
33
37
  exports?: Record<string, MockExport>;
34
38
  /** `useRecording(alias)` reads as available; `start`/`stop` change nothing. */
@@ -41,6 +45,8 @@ export declare function hasMockFlag(): boolean;
41
45
  export declare function getMockRows(alias: string, call: MockQueryCall): Array<Record<string, unknown>> | Promise<Array<Record<string, unknown>>> | null;
42
46
  /** Null for an unmocked alias, which executes for real. */
43
47
  export declare function getMockWorkflow(alias: string): MockWorkflow | null;
48
+ /** Null for an unmocked alias, which runs for real. */
49
+ export declare function getMockAgent(alias: string): MockAgent | null;
44
50
  /** Null for an unmocked alias, which the server exports. */
45
51
  export declare function getMockExport(alias: string): MockExport | null;
46
52
  export declare function getMockRecordings(): Record<string, RecordingState> | null;
@@ -0,0 +1,23 @@
1
+ /**
2
+ * A RUN PLAYED WITHOUT A MODEL, for design time: the lines a run writes as it works, one a beat, then how it ends — the
3
+ * result the server accepted, or the error it failed with — streamed as the server streams a run, so the one reducer
4
+ * that reads a real run reads this one.
5
+ */
6
+ export interface MockAgentRun {
7
+ /** What the run writes as it works, a line a beat. */
8
+ steps?: readonly string[];
9
+ /** A structured agent's result, as the server hands it once it accepted it. */
10
+ output?: Record<string, unknown>;
11
+ /** The run fails with these words instead, its output never sent. */
12
+ error?: string;
13
+ }
14
+ /** A run, or one made of what the run was handed. */
15
+ export type MockAgent = MockAgentRun | ((input: Record<string, unknown>) => MockAgentRun);
16
+ /**
17
+ * Plays `run` into `onText` as `rpcAgentRun` streams a real one: a line each `beat`, then its verdict — the error, or
18
+ * the accepted output — with `finish`, as the server sends them. `abort` stops it where it stands.
19
+ */
20
+ export declare function playMockRun(run: MockAgentRun, onText: (chunk: string) => void, beat?: number): {
21
+ done: Promise<void>;
22
+ abort: () => void;
23
+ };
package/docs/ai.md CHANGED
@@ -65,7 +65,7 @@ await recognize.run({ image_file_id: fileId }, { sessionId });
65
65
  | `answerChoice` | `(answers: {value, custom}[]) => Promise<AgentRunLanding<TOutput>>` | Answer the pending question(s) and CONTINUE the run — one entry per question, aligned by index (exactly what `ClarifyWizard`'s `onSubmit` yields). Streams the continuation into the same `parts`; resolves like `run`. Rejects when nothing is pending — and when the server refuses the answer, in which case the pending question is restored for a retry |
66
66
  | `parts` | `AgentUIPart[]` | The ordered live transcript as **ai-sdk `UIMessage.parts`** — answer prose, thinking, and tool calls, in stream order. The single source of truth for the feed; hand it straight to `@lotics/ui` `AgentRun` |
67
67
  | `text` | `string` | The agent's **answer prose** (every `text` part concatenated), accumulating live. Excludes thinking. For a free-text agent this IS the result |
68
- | `output` | `TOutput \| undefined` | The structured result once the run completes. `undefined` when the run produced none |
68
+ | `output` | `TOutput \| undefined` | The structured result the server accepted — read it once `status` is `"completed"`. `undefined` for a free-text agent |
69
69
  | `error` | `string \| undefined` | The failure message when `status` is `"error"` |
70
70
 
71
71
  ### The `parts` transcript → `@lotics/ui` `AgentRun`
@@ -78,7 +78,7 @@ await recognize.run({ image_file_id: fileId }, { sessionId });
78
78
  | `"reasoning"` | `text`, `state` | The agent's thinking — a **distinct part**, kept out of `text`, so `AgentRun` shows it collapsed / revealed on demand. Only produced when the agent's declared model supports thinking (**Sonnet / Opus**, not Haiku) — a Haiku agent never emits reasoning |
79
79
  | `"dynamic-tool"` | `toolName`, `toolCallId`, `state` (ai's tool lifecycle — `input-available` → `output-available` / `output-error`), `input`, `output`, `errorText` | One tool call — opens running the moment it fires, settles when its result arrives. `input`/`output` ride along for `AgentRun`'s on-demand reveal, not for the feed row |
80
80
 
81
- The terminal `submit_result` call is captured into `output`, **not** rendered as a tool part (structured agents therefore have no visible final step). Source/file parts aren't emitted by app agents. `parts` is exactly what `@lotics/ui` `AgentRun` renders, so the pairing needs **no adapter and no hand-assembly**:
81
+ The terminal `submit_result` call is **not** rendered as a tool part (structured agents therefore have no visible final step); `output` is the result the server accepted, sent as the run finishes. Source/file parts aren't emitted by app agents. `parts` is exactly what `@lotics/ui` `AgentRun` renders, so the pairing needs **no adapter and no hand-assembly**:
82
82
 
83
83
  ```tsx
84
84
  <AgentRun
@@ -89,7 +89,7 @@ The terminal `submit_result` call is captured into `output`, **not** rendered as
89
89
  />
90
90
  ```
91
91
 
92
- `AgentRun` renders thinking collapsed, groups consecutive tool calls, and expands each tool's input/output in place on press — all for free. Running it in a bounded container (a dialog, a panel)? Wrap it in `@lotics/ui`'s `FollowScroll` so the container follows the stream instead of letting new content grow below the fold. **Errors surface two ways:** a per-tool failure is an `output-error` part (amber dot, reason in the expanded Error panel — the feed flows on, exactly like a run that retried and recovered); a **breaking** error that killed the run lives in `run.error` (not in `parts`) — pass it as `error` and it renders as a terminal danger row. Never rebuild this feed by hand.
92
+ `AgentRun` renders thinking collapsed, groups consecutive tool calls, and expands each tool's input/output in place on press — all for free. Running it in a bounded container (a dialog, a panel)? Wrap it in `@lotics/ui`'s `FollowScroll` so the container follows the stream instead of letting new content grow below the fold. **Errors surface two ways:** a per-tool failure is an `output-error` part (amber dot, reason in the expanded Error panel — the feed flows on, exactly like a run that retried and recovered); a **breaking** error that killed the run lives in `run.error` (not in `parts`) — pass it as `error` and it renders as a terminal danger row. Never rebuild this feed by hand. To see its live, done and failed states without a run, mock the agent (`fixture.agents`, [runtime](./runtime.md)): the run is played through the same stream reader, never billed.
93
93
 
94
94
  `run.error` is always a **sentence to show a person**, never a payload — on both the ways a run can fail.
95
95
 
@@ -139,7 +139,7 @@ answer may park again.
139
139
 
140
140
  ### `output` typing and the inner-field caveat
141
141
 
142
- The server validates the agent's submitted result strictly (unknown keys rejected, required fields present, types checked, `select` values bound to the declared options); an invalid submission is rejected back to the agent with the validation errors, and the agent retries. A run only settles **completed** with a result that passed this — so on a completed run, `output.items.map(...)` on a declared array field is safe. What that does **not** guarantee:
142
+ The server validates the agent's submitted result strictly (unknown keys rejected, required fields present, types checked, `select` values bound to the declared options); an invalid submission is rejected back to the agent with the validation errors, and the agent retries. A submission whose JSON arrived broken is not retried: the agent would rewrite it from memory and drop fields, so the run fails instead. A run only settles **completed** with a result that passed this — so on a completed run, `output.items.map(...)` on a declared array field is safe. What that does **not** guarantee:
143
143
 
144
144
  - a field declared `required: false` may be absent;
145
145
  - a `json`-typed output field is passthrough — anything goes inside it;
@@ -147,21 +147,21 @@ The server validates the agent's submitted result strictly (unknown keys rejecte
147
147
  timestamp, an address shape) but not for being the *right* value — a parseable date can still
148
148
  be the wrong date;
149
149
  - every **value** was authored by the model — a schema-valid string can still be semantically wrong;
150
- - one live-stream corner: the hook adopts `output` from the submit call's **arguments as they stream**, before the server-side validation runs. Normally the loud rejection makes the agent retry and the last (valid) submit overwrites it — but a run whose *final* submit was rejected ends the live stream with that invalid attempt still in `output` (the persisted run settles as an error). The review surface below is the backstop.
150
+ - while the run streams, `output` holds the submit call's **arguments** as they arrive, before the server has checked them — only a `completed` run's `output` is the accepted result.
151
151
 
152
152
  Treat `output` as a trusted *shape* carrying untrusted *values*: render it into a review surface and let the user confirm before a workflow commits it. Never write model output straight to records without a review step.
153
153
 
154
- ### Errors — one landing, and one quiet mode
154
+ ### Errors — one landing
155
155
 
156
156
  A run that cannot start (AI-credit quota exhausted, the per-member concurrency cap, an undeclared alias, input validation, a network error) and one that fails mid-run both **resolve** `{kind:"failed", error}`, with `status` `"error"`. Only `answerChoice` rejects, when the server refuses an answer.
157
157
 
158
158
  ```tsx
159
159
  const landing = await recognize.run(input, { sessionId });
160
160
  if (landing.kind === "failed") { /* surface landing.error */ }
161
- else if (landing.kind === "settled" && landing.output === undefined) { /* no result */ }
161
+ else if (landing.kind === "settled") { /* review landing.output */ }
162
162
  ```
163
163
 
164
- A capped run's live message is a generic "The run was stopped." (the persisted run carries "Run exceeded the 20-minute limit."). Two modes are **quiet on the live stream**: a structured agent that finished without ever submitting a result, and a free-text agent that produced no text, land `settled` with no result — only the *persisted* run settles as an error. That is why the example checks `output === undefined`.
164
+ The stream ends on the persisted run's outcome, so the landing and the run never disagree: a structured agent that finished without a valid result, one whose result arrived unreadable, and a free-text agent that produced no text all land `failed`, with the message the run carries (a capped run's "Run exceeded the 20-minute limit." included).
165
165
 
166
166
  ### `cancel` vs `abort`
167
167
 
package/docs/queries.md CHANGED
@@ -234,7 +234,7 @@ supports the full §5 operator matrix **except**: `traversal` nodes, `locked`, a
234
234
  `record_id` works on any row-level derived query (rejected over a `group`, which has no row
235
235
  identity). When a `filter` directly wraps a `union` of `project(from_table)` arms and every
236
236
  condition targets bare passthrough columns, the engine pushes the predicate into each arm's
237
- source filter automatically (making it index-servable); otherwise it evaluates post-union.
237
+ source filter automatically, where it runs once per record; otherwise it evaluates post-union.
238
238
 
239
239
  ### `join` — combine two row sets
240
240
 
@@ -545,9 +545,9 @@ Negation-shaped inner operators (`has_none_of`, `not_equals`, `is_none_of`,
545
545
 
546
546
  Traversals are **source-layer only** (rejected on derived columns — push them into
547
547
  `from_table.filter`). Each hop reaches the linked rows by primary key from the ids in the link
548
- cell. Under an OR beside column conditions a traversal runs once per row and takes the column
549
- conditions off their indexes with it; give it a `union` arm of its own when the other arms must
550
- stay index-served. Two access classes:
548
+ cell. Under an OR beside column conditions a traversal runs once per row and takes a membership
549
+ condition off its index with it; give it a `union` arm of its own when the other arms must keep
550
+ theirs. Two access classes:
551
551
 
552
552
  - **Self-scoped** — inner operator `is_current_member` / `is_not_current_member`: tests only
553
553
  the viewer's own membership on the linked row, leaks nothing, and is exempt from the linked
@@ -871,7 +871,7 @@ ordering and scoping into the template or its params.
871
871
  Any other execution failure returns a generic `query execution failed` (the real error — which
872
872
  may embed SQL — is server-logged only). Bind-time and validation errors are always specific.
873
873
 
874
- ### Index reality, in plain terms
874
+ ### What a query reads, in plain terms
875
875
 
876
876
  Records are stored partitioned by workspace, so a query reads only its own workspace's slice; a
877
877
  `from_table` narrows that slice to the table's rows, and within them:
@@ -879,39 +879,18 @@ Records are stored partitioned by workspace, so a query reads only its own works
879
879
  - **GIN-served (fast at any size):** *positive* exact-membership filters at the **source
880
880
  layer** — `select`/`select_member`/`select_record_link` `has_any_of` / `has_all_of`, and
881
881
  `is_current_member`. These compile to containment the JSONB GIN index serves.
882
- - **Trigram-served:** `from_table.search` (§7).
883
- - **B-tree-served (automatic for bound queries):** text `equals`, number and date
884
- comparisons (exact and range), `from_table.sort` fields, and a one-key `sort` node over a
885
- number or date field the rows pass unchanged — for fields referenced in a **bound named
886
- query's template**. The platform provisions a partial expression index per referenced
887
- field automatically: built online whenever `set_app_query` or `set_app_queries` binds a query, re-synced daily,
888
- capped at 8 per table (fields past the cap fall back to the scan tier, with a server WARN).
889
- A `{{params.…}}` value hole doesn't change this — the field key is static in the template,
890
- so it still gets its index. Index-seek speed at any table size once provisioned.
891
- - **Table scan (linear in table size):** everything else — text `contains`, negations
892
- (`has_none_of`, `is_none_of`, `not_*`), emptiness, files predicates, and predicates/sorts on
893
- fields that appear **only** in the runtime `filter`/`sort` options rather than the bound
894
- template. Fine on thousands of rows; on very large tables these dominate latency and are the
895
- usual timeout cause.
896
-
897
- **Filter shape drives latency.** Equality/range/sort predicates in the bound template are
898
- index-served; lead with those or a GIN-served membership filter / `search`, and let scan-shaped
899
- predicates refine the already-narrowed set. Derived-layer filters run over the subquery result
900
- (no index), so **filter at the source layer whenever the field exists there** — the runtime
901
- filter is for caller-driven refinement, not for the main cut (a runtime-only field gets no
902
- managed index). (The engine pushes eligible filter-over-union predicates down automatically,
903
- but don't rely on that for other shapes.)
904
-
905
- **A sorted page reads in its sort index — declare the order it is read in.** A page sorted by one
906
- column that passes a number or date field unchanged (a bare `project` source with no `type`
907
- override, an `unpivot` passthrough, or a row column that reads the same field in every row) reads
908
- the table in that field's sort index and stops at the page, instead of reading every row and
909
- sorting. Over a `union`, each arm is paged this way on its own and the arms are merged, so a
910
- register over several tables or `unpivot` sides is fast at any size — don't hand-split it into
911
- per-table queries you merge client-side. The index exists when a bound template sorts that field
912
- in the same direction and blank position: give the query its default order as a top-level `sort`,
913
- and a page the app sorts the same way is served. A text column, a computed, literal or cast column,
914
- a sort on more than one key, and a cursor (`keyset`) page sort every row.
882
+ - **Trigram-served:** `from_table.search` (§7), within the one table.
883
+ - **Every row of the table:** everything else — text equality and `contains`, number and date
884
+ comparisons, sorts, negations, emptiness, files predicates. Fine on thousands of rows; the cost
885
+ grows with the table, and on very large tables these dominate latency and are the usual
886
+ timeout cause.
887
+
888
+ **Filter shape drives latency.** Lead with a GIN-served membership filter or `search`, which
889
+ narrow the table before its rows are read, and let the other conditions refine that set.
890
+ Derived-layer filters run over the subquery result, after any `unpivot` fan-out, so **filter at
891
+ the source layer whenever the field exists there** — the runtime filter is for caller-driven
892
+ refinement, not for the main cut. (The engine moves eligible filter-over-union predicates into
893
+ each arm's source automatically, but don't rely on that for other shapes.)
915
894
 
916
895
  **An aggregate arm you cannot filter costs its whole table, every execution.** A `join` whose right
917
896
  side is a `group` over entire tables, keyed on a value the LEFT side supplies at run time, has no
package/docs/runtime.md CHANGED
@@ -54,6 +54,12 @@ mount(<App />, {
54
54
  ? { status: "error", message: "Row 2 has no customer." }
55
55
  : new Promise((r) => setTimeout(() => r({ status: "success" }), 1200)),
56
56
  },
57
+ agents: {
58
+ // A run's lines, then the result it lands — or a FUNCTION of the run's input.
59
+ recognize: { steps: ["Reading the invoice."], output: { total: 1250 } },
60
+ // Or how it fails.
61
+ triage: { error: "The run ended without a result. Run it again." },
62
+ },
57
63
  },
58
64
  });
59
65
  ```
@@ -62,7 +68,7 @@ Activation is a **two-step gate** — both must hold, so demo data shipping in t
62
68
  bundle never leaks into normal traffic:
63
69
 
64
70
  1. A fixture is registered via `mount({ fixture })` (`AppFixture` type:
65
- `dist/mock.d.ts` — `{ queries?, workflows?, exports?, recordings? }`).
71
+ `dist/mock.d.ts` — `{ queries?, workflows?, agents?, exports?, recordings? }`).
66
72
  2. The page URL carries `?__mock=1` (exactly `1`). Without the flag the fixture
67
73
  is completely inert.
68
74
 
@@ -99,12 +105,17 @@ pending state. Calling `mount` again (HMR) replaces the registration last-write-
99
105
  **Reach the in-flight state with a function** that resolves on a timer: an app
100
106
  whose only AI surface is a workflow calling `agent(...)` otherwise has no
101
107
  non-billing path to its own thinking / done / error screens.
108
+ - **A mocked agent is PLAYED, never run.** `useAgentRun(alias)` streams the
109
+ fixture's `steps` a line a beat, then lands its `output` — or fails with its
110
+ `error` — through the same stream reader a real run goes through, so the pane's
111
+ live, done and failed states are reviewable with no spend. It never parks on a
112
+ question, and there is no run to cancel on the server.
102
113
  - **A mocked recording is a canned state.** `recordings: { log_visit: { phase:
103
114
  "live", elapsed_seconds: 754, inputs: { site: "rec_1" } } }` makes
104
115
  `useRecording("log_visit")` available with that `state`; `start` and `stop`
105
116
  resolve without recording anything or moving the state.
106
- - **Not mocked:** uploads, `useFieldOptions`, members, comments, agent runs. In
107
- mock mode those still hit the real transport.
117
+ - **Not mocked:** uploads, `useFieldOptions`, members, comments. In mock mode
118
+ those still hit the real transport.
108
119
  - **Analytics is disabled** whenever `?__mock=1` is present, fixture or not — a
109
120
  screenshot/design-time load emits no events.
110
121
  - The `__mock` param-name prefix is reserved by the SDK; don't use it for
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/app-sdk",
3
- "version": "0.102.0",
3
+ "version": "0.102.2",
4
4
  "description": "The SDK a Lotics custom-code app reads and writes through \u2014 typed hooks over the host bridge, cell readers, mount() and AppRouter",
5
5
  "type": "module",
6
6
  "exports": {