@lotics/app-sdk 0.51.3 → 0.52.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`) 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
 
@@ -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";
21
+ import { rpc, rpcAgentRun, postHostNotification, subscribeHostRefetch, } from "./rpc.js";
22
22
  import { initialAgentRunState, reduceAgentChunk, parseSseChunks, } from "./agent_stream.js";
23
- import { getMockRows } from "./mock.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 }`
@@ -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
 
@@ -127,10 +127,11 @@ The run's lifetime is decoupled from the stream: the server drives it to complet
127
127
 
128
128
  ### Sessions
129
129
 
130
- `run()` requires `{ sessionId }` — an **app-minted opaque key** grouping runs into a working session. Each run replays the session's prior **completed** runs (their inputs and outputs, including re-materialized image/PDF inputs) as conversation context, so a follow-up like "make it a bit less" resolves against the previous result. Two rules keep this sane:
130
+ `run()` requires `{ sessionId }` — an **app-minted opaque key** grouping runs into a working session. Each run replays the session's prior **completed** runs (their inputs and outputs, including re-materialized image/PDF inputs) as conversation context, so a follow-up like "make it a bit less" resolves against the previous result. Three rules keep this sane:
131
131
 
132
132
  - **The app owns the state.** Always pass the authoritative current state in `input` — the session context is memory, not the source of truth.
133
133
  - **Mint a new `sessionId` to clear context.** There is no reset call; a fresh key is a fresh session.
134
+ - **One-shot agents get a fresh `sessionId` per run.** Session context only pays for conversational follow-ups. If every run is self-contained (the app passes the complete input each time — e.g. a document-extraction agent), reusing a key replays dead context into every request — every replayed run still bills its input tokens, so a one-shot agent on a shared key pays for history it never uses. Append a per-run nonce to the key instead. (Media itself replays as a provider file reference — uploaded once, referenced by id — so a long session no longer grows toward the provider's request-size cap; the cost of dead context is tokens, not payload.)
134
135
 
135
136
  Sessions are scoped to the authenticated member who ran them: two members using the same `sessionId` string share nothing, and a member can never read or extend another member's thread.
136
137
 
@@ -188,6 +189,68 @@ At least one of the four is required — an empty call rejects. The bridge carri
188
189
 
189
190
  ---
190
191
 
192
+ ## `useAiContext(slot, context)` — tell the ambient chat what the member is looking at
193
+
194
+ 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.
195
+
196
+ ```tsx
197
+ import { useAiContext } from "@lotics/app-sdk";
198
+
199
+ // A list screen publishes what it rendered:
200
+ useAiContext("orders_list", {
201
+ description: `Viewing ${rows.length} orders filtered to status=open, sorted by due date.`,
202
+ records: rows.map((r) => ({ table_id: r.__source_table_id, record_id: r.__source_record_id })),
203
+ data: { filter: "status=open", sort: "due_date desc" },
204
+ });
205
+
206
+ // A detail drawer publishes its own slot; clears it when nothing is selected:
207
+ useAiContext(
208
+ "order_detail",
209
+ selected
210
+ ? { description: `Order ${selected.code}, status ${selected.status}`, records: [{ table_id: "tbl_orders", record_id: selected.id }] }
211
+ : null,
212
+ );
213
+ ```
214
+
215
+ ### When to use it
216
+
217
+ - **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`.
218
+ - **Detail / record views** — which record is open, its key fields; pass the one record ref.
219
+ - **Form / composer state** — what the member has entered so far (`data`), so a mid-task question resolves against the draft.
220
+
221
+ 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.
222
+
223
+ ### Lifecycle and slots
224
+
225
+ 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.
226
+
227
+ ### The caps (host-enforced — exceeding them truncates or drops, never errors)
228
+
229
+ | Field | Cap |
230
+ |---|---|
231
+ | `slot` | ≤ 50 chars |
232
+ | `description` | ≤ 1000 chars (truncated with `…`) |
233
+ | `records` | ≤ 20 refs |
234
+ | `data` | must JSON-serialize to ≤ 2000 chars, else the `data` field is **dropped** (the description is kept) |
235
+ | slots per app | ≤ 8 (a 9th evicts the least-recently-updated) |
236
+
237
+ 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`.
238
+
239
+ ### Security — push-only, a snapshot of what was already rendered
240
+
241
+ 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:
242
+
243
+ - **`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.
244
+ - **`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).
245
+
246
+ ### Query freshness — the mutation companion
247
+
248
+ 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).
249
+
250
+ **No-ops** with no embedding host (standalone `<slug>.lotics.app` — there's no chat surface to inform) and in mock mode.
251
+
252
+ ---
253
+
191
254
  ## Version floors
192
255
 
193
256
  The floors below are when each capability shipped in `@lotics/app-sdk`; an app pinned older silently lacks them.
@@ -199,3 +262,4 @@ The floors below are when each capability shipped in `@lotics/app-sdk`; an app p
199
262
  | `items` transcript (reasoning segments + per-tool `input`/`output`) | `@lotics/app-sdk` 0.43; rendering pairs with `@lotics/ui` ≥ 7.13 (`AgentRun` `items` prop) |
200
263
  | Live streamed-argument size on a running step (`detail`) | `@lotics/app-sdk` 0.44 |
201
264
  | `askAi` | `@lotics/app-sdk` 0.45 |
265
+ | `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.3",
3
+ "version": "0.52.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": {