@lotics/app-sdk 0.51.5 → 0.52.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md 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`) and `askAi` — plus the fields-vs-file razor for choosing between them. |
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). |
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
 
@@ -68,6 +68,24 @@ export interface AgentRunState {
68
68
  output?: unknown;
69
69
  error?: string;
70
70
  }
71
+ /** A settled run row, as the poll endpoint returns it. */
72
+ export interface SettledAgentRun {
73
+ status: string;
74
+ output?: unknown;
75
+ error_message?: string | null;
76
+ }
77
+ /**
78
+ * Fold a POLLED settled run row into the stream-accumulated state — the shared
79
+ * adoption step for BOTH recovery paths (a dropped stream, and a stream that
80
+ * ended cleanly WITHOUT a `finish` frame — an edge can close a long SSE
81
+ * gracefully mid-run, which looks like completion but is a truncation).
82
+ *
83
+ * The row is the source of truth for status and the STRUCTURED output. A
84
+ * free-text run's row `output` is its prose string — that never enters
85
+ * `state.output` (a structured consumer reads `output.<field>`; a stray string
86
+ * would crash it). The free-text answer stays in the transcript text.
87
+ */
88
+ export declare function adoptSettledRun(state: AgentRunState, settled: SettledAgentRun): AgentRunState;
71
89
  export declare function initialAgentRunState(): AgentRunState;
72
90
  /** A parsed UI-message chunk — only the fields we read, all optional. */
73
91
  interface Chunk {
@@ -20,6 +20,24 @@
20
20
  */
21
21
  /** The tool the backend injects to carry a typed structured result. */
22
22
  const SUBMIT_TOOL = "submit_result";
23
+ /**
24
+ * Fold a POLLED settled run row into the stream-accumulated state — the shared
25
+ * adoption step for BOTH recovery paths (a dropped stream, and a stream that
26
+ * ended cleanly WITHOUT a `finish` frame — an edge can close a long SSE
27
+ * gracefully mid-run, which looks like completion but is a truncation).
28
+ *
29
+ * The row is the source of truth for status and the STRUCTURED output. A
30
+ * free-text run's row `output` is its prose string — that never enters
31
+ * `state.output` (a structured consumer reads `output.<field>`; a stray string
32
+ * would crash it). The free-text answer stays in the transcript text.
33
+ */
34
+ export function adoptSettledRun(state, settled) {
35
+ if (settled.status === "completed") {
36
+ const structured = settled.output !== null && typeof settled.output === "object" ? settled.output : undefined;
37
+ return { ...state, status: "completed", output: structured ?? state.output };
38
+ }
39
+ return { ...state, status: "error", error: settled.error_message ?? "The run was stopped." };
40
+ }
23
41
  export function initialAgentRunState() {
24
42
  return { status: "streaming", items: [] };
25
43
  }
@@ -1,3 +1,4 @@
1
+ import { type AiContextValue } from "./rpc.js";
1
2
  import { type AgentRunStep, type AgentRunItem } from "./agent_stream.js";
2
3
  import type { AppWorkflows, AppWorkflowResults, AppQueries, AppAgents, AppAgentResults } from "./types.js";
3
4
  import type { ResolvedMember } from "./members.js";
@@ -372,6 +373,41 @@ interface AttachmentsState {
372
373
  * ```
373
374
  */
374
375
  export declare function useAttachments(): AttachmentsState;
376
+ /**
377
+ * Publish a slice of the CURRENT SCREEN's view state to the app's ambient chat
378
+ * agent, so a member chatting alongside the app gets an agent that knows what
379
+ * they are looking at — which list is filtered to what, which record is open,
380
+ * what is typed into a form. Declarative and lifecycle-bound: mounting or
381
+ * changing `context` pushes it to the host; unmounting, renaming the `slot`, or
382
+ * passing `null` clears it. Independent components may publish different `slot`s
383
+ * concurrently (a list screen + an open detail drawer); the newest value per
384
+ * slot wins.
385
+ *
386
+ * **Push-only, and a SNAPSHOT of what the app already RENDERED to this member** —
387
+ * never a channel for chat to pull app-authority data. `records` are passed as
388
+ * raw `{ table_id, record_id }` refs (unresolved); the member's own chat agent
389
+ * acts on them only where that member's IAM already allows. The host feeds
390
+ * `description`/`data` into the agent's prompt as clearly-labeled DATA, never as
391
+ * instructions.
392
+ *
393
+ * Host-enforced caps (exceeding them truncates/drops — never an error): `slot`
394
+ * ≤ 50 chars; `description` ≤ 1000 chars (truncated with "…"); `records` ≤ 20;
395
+ * `data` must JSON-serialize to ≤ 2000 chars or the `data` field is dropped (the
396
+ * description is kept); ≤ 8 slots per app (a 9th evicts the least-recently
397
+ * updated).
398
+ *
399
+ * ```tsx
400
+ * useAiContext("orders_list", {
401
+ * description: `Viewing ${rows.length} orders filtered to status=open, sorted by due date.`,
402
+ * records: rows.map((r) => ({ table_id: r.__source_table_id, record_id: r.__source_record_id })),
403
+ * data: { filter: "status=open", sort: "due_date desc" },
404
+ * });
405
+ * ```
406
+ *
407
+ * No-ops with no embedding host (standalone `<slug>.lotics.app` — there is no
408
+ * chat surface to inform) and in mock mode. Since 0.52.
409
+ */
410
+ export declare function useAiContext(slot: string, context: AiContextValue | null): void;
375
411
  interface MembersState {
376
412
  /** Members of the app's organization, for assign / member-picker UIs. */
377
413
  members: ResolvedMember[];
package/dist/src/hooks.js CHANGED
@@ -18,9 +18,9 @@
18
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, rpcAgentRun } from "./rpc.js";
22
- import { initialAgentRunState, reduceAgentChunk, parseSseChunks, } from "./agent_stream.js";
23
- import { getMockRows } from "./mock.js";
21
+ import { rpc, rpcAgentRun, postHostNotification, subscribeHostRefetch, } from "./rpc.js";
22
+ import { initialAgentRunState, reduceAgentChunk, parseSseChunks, adoptSettledRun, } from "./agent_stream.js";
23
+ import { getMockRows, hasMockFlag } from "./mock.js";
24
24
  import { captureAppEvent } from "./analytics.js";
25
25
  export function useWorkflow(alias) {
26
26
  return useCallback((inputs) => rpc("workflow", { alias, inputs: inputs ?? {} }), [alias]);
@@ -35,6 +35,19 @@ function swrConfig(revalidateOnFocus) {
35
35
  shouldRetryOnError: false,
36
36
  };
37
37
  }
38
+ /**
39
+ * Re-run `refetch` when the host pushes `refetchQueries` — after an ambient
40
+ * app-chat agent turn mutated records, every mounted query hook refreshes the
41
+ * data it rendered. Inert in mock mode (no host, no listener) and for a mocked
42
+ * alias (its rows come from the fixture). Shared by all three query hooks.
43
+ */
44
+ function useHostRefetch(refetch, mockRows) {
45
+ useEffect(() => {
46
+ if (hasMockFlag() || mockRows)
47
+ return;
48
+ return subscribeHostRefetch(refetch);
49
+ }, [refetch, mockRows]);
50
+ }
38
51
  export function useQuery(alias, params, opts) {
39
52
  const pageSize = opts?.pageSize;
40
53
  const enabled = opts?.enabled ?? true;
@@ -59,6 +72,7 @@ export function useQuery(alias, params, opts) {
59
72
  const refetch = useCallback(() => {
60
73
  void swr.mutate();
61
74
  }, [swr]);
75
+ useHostRefetch(refetch, mockRows);
62
76
  return {
63
77
  rows: mockRows ?? swr.data?.rows ?? [],
64
78
  loading: mockRows ? false : swr.isLoading,
@@ -139,6 +153,7 @@ export function useInfiniteQuery(alias, params, opts) {
139
153
  const refetch = useCallback(() => {
140
154
  void swr.mutate();
141
155
  }, [swr]);
156
+ useHostRefetch(refetch, mockRows);
142
157
  const loadMore = useCallback(() => {
143
158
  if (!hasMore || loadingMore)
144
159
  return;
@@ -196,6 +211,7 @@ export function usePaginatedQuery(alias, params, opts) {
196
211
  void rowsSwr.mutate();
197
212
  void countSwr.mutate();
198
213
  }, [rowsSwr, countSwr]);
214
+ useHostRefetch(refetch, mockRows);
199
215
  return {
200
216
  rows,
201
217
  total,
@@ -302,6 +318,77 @@ export function useAttachments() {
302
318
  const fileIds = files.flatMap((f) => (f.status === "ready" && f.file_id ? [f.file_id] : []));
303
319
  return { files, add, remove, clear, uploading, fileIds };
304
320
  }
321
+ /**
322
+ * JSON-serialize the view-state snapshot — used for BOTH change-detection (a
323
+ * fresh inline object each render must NOT re-post) and the wire payload. The
324
+ * `data` field is app-supplied `unknown`; if it can't be JSON-serialized we drop
325
+ * it and keep description + records, mirroring the host's own `data` cap (which
326
+ * JSON-encodes `data` and drops it past the size limit) — a non-serializable
327
+ * value must never crash the app's render.
328
+ */
329
+ function serializeAiContext(context) {
330
+ try {
331
+ return JSON.stringify(context);
332
+ }
333
+ catch {
334
+ return JSON.stringify({ description: context.description, records: context.records });
335
+ }
336
+ }
337
+ /**
338
+ * Publish a slice of the CURRENT SCREEN's view state to the app's ambient chat
339
+ * agent, so a member chatting alongside the app gets an agent that knows what
340
+ * they are looking at — which list is filtered to what, which record is open,
341
+ * what is typed into a form. Declarative and lifecycle-bound: mounting or
342
+ * changing `context` pushes it to the host; unmounting, renaming the `slot`, or
343
+ * passing `null` clears it. Independent components may publish different `slot`s
344
+ * concurrently (a list screen + an open detail drawer); the newest value per
345
+ * slot wins.
346
+ *
347
+ * **Push-only, and a SNAPSHOT of what the app already RENDERED to this member** —
348
+ * never a channel for chat to pull app-authority data. `records` are passed as
349
+ * raw `{ table_id, record_id }` refs (unresolved); the member's own chat agent
350
+ * acts on them only where that member's IAM already allows. The host feeds
351
+ * `description`/`data` into the agent's prompt as clearly-labeled DATA, never as
352
+ * instructions.
353
+ *
354
+ * Host-enforced caps (exceeding them truncates/drops — never an error): `slot`
355
+ * ≤ 50 chars; `description` ≤ 1000 chars (truncated with "…"); `records` ≤ 20;
356
+ * `data` must JSON-serialize to ≤ 2000 chars or the `data` field is dropped (the
357
+ * description is kept); ≤ 8 slots per app (a 9th evicts the least-recently
358
+ * updated).
359
+ *
360
+ * ```tsx
361
+ * useAiContext("orders_list", {
362
+ * description: `Viewing ${rows.length} orders filtered to status=open, sorted by due date.`,
363
+ * records: rows.map((r) => ({ table_id: r.__source_table_id, record_id: r.__source_record_id })),
364
+ * data: { filter: "status=open", sort: "due_date desc" },
365
+ * });
366
+ * ```
367
+ *
368
+ * No-ops with no embedding host (standalone `<slug>.lotics.app` — there is no
369
+ * chat surface to inform) and in mock mode. Since 0.52.
370
+ */
371
+ export function useAiContext(slot, context) {
372
+ // Serialize once for both the change key and the payload. The effect re-posts
373
+ // only when the serialized value (or slot) actually changes, so a fresh inline
374
+ // object each render never spams the host. Payloads are small (host-capped),
375
+ // so the JSON round-trip is cheap.
376
+ const serialized = context === null ? null : serializeAiContext(context);
377
+ // Post the current value on mount and whenever it changes.
378
+ useEffect(() => {
379
+ if (hasMockFlag())
380
+ return;
381
+ const payload = serialized === null ? null : JSON.parse(serialized);
382
+ postHostNotification({ type: "aiContext", slot, context: payload });
383
+ }, [slot, serialized]);
384
+ // Clear the slot on unmount or slot rename ONLY — a value-only change is
385
+ // overwritten by the post above, with no intermediate clear.
386
+ useEffect(() => {
387
+ if (hasMockFlag())
388
+ return;
389
+ return () => postHostNotification({ type: "aiContext", slot, context: null });
390
+ }, [slot]);
391
+ }
305
392
  /**
306
393
  * List the members of the app's organization — the candidate set for an
307
394
  * "assign to a member" picker. Each member is `{ id, name, email, image }`
@@ -400,16 +487,33 @@ export function useAgentRun(alias) {
400
487
  },
401
488
  };
402
489
  return handle.done
403
- .then(() => {
490
+ .then(async () => {
404
491
  if (aborted)
405
492
  return undefined;
406
- // `finish` normally settles status; guard a stream that ended without one.
407
- // Keep `output` as whatever `submit_result` set (else undefined) never
408
- // coerce the free-text into it (that string would crash a structured
409
- // consumer reading `output.<field>`; the answer stays in `text`).
493
+ // A clean stream end WITHOUT a `finish` frame is a truncation, not a
494
+ // completion an edge can close a long SSE gracefully mid-run (seen
495
+ // in production on ~2-minute runs), swallowing the frames that carry
496
+ // the structured result. The run is decoupled and settles server-side
497
+ // regardless, so the row is the source of truth: poll it, exactly
498
+ // like the dropped-with-error path below. (2026-07-18: four NOXH
499
+ // extractions completed server-side while every client showed
500
+ // failure through this hole.)
410
501
  if (acc.status === "streaming") {
411
- acc = { ...acc, status: "completed" };
502
+ const runId = runIdRef.current;
503
+ const settled = runId
504
+ ? await pollAgentRunToSettle(runId, () => aborted || !mountedRef.current)
505
+ : null;
506
+ if (aborted)
507
+ return undefined;
508
+ acc = settled ? adoptSettledRun(acc, settled) : { ...acc, status: "completed" };
412
509
  safeSetState(acc);
510
+ // Fleet visibility: how often edges cut agent SSE streams. One
511
+ // event per truncation, with whether the row rescued the result.
512
+ captureAppEvent("app_agent_stream_truncated", {
513
+ recovered: settled != null,
514
+ settled_status: settled?.status ?? null,
515
+ has_output: acc.output !== undefined,
516
+ });
413
517
  }
414
518
  return acc.output;
415
519
  })
@@ -423,10 +527,7 @@ export function useAgentRun(alias) {
423
527
  if (runId) {
424
528
  const settled = await pollAgentRunToSettle(runId, () => aborted || !mountedRef.current);
425
529
  if (settled && !aborted) {
426
- acc =
427
- settled.status === "completed"
428
- ? { ...acc, status: "completed", output: settled.output ?? acc.output }
429
- : { ...acc, status: "error", error: settled.error_message ?? "The run was stopped." };
530
+ acc = adoptSettledRun(acc, settled);
430
531
  safeSetState(acc);
431
532
  return acc.output;
432
533
  }
@@ -16,7 +16,7 @@
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, useAttachments, useMembers, useAgentRun, useAgentRuns, } from "./hooks.js";
19
+ export { useWorkflow, useQuery, useInfiniteQuery, usePaginatedQuery, useFieldOptions, useFileUpload, useAttachments, useMembers, useAgentRun, useAgentRuns, useAiContext, } from "./hooks.js";
20
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";
21
21
  export { useComments, useCommentCounts } from "./comments.js";
22
22
  export type { AppComment, AppCommentFile, CommentsState, UseCommentsArgs, CommentCountsState, UseCommentCountsArgs, } from "./comments.js";
@@ -26,7 +26,7 @@ export type { AppConfigValue } from "./rpc.js";
26
26
  export { requestGeofencedLocation, isWithinZone } from "./geolocation.js";
27
27
  export type { GeofenceZone, GeoCoords, GeofenceOutcome, GeofenceOptions } from "./geolocation.js";
28
28
  export { rpc, isEmbedded, getAppBinding } from "./rpc.js";
29
- export type { RpcOp, AppBinding } from "./rpc.js";
29
+ export type { RpcOp, AppBinding, AiContextValue, AiContextRecordRef } from "./rpc.js";
30
30
  export { openExternal } from "./open_external.js";
31
31
  export { askAi, type AskAiArgs } from "./ask_ai.js";
32
32
  export { downloadFile } from "./download.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, useAttachments, useMembers, useAgentRun, useAgentRuns, } from "./hooks.js";
18
+ export { useWorkflow, useQuery, useInfiniteQuery, usePaginatedQuery, useFieldOptions, useFileUpload, useAttachments, useMembers, useAgentRun, useAgentRuns, useAiContext, } from "./hooks.js";
19
19
  export { useComments, useCommentCounts } from "./comments.js";
20
20
  export { useViewer } from "./viewer.js";
21
21
  export { useConfig } from "./config.js";
package/dist/src/rpc.d.ts CHANGED
@@ -54,6 +54,37 @@ export interface AgentRunHandle {
54
54
  * mirror, like `AppContext` itself).
55
55
  */
56
56
  export type AppConfigValue = string | number | boolean;
57
+ /** A raw reference to one record — table + record id, passed through UNRESOLVED.
58
+ * The member's own chat agent may act on it only where that member's IAM already
59
+ * allows; the app never resolves it into data here. */
60
+ export interface AiContextRecordRef {
61
+ table_id: string;
62
+ record_id: string;
63
+ }
64
+ /**
65
+ * A snapshot of what the app RENDERED to this member on the current screen —
66
+ * published to the ambient app chat agent via `useAiContext` so the member's
67
+ * agent knows what they are looking at. Push-only: this is data the app already
68
+ * showed, never a channel for chat to pull app-authority data back out.
69
+ */
70
+ export interface AiContextValue {
71
+ /** Human-readable summary of the view — enters the agent prompt as labeled
72
+ * DATA (never instructions). Host-capped to 1000 chars. */
73
+ description: string;
74
+ /** Records the screen is showing, as raw `{ table_id, record_id }` refs
75
+ * (unresolved). Host-capped to 20. */
76
+ records?: AiContextRecordRef[];
77
+ /** Optional structured detail (filters, sort, form values). Host JSON-encodes
78
+ * it and DROPS the field past its size cap, keeping the description. */
79
+ data?: unknown;
80
+ }
81
+ /** A fire-and-forget notification the app pushes UP to its embedding host — no
82
+ * id, no reply, distinct from the request/reply `rpc()` bridge. */
83
+ export type HostNotification = {
84
+ type: "aiContext";
85
+ slot: string;
86
+ context: AiContextValue | null;
87
+ };
57
88
  export interface AppContext {
58
89
  app_id: string;
59
90
  app_name: string;
@@ -95,6 +126,20 @@ export declare function peekUrlParams(): UrlParams;
95
126
  /** Subscribe to external query changes — browser back/forward and edited URLs.
96
127
  * Embedded: the host's `url-state` broadcast; standalone: `popstate`. */
97
128
  export declare function subscribeUrlParams(cb: (params: UrlParams) => void): () => void;
129
+ /**
130
+ * Push a fire-and-forget notification to the embedding host — no id, no reply.
131
+ * Only the embedded host can receive it (it owns the chat surface), so this
132
+ * no-ops standalone (`<slug>.lotics.app` has no host to inform) and never
133
+ * throws. Distinct from `rpc()`: this is one-way, app → host.
134
+ */
135
+ export declare function postHostNotification(message: HostNotification): void;
136
+ /**
137
+ * Subscribe to the host's `refetchQueries` push. Returns an unsubscribe fn.
138
+ * Wired by every mounted query hook so an ambient-chat record mutation refreshes
139
+ * exactly the data currently on screen. No host ever posts it standalone, so the
140
+ * subscription is inert there.
141
+ */
142
+ export declare function subscribeHostRefetch(cb: () => void): () => void;
98
143
  /**
99
144
  * Start a streaming agent run. Each raw SSE text chunk is handed to `onText`
100
145
  * (the caller parses it via `agent_stream`); `done` settles when the stream
package/dist/src/rpc.js CHANGED
@@ -78,11 +78,40 @@ export function subscribeUrlParams(cb) {
78
78
  window.addEventListener("popstate", handler);
79
79
  return () => window.removeEventListener("popstate", handler);
80
80
  }
81
+ // ── Host notifications (app → host, fire-and-forget) ─────────────────────────
82
+ /**
83
+ * Push a fire-and-forget notification to the embedding host — no id, no reply.
84
+ * Only the embedded host can receive it (it owns the chat surface), so this
85
+ * no-ops standalone (`<slug>.lotics.app` has no host to inform) and never
86
+ * throws. Distinct from `rpc()`: this is one-way, app → host.
87
+ */
88
+ export function postHostNotification(message) {
89
+ const hostOrigin = getHostOrigin();
90
+ if (!hostOrigin)
91
+ return;
92
+ window.parent.postMessage(message, hostOrigin);
93
+ }
94
+ /**
95
+ * Subscribe to the host's `refetchQueries` push. Returns an unsubscribe fn.
96
+ * Wired by every mounted query hook so an ambient-chat record mutation refreshes
97
+ * exactly the data currently on screen. No host ever posts it standalone, so the
98
+ * subscription is inert there.
99
+ */
100
+ export function subscribeHostRefetch(cb) {
101
+ ensureListener();
102
+ refetchSubscribers.add(cb);
103
+ return () => {
104
+ refetchSubscribers.delete(cb);
105
+ };
106
+ }
81
107
  const pending = new Map();
82
108
  const streaming = new Map();
83
109
  /** `useUrlState` subscribers — notified when the host broadcasts new params
84
110
  * after browser back/forward. */
85
111
  const urlStateSubscribers = new Set();
112
+ /** Query hooks subscribed to the host's `refetchQueries` push — the host sends
113
+ * it after an ambient app-chat agent turn mutated records. */
114
+ const refetchSubscribers = new Set();
86
115
  let nextRpcId = 0;
87
116
  let listenerInstalled = false;
88
117
  function ensureListener() {
@@ -103,6 +132,15 @@ function ensureListener() {
103
132
  cb(msg.params);
104
133
  return;
105
134
  }
135
+ // Broadcast (no id): the host tells the app its rendered data may be stale
136
+ // after an ambient app-chat agent turn mutated records — every mounted query
137
+ // hook re-reads through its own declared queries. Push-only freshness; the
138
+ // host never reads app data through this path.
139
+ if (msg.type === "refetchQueries") {
140
+ for (const cb of refetchSubscribers)
141
+ cb();
142
+ return;
143
+ }
106
144
  if (typeof msg.id !== "number")
107
145
  return;
108
146
  // Single-response ops.
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. 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`), `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` (`AgentRunItem`, `AgentRunStep`, `AgentRunState`), `dist/src/ask_ai.d.ts` (`AskAiArgs`).
4
4
 
5
5
  ## Choosing the surface — the fields-vs-file razor
6
6
 
@@ -121,9 +121,14 @@ Both settle the in-flight `run()` promise cleanly with `undefined` — a stop is
121
121
 
122
122
  ### Runs survive dropped connections
123
123
 
124
- The run's lifetime is decoupled from the stream: the server drives it to completion and persists the result even if the connection drops. The hook reads the run id from the stream's start; if the connection then fails, it **polls the persisted run to completion** (every 2.5 s, bounded at 11 minutes) and resolves with the settled result instead of surfacing a network error so a run that settles within that 11-minute poll window survives a flaky connection. A run still going when the poll deadline passes (the hard cap is 20 min) is **not** recovered on the client: the poll gives up and the drop surfaces as an error, though the result is still persisted server-side. Only when no run id was ever received (the run never started) does the failure reject before any polling.
124
+ The run's lifetime is decoupled from the stream: the server drives it to completion and persists the result even if the connection drops. The hook reads the run id from the stream's start and recovers through the **persisted run row** the source of truthin BOTH failure shapes:
125
125
 
126
- **Warning:** on this recovery path the hook adopts the *persisted* run's output and a **free-text** run is persisted with its final answer text as the output. So after a connection-drop recovery, a free-text agent's `output` can carry the answer **string** (the one place the "never a stray string" rule doesn't hold), while `text` keeps only what streamed before the drop. If you consume free-text agents, guard with `typeof output === "string"` on the resolved value.
126
+ - **The connection drops with an error** → the hook polls the persisted run to completion (every 2.5 s, bounded at 11 minutes) and resolves with the settled result instead of surfacing a network error.
127
+ - **The stream ends cleanly WITHOUT a `finish` frame** — an edge/proxy can close a long SSE gracefully mid-run, which looks like completion but swallowed the trailing frames (including the structured result). The hook detects the missing `finish` and polls the row the same way. Each such truncation emits the `app_agent_stream_truncated` analytics event (with whether the row rescued the result), so edge-cut frequency is visible fleet-wide.
128
+
129
+ A run still going when the poll deadline passes (the hard cap is 20 min) is **not** recovered on the client: the poll gives up and the drop surfaces as an error, though the result is still persisted server-side. Only when no run id was ever received (the run never started) does the failure reject before any polling.
130
+
131
+ 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.
127
132
 
128
133
  ### Sessions
129
134
 
@@ -189,6 +194,68 @@ At least one of the four is required — an empty call rejects. The bridge carri
189
194
 
190
195
  ---
191
196
 
197
+ ## `useAiContext(slot, context)` — tell the ambient chat what the member is looking at
198
+
199
+ A member using an app inside Lotics has an **ambient chat agent** riding alongside the app (distinct from `askAi`, which opens a fresh seeded chat, and from `useAgentRun`, which runs an agent the app declares). `useAiContext` publishes a snapshot of the **current screen's view state** into that ambient chat, so when the member turns to it and asks "why is this one overdue?" or "summarize what I'm seeing", the agent already knows which list is filtered to what, which record is open, and what's typed into a form — without the member re-describing it.
200
+
201
+ ```tsx
202
+ import { useAiContext } from "@lotics/app-sdk";
203
+
204
+ // A list screen publishes what it rendered:
205
+ useAiContext("orders_list", {
206
+ description: `Viewing ${rows.length} orders filtered to status=open, sorted by due date.`,
207
+ records: rows.map((r) => ({ table_id: r.__source_table_id, record_id: r.__source_record_id })),
208
+ data: { filter: "status=open", sort: "due_date desc" },
209
+ });
210
+
211
+ // A detail drawer publishes its own slot; clears it when nothing is selected:
212
+ useAiContext(
213
+ "order_detail",
214
+ selected
215
+ ? { description: `Order ${selected.code}, status ${selected.status}`, records: [{ table_id: "tbl_orders", record_id: selected.id }] }
216
+ : null,
217
+ );
218
+ ```
219
+
220
+ ### When to use it
221
+
222
+ - **List / register screens** — the filtered, sorted set the member is scanning: a one-line `description` of the filter + count, and the visible rows as `records`.
223
+ - **Detail / record views** — which record is open, its key fields; pass the one record ref.
224
+ - **Form / composer state** — what the member has entered so far (`data`), so a mid-task question resolves against the draft.
225
+
226
+ Publish the **rendered** view, not the whole table. A screen that shows page 2 of an open-orders filter publishes those rows — that is exactly what "what I'm looking at" means.
227
+
228
+ ### Lifecycle and slots
229
+
230
+ Declarative and lifecycle-bound: mounting or changing `context` pushes it; unmounting, renaming the `slot`, or passing `null` clears it. Independent components hold **different slots** at once (a list screen + an open drawer), and the newest value per slot wins. Re-posting is change-gated — passing a fresh inline object each render does **not** spam the host; only a real value change re-publishes.
231
+
232
+ ### The caps (host-enforced — exceeding them truncates or drops, never errors)
233
+
234
+ | Field | Cap |
235
+ |---|---|
236
+ | `slot` | ≤ 50 chars |
237
+ | `description` | ≤ 1000 chars (truncated with `…`) |
238
+ | `records` | ≤ 20 refs |
239
+ | `data` | must JSON-serialize to ≤ 2000 chars, else the `data` field is **dropped** (the description is kept) |
240
+ | slots per app | ≤ 8 (a 9th evicts the least-recently-updated) |
241
+
242
+ Keep `description` tight and human-readable — it is the line the agent reads. Put anything structured (filter/sort/form values) in `data`, and remember it is dropped whole past its size cap, so don't hide load-bearing facts there that aren't also in `description`.
243
+
244
+ ### Security — push-only, a snapshot of what was already rendered
245
+
246
+ This is a **one-way push of data the app already showed this member** — never a channel for chat to pull app-authority data back out. Two consequences hold it to that:
247
+
248
+ - **`records` are raw `{ table_id, record_id }` refs, passed UNRESOLVED.** The app does not resolve them here; the member's own chat agent may read or act on them only where **that member's IAM already allows**. A ref to a record the member can't see stays inert.
249
+ - **`description` and `data` enter the agent's prompt as clearly-labeled DATA, never as instructions.** Text an app renders can't hijack the agent — the host wraps it as app-supplied view state (see [security](./security.md) for the labeled-data convention).
250
+
251
+ ### Query freshness — the mutation companion
252
+
253
+ When the ambient chat agent's turn ends and it mutated records, the host pushes every mounted query hook to re-read, so the screen the member is looking at reflects the agent's change without a manual refresh. That companion behavior is automatic — you write no code for it — and is documented with the query caching contract in [data_fetching](./data_fetching.md#caching-loading-states-and-errors).
254
+
255
+ **No-ops** with no embedding host (standalone `<slug>.lotics.app` — there's no chat surface to inform) and in mock mode.
256
+
257
+ ---
258
+
192
259
  ## Version floors
193
260
 
194
261
  The floors below are when each capability shipped in `@lotics/app-sdk`; an app pinned older silently lacks them.
@@ -200,3 +267,4 @@ The floors below are when each capability shipped in `@lotics/app-sdk`; an app p
200
267
  | `items` transcript (reasoning segments + per-tool `input`/`output`) | `@lotics/app-sdk` 0.43; rendering pairs with `@lotics/ui` ≥ 7.13 (`AgentRun` `items` prop) |
201
268
  | Live streamed-argument size on a running step (`detail`) | `@lotics/app-sdk` 0.44 |
202
269
  | `askAi` | `@lotics/app-sdk` 0.45 |
270
+ | `useAiContext` (ambient-chat view state + auto query refetch on chat mutation) | `@lotics/app-sdk` 0.52 |
@@ -83,6 +83,12 @@ server validates system conditions by `type` and never reads `field_key` on them
83
83
  - **`refetch()`** re-runs the query. Call it after a known mutation point — a successful
84
84
  `useWorkflow` call — to pull the latest state (see [./mutations.md](./mutations.md)).
85
85
  `usePaginatedQuery.refetch()` refreshes both the page and the count.
86
+ - **Ambient-chat mutations refetch automatically.** When the member's ambient chat agent (see
87
+ [./ai.md](./ai.md#useaicontextslot-context--tell-the-ambient-chat-what-the-member-is-looking-at))
88
+ finishes a turn that mutated records, the host pushes **every mounted query hook** to re-read —
89
+ so the screen reflects the change with no `refetch()` call and no reload. It refreshes exactly the
90
+ queries currently on screen (mounted hooks only); it never reaches into app data, it only tells
91
+ the app its rendered rows may be stale. Inert standalone and in mock mode.
86
92
  - A design-time fixture registered via `mount(<App />, { fixture })` plus the `?__mock=1` URL flag
87
93
  short-circuits all three hooks (rows come from the fixture, no request, `loading` stays `false`)
88
94
  — see [./runtime.md](./runtime.md).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/app-sdk",
3
- "version": "0.51.5",
3
+ "version": "0.52.1",
4
4
  "description": "Runtime SDK for Lotics custom-code apps — typed hooks, postMessage bridge, mount entry point",
5
5
  "type": "module",
6
6
  "exports": {