@guuey/agent-client 0.7.1 → 0.7.2

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.
@@ -127,6 +127,8 @@ export interface CreateUiResourceReaderOptions {
127
127
  }
128
128
  /** @internal test seam — the once-flag is module state; suites reset it between cases. */
129
129
  export declare function __resetReaderEndpointWarning(): void;
130
+ /** @internal test seam — the once-flag is module state; suites reset it between cases. */
131
+ export declare function __resetRelayEndpointWarning(): void;
130
132
  /**
131
133
  * Build a `UiResourceReader` over guuey's authenticated resources/read
132
134
  * doors — the pod door for LIVE turns (guuey#209 C1:
@@ -156,6 +158,24 @@ export interface CreateUiActionRelayOptions {
156
158
  apiBaseUrl: string;
157
159
  /** The thread whose persisted cards this relay may act for. */
158
160
  threadId: string;
161
+ /**
162
+ * The surface's invoke endpoint (pod base URL or full `/agent/invoke`
163
+ * URL). When set, actions POST to the POD's live door first
164
+ * (`POST <pod>/agent/ui-action`, guuey#222) — the only authority that
165
+ * can relay a click for a card whose turn is still streaming (persisted
166
+ * `kind:'card'` rows land at turn COMPLETION, so the platform door 404s
167
+ * mid-turn by construction). A pod 404 (not live, or past the ledger's
168
+ * grace window) falls through to the platform door; every other pod
169
+ * answer is terminal for the same reason it would be on the platform
170
+ * door. Absent → platform door only (pre-#222 behavior): **a click on a
171
+ * card produced mid-turn cannot reach the agent until its turn
172
+ * completes** — the exact "no moment where a click both resolves AND
173
+ * finds a live consumer" defect. A live surface MUST pass it; omitting it
174
+ * is only correct for a pure history viewer with no pod. The relay warns
175
+ * once at construction when a platform door is configured without a pod
176
+ * door (same guardrail as {@link createUiResourceReader}).
177
+ */
178
+ endpointUrl?: string | null;
159
179
  /** Signed-in bearer — wins over the guest secret (same rule as the transport). */
160
180
  getAccessToken?: (opts?: {
161
181
  forceRefresh?: boolean;
@@ -1 +1 @@
1
- {"version":3,"file":"web-adapters.d.ts","sourceRoot":"","sources":["../src/web-adapters.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AACH,OAAO,EAIL,KAAK,iBAAiB,EAEtB,KAAK,iBAAiB,EACtB,KAAK,eAAe,EACrB,MAAM,sBAAsB,CAAC;AAC9B,OAAO,KAAK,EAAE,mBAAmB,EAAmB,aAAa,EAAE,MAAM,YAAY,CAAC;AAKtF,sEAAsE;AACtE,eAAO,MAAM,uBAAuB,EAAE,aAiBrC,CAAC;AAEF,mEAAmE;AACnE,wBAAgB,aAAa,IAAI,MAAM,CAKtC;AAED,MAAM,WAAW,wBAAwB;IACvC;;;OAGG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB;;;;;;;;;;;;;;;;;OAiBG;IACH,cAAc,CAAC,EAAE,CAAC,IAAI,CAAC,EAAE;QAAE,YAAY,CAAC,EAAE,OAAO,CAAA;KAAE,KAAK,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAC;IAC/E;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;OAuCG;IACH,cAAc,CAAC,EAAE,MAAM,MAAM,GAAG,IAAI,CAAC;CACtC;AAED;;;;;;GAMG;AACH,wBAAgB,iBAAiB,CAC/B,IAAI,GAAE,wBAA6B,GAClC,mBAAmB,CA8ErB;AAED,mGAAmG;AACnG,MAAM,WAAW,6BAA6B;IAC5C,0CAA0C;IAC1C,UAAU,EAAE,MAAM,CAAC;IACnB,mEAAmE;IACnE,QAAQ,EAAE,MAAM,CAAC;IACjB;;;;;;;;;;;;;;;;;;;OAmBG;IACH,WAAW,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC5B,kFAAkF;IAClF,cAAc,CAAC,EAAE,CAAC,IAAI,CAAC,EAAE;QAAE,YAAY,CAAC,EAAE,OAAO,CAAA;KAAE,KAAK,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAC;IAC/E,iEAAiE;IACjE,WAAW,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC5B,4BAA4B;IAC5B,SAAS,CAAC,EAAE,OAAO,KAAK,CAAC;CAC1B;AAID,0FAA0F;AAC1F,wBAAgB,4BAA4B,IAAI,IAAI,CAEnD;AAOD;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,wBAAgB,sBAAsB,CACpC,OAAO,EAAE,6BAA6B,GACrC,CAAC,WAAW,EAAE,MAAM,KAAK,OAAO,CAAC,iBAAiB,GAAG,SAAS,CAAC,CAsFjE;AAED,uFAAuF;AACvF,MAAM,WAAW,0BAA0B;IACzC,0CAA0C;IAC1C,UAAU,EAAE,MAAM,CAAC;IACnB,+DAA+D;IAC/D,QAAQ,EAAE,MAAM,CAAC;IACjB,kFAAkF;IAClF,cAAc,CAAC,EAAE,CAAC,IAAI,CAAC,EAAE;QAAE,YAAY,CAAC,EAAE,OAAO,CAAA;KAAE,KAAK,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAC;IAC/E,iEAAiE;IACjE,WAAW,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC5B,4BAA4B;IAC5B,SAAS,CAAC,EAAE,OAAO,KAAK,CAAC;CAC1B;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,mBAAmB,CACjC,OAAO,EAAE,0BAA0B,GAClC,CAAC,OAAO,EAAE,eAAe,KAAK,OAAO,CAAC,iBAAiB,CAAC,CA+C1D"}
1
+ {"version":3,"file":"web-adapters.d.ts","sourceRoot":"","sources":["../src/web-adapters.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AACH,OAAO,EAIL,KAAK,iBAAiB,EAEtB,KAAK,iBAAiB,EACtB,KAAK,eAAe,EACrB,MAAM,sBAAsB,CAAC;AAC9B,OAAO,KAAK,EAAE,mBAAmB,EAAmB,aAAa,EAAE,MAAM,YAAY,CAAC;AAKtF,sEAAsE;AACtE,eAAO,MAAM,uBAAuB,EAAE,aAiBrC,CAAC;AAEF,mEAAmE;AACnE,wBAAgB,aAAa,IAAI,MAAM,CAKtC;AAED,MAAM,WAAW,wBAAwB;IACvC;;;OAGG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB;;;;;;;;;;;;;;;;;OAiBG;IACH,cAAc,CAAC,EAAE,CAAC,IAAI,CAAC,EAAE;QAAE,YAAY,CAAC,EAAE,OAAO,CAAA;KAAE,KAAK,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAC;IAC/E;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;OAuCG;IACH,cAAc,CAAC,EAAE,MAAM,MAAM,GAAG,IAAI,CAAC;CACtC;AAED;;;;;;GAMG;AACH,wBAAgB,iBAAiB,CAC/B,IAAI,GAAE,wBAA6B,GAClC,mBAAmB,CA8ErB;AAED,mGAAmG;AACnG,MAAM,WAAW,6BAA6B;IAC5C,0CAA0C;IAC1C,UAAU,EAAE,MAAM,CAAC;IACnB,mEAAmE;IACnE,QAAQ,EAAE,MAAM,CAAC;IACjB;;;;;;;;;;;;;;;;;;;OAmBG;IACH,WAAW,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC5B,kFAAkF;IAClF,cAAc,CAAC,EAAE,CAAC,IAAI,CAAC,EAAE;QAAE,YAAY,CAAC,EAAE,OAAO,CAAA;KAAE,KAAK,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAC;IAC/E,iEAAiE;IACjE,WAAW,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC5B,4BAA4B;IAC5B,SAAS,CAAC,EAAE,OAAO,KAAK,CAAC;CAC1B;AAID,0FAA0F;AAC1F,wBAAgB,4BAA4B,IAAI,IAAI,CAEnD;AAcD,0FAA0F;AAC1F,wBAAgB,2BAA2B,IAAI,IAAI,CAElD;AAED;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,wBAAgB,sBAAsB,CACpC,OAAO,EAAE,6BAA6B,GACrC,CAAC,WAAW,EAAE,MAAM,KAAK,OAAO,CAAC,iBAAiB,GAAG,SAAS,CAAC,CAsFjE;AAED,uFAAuF;AACvF,MAAM,WAAW,0BAA0B;IACzC,0CAA0C;IAC1C,UAAU,EAAE,MAAM,CAAC;IACnB,+DAA+D;IAC/D,QAAQ,EAAE,MAAM,CAAC;IACjB;;;;;;;;;;;;;;;;OAgBG;IACH,WAAW,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC5B,kFAAkF;IAClF,cAAc,CAAC,EAAE,CAAC,IAAI,CAAC,EAAE;QAAE,YAAY,CAAC,EAAE,OAAO,CAAA;KAAE,KAAK,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAC;IAC/E,iEAAiE;IACjE,WAAW,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC5B,4BAA4B;IAC5B,SAAS,CAAC,EAAE,OAAO,KAAK,CAAC;CAC1B;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,mBAAmB,CACjC,OAAO,EAAE,0BAA0B,GAClC,CAAC,OAAO,EAAE,eAAe,KAAK,OAAO,CAAC,iBAAiB,CAAC,CAuG1D"}
@@ -135,6 +135,16 @@ export function __resetReaderEndpointWarning() {
135
135
  function toUiResourceUrl(endpointUrl) {
136
136
  return toInvokeUrl(endpointUrl).replace(/\/agent\/invoke$/, "/agent/ui-resource");
137
137
  }
138
+ /** `<pod base>/agent/ui-action` — the live ACTION door (guuey#222), the read door's twin. */
139
+ function toUiActionUrl(endpointUrl) {
140
+ return toInvokeUrl(endpointUrl).replace(/\/agent\/invoke$/, "/agent/ui-action");
141
+ }
142
+ /** Warn once per module load — sibling of the reader's flag; per-surface, not per-click. */
143
+ let relayEndpointWarned = false;
144
+ /** @internal test seam — the once-flag is module state; suites reset it between cases. */
145
+ export function __resetRelayEndpointWarning() {
146
+ relayEndpointWarned = false;
147
+ }
138
148
  /**
139
149
  * Build a `UiResourceReader` over guuey's authenticated resources/read
140
150
  * doors — the pod door for LIVE turns (guuey#209 C1:
@@ -261,8 +271,37 @@ export function createUiResourceReader(options) {
261
271
  */
262
272
  export function createUiActionRelay(options) {
263
273
  const fetchImpl = options.fetchImpl ?? fetch;
264
- const callTool = async (uri, name, args) => {
274
+ // A platform door without a pod door is almost always a live surface
275
+ // that forgot `endpointUrl` — its cards' clicks would go nowhere for the
276
+ // whole mid-turn window (guuey#222). `null` is the explicit "history-only
277
+ // viewer, there is no pod" opt-out; `undefined` is the forgotten case.
278
+ if (options.endpointUrl === undefined && !relayEndpointWarned) {
279
+ relayEndpointWarned = true;
280
+ console.warn("createUiActionRelay: no `endpointUrl` — a click on a card produced mid-turn cannot reach the agent until the turn completes (the pod door is the only authority while a turn streams; post-turn clicks reach the platform door). Pass the surface's invoke endpoint, or `endpointUrl: null` to declare a history-only viewer.");
281
+ }
282
+ /**
283
+ * One door: POST + the reader's 401-forceRefresh recovery. Returns the
284
+ * parsed result on 2xx, `"miss"` on 404 (the pod's "not live / not yours /
285
+ * past grace" — deny==miss, so the NEXT door may still answer), and
286
+ * `undefined` for every other failure (terminal: the host relay answers
287
+ * in-band as an `isError` result, never a thrown error into the sandbox
288
+ * bridge). A pod 502 UPSTREAM_UNAVAILABLE is a real failure, not a miss —
289
+ * the persisted door cannot relay a mid-turn click either, so falling
290
+ * through would only trade one honest error for a misleading 404.
291
+ */
292
+ const postDoor = async (requestUrl, body) => {
293
+ // Exactly ONE identity carrier per call — the reader's rule verbatim:
294
+ // bearer → guest header → else cookie credentials (the HttpOnly
295
+ // `guuey_guest` cookie the pod mints for anonymous browser callers).
296
+ // Without the third arm a cookie-mode guest POSTed identity-less and
297
+ // every click failed auth (the guuey#221 class, on the relay). A JSON
298
+ // POST is always preflighted, so unlike the reader's GET this arm can
299
+ // never be a CORS "simple request" — which is fine because both doors
300
+ // answer a credentialed preflight: the pod echoes origin +
301
+ // `Access-Control-Allow-Credentials` on OPTIONS and every status, and
302
+ // the platform door's own OPTIONS branch does the same (guuey#224).
265
303
  const headers = { "content-type": "application/json" };
304
+ const init = { method: "POST", headers, body };
266
305
  const token = options.getAccessToken ? await options.getAccessToken() : null;
267
306
  const guest = sendableGuestSecret(options.guestSecret);
268
307
  if (token) {
@@ -271,17 +310,19 @@ export function createUiActionRelay(options) {
271
310
  else if (guest) {
272
311
  headers[GUEST_HEADER] = guest;
273
312
  }
274
- const requestUrl = `${options.apiBaseUrl}/threads/${encodeURIComponent(options.threadId)}/ui-action`;
275
- const body = JSON.stringify({ uri, name, ...(args !== undefined ? { arguments: args } : {}) });
313
+ else {
314
+ init.credentials = "include";
315
+ }
276
316
  let res;
277
317
  try {
278
- res = await fetchImpl(requestUrl, { method: "POST", headers, body });
318
+ res = await fetchImpl(requestUrl, init);
279
319
  }
280
320
  catch {
281
321
  return undefined; // transport failure — the host relay answers in-band
282
322
  }
283
323
  // One forceRefresh retry on 401 with a bearer in play — the same
284
- // expired-but-refreshable recovery the reader performs.
324
+ // expired-but-refreshable recovery the reader performs. The retry
325
+ // carries the fresh bearer and nothing else (same one-carrier rule).
285
326
  if (res.status === 401 && options.getAccessToken) {
286
327
  const fresh = await options.getAccessToken({ forceRefresh: true }).catch(() => null);
287
328
  if (fresh) {
@@ -297,14 +338,33 @@ export function createUiActionRelay(options) {
297
338
  }
298
339
  }
299
340
  }
341
+ if (res.status === 404)
342
+ return "miss";
300
343
  if (!res.ok)
301
344
  return undefined;
302
345
  try {
303
- return (await res.json());
346
+ return { kind: "result", value: (await res.json()) };
304
347
  }
305
348
  catch {
306
349
  return undefined;
307
350
  }
308
351
  };
352
+ const podUrl = options.endpointUrl ? toUiActionUrl(options.endpointUrl) : null;
353
+ const callTool = async (uri, name, args) => {
354
+ // The kit sends only what the click carries; the pod overwrites any
355
+ // sessionId/appId from the authorized locator + its own binding.
356
+ const body = JSON.stringify({ uri, name, ...(args !== undefined ? { arguments: args } : {}) });
357
+ if (podUrl !== null) {
358
+ const live = await postDoor(podUrl, body);
359
+ if (live === undefined)
360
+ return undefined; // terminal on the pod — no fall-through
361
+ if (live !== "miss")
362
+ return live.value;
363
+ // 404 → not live here (completed turn past grace, or never live):
364
+ // the persisted door owns it.
365
+ }
366
+ const persisted = await postDoor(`${options.apiBaseUrl}/threads/${encodeURIComponent(options.threadId)}/ui-action`, body);
367
+ return persisted === undefined || persisted === "miss" ? undefined : persisted.value;
368
+ };
309
369
  return createMcpUiActionRelay({ callTool });
310
370
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@guuey/agent-client",
3
- "version": "0.7.1",
3
+ "version": "0.7.2",
4
4
  "description": "Client SDK for Guuey's agent runtime: the `useAgentInvoke` React hook + pure SSE helpers that speak the /agent/invoke streaming contract, plus the paginated thread-history read plane. Host adapters (storage / id / transport) are injected, so it runs on web (Next) and React Native alike.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -35,7 +35,7 @@
35
35
  },
36
36
  "dependencies": {
37
37
  "@silverprotocol/core": "0.5.0",
38
- "@guuey/mcp-apps-host": "0.7.1"
38
+ "@guuey/mcp-apps-host": "0.7.2"
39
39
  },
40
40
  "peerDependencies": {
41
41
  "react": ">=18"
@@ -252,6 +252,18 @@ function toUiResourceUrl(endpointUrl: string): string {
252
252
  return toInvokeUrl(endpointUrl).replace(/\/agent\/invoke$/, "/agent/ui-resource");
253
253
  }
254
254
 
255
+ /** `<pod base>/agent/ui-action` — the live ACTION door (guuey#222), the read door's twin. */
256
+ function toUiActionUrl(endpointUrl: string): string {
257
+ return toInvokeUrl(endpointUrl).replace(/\/agent\/invoke$/, "/agent/ui-action");
258
+ }
259
+
260
+ /** Warn once per module load — sibling of the reader's flag; per-surface, not per-click. */
261
+ let relayEndpointWarned = false;
262
+ /** @internal test seam — the once-flag is module state; suites reset it between cases. */
263
+ export function __resetRelayEndpointWarning(): void {
264
+ relayEndpointWarned = false;
265
+ }
266
+
255
267
  /**
256
268
  * Build a `UiResourceReader` over guuey's authenticated resources/read
257
269
  * doors — the pod door for LIVE turns (guuey#209 C1:
@@ -370,6 +382,24 @@ export interface CreateUiActionRelayOptions {
370
382
  apiBaseUrl: string;
371
383
  /** The thread whose persisted cards this relay may act for. */
372
384
  threadId: string;
385
+ /**
386
+ * The surface's invoke endpoint (pod base URL or full `/agent/invoke`
387
+ * URL). When set, actions POST to the POD's live door first
388
+ * (`POST <pod>/agent/ui-action`, guuey#222) — the only authority that
389
+ * can relay a click for a card whose turn is still streaming (persisted
390
+ * `kind:'card'` rows land at turn COMPLETION, so the platform door 404s
391
+ * mid-turn by construction). A pod 404 (not live, or past the ledger's
392
+ * grace window) falls through to the platform door; every other pod
393
+ * answer is terminal for the same reason it would be on the platform
394
+ * door. Absent → platform door only (pre-#222 behavior): **a click on a
395
+ * card produced mid-turn cannot reach the agent until its turn
396
+ * completes** — the exact "no moment where a click both resolves AND
397
+ * finds a live consumer" defect. A live surface MUST pass it; omitting it
398
+ * is only correct for a pure history viewer with no pod. The relay warns
399
+ * once at construction when a platform door is configured without a pod
400
+ * door (same guardrail as {@link createUiResourceReader}).
401
+ */
402
+ endpointUrl?: string | null;
373
403
  /** Signed-in bearer — wins over the guest secret (same rule as the transport). */
374
404
  getAccessToken?: (opts?: { forceRefresh?: boolean }) => Promise<string | null>;
375
405
  /** Caller-owned anonymous guest secret (widget / guest chat). */
@@ -394,29 +424,61 @@ export function createUiActionRelay(
394
424
  options: CreateUiActionRelayOptions,
395
425
  ): (request: UiActionRequest) => Promise<McpToolCallResult> {
396
426
  const fetchImpl = options.fetchImpl ?? fetch;
397
- const callTool = async (
398
- uri: string,
399
- name: string,
400
- args: McpToolStructuredContent | undefined,
401
- ): Promise<unknown> => {
427
+ // A platform door without a pod door is almost always a live surface
428
+ // that forgot `endpointUrl` — its cards' clicks would go nowhere for the
429
+ // whole mid-turn window (guuey#222). `null` is the explicit "history-only
430
+ // viewer, there is no pod" opt-out; `undefined` is the forgotten case.
431
+ if (options.endpointUrl === undefined && !relayEndpointWarned) {
432
+ relayEndpointWarned = true;
433
+ console.warn(
434
+ "createUiActionRelay: no `endpointUrl` — a click on a card produced mid-turn cannot reach the agent until the turn completes (the pod door is the only authority while a turn streams; post-turn clicks reach the platform door). Pass the surface's invoke endpoint, or `endpointUrl: null` to declare a history-only viewer.",
435
+ );
436
+ }
437
+
438
+ /**
439
+ * One door: POST + the reader's 401-forceRefresh recovery. Returns the
440
+ * parsed result on 2xx, `"miss"` on 404 (the pod's "not live / not yours /
441
+ * past grace" — deny==miss, so the NEXT door may still answer), and
442
+ * `undefined` for every other failure (terminal: the host relay answers
443
+ * in-band as an `isError` result, never a thrown error into the sandbox
444
+ * bridge). A pod 502 UPSTREAM_UNAVAILABLE is a real failure, not a miss —
445
+ * the persisted door cannot relay a mid-turn click either, so falling
446
+ * through would only trade one honest error for a misleading 404.
447
+ */
448
+ const postDoor = async (
449
+ requestUrl: string,
450
+ body: string,
451
+ ): Promise<{ kind: "result"; value: unknown } | "miss" | undefined> => {
452
+ // Exactly ONE identity carrier per call — the reader's rule verbatim:
453
+ // bearer → guest header → else cookie credentials (the HttpOnly
454
+ // `guuey_guest` cookie the pod mints for anonymous browser callers).
455
+ // Without the third arm a cookie-mode guest POSTed identity-less and
456
+ // every click failed auth (the guuey#221 class, on the relay). A JSON
457
+ // POST is always preflighted, so unlike the reader's GET this arm can
458
+ // never be a CORS "simple request" — which is fine because both doors
459
+ // answer a credentialed preflight: the pod echoes origin +
460
+ // `Access-Control-Allow-Credentials` on OPTIONS and every status, and
461
+ // the platform door's own OPTIONS branch does the same (guuey#224).
402
462
  const headers: Record<string, string> = { "content-type": "application/json" };
463
+ const init: RequestInit = { method: "POST", headers, body };
403
464
  const token = options.getAccessToken ? await options.getAccessToken() : null;
404
465
  const guest = sendableGuestSecret(options.guestSecret);
405
466
  if (token) {
406
467
  headers["authorization"] = `Bearer ${token}`;
407
468
  } else if (guest) {
408
469
  headers[GUEST_HEADER] = guest;
470
+ } else {
471
+ init.credentials = "include";
409
472
  }
410
- const requestUrl = `${options.apiBaseUrl}/threads/${encodeURIComponent(options.threadId)}/ui-action`;
411
- const body = JSON.stringify({ uri, name, ...(args !== undefined ? { arguments: args } : {}) });
412
473
  let res: Response;
413
474
  try {
414
- res = await fetchImpl(requestUrl, { method: "POST", headers, body });
475
+ res = await fetchImpl(requestUrl, init);
415
476
  } catch {
416
477
  return undefined; // transport failure — the host relay answers in-band
417
478
  }
418
479
  // One forceRefresh retry on 401 with a bearer in play — the same
419
- // expired-but-refreshable recovery the reader performs.
480
+ // expired-but-refreshable recovery the reader performs. The retry
481
+ // carries the fresh bearer and nothing else (same one-carrier rule).
420
482
  if (res.status === 401 && options.getAccessToken) {
421
483
  const fresh = await options.getAccessToken({ forceRefresh: true }).catch(() => null);
422
484
  if (fresh) {
@@ -431,12 +493,36 @@ export function createUiActionRelay(
431
493
  }
432
494
  }
433
495
  }
496
+ if (res.status === 404) return "miss";
434
497
  if (!res.ok) return undefined;
435
498
  try {
436
- return (await res.json()) as unknown;
499
+ return { kind: "result", value: (await res.json()) as unknown };
437
500
  } catch {
438
501
  return undefined;
439
502
  }
440
503
  };
504
+
505
+ const podUrl = options.endpointUrl ? toUiActionUrl(options.endpointUrl) : null;
506
+ const callTool = async (
507
+ uri: string,
508
+ name: string,
509
+ args: McpToolStructuredContent | undefined,
510
+ ): Promise<unknown> => {
511
+ // The kit sends only what the click carries; the pod overwrites any
512
+ // sessionId/appId from the authorized locator + its own binding.
513
+ const body = JSON.stringify({ uri, name, ...(args !== undefined ? { arguments: args } : {}) });
514
+ if (podUrl !== null) {
515
+ const live = await postDoor(podUrl, body);
516
+ if (live === undefined) return undefined; // terminal on the pod — no fall-through
517
+ if (live !== "miss") return live.value;
518
+ // 404 → not live here (completed turn past grace, or never live):
519
+ // the persisted door owns it.
520
+ }
521
+ const persisted = await postDoor(
522
+ `${options.apiBaseUrl}/threads/${encodeURIComponent(options.threadId)}/ui-action`,
523
+ body,
524
+ );
525
+ return persisted === undefined || persisted === "miss" ? undefined : persisted.value;
526
+ };
441
527
  return createMcpUiActionRelay({ callTool });
442
528
  }