@unoverse-platform/studio 0.1.103 → 0.1.104

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 (79) hide show
  1. package/package.json +4 -2
  2. package/screens/AtomsView.tsx +2 -1
  3. package/screens/ComponentsView.tsx +2 -1
  4. package/screens/NativeAppHost.tsx +1 -2
  5. package/screens/TemplatesLibraryView.tsx +14 -1
  6. package/screens/TemplatesView.tsx +2 -1
  7. package/screens/index.ts +4 -0
  8. package/screens/react/unoverse.tsx +281 -0
  9. package/vendor/base/src/agent-mcp/index.ts +1 -1
  10. package/vendor/base/src/agent-mcp/invoke.ts +6 -2
  11. package/vendor/base/src/agent-mcp/tools.ts +14 -5
  12. package/vendor/base/src/agent-mcp/types.ts +6 -0
  13. package/vendor/base/src/auth/auth.ts +90 -23
  14. package/vendor/base/src/definitions/load/compile/sections.ts +79 -0
  15. package/vendor/base/src/definitions/load/compile/template.ts +23 -2
  16. package/vendor/base/src/definitions/model.ts +9 -0
  17. package/vendor/base/src/definitions/registry/catalog.ts +6 -7
  18. package/vendor/base/src/executor.ts +25 -1
  19. package/vendor/base/src/manifests/executor/toolBridge.ts +25 -19
  20. package/vendor/base/src/manifests/runtime/channels/final.ts +4 -1
  21. package/vendor/base/src/manifests/runtime/duplex/session.ts +3 -9
  22. package/vendor/base/src/manifests/runtime/tools/toolloop.ts +28 -25
  23. package/vendor/base/src/platform/serviceCalls.ts +13 -0
  24. package/vendor/sdk/appEngine.ts +363 -0
  25. package/vendor/sdk/lib/actions.ts +36 -0
  26. package/vendor/sdk/lib/citations.ts +48 -0
  27. package/vendor/sdk/lib/core/apps.ts +18 -0
  28. package/vendor/sdk/lib/core/client.ts +3 -3
  29. package/vendor/sdk/lib/core/store.ts +6 -0
  30. package/vendor/sdk/lib/dom/app.ts +546 -0
  31. package/vendor/sdk/lib/dom/chrome.ts +125 -0
  32. package/vendor/sdk/lib/dom/component.ts +333 -0
  33. package/vendor/sdk/lib/dom/connection.ts +309 -0
  34. package/vendor/sdk/lib/dom/element.ts +69 -0
  35. package/vendor/sdk/lib/dom/isolate.ts +37 -0
  36. package/vendor/sdk/lib/dom/markdown.ts +388 -0
  37. package/vendor/sdk/lib/dom/markdownParse.ts +440 -0
  38. package/vendor/sdk/lib/dom/mounted.ts +29 -0
  39. package/vendor/sdk/lib/dom/primitives.ts +689 -0
  40. package/vendor/sdk/lib/dom/region.ts +195 -0
  41. package/vendor/sdk/lib/dom/render.ts +177 -0
  42. package/vendor/sdk/lib/dom/theme.ts +38 -0
  43. package/vendor/sdk/lib/dom/types.ts +36 -0
  44. package/vendor/sdk/lib/index.ts +26 -17
  45. package/vendor/sdk/lib/orb/canvas.ts +139 -0
  46. package/vendor/sdk/lib/orb/index.ts +8 -18
  47. package/vendor/sdk/lib/orb/presets.ts +1 -1
  48. package/vendor/sdk/lib/orb/{types.ts → states.ts} +0 -24
  49. package/vendor/sdk/lib/realtime/capture.ts +152 -0
  50. package/vendor/sdk/lib/realtime/playback.ts +129 -0
  51. package/vendor/sdk/lib/realtime/socket.ts +117 -0
  52. package/vendor/sdk/lib/style.ts +45 -37
  53. package/vendor/sdk/lib/voiceService.ts +344 -0
  54. package/vendor/sdk/main.ts +107 -0
  55. package/vendor/sdk/mcpAppBridge.ts +125 -102
  56. package/vendor/sdk/pageInterfaces.ts +452 -0
  57. package/vendor/sdk/responseView.ts +193 -0
  58. package/vite.config.ts +3 -3
  59. package/vendor/sdk/appEngine.tsx +0 -395
  60. package/vendor/sdk/lib/UnoverseComponent.tsx +0 -325
  61. package/vendor/sdk/lib/app.tsx +0 -730
  62. package/vendor/sdk/lib/connection.tsx +0 -433
  63. package/vendor/sdk/lib/isolate.tsx +0 -70
  64. package/vendor/sdk/lib/orb/ThinkingOrb.tsx +0 -131
  65. package/vendor/sdk/lib/orb/theme.ts +0 -83
  66. package/vendor/sdk/lib/primitives.tsx +0 -943
  67. package/vendor/sdk/lib/realtime/useAudioCapture.ts +0 -237
  68. package/vendor/sdk/lib/realtime/useAudioPlayback.ts +0 -157
  69. package/vendor/sdk/lib/realtime/useRealtimeWebSocket.ts +0 -164
  70. package/vendor/sdk/lib/render.tsx +0 -244
  71. package/vendor/sdk/lib/streamed.tsx +0 -30
  72. package/vendor/sdk/lib/theme.ts +0 -55
  73. package/vendor/sdk/lib/transition/MountArrangement.tsx +0 -71
  74. package/vendor/sdk/lib/transition/index.ts +0 -9
  75. package/vendor/sdk/lib/transition/viewTransition.ts +0 -29
  76. package/vendor/sdk/lib/voice.tsx +0 -422
  77. package/vendor/sdk/main.tsx +0 -125
  78. package/vendor/sdk/pageInterfaces.tsx +0 -388
  79. package/vendor/sdk/responseView.tsx +0 -82
@@ -172,7 +172,12 @@ async function verifyJWT(token: string): Promise<AuthUser | null> {
172
172
  const aud = payload.aud;
173
173
  const clientId = (payload as Record<string, unknown>).client_id;
174
174
  const values = [...(Array.isArray(aud) ? aud : aud ? [aud] : []), ...(clientId ? [clientId] : [])].map(String);
175
- if (!values.includes(expected)) {
175
+ // A connector's token (ChatGPT, Claude) names OUR MCP ADDRESS as its audience, not our
176
+ // app's client: it registered its own client and asked the provider for a token for
177
+ // `<public url>/mcp/<org>` (RFC 8707). Only our configured public URL counts, so a token
178
+ // minted for anyone else's resource is refused as before. Approved by Gavin 2026-09-30.
179
+ const forOurMcp = Array.isArray(aud) ? aud.some(isOurMcpResource) : typeof aud === "string" && isOurMcpResource(aud);
180
+ if (!values.includes(expected) && !forOurMcp) {
176
181
  console.error(
177
182
  `[auth] JWT validation failed: audience mismatch (expected ${expected}, token had ${values.join(", ") || "none"})`,
178
183
  );
@@ -187,6 +192,11 @@ async function verifyJWT(token: string): Promise<AuthUser | null> {
187
192
  raw: payload,
188
193
  };
189
194
  } catch (error) {
195
+ // An expired token is routine (a held tab, a host replaying an old tool result): one line, no stack.
196
+ if ((error as { code?: string }).code === "ERR_JWT_EXPIRED") {
197
+ console.warn(`[auth] JWT expired (sub ${(error as { payload?: { sub?: string } }).payload?.sub ?? "?"})`);
198
+ return null;
199
+ }
190
200
  console.error("[auth] JWT validation failed:", error);
191
201
  return null;
192
202
  }
@@ -265,9 +275,44 @@ function resourceBase(host: string): string {
265
275
  return `${protocol}://${host}`;
266
276
  }
267
277
 
268
- /** The 401 challenge — points clients at the RFC 9728 protected-resource-metadata. */
269
- export function unauthorizedResponse(host: string): Response {
270
- const resourceMetadataUrl = `${resourceBase(host)}/.well-known/oauth-protected-resource`;
278
+ /**
279
+ * WAS THIS TOKEN ISSUED TO AN OUTSIDE CONNECTOR? (the MCP Action is the front door, 2026-09-30)
280
+ *
281
+ * A connector (ChatGPT, Claude) signs in through the provider and asks for a token FOR OUR MCP
282
+ * ADDRESS, so its `aud` is `<public url>/mcp` or `<public url>/mcp/<org>` (RFC 8707). Our own
283
+ * hosts (Canvas, Studio, the builder) sign in as our app and never carry that audience. The
284
+ * answer decides only WHICH TOOLS the session is offered: a connector gets the MCP Actions and
285
+ * nothing else, signed in or not. It grants nothing. Call it only on a token `authorize` has
286
+ * already accepted; the payload is read, not verified, here.
287
+ */
288
+ export function isConnectorToken(token: string): boolean {
289
+ try {
290
+ const payload = JSON.parse(Buffer.from(token.split(".")[1] ?? "", "base64url").toString("utf8"));
291
+ const aud: unknown[] = Array.isArray(payload?.aud) ? payload.aud : payload?.aud ? [payload.aud] : [];
292
+ return aud.some(isOurMcpResource);
293
+ } catch {
294
+ return false;
295
+ }
296
+ }
297
+
298
+ /** `<UNOVERSE_PUBLIC_URL>/mcp` or `<UNOVERSE_PUBLIC_URL>/mcp/<org>`: the resource a connector's token is for. */
299
+ function isOurMcpResource(value: unknown): boolean {
300
+ const base = process.env.UNOVERSE_PUBLIC_URL?.replace(/\/$/, "");
301
+ if (!base || typeof value !== "string") return false;
302
+ if (!value.startsWith(base)) return false;
303
+ return /^\/mcp(\/[A-Za-z0-9._-]+)?$/.test(value.slice(base.length));
304
+ }
305
+
306
+ /**
307
+ * The 401 challenge — points clients at the RFC 9728 protected-resource-metadata.
308
+ *
309
+ * `path` is the MCP door that was called (`/mcp/sab`). The challenge must name THAT door's
310
+ * metadata: a connector follows this header and sends the `resource` it finds there to the
311
+ * provider, which issues a token only for a resource indicator it knows. Pointing every door
312
+ * at the root document made ChatGPT ask for the bare origin and fail at the callback.
313
+ */
314
+ export function unauthorizedResponse(host: string, path = ""): Response {
315
+ const resourceMetadataUrl = `${resourceBase(host)}/.well-known/oauth-protected-resource${path}`;
271
316
  return new Response(
272
317
  JSON.stringify({ jsonrpc: "2.0", error: { code: -32001, message: "Unauthorized" }, id: null }),
273
318
  {
@@ -280,22 +325,36 @@ export function unauthorizedResponse(host: string): Response {
280
325
  );
281
326
  }
282
327
 
283
- /** OAuth Authorization Server Metadata (for MCP Inspector / discovery). */
284
- export function getOAuthAuthorizationServerMetadata(): object {
285
- const issuer = process.env.AUTH_ISSUER;
286
- const audience = process.env.AUTH_AUDIENCE;
287
- return {
288
- issuer,
289
- authorization_endpoint: audience
290
- ? `${issuer}/authorize?audience=${encodeURIComponent(audience)}`
291
- : `${issuer}/authorize`,
292
- token_endpoint: `${issuer}/oauth/token`,
293
- registration_endpoint: `${issuer}/oidc/register`,
294
- scopes_supported: ["openid", "profile", "email", "offline_access"],
295
- response_types_supported: ["code"],
296
- grant_types_supported: ["authorization_code", "refresh_token"],
297
- code_challenge_methods_supported: ["S256"],
298
- };
328
+ /**
329
+ * OAuth Authorization Server Metadata: THE PROVIDER'S OWN DOCUMENT, passed through.
330
+ *
331
+ * This used to be written here, with Auth0's paths (`/authorize?audience=`, `/oauth/token`,
332
+ * `/oidc/register`) on whatever issuer was configured. On WorkOS AuthKit none of those exist
333
+ * (`/oauth2/authorize`, `/oauth2/token`, `/oauth2/register`), and ChatGPT, which reads this
334
+ * copy on our host, was sent to a token address that is not there: sign-in succeeded and the
335
+ * connector failed at the callback (2026-09-30). Cognito has no registration endpoint at all,
336
+ * yet this advertised one. The provider says where its endpoints are, so we repeat what it
337
+ * says and add nothing. RFC 8414 first, OIDC discovery second (Cognito publishes only that).
338
+ * Held for five minutes, so a change made in the provider's dashboard shows up without a
339
+ * restart. Null when the provider cannot be reached: the caller answers 502, never a guess.
340
+ */
341
+ let asMetadata: { doc: object; at: number } | null = null;
342
+ export async function getOAuthAuthorizationServerMetadata(): Promise<object | null> {
343
+ const base = getIssuerBase();
344
+ if (!base) return null;
345
+ if (asMetadata && Date.now() - asMetadata.at < 5 * 60_000) return asMetadata.doc;
346
+ for (const path of ["/.well-known/oauth-authorization-server", "/.well-known/openid-configuration"]) {
347
+ try {
348
+ const res = await fetch(`${base}${path}`, { signal: AbortSignal.timeout(5000) });
349
+ if (!res.ok) continue;
350
+ const doc = (await res.json()) as object;
351
+ asMetadata = { doc, at: Date.now() };
352
+ return doc;
353
+ } catch {
354
+ // try the next discovery location
355
+ }
356
+ }
357
+ return null;
299
358
  }
300
359
 
301
360
  /**
@@ -331,10 +390,18 @@ export function getPublicAuthConfig(): object {
331
390
  };
332
391
  }
333
392
 
334
- /** OAuth 2.0 Protected Resource Metadata (RFC 9728); canonical URL preferred over Host. */
335
- export function getOAuthProtectedResourceMetadata(host: string): object {
393
+ /**
394
+ * OAuth 2.0 Protected Resource Metadata (RFC 9728); canonical URL preferred over Host.
395
+ *
396
+ * `path` is the resource the client asked about (RFC 9728 §3.1 inserts it after the
397
+ * well-known prefix: `/.well-known/oauth-protected-resource/mcp/sab` describes `/mcp/sab`).
398
+ * The client sends `resource` back to the provider as the resource indicator, and the
399
+ * provider only issues a token for an indicator it knows, so it must be the address the
400
+ * connector was given, not the bare origin.
401
+ */
402
+ export function getOAuthProtectedResourceMetadata(host: string, path = ""): object {
336
403
  return {
337
- resource: resourceBase(host),
404
+ resource: `${resourceBase(host)}${path}`,
338
405
  authorization_servers: [process.env.AUTH_ISSUER],
339
406
  scopes_supported: ["openid", "profile", "email"],
340
407
  bearer_methods_supported: ["header"],
@@ -46,3 +46,82 @@ export function expandTemplateSections(
46
46
  for (const [k2, v] of Object.entries(n)) o[k2] = expandTemplateSections(v, templateName, out);
47
47
  return o;
48
48
  }
49
+
50
+ /** One component a template HOLDS: which component, and the path of item arrays it is
51
+ * drawn over (`["courses", "dishes"]` is one instance per dish of every course). */
52
+ export interface HeldSpec {
53
+ component: string;
54
+ items: string[];
55
+ }
56
+
57
+ /** HOLD THE TEMPLATE'S OWN COMPONENTS (docs/design/templates.md "Holding a component"). A
58
+ * `Ref` to a component whose states name places, drawn inside an `Each` over the template's
59
+ * data, is not drawn flat: each item is its own instance of that component, keeping its own
60
+ * state, and the layout keeps a slot for it in its spot. `holds` resolves a ref to the
61
+ * component's name when it is one to hold, and to nothing otherwise, so this stays pure. */
62
+ export function expandHeldRefs(node: unknown, holds: (ref: string) => string | undefined, out: HeldSpec[], scope: string[] = []): unknown {
63
+ if (Array.isArray(node)) return node.map((c) => expandHeldRefs(c, holds, out, scope));
64
+ if (!node || typeof node !== "object") return node;
65
+ const n = node as Record<string, unknown>;
66
+ if (scope.length && n.type === "Ref" && typeof n.ref === "string" && !n.ref.startsWith(PART_PREFIX)) {
67
+ const component = holds(n.ref);
68
+ if (component) {
69
+ // One entry per component and path: a layout drawn in two branches holds it once.
70
+ if (!out.some((h) => h.component === component && h.items.join("/") === scope.join("/"))) out.push({ component, items: [...scope] });
71
+ const { type: _t, ref: _r, ...rest } = n;
72
+ return { type: "ComponentSlot", hold: { type: component, items: [...scope] }, ...rest };
73
+ }
74
+ }
75
+ // An `Each` over a bound field draws one item of it; an `Each` over literal `items` draws
76
+ // the author's own content, which holds nothing.
77
+ const itemsField = n.type === "Each" ? (n.bind as { items?: unknown } | undefined)?.items : undefined;
78
+ const itemScope = n.type !== "Each" ? scope : typeof itemsField === "string" && !Array.isArray(n.items) ? [...scope, itemsField] : [];
79
+ const o: Record<string, unknown> = {};
80
+ for (const [k, v] of Object.entries(n)) o[k] = expandHeldRefs(v, holds, out, k === "item" ? itemScope : scope);
81
+ return o;
82
+ }
83
+
84
+ type Data = Record<string, unknown>;
85
+
86
+ /** A held instance's node id: its template's, the component, and the item's own id. The SDK
87
+ * builds the same id to find it (web/sdk core/apps.ts `heldPointer`), so this is the one place
88
+ * it is spelled on the server side. */
89
+ export function heldNodeId(host: string, component: string, key: string): string {
90
+ return `${host}:${component}:${key}`;
91
+ }
92
+
93
+ /** An item's own id: the row's id when it carries one, else its position in its own list,
94
+ * which is the `index` the client's `Each` gives it. */
95
+ function keyOfItem(item: Data, index: number): string {
96
+ const id = item.universal_id ?? item.ref ?? item.id;
97
+ return typeof id === "string" || typeof id === "number" ? String(id) : String(index);
98
+ }
99
+
100
+ /** Every item a held spec is drawn over, walking its path of arrays (`courses` then `dishes`).
101
+ * Undefined when the data does not carry the path's first field: a partial update that leaves
102
+ * the held data alone holds nothing new. */
103
+ function itemsAt(data: Data, path: string[]): { item: Data; index: number }[] | undefined {
104
+ if (!(path[0] in data)) return undefined;
105
+ let level: { item: unknown; index: number }[] = [{ item: data, index: 0 }];
106
+ for (const field of path)
107
+ level = level.flatMap(({ item: o }) => {
108
+ const list = o && typeof o === "object" ? (o as Data)[field] : undefined;
109
+ return Array.isArray(list) ? list.map((item, index) => ({ item, index })) : [];
110
+ });
111
+ return level.filter((i): i is { item: Data; index: number } => !!i.item && typeof i.item === "object" && !Array.isArray(i.item));
112
+ }
113
+
114
+ /** The instances a template holds, from data written to it: one per item of each held spec,
115
+ * each keyed by the item's own id and carrying the item's fields as its props. */
116
+ export function heldInstances(held: HeldSpec[], data: Data): { key: string; component: string; props: Data }[] {
117
+ const seen = new Set<string>();
118
+ const out: { key: string; component: string; props: Data }[] = [];
119
+ for (const spec of held)
120
+ itemsAt(data, spec.items)?.forEach(({ item, index }) => {
121
+ const key = keyOfItem(item, index);
122
+ if (seen.has(`${spec.component}:${key}`)) return;
123
+ seen.add(`${spec.component}:${key}`);
124
+ out.push({ key, component: spec.component, props: { ...item } });
125
+ });
126
+ return out;
127
+ }
@@ -8,7 +8,7 @@
8
8
  import type { UnoverseDefinition } from "../../model.js";
9
9
  import { loadDefinition } from "../load.js";
10
10
  import { composeIncludes } from "../expand/index.js";
11
- import { expandTemplateSections } from "./sections.js";
11
+ import { expandHeldRefs, expandTemplateSections, heldInstances, type HeldSpec } from "./sections.js";
12
12
  import { placesOfStates, pointStepsAtStep, STEP } from "./component.js";
13
13
 
14
14
  /**
@@ -105,19 +105,37 @@ export function synthesizeTemplateRoot(def: UnoverseDefinition, folderDir?: stri
105
105
  }
106
106
  const layouts: Record<string, unknown> = {};
107
107
  const sections: NonNullable<UnoverseDefinition["sections"]> = {};
108
+ const held: Record<string, HeldSpec[]> = {};
109
+ // A component is HELD when its states name places: it can open somewhere of its own.
110
+ const holds = (ref: string): string | undefined => {
111
+ const c = loadDefinition(ref, "component", "expanded", def.org) as { statePlaces?: Record<string, string>; org?: string } | null;
112
+ if (!c?.statePlaces || !Object.keys(c.statePlaces).length) return undefined;
113
+ return ref.includes("/") || !c.org ? ref : `${c.org}/${ref}`;
114
+ };
108
115
  const compiledPreview: Record<string, string[]> = {};
109
116
  for (const name of shapeNames) {
110
117
  const st = (states[name] ?? {}) as Record<string, unknown>;
111
118
  if (typeof st.layout !== "string") continue;
112
119
  const secs: NonNullable<UnoverseDefinition["sections"]>[string] = [];
113
120
  const authored = composeIncludes({ $include: st.layout }, folderDir);
114
- layouts[name] = expandTemplateSections(steps.size ? pointStepsAtStep(authored, steps) : authored, def.name, secs);
121
+ const heldHere: HeldSpec[] = [];
122
+ const holding = expandHeldRefs(authored, holds, heldHere);
123
+ layouts[name] = expandTemplateSections(steps.size ? pointStepsAtStep(holding, steps) : holding, def.name, secs);
115
124
  sections[name] = secs;
125
+ if (heldHere.length) held[name] = heldHere;
116
126
  // MOCK VALUES TRAVEL WITH THE SECTION — a preview host seeds instances from the wire.
117
127
  seedSections(secs, def.org, compiledPreview);
118
128
  }
119
129
  if (!Object.keys(layouts).length) return;
120
130
  def.layouts = layouts;
131
+ if (Object.keys(held).length) {
132
+ def.held = held;
133
+ // THE HELD INSTANCES' STAND-INS: one per item of the template's own preview data, so a
134
+ // preview host seeds them the way it seeds parts and the arrangement draws finished.
135
+ const props = (def.props ?? {}) as Record<string, { preview?: unknown; default?: unknown } | null>;
136
+ const mock = Object.fromEntries(Object.entries(props).map(([k, v]) => [k, v?.preview ?? v?.default]));
137
+ def.heldSeeds = Object.fromEntries(Object.entries(held).map(([layout, specs]) => [layout, heldInstances(specs, mock)]));
138
+ }
121
139
  def.defaultLayout = shapeNames[0];
122
140
  def.stateOrder = shapeNames.slice(1);
123
141
  // THE FACE IT ARRIVES WEARING (2026-08-30). A template's states compiled into
@@ -140,10 +158,13 @@ export function synthesizeTemplateRoot(def: UnoverseDefinition, folderDir?: stri
140
158
  const names = subs && typeof subs === "object" ? Object.keys(subs) : [];
141
159
  // A state that takes the whole screen says so; the SDK takes the browser fullscreen for it.
142
160
  const full = (states[n] as { fullscreen?: unknown } | null)?.fullscreen === true ? { fullscreen: true } : {};
161
+ // A state whose steps the arrow keys move says so; no other template answers the keys.
162
+ const arrows = (states[n] as { arrows?: unknown } | null)?.arrows === true ? { arrows: true } : {};
143
163
  if (!names.length) return { name: n, ...full };
144
164
  return {
145
165
  name: n,
146
166
  ...full,
167
+ ...arrows,
147
168
  on: STEP,
148
169
  initial: names[0],
149
170
  states: names.map((sub) => ({ name: sub, ...(typeof subs![sub]?.layout === "string" ? { layout: subs![sub]!.layout as string } : {}) })),
@@ -38,6 +38,13 @@ export interface UnoverseDefinition {
38
38
  seeds?: Record<string, Record<string, unknown>>;
39
39
  }[]
40
40
  >;
41
+ /** TEMPLATES: the components each state's layout HOLDS, one instance per item of the
42
+ * data it is drawn over (docs/design/templates.md "Holding a component"). The wire layout
43
+ * carries a ComponentSlot with `hold` in each one's spot. */
44
+ held?: Record<string, { component: string; items: string[] }[]>;
45
+ /** TEMPLATES: the held instances' preview stand-ins per state, from the template's own prop
46
+ * previews. Studio seeds them under its template instance; never used at runtime. */
47
+ heldSeeds?: Record<string, { key: string; component: string; props: Record<string, unknown> }[]>;
41
48
  /** TEMPLATES: Studio preview mocks per slot — interface refs, each rendering its
42
49
  * own defaults (the interface-model rule: mocks are the interface's OWN defaults,
43
50
  * never authored preview data). From the template's manifest.yaml. */
@@ -155,6 +162,8 @@ export interface UnoverseDefinition {
155
162
  states?: { name: string; layout?: string }[];
156
163
  /** The state takes the whole screen (the authored `fullscreen: true`). */
157
164
  fullscreen?: boolean;
165
+ /** The arrow keys step this state's steps (the authored `arrows: true`). */
166
+ arrows?: boolean;
158
167
  }[];
159
168
  }
160
169
 
@@ -163,13 +163,12 @@ export interface AppManifest {
163
163
  * (`isPublicEntry`). The org URL picks WHICH SET a connector sees; this picks what is in
164
164
  * that set at all. */
165
165
  makeMCP?: boolean;
166
- /** Asset origins this app's content loads (images, media) — e.g. the org's image CDN.
167
- * Authored per app in manifest.yaml; the server UNIONS them across the connector's
168
- * published apps into the MCP-app shell's CSP (`ui.csp.resourceDomains`). A sandboxed
169
- * host builds its img-src from that list and blocks everything else, so an origin
170
- * missing here renders as broken images in ChatGPT/Claude while working in our own
171
- * hosts. Full origins with scheme, wildcards allowed: "https://*.datocms-assets.com". */
172
- assetDomains?: string[];
166
+ /** The outbound hosts this folder may reach (lifecycle-hooks.md): a hook's server-side
167
+ * calls, and the pictures its content loads. The server UNIONS them across the
168
+ * connector's org into the MCP-app shell's CSP (`ui.csp.resourceDomains`), because a
169
+ * sandboxed host builds its img-src from that list and blocks everything else. Bare
170
+ * hosts, the node package grammar: "www.sab.com", "*.datocms-assets.com". */
171
+ allowedHosts?: string[];
173
172
  /** Render lifetime (docs/design/04 §Two lifetimes). Default "turn": the instance returns
174
173
  * to inline/retires on the next user turn (the universal reset). "conversation" = a
175
174
  * DURABLE conversation-scoped surface (a cart, an itinerary, a composed page): keyed by
@@ -5,6 +5,7 @@
5
5
  import type { NodeDefinition } from "./types.js";
6
6
  import { getNode } from "./registry.js";
7
7
  import { buildExecutionContext } from "./platform/index.js";
8
+ import { recordCall } from "./manifests/runtime/tools/toolloop.js";
8
9
 
9
10
  /**
10
11
  * Create a logger for a node type - outputs structured JSON for Loki/Promtail
@@ -251,7 +252,30 @@ export async function executeServiceCall(
251
252
  }
252
253
 
253
254
  const executionContext = buildExecutionContext(nodeType, {}, config, context);
254
- return await executor.handleServiceCall(method, params, config, executionContext);
255
+
256
+ /**
257
+ * THE METHOD RECORDS ITS OWN CALL (packages/docs/nodes/mcp-services.md, "Who records a call"),
258
+ * nested under the node that called it (`callingNodeId`; `nodeId` is this node's own by now),
259
+ * whoever that is, so the caller
260
+ * records nothing. Only a method the node offers as a tool: `getSchema` is discovery, not a
261
+ * call anybody chose. Taken from the caller's own context, never the fallbacks
262
+ * buildExecutionContext invents, so a call outside a run records nothing.
263
+ */
264
+ const offered = (Array.isArray(nodeDef.serviceConnectors) ? nodeDef.serviceConnectors : []).some(
265
+ (c: any) => Array.isArray(c?.methods) && c.methods.includes(method),
266
+ );
267
+ const startTime = Date.now();
268
+ const record = (result: unknown, success: boolean) =>
269
+ offered &&
270
+ recordCall({ executionId: context?.executionId, parentNodeId: context?.callingNodeId, toolName: method, args: params, result, startTime, success, who: nodeType });
271
+ try {
272
+ const result = await executor.handleServiceCall(method, params, config, executionContext);
273
+ record(result, true);
274
+ return result;
275
+ } catch (err: any) {
276
+ record({ error: err?.message ?? String(err) }, false);
277
+ throw err;
278
+ }
255
279
  }
256
280
 
257
281
  /**
@@ -11,6 +11,7 @@
11
11
  */
12
12
  import type { ToolBridge } from "../runtime/index.js";
13
13
  import { engineUrl } from "./context.js";
14
+ import { saveAvailableTools } from "../../platform/serviceCalls.js";
14
15
 
15
16
  /** The screen's verbs on an app with places (docs/unoverse/design/screen/UNOVERSE_SCREEN.md §3.3). */
16
17
  const SCREEN_VERBS = {
@@ -60,6 +61,13 @@ export async function toolBridgeFor(executionContext: any): Promise<ToolBridge |
60
61
  chatId: pub?.chatId,
61
62
  accessToken: executionContext?.auth?.accessToken,
62
63
  };
64
+ /**
65
+ * THE CALLER, for a tool the MODEL calls: each records its own call on the trace under this
66
+ * run and this node (packages/docs/nodes/mcp-services.md, "Who records a call"). Kept apart
67
+ * from `identity`, which also travels on calls no model made.
68
+ */
69
+ const caller = { executionId: executionContext?.executionId, nodeId: executionContext?.nodeId };
70
+ const called = { ...identity, trace: caller };
63
71
 
64
72
  /**
65
73
  * Discovered PATH-B component apps, by tool name.
@@ -83,11 +91,6 @@ export async function toolBridgeFor(executionContext: any): Promise<ToolBridge |
83
91
  */
84
92
  const discoveredApps = new Set<string>();
85
93
 
86
- /**
87
- * TOOLS MINTED FROM A TASK ROW, by minted name. A call to one of these is a task step on
88
- * the execution timeline, not a bare tool call; `kindOf` answers from this set.
89
- */
90
- const taskTools = new Set<string>();
91
94
  /**
92
95
  * TOOLS WHOSE CALL HANDS THE TURN OFF: a workflow-bound app fired through the service
93
96
  * lane (`workflow@trigger`, fire-and-forget). Only these can end the loop; a component
@@ -120,7 +123,11 @@ export async function toolBridgeFor(executionContext: any): Promise<ToolBridge |
120
123
  appInvokers.set(name, async (input) => {
121
124
  const { client, close } = await harness.connectPlatformMcp({ accessToken: identity.accessToken });
122
125
  try {
123
- const r = (await client.callTool({ name, arguments: { ...((input ?? {}) as Record<string, unknown>), userId: identity.userId, conversationId: identity.conversationId, chatId: identity.chatId } })) as { content?: Array<{ type: string; text?: string }> };
126
+ const r = (await client.callTool({
127
+ name,
128
+ arguments: { ...((input ?? {}) as Record<string, unknown>), userId: identity.userId, conversationId: identity.conversationId, chatId: identity.chatId },
129
+ ...(caller.executionId && caller.nodeId ? { _meta: { [harness.TRACE_META]: caller } } : {}),
130
+ })) as { content?: Array<{ type: string; text?: string }> };
124
131
  return r.content?.find((c) => c.type === "text")?.text ?? "";
125
132
  } finally {
126
133
  await close();
@@ -150,7 +157,7 @@ export async function toolBridgeFor(executionContext: any): Promise<ToolBridge |
150
157
  schema: { methods: {} },
151
158
  };
152
159
  const def = await harness.resolveToolDefNative(mcp, { accessToken: identity.accessToken });
153
- appInvokers.set(def.name, harness.componentAppInvoker(mcp, identity));
160
+ appInvokers.set(def.name, harness.componentAppInvoker(mcp, called));
154
161
  discoveredApps.add(mcp.component);
155
162
  preMinted.push({
156
163
  ...def,
@@ -171,7 +178,7 @@ export async function toolBridgeFor(executionContext: any): Promise<ToolBridge |
171
178
 
172
179
  return {
173
180
  async discover() {
174
- return [
181
+ const tools = [
175
182
  ...Object.entries<any>(schema.methods).map(([name, m]) => ({
176
183
  name,
177
184
  description: m.description || `Execute ${name}`,
@@ -179,6 +186,9 @@ export async function toolBridgeFor(executionContext: any): Promise<ToolBridge |
179
186
  })),
180
187
  ...preMinted,
181
188
  ];
189
+ // Everything the model starts with, the screen and open pages included.
190
+ if (caller.executionId && caller.nodeId) saveAvailableTools(caller.executionId, caller.nodeId, tools.map((t) => t.name));
191
+ return tools;
182
192
  },
183
193
 
184
194
  async call(name, args) {
@@ -220,9 +230,8 @@ export async function toolBridgeFor(executionContext: any): Promise<ToolBridge |
220
230
  // carry no schema copy. For a BRIEFED component that schema is the instruction
221
231
  // channel, so the model hydrates real fields instead of following prose.
222
232
  const def = await harness.resolveToolDefNative(mcp, { accessToken: identity.accessToken });
223
- const invoke = harness.componentAppInvoker(mcp, identity);
233
+ const invoke = harness.componentAppInvoker(mcp, called);
224
234
  appInvokers.set(def.name, invoke);
225
- if (mcp.task) taskTools.add(def.name);
226
235
 
227
236
  /**
228
237
  * A TASK OPENS WHEN THE LLM CALLS IT (UNOVERSE_RENDER_PIPELINE §4, locked
@@ -255,7 +264,7 @@ export async function toolBridgeFor(executionContext: any): Promise<ToolBridge |
255
264
  const def = harness.toolDefFromDiscoveredMCP(mcp);
256
265
  // A WORKFLOW TASK (2026-09-06) is called NATIVELY: the held call runs the Agent
257
266
  // and returns its result. Registered by the minted name, like a component app.
258
- if (harness.isWorkflowTask(mcp)) appInvokers.set(def.name, harness.workflowTaskInvoker(mcp, identity));
267
+ if (harness.isWorkflowTask(mcp)) appInvokers.set(def.name, harness.workflowTaskInvoker(mcp, called));
259
268
  else if (mcp.workflowId) {
260
269
  // A WORKFLOW-BOUND APP (path A) OPENS, then ends the turn. It used to only end
261
270
  // the turn: nothing registered an invoker, so the model's call fell through to
@@ -263,11 +272,10 @@ export async function toolBridgeFor(executionContext: any): Promise<ToolBridge |
263
272
  // success and the screen stayed blank (SAB, 2026-09-07). Both, in this order —
264
273
  // the app tool renders the surface and fires no workflow itself (server
265
274
  // mcp/server/tools.ts), so the handoff that follows is not a second run.
266
- appInvokers.set(def.name, harness.workflowAppInvoker(mcp, identity));
275
+ appInvokers.set(def.name, harness.workflowAppInvoker(mcp, called));
267
276
  handoffTools.add(def.name);
268
277
  }
269
- if (mcp.task) taskTools.add(def.name);
270
- minted.push({ name: def.name, description: def.description, parameters: def.parameters, ...(def.outputSchema ? { outputSchema: def.outputSchema } : {}) });
278
+ minted.push({ name: def.name, description: def.description, parameters: def.parameters, ...(def.outputSchema ? { outputSchema: def.outputSchema } : {}) });
271
279
  }
272
280
 
273
281
  /**
@@ -280,7 +288,7 @@ export async function toolBridgeFor(executionContext: any): Promise<ToolBridge |
280
288
  */
281
289
  for (const fn of harness.parseOfferedFunctions(toolName, resultContent)) {
282
290
  const def = harness.objectFunctionToolDef(fn);
283
- appInvokers.set(def.name, harness.objectFunctionInvoker(fn, engineUrl()));
291
+ appInvokers.set(def.name, harness.objectFunctionInvoker(fn, engineUrl(), caller));
284
292
  minted.push({ name: def.name, description: def.description, parameters: def.parameters });
285
293
  }
286
294
 
@@ -291,15 +299,13 @@ export async function toolBridgeFor(executionContext: any): Promise<ToolBridge |
291
299
  */
292
300
  const skills = harness.parseRegionSkills(resultContent);
293
301
 
302
+ // The model's choice has grown: each call from here records the tools it really had.
303
+ if (minted.length && caller.executionId && caller.nodeId) saveAvailableTools(caller.executionId, caller.nodeId, minted.map((m) => m.name));
294
304
  return { content, minted, skills };
295
305
  },
296
306
 
297
307
  endsTurn(calls) {
298
308
  return harness.isTurnEndingHandoff(calls, handoffTools);
299
309
  },
300
-
301
- kindOf(toolName) {
302
- return taskTools.has(toolName) ? "task" : "tool";
303
- },
304
310
  };
305
311
  }
@@ -148,7 +148,10 @@ export async function runFinal(
148
148
  const res = await sendRequest(node, call, ctx, node.type);
149
149
  const transport = call.transport;
150
150
 
151
- if (transport === "json" || transport === "text") {
151
+ // Every SETTLED transport readSettled knows, not only json and text: a node may end on a
152
+ // file download (binary) or an xml body just as it may on a json one. binary as the final
153
+ // call threw here while working everywhere else (VeoVideo's download, 2026-09-29).
154
+ if (transport === "json" || transport === "text" || transport === "binary" || transport === "xml") {
152
155
  const payload = await readSettled(res, transport, call.encoding);
153
156
  await assertOk(node, call, payload, node.type);
154
157
  // Token usage: a settled vendor body carries it inline (Chat Completions puts it
@@ -68,7 +68,7 @@ const IDLE_TIMEOUT_MS = 30_000;
68
68
  const SILENCE_TIMEOUT_MS = 60_000;
69
69
  const MAX_SESSION_MS = 30 * 60_000;
70
70
 
71
- import { offerDiscovered, parseMaybeJson, recordToolTrace, type ToolBridge } from "../tools/toolloop.js";
71
+ import { offerDiscovered, parseMaybeJson, type ToolBridge } from "../tools/toolloop.js";
72
72
  import { applyInForce, renderSkillsText, type InForceSkill } from "../tools/regionSkills.js";
73
73
  import { registerLiveInput, liveInputKey } from "../../../platform/liveInput.js";
74
74
 
@@ -750,10 +750,6 @@ export async function runDuplexSession(opts: {
750
750
  // A lookup takes 8-9s, and silence on a call reads as a dropped line. The client,
751
751
  // the lookup-indicator and the docs all had this wired already; nothing ever sent it.
752
752
  sendState("tool", "TOOL_USE", { toolName: name });
753
- // Started BEFORE the call, so the timeline bar measures the wait the caller
754
- // actually sat through, not the bookkeeping after it.
755
- const toolStart = Date.now();
756
- let toolOk = true;
757
753
  try {
758
754
  const args = JSON.parse(rawArgs || "{}");
759
755
  /**
@@ -825,12 +821,10 @@ export async function runDuplexSession(opts: {
825
821
  } catch (err: any) {
826
822
  // Reported TO THE MODEL rather than thrown. A failed tool on a live call should let
827
823
  // the assistant say it could not do the thing, not drop the conversation.
828
- toolOk = false;
829
824
  output = `Tool "${name}" failed: ${err?.message ?? err}`;
830
825
  }
831
- // The same timeline row an HTTP agent's tool call draws. `runToolLoop` has recorded
832
- // these since the migration; the socket never called it, so voice tools were invisible.
833
- recordToolTrace(node, ctx, name, parseMaybeJson(rawArgs), output, toolStart, toolOk, tools.kindOf?.(name) ?? "tool");
826
+ // The timeline row is the tool's own (packages/docs/nodes/mcp-services.md, "Who records
827
+ // a call"), so a voice Agent's calls read the same as a text Agent's.
834
828
  // After the catch, so a tool that THREW still stops the dots. Spinning forever is
835
829
  // worse than no indicator: it promises something still coming.
836
830
  sendState("tool", "TOOL_USE_COMPLETED");