@lotics/app-sdk 0.100.1 → 0.101.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.
Files changed (108) hide show
  1. package/AGENTS.md +32 -47
  2. package/dist/agent_stream.d.ts +131 -0
  3. package/dist/ask_ai.d.ts +27 -0
  4. package/dist/attachments.d.ts +58 -0
  5. package/dist/chunk-ARV5FAU5.js +1132 -0
  6. package/dist/comments.d.ts +89 -0
  7. package/dist/error_report.d.ts +9 -0
  8. package/dist/folder_pick.d.ts +8 -0
  9. package/dist/geolocation.d.ts +42 -0
  10. package/dist/hooks.d.ts +251 -0
  11. package/dist/{src/index.d.ts → index.d.ts} +13 -22
  12. package/dist/index.js +31331 -0
  13. package/dist/index.js.LEGAL.txt +11 -0
  14. package/dist/members.d.ts +32 -0
  15. package/dist/mock.d.ts +37 -0
  16. package/dist/mount.d.ts +19 -0
  17. package/dist/new_record.d.ts +37 -0
  18. package/dist/open_app.d.ts +12 -0
  19. package/dist/open_external.d.ts +10 -0
  20. package/dist/overlay.d.ts +25 -0
  21. package/dist/queries.d.ts +231 -0
  22. package/dist/recording.d.ts +47 -0
  23. package/dist/recording_state.d.ts +43 -0
  24. package/dist/rename_file.d.ts +13 -0
  25. package/dist/router.d.ts +10 -0
  26. package/dist/router.js +97 -0
  27. package/dist/row.d.ts +87 -0
  28. package/dist/rpc.d.ts +114 -0
  29. package/dist/select.d.ts +24 -0
  30. package/dist/shared_types.d.ts +8 -0
  31. package/dist/store.d.ts +43 -0
  32. package/dist/types.d.ts +36 -0
  33. package/dist/upload/optimize.d.ts +30 -0
  34. package/dist/upload/pipeline.d.ts +36 -0
  35. package/dist/upload/transport.d.ts +19 -0
  36. package/dist/url_params.d.ts +55 -0
  37. package/dist/use_recents.d.ts +15 -0
  38. package/dist/{src/use_url_state.d.ts → use_url_state.d.ts} +0 -2
  39. package/dist/viewer.d.ts +41 -0
  40. package/dist/written.d.ts +79 -0
  41. package/docs/ai.md +74 -133
  42. package/docs/data_fetching.md +209 -290
  43. package/docs/files.md +61 -51
  44. package/docs/members_and_options.md +92 -62
  45. package/docs/mutations.md +136 -205
  46. package/docs/navigation_and_state.md +26 -35
  47. package/docs/queries.md +144 -207
  48. package/docs/recipes.md +21 -45
  49. package/docs/runtime.md +74 -137
  50. package/docs/security.md +8 -11
  51. package/docs/workflows.md +189 -174
  52. package/package.json +27 -28
  53. package/dist/src/agent_stream.d.ts +0 -200
  54. package/dist/src/agent_stream.js +0 -314
  55. package/dist/src/ask_ai.d.ts +0 -40
  56. package/dist/src/ask_ai.js +0 -35
  57. package/dist/src/attachments.d.ts +0 -68
  58. package/dist/src/attachments.js +0 -93
  59. package/dist/src/comments.d.ts +0 -127
  60. package/dist/src/comments.js +0 -192
  61. package/dist/src/download.js +0 -54
  62. package/dist/src/geolocation.d.ts +0 -64
  63. package/dist/src/geolocation.js +0 -96
  64. package/dist/src/hooks.d.ts +0 -781
  65. package/dist/src/hooks.js +0 -860
  66. package/dist/src/index.js +0 -34
  67. package/dist/src/members.d.ts +0 -105
  68. package/dist/src/members.js +0 -62
  69. package/dist/src/mock.d.ts +0 -118
  70. package/dist/src/mock.js +0 -124
  71. package/dist/src/mount.d.ts +0 -47
  72. package/dist/src/mount.js +0 -34
  73. package/dist/src/new_record.d.ts +0 -74
  74. package/dist/src/new_record.js +0 -117
  75. package/dist/src/open_app.d.ts +0 -15
  76. package/dist/src/open_app.js +0 -18
  77. package/dist/src/open_external.d.ts +0 -16
  78. package/dist/src/open_external.js +0 -19
  79. package/dist/src/recording.d.ts +0 -59
  80. package/dist/src/recording.js +0 -30
  81. package/dist/src/recording_state.d.ts +0 -59
  82. package/dist/src/recording_state.js +0 -94
  83. package/dist/src/router.d.ts +0 -17
  84. package/dist/src/router.js +0 -144
  85. package/dist/src/row.d.ts +0 -159
  86. package/dist/src/row.js +0 -254
  87. package/dist/src/rpc.d.ts +0 -207
  88. package/dist/src/rpc.js +0 -904
  89. package/dist/src/select.d.ts +0 -48
  90. package/dist/src/select.js +0 -40
  91. package/dist/src/types.d.ts +0 -115
  92. package/dist/src/types.js +0 -1
  93. package/dist/src/upload/optimize.d.ts +0 -54
  94. package/dist/src/upload/optimize.js +0 -207
  95. package/dist/src/upload/pipeline.d.ts +0 -55
  96. package/dist/src/upload/pipeline.js +0 -52
  97. package/dist/src/upload/transport.d.ts +0 -42
  98. package/dist/src/upload/transport.js +0 -128
  99. package/dist/src/url_params.d.ts +0 -93
  100. package/dist/src/url_params.js +0 -215
  101. package/dist/src/use_optimistic.d.ts +0 -27
  102. package/dist/src/use_optimistic.js +0 -27
  103. package/dist/src/use_recents.d.ts +0 -19
  104. package/dist/src/use_recents.js +0 -71
  105. package/dist/src/use_url_state.js +0 -73
  106. package/dist/src/viewer.d.ts +0 -26
  107. package/dist/src/viewer.js +0 -47
  108. /package/dist/{src/download.d.ts → download.d.ts} +0 -0
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` (`AgentRunState`, `AgentUIPart`), `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. **`useAiContext(slot, context)`** feeds the member's *ambient* chat agent a snapshot of what the current screen is showing. The authority model agent runs execute under is [security](./security.md). Exact signatures: `dist/hooks.d.ts` (`useAgentRun`, `useAgentRuns`, `useAiContext`), `dist/agent_stream.d.ts` (`AgentRunState`, `AgentUIPart`), `dist/ask_ai.d.ts` (`AskAiArgs`).
4
4
 
5
5
  ## Choosing the surface — the fields-vs-file razor
6
6
 
@@ -13,7 +13,7 @@ Don't run a structured extraction through `askAi` (the result is stranded in a c
13
13
 
14
14
  ## Declared agents — what `useAgentRun` runs
15
15
 
16
- An agent is **declared on the app server-side, by alias**, with the `set_app_agent` tool (removed with `remove_app_agent`) — the only verb that creates a binding, so no deploy binds an alias the app does not already have. A deploy does push an alias it has: the prose in `src/agents/<alias>.md` and the authored `inputs`/`outputs` in `package.json#lotics.agents.<alias>` go through `set_app_agent` whenever either differs from the live row, before the bundle ships. Every other key of that map is a reflection `lotics app pull` refreshes and no verb sends, so replaying a stale snapshot cannot revert a grant bound elsewhere. Invoking an alias that isn't bound fails with a "no agent alias" error naming `set_app_agent`. (After a deploy, the CLI warns about any manifest alias not bound on the server.)
16
+ An agent is **declared on the app server-side, by alias**, with the `set_app_agent` tool (removed with `remove_app_agent`) — the one way an agent is bound or changed, each write a new version of the app; a deploy never touches it, and `get_app_agent` reads one back. Invoking an alias that isn't bound fails with a "no agent alias" error naming `set_app_agent`, and a deploy warns about any alias the code calls that is not bound.
17
17
 
18
18
  A declaration carries:
19
19
 
@@ -26,7 +26,7 @@ A declaration carries:
26
26
  | `workflow_aliases` | The app's own workflows the agent may invoke via `run_app_workflow` — its **entire write surface** |
27
27
  | `model_tier` | Optional model tier — `haiku` \| `sonnet` \| `opus`. Omit (preferred) to follow the platform default tier, resolved at run time. A tier names capability, not a version, so the agent tracks model generations with no rewrite. Pin only a deliberate, tested choice |
28
28
  | `effort_level` | Optional reasoning depth for adaptive-thinking tiers. Requires an explicit `model_tier` pin — effort is tuned per tier |
29
- | `prefix_cache_ttl` | Optional prompt-cache window for the agent's stable prefix (tools + system). **Omit it** — the default (`"5m"`, Anthropic's own) is right for essentially every agent. `"1h"` is a leveraged bet: it doubles the write price (2x the input rate against 1.25x, both reading back at 0.1x) to buy only the five-minute-to-one-hour band. Declining it is never "uncached" — the same prefix stays cached at the default window. Set `"1h"` only with measured cadence showing runs reliably land in that band, such as a scheduled sweep |
29
+ | `prefix_cache_ttl` | Optional prompt-cache window for the agent's stable prefix (tools + system). **Omit it** — the default `"5m"` is right for essentially every agent. `"1h"` writes at 2x the input rate (against 1.25x) to buy only the five-minute-to-one-hour band; set it only with measured cadence showing runs land there, such as a scheduled sweep |
30
30
  | `inputs` | Optional typed input schema for one run — the same vocabulary as workflow inputs (`text`, `number`, `file`, `member`, `record_link`, `select`, …). The server validates every run payload against it. **Every field defaults to `required: true`**, exactly as `outputs` does — the two extend the same base — so an input the caller may legitimately omit needs `"required": false`, or the run is rejected before the agent sees it |
31
31
  | `outputs` | Optional typed output schema — the declaration vocabulary, including the two ways a `select` names its option set, is [mutations](./mutations.md#structured-results-return-data). Declared → **structured agent** (the run must emit a matching result); omitted → **free-text agent** (the answer is the final prose). **Every field defaults to `required: true`** (the same base schema as `inputs` above) — mark `"required": false` on anything the source may legitimately not carry. It matters most for `number`, which has no blank: text can answer `""`, but a required number leaves only a wrong value or a rejected submission |
32
32
 
@@ -34,11 +34,11 @@ A declaration carries:
34
34
 
35
35
  **Input validation and tenant bounds.** Run inputs get the same server-side enforcement as workflow inputs: `record_link` ids must live in the declared table, `member` ids in the declared group, `file` ids in the app's workspace (see [the caller boundary](./security.md)). The run executes under the **app owner's** authority in the app's own workspace.
36
36
 
37
- **Record data is reached only through declared aliases.** Every tool that reads or writes record VALUES by table id is rejected in `tool_names` — there is no `query_records`, `get_record`, `create_records`, or `update_records` for an agent. The agent reads with `run_app_query(alias, params)` and writes with `run_app_workflow(alias, inputs)`, both refused for any alias outside `query_aliases` / `workflow_aliases`. This is stricter than a list of tables: a query template fixes its own tables, joins, filters AND projection, so it bounds which **rows and columns** the agent sees; writes travel the app's declared mutation path, so workflow validation and table hooks apply. It matters because a run carries the OWNER's authority while any member can invoke it — raw table access would let a caller read or write, under that authority, anything the owner can. `is_current_member` inside a declared query resolves to the **invoking** member, so a self-scoped query scopes to the person using the agent, exactly as it does in the app's UI.
37
+ **Record data is reached only through declared aliases.** Every tool that reads or writes record VALUES by table id is rejected in `tool_names` — there is no `query_records`, `get_record`, `create_records`, or `update_records` for an agent. The agent reads with `run_app_query(alias, params)` and writes with `run_app_workflow(alias, inputs)`, both refused for any alias outside `query_aliases` / `workflow_aliases`. A query template fixes its own tables, joins, filters AND projection, so it bounds which **rows and columns** the agent sees; writes travel the app's declared mutation path, so workflow validation and table hooks apply. `is_current_member` inside a declared query resolves to the **invoking** member, so a self-scoped query scopes to the person using the agent, exactly as it does in the app's UI.
38
38
 
39
39
  So an agent that reads or writes records needs a named query or workflow for each thing it touches — the same declaration the app's own UI uses. Declare only what that agent needs; a second agent in the same app can declare a narrower set.
40
40
 
41
- **Typing.** `lotics app pull` / `lotics app codegen` emit `.lotics/app_agents.d.ts`, which augments the SDK's `AppAgents` (alias → input shape) and `AppAgentResults` (alias → declared output shape) interfaces — `useAgentRun("recognize")` then types both `run(input)` and `output`. An alias with no codegen falls back to `Record<string, unknown>` input / `unknown` output.
41
+ **Typing.** `.lotics/app_agents.d.ts`, generated from the app's live bindings whenever a sandbox session opens on the app, augments the SDK's `AppAgents` (alias → input shape) and `AppAgentResults` (alias → declared output shape) interfaces — `useAgentRun("recognize")` then types both `run(input)` and `output`. An alias it does not carry — every alias, in a project on your own machine — falls back to `Record<string, unknown>` input / `unknown` output.
42
42
 
43
43
  ---
44
44
 
@@ -57,8 +57,8 @@ await recognize.run({ image_file_id: fileId }, { sessionId });
57
57
 
58
58
  | Member | Type | What it is |
59
59
  |---|---|---|
60
- | `run` | `(input, { sessionId, replace? }) => Promise<AgentRunLanding<TOutput>>` | Start a run. Streams progress into the hook's state and resolves with **how the leg ended** — `{kind:"settled", output?, text}`, `{kind:"parked"}`, `{kind:"failed", error}` or `{kind:"aborted"}`. Switch on `kind`; do NOT read the hook's state to tell them apart, because it has not committed when the promise resolves (that race closed dialogs over live, answerable runs). A run failure is DATA here, so no try/catch is needed for it. **Single-flight:** while a run is in flight, calling it again returns the in-flight run's promise — an accidental double-press joins the first run instead of billing a second one. Pass `replace: true` to deliberately abort-and-restart; the replaced run still executes and bills server-side |
61
- | `cancel` | `() => void` | Stop the run **server-side** (saves tokens) and locally. Wire a user-facing Stop button to this. The platform stamps `app_agent_runs.cancel_requested_at`, which is what distinguishes a user Stop from the unmount that `abort` also serves — so there is no client analytics event for it |
60
+ | `run` | `(input, { sessionId, replace? }) => Promise<AgentRunLanding<TOutput>>` | Start a run. Streams progress into the hook's state and resolves with **how the leg ended** — `{kind:"settled", output?, text}`, `{kind:"parked"}`, `{kind:"failed", error}` or `{kind:"aborted"}`. Switch on `kind`; do NOT read the hook's state to tell them apart, because it has not committed when the promise resolves. A run failure is DATA here, never a rejection. **Single-flight:** while a run is in flight, calling it again returns the in-flight run's promise — an accidental double-press joins the first run instead of billing a second one. Pass `replace: true` to deliberately abort-and-restart; the replaced run still executes and bills server-side |
61
+ | `cancel` | `() => void` | Stop the run **server-side** (saves tokens) and locally. Wire a user-facing Stop button to this; the platform stamps `app_agent_runs.cancel_requested_at` |
62
62
  | `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) |
63
63
  | `status` | `"idle" \| "streaming" \| "awaiting_input" \| "completed" \| "error"` | Whole-run state. `awaiting_input` = the run is PARKED on a question the agent asked (see the ask-back section below). `abort`/`cancel` reset it to `"idle"` (and clear the partial transcript) |
64
64
  | `pendingChoice` | `PendingChoice \| null` | The agent's pending question(s) — non-null exactly while `status` is `awaiting_input`. `questions` maps 1:1 onto `@lotics/ui`'s `ClarifyWizard` (`{question, options: {label, description}[], allow_custom}`) |
@@ -95,11 +95,11 @@ The terminal `submit_result` call is captured into `output`, **not** rendered as
95
95
 
96
96
  *Before it starts*: the request is refused (most often a 402 when the workspace is out of AI credits — "Credit quota exceeded. Upgrade your plan for continued AI access." — or a 403/404). You get that response's `message` alone; the rest of the body (`error`, `plan_id`, `credits_used`, …) never reaches the string, and a non-JSON body or any 5xx is replaced by a status-derived message.
97
97
 
98
- *After it starts*: the run dies mid-flight, and the message is written **in the triggering member's language** (the workspace default when an anonymous visitor triggered it). An error the platform wrote surfaces as-is, so running out of credits **during** a run still says to upgrade rather than to retry. A provider or tool failure does NOT — its wording is a debugging string that names our request shape, so it becomes one of two sentences: the request itself was rejected (an attachment that is corrupt or too large — retrying unchanged cannot help), or it broke transiently and is worth another run. The raw error is kept in error tracking with the run id.
98
+ *After it starts*: the run dies mid-flight, and the message is written **in the triggering member's language** (the workspace default for an anonymous visitor). An error the platform wrote surfaces as-is, so running out of credits **during** a run still says to upgrade. A provider or tool failure becomes one of two sentences: the request itself was rejected (an attachment that is corrupt or too large — retrying unchanged cannot help), or it broke transiently and is worth another run.
99
99
 
100
- Two caveats on that language guarantee. A message written at its throw site rather than fixed on its error class stays English — those strings are also what the chat agent reads to correct its own tool calls, so they are deliberately one language. And the *before it starts* case above is the API response's own `message`, which is not localized either. So an app that must be wholly in one language should render its own copy on failure rather than the platform's.
100
+ Two messages stay English: one written at its throw site rather than fixed on its error class (the chat agent reads those to correct its own tool calls), and the *before it starts* response's own `message`. An app that must be wholly in one language renders its own copy on failure.
101
101
 
102
- So `error={run.error}` is safe to render verbatim. What it is NOT is machine-readable: `status` tells you a run failed, not why, and the difference between "worth retrying" and "retrying cannot help" lives in the sentence rather than in a code. **Do not parse it** — the wording is copy and will change. If your app needs to act on the distinction (a retry button that disables itself when a retry is futile, an upgrade prompt on a quota hit), that discriminator can be exposed; it is classified server-side already and simply is not on the wire yet.
102
+ So `error={run.error}` is safe to render verbatim, and never machine-readable: **do not parse it** — the wording is copy and will change. If your app needs the retry-vs-futile distinction, file a `lotics report` asking for it.
103
103
 
104
104
  ### The agent asks back — `pendingChoice` / `answerChoice`
105
105
 
@@ -125,14 +125,12 @@ const run = useAgentRun("importer");
125
125
  ) : null}
126
126
  ```
127
127
 
128
- The ask renders in the feed as a settled tool row once answered (the answer rides its
129
- on-demand reveal). `run()` resolves `{kind:"parked"}` when the run parks — nothing for you
130
- to do there, the wizard renders off `pendingChoice` — and the continuation's landing (from
131
- `answerChoice`) carries the final output, or `parked` again for a follow-up ask. The question is as
132
- connection-decoupled as the run: a dropped stream can't lose it — the hook's recovery poll
133
- rebuilds the pending question from the persisted run, so `awaiting_input` always yields an
134
- answerable `pendingChoice` (the one unrecoverable corner surfaces a retryable `error`, never
135
- a silent dead end). If the server refuses an answer (an invalid submission, a raced cancel
128
+ The ask renders in the feed as a settled tool row once answered. `run()` resolves
129
+ `{kind:"parked"}` when the run parks — the wizard renders off `pendingChoice` — and the
130
+ continuation's landing (from `answerChoice`) carries the final output, or `parked` again for a
131
+ follow-up ask. A dropped stream can't lose the question: the recovery poll rebuilds it from the
132
+ persisted run, so `awaiting_input` always yields an answerable `pendingChoice` (or a retryable
133
+ `error`). If the server refuses an answer (an invalid submission, a raced cancel
136
134
  or expiry, a connection failure), `answerChoice` REJECTS and the pending question is
137
135
  restored — surface the error and let the user submit again; nothing is half-committed.
138
136
  `cancel()` on a parked run settles it `aborted` immediately; an unanswered park expires
@@ -153,19 +151,17 @@ The server validates the agent's submitted result strictly (unknown keys rejecte
153
151
 
154
152
  Treat `output` as a trusted *shape* carrying untrusted *values*: render it into a review surface and let the user confirm before a workflow commits it. Never write model output straight to records without a review step.
155
153
 
156
- ### Errors — two channels, handle both
154
+ ### Errors — one landing, and one quiet mode
157
155
 
158
- 1. **`run()` rejects (throws)** when the run can't start or the stream fails unrecoverably: AI-credit quota exhausted, the per-member concurrency cap, an undeclared alias, input validation failure, or a network error. Wrap the call in try/catch and surface the message.
159
- 2. **An in-run failure resolves.** A model or tool error mid-run arrives as a stream frame: `run()` resolves `undefined`, `status` becomes `"error"`, and `error` carries the message. Checking only try/catch misses this path; checking only `status` misses the first.
156
+ A run that cannot start (AI-credit quota exhausted, the per-member concurrency cap, an undeclared alias, input validation, a network error) and one that fails mid-run both **resolve** `{kind:"failed", error}`, with `status` `"error"`. Only `answerChoice` rejects, when the server refuses an answer.
160
157
 
161
158
  ```tsx
162
- try {
163
- const output = await recognize.run(input, { sessionId });
164
- if (recognize.status === "error" || output === undefined) { /* surface recognize.error */ }
165
- } catch (err) { /* quota / network / validation — surface err.message */ }
159
+ const landing = await recognize.run(input, { sessionId });
160
+ if (landing.kind === "failed") { /* surface landing.error */ }
161
+ else if (landing.kind === "settled" && landing.output === undefined) { /* no result */ }
166
162
  ```
167
163
 
168
- Server-side failure modes are not all equally loud. A mid-run model/tool exception and the max-duration cap surface through channel 2 (the live run errors; a capped run's live message is a generic "The run was stopped." — the persisted run carries "Run exceeded the 20-minute limit."). But two modes are **quiet on the live stream**: a structured agent that finished without ever submitting a result, and a free-text agent that produced no text, end the live stream with `status` `"completed"` and no result — only the *persisted* run settles as an error ("Run ended without producing a structured result." / "Run produced no output."). On the live hook the only signal is the missing output, which is why the example checks `output === undefined` and not just `status`.
164
+ A capped run's live message is a generic "The run was stopped." (the persisted run carries "Run exceeded the 20-minute limit."). Two modes are **quiet on the live stream**: a structured agent that finished without ever submitting a result, and a free-text agent that produced no text, land `settled` with no result — only the *persisted* run settles as an error. That is why the example checks `output === undefined`.
169
165
 
170
166
  ### `cancel` vs `abort`
171
167
 
@@ -175,7 +171,7 @@ Server-side failure modes are not all equally loud. A mid-run model/tool excepti
175
171
  | Local effect | Clears state to `idle` | Clears state to `idle` |
176
172
  | Use for | A user-facing **Stop** button | Unmount / navigating away (the hook already calls it on unmount) |
177
173
 
178
- Both settle the in-flight `run()` promise cleanly with `undefined` — a stop is not a failure. A dropped connection does **not** cancel a run; `cancel()` is the only stop path.
174
+ Both settle the in-flight `run()` promise with `{kind:"aborted"}` — a stop is not a failure. A dropped connection does **not** cancel a run; `cancel()` is the only stop path.
179
175
 
180
176
  **Warning:** server-side cancellation is honored *between agent steps* — a cancel issued mid-way through one long generation takes effect at the next step boundary, not instantly. The UI should reflect "stopping" optimistically (the local state clears at once).
181
177
 
@@ -188,11 +184,11 @@ The run's lifetime is decoupled from the stream: the server drives it to complet
188
184
 
189
185
  Either way the run itself is unaffected — it keeps executing server-side and settles in `app_agent_runs`, which is where its outcome is read.
190
186
 
191
- **Anonymous runs poll with a capability token, handled for you.** On a publicly-shared app a visitor has no member identity, so the server cannot authorize their poll by ownership — `triggered_by_member_id` is null by design. Instead the run response carries a per-run token (`x-app-agent-run-token`); the SDK stores it against that run id and replays it on the poll, on `cancel`, and on `answerChoice`. It is transport-level, like the password-session token — never surfaced to app code, and never issued for a member run (identity already authorizes those). Nothing to wire: an app calls `useAgentRun` the same way on both. The token is bound to ONE run, so it cannot read another visitor's, and it is why a dropped connection on a public app recovers rather than losing the answer.
187
+ **Anonymous runs poll with a capability token, handled for you.** A public-app visitor has no member identity to authorize a poll by, so the run response carries a per-run token (`x-app-agent-run-token`) the SDK replays on the poll, on `cancel`, and on `answerChoice` — never surfaced to app code, never issued for a member run, and bound to ONE run.
192
188
 
193
- 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.
189
+ 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. When no run id was ever received (the run never started), the landing is `failed` with no polling.
194
190
 
195
- 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.
191
+ 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 and not client-readable (`useAgentRuns` reaches it on no transport — see the limitation below), so treat the streamed prefix as terminal.
196
192
 
197
193
  ### Sessions
198
194
 
@@ -212,35 +208,30 @@ Sessions are scoped to the authenticated member who ran them: two members using
212
208
  |---|---|---|
213
209
  | **Perceived natively** | `image/*`, `application/pdf` | A vision / document part — the agent literally sees it |
214
210
  | **Materialized** | Word (`.docx`/`.doc`), Excel (`.xlsx`/`.xls`), CSV, and text (`.txt`, `.md`, `.json`, `.eml`, `.html`, `.xml`, `.yaml`) | Read server-side by the same engines `view_files` uses and inlined into the run's message, truncated at 40,000 characters with the agent told when that happened |
211
+ | **Unreadable** | Archives, audio, video | The run fails immediately, naming the file |
215
212
 
216
- **Pictures inside a Word document are delivered as images**, so a scanned page, an ID card photographed into a `.docx`, or a screenshot pasted into one is READ, not merely described. Any picture that is skipped or unresolvable is counted and stated in the text, so a partially-read document never reads as a complete one.
217
- | **Unreadable** | Archives, audio, video | The run fails immediately, naming the file — a fact about the upload, not a gap in your configuration |
213
+ **Pictures inside a Word document are delivered as images**, so a scanned page photographed into a `.docx` is READ, not merely described; a picture skipped or unresolvable is counted and stated in the text.
218
214
 
219
215
  Every file in a `multi` input is materialized — nothing is collapsed to the first. Get the ids from [`useFileUpload` / `useAttachments`](./files.md).
220
216
 
221
- **So do not dispatch on the file type.** Every tier above reads, which means the format tells you
222
- nothing about what a document IS — the same claim form arrives as `.xlsx` from a laptop, as a PDF
223
- through a chat app, and as a photo of a printout. An intake that routes on the extension is reading the envelope, and it fails
224
- SILENTLY: the wrong extractor runs, returns something correctly shaped, and the user is handed a
225
- confident wrong answer. Let one agent read the drop and say what each document is, keyed on what
226
- only the content carries. Prefer separate output slots (`don_hang`, `hoa_don`, plus a named
227
- `bo_qua` for what fits neither) over a `loai` discriminator beside a payload — which slot a
228
- document lands in IS the classification, so there is no second field free to disagree with it, and
229
- nothing disappears quietly.
217
+ **So do not dispatch on the file type** — the same form arrives as `.xlsx`, as a PDF and as a
218
+ photo of a printout, and routing on the extension runs the wrong extractor silently. Let one agent
219
+ read the drop and say what each document is. Prefer separate output slots (one per document kind,
220
+ plus a named slot for what fits neither) over a kind discriminator beside a payload: which slot a
221
+ document lands in IS the classification.
230
222
 
231
- **Why no tool:** the content ends up in the agent's context either way, so making it fetch what the server already holds costs a model round-trip and buys nothing. It also keeps the capability boundary tight — an agent that reads its own input never gains the ability to read *other* files in the workspace.
223
+ **Why no tool:** fetching what the server already holds costs a model round-trip, and an agent
224
+ that reads its own input never gains the ability to read *other* files in the workspace.
232
225
 
233
226
  **When to add a tool anyway.** Only to reach *past* what was inlined. A spreadsheet is materialized at 100 rows × 50 columns per sheet and a Word file at 1,000 elements; overflow is reported, never silent. If the agent must read row 4,000 of a large sheet, add `excel_get_range` / `excel_find_cells` (or `view_files` for an ad-hoc second look at a different file).
234
227
 
235
228
  ### Auth, quota, and bounds
236
229
 
237
- - **Member-authenticated, embedded-only.** Agent runs require a signed-in member — an anonymous visitor to a public/standalone app is rejected with an explicit error ("App agent runs require an authenticated member…"). Runs work embedded in the product and in the `lotics app dev` harness (where they run as the CLI key's member).
230
+ - **Member-authenticated, embedded-only.** Agent runs require a signed-in member — an anonymous visitor to a public/standalone app is rejected with an explicit error ("App agent runs require an authenticated member…"). Runs work embedded in the product.
238
231
  - **Owner-billed.** Token usage is recorded against the app's organization's AI credits. The quota is enforced before the first model call (a `run()` rejection) and re-checked between steps (a mid-run exhaustion settles the run as an error).
239
232
  - **Hard duration cap: 20 minutes per run.** A capped run settles as an error — but with whatever partial transcript and captured output it produced, recoverable from the persisted run.
240
233
  - **Concurrency cap: 5 in-flight runs per member.** Exceeding it rejects with "Too many agent runs in progress".
241
- - **One tool result may not exceed 512 KiB.** A tool's result is persisted into the run's transcript and replayed on every later step, so an oversized one is paid again per step until the run stops fitting in a request. A tool that overruns fails by name ("Tool \"x\" returned N bytes…") rather than being delivered; narrow the call, or use a tool that returns a reference instead of the bytes. Reaching this from a normal call means too much was asked for at once — read a range, not a whole sheet.
242
-
243
- **Limitation (dev harness):** `lotics app dev` streams runs but does not forward the run id, so in the dev loop `cancel()` degrades to a local `abort()` (the run keeps executing server-side) and connection-drop recovery is unavailable. Both work fully in the embedded product.
234
+ - **One tool result may not exceed 512 KiB**, because it is replayed on every later step. A tool that overruns fails by name ("Tool \"x\" returned N bytes…"); narrow the call — read a range, not a whole sheet — or use a tool that returns a reference instead of the bytes.
244
235
 
245
236
  ---
246
237
 
@@ -248,7 +239,7 @@ nothing disappears quietly.
248
239
 
249
240
  Reads a session's persisted run history, oldest-first: `{ runs, loading, error, refetch }`, where each `AgentRunRecord` carries `id`, `agent_alias`, `session_id`, `status`, `input`, `output`, `error_message`, `started_at`, `completed_at`. `opts` takes `{ enabled?, revalidateOnFocus? }`. A persisted record's `output` is the validated structured object for a structured agent and the **final answer string** for a free-text agent.
250
241
 
251
- **Limitation:** the embedded product host does not currently implement the history op — `useAgentRuns` errors with "Unknown RPC op: agentRuns" in an embedded app, and the dev harness rejects it the same way (standalone anonymous callers are rejected as unauthenticated). The history *exists* server-side (it is what feeds session context), but this hook cannot read it from any current transport. Do not build a session-log UI on it yet; keep the visible log in app state from the live `useAgentRun` results instead.
242
+ **Limitation:** the embedded product host implements no history op — `useAgentRuns` errors with "Unknown RPC op: agentRuns" in an embedded app, and a standalone caller is anonymous, so it is refused. The history *exists* server-side (it is what feeds session context), but this hook reads it from no transport. Do not build a session-log UI on it; keep the visible log in app state from the live `useAgentRun` results instead.
252
243
 
253
244
  ---
254
245
 
@@ -277,7 +268,7 @@ At least one of the four is required — an empty call rejects. The bridge carri
277
268
 
278
269
  **Name the task in `prompt`.** Word/Excel file bytes are not inlined into the model's context — a clear brief ("update the header of this invoice…") makes the agent read the attached file first instead of asking what to do.
279
270
 
280
- **Embedded-only.** `askAi` rejects in standalone mode ("askAi is only available when the app runs inside Lotics") and in the `lotics app dev` harness (unknown op). It also rejects on the rare embedded screen where the chat surface is unavailable ("chat is unavailable on this screen") — treat the returned promise's rejection as a real path and surface it.
271
+ **Embedded-only.** `askAi` rejects in standalone mode ("askAi is only available when the app runs inside Lotics"). It also rejects on the rare embedded screen where the chat surface is unavailable ("chat is unavailable on this screen") — treat the returned promise's rejection as a real path and surface it.
281
272
 
282
273
  `askAi` returns once the handoff is delivered; it never reports what the user or agent did afterwards. If the app needs the outcome, that's the razor telling you to use `useAgentRun`.
283
274
 
@@ -285,7 +276,7 @@ At least one of the four is required — an empty call rejects. The bridge carri
285
276
 
286
277
  ## `useAiContext(slot, context)` — tell the ambient chat what the member is looking at
287
278
 
288
- 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.
279
+ A member using an app inside Lotics has an **ambient chat agent** riding alongside it. `useAiContext` publishes a snapshot of the **current screen's view state** into that chat, so "why is this one overdue?" resolves against which list is filtered to what, which record is open, and what's typed into a form.
289
280
 
290
281
  ```tsx
291
282
  import { useAiContext } from "@lotics/app-sdk";
@@ -326,22 +317,15 @@ Declarative and lifecycle-bound: mounting or changing `context` pushes it; unmou
326
317
 
327
318
  ### A slot that never publishes looks exactly like a slot with nothing to say
328
319
 
329
- The failure mode worth knowing before you wire one: this hook has **no visible output**. A
330
- `useAiContext` behind a guard that never opens publishes `null` forever while the screen it
331
- describes renders perfectly, and nothing anywhere reports it. Silence is the same shape as
332
- "there is nothing to report", so neither the app nor a review catches it.
333
-
334
- The usual cause is a guard on the query state. `useQuery`'s `error` is `string | null` — it is
335
- **never `undefined`** — so `error !== undefined` is always true and holds the guard shut on every
336
- render. Write `if (loading || error) return;`, which is correct whichever of the two the field
337
- turns out to be, and reads as the question you meant.
320
+ This hook has **no visible output**: a `useAiContext` behind a guard that never opens publishes
321
+ `null` forever while its screen renders perfectly. The usual cause is a guard on the query state —
322
+ `useQuery`'s `error` is `string | null`, so `error !== undefined` holds it shut; write
323
+ `if (loading || error) return;`.
338
324
 
339
- **Verify by observing what is published, never by reading the code.** Run the app under
340
- `lotics app dev` and listen for the host notification on the wrapper page — the payload is the
341
- context as the chat agent will receive it. Check three things, because each fails differently:
342
- the slot carries REAL values matching what is on screen (not placeholders), it clears to `null`
343
- when the surface closes or the tab changes, and it does not publish while the first read is still
344
- in flight — a count of zero taken from a pending query is an answer nothing measured.
325
+ **Verify by observing what is published, never by reading the code.** Open the deployed app in
326
+ the product with the chat beside it, and check that the context the chat names carries REAL values
327
+ matching the screen, clears to `null` when the surface closes, and does not publish while the
328
+ first read is still in flight.
345
329
 
346
330
  ### The caps (host-enforced — exceeding them truncates or drops, never errors)
347
331
 
@@ -360,30 +344,21 @@ Keep `description` tight and human-readable — it is the line the agent reads.
360
344
  This hook is a **one-way push of data the app already showed this member**. (Chat can also PULL, through the app's declared aliases — a separate channel with its own `app:use` gate, below.) Two consequences hold the push to what was rendered:
361
345
 
362
346
  - **`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.
363
- - **`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).
347
+ - **`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.
364
348
 
365
349
  ### The chat can also USE the app, not just be told about it
366
350
 
367
- `useAiContext` is a PUSH — a snapshot of what one screen rendered. It cannot
368
- answer a question about a record that is not on screen, and it is capped, so it
369
- is not the whole story.
370
-
371
351
  While a member has an app open, their chat agent can also **call that app's own
372
352
  declared aliases** — named queries via `run_app_query`, workflows via
373
353
  `run_app_workflow`. This adds no exposure: every declared alias is already
374
- callable by that member against `POST /v1/apps/{id}/query` and
375
- `POST /v1/apps/{id}/workflows/{alias}/execute`, so the manifest already IS
376
- their reachable surface — the agent simply reaches it without devtools. It is
377
- gated per turn on `app:use` for the app in the member's workspace, and runs
378
- under the app's OWNER authority with `is_current_member` bound to the MEMBER
379
- (exactly as the app's own UI does).
354
+ callable by that member ([the devtools test](./security.md#the-devtools-test)).
355
+ It is gated per turn on `app:use`, and runs under the app's OWNER authority with
356
+ `is_current_member` bound to the MEMBER, exactly as the app's own UI does.
380
357
 
381
358
  The agent is handed a **catalog** of those aliases — each one's `description`
382
- (a query's own, [declared in the manifest](./queries.md); a workflow's, off its
359
+ (a query's own, [set on its binding](./queries.md); a workflow's, off its
383
360
  workflow row) and its **complete input contract** — so it picks the right one
384
- and calls it correctly instead of guessing and learning from a refusal. Every
385
- alias you declare appears, however large the manifest, and every input appears
386
- with it:
361
+ and calls it correctly. Every alias you declare appears, with every input:
387
362
 
388
363
  ```
389
364
  workflows (run_app_workflow):
@@ -394,76 +369,42 @@ workflows (run_app_workflow):
394
369
 
395
370
  `?` marks an input the caller may omit, `[]` one that takes a list, a
396
371
  `select` spells out every legal value as `label=value` — **the agent sends the
397
- value** — and a `record_link` names the table an id must come from. This is the
398
- only place any of it is legible: both tools take an opaque
399
- inputs object, and a select's legal values are your DECLARATION's — usually an
400
- inline set that matches no table field — so an agent reading option values off
401
- the underlying table would send ones your write rejects.
372
+ value** — and a `record_link` names the table an id must come from. Both tools
373
+ take an opaque inputs object, so this listing is the only place any of it is
374
+ legible.
402
375
 
403
376
  The app's own `description` heads that listing as `about:`, on both surfaces. It
404
377
  is the only channel that reaches the agent on **every** turn without the member
405
378
  saying anything, so a standing process belongs there: the recurring job the app
406
379
  exists for, which alias does it, and what the agent must leave alone.
407
380
 
408
- Three things follow for you as an author. **Your descriptions are what it
409
- reads**, on this surface and inside your own agents' runs — they enter the
410
- prompt as a capability listing, never as instructions. **A `select` input's
411
- options are part of the contract the agent sees**, so prefer the
412
- `field: "fld_…"` form when the values come from a real field: it resolves to
413
- that field's CURRENT options at both render and validation time. And **an
414
- input's `description` is the only place a DEFAULT is visible** — the catalog
415
- renders a name, a type, a `?` and your prose, never the body. An optional input
416
- whose default goes unstated is one the agent asks the member about instead of
417
- omitting; write what omitting it means ("left blank = today").
381
+ **Your descriptions are what it reads**, here and inside your own agents' runs —
382
+ as a capability listing, never as instructions. **A `select` input's options are
383
+ part of that contract**, so prefer the `field: "fld_…"` form when the values come
384
+ from a real field. And **an input's `description` is the only place a DEFAULT is
385
+ visible** — write what omitting it means ("left blank = today"), or the agent
386
+ asks the member instead of omitting.
418
387
 
419
388
  **Put anything irreversible behind a
420
389
  [`wait_for_approval`](./workflows.md#wait_for_approval--the-one-wait-worth-binding):**
421
390
  an email, a shipment, a published post, a write over a value with no version to
422
- restore. The body's own gate is the only human checkpoint on this path — it
423
- names the act in the language of the business and reaches the approvers the
424
- workflow declares.
391
+ restore. The body's own gate is the only human checkpoint on this path.
425
392
 
426
393
  **Shape a mutating alias around ONE call per job.** An alias that takes a single
427
- record turns a fifteen-record job into fifteen executions and no single answer to
428
- report, and a partial failure leaves it half done with nothing that says so. Take
429
- an array input instead — one call, one outcome. A narrow write is right for a
430
- screen's button, where the member picked the record by opening it; here nobody
431
- picked anything.
394
+ record turns a fifteen-record job into fifteen executions, and a partial failure
395
+ leaves it half done with nothing that says so. Take an array input — one call,
396
+ one outcome.
432
397
 
433
398
  **Report what it did NOT do.** The member's account of the write is assembled
434
- from what the workflow returned and nothing else, so a batch that matched nine of
435
- eleven and returns only success tells them all eleven were done. Return the
436
- misses in `data`.
399
+ from what the workflow returned, so return the misses in `data`.
437
400
 
438
- If a workflow should NOT be agent-reachable, it
439
- does not belong in the manifest at all, since the member can already fire it
440
- from the app's own buttons.
401
+ A workflow that should NOT be agent-reachable is not bound to the app at
402
+ all, since the member can already fire it from the app's own buttons.
441
403
 
442
- **What this means for what you push.** Keep `description` — the one line that
443
- says who is on screen and where they stand, which is what makes "this one"
444
- resolvable. Don't pack bulk rows into `data` to pre-answer questions that may
445
- never be asked: the agent can fetch them when needed, fresher than a snapshot,
446
- and a payload pushed on every message is carried by every later turn (see the
447
- caps above).
404
+ **So push the one line that makes "this one" resolvable** — who is on screen and
405
+ where they stand — and no bulk rows in `data`: the agent fetches them fresher
406
+ when needed, and a pushed payload rides every later turn.
448
407
 
449
408
  ### Query freshness — the mutation companion
450
409
 
451
- 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).
452
-
453
- **No-ops** with no embedding host (standalone on the app's own origin — there's no chat surface to inform) and in mock mode.
454
-
455
- ---
456
-
457
- ## Version floors
458
-
459
- The floors below are when each capability shipped in `@lotics/app-sdk`; an app pinned older silently lacks them.
460
-
461
- | Capability | Minimum version |
462
- |---|---|
463
- | `useAgentRun` (with `abort`) + `useAgentRuns` | `@lotics/app-sdk` 0.34 |
464
- | `cancel()` (server-side stop) + connection-drop recovery | `@lotics/app-sdk` 0.37 |
465
- | `parts` transcript (ai-sdk `UIMessagePart[]` — reasoning + per-tool `input`/`output`) | `@lotics/app-sdk` 0.54; renders with `@lotics/ui` ≥ 14.0 (`AgentRun` `parts` prop) |
466
- | `askAi` | `@lotics/app-sdk` 0.45 |
467
- | `useAiContext` (ambient-chat view state + auto query refetch on chat mutation) | `@lotics/app-sdk` 0.52 |
468
- | The agent asks back (`pendingChoice`/`answerChoice`, `awaiting_input`) | `@lotics/app-sdk` 0.55 |
469
- | A leg resolves an `AgentRunLanding` (`settled`/`parked`/`failed`/`aborted`) instead of `TOutput \| undefined` — the type is exported from the package root | `@lotics/app-sdk` 0.62.1 |
410
+ When the ambient chat agent's turn ends having mutated records, the host pushes every mounted query hook to re-read, so the screen reflects the change with no code and no reload. It refreshes only mounted hooks and never reaches into app data. **No-ops** standalone (there is no chat surface) and in mock mode.