@lotics/app-sdk 0.60.3 → 0.61.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/dist/src/rpc.d.ts CHANGED
@@ -170,6 +170,8 @@ export declare function rpcAgentRunContinue(payload: AgentRunContinuePayload, on
170
170
  * `APP_PUBLIC_SESSION_HEADER`; both sides are pinned by tests.
171
171
  */
172
172
  export declare const APP_PUBLIC_SESSION_HEADER = "x-lotics-app-session";
173
+ /** The run token header — mirrored server-side by `APP_AGENT_RUN_TOKEN_HEADER`. */
174
+ export declare const APP_AGENT_RUN_TOKEN_HEADER = "x-app-agent-run-token";
173
175
  /**
174
176
  * The error message for a non-ok response. A genuine JSON error (a 4xx carrying
175
177
  * a `message`) surfaces verbatim; a non-JSON body (a gateway HTML page), any
package/dist/src/rpc.js CHANGED
@@ -250,6 +250,11 @@ export function rpcAgentRunContinue(payload, onText) {
250
250
  const headers = { "content-type": "application/json" };
251
251
  if (sessionToken)
252
252
  headers[APP_PUBLIC_SESSION_HEADER] = sessionToken;
253
+ // Continuing an anonymous parked run is authorized by the same per-run
254
+ // capability that reads it — there is no member to authorize instead.
255
+ const continueToken = runTokens.get(payload.run_id);
256
+ if (continueToken)
257
+ headers[APP_AGENT_RUN_TOKEN_HEADER] = continueToken;
253
258
  const res = await fetch(`${API_BASE}/v1/apps/${app_id}/agent-runs/${encodeURIComponent(payload.run_id)}/continue`, {
254
259
  method: "POST",
255
260
  headers,
@@ -297,8 +302,12 @@ function agentRunStandalone(payload, onText, onRunId) {
297
302
  throw await streamStartError(res);
298
303
  }
299
304
  const runId = res.headers.get("x-app-agent-run-id");
300
- if (runId)
305
+ if (runId) {
306
+ // Anonymous runs carry their retrieval capability here; a member run does
307
+ // not, and `rememberRunToken` no-ops on the null.
308
+ rememberRunToken(runId, res.headers.get(APP_AGENT_RUN_TOKEN_HEADER));
301
309
  onRunId?.(runId);
310
+ }
302
311
  const reader = res.body.getReader();
303
312
  const decoder = new TextDecoder();
304
313
  try {
@@ -343,6 +352,40 @@ let sessionToken = null;
343
352
  * `APP_PUBLIC_SESSION_HEADER`; both sides are pinned by tests.
344
353
  */
345
354
  export const APP_PUBLIC_SESSION_HEADER = "x-lotics-app-session";
355
+ /**
356
+ * Per-run retrieval credentials for ANONYMOUS agent runs, keyed by run id.
357
+ *
358
+ * A member's run is authorized by their identity, so reading it back needs
359
+ * nothing extra. An anonymous run on a publicly-shared app has no member to key
360
+ * on (`triggered_by_member_id` is null by design), so the server mints a
361
+ * capability token bound to that one run and returns it on the run response.
362
+ * Without it the recovery poll 404s and a completed answer is lost to any
363
+ * dropped connection — routine on mobile for a run measured in tens of seconds.
364
+ *
365
+ * Transport-level, like `sessionToken` above and for the same reason: it is a
366
+ * credential, not app state. Threading it through the hook would put a bearer
367
+ * into the app's typed surface for every app to forward by hand.
368
+ *
369
+ * Only the STANDALONE transport needs this — an embedded app always runs under
370
+ * a member session, so it never produces an anonymous run.
371
+ */
372
+ const runTokens = new Map();
373
+ /** Bound the map so a long-lived page cannot accumulate tokens without limit.
374
+ * Insertion-ordered, so the oldest entry is the first key. */
375
+ const MAX_TRACKED_RUN_TOKENS = 8;
376
+ function rememberRunToken(runId, token) {
377
+ if (!token)
378
+ return;
379
+ runTokens.set(runId, token);
380
+ while (runTokens.size > MAX_TRACKED_RUN_TOKENS) {
381
+ const oldest = runTokens.keys().next();
382
+ if (oldest.done)
383
+ break;
384
+ runTokens.delete(oldest.value);
385
+ }
386
+ }
387
+ /** The run token header — mirrored server-side by `APP_AGENT_RUN_TOKEN_HEADER`. */
388
+ export const APP_AGENT_RUN_TOKEN_HEADER = "x-app-agent-run-token";
346
389
  /**
347
390
  * The cookie the app-host Worker sets after the visitor clears the password
348
391
  * gate. Readable by design — the SDK forwards its value as the session header
@@ -473,6 +516,11 @@ async function apiCall(method, path, body, opts) {
473
516
  if (sessionToken && !opts?.skipAuth) {
474
517
  headers[APP_PUBLIC_SESSION_HEADER] = sessionToken;
475
518
  }
519
+ // The per-run capability for an ANONYMOUS run — the only thing that authorizes
520
+ // reading or cancelling it, since there is no member identity to check.
521
+ if (opts?.runToken) {
522
+ headers[APP_AGENT_RUN_TOKEN_HEADER] = opts.runToken;
523
+ }
476
524
  const controller = new AbortController();
477
525
  let didTimeout = false;
478
526
  const timeoutId = setTimeout(() => {
@@ -716,6 +764,7 @@ async function standaloneAgentRunGet(p) {
716
764
  const { app_id } = await boot();
717
765
  const r = (await apiCall("GET", `/v1/apps/${app_id}/agent-runs/${encodeURIComponent(p.run_id)}`, undefined, {
718
766
  appId: app_id,
767
+ runToken: runTokens.get(p.run_id),
719
768
  }));
720
769
  return { run: r.run };
721
770
  }
@@ -725,6 +774,7 @@ async function standaloneAgentRunCancel(p) {
725
774
  const { app_id } = await boot();
726
775
  await apiCall("POST", `/v1/apps/${app_id}/agent-runs/${encodeURIComponent(p.run_id)}/cancel`, {}, {
727
776
  appId: app_id,
777
+ runToken: runTokens.get(p.run_id),
728
778
  });
729
779
  return { ok: true };
730
780
  }
package/docs/ai.md CHANGED
@@ -21,7 +21,7 @@ A declaration carries:
21
21
  |---|---|
22
22
  | `instructions` | System instructions — the task the agent performs per run |
23
23
  | `tool_names` | The tools the agent may call, resolved against the platform's automation tool registry. The capability boundary for everything EXCEPT workspace data — the run can use nothing else. May be empty — including for an agent that reads documents, since a [`file` input carries its own content](#file-inputs--what-the-agent-can-actually-see) |
24
- | `knowledge_doc_ids` | The knowledge docs the agent may read. Small docs are materialized into the run's system context; a doc too large to inline is read by staging it into a code run, which requires `code_exec` in `tool_names` |
24
+ | `knowledge_doc_ids` | The knowledge docs the agent may read, and the whole set it can reach a doc absent from this list is unreadable even if the agent names its id. Declare `grep_knowledge` / `read_knowledge` in `tool_names` to read them. There is no size limit and nothing is inlined, so a multi-megabyte reference corpus (a full tariff, a regulation set) is a normal declaration. Reach for `code_exec` only to COMPUTE across the corpus — counting, cross-referencing — never merely to read it |
25
25
  | `query_aliases` | The app's own named queries the agent may run via `run_app_query` — its **entire read surface** over records |
26
26
  | `workflow_aliases` | The app's own workflows the agent may invoke via `run_app_workflow` — its **entire write surface** |
27
27
  | `model_id` | Optional chat model pin. Omit (preferred) to follow the platform default chat model, resolved at run time — the agent tracks model generations with no rewrite. Pin only a deliberate, tested choice |
@@ -184,6 +184,8 @@ The run's lifetime is decoupled from the stream: the server drives it to complet
184
184
 
185
185
  Both truncation shapes emit the `app_agent_stream_truncated` analytics event (`kind: "connection_error" | "clean_end"`, with whether the row rescued the result), so edge-cut frequency is visible fleet-wide.
186
186
 
187
+ **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.
188
+
187
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. Only when no run id was ever received (the run never started) does the failure reject before any polling.
188
190
 
189
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 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/app-sdk",
3
- "version": "0.60.3",
3
+ "version": "0.61.0",
4
4
  "description": "Runtime SDK for Lotics custom-code apps \u2014 typed hooks, postMessage bridge, mount entry point",
5
5
  "type": "module",
6
6
  "exports": {