@lotics/app-sdk 0.52.4 → 0.54.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md CHANGED
@@ -20,7 +20,7 @@ signature; open the file.**
20
20
  | [docs/files.md](./docs/files.md) | Files end to end — `useFileUpload`, `useAttachments`, `readFiles`/presigned URLs, workflow-generated files, preview pairing, filter operators, the server-side delivery bounds. |
21
21
  | [docs/members_and_options.md](./docs/members_and_options.md) | People + select options + comments — `useMembers`, `useFieldOptions`, `useViewer`, `useComments`, and the `@lotics/ui` components they feed. |
22
22
  | [docs/navigation_and_state.md](./docs/navigation_and_state.md) | `AppRouter` (embedded/standalone URL model), `useUrlState` + `urlParam` codecs, `useRecents`. |
23
- | [docs/ai.md](./docs/ai.md) | `useAgentRun` (structured vs free-text, streaming `items` → `AgentRun`), `askAi` — plus the fields-vs-file razor for choosing between them — and `useAiContext` (push the current screen's view state to the member's ambient chat agent; caps, push-only semantics, auto query-refetch on chat mutation). |
23
+ | [docs/ai.md](./docs/ai.md) | `useAgentRun` (structured vs free-text, streaming ai-sdk `parts` → `AgentRun`), `askAi` — plus the fields-vs-file razor for choosing between them — and `useAiContext` (push the current screen's view state to the member's ambient chat agent; caps, push-only semantics, auto query-refetch on chat mutation). |
24
24
  | [docs/security.md](./docs/security.md) | **Read before shipping** — the owner-principal model, `is_current_member` scoping, write attribution, group gates, public-app bounds, what runtime refinement cannot widen. |
25
25
  | [docs/runtime.md](./docs/runtime.md) | `mount()`, the two transports, `rpc()`, `openExternal`/`downloadFile`, geofencing, analytics, `useConfig` (App-Packages installation config), `getAppBinding` (package apps' runtime `F`/`OPT`/`ROLE` resolution via the generated `.lotics/app_fields.ts`), and the publish chain for package contributors. |
26
26
 
@@ -3,63 +3,28 @@
3
3
  *
4
4
  * The backend streams the AI-SDK "UI message" SSE protocol (the same wire format
5
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.
6
+ * `data: [DONE]`. We fold the chunk types that matter into an ordered `parts` array
7
+ * the SAME ai-sdk `UIMessagePart[]` shape `@lotics/ui`'s `AgentRun` renders, so
8
+ * `<AgentRun parts={run.parts} />` needs no adapter. `ai` is a TYPE-ONLY import here
9
+ * (tool calls are hand-built as concrete `dynamic-tool` parts); NO `ai` runtime
10
+ * enters the sandboxed app bundle. Unknown chunk types are ignored, so a newer
11
+ * AI-SDK never breaks the SDK.
10
12
  *
11
- * Chunks fold into an ordered `items` transcript answer prose, thinking
12
- * (`reasoning`, kept a distinct segment so the UI can collapse it), and tool steps
13
- * (each carrying its `input`/`output`/`status` for an on-demand reveal), in stream
14
- * order that feeds `@lotics/ui` `AgentRun` directly. An agent can be either kind:
15
- * a STRUCTURED agent (declares `outputs`) emits its result as the input of the
16
- * injected `submit_result` tool → `output`; a FREE-TEXT agent's result is its prose
17
- * → the transcript's text. `output` is NEVER the free-text (see
18
- * `AgentRunState.output`). Pure + reducer-shaped so it is fully unit-testable
19
- * against recorded frames — no live model needed.
13
+ * An agent can be either kind: a STRUCTURED agent (declares `outputs`) emits its
14
+ * result as the input of the injected `submit_result` tool `output` (NOT a part);
15
+ * a FREE-TEXT agent's result is its prose → a text part. `output` is NEVER the
16
+ * free-text (see `AgentRunState.output`). Pure + reducer-shaped so it is fully
17
+ * unit-testable against recorded frames no live model needed.
20
18
  */
21
- export interface AgentRunStep {
22
- id: string;
23
- label: string;
24
- detail?: string;
25
- status: "running" | "done" | "error";
26
- kind?: "step" | "tool";
27
- /** The tool call's input (arguments). Carried for on-demand reveal, NOT shown in
28
- * the feed — `@lotics/ui` `AgentRun` renders it in a press-to-open peek. */
29
- input?: unknown;
30
- /** The tool call's result, once it returns. On-demand reveal, same as `input`. */
31
- output?: unknown;
32
- /** The tool failure message, when `status` is `"error"`. */
33
- errorText?: string;
34
- /** Running count of streamed argument characters. While a tool call's arguments
35
- * are still generating, the reducer writes a live size into `detail` ("18 KB")
36
- * off this accumulator, so a long generation reads as progressing, not frozen;
37
- * both are cleared when the call settles. */
38
- streamedChars?: number;
39
- }
40
- /** One entry in the ordered run transcript, in the order it streamed: the agent's
41
- * answer prose (`text`), its thinking (`reasoning` — shown collapsed, revealed on
42
- * demand), or a tool it ran (`step`). This is structurally the `AgentRunItem` that
43
- * `@lotics/ui`'s `AgentRun` takes, so `<AgentRun items={run.items} state={…} />`
44
- * needs no adapter — the SDK can't import the UI type (it must stay off the
45
- * react-native-web dep tree), it mirrors the shape. */
46
- export type AgentRunItem = {
47
- type: "text";
48
- id: string;
49
- text: string;
50
- } | {
51
- type: "reasoning";
52
- id: string;
53
- text: string;
54
- } | ({
55
- type: "step";
56
- } & AgentRunStep);
19
+ import type { UIMessagePart, UIDataTypes, UITools } from "ai";
20
+ /** An ai-sdk message part — the render model, tool-set-agnostic. */
21
+ export type AgentUIPart = UIMessagePart<UIDataTypes, UITools>;
57
22
  export interface AgentRunState {
58
23
  status: "streaming" | "completed" | "error";
59
- /** The ordered transcript — prose interleaved with the tool steps, as it
60
- * streamed. The single source of truth for the feed; `useAgentRun` derives the
61
- * backward-compat `text` / `steps` from it. */
62
- items: AgentRunItem[];
24
+ /** The ordered transcript as ai-sdk `parts` — prose interleaved with the tool
25
+ * calls (`dynamic-tool` parts), as it streamed. The single source of truth for
26
+ * the feed; `useAgentRun` derives `text` / `steps` from it. */
27
+ parts: AgentUIPart[];
63
28
  /** The structured result — the `submit_result` tool's input — or `undefined`.
64
29
  * NEVER the free-text answer: a free-text agent (or a structured one that
65
30
  * finished without submitting) has no `output`; its answer is the transcript's
@@ -3,20 +3,18 @@
3
3
  *
4
4
  * The backend streams the AI-SDK "UI message" SSE protocol (the same wire format
5
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.
6
+ * `data: [DONE]`. We fold the chunk types that matter into an ordered `parts` array
7
+ * the SAME ai-sdk `UIMessagePart[]` shape `@lotics/ui`'s `AgentRun` renders, so
8
+ * `<AgentRun parts={run.parts} />` needs no adapter. `ai` is a TYPE-ONLY import here
9
+ * (tool calls are hand-built as concrete `dynamic-tool` parts); NO `ai` runtime
10
+ * enters the sandboxed app bundle. Unknown chunk types are ignored, so a newer
11
+ * AI-SDK never breaks the SDK.
10
12
  *
11
- * Chunks fold into an ordered `items` transcript answer prose, thinking
12
- * (`reasoning`, kept a distinct segment so the UI can collapse it), and tool steps
13
- * (each carrying its `input`/`output`/`status` for an on-demand reveal), in stream
14
- * order that feeds `@lotics/ui` `AgentRun` directly. An agent can be either kind:
15
- * a STRUCTURED agent (declares `outputs`) emits its result as the input of the
16
- * injected `submit_result` tool → `output`; a FREE-TEXT agent's result is its prose
17
- * → the transcript's text. `output` is NEVER the free-text (see
18
- * `AgentRunState.output`). Pure + reducer-shaped so it is fully unit-testable
19
- * against recorded frames — no live model needed.
13
+ * An agent can be either kind: a STRUCTURED agent (declares `outputs`) emits its
14
+ * result as the input of the injected `submit_result` tool `output` (NOT a part);
15
+ * a FREE-TEXT agent's result is its prose → a text part. `output` is NEVER the
16
+ * free-text (see `AgentRunState.output`). Pure + reducer-shaped so it is fully
17
+ * unit-testable against recorded frames no live model needed.
20
18
  */
21
19
  /** The tool the backend injects to carry a typed structured result. */
22
20
  const SUBMIT_TOOL = "submit_result";
@@ -39,94 +37,89 @@ export function adoptSettledRun(state, settled) {
39
37
  return { ...state, status: "error", error: settled.error_message ?? "The run was stopped." };
40
38
  }
41
39
  export function initialAgentRunState() {
42
- return { status: "streaming", items: [] };
40
+ return { status: "streaming", parts: [] };
43
41
  }
44
- /** A live size for a step's still-streaming arguments B under 1 KB, else KB. */
45
- function formatStreamSize(chars) {
46
- return chars < 1024 ? `${chars} B` : `${Math.round(chars / 1024)} KB`;
47
- }
48
- /** Grow the trailing prose segment of the given kind, or open a new one (after a
49
- * tool ran, or when the kind flips text↔reasoning) — so the transcript interleaves
42
+ /** Grow the trailing prose part of the given kind, or open a new one (after a tool
43
+ * ran, or when the kind flips text↔reasoning) — so the transcript interleaves
50
44
  * answer prose, thinking, and tools in the order they streamed. */
51
- function appendProse(items, kind, piece) {
52
- const last = items[items.length - 1];
53
- if (last?.type === kind) {
54
- return [...items.slice(0, -1), { ...last, text: last.text + piece }];
45
+ function appendProse(parts, kind, piece) {
46
+ const last = parts[parts.length - 1];
47
+ if (last && last.type === kind) {
48
+ return [...parts.slice(0, -1), { ...last, text: last.text + piece }];
55
49
  }
56
- return [...items, { type: kind, id: `${kind}-${items.length}`, text: piece }];
50
+ const opened = kind === "text" ? { type: "text", text: piece, state: "streaming" } : { type: "reasoning", text: piece, state: "streaming" };
51
+ return [...parts, opened];
57
52
  }
58
- /** Apply an update to the tool step with the given call id (by identity), leaving
59
- * the rest untouched. A missing id (older frame) or no match is a no-op. */
60
- function updateStep(items, toolCallId, fn) {
53
+ /** Apply an update to the `dynamic-tool` part with the given call id (by identity),
54
+ * leaving the rest untouched. A missing id (older frame) or no match is a no-op. */
55
+ function updateTool(parts, toolCallId, fn) {
61
56
  if (!toolCallId)
62
- return items;
63
- return items.map((it) => (it.type === "step" && it.id === toolCallId ? fn(it) : it));
57
+ return parts;
58
+ return parts.map((p) => (p.type === "dynamic-tool" && p.toolCallId === toolCallId ? fn(p) : p));
64
59
  }
65
60
  /** Fold one chunk into the run state. Returns a new state (immutable). */
66
61
  export function reduceAgentChunk(state, chunk) {
67
62
  switch (chunk.type) {
68
63
  case "text-delta": {
69
64
  const piece = chunk.delta ?? "";
70
- return piece ? { ...state, items: appendProse(state.items, "text", piece) } : state;
65
+ return piece ? { ...state, parts: appendProse(state.parts, "text", piece) } : state;
71
66
  }
72
67
  case "reasoning-delta": {
73
- // Thinking is its OWN segment (not folded into the answer prose) so the UI can
68
+ // Thinking is its OWN part (not folded into the answer prose) so the UI can
74
69
  // keep it collapsed / revealed-on-demand rather than inline in the answer.
75
70
  const piece = chunk.delta ?? "";
76
- return piece ? { ...state, items: appendProse(state.items, "reasoning", piece) } : state;
71
+ return piece ? { ...state, parts: appendProse(state.parts, "reasoning", piece) } : state;
77
72
  }
78
73
  case "tool-input-start":
79
74
  case "tool-input-available": {
80
75
  const name = chunk.toolName ?? "tool";
81
76
  if (name === SUBMIT_TOOL) {
82
- // The agent emitted its structured result — not a transcript step.
77
+ // The agent emitted its structured result — routed to `output`, not a part.
83
78
  return { ...state, output: chunk.input };
84
79
  }
85
- const id = chunk.toolCallId ?? `${name}-${state.items.length}`;
86
- // Upsert by call id: the step opens "running" the moment the call fires and
87
- // settles when its output arrives (below), so the feed shows a live tool, not
88
- // an instantly-done one. `input` rides along for the on-demand peek.
89
- const existing = state.items.some((it) => it.type === "step" && it.id === id);
80
+ const id = chunk.toolCallId ?? `${name}-${state.parts.length}`;
81
+ // Upsert by call id: the tool part opens "input-available" (a running step in
82
+ // the feed) the moment the call fires and settles when its output arrives
83
+ // (below), so the feed shows a live tool, not an instantly-done one.
84
+ const existing = state.parts.some((p) => p.type === "dynamic-tool" && p.toolCallId === id);
90
85
  if (existing) {
91
- return { ...state, items: updateStep(state.items, id, (s) => ({ ...s, label: s.label || name, input: chunk.input ?? s.input })) };
86
+ return { ...state, parts: updateTool(state.parts, id, (p) => ({ type: "dynamic-tool", toolName: p.toolName || name, toolCallId: id, state: "input-available", input: chunk.input ?? p.input })) };
92
87
  }
93
88
  return {
94
89
  ...state,
95
- items: [...state.items, { type: "step", id, label: name, kind: "tool", status: "running", input: chunk.input }],
96
- };
97
- }
98
- case "tool-input-delta": {
99
- // A tool's arguments streaming in — grow the step's live size so a long
100
- // generation reads as progressing. `submit_result` created no step (it sets
101
- // `output`), so its deltas find no matching step and this no-ops.
102
- const len = (chunk.inputTextDelta ?? "").length;
103
- if (!len)
104
- return state;
105
- return {
106
- ...state,
107
- items: updateStep(state.items, chunk.toolCallId, (s) => {
108
- const chars = (s.streamedChars ?? 0) + len;
109
- return { ...s, streamedChars: chars, detail: formatStreamSize(chars) };
110
- }),
90
+ parts: [...state.parts, { type: "dynamic-tool", toolName: name, toolCallId: id, state: "input-available", input: chunk.input }],
111
91
  };
112
92
  }
93
+ case "tool-input-delta":
94
+ // A tool's arguments streaming in. The ai-parts model carries no live size, so
95
+ // this is a no-op — the part already reads as running.
96
+ return state;
113
97
  case "tool-output-available":
114
- // Settled drop the live streaming size; the row is just label + done.
115
- return { ...state, items: updateStep(state.items, chunk.toolCallId, (s) => ({ ...s, status: "done", output: chunk.output, detail: undefined, streamedChars: undefined })) };
98
+ 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 })) };
116
99
  case "tool-output-error":
117
- return { ...state, items: updateStep(state.items, chunk.toolCallId, (s) => ({ ...s, status: "error", errorText: chunk.errorText ?? "The tool failed." })) };
100
+ 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." })) };
118
101
  case "error":
119
102
  return { ...state, status: "error", error: chunk.errorText ?? "The run failed." };
120
103
  case "abort":
121
104
  return { ...state, status: state.status === "streaming" ? "error" : state.status, error: state.error ?? "The run was stopped." };
122
105
  case "finish": {
123
- // Settle the status, and settle any tool still "running" (its output frame
124
- // never came) to "done" so nothing spins forever. `output` is whatever
125
- // `submit_result` set (else undefined) never the accumulated text: a
126
- // structured-output consumer reads `output.<field>`, so a stray free-text
127
- // string there would crash it. The free-text answer is always in `text`.
128
- const items = state.items.map((it) => (it.type === "step" && it.status === "running" ? { ...it, status: "done" } : it));
129
- return { ...state, status: state.status === "error" ? "error" : "completed", items };
106
+ // Settle the status, and settle anything still streaming so nothing reads as
107
+ // in-progress on a completed run: a tool with no output frame → output-available
108
+ // (its output never came); a text/reasoning part still "streaming" → "done" (the
109
+ // stream's `text-end`/`reasoning-end` markers aren't tracked per-part). `output`
110
+ // is whatever `submit_result` set (else undefined) NEVER the accumulated text:
111
+ // a structured-output consumer reads `output.<field>`, so a stray free-text
112
+ // string there would crash it. The free-text answer is always in a text part.
113
+ const parts = state.parts.map((p) => {
114
+ if (p.type === "dynamic-tool" && (p.state === "input-available" || p.state === "input-streaming")) {
115
+ return { type: "dynamic-tool", toolName: p.toolName, toolCallId: p.toolCallId, state: "output-available", input: p.input, output: undefined };
116
+ }
117
+ if ((p.type === "text" || p.type === "reasoning") && p.state === "streaming") {
118
+ return { ...p, state: "done" };
119
+ }
120
+ return p;
121
+ });
122
+ return { ...state, status: state.status === "error" ? "error" : "completed", parts };
130
123
  }
131
124
  default:
132
125
  return state;
@@ -1,9 +1,9 @@
1
1
  import { type AiContextValue } from "./rpc.js";
2
- import { type AgentRunStep, type AgentRunItem } from "./agent_stream.js";
2
+ import { type AgentUIPart } from "./agent_stream.js";
3
3
  import type { AppWorkflows, AppWorkflowResults, AppQueries, AppAgents, AppAgentResults } from "./types.js";
4
4
  import type { ResolvedMember } from "./members.js";
5
5
  import type { ResolvedOption } from "./select.js";
6
- export type { AgentRunStep, AgentRunState, AgentRunItem } from "./agent_stream.js";
6
+ export type { AgentRunState, AgentUIPart } from "./agent_stream.js";
7
7
  /** Fields shared by every query hook's return value. */
8
8
  interface QueryStateBase {
9
9
  /**
@@ -470,20 +470,15 @@ export interface UseAgentRun<TInput, TOutput> {
470
470
  * user-facing "Stop" button to this, not `abort`. */
471
471
  cancel: () => void;
472
472
  status: "idle" | "streaming" | "completed" | "error";
473
- /** The ordered run transcript — prose interleaved with tool steps, in stream
474
- * order. Feed straight to `@lotics/ui` `AgentRun`:
475
- * `<AgentRun items={run.items} state={run.status === "streaming" ? "streaming" : run.status === "error" ? "error" : "done"} />`
473
+ /** The ordered run transcript as ai-sdk `parts` — prose interleaved with tool
474
+ * calls, in stream order. Feed straight to `@lotics/ui` `AgentRun`:
475
+ * `<AgentRun parts={run.parts} state={run.status === "streaming" ? "streaming" : run.status === "error" ? "error" : "done"} />`
476
476
  * — no hand-assembly. */
477
- items: AgentRunItem[];
478
- /** The agent's ANSWER prose (every `text` segment concatenated), accumulating
479
- * live — excludes thinking (`reasoning` is its own segment in `items`). For a
480
- * FREE-TEXT agent this IS the result; a structured agent's result is `output`.
481
- * Derived from `items`. */
477
+ parts: AgentUIPart[];
478
+ /** The agent's ANSWER prose (every `text` part concatenated), accumulating live —
479
+ * excludes thinking (`reasoning` is its own part). For a FREE-TEXT agent this IS
480
+ * the result; a structured agent's result is `output`. Derived from `parts`. */
482
481
  text: string;
483
- /** Only the tool/step items (each with its `input`/`output`/`status`) — backward
484
- * compat with the older steps-only `AgentRun`. Prefer `items` (it keeps the
485
- * text↔reasoning↔tool ordering the feed renders). Derived from `items`. */
486
- steps: AgentRunStep[];
487
482
  /** The structured result once the run completes — the agent's `submit_result`
488
483
  * output. `undefined` when the run produced none (a free-text agent, or one that
489
484
  * finished without submitting); a free-text answer lives in `text`, never here.
@@ -496,14 +491,14 @@ export interface UseAgentRun<TInput, TOutput> {
496
491
  /**
497
492
  * Run a streaming agent declared in `package.json` lotics.agents and invoked by
498
493
  * alias. `run(input, { sessionId })` starts it; progress streams into `status` +
499
- * the ordered `items` transcript (feed straight to `@lotics/ui` `AgentRun`). A
494
+ * the ordered `parts` transcript (feed straight to `@lotics/ui` `AgentRun`). A
500
495
  * STRUCTURED agent's result lands in `output`; a FREE-TEXT agent's answer is the
501
496
  * transcript's prose (`text`). Read the session's history with `useAgentRuns`.
502
497
  *
503
498
  * ```tsx
504
499
  * const recognize = useAgentRun("recognize");
505
500
  * await recognize.run({ image_file_id }, { sessionId });
506
- * // <AgentRun items={recognize.items} state={recognize.status === "streaming" ? "streaming" : "done"} />
501
+ * // <AgentRun parts={recognize.parts} state={recognize.status === "streaming" ? "streaming" : "done"} />
507
502
  * // then read recognize.output (structured) or recognize.text (free-text)
508
503
  * ```
509
504
  */
package/dist/src/hooks.js CHANGED
@@ -586,26 +586,24 @@ export function useAgentRun(alias) {
586
586
  void rpc("agentRun.cancel", { run_id: runId }).catch(() => { });
587
587
  handleRef.current?.abort();
588
588
  }, []);
589
- // `items` is the source of truth; `text` (all prose) and `steps` (tool items
590
- // only) are the backward-compat views derived from it.
591
- const items = state?.items ?? EMPTY_ITEMS;
592
- const text = useMemo(() => items.reduce((acc, i) => (i.type === "text" ? acc + i.text : acc), ""), [items]);
593
- const steps = useMemo(() => items.flatMap((i) => (i.type === "step" ? [{ id: i.id, label: i.label, detail: i.detail, status: i.status, kind: i.kind, input: i.input, output: i.output, errorText: i.errorText }] : [])), [items]);
589
+ // `parts` is the source of truth; `text` (all prose concatenated) is the derived
590
+ // answer view.
591
+ const parts = state?.parts ?? EMPTY_PARTS;
592
+ const text = useMemo(() => parts.reduce((acc, p) => (p.type === "text" ? acc + p.text : acc), ""), [parts]);
594
593
  return {
595
594
  run,
596
595
  abort,
597
596
  cancel,
598
597
  status: state?.status ?? "idle",
599
- items,
598
+ parts,
600
599
  text,
601
- steps,
602
600
  output: state?.output,
603
601
  error: state?.error,
604
602
  };
605
603
  }
606
- /** Stable empty transcript so an idle hook returns a constant `items` reference
604
+ /** Stable empty transcript so an idle hook returns a constant `parts` reference
607
605
  * (no new [] each render → dependents don't re-run needlessly). */
608
- const EMPTY_ITEMS = [];
606
+ const EMPTY_PARTS = [];
609
607
  /**
610
608
  * Bounded PAST the server-side max-run cap (20 min) plus settle grace. The
611
609
  * server guarantees a LIVE run settles by its own cap timer, so a row still
@@ -17,7 +17,7 @@
17
17
  export { mount } from "./mount.js";
18
18
  export type { MountOptions } from "./mount.js";
19
19
  export { useWorkflow, useQuery, useInfiniteQuery, usePaginatedQuery, useFieldOptions, useFileUpload, useAttachments, useMembers, useAgentRun, useAgentRuns, useAiContext, } from "./hooks.js";
20
- export type { UploadedFile, AttachedFile, BaseQueryOptions, QueryOptions, InfiniteQueryOptions, PaginatedQueryOptions, QuerySortKey, QueryFilter, QueryFilterCondition, QueryFilterGroup, WorkflowResult, MembersOptions, AgentRunOptions, UseAgentRun, AgentRunRecord, AgentRunState, AgentRunStep, AgentRunItem, FieldOptions, FieldOptionsState, FieldOptionsOptions, } from "./hooks.js";
20
+ export type { UploadedFile, AttachedFile, BaseQueryOptions, QueryOptions, InfiniteQueryOptions, PaginatedQueryOptions, QuerySortKey, QueryFilter, QueryFilterCondition, QueryFilterGroup, WorkflowResult, MembersOptions, AgentRunOptions, UseAgentRun, AgentRunRecord, AgentRunState, AgentUIPart, 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";
package/dist/src/row.d.ts CHANGED
@@ -24,7 +24,9 @@ declare function bool(v: unknown): boolean;
24
24
  /**
25
25
  * Date/datetime field → a LOCAL-midnight Date for the stored calendar day, so
26
26
  * calendar/gantt placement never shifts across timezones. Parses the leading
27
- * `YYYY-MM-DD` of the serialized string; null if absent or unparseable. (Range
27
+ * `YYYY[-MM[-DD]]` of the serialized string a reduced-precision `date` value
28
+ * ("2026-05" / "2026") decodes to its PERIOD START (missing month/day → 1)
29
+ * rather than vanishing to null. Null only if absent or unparseable. (Range
28
30
  * fields are not handled here — they have no consumer yet.)
29
31
  */
30
32
  declare function date(v: unknown): Date | null;
@@ -34,8 +36,10 @@ declare function date(v: unknown): Date | null;
34
36
  * value is a timezone-less workspace wall-clock (see the serialization note
35
37
  * above), so it is read verbatim — no UTC conversion. Use this when the time
36
38
  * matters (a check-in time, an appointment); `date` keeps only the calendar day.
37
- * Parses `YYYY-MM-DD` with an optional `T`-or-space `HH:mm[:ss]`; a missing time
38
- * is midnight. Null if absent or unparseable.
39
+ * Parses `YYYY[-MM[-DD]]` with an optional `T`-or-space `HH:mm[:ss]`; a missing
40
+ * month/day is the period start (1) and a missing time is midnight — a
41
+ * reduced-precision value decodes rather than vanishing. Null if absent or
42
+ * unparseable.
39
43
  */
40
44
  declare function datetime(v: unknown): Date | null;
41
45
  /** A linked record cell entry — the target record's id + its display text. */
package/dist/src/row.js CHANGED
@@ -53,12 +53,16 @@ function bool(v) {
53
53
  /**
54
54
  * Date/datetime field → a LOCAL-midnight Date for the stored calendar day, so
55
55
  * calendar/gantt placement never shifts across timezones. Parses the leading
56
- * `YYYY-MM-DD` of the serialized string; null if absent or unparseable. (Range
56
+ * `YYYY[-MM[-DD]]` of the serialized string a reduced-precision `date` value
57
+ * ("2026-05" / "2026") decodes to its PERIOD START (missing month/day → 1)
58
+ * rather than vanishing to null. Null only if absent or unparseable. (Range
57
59
  * fields are not handled here — they have no consumer yet.)
58
60
  */
59
61
  function date(v) {
60
- const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(text(v));
61
- return m ? new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3])) : null;
62
+ const m = /^(\d{4})(?:-(\d{2}))?(?:-(\d{2}))?/.exec(text(v));
63
+ if (!m)
64
+ return null;
65
+ return new Date(Number(m[1]), (m[2] ? Number(m[2]) : 1) - 1, m[3] ? Number(m[3]) : 1);
62
66
  }
63
67
  /**
64
68
  * Date/datetime field → a LOCAL Date that KEEPS the stored wall-clock time, so
@@ -66,14 +70,16 @@ function date(v) {
66
70
  * value is a timezone-less workspace wall-clock (see the serialization note
67
71
  * above), so it is read verbatim — no UTC conversion. Use this when the time
68
72
  * matters (a check-in time, an appointment); `date` keeps only the calendar day.
69
- * Parses `YYYY-MM-DD` with an optional `T`-or-space `HH:mm[:ss]`; a missing time
70
- * is midnight. Null if absent or unparseable.
73
+ * Parses `YYYY[-MM[-DD]]` with an optional `T`-or-space `HH:mm[:ss]`; a missing
74
+ * month/day is the period start (1) and a missing time is midnight — a
75
+ * reduced-precision value decodes rather than vanishing. Null if absent or
76
+ * unparseable.
71
77
  */
72
78
  function datetime(v) {
73
- const m = /^(\d{4})-(\d{2})-(\d{2})(?:[ T](\d{2}):(\d{2}))?/.exec(text(v));
79
+ const m = /^(\d{4})(?:-(\d{2}))?(?:-(\d{2}))?(?:[ T](\d{2}):(\d{2}))?/.exec(text(v));
74
80
  if (!m)
75
81
  return null;
76
- return new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]), m[4] ? Number(m[4]) : 0, m[5] ? Number(m[5]) : 0);
82
+ return new Date(Number(m[1]), (m[2] ? Number(m[2]) : 1) - 1, m[3] ? Number(m[3]) : 1, m[4] ? Number(m[4]) : 0, m[5] ? Number(m[5]) : 0);
77
83
  }
78
84
  /** One `{ id, display }` object → ResolvedLink, or null if absent/malformed. */
79
85
  function asLink(v) {
package/docs/ai.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # AI in apps
2
2
 
3
- An app has two AI surfaces, and they answer different questions. **`useAgentRun(alias)`** runs an agent *declared on the app* — a streaming, tool-looping run whose result lands back **in the app** (a typed structured output, or free-text prose) for the app to review and commit through its own [workflows](./mutations.md). **`askAi(args)`** is a *handoff* — it opens the Lotics chat messenger seeded with files, records, and a prefilled prompt, and the outcome lands **in chat**, under the signed-in member's control. Beyond those two, **`useAiContext(slot, context)`** feeds the member's *ambient* chat agent — the one riding alongside the app — a snapshot of what the current screen is showing, so a question the member asks there resolves against what they're looking at. Read this doc when adding any AI-driven feature to an app; read [security](./security.md) first for the authority model agent runs execute under. Exact signatures: `dist/src/hooks.d.ts` (`useAgentRun`, `useAgentRuns`, `useAiContext`), `dist/src/agent_stream.d.ts` (`AgentRunItem`, `AgentRunStep`, `AgentRunState`), `dist/src/ask_ai.d.ts` (`AskAiArgs`).
3
+ An app has two AI surfaces, and they answer different questions. **`useAgentRun(alias)`** runs an agent *declared on the app* — a streaming, tool-looping run whose result lands back **in the app** (a typed structured output, or free-text prose) for the app to review and commit through its own [workflows](./mutations.md). **`askAi(args)`** is a *handoff* — it opens the Lotics chat messenger seeded with files, records, and a prefilled prompt, and the outcome lands **in chat**, under the signed-in member's control. Beyond those two, **`useAiContext(slot, context)`** feeds the member's *ambient* chat agent — the one riding alongside the app — a snapshot of what the current screen is showing, so a question the member asks there resolves against what they're looking at. Read this doc when adding any AI-driven feature to an app; read [security](./security.md) first for the authority model agent runs execute under. Exact signatures: `dist/src/hooks.d.ts` (`useAgentRun`, `useAgentRuns`, `useAiContext`), `dist/src/agent_stream.d.ts` (`AgentRunState`, `AgentUIPart`), `dist/src/ask_ai.d.ts` (`AskAiArgs`).
4
4
 
5
5
  ## Choosing the surface — the fields-vs-file razor
6
6
 
@@ -41,7 +41,7 @@ import { useAgentRun } from "@lotics/app-sdk";
41
41
 
42
42
  const recognize = useAgentRun("recognize");
43
43
  await recognize.run({ image_file_id: fileId }, { sessionId });
44
- // live: recognize.status, recognize.items
44
+ // live: recognize.status, recognize.parts
45
45
  // done: recognize.output (structured) or recognize.text (free-text)
46
46
  ```
47
47
 
@@ -53,33 +53,33 @@ await recognize.run({ image_file_id: fileId }, { sessionId });
53
53
  | `cancel` | `() => void` | Stop the run **server-side** (saves tokens) and locally. Wire a user-facing Stop button to this |
54
54
  | `abort` | `() => void` | Stop listening **locally only** — the run keeps executing server-side and its result is still persisted. This is the unmount path (the hook calls it automatically on unmount) |
55
55
  | `status` | `"idle" \| "streaming" \| "completed" \| "error"` | Whole-run state. `abort`/`cancel` reset it to `"idle"` (and clear the partial transcript) |
56
- | `items` | `AgentRunItem[]` | The ordered live transcript — answer prose, thinking, and tool steps, in stream order. The single source of truth for the feed |
57
- | `text` | `string` | The agent's **answer prose** (every `text` segment concatenated), accumulating live. Excludes thinking. For a free-text agent this IS the result |
58
- | `steps` | `AgentRunStep[]` | Only the tool/step items — a backward-compat view derived from `items`. Prefer `items` (it keeps the text↔reasoning↔tool ordering) |
56
+ | `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` |
57
+ | `text` | `string` | The agent's **answer prose** (every `text` part concatenated), accumulating live. Excludes thinking. For a free-text agent this IS the result |
59
58
  | `output` | `TOutput \| undefined` | The structured result once the run completes. `undefined` when the run produced none |
60
59
  | `error` | `string \| undefined` | The failure message when `status` is `"error"` |
61
60
 
62
- ### The `items` transcript → `@lotics/ui` `AgentRun`
61
+ ### The `parts` transcript → `@lotics/ui` `AgentRun`
63
62
 
64
- `AgentRunItem` is a discriminated union in stream order:
63
+ `parts` is the **ai-sdk `UIMessagePart[]`** shape — the same wire model chat and app agents both emit, so there is no bespoke transcript type to keep in sync. The reducer folds the run's SSE chunks into it, in stream order:
65
64
 
66
- | `type` | Carries | Rendering intent |
65
+ | Part `type` | Carries | Rendering intent |
67
66
  |---|---|---|
68
- | `"text"` | `id`, `text` | Answer prose, grows as it streams |
69
- | `"reasoning"` | `id`, `text` | The agent's thinking — a **distinct segment**, kept out of `text`, so the UI can show it collapsed / revealed on demand |
70
- | `"step"` | `AgentRunStep`: `id`, `label` (raw tool name), `status` (`"running" \| "done" \| "error"`), `kind`, `input`, `output`, `errorText`, `detail`, `streamedChars` | One tool call — opens `"running"` the moment it fires, settles when its result arrives. `input`/`output` ride along for an on-demand reveal, not for the feed. While a call's arguments are still generating, `detail` carries a live size ("18 KB", accumulated in `streamedChars`) so a long generation reads as progressing; both clear when the call's output arrives (a call that settles `"error"` keeps its last size) |
67
+ | `"text"` | `text`, `state` | Answer prose, grows as it streams |
68
+ | `"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 4.6 / Opus 4.8**, not Haiku) — a Haiku agent never emits reasoning |
69
+ | `"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 |
71
70
 
72
- The shape is structurally the `AgentRunItem` that `@lotics/ui`'s `AgentRun` component takes, so the pairing needs **no adapter and no hand-assembly**:
71
+ 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**:
73
72
 
74
73
  ```tsx
75
74
  <AgentRun
76
- items={run.items}
75
+ parts={run.parts}
77
76
  state={run.status === "streaming" ? "streaming" : run.status === "error" ? "error" : "done"}
77
+ error={run.error} // the breaking error — a terminal danger row
78
78
  labelForTool={(name) => toolLabels[name]} // optional: localize tool labels
79
79
  />
80
80
  ```
81
81
 
82
- `AgentRun` renders thinking collapsed, groups consecutive tool steps, and shows each tool's input/output in a press-to-open peek — all for free. Never rebuild this feed from `text` + `steps`.
82
+ `AgentRun` renders thinking collapsed, groups consecutive tool calls, and expands each tool's input/output in place on press — all for free. **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.
83
83
 
84
84
  ### `output` typing and the inner-field caveat
85
85
 
@@ -130,7 +130,7 @@ Both truncation shapes emit the `app_agent_stream_truncated` analytics event (`k
130
130
 
131
131
  The poll is bounded at 22 minutes — deliberately PAST the server's 20-minute hard run cap, so a live run always settles before the client gives up. A row still `running` at the deadline means the run's process died mid-flight (e.g. a crash that skipped the server's shutdown drain); the poll surfaces an error and the server's reaper repairs the row. Only when no run id was ever received (the run never started) does the failure reject before any polling.
132
132
 
133
- On recovery, `output` adopts the row's output **only when it is an object** (a structured result) — the "`output` is never a stray string" rule holds on every path. A **free-text** run recovered from truncation keeps only the streamed prefix in `text`; read the full settled answer via `useAgentRuns(sessionId)` if you need it.
133
+ On recovery, `output` adopts the row's output **only when it is an object** (a structured result) — the "`output` is never a stray string" rule holds on every path. A **free-text** run recovered from truncation keeps only the streamed prefix in `text`; the full settled answer is persisted server-side but is not currently client-readable (`useAgentRuns` can't reach it on any transport — see the limitation below), so treat the streamed prefix as terminal for now.
134
134
 
135
135
  ### Sessions
136
136
 
@@ -266,7 +266,6 @@ The floors below are when each capability shipped in `@lotics/app-sdk`; an app p
266
266
  |---|---|
267
267
  | `useAgentRun` (with `abort`) + `useAgentRuns` | `@lotics/app-sdk` 0.34 |
268
268
  | `cancel()` (server-side stop) + connection-drop recovery | `@lotics/app-sdk` 0.37 |
269
- | `items` transcript (reasoning segments + per-tool `input`/`output`) | `@lotics/app-sdk` 0.43; rendering pairs with `@lotics/ui` ≥ 7.13 (`AgentRun` `items` prop) |
270
- | Live streamed-argument size on a running step (`detail`) | `@lotics/app-sdk` 0.44 |
269
+ | `parts` transcript (ai-sdk `UIMessagePart[]` — reasoning + per-tool `input`/`output`) | `@lotics/app-sdk` 0.54; renders with `@lotics/ui` ≥ 14.0 (`AgentRun` `parts` prop) |
271
270
  | `askAi` | `@lotics/app-sdk` 0.45 |
272
271
  | `useAiContext` (ambient-chat view state + auto query refetch on chat mutation) | `@lotics/app-sdk` 0.52 |
@@ -228,7 +228,7 @@ Wire shapes per output column type:
228
228
  | `text` | `string` |
229
229
  | `number` | `number` (numeric database strings are normalized server-side; a rare decimal too precise for float64 stays a string — `row.num` parses both) |
230
230
  | `boolean` | `boolean` |
231
- | `date` | `"YYYY-MM-DD"` — a calendar day, no time component |
231
+ | `date` | `"YYYY-MM-DD"` — a calendar day, no time component (a reduced-precision date field may also carry `"YYYY-MM"` or `"YYYY"`; see below) |
232
232
  | `datetime` | `"YYYY-MM-DDTHH:mm"` — workspace wall-clock, minute precision |
233
233
  | `select` | `Array<{ key, label }>` — one entry per selected option |
234
234
  | `select_member` | `Array<{ id, name, email? }>` — `email` only for authenticated viewers of the app's own org |
@@ -249,8 +249,8 @@ metadata. A grouped query collapses rows and emits none of these. Details:
249
249
  | `row.text(cell)` | any → `string` | strings pass through, finite numbers stringify, everything else → `""` |
250
250
  | `row.num(cell)` | any → `number` | numbers pass (NaN/Infinity → 0), parseable strings parse, everything else → `0` |
251
251
  | `row.bool(cell)` | any → `boolean` | `true` or the string `"true"`; everything else `false` |
252
- | `row.date(cell)` | date/datetime cell → `Date \| null` | the stored **calendar day** at LOCAL midnight — time stripped |
253
- | `row.datetime(cell)` | date/datetime cell → `Date \| null` | local `Date` **keeping the stored wall-clock** (minute precision; seconds are dropped); missing time = midnight |
252
+ | `row.date(cell)` | date/datetime cell → `Date \| null` | the stored **calendar day** at LOCAL midnight — time stripped; a reduced-precision value decodes to its **period start** |
253
+ | `row.datetime(cell)` | date/datetime cell → `Date \| null` | local `Date` **keeping the stored wall-clock** (minute precision; seconds are dropped); missing time = midnight; a reduced-precision value decodes to its **period start** |
254
254
  | `row.link(cell)` | link cell → `{ id, display } \| null` | the FIRST linked record |
255
255
  | `readLinks(cell)` | link cell → `{ id, display }[]` | ALL linked records (`[]` when empty) |
256
256
  | `readSelect(cell)` | select cell → `ResolvedOption[]` | all selected options as `{ key, label }` (`[]` when empty) |
@@ -262,7 +262,7 @@ All readers are pure (`unknown` in, value out), never throw, and return their em
262
262
  (`null` / `""` / `0` / `false` / `[]`) for absent or malformed input — so callers iterate and
263
263
  render without null-check pyramids.
264
264
 
265
- **`row.date` vs `row.datetime`.** `row.date` parses only the leading `YYYY-MM-DD` and builds a
265
+ **`row.date` vs `row.datetime`.** `row.date` parses the leading `YYYY[-MM[-DD]]` and builds a
266
266
  LOCAL-midnight `Date` — calendar/gantt placement never shifts across viewer timezones. `row.datetime`
267
267
  keeps the stored wall-clock verbatim (no UTC conversion — the stored value is a timezone-less
268
268
  workspace wall-clock), so `getHours()` / `toLocaleTimeString()` render the time as written. A
@@ -270,6 +270,14 @@ workspace wall-clock), so `getHours()` / `toLocaleTimeString()` render the time
270
270
  and the UI prints `00:00`. When you need the time, the query must output the column as `datetime`
271
271
  (the type-override rules are in [./queries.md](./queries.md)).
272
272
 
273
+ **Reduced-precision date values.** A `date` field may store an ISO 8601 truncated value —
274
+ `"2026-05"` (month) or `"2026"` (year) — when a document carries only that precision. Both decoders
275
+ resolve it to its **period start** (missing month/day → 1): `row.date("2026-05")` →
276
+ May 1 2026 at local midnight, `row.date("2026")` → Jan 1 2026 — never `null`. For DISPLAY that
277
+ respects the precision (show "05/2026", not "01/05/2026"), format with `@lotics/ui`'s `formatDate`
278
+ on the raw cell string rather than the decoded `Date`. Sorting/filtering server-side already treats
279
+ a partial as its period start.
280
+
273
281
  **`readSelect`.** A query **cell** carries `key` + `label` only — `color` comes from
274
282
  `useFieldOptions`, not the cell. An option deleted after the cell was written surfaces as
275
283
  `label === key` (the stale state is explicit, never hidden). Render with `@lotics/ui` `OptionBadge`
package/docs/mutations.md CHANGED
@@ -128,12 +128,16 @@ A workflow body ends with `return({ status, message?, data? })`. `data` is arbit
128
128
  structured data (computed totals, row lists, status objects) the app reads back as
129
129
  `result.data`:
130
130
 
131
- - **Typed for free.** The alias's `outputs` schema is derived at save time from the inferred
132
- TypeScript type of the body's `return({ data })` — the return *is* the declaration. Codegen
133
- then types `result.data` per alias. Declare an explicit `outputs` on the alias (same
134
- recursive vocabulary as inputs: scalars plus nested `object`/`array`, no member/file/
135
- date_range) only to narrow beyond what's inferred; a shape the checker can't pin down
136
- degrades to untyped `json`, never to a wrong schema.
131
+ - **Typed for free — persisted at `set`.** The alias's `outputs` schema is derived at save
132
+ time from the inferred TypeScript type of the body's `return({ data })` — the return *is*
133
+ the declaration. When the manifest declared **no** `outputs`, `lotics app workflow set`
134
+ writes the server-derived schema into `package.json#lotics.workflows.<alias>.outputs` and
135
+ refreshes that alias's generated types in the same command, so `result.data` is typed
136
+ immediately no hand-copy of the echoed schema, no second `lotics app codegen`. An
137
+ **explicitly declared** `outputs` is authoritative and never overwritten. Declare an explicit
138
+ `outputs` (same recursive vocabulary as inputs: scalars plus nested `object`/`array`, no
139
+ member/file/date_range) only to narrow beyond what's inferred; a shape the checker can't pin
140
+ down degrades to untyped `json`, never to a wrong schema.
137
141
  - **Validated at run.** On a success return, the returned `data` is validated against the
138
142
  schema at the app boundary — a mismatch resolves as `status: "error"` with a field-level
139
143
  message, so a declared output is a real contract. An *error* return's `data` passes through
@@ -173,7 +177,7 @@ Every declaration takes optional `description` and `required` (default **true**)
173
177
  | `date` | — | `"YYYY-MM-DD"`, or a TZ-bearing ISO datetime (projected to the workspace day) | `string` |
174
178
  | `datetime` | — | `"YYYY-MM-DDTHH:mm"` naive workspace wall-clock (what the platform DatePicker emits), or TZ-bearing ISO | `string` |
175
179
  | `email` | — | a valid email | `string` |
176
- | `select` | `options: [{label, value}]` (min 1), `multi?` | an option key (array when `multi`) | union of declared `value` literals; `ReadonlyArray<…>` when `multi` |
180
+ | `select` | exactly one of `options: [{label, value}]` (min 1) **or** `field: "fld_…"`; `multi?` | an option key (array when `multi`) | union of the option `value`s (inline) or the field's current option keys (`field`); `ReadonlyArray<…>` when `multi` |
177
181
  | `record_link` | `table_id` (required), `multi?` | a record id — must exist **in the declared table** | `string` / `ReadonlyArray<string>` |
178
182
  | `member` | `group?`, `multi?` | a member id — with `group`, must belong to that group | `string` / `ReadonlyArray<string>` |
179
183
  | `file` | `multi?` | a file id from an upload — must live in the app's workspace | `string` / `ReadonlyArray<string>` |
@@ -209,12 +213,24 @@ When the alias declares `inputs`, the server validates the payload before the wo
209
213
  declared group; every `file` id must live in the app's workspace. These are real write-time
210
214
  constraints, not picker cosmetics — a hand-crafted request can't redirect the workflow.
211
215
  Rationale and the full caller-boundary model: [security](./security.md).
212
- - **`select` accepts any well-formed option key at runtime**, not only the deploy-declared
213
- `options`. **Limitation:** the declared options are frozen into the codegen literal union,
214
- so an option added to the live field after deploy is valid at runtime but fails the
215
- compile-time type. Populate pickers from `useFieldOptions` (the live option set see
216
- [members_and_options](./members_and_options.md)) and widen or redeploy the declaration when
217
- the type gets in the way.
216
+ - **`select` names its option set one of two ways declare exactly one (both or neither is a
217
+ set-time error).** *Inline* `options: [{label, value}]` freezes the set into the codegen
218
+ literal union and is **format-only at run time**: any well-formed key is accepted, and an
219
+ option added to the live field after deploy is valid at run time but fails the compile-time
220
+ type (widen or redeploy the declaration when the frozen union gets in the way).
221
+ *Field-referenced* `field: "fld_…"` (a select field's global key — `fld_…`, no table prefix)
222
+ is **drift-proof**: the union is resolved from the field's *current* options at type-gen, and
223
+ the submitted value is validated against the field's *current* options at run time — an option
224
+ added after authoring is accepted, a removed one rejected
225
+ (`select input "<path>" value "<v>" is not one of field "<key>"'s current options`) with no
226
+ redeploy. A `field` that doesn't exist or names a non-select field is rejected at bind time
227
+ (`lotics app workflow set` / `set_app_workflow`; `lotics app deploy` never binds workflows) with
228
+ `select input references field "<key>", which does not exist in this workspace` /
229
+ `… which is a <type> field, not a select`. Prefer `field` for any select backed by a real
230
+ field; keep `options` for a fixed enum the app owns or a select you plan to **package** — a
231
+ `field`-form select can't ride a package contract (it carries a concrete field id), so inline
232
+ the options before publishing. Populate pickers from `useFieldOptions` either way (the live
233
+ option set — see [members_and_options](./members_and_options.md)).
218
234
 
219
235
  If the alias declares **no** `inputs`, the payload passes through opaquely — no validation,
220
236
  no typing, no reference binding. Fine for a zero-input action; declare inputs for anything
@@ -254,6 +270,27 @@ if (i.title != null) {
254
270
  }
255
271
  ```
256
272
 
273
+ When several optional inputs each map to a field, per-field guards become noise. Hand the whole
274
+ bag to `update_records`' **`set_skip_null`** instead — same object shape as `set`, but entries
275
+ whose value is `null`/`undefined` are dropped, so only the fields actually provided get written
276
+ (untouched fields keep their lock / `before_update` / concurrent-edit safety — the diff-write
277
+ discipline below, done in the body):
278
+
279
+ ```js
280
+ const i = trigger.app_workflow.inputs;
281
+ await update_records({
282
+ table_id: "tbl_items",
283
+ record_ids: [i.record_id],
284
+ set_skip_null: { fld_title: i.title, fld_status: i.status, fld_due: i.due }, // absent inputs drop out
285
+ });
286
+ ```
287
+
288
+ `set` still writes every key it carries — `null` in `set` **clears** the field; `null`/absent in
289
+ `set_skip_null` **skips** it. That is the per-field "clear vs. leave unchanged" choice, so a field
290
+ may appear in **at most one** of `set` / `set_skip_null` (naming it in both is rejected at run
291
+ time). An all-absent `set_skip_null` with no other write surface is a no-op — no records touched,
292
+ no `before_update` hooks.
293
+
257
294
  ## Refetch after a mutation
258
295
 
259
296
  Query hooks cache through SWR and know nothing about your workflows — a successful mutation
@@ -281,9 +318,9 @@ in place of an explicit refetch after a write the user is watching for.
281
318
 
282
319
  An edit form snapshots the record's values when it loads, and on save sends **only the fields
283
320
  the user actually changed** to its update workflow. Declare each updatable input
284
- `required: false`; an omitted input means "not written" (the body guards each write as shown
285
- above). "Changed" is decided at the edit surface that loaded the before-state — compare
286
- against the load-time snapshot, not against a re-fetch.
321
+ `required: false`; an omitted input means "not written" (the body guards each write or hands
322
+ them to `set_skip_null` — as shown above). "Changed" is decided at the edit surface that loaded
323
+ the before-state — compare against the load-time snapshot, not against a re-fetch.
287
324
 
288
325
  Why a full-form snapshot save is a bug, not a style choice — three independent mechanisms:
289
326
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/app-sdk",
3
- "version": "0.52.4",
3
+ "version": "0.54.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": {
@@ -30,11 +30,15 @@
30
30
  "swr": "^2.4.1"
31
31
  },
32
32
  "peerDependencies": {
33
+ "ai": ">=7.0.0",
33
34
  "react": "^19.2.0",
34
35
  "react-dom": "^19.2.0",
35
36
  "react-router-dom": "^7.0.0"
36
37
  },
37
38
  "peerDependenciesMeta": {
39
+ "ai": {
40
+ "optional": true
41
+ },
38
42
  "react-router-dom": {
39
43
  "optional": true
40
44
  }