@frontera-sdk/chat 1.50.40

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.
@@ -0,0 +1,133 @@
1
+ /**
2
+ * The OAuth callback page, as a string of HTML.
3
+ *
4
+ * The callback has to live on YOUR domain — a provider only authorises
5
+ * redirect URIs under a domain its project owner has verified — so this is one
6
+ * page every integration must host, and the only page in the whole flow they
7
+ * cannot get from us pre-built. What it does is small and entirely mechanical:
8
+ * take `code` and `state` off its own URL and hand them back to the chat.
9
+ *
10
+ * So we ship the mechanism instead of the instructions. Any JS server can
11
+ * serve this — a Next route handler, Express, Hono, Fastify, Workers, Bun,
12
+ * Deno — and a backend in another language can serve the same markup as a
13
+ * static file (`bunx frontera-chat-callback > callback.html`, or copy it from
14
+ * the docs).
15
+ *
16
+ * No import runs on the page itself: the script is inlined, because a callback
17
+ * page that has to load a bundle is a callback page that can fail to load one
18
+ * while holding an authorization code.
19
+ */
20
+
21
+ export interface ConnectCallbackPageOptions {
22
+ /**
23
+ * Origin allowed to receive the code in popup mode. Defaults to the page's
24
+ * own origin, correct when the chat and the callback are the same app.
25
+ *
26
+ * Never `'*'`. The message carries an authorization code, and any page that
27
+ * can guess your callback could otherwise listen for it.
28
+ */
29
+ targetOrigin?: string
30
+ /** Shown while the page does its work. Replace it to match your product. */
31
+ message?: string
32
+ /** Fallback when a redirect return has no stored address. Defaults to `/`. */
33
+ fallbackReturnTo?: string
34
+ }
35
+
36
+ const DEFAULT_MESSAGE = 'Finishing up…'
37
+
38
+ /**
39
+ * `JSON.stringify` also escapes `<` when we ask, which is what keeps a value
40
+ * from closing the script element it sits inside.
41
+ */
42
+ function jsLiteral(value: string): string {
43
+ return JSON.stringify(value).replace(/</g, '\\u003c')
44
+ }
45
+
46
+ export function renderConnectCallbackPage(options: ConnectCallbackPageOptions = {}): string {
47
+ const targetOrigin = options.targetOrigin ? jsLiteral(options.targetOrigin) : 'location.origin'
48
+ const fallback = jsLiteral(options.fallbackReturnTo ?? '/')
49
+ const message = (options.message ?? DEFAULT_MESSAGE).replace(/[<>&]/g, (c) =>
50
+ c === '<' ? '&lt;' : c === '>' ? '&gt;' : '&amp;',
51
+ )
52
+
53
+ return `<!doctype html>
54
+ <html lang="en">
55
+ <head>
56
+ <meta charset="utf-8">
57
+ <meta name="viewport" content="width=device-width, initial-scale=1">
58
+ <meta name="robots" content="noindex">
59
+ <title>Connecting…</title>
60
+ <style>
61
+ body {
62
+ margin: 0; min-height: 100vh;
63
+ display: flex; align-items: center; justify-content: center;
64
+ font: 15px/1.5 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
65
+ color: #16201f; background: #fbfbfa;
66
+ }
67
+ @media (prefers-color-scheme: dark) { body { color: #e6ecea; background: #0e1413; } }
68
+ p { opacity: .7; }
69
+ </style>
70
+ </head>
71
+ <body>
72
+ <p>${message}</p>
73
+ <script>
74
+ (function () {
75
+ var params = new URLSearchParams(location.search);
76
+ var payload = {
77
+ type: 'frontera:oauth-callback',
78
+ code: params.get('code') || undefined,
79
+ state: params.get('state') || undefined,
80
+ error: params.get('error') || undefined
81
+ };
82
+
83
+ // Popup: hand the result to the window that opened us, then get out of the
84
+ // way. The origin is pinned — this message carries an authorization code.
85
+ if (window.opener) {
86
+ try { window.opener.postMessage(payload, ${targetOrigin}); } catch (e) {}
87
+ window.close();
88
+ return;
89
+ }
90
+
91
+ // Redirected tab — the normal path on mobile, where popups are blocked. The
92
+ // chat parked its return address before leaving, because the redirect URI is
93
+ // registered with the provider and cannot carry one.
94
+ var returnTo = ${fallback};
95
+ try {
96
+ var pending = JSON.parse(sessionStorage.getItem('frontera:connect-pending') || 'null');
97
+ if (pending && pending.returnTo) returnTo = pending.returnTo;
98
+ } catch (e) {}
99
+
100
+ var target = new URL(returnTo, location.origin);
101
+ if (payload.code) target.searchParams.set('frontera_code', payload.code);
102
+ if (payload.state) target.searchParams.set('frontera_state', payload.state);
103
+ if (payload.error) target.searchParams.set('frontera_error', payload.error);
104
+ location.replace(target.toString());
105
+ })();
106
+ </script>
107
+ </body>
108
+ </html>`
109
+ }
110
+
111
+ /**
112
+ * A `Response` carrying the page, for any runtime with the Fetch API.
113
+ *
114
+ * ```ts
115
+ * // Next.js — app/oauth/callback/route.ts
116
+ * export const GET = () => connectCallbackResponse()
117
+ * ```
118
+ *
119
+ * `no-store` matters: this page is only ever loaded with a single-use code in
120
+ * the query string, and a cached copy is a stale code waiting to confuse
121
+ * someone.
122
+ */
123
+ export function connectCallbackResponse(options: ConnectCallbackPageOptions = {}): Response {
124
+ return new Response(renderConnectCallbackPage(options), {
125
+ headers: {
126
+ 'content-type': 'text/html; charset=utf-8',
127
+ 'cache-control': 'no-store',
128
+ // The page is not meant to be embedded, and framing it would put an
129
+ // authorization code inside someone else's document.
130
+ 'x-frame-options': 'DENY',
131
+ },
132
+ })
133
+ }
@@ -0,0 +1,472 @@
1
+ import type { FronteraClient } from '@frontera-sdk/core/client'
2
+ import { FronteraError } from '@frontera-sdk/core/errors'
3
+
4
+ import { chatDoctor } from './doctor'
5
+ import { resumeStream, startChat, type ChatStreamOptions } from './stream'
6
+ import type {
7
+ ArtifactVersionMeta,
8
+ ApprovalDecision,
9
+ ChatAgent,
10
+ ChatArtifact,
11
+ CompleteAppConnectionRequest,
12
+ CompleteAppConnectionResult,
13
+ PendingApproval,
14
+ ResumeToolCallResult,
15
+ StartAppConnectionRequest,
16
+ StartAppConnectionResult,
17
+ UploadedFile,
18
+ ChatClientOptions,
19
+ CreateSessionRequest,
20
+ ListSessionsQuery,
21
+ Paginated,
22
+ Session,
23
+ SessionMessage,
24
+ StartChatRequest,
25
+ StartChatResult,
26
+ UpdateSessionRequest,
27
+ } from './types'
28
+
29
+ /**
30
+ * Domain client for the chat surface, in the mould of `BlueprintClient`: one
31
+ * thin method per endpoint over a `FronteraClient`.
32
+ *
33
+ * `authorizeConversation` exists because under API-key auth the SERVER SKIPS
34
+ * conversation ownership checks — any conversation id under the key's agent is
35
+ * accepted. A backend serving several of its own users must therefore gate
36
+ * conversation access itself; wiring that gate in here means every access path
37
+ * goes through it. Methods throw `FronteraError` code `FORBIDDEN_LOCAL` when
38
+ * the callback denies. Under an end-user `cht_` token the server enforces
39
+ * isolation and the callback is unnecessary.
40
+ */
41
+ export class ChatClient {
42
+ constructor(
43
+ private readonly client: FronteraClient,
44
+ private readonly options: ChatClientOptions = {},
45
+ ) {}
46
+
47
+ private async authorize(conversationId: string): Promise<void> {
48
+ const gate = this.options.authorizeConversation
49
+ if (!gate) return
50
+ if (!(await gate(conversationId))) {
51
+ throw new FronteraError(`access to conversation ${conversationId} denied locally`, {
52
+ code: 'FORBIDDEN_LOCAL',
53
+ })
54
+ }
55
+ }
56
+
57
+ // --- chat runs ---
58
+
59
+ /** Start (or continue) a run. See `stream.ts` for stream semantics. */
60
+ async startChat(request: StartChatRequest, options?: ChatStreamOptions): Promise<StartChatResult> {
61
+ if (request.conversationId) await this.authorize(request.conversationId)
62
+ return startChat(this.client, request, options)
63
+ }
64
+
65
+ /**
66
+ * Re-attach to a run's stream. Run ids are capabilities: they only leave the
67
+ * server through a response the caller was already authorized to receive, so
68
+ * there is no conversation gate here — treat them accordingly.
69
+ */
70
+ resumeStream(
71
+ runId: string,
72
+ options?: { startIndex?: number } & ChatStreamOptions,
73
+ ): Promise<Response> {
74
+ return resumeStream(this.client, runId, options)
75
+ }
76
+
77
+ cancelRun(runId: string): Promise<{ success: true }> {
78
+ return this.client.request(`/v1/chat/${encodeURIComponent(runId)}/cancel`, { method: 'POST' })
79
+ }
80
+
81
+ steerRun(runId: string, message: string): Promise<{ success: true }> {
82
+ return this.client.request(`/v1/chat/${encodeURIComponent(runId)}/steer`, {
83
+ method: 'POST',
84
+ body: { message },
85
+ })
86
+ }
87
+
88
+ // --- approvals ---
89
+
90
+ /**
91
+ * The tool calls this conversation has parked, waiting on a person.
92
+ *
93
+ * Poll or call this on mount: a run blocked on an approval stays blocked
94
+ * across a page reload, and the `tool-approval-request` chunk that announced
95
+ * it is not replayed by a fresh `useChat`. Without reading them back, the
96
+ * conversation looks like an agent that stopped for no reason.
97
+ *
98
+ * End-user (`cht_`) tokens only — see `respondToApproval`.
99
+ */
100
+ async listPendingApprovals(conversationId: string): Promise<PendingApproval[]> {
101
+ // Reading the list counts as handling it: a surface that knows what is
102
+ // parked is a surface that can render it, and warning at one that already
103
+ // asked would be noise.
104
+ chatDoctor.resolved('approval')
105
+ chatDoctor.resolved('connect')
106
+ await this.authorize(conversationId)
107
+ const envelope = await this.client.requestEnvelope<{ data: PendingApproval[] }>(
108
+ '/v1/chat/approvals',
109
+ { query: { conversationId } },
110
+ )
111
+ return envelope.data
112
+ }
113
+
114
+ /**
115
+ * Approve or deny a parked tool call, releasing (or refusing) the run.
116
+ *
117
+ * Requires an end-user `cht_` token: the decision is recorded against the
118
+ * person who made it, and an `sak_` key is one credential shared by every
119
+ * user of your product — there is nobody it could be deciding as, so the
120
+ * server answers 403 (`FORBIDDEN`). An integration that wants its agent to
121
+ * act unattended sets the agent's approval mode to `auto` instead.
122
+ *
123
+ * Deciding twice is not an error: the first decision wins and this reports
124
+ * the settled status. A lapsed request answers `expired`.
125
+ */
126
+ respondToApproval(
127
+ approvalId: string,
128
+ decision: ApprovalDecision,
129
+ ): Promise<{ id: string; status: string }> {
130
+ chatDoctor.resolved('approval')
131
+ return this.client.request(`/v1/chat/approvals/${encodeURIComponent(approvalId)}`, {
132
+ method: 'POST',
133
+ body: { decision },
134
+ })
135
+ }
136
+
137
+ // --- app connections ---
138
+
139
+ /**
140
+ * Begin connecting the end user's own account for a parked `needs_connect`
141
+ * call, and get the URL to send them to.
142
+ *
143
+ * Three things must line up before this answers, and all three are one-time
144
+ * setup rather than per-user work:
145
+ * 1. the workspace has ITS OWN OAuth client for the provider (the
146
+ * deployment-managed one cannot serve this — your users are authorising
147
+ * your application, and only its owner can register your callback);
148
+ * 2. `redirectUri` is registered with that client AND on the workspace's
149
+ * embed allowlist;
150
+ * 3. the install allows external users, and this user's token carries the
151
+ * `plugin-connect` capability you granted when you minted it.
152
+ *
153
+ * Open the returned `authUrl` in a popup. Your callback receives `code` and
154
+ * `state` — hand them to `completeAppConnection`, then `resumeToolCall`.
155
+ */
156
+ startAppConnection(request: StartAppConnectionRequest): Promise<StartAppConnectionResult> {
157
+ chatDoctor.resolved('connect')
158
+ return this.client.request('/v1/chat/app-connections/start', {
159
+ method: 'POST',
160
+ body: request,
161
+ })
162
+ }
163
+
164
+ /**
165
+ * Finish a connect from the code your own callback received.
166
+ *
167
+ * `state` is signed by us and names the install, the user and the redirect
168
+ * URI, so none of the three can be swapped between the authorize request and
169
+ * this call. Pass it back exactly as it arrived.
170
+ */
171
+ completeAppConnection(
172
+ request: CompleteAppConnectionRequest,
173
+ ): Promise<CompleteAppConnectionResult> {
174
+ return this.client.request('/v1/chat/app-connections/exchange', {
175
+ method: 'POST',
176
+ body: request,
177
+ })
178
+ }
179
+
180
+ /**
181
+ * Re-run the tool call that was waiting on the connection.
182
+ *
183
+ * Not optional: the agent is blocked on a tool RESULT, not on an account, so
184
+ * a connect without this leaves the conversation exactly as stuck as before.
185
+ */
186
+ resumeToolCall(pendingToolCallId: string): Promise<ResumeToolCallResult> {
187
+ return this.client.request('/v1/chat/app-connections/resume', {
188
+ method: 'POST',
189
+ body: { pendingToolCallId },
190
+ })
191
+ }
192
+
193
+ /**
194
+ * The agents this credential may talk to — what an agent picker renders.
195
+ *
196
+ * Scoped to the credential, so a token bound to one agent lists exactly that
197
+ * agent while a workspace-wide token lists them all. The Console listing is
198
+ * session-only and returns configuration an end user has no business seeing,
199
+ * which is why this exists separately.
200
+ */
201
+ listAgents(): Promise<ChatAgent[]> {
202
+ return this.client.request('/v1/chat-agents')
203
+ }
204
+
205
+ /**
206
+ * Upload a file for this user, then reference the result in
207
+ * `startChat({ fileAttachments: [uploaded] })`.
208
+ *
209
+ * A chat-scoped route, not the platform's `/v1/upload`: that one authorises
210
+ * through workspace membership, which an end user of a customer's product
211
+ * does not have and should not need.
212
+ */
213
+ async upload(file: File | Blob, filename?: string): Promise<UploadedFile> {
214
+ const form = new FormData()
215
+ form.append('file', file, filename ?? (file as File).name ?? 'upload')
216
+ // Not through `request`: that serialises JSON and would set the wrong
217
+ // content type. The boundary must be chosen by FormData itself.
218
+ const response = await this.client.requestRaw('/v1/chat-uploads', {
219
+ method: 'POST',
220
+ body: form as unknown as undefined,
221
+ })
222
+ const payload = (await response.json()) as { data?: UploadedFile } & UploadedFile
223
+ return payload.data ?? payload
224
+ }
225
+
226
+ /**
227
+ * A live URL for a stored upload.
228
+ *
229
+ * The URL saved on a message is presigned and expires about an hour after
230
+ * upload, so anything rendered later re-signs from the stored path — without
231
+ * this, attachments in yesterday's messages become broken images.
232
+ */
233
+ async signUpload(storagePath: string): Promise<string> {
234
+ const { url } = await this.client.request<{ url: string }>('/v1/chat-uploads/sign', {
235
+ method: 'POST',
236
+ body: { path: storagePath },
237
+ })
238
+ return url
239
+ }
240
+
241
+ /** The artifacts this user owns — documents, decks and images the agent made. */
242
+ async listArtifacts(query: { search?: string; limit?: number; cursor?: string } = {}): Promise<
243
+ Paginated<ChatArtifact>
244
+ > {
245
+ const envelope = await this.client.requestEnvelope<{
246
+ data: ChatArtifact[]
247
+ nextCursor?: string | null
248
+ }>('/v1/chat-artifacts', { query })
249
+ return { data: envelope.data, nextCursor: envelope.nextCursor ?? undefined }
250
+ }
251
+
252
+ getArtifact(id: string): Promise<ChatArtifact> {
253
+ return this.client.request(`/v1/chat-artifacts/${encodeURIComponent(id)}`)
254
+ }
255
+
256
+ /**
257
+ * The artifact's contents as TEXT.
258
+ *
259
+ * Only for artifacts that are text — markdown, csv, code. Reading a PNG or a
260
+ * spreadsheet through here destroys it: `.text()` decodes the bytes as UTF-8,
261
+ * and every byte that is not valid UTF-8 becomes a replacement character on
262
+ * the way. Use `getArtifactFile` for anything binary.
263
+ */
264
+ async getArtifactContent(id: string): Promise<string> {
265
+ const response = await this.client.requestRaw(
266
+ `/v1/chat-artifacts/${encodeURIComponent(id)}/file`,
267
+ )
268
+ return response.text()
269
+ }
270
+
271
+ /**
272
+ * The artifact's raw bytes, with the server's own content type.
273
+ *
274
+ * The Blob carries the type the route reported rather than one guessed from
275
+ * the format, so an <img> or a PDF reader gets what it needs without the
276
+ * caller restating it — and a wrong guess here is an image that silently
277
+ * will not display.
278
+ */
279
+ async getArtifactFile(id: string): Promise<Blob> {
280
+ const response = await this.client.requestRaw(
281
+ `/v1/chat-artifacts/${encodeURIComponent(id)}/file`,
282
+ )
283
+ return response.blob()
284
+ }
285
+
286
+ /**
287
+ * A PDF rendering of an artifact a browser cannot open itself.
288
+ *
289
+ * `.docx` and `.pptx` are bytes with no source to fall back on, so the
290
+ * service renders a PDF beside them and this returns it — displaying one
291
+ * without shipping an OOXML reader.
292
+ *
293
+ * Only artifacts with `previewKey` set have one; anything else answers 404
294
+ * as `NOT_FOUND`. Check the field before asking, or catch it.
295
+ */
296
+ /**
297
+ * Save an edit made in the viewer — a sheet cell, a document's text.
298
+ *
299
+ * Produces a new REVISION rather than overwriting: the service re-renders to
300
+ * a versioned storage key and bumps the version, so the artifact keeps the
301
+ * history an edit implies.
302
+ */
303
+ async updateArtifact(
304
+ id: string,
305
+ patch: { content: string; title?: string },
306
+ ): Promise<ChatArtifact> {
307
+ return this.client.request<ChatArtifact>(
308
+ `/v1/chat-artifacts/${encodeURIComponent(id)}`,
309
+ { method: 'PATCH', body: patch },
310
+ )
311
+ }
312
+
313
+ /** One revision's content — what the inline "viewing v1" panel shows. */
314
+ async getArtifactVersion(id: string, version: number): Promise<ChatArtifact> {
315
+ return this.client.request<ChatArtifact>(
316
+ `/v1/chat-artifacts/${encodeURIComponent(id)}/versions/${encodeURIComponent(String(version))}`,
317
+ )
318
+ }
319
+
320
+ /**
321
+ * Roll back to a revision.
322
+ *
323
+ * Writes a new revision holding the old content — nothing is deleted, so a
324
+ * restore made in error is itself undoable.
325
+ */
326
+ async restoreArtifactVersion(id: string, version: number): Promise<ChatArtifact> {
327
+ return this.client.request<ChatArtifact>(
328
+ `/v1/chat-artifacts/${encodeURIComponent(id)}/versions/${encodeURIComponent(String(version))}/restore`,
329
+ { method: 'POST' },
330
+ )
331
+ }
332
+
333
+ /** Revisions of one artifact, newest first. Metadata only. */
334
+ async listArtifactVersions(id: string): Promise<ArtifactVersionMeta[]> {
335
+ return this.client.request<ArtifactVersionMeta[]>(
336
+ `/v1/chat-artifacts/${encodeURIComponent(id)}/versions`,
337
+ )
338
+ }
339
+
340
+ /**
341
+ * The rendition of a PAST version, for the viewer's version picker.
342
+ *
343
+ * Separate from `getArtifactPreview` rather than an optional argument: the
344
+ * current preview lives at a different path, and a version of `0` or `NaN`
345
+ * silently falling back to "current" is how a reader ends up looking at
346
+ * today's document under yesterday's heading.
347
+ */
348
+ async getArtifactVersionPreview(id: string, version: number): Promise<Blob> {
349
+ const response = await this.client.requestRaw(
350
+ `/v1/chat-artifacts/${encodeURIComponent(id)}/versions/${encodeURIComponent(String(version))}/preview`,
351
+ )
352
+ return response.blob()
353
+ }
354
+
355
+ async getArtifactPreview(id: string): Promise<Blob> {
356
+ const response = await this.client.requestRaw(
357
+ `/v1/chat-artifacts/${encodeURIComponent(id)}/preview`,
358
+ )
359
+ return response.blob()
360
+ }
361
+
362
+ // --- sessions ---
363
+
364
+ async listSessions(query: ListSessionsQuery = {}): Promise<Paginated<Session>> {
365
+ // Envelope-preserving request: `nextCursor` sits BESIDE `data` in the
366
+ // response envelope and the default unwrap would drop it.
367
+ const envelope = await this.client.requestEnvelope<{
368
+ data: Session[]
369
+ nextCursor?: string | null
370
+ }>('/v1/sessions', {
371
+ query: {
372
+ cursor: query.cursor,
373
+ limit: query.limit,
374
+ status: query.status,
375
+ label: query.label,
376
+ group: query.group,
377
+ search: query.search,
378
+ pinned: query.pinned,
379
+ source: query.source,
380
+ agentName: query.agentName,
381
+ agentId: query.agentId,
382
+ updatedAfter: query.updatedAfter,
383
+ updatedBefore: query.updatedBefore,
384
+ surface: query.surface,
385
+ },
386
+ })
387
+ return { data: envelope.data, nextCursor: envelope.nextCursor ?? undefined }
388
+ }
389
+
390
+ createSession(request: CreateSessionRequest = {}): Promise<Session> {
391
+ return this.client.request('/v1/sessions', { method: 'POST', body: request })
392
+ }
393
+
394
+ async getSession(id: string): Promise<Session> {
395
+ await this.authorize(id)
396
+ return this.client.request(`/v1/sessions/${encodeURIComponent(id)}`)
397
+ }
398
+
399
+ async getMessages(id: string, options: { limit?: number } = {}): Promise<SessionMessage[]> {
400
+ await this.authorize(id)
401
+ return this.client.request(`/v1/sessions/${encodeURIComponent(id)}/messages`, {
402
+ query: { limit: options.limit },
403
+ })
404
+ }
405
+
406
+ // The four below act on ONE person's own session, so they need a credential
407
+ // that names one: a `cht_` end-user token works, an `sak_` key answers 403
408
+ // (`FORBIDDEN`) because it stands for every user of your product at once.
409
+ //
410
+ // `getShare`/`createShare` are deliberately absent: a share link works with
411
+ // no credential at all, and whether your end users may publish a
412
+ // conversation is your product's decision to implement, not a default they
413
+ // inherit from us. Those routes are platform-only and answer 403.
414
+
415
+ /**
416
+ * Rename, pin, or restatus a conversation.
417
+ *
418
+ * Answers `{ success: true }` rather than the updated row — the service
419
+ * confirms the write and nothing more. Typed as what actually arrives: a
420
+ * `Session` here would be a promise the server never made, and the first
421
+ * caller to read `.title` off it would get `undefined` with no explanation.
422
+ * Re-read with `getSession` when you need the new state.
423
+ */
424
+ async updateSession(id: string, patch: UpdateSessionRequest): Promise<{ success: boolean }> {
425
+ await this.authorize(id)
426
+ return this.client.request(`/v1/sessions/${encodeURIComponent(id)}`, {
427
+ method: 'PATCH',
428
+ body: patch,
429
+ })
430
+ }
431
+
432
+ async deleteSession(id: string): Promise<void> {
433
+ await this.authorize(id)
434
+ await this.client.request(`/v1/sessions/${encodeURIComponent(id)}`, { method: 'DELETE' })
435
+ }
436
+
437
+ async forkSession(id: string, messageId: string): Promise<Session> {
438
+ await this.authorize(id)
439
+ return this.client.request(`/v1/sessions/${encodeURIComponent(id)}/fork`, {
440
+ method: 'POST',
441
+ body: { messageId },
442
+ })
443
+ }
444
+
445
+ /** Returns the export as a raw string — this route has no JSON envelope. */
446
+ async exportSession(id: string, format: string): Promise<string> {
447
+ await this.authorize(id)
448
+ const response = await this.client.requestRaw(
449
+ `/v1/sessions/${encodeURIComponent(id)}/export`,
450
+ { query: { format } },
451
+ )
452
+ return response.text()
453
+ }
454
+
455
+ // --- aux ---
456
+
457
+ async getFollowUps(
458
+ conversationId: string,
459
+ messageId: string,
460
+ ): Promise<{ suggestions: unknown[] }> {
461
+ await this.authorize(conversationId)
462
+ return this.client.request('/v1/follow-ups', { query: { conversationId, messageId } })
463
+ }
464
+
465
+ async getMessageMetadata(
466
+ conversationId: string,
467
+ messageId: string,
468
+ ): Promise<{ metadata: unknown }> {
469
+ await this.authorize(conversationId)
470
+ return this.client.request('/v1/message-metadata', { query: { conversationId, messageId } })
471
+ }
472
+ }