@truefoundry/assistant-ui-runtime 0.1.4 → 0.1.6-rc.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.
Files changed (57) hide show
  1. package/README.md +374 -190
  2. package/dist/index.d.ts +32 -29
  3. package/dist/index.js +334 -241
  4. package/dist/index.js.map +1 -1
  5. package/dist/plugins/truefoundry-agent-server-adapter/index.d.ts +30 -0
  6. package/dist/plugins/truefoundry-agent-server-adapter/index.js +198 -0
  7. package/dist/plugins/truefoundry-agent-server-adapter/index.js.map +1 -0
  8. package/dist/types-VUBzoJT2.d.ts +462 -0
  9. package/package.json +12 -4
  10. package/src/askUserQuestion.ts +3 -3
  11. package/src/collectPending.ts +1 -1
  12. package/src/convertTurnMessages.test.ts +141 -196
  13. package/src/convertTurnMessages.ts +131 -77
  14. package/src/createSubAgent.ts +1 -1
  15. package/src/draftAgentConfig.test.ts +26 -29
  16. package/src/extractTurnUserText.ts +1 -1
  17. package/src/foldPeerThreads.test.ts +1 -1
  18. package/src/foldPeerThreads.ts +3 -2
  19. package/src/index.ts +39 -4
  20. package/src/listPages.ts +21 -0
  21. package/src/loadSessionSnapshot.test.ts +9 -8
  22. package/src/loadSessionSnapshot.ts +9 -14
  23. package/src/mcpAuth.ts +6 -3
  24. package/src/messageCustomMetadata.ts +1 -1
  25. package/src/modelMessageContent.ts +1 -1
  26. package/src/modelMessageImageContent.test.ts +1 -1
  27. package/src/modelMessageImageContent.ts +7 -6
  28. package/src/plugins/truefoundry-agent-server-adapter/index.ts +285 -0
  29. package/src/private/agentSpec.ts +8 -3
  30. package/src/private/draftSessionBridge.ts +14 -13
  31. package/src/private/truefoundryDraftThreadListAdapter.test.ts +44 -49
  32. package/src/private/truefoundryDraftThreadListAdapter.ts +22 -16
  33. package/src/requiredActionInputs.ts +1 -1
  34. package/src/requiredActionsFromActiveUpdate.test.ts +1 -1
  35. package/src/server/eventUtils.ts +120 -0
  36. package/src/server/events.ts +246 -0
  37. package/src/server/index.ts +66 -0
  38. package/src/server/types.ts +313 -0
  39. package/src/sessionSnapshot.ts +1 -1
  40. package/src/sessions.ts +5 -21
  41. package/src/streamTurn.test.ts +175 -158
  42. package/src/streamTurn.ts +51 -57
  43. package/src/toolApproval.ts +4 -4
  44. package/src/toolResponse.ts +4 -4
  45. package/src/truefoundryExtras.ts +1 -1
  46. package/src/truefoundryOwnedSessionsThreadListAdapter.test.ts +26 -29
  47. package/src/truefoundryOwnedSessionsThreadListAdapter.ts +18 -23
  48. package/src/truefoundryThreadListAdapter.test.ts +16 -18
  49. package/src/truefoundryThreadListAdapter.ts +9 -9
  50. package/src/turnEventHelpers.ts +1 -1
  51. package/src/types.ts +2 -16
  52. package/src/useTrueFoundryAgentMessages.test.tsx +38 -70
  53. package/src/useTrueFoundryAgentMessages.ts +33 -45
  54. package/src/useTrueFoundryAgentRuntime.ts +11 -28
  55. package/src/private/bindDraftAgentSession.test.ts +0 -54
  56. package/src/private/bindDraftAgentSession.ts +0 -28
  57. package/src/private/getGatewayFromPrivateClient.ts +0 -13
package/README.md CHANGED
@@ -1,55 +1,77 @@
1
- # truefoundry-agents-assistant-ui-runtime
1
+ # @truefoundry/assistant-ui-runtime
2
2
 
3
- TrueFoundry Gateway agent runtime adapter for [assistant-ui](https://www.assistant-ui.com/).
3
+ TrueFoundry agent runtime adapter for [assistant-ui](https://www.assistant-ui.com/).
4
4
 
5
5
  Connect assistant-ui components (`Thread`, `Composer`, tool UIs, `ThreadList`) to TrueFoundry agent sessions via `useTrueFoundryAgentRuntime`. The adapter maps gateway turns and streaming events onto assistant-ui's external-store runtime, including multi-agent nesting, tool approvals, ask-user tool responses, MCP auth, batched resume, resumable streams, and composer attachment forwarding on send.
6
6
 
7
7
  ## Requirements
8
8
 
9
9
  - **React** `^18 || ^19` (peer dependency)
10
- - **`truefoundry-gateway-sdk`** (peer dependency) — provides `AgentSessionClient` and agent types
11
10
  - **`@assistant-ui/react`** in the host app for the UI primitives
12
- - Bundled deps `@assistant-ui/core` and `@assistant-ui/store` are pulled in automatically
11
+ - An **`AgentChatServer`** implementation either use the built-in TrueFoundry gateway plugin (see below) or bring your own
12
+
13
+ Bundled deps `@assistant-ui/core` and `@assistant-ui/store` are pulled in automatically.
13
14
 
14
15
  ## Installation
15
16
 
16
17
  ```bash
17
- npm install @assistant-ui/react truefoundry-agents-assistant-ui-runtime truefoundry-gateway-sdk@^0.1.0-rc.1
18
+ npm install @truefoundry/assistant-ui-runtime @assistant-ui/react
19
+ ```
20
+
21
+ If using the built-in TrueFoundry gateway adapter plugin, also install the gateway SDK:
22
+
23
+ ```bash
24
+ npm install truefoundry-gateway-sdk
18
25
  ```
19
26
 
20
27
  ## Quickstart
21
28
 
22
- ### 1. Create an `AgentSessionClient`
29
+ ### 1. Create an `AgentChatServer`
30
+
31
+ The runtime accepts any object implementing the `AgentChatServer` interface — a flat, stateless port with methods like `createSession`, `listSessions`, `prepareAndExecuteTurn`, etc. It never reads credentials itself.
23
32
 
24
- Construct the client in your app (server module, proxy route, or demo). The runtime only accepts a pre-built client — it does **not** read API keys or gateway URLs itself.
33
+ Using the built-in TrueFoundry gateway plugin (requires `truefoundry-gateway-sdk`):
25
34
 
26
35
  ```tsx
27
- import { AgentSessionClient } from "truefoundry-gateway-sdk/agents";
36
+ import { createTrueFoundryChatServer } from "@truefoundry/assistant-ui-runtime/plugins/truefoundry-agent-server-adapter";
28
37
 
29
- const client = new AgentSessionClient({
38
+ const server = createTrueFoundryChatServer({
30
39
  apiKey: process.env.TFY_API_KEY!,
31
- environment: process.env.TFY_GATEWAY_URL!, // https://gateway.truefoundry.ai/<tenant>
40
+ baseUrl: process.env.TFY_GATEWAY_URL!,
32
41
  });
33
42
  ```
34
43
 
35
- For production, point `fetch` or `auth` at your own backend proxy so secrets never reach the browser.
44
+ Or bring your own implementation against any backend:
45
+
46
+ ```tsx
47
+ import type { AgentChatServer } from "@truefoundry/assistant-ui-runtime";
48
+
49
+ const server: AgentChatServer = {
50
+ createSession: async (req) => { /* ... */ },
51
+ listSessions: async (req) => { /* ... */ },
52
+ getSession: async (req) => { /* ... */ },
53
+ updateSession: async (req) => { /* ... */ },
54
+ prepareAndExecuteTurn: (req) => { /* return AsyncIterable<TurnStreamData> */ },
55
+ cancelSession: async (req) => { /* ... */ },
56
+ listTurns: async (req) => { /* ... */ },
57
+ getTurn: async (req) => { /* ... */ },
58
+ listEvents: async (req) => { /* ... */ },
59
+ };
60
+ ```
36
61
 
37
- ### 2. Set up the client runtime
62
+ ### 2. Set up the runtime
38
63
 
39
64
  ```tsx
40
65
  "use client";
41
66
 
42
67
  import { AssistantRuntimeProvider } from "@assistant-ui/react";
43
- import { useTrueFoundryAgentRuntime } from "truefoundry-agents-assistant-ui-runtime";
68
+ import { useTrueFoundryAgentRuntime } from "@truefoundry/assistant-ui-runtime";
44
69
  import { Thread } from "@/components/assistant-ui/thread";
45
70
 
46
- const AGENT_NAME = process.env.TFY_AGENT_NAME!;
47
- const client = new AgentSessionClient({ /* ... */ });
48
-
49
71
  export function MyAssistant() {
50
72
  const runtime = useTrueFoundryAgentRuntime({
51
- client,
52
- agentName: AGENT_NAME,
73
+ server,
74
+ agentName: "support-bot",
53
75
  });
54
76
 
55
77
  return (
@@ -84,46 +106,50 @@ See the assistant-ui [Thread UI guide](https://www.assistant-ui.com/docs/ui/thre
84
106
 
85
107
  | Option | Type | Required | Description |
86
108
  |--------|------|----------|-------------|
87
- | `client` | `AgentSessionClient` | Yes | Pre-built gateway client. The runtime never reads credentials itself. |
88
- | `agentName` | `string` | Yes | Saved agent to run (gateway agent name). |
109
+ | `server` | `AgentChatServer` | Yes | Server implementation. The runtime never reads credentials itself. |
110
+ | `agentName` | `string` | Yes* | Saved agent to run. *Or use `agent` for draft mode. |
111
+ | `agent` | `NamedAgentConfig \| DraftAgentConfig` | No | Discriminated agent source. Overrides `agentName` when set. |
89
112
  | `initialSessionId` | `string` | No | Pin an existing session once on mount (uncontrolled). |
90
113
  | `threadId` | `string` | No | Controlled active session id; reactive and URL-syncable. |
91
114
  | `onThreadIdChange` | `(threadId: string \| undefined) => void` | No | Fires when the active session changes. |
92
115
  | `onError` | `(error: unknown) => void` | No | Invoked on stream/load/turn errors. |
93
- | `adapters` | `{ attachments?, speech?, dictation?, voice?, feedback? }` | No | Optional assistant-ui adapters forwarded to the runtime. See [Unsupported assistant-ui features](#unsupported-assistant-ui-features). |
116
+ | `adapters` | `{ attachments?, speech?, dictation?, voice?, feedback? }` | No | Optional assistant-ui adapters forwarded to the runtime. |
94
117
 
95
118
  ### Specifying the agent
96
119
 
97
- Pass the saved agent name as `agentName`:
120
+ Named agent (saved on the gateway):
98
121
 
99
122
  ```tsx
100
123
  const runtime = useTrueFoundryAgentRuntime({
101
- client,
124
+ server,
102
125
  agentName: "support-bot",
103
126
  });
104
127
  ```
105
128
 
106
- ### Adding adapters
107
-
108
- Pass optional assistant-ui adapters through `adapters`. Attachments are **opt-in**: wire the built-in adapter when you want composer file pick / previews and gateway forwarding on send.
129
+ Draft agent (inline spec, mutable):
109
130
 
110
131
  ```tsx
111
- import { trueFoundryAttachmentAdapter, useTrueFoundryAgentRuntime } from "truefoundry-agents-assistant-ui-runtime";
112
-
113
132
  const runtime = useTrueFoundryAgentRuntime({
114
- client,
115
- agentName,
116
- adapters: { attachments: trueFoundryAttachmentAdapter },
133
+ server,
134
+ agent: {
135
+ mode: "draft",
136
+ defaultAgentSpec: { model: { name: "gpt-4o" } },
137
+ onAgentSpecChange: (spec) => console.log("spec updated", spec),
138
+ },
117
139
  });
118
140
  ```
119
141
 
120
- Other adapters (speech, feedback, etc.) follow the same pattern:
142
+ ### Adding adapters
143
+
144
+ Attachments are **opt-in**: wire the built-in adapter when you want composer file pick / previews and gateway forwarding on send.
121
145
 
122
146
  ```tsx
147
+ import { trueFoundryAttachmentAdapter, useTrueFoundryAgentRuntime } from "@truefoundry/assistant-ui-runtime";
148
+
123
149
  const runtime = useTrueFoundryAgentRuntime({
124
- client,
150
+ server,
125
151
  agentName,
126
- adapters: { attachments: trueFoundryAttachmentAdapter, speech, feedback },
152
+ adapters: { attachments: trueFoundryAttachmentAdapter },
127
153
  });
128
154
  ```
129
155
 
@@ -131,7 +157,7 @@ const runtime = useTrueFoundryAgentRuntime({
131
157
 
132
158
  ```tsx
133
159
  const runtime = useTrueFoundryAgentRuntime({
134
- client,
160
+ server,
135
161
  agentName,
136
162
  initialSessionId: "ses_abc123",
137
163
  });
@@ -139,11 +165,11 @@ const runtime = useTrueFoundryAgentRuntime({
139
165
 
140
166
  ### Bring your own session ID (no session list)
141
167
 
142
- You can drive a single, externally-owned session without rendering `<ThreadList>`. Pin the active session with `initialSessionId` (one-time) or controlled `threadId` (reactive, URL-syncable). Omit `<ThreadList>` — the session list adapter only powers that UI.
168
+ Pin the active session with `initialSessionId` (one-time) or controlled `threadId` (reactive, URL-syncable). Omit `<ThreadList>` — the session list adapter only powers that UI.
143
169
 
144
170
  ```tsx
145
171
  const runtime = useTrueFoundryAgentRuntime({
146
- client,
172
+ server,
147
173
  agentName,
148
174
  initialSessionId: "ses_abc123",
149
175
  });
@@ -157,16 +183,291 @@ return (
157
183
 
158
184
  Each gateway session corresponds to one assistant-ui thread.
159
185
 
186
+ ## `AgentChatServer` interface
187
+
188
+ The runtime operates against a flat server port — no session-with-methods objects, no SDK dependency. Any backend can implement this interface:
189
+
190
+ ```tsx
191
+ interface AgentChatServer {
192
+ createSession(req: CreateSessionRequest): Promise<Session>;
193
+ listSessions(req?: ListSessionsParams): Promise<ListResult<Session>>;
194
+ getSession(req: { sessionId: string }): Promise<Session>;
195
+ updateSession(req: UpdateSessionRequest): Promise<Session>;
196
+
197
+ prepareAndExecuteTurn(req: {
198
+ sessionId: string;
199
+ input?: TurnInputItem[];
200
+ previousTurnId?: PreviousTurnIdInput;
201
+ abortSignal?: AbortSignal;
202
+ headers?: Record<string, string>;
203
+ }): AsyncIterable<TurnStreamData>;
204
+
205
+ cancelSession(req: { sessionId: string }): Promise<void>;
206
+ deleteSession?(req: { sessionId: string }): Promise<void>;
207
+
208
+ listTurns(req: { sessionId: string; limit?: number; pageToken?: string; order?: "asc" | "desc" }): Promise<ListResult<Turn>>;
209
+ getTurn(req: { sessionId: string; turnId: string }): Promise<Turn>;
210
+ listEvents(req: { sessionId: string; pageToken?: string; lastTurnId?: string; limit?: number }): Promise<ListResult<SessionEventItem>>;
211
+
212
+ listTurnEvents?(req: { sessionId: string; turnId: string; limit?: number; pageToken?: string; order?: "asc" | "desc" }): Promise<ListResult<TurnEvent>>;
213
+ subscribeToTurn?(req: { sessionId: string; turnId: string; afterSequenceNumber?: number; abortSignal?: AbortSignal }): AsyncIterable<TurnStreamData>;
214
+ downloadSandboxFile?(sandboxId: string, req: { path: string }): Promise<Blob>;
215
+ }
216
+ ```
217
+
218
+ `ListResult<T>` is `{ data: T[]; nextPageToken?: string }` — flat token-based pagination.
219
+
220
+ ### Implementing your own backend
221
+
222
+ Below is a fully-typed class implementing `AgentChatServer` against a custom REST API. Use this as a starting point when integrating your own agent backend:
223
+
224
+ ```typescript
225
+ import type {
226
+ AgentChatServer,
227
+ CreateSessionRequest,
228
+ ListResult,
229
+ ListSessionsParams,
230
+ Session,
231
+ SessionEventItem,
232
+ Turn,
233
+ TurnEvent,
234
+ TurnInputItem,
235
+ TurnStreamData,
236
+ UpdateSessionRequest,
237
+ } from "@truefoundry/assistant-ui-runtime";
238
+
239
+ class MyAgentChatServer implements AgentChatServer {
240
+ constructor(private baseUrl: string, private authToken: string) {}
241
+
242
+ private async request<T>(path: string, init?: RequestInit): Promise<T> {
243
+ const res = await fetch(`${this.baseUrl}${path}`, {
244
+ ...init,
245
+ headers: {
246
+ "Content-Type": "application/json",
247
+ Authorization: `Bearer ${this.authToken}`,
248
+ ...init?.headers,
249
+ },
250
+ });
251
+ if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
252
+ return res.json() as Promise<T>;
253
+ }
254
+
255
+ async createSession(req: CreateSessionRequest): Promise<Session> {
256
+ return this.request("/sessions", {
257
+ method: "POST",
258
+ body: JSON.stringify(req),
259
+ });
260
+ }
261
+
262
+ async listSessions(req?: ListSessionsParams): Promise<ListResult<Session>> {
263
+ const params = new URLSearchParams();
264
+ if (req?.limit) params.set("limit", String(req.limit));
265
+ if (req?.pageToken) params.set("pageToken", req.pageToken);
266
+ if (req?.agentName) params.set("agentName", req.agentName);
267
+ return this.request(`/sessions?${params}`);
268
+ }
269
+
270
+ async getSession(req: { sessionId: string }): Promise<Session> {
271
+ return this.request(`/sessions/${req.sessionId}`);
272
+ }
273
+
274
+ async updateSession(req: UpdateSessionRequest): Promise<Session> {
275
+ return this.request(`/sessions/${req.sessionId}`, {
276
+ method: "PATCH",
277
+ body: JSON.stringify({ agentSpec: req.agentSpec, title: req.title }),
278
+ });
279
+ }
280
+
281
+ prepareAndExecuteTurn(req: {
282
+ sessionId: string;
283
+ input?: TurnInputItem[];
284
+ previousTurnId?: string;
285
+ abortSignal?: AbortSignal;
286
+ }): AsyncIterable<TurnStreamData> {
287
+ const self = this;
288
+ return {
289
+ [Symbol.asyncIterator]() {
290
+ return self.streamTurn(req);
291
+ },
292
+ };
293
+ }
294
+
295
+ private async *streamTurn(req: {
296
+ sessionId: string;
297
+ input?: TurnInputItem[];
298
+ previousTurnId?: string;
299
+ abortSignal?: AbortSignal;
300
+ }): AsyncGenerator<TurnStreamData> {
301
+ const res = await fetch(`${this.baseUrl}/sessions/${req.sessionId}/turns`, {
302
+ method: "POST",
303
+ headers: {
304
+ "Content-Type": "application/json",
305
+ Authorization: `Bearer ${this.authToken}`,
306
+ Accept: "text/event-stream",
307
+ },
308
+ body: JSON.stringify({
309
+ input: req.input,
310
+ previousTurnId: req.previousTurnId,
311
+ }),
312
+ signal: req.abortSignal,
313
+ });
314
+
315
+ if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
316
+ const reader = res.body!.getReader();
317
+ const decoder = new TextDecoder();
318
+ let buffer = "";
319
+
320
+ while (true) {
321
+ const { done, value } = await reader.read();
322
+ if (done) break;
323
+
324
+ buffer += decoder.decode(value, { stream: true });
325
+ const lines = buffer.split("\n");
326
+ buffer = lines.pop() ?? "";
327
+
328
+ for (const line of lines) {
329
+ if (!line.startsWith("data: ")) continue;
330
+ const json = line.slice(6);
331
+ if (json === "[DONE]") return;
332
+ yield JSON.parse(json) as TurnStreamData;
333
+ }
334
+ }
335
+ }
336
+
337
+ async cancelSession(req: { sessionId: string }): Promise<void> {
338
+ await this.request(`/sessions/${req.sessionId}/cancel`, { method: "POST" });
339
+ }
340
+
341
+ async listTurns(req: {
342
+ sessionId: string;
343
+ limit?: number;
344
+ pageToken?: string;
345
+ }): Promise<ListResult<Turn>> {
346
+ const params = new URLSearchParams();
347
+ if (req.limit) params.set("limit", String(req.limit));
348
+ if (req.pageToken) params.set("pageToken", req.pageToken);
349
+ return this.request(`/sessions/${req.sessionId}/turns?${params}`);
350
+ }
351
+
352
+ async getTurn(req: { sessionId: string; turnId: string }): Promise<Turn> {
353
+ return this.request(`/sessions/${req.sessionId}/turns/${req.turnId}`);
354
+ }
355
+
356
+ async listEvents(req: {
357
+ sessionId: string;
358
+ pageToken?: string;
359
+ lastTurnId?: string;
360
+ limit?: number;
361
+ }): Promise<ListResult<SessionEventItem>> {
362
+ const params = new URLSearchParams();
363
+ if (req.limit) params.set("limit", String(req.limit));
364
+ if (req.pageToken) params.set("pageToken", req.pageToken);
365
+ if (req.lastTurnId) params.set("lastTurnId", req.lastTurnId);
366
+ return this.request(`/sessions/${req.sessionId}/events?${params}`);
367
+ }
368
+
369
+ async listTurnEvents(req: {
370
+ sessionId: string;
371
+ turnId: string;
372
+ limit?: number;
373
+ pageToken?: string;
374
+ }): Promise<ListResult<TurnEvent>> {
375
+ const params = new URLSearchParams();
376
+ if (req.limit) params.set("limit", String(req.limit));
377
+ if (req.pageToken) params.set("pageToken", req.pageToken);
378
+ return this.request(
379
+ `/sessions/${req.sessionId}/turns/${req.turnId}/events?${params}`,
380
+ );
381
+ }
382
+
383
+ subscribeToTurn(req: {
384
+ sessionId: string;
385
+ turnId: string;
386
+ afterSequenceNumber?: number;
387
+ abortSignal?: AbortSignal;
388
+ }): AsyncIterable<TurnStreamData> {
389
+ const self = this;
390
+ return {
391
+ [Symbol.asyncIterator]() {
392
+ return self.streamSubscribe(req);
393
+ },
394
+ };
395
+ }
396
+
397
+ private async *streamSubscribe(req: {
398
+ sessionId: string;
399
+ turnId: string;
400
+ afterSequenceNumber?: number;
401
+ abortSignal?: AbortSignal;
402
+ }): AsyncGenerator<TurnStreamData> {
403
+ const params = new URLSearchParams();
404
+ if (req.afterSequenceNumber != null) {
405
+ params.set("after", String(req.afterSequenceNumber));
406
+ }
407
+ const res = await fetch(
408
+ `${this.baseUrl}/sessions/${req.sessionId}/turns/${req.turnId}/stream?${params}`,
409
+ {
410
+ headers: {
411
+ Authorization: `Bearer ${this.authToken}`,
412
+ Accept: "text/event-stream",
413
+ },
414
+ signal: req.abortSignal,
415
+ },
416
+ );
417
+
418
+ if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
419
+ const reader = res.body!.getReader();
420
+ const decoder = new TextDecoder();
421
+ let buffer = "";
422
+
423
+ while (true) {
424
+ const { done, value } = await reader.read();
425
+ if (done) break;
426
+
427
+ buffer += decoder.decode(value, { stream: true });
428
+ const lines = buffer.split("\n");
429
+ buffer = lines.pop() ?? "";
430
+
431
+ for (const line of lines) {
432
+ if (!line.startsWith("data: ")) continue;
433
+ const json = line.slice(6);
434
+ if (json === "[DONE]") return;
435
+ yield JSON.parse(json) as TurnStreamData;
436
+ }
437
+ }
438
+ }
439
+ }
440
+ ```
441
+
442
+ Then use it with the runtime:
443
+
444
+ ```tsx
445
+ const server = new MyAgentChatServer("https://api.example.com", authToken);
446
+
447
+ function App() {
448
+ const runtime = useTrueFoundryAgentRuntime({
449
+ server,
450
+ agentName: "my-agent",
451
+ });
452
+
453
+ return (
454
+ <AssistantRuntimeProvider runtime={runtime}>
455
+ <Thread />
456
+ </AssistantRuntimeProvider>
457
+ );
458
+ }
459
+ ```
460
+
160
461
  ## Multi-agent (nested sub-agents)
161
462
 
162
- TrueFoundry sub-agents are discovered at runtime via `thread.created` and nested under `ToolCallMessagePart.messages`. The gateway always sends `title` as a required `string` on `thread.created` / `thread.done` (never `null`). This runtime copies it onto `metadata.custom.subAgent.title` for the first nested message of each child thread; `name` comes from `agentInfo.name` on the same event.
463
+ TrueFoundry sub-agents are discovered at runtime via `thread.created` and nested under `ToolCallMessagePart.messages`. The gateway sends `title` on `thread.created` / `thread.done`. This runtime copies it onto `metadata.custom.subAgent.title` for the first nested message of each child thread; `name` comes from `agentInfo.name` on the same event.
163
464
 
164
- Render nested threads with `MessagePartPrimitive.Messages` inside your tool fallback (recommended — no per-tool registration):
465
+ Render nested threads with `MessagePartPrimitive.Messages` inside your tool fallback:
165
466
 
166
467
  ```tsx
167
468
  import { MessagePartPrimitive, MessagePrimitive } from "@assistant-ui/react";
168
469
  import { useAuiState } from "@assistant-ui/store";
169
- import type { TrueFoundryMessageCustomMetadata } from "truefoundry-agents-assistant-ui-runtime";
470
+ import type { TrueFoundryMessageCustomMetadata } from "@truefoundry/assistant-ui-runtime";
170
471
 
171
472
  function NestedSubAgentAssistantMessage() {
172
473
  const custom = useAuiState(
@@ -194,10 +495,6 @@ function NestedSubAgentAssistantMessage() {
194
495
  />
195
496
  ```
196
497
 
197
- For a collapsed tool-row header, cast the spawning `create_sub_agent` tool part’s `artifact` to `SubAgentArtifact` and read `subAgents[].title` (or `agentInfo.name`):
198
-
199
- Alternative: register `defineToolkit({ create_sub_agent: ... })` — all sub-agents share that one system tool name.
200
-
201
498
  See the [Multi-Agent Chat UI guide](https://www.assistant-ui.com/docs/tools/multi-agent).
202
499
 
203
500
  ## Tool approvals
@@ -237,7 +534,7 @@ import {
237
534
  useTrueFoundryApprovals,
238
535
  useTrueFoundryToolResponses,
239
536
  useTrueFoundryMcpAuth,
240
- } from "truefoundry-agents-assistant-ui-runtime";
537
+ } from "@truefoundry/assistant-ui-runtime";
241
538
 
242
539
  function ApprovalBar() {
243
540
  const { pending, respond } = useTrueFoundryApprovals();
@@ -293,28 +590,13 @@ function McpAuthContinue() {
293
590
 
294
591
  ### Action hooks (any render context, including nested sub-agents)
295
592
 
296
- ```tsx
297
- import { useTrueFoundryRespondToToolApproval } from "truefoundry-agents-assistant-ui-runtime";
298
-
299
- function NestedToolApprovalButton({ approvalId }: { approvalId: string }) {
300
- const respond = useTrueFoundryRespondToToolApproval();
301
- return (
302
- <button onClick={() => respond({ approvalId, approved: true })}>
303
- Allow
304
- </button>
305
- );
306
- }
307
- ```
308
-
309
- The action-only hooks return a single callback you can call from any render context (root or nested sub-agent thread). All four follow the same pattern:
310
-
311
593
  ```tsx
312
594
  import {
313
595
  useTrueFoundryRespondToToolApproval,
314
596
  useTrueFoundryRespondToToolResponse,
315
597
  useTrueFoundryResumeMcpAuth,
316
598
  useTrueFoundryCancel,
317
- } from "truefoundry-agents-assistant-ui-runtime";
599
+ } from "@truefoundry/assistant-ui-runtime";
318
600
 
319
601
  const respondToApproval = useTrueFoundryRespondToToolApproval();
320
602
  const respondToResponse = useTrueFoundryRespondToToolResponse();
@@ -331,170 +613,85 @@ void cancel();
331
613
 
332
614
  | Hook | Returns | Description |
333
615
  |------|---------|-------------|
334
- | `useTrueFoundryApprovals()` | `{ pending: PendingApproval[]; respond: (r: RespondToToolApprovalOptions) => void }` | Pending tool approvals (across all threads) plus a respond action. Use for thread-level approval chrome. |
335
- | `useTrueFoundryToolResponses()` | `{ pending: PendingToolResponse[]; respond: (r: RespondToToolResponseOptions) => void }` | Pending ask-user / `tool.response_required` prompts plus a respond action. |
336
- | `useTrueFoundryMcpAuth()` | `{ pending: { mcpServers } \| null; resume: () => Promise<void> }` | Pending MCP OAuth pause state plus a resume action. |
337
- | `useTrueFoundryRespondToToolApproval()` | `(r: RespondToToolApprovalOptions) => void` | Respond to a tool approval from any render context, including nested sub-agent (readonly) renderers. |
338
- | `useTrueFoundryRespondToToolResponse()` | `(r: RespondToToolResponseOptions) => void` | Respond to an ask-user / `tool.response_required` prompt from any render context. |
339
- | `useTrueFoundryResumeMcpAuth()` | `() => Promise<void>` | Resume the paused turn after the user completes MCP OAuth in the browser. |
340
- | `useTrueFoundryCancel()` | `() => Promise<void>` | Cancel the active turn. Calls `session.cancel()` and drains the stream to its terminal `turn.done`. |
341
- | `useTrueFoundryHistoryPagination()` | `{ hasOlderHistory, isLoadingOlderHistory, loadOlderHistory }` | Scroll-up older history: call `loadOlderHistory()` when the user nears the top of the thread. |
342
-
343
- Where:
344
-
345
- - `PendingApproval` = `{ approvalId, threadId, toolName, args, argsText }`
346
- - `PendingToolResponse` = `{ toolCallId, threadId, toolName, args, argsText, question?, options? }`
347
- - `RespondToToolApprovalOptions` = `{ approvalId, approved, optionId?, reason? }`
348
- - `RespondToToolResponseOptions` = `{ toolCallId, content }`
349
-
350
- > **Read vs. action hooks.** The three `use*Approvals` / `use*ToolResponses` / `use*McpAuth` read hooks subscribe to extras state and re-render when pending items change — use them in thread-level UI (e.g. an approval bar). The four action-only hooks (`useTrueFoundryRespondTo*`, `useTrueFoundryResumeMcpAuth`, `useTrueFoundryCancel`) read the action via `trueFoundryExtras.get(aui)` and do **not** subscribe to state, so they are safe to call from nested sub-agent renderers where only a readonly context is available.
616
+ | `useTrueFoundryApprovals()` | `{ pending, respond }` | Pending tool approvals plus a respond action. |
617
+ | `useTrueFoundryToolResponses()` | `{ pending, respond }` | Pending ask-user prompts plus a respond action. |
618
+ | `useTrueFoundryMcpAuth()` | `{ pending, resume }` | Pending MCP OAuth pause state plus a resume action. |
619
+ | `useTrueFoundryRespondToToolApproval()` | `(r) => void` | Respond to a tool approval from any render context. |
620
+ | `useTrueFoundryRespondToToolResponse()` | `(r) => void` | Respond to an ask-user prompt from any render context. |
621
+ | `useTrueFoundryResumeMcpAuth()` | `() => Promise<void>` | Resume after MCP OAuth. |
622
+ | `useTrueFoundryCancel()` | `() => Promise<void>` | Cancel the active turn. |
623
+ | `useTrueFoundryResetFromTurn()` | n/a | Re-submit a user turn (branch/reset). |
624
+ | `useTrueFoundryReload()` | n/a | Retry the current session load. |
625
+ | `useTrueFoundryHistoryPagination()` | `{ hasOlderHistory, isLoadingOlderHistory, loadOlderHistory }` | Scroll-up older history. |
351
626
 
352
627
  ### Low-level namespace
353
628
 
354
629
  ```tsx
355
- import { trueFoundryExtras, type TrueFoundryRuntimeExtras } from "truefoundry-agents-assistant-ui-runtime";
630
+ import { trueFoundryExtras, type TrueFoundryRuntimeExtras } from "@truefoundry/assistant-ui-runtime";
356
631
 
357
- // Throws outside useTrueFoundryAgentRuntime:
358
632
  const extras = trueFoundryExtras.use();
359
-
360
- // Safe with fallback (returns default outside runtime):
361
633
  const pending = trueFoundryExtras.use((e) => e.pendingApprovals, []);
362
634
  ```
363
635
 
364
- `TrueFoundryRuntimeExtras` fields:
365
-
366
- | Field | Type | Purpose |
367
- |-------|------|---------|
368
- | `pendingApprovals` | `PendingApproval[]` | Undecided tool approvals across all threads |
369
- | `pendingToolResponses` | `PendingToolResponse[]` | Unanswered ask-user / client-side tool prompts |
370
- | `pendingMcpAuth` | `{ mcpServers } \| null` | MCP OAuth pause state |
371
- | `respondToToolApproval` | `(r: { approvalId, approved, reason? }) => void` | Stage approval; batch-send when complete |
372
- | `respondToToolResponse` | `(r: { toolCallId, content }) => void` | Stage answer; batch-send when complete |
373
- | `resumeMcpAuth` | `() => Promise<void>` | Resume after OAuth |
374
- | `cancel` | `() => Promise<void>` | Cancel the active turn: calls `session.cancel()` and lets the stream drain to its terminal `turn.done` (reconciles on next session load) |
375
- | `resetFromTurn` | `(turnId: string) => Promise<void>` | Re-submit a user turn (branch/reset) |
376
- | `reload` | `() => void` | Retry the current session load |
377
- | `downloadSandboxFile` | `(path: string) => Promise<Blob>` | Download a file from the session sandbox |
378
- | `hasOlderHistory` | `boolean` | True when another older `listEvents` page is available |
379
- | `isLoadingOlderHistory` | `boolean` | True while `loadOlderHistory` is in flight |
380
- | `loadOlderHistory` | `() => Promise<void>` | Prepend the next older history window (scroll-up) |
381
- | `draft` | `TrueFoundryDraftRuntimeExtras \| null` | Draft-mode agent spec sync extras |
382
-
383
- Per-part `respondToApproval` from assistant-ui still works for root-thread tool UIs; extras complements that for global chrome and nested renderers.
384
-
385
636
  ## Cancellation
386
637
 
387
- `cancel()` does **not** tear down the stream mid-flight. It calls `session.cancel()` and then keeps consuming the active stream: the backend closes the SSE gracefully by emitting a terminal `turn.done` event before ending the stream, so the in-flight run drains to completion on its own. No explicit reconcile is performed — the cancelled turn is terminal, and local state reconciles against the authoritative event log on the next session load (e.g. page reload). A subsequent `sendTurn` chains on the cancelled turn's history via `previousTurnId: "auto"`.
388
-
389
- (Hard aborts still happen when *switching away* — starting a new run or changing sessions abandons the previous turn.)
638
+ `cancel()` calls `server.cancelSession()` and then keeps consuming the active stream: the backend closes the SSE gracefully by emitting a terminal `turn.done` event before ending the stream. No explicit reconcile is performed — the cancelled turn is terminal, and local state reconciles against the authoritative event log on the next session load.
390
639
 
391
640
  ## Resumable streams
392
641
 
393
- Works out of the box — no server route or Redis store. TrueFoundry persists every turn server-side; on reload or reconnect the runtime calls `turn.stream({})` and replays events into the fold (idempotent). Running turns are detected on session load and resumed automatically.
394
-
395
- > **TODO:** Track the last ingested `sequenceNumber` and pass `afterSequenceNumber` on reconnect to avoid replaying already-seen events.
396
-
397
- Contrast with the [AI SDK resumable streams guide](https://www.assistant-ui.com/docs/guides/resumable-streams), which requires a separate encoded-byte store.
642
+ Works out of the box — no server route or Redis store. TrueFoundry persists every turn server-side; on reload or reconnect the runtime calls `subscribeToTurn` and replays events into the fold (idempotent). Running turns are detected on session load and resumed automatically.
398
643
 
399
644
  ## History pagination
400
645
 
401
646
  Thread open no longer drains every turn. Initial load:
402
647
 
403
- 1. `listTurns({ limit: 1 })` once — detect a running turn (does **not** walk `page_token`).
648
+ 1. `listTurns({ limit: 1 })` once — detect a running turn.
404
649
  2. One (or a few) `listEvents` page(s) for the newest complete user-message group.
405
650
  3. Clears `isLoading`, then resumes a running turn via subscribe if needed.
406
651
 
407
- Older history is opt-in via extras / `useTrueFoundryHistoryPagination()`:
652
+ Older history is opt-in via `useTrueFoundryHistoryPagination()`:
408
653
 
409
654
  ```tsx
410
655
  const { hasOlderHistory, isLoadingOlderHistory, loadOlderHistory } =
411
656
  useTrueFoundryHistoryPagination();
412
657
 
413
- // e.g. IntersectionObserver at the top of the message list
414
658
  if (hasOlderHistory && !isLoadingOlderHistory) {
415
659
  void loadOlderHistory();
416
660
  }
417
661
  ```
418
662
 
419
- `loadOlderHistory` prepends older turns without aborting an active stream.
420
-
421
- ## Public API
422
-
423
- Everything below is exported from the package root (`truefoundry-agents-assistant-ui-runtime`).
424
-
425
- | Export | Kind | Purpose |
426
- |--------|------|---------|
427
- | `useTrueFoundryAgentRuntime` | hook | Main entry point. Returns an assistant-ui runtime bound to gateway sessions. |
428
- | `UseTrueFoundryAgentRuntimeOptions` | type | Options for the hook (see table above). |
429
- | `useTrueFoundryApprovals` | hook | `{ pending, respond }` for tool approvals via extras. |
430
- | `useTrueFoundryToolResponses` | hook | `{ pending, respond }` for ask-user / `tool.response_required` prompts. |
431
- | `useTrueFoundryMcpAuth` | hook | `{ pending, resume }` for MCP OAuth pause/resume. |
432
- | `useTrueFoundryRespondToToolApproval` | hook | Action callback via `trueFoundryExtras.get(aui)` — works in nested renderers. |
433
- | `useTrueFoundryRespondToToolResponse` | hook | Same pattern for tool responses. |
434
- | `useTrueFoundryResumeMcpAuth` | hook | Same pattern for MCP resume. |
435
- | `useTrueFoundryCancel` | hook | Same pattern for cancel. |
436
- | `useTrueFoundryHistoryPagination` | hook | `{ hasOlderHistory, isLoadingOlderHistory, loadOlderHistory }` for scroll-up history. |
437
- | `trueFoundryExtras` | namespace | `createRuntimeExtras` channel — `.use()`, `.get(aui)`, `.provide()`. |
438
- | `TrueFoundryRuntimeExtras` | type | Shape provided into the runtime extras slot. |
439
- | `PendingApproval`, `PendingToolResponse` | types | Derived pending items for UI rendering. |
440
- | `createTrueFoundryThreadListAdapter` | fn | Builds the cursor-paginated `RemoteThreadListAdapter` powering `<ThreadList>` (`list({ after })` → `nextCursor`). Used internally; exported for custom wiring. |
441
- | `getSession` | fn | `(client, sessionId) => Promise<AgentSession>` convenience wrapper. |
442
- | `convertTurnsToThreadMessages` | fn | Loads a session's turns and folds them into assistant-ui `ThreadMessage[]` (`ConvertTurnsResult`). |
443
- | `buildTurnAssistantContent` | fn | Folds a single turn's events into assistant content parts. |
444
- | `repositoryItemsFromMessages` | fn | Converts messages into `ExportedMessageRepositoryItem[]` for history export. |
445
- | `getTurnMessageContent` | fn | Extracts the text payload from an `AppendMessage`. |
446
- | `ConvertTurnsResult` | type | Result of `convertTurnsToThreadMessages` (`messages`, `foldState`, `runningTurn?`, `unstable_resume?`). |
447
- | `collectApprovalInputs` | fn | Collects decided approvals from a message into `user.tool_approval` inputs. |
448
- | `collectResponseInputs` | fn | Collects staged answers into `user.tool_response` inputs. |
449
- | `collectRequiredActionInputs` | fn | Collects both approval + response inputs once nothing is pending. |
450
- | `messageHasPendingApprovals` | fn | True if a message still has undecided tool approvals. |
451
- | `messageHasPendingResponses` | fn | True if a message still has unanswered tool responses. |
452
- | `messageHasPendingRequiredActions` | fn | True if either approvals or responses are still pending. |
453
- | `findPausedAssistantMessage` | fn | Last assistant message in `requires-action` state. |
454
- | `toTrueFoundryApprovalInputs` | fn | Applies an approval decision and returns gateway inputs. |
455
- | `SubAgentArtifact`, `SubAgentCustomMetadata` | types | Shapes attached to sub-agent tool calls / nested messages. |
456
- | `TrueFoundryMessageCustomMetadata` | type | Typed keys on `ThreadMessage.metadata.custom` written by this adapter. |
457
- | `ROOT_THREAD_ID` | const | The literal `"main"` — the gateway's root thread id. |
458
-
459
663
  ## Architecture (source map)
460
664
 
461
- For contributors and agents working inside this package. Source lives in `src/`; the published entry point is `dist/index.js` (built by `tsup`).
665
+ For contributors working inside this package. Source lives in `src/`; the published entry point is `dist/index.js` (built by `tsup`).
462
666
 
463
667
  | File | Responsibility |
464
668
  |------|----------------|
669
+ | `server/types.ts` | `AgentChatServer` + `AgentBuilderServer` interfaces, `Session`, `Turn`, `AgentSpec`, pagination types. |
670
+ | `server/events.ts` | Concrete turn/stream event types (`ModelMessageEvent`, `TurnCreatedEvent`, etc.). |
671
+ | `server/eventUtils.ts` | `isEventDelta()` + `mergeEventDelta()` — streaming delta merge logic. |
465
672
  | `useTrueFoundryAgentRuntime.ts` | Public hook. Wires the external-store runtime, thread-list runtime, adapters, and extras. |
466
- | `useTrueFoundryAgentMessages.ts` | Reactive `SessionSnapshot` store: load, stream ingestion, cancel, resume; derives `messages` via pure projection; records approval/response decisions in overlay. |
673
+ | `useTrueFoundryAgentMessages.ts` | Reactive `SessionSnapshot` store: load, stream ingestion, cancel, resume; derives `messages` via pure projection. |
467
674
  | `sessionSnapshot.ts` | `SessionSnapshot` shape, required-actions overlay, and immutable wrapper helpers. |
468
675
  | `truefoundryExtras.ts` | `createRuntimeExtras` namespace and `TrueFoundryRuntimeExtras` type. |
469
676
  | `hooks.ts` | Consumer hooks — read selectors + action callbacks via `.get(aui)`. |
470
677
  | `collectPending.ts` | Derives `pendingApprovals`, `pendingToolResponses`, `pendingMcpAuth` from messages. |
471
678
  | `requiredActionInputs.ts` | Combined gate + `collectRequiredActionInputs` for batched resume. |
472
- | `truefoundryThreadListAdapter.ts` | `RemoteThreadListAdapter` — cursor-paginated session list (`list({ after })` → `nextCursor`), create/fetch sessions. |
473
- | `convertTurnMessages.ts` | `projectSessionMessages` pure projector; `buildSnapshotFromSession` history ingest; `convertTurnsToThreadMessages` wrapper; stream-event aggregation. |
679
+ | `truefoundryThreadListAdapter.ts` | `RemoteThreadListAdapter` — cursor-paginated session list. |
680
+ | `convertTurnMessages.ts` | `projectSessionMessages` pure projector; `buildSnapshotFromSessionEvents` history ingest; stream-event aggregation. |
474
681
  | `foldPeerThreads.ts` | `PeerThreadFoldState` — folds peer/sub-agent threads under their spawning tool call. |
475
- | `messageCustomMetadata.ts` | `TrueFoundryMessageCustomMetadata` typed `metadata.custom` keys for this adapter. |
476
- | `modelMessageContent.ts` | `model.message` events → assistant content parts (text, reasoning, tool calls). |
477
- | `streamTurn.ts` | `streamTurnContent` / `resumeTurnStream` generators over `prepareTurn`/`stream`. |
682
+ | `streamTurn.ts` | `streamTurnContent` / `resumeTurnStream` generators over `AgentChatServer`. |
478
683
  | `toolApproval.ts` | Approval state, decision mapping, and `user.tool_approval` input collection. |
479
684
  | `toolResponse.ts` | Ask-user response state, staging, and `user.tool_response` input collection. |
480
- | `askUserQuestion.ts` | `ask_user_question` detection and argument parsing. |
481
- | `mcpAuth.ts` | MCP auth-required detection and structured authorize UI metadata. |
482
- | `turnEventHelpers.ts` | Appends approval / response / MCP-auth status onto turn updates. |
483
- | `createSubAgent.ts` | Detects the `create_sub_agent` system tool call. |
484
- | `extractTurnUserText.ts` / `lastUserMessageText.ts` | Text extraction helpers. |
485
- | `sessions.ts` | `getSession` wrapper. |
486
- | `sessionListStartTimestamp.ts` | Default `listSessions` window (1 year). |
487
- | `constants.ts` | `ROOT_THREAD_ID = "main"`. |
488
- | `types.ts` / `turnStreamUpdate.ts` | Shared option and update types. |
685
+ | `listPages.ts` | `drainListPages` utility for exhausting token-paginated `ListResult` APIs. |
489
686
 
490
687
  ### Invariants
491
688
 
492
689
  - One gateway **session** ⇄ one assistant-ui **thread** (`session.id` = thread `remoteId`).
493
690
  - The root thread id is always `"main"` (`ROOT_THREAD_ID`); sub-agent threads nest beneath their `create_sub_agent` tool call.
494
- - The runtime never holds credentials — always pass a constructed `AgentSessionClient`.
495
- - Gateway types come from `truefoundry-gateway-sdk/agents`; do not redefine event/turn shapes locally.
691
+ - The runtime never holds credentials — it only accepts a pre-built `AgentChatServer`.
692
+ - Event/turn types are defined in `src/server/events.ts` (first-party, no external SDK dependency).
496
693
  - A paused turn's resume `input` must include **all** pending `user.tool_approval` and `user.tool_response` events across every thread in one batch.
497
- - Approval decisions are allow/deny only (`ApprovalDecision`); there is no `optionId` on the gateway wire.
694
+ - Two agent modes: **named** (`agentName`) and **draft** (`agent: { mode: "draft", defaultAgentSpec }`).
498
695
 
499
696
  ## Local development
500
697
 
@@ -506,7 +703,7 @@ pnpm test # vitest run
506
703
  pnpm typecheck # tsc --noEmit
507
704
  ```
508
705
 
509
- `dist/` is generated output and is gitignored. From the repo root, `pnpm build` builds this package before the Next.js app.
706
+ `dist/` is generated output and is gitignored. From the repo root, `pnpm build` builds this package.
510
707
 
511
708
  ## Unsupported assistant-ui features
512
709
 
@@ -514,22 +711,9 @@ Features below are not implemented in this adapter today. Other assistant-ui cap
514
711
 
515
712
  | Feature | Notes |
516
713
  |---------|-------|
517
- | Attachment rendering | Attachments are forwarded to the gateway on send when you provide an `AttachmentAdapter`, but user message bubbles show text only. |
714
+ | Attachment rendering | Attachments are forwarded to the gateway on send, but user message bubbles show text only. |
518
715
  | Built-in `AttachmentAdapter` | Ships as `trueFoundryAttachmentAdapter` (opt-in via `adapters.attachments`). Not applied by default. |
519
- | Speech synthesis (`adapters.speech`) | Pass-through only. Not shipped. |
520
- | Dictation (`adapters.dictation`) | Pass-through only. Not shipped. |
521
- | Voice (`adapters.voice`) | Pass-through only. Not shipped. |
522
- | Feedback (`adapters.feedback`) | Pass-through only. Ratings are not persisted to the gateway. |
523
- | Message edit (`onEdit`) | Not wired. |
524
- | Regenerate (`onReload`) | Not wired. |
525
- | Message delete (`onDelete`) | Not wired. |
526
- | Client-side tool results (`onAddToolResult`) | Not wired. |
527
- | Tool call resume (`onResumeToolCall`) | Not wired. |
528
- | Message queue (`queue`) | Not wired. |
529
- | Branch switching | Not wired. |
716
+ | Speech / Dictation / Voice | Pass-through only. Not shipped. |
717
+ | Feedback | Pass-through only. Ratings are not persisted to the gateway. |
530
718
  | Thread rename / archive / delete | Thread-list adapter no-ops. |
531
719
  | Thread title generation | Returns an empty stream. |
532
- | Generative UI message parts | Not mapped from gateway events. |
533
- | Source citation parts | Not mapped from gateway events. |
534
- | Message import / external state | `onImport`, `onExportExternalState`, `onLoadExternalState` not wired. |
535
- | Composer suggestions | `suggestions` not populated. |