@agentforge-io/chat-sdk 2.0.19 → 2.0.21

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.
@@ -19,25 +19,39 @@ export type ChatRole = 'user' | 'assistant' | 'system';
19
19
  * Future kinds extend this union without breaking back-compat: the
20
20
  * View checks `kind` and falls back to plain text when it's unknown.
21
21
  */
22
+ export interface ChatApprovalCopy {
23
+ title: string;
24
+ body: string;
25
+ approveLabel: string;
26
+ approveBusyLabel: string;
27
+ denyLabel: string;
28
+ denyBusyLabel: string;
29
+ approvedPill: string;
30
+ deniedPill: string;
31
+ readOnlyHint: string;
32
+ blockedTitle: string;
33
+ blockedBody: string;
34
+ expiresPrefix: string;
35
+ }
22
36
  export type ChatMessageMetadata = {
23
37
  kind: 'awaiting_approval';
24
38
  approvalId: string;
25
39
  toolName: string;
26
40
  expiresAt: string;
27
- /** Friendly connector display name surfaced by the server when
28
- * available (e.g. `'Granola'`). Lets the View render a
29
- * natural-language card without the host having to map slugs
30
- * to product names. */
31
41
  connectorName?: string;
32
- /** First line of the tool's description from the connector
33
- * definition. Used as a one-line "what will happen" hint. */
34
42
  toolDescription?: string;
43
+ /** Server-authored microcopy for the approval bubble. Generated
44
+ * on every pause by an AI copywriter on the backend so language
45
+ * and tone match the live conversation. View layers render it
46
+ * verbatim; when missing, fall back to a minimal meta render. */
47
+ copy?: ChatApprovalCopy;
35
48
  } | {
36
49
  kind: 'tool_blocked';
37
50
  toolName: string;
38
51
  reason?: string;
39
52
  connectorName?: string;
40
53
  toolDescription?: string;
54
+ copy?: ChatApprovalCopy;
41
55
  } | {
42
56
  kind: string;
43
57
  [k: string]: unknown;
package/dist/react.d.ts CHANGED
@@ -18,57 +18,45 @@
18
18
  import { type CSSProperties } from 'react';
19
19
  import type { ChatTheme } from './entities';
20
20
  /**
21
- * Optional callback invoked when the visitor clicks the Approve/Deny
22
- * button on a tool-approval bubble. When omitted, the bubble renders
23
- * as a read-only "waiting" hint the visitor can't decide from inside
24
- * the widget. When wired, the widget calls this with `{approvalId,
25
- * action}` and the host implementation typically POSTs to
26
- * `/public/chat/approvals/:id/{approve|deny}` with the visitor's
27
- * `browserSessionId` for ownership verification.
28
- *
29
- * Throwing or rejecting from the handler keeps the bubble in its
30
- * pending state and surfaces the error inline so the visitor can
31
- * retry.
21
+ * Optional observer fired after the SDK resolves a tool-approval bubble.
22
+ * The SDK owns the network call (it already has `apiBaseUrl` and the
23
+ * `browserSessionId`); the host only gets notified for analytics or
24
+ * audit logging. The hook is fire-and-forget throwing from it does
25
+ * not roll back the decision the server already committed.
32
26
  */
33
- export type ApprovalActionHandler = (args: {
27
+ export type ApprovalDecisionObserver = (args: {
34
28
  approvalId: string;
35
29
  action: 'approve' | 'deny';
36
- }) => Promise<void>;
30
+ ok: boolean;
31
+ error?: string;
32
+ }) => void;
37
33
  /**
38
34
  * Localizable copy for the approval / blocked bubbles. Pass via the
39
35
  * `approvalCopy` prop on `<ChatWidget>` to drop the defaults (Spanish,
40
- * conversational) and tailor every line to your audience.
36
+ * Server-authored approval microcopy. The bubble renders whatever the
37
+ * server places in `metadata.copy` — title, body, button labels, etc.
38
+ * That copy is generated by the AgentForge backend on every pause via a
39
+ * dedicated Haiku call, so its language and tone always match the
40
+ * conversation the user is having. There is no client-side dictionary
41
+ * to keep in sync.
41
42
  *
42
- * Functions receive whatever context the server enriched the chunk
43
- * with — typically a friendly connector name like `'Granola'`. When
44
- * the server didn't supply it, the function is called with `undefined`
45
- * and the implementation should pick a sensible generic line. The
46
- * widget itself never references the raw tool slug in copy.
43
+ * The shape mirrors `ApprovalCopyBundle` in `@agentforge-io/core`.
47
44
  */
48
45
  export interface ApprovalCopy {
49
46
  title: string;
50
- body: (ctx: {
51
- connectorName?: string;
52
- toolDescription?: string;
53
- }) => string;
47
+ body: string;
54
48
  approveLabel: string;
55
49
  approveBusyLabel: string;
56
50
  denyLabel: string;
57
51
  denyBusyLabel: string;
58
52
  approvedPill: string;
59
53
  deniedPill: string;
60
- /** Shown when the host didn't wire `onApprovalAction` — the visitor
61
- * can't decide from inside the widget so we tell them where to go. */
62
54
  readOnlyHint: string;
63
- /** Used by the `expires in …` countdown. Receives minutes/seconds
64
- * and returns the formatted string. */
65
- expiresIn: (mins: number, secs: number) => string;
66
- /** Block bubble title + body. */
67
55
  blockedTitle: string;
68
- blockedBody: (ctx: {
69
- connectorName?: string;
70
- reason?: string;
71
- }) => string;
56
+ blockedBody: string;
57
+ /** Prefix shown before the countdown, e.g. "Expires in". The seconds
58
+ * are appended client-side because they tick locally. */
59
+ expiresPrefix: string;
72
60
  }
73
61
  export interface ChatWidgetProps {
74
62
  /** Public chat token (`aft_*`) issued from the admin UI. */
@@ -92,17 +80,19 @@ export interface ChatWidgetProps {
92
80
  /** Inline style on the root container. */
93
81
  style?: CSSProperties;
94
82
  /**
95
- * Handler for tool-approval bubble actions. See `ApprovalActionHandler`
96
- * for the contract. Omit to render approval messages as read-only
97
- * hints.
83
+ * Optional observer called after the SDK resolves an approval bubble.
84
+ * The SDK handles the network call internally pass this only if you
85
+ * want to record the decision for analytics. See
86
+ * `ApprovalDecisionObserver`.
98
87
  */
99
- onApprovalAction?: ApprovalActionHandler;
88
+ onApprovalDecision?: ApprovalDecisionObserver;
100
89
  /**
101
- * Override any subset of the approval-card copy. Keys you don't
102
- * provide fall back to the Spanish defaults in `DEFAULT_APPROVAL_COPY`.
103
- * Use this for translations or to match your product's voice.
90
+ * When `false` (the default), the Approve/Deny buttons render on
91
+ * `awaiting_approval` bubbles. Set to `true` to render bubbles as
92
+ * read-only useful for transcripts/embeds where the visitor cannot
93
+ * legitimately decide.
104
94
  */
105
- approvalCopy?: Partial<ApprovalCopy>;
95
+ readOnlyApprovals?: boolean;
106
96
  }
107
97
  /**
108
98
  * Drop-in chat widget. Owns its own `ChatSession` and re-renders on every
package/dist/react.js CHANGED
@@ -154,37 +154,34 @@ function renderMarkdown(src) {
154
154
  return out.join('');
155
155
  }
156
156
  /**
157
- * Defaults are Spanish + conversational. The host overrides individual
158
- * keys via the `approvalCopy` prop; unset keys keep these values.
157
+ * Minimal fallback used when the server didn't supply `metadata.copy`
158
+ * (older backend, copywriter timeout, missing API key, etc.). Intentionally
159
+ * terse and meta-only — the goal is to avoid lying about language. The
160
+ * server is the source of truth for natural copy.
159
161
  */
160
- const DEFAULT_APPROVAL_COPY = {
161
- title: 'Necesito tu permiso',
162
- body: ({ connectorName }) => connectorName
163
- ? `¿Me das permiso para usar ${connectorName}?`
164
- : '¿Me das permiso para continuar?',
165
- approveLabel: 'Permitir',
166
- approveBusyLabel: 'Permitiendo…',
167
- denyLabel: 'No, gracias',
168
- denyBusyLabel: 'Cancelando…',
169
- approvedPill: 'Listo, continuando…',
170
- deniedPill: 'Cancelado',
171
- readOnlyHint: 'Pídele al administrador del workspace que lo apruebe.',
172
- expiresIn: (m, s) => m >= 60
173
- ? `Expira en ${Math.floor(m / 60)}h ${m % 60}m`
174
- : `Expira en ${m}m ${s.toString().padStart(2, '0')}s`,
175
- blockedTitle: 'No disponible',
176
- blockedBody: ({ connectorName, reason }) => reason ??
177
- (connectorName
178
- ? `No puedo usar ${connectorName} en esta cuenta.`
179
- : 'No puedo usar esa herramienta en esta cuenta.'),
180
- };
162
+ function fallbackCopy(ctx) {
163
+ const target = ctx.connectorName ?? ctx.toolName ?? '';
164
+ return {
165
+ title: target || 'Permission required',
166
+ body: target ? `${target} → ✓ / ✗` : 'Permission required',
167
+ approveLabel: '',
168
+ approveBusyLabel: '…',
169
+ denyLabel: '',
170
+ denyBusyLabel: '…',
171
+ approvedPill: '',
172
+ deniedPill: '',
173
+ readOnlyHint: '',
174
+ blockedTitle: target || 'Blocked',
175
+ blockedBody: ctx.reason ?? (target ? `${target} blocked` : 'Blocked'),
176
+ expiresPrefix: '',
177
+ };
178
+ }
181
179
  /**
182
180
  * Drop-in chat widget. Owns its own `ChatSession` and re-renders on every
183
181
  * SDK event. Consumers don't need to read `session.getState()` themselves.
184
182
  */
185
183
  function ChatWidget(props) {
186
- const { token, apiBaseUrl, inline = false, position, browserSessionId, resumeConversationId, stream, className, style, onApprovalAction, approvalCopy, } = props;
187
- const copy = { ...DEFAULT_APPROVAL_COPY, ...(approvalCopy ?? {}) };
184
+ const { token, apiBaseUrl, inline = false, position, browserSessionId, resumeConversationId, stream, className, style, onApprovalDecision, readOnlyApprovals = false, } = props;
188
185
  const [session, setSession] = (0, react_1.useState)(null);
189
186
  const [status, setStatus] = (0, react_1.useState)('idle');
190
187
  const [agent, setAgent] = (0, react_1.useState)();
@@ -304,7 +301,7 @@ function ChatWidget(props) {
304
301
  };
305
302
  return ((0, jsx_runtime_1.jsxs)("div", { className: rootClass, style: rootStyle, "data-style-id": styleId, children: [!inline && ((0, jsx_runtime_1.jsx)("button", { type: "button", className: "af-toggle", "aria-label": open ? 'Close chat' : 'Open chat', "aria-expanded": open, onClick: () => setOpen((v) => !v), children: open ? (0, jsx_runtime_1.jsx)(CloseIcon, {}) : (0, jsx_runtime_1.jsx)(ChatIcon, {}) })), (0, jsx_runtime_1.jsxs)("div", { className: `af-panel ${open ? 'af-open' : ''}`, children: [(0, jsx_runtime_1.jsxs)("div", { className: "af-header", children: [theme?.avatarUrl ? (
306
303
  // eslint-disable-next-line @next/next/no-img-element
307
- (0, jsx_runtime_1.jsx)("img", { className: "af-header-avatar", src: theme.avatarUrl, alt: "" })) : null, (0, jsx_runtime_1.jsxs)("div", { className: "af-header-info", children: [(0, jsx_runtime_1.jsx)("div", { className: "af-header-title", children: theme?.title ?? agent?.name ?? 'Chat' }), (0, jsx_runtime_1.jsx)("div", { className: "af-header-subtitle", children: status === 'loading' ? 'Loading…' : agent?.description ?? '' })] }), !inline && ((0, jsx_runtime_1.jsx)("button", { type: "button", className: "af-close", onClick: () => setOpen(false), "aria-label": "Close chat", children: (0, jsx_runtime_1.jsx)(CloseIcon, {}) }))] }), (0, jsx_runtime_1.jsx)("div", { className: "af-messages", ref: messagesRef, children: messages.map((m) => ((0, jsx_runtime_1.jsx)(MessageBubble, { message: m, copy: copy, onApprovalAction: onApprovalAction, onContinue: () => {
304
+ (0, jsx_runtime_1.jsx)("img", { className: "af-header-avatar", src: theme.avatarUrl, alt: "" })) : null, (0, jsx_runtime_1.jsxs)("div", { className: "af-header-info", children: [(0, jsx_runtime_1.jsx)("div", { className: "af-header-title", children: theme?.title ?? agent?.name ?? 'Chat' }), (0, jsx_runtime_1.jsx)("div", { className: "af-header-subtitle", children: status === 'loading' ? 'Loading…' : agent?.description ?? '' })] }), !inline && ((0, jsx_runtime_1.jsx)("button", { type: "button", className: "af-close", onClick: () => setOpen(false), "aria-label": "Close chat", children: (0, jsx_runtime_1.jsx)(CloseIcon, {}) }))] }), (0, jsx_runtime_1.jsx)("div", { className: "af-messages", ref: messagesRef, children: messages.map((m) => ((0, jsx_runtime_1.jsx)(MessageBubble, { message: m, session: session, readOnly: readOnlyApprovals, onDecision: onApprovalDecision, onContinue: () => {
308
305
  // After a successful Approve, kick the next turn so the
309
306
  // gate's fast-path consumes the approval and the tool
310
307
  // actually runs. `silent: true` keeps the literal
@@ -318,13 +315,13 @@ function ChatWidget(props) {
318
315
  }, 250);
319
316
  } }, m.id))) }), lastError && status === 'error' && ((0, jsx_runtime_1.jsx)("div", { className: "af-error", children: lastError })), (0, jsx_runtime_1.jsxs)("div", { className: "af-input-row", children: [(0, jsx_runtime_1.jsx)("textarea", { className: "af-input", value: draft, onChange: (e) => setDraft(e.target.value), onKeyDown: onKeyDown, placeholder: "Type a message\u2026", rows: 1, disabled: status === 'ended' || status === 'loading' || status === 'idle' }), (0, jsx_runtime_1.jsx)("button", { type: "button", className: "af-send", onClick: handleSend, disabled: sendDisabled, "aria-label": "Send message", children: (0, jsx_runtime_1.jsx)(SendIcon, {}) })] }), (0, jsx_runtime_1.jsx)("div", { className: "af-footer", children: "Powered by AgentForge" })] })] }));
320
317
  }
321
- function MessageBubble({ message, copy, onApprovalAction, onContinue, }) {
318
+ function MessageBubble({ message, session, readOnly, onDecision, onContinue, }) {
322
319
  const kind = message.metadata?.kind;
323
320
  if (kind === 'awaiting_approval') {
324
- return ((0, jsx_runtime_1.jsx)(ApprovalBubble, { message: message, copy: copy, onAction: onApprovalAction, onContinue: onContinue }));
321
+ return ((0, jsx_runtime_1.jsx)(ApprovalBubble, { message: message, session: session, readOnly: readOnly, onDecision: onDecision, onContinue: onContinue }));
325
322
  }
326
323
  if (kind === 'tool_blocked') {
327
- return (0, jsx_runtime_1.jsx)(BlockedBubble, { message: message, copy: copy });
324
+ return (0, jsx_runtime_1.jsx)(BlockedBubble, { message: message });
328
325
  }
329
326
  const cls = `af-msg af-msg-${message.role}${message.role === 'assistant' && message.isStreaming
330
327
  ? message.content
@@ -351,54 +348,60 @@ function MessageBubble({ message, copy, onApprovalAction, onContinue, }) {
351
348
  * so the chat auto-sends "continue" and the gate's fast-path consumes
352
349
  * the row on the next turn.
353
350
  */
354
- function ApprovalBubble({ message, copy, onAction, onContinue, }) {
351
+ function ApprovalBubble({ message, session, readOnly, onDecision, onContinue, }) {
355
352
  const meta = message.metadata;
356
353
  const [busy, setBusy] = (0, react_1.useState)(null);
357
354
  const [decided, setDecided] = (0, react_1.useState)(null);
358
355
  const [err, setErr] = (0, react_1.useState)(null);
359
- const remaining = useExpiresIn(meta?.expiresAt, copy.expiresIn);
356
+ const copy = meta?.copy ?? fallbackCopy({
357
+ connectorName: meta?.connectorName,
358
+ toolName: meta?.toolName,
359
+ });
360
+ const remaining = useExpiresIn(meta?.expiresAt, copy.expiresPrefix);
360
361
  if (!meta)
361
362
  return null;
362
363
  const act = async (action) => {
363
- if (!onAction)
364
+ if (!session)
364
365
  return;
365
366
  setBusy(action);
366
367
  setErr(null);
367
368
  try {
368
- await onAction({ approvalId: meta.approvalId, action });
369
+ await session.resolveApproval(meta.approvalId, action);
369
370
  setDecided(action === 'approve' ? 'approved' : 'denied');
371
+ onDecision?.({ approvalId: meta.approvalId, action, ok: true });
370
372
  if (action === 'approve')
371
373
  onContinue();
372
374
  }
373
375
  catch (e) {
374
- setErr(e instanceof Error ? e.message : String(e));
376
+ const message = e instanceof Error ? e.message : String(e);
377
+ setErr(message);
378
+ onDecision?.({ approvalId: meta.approvalId, action, ok: false, error: message });
375
379
  }
376
380
  finally {
377
381
  setBusy(null);
378
382
  }
379
383
  };
380
- return ((0, jsx_runtime_1.jsxs)("div", { className: "af-msg af-msg-approval", children: [(0, jsx_runtime_1.jsx)("div", { className: "af-approval-title", children: copy.title }), (0, jsx_runtime_1.jsx)("div", { className: "af-approval-body", children: copy.body({
381
- connectorName: meta.connectorName,
382
- toolDescription: meta.toolDescription,
383
- }) }), remaining && !decided && ((0, jsx_runtime_1.jsx)("div", { className: "af-approval-meta", children: remaining })), decided && ((0, jsx_runtime_1.jsx)("div", { className: `af-approval-pill af-approval-pill-${decided}`, children: decided === 'approved' ? copy.approvedPill : copy.deniedPill })), !decided && onAction && ((0, jsx_runtime_1.jsxs)("div", { className: "af-approval-actions", children: [(0, jsx_runtime_1.jsx)("button", { type: "button", className: "af-approval-btn af-approval-approve", disabled: busy !== null, onClick: () => act('approve'), children: busy === 'approve' ? copy.approveBusyLabel : copy.approveLabel }), (0, jsx_runtime_1.jsx)("button", { type: "button", className: "af-approval-btn af-approval-deny", disabled: busy !== null, onClick: () => act('deny'), children: busy === 'deny' ? copy.denyBusyLabel : copy.denyLabel })] })), !decided && !onAction && ((0, jsx_runtime_1.jsx)("div", { className: "af-approval-hint", children: copy.readOnlyHint })), err && (0, jsx_runtime_1.jsx)("div", { className: "af-approval-error", children: err })] }));
384
+ return ((0, jsx_runtime_1.jsxs)("div", { className: "af-msg af-msg-approval", children: [(0, jsx_runtime_1.jsx)("div", { className: "af-approval-title", children: copy.title }), (0, jsx_runtime_1.jsx)("div", { className: "af-approval-body", children: copy.body }), remaining && !decided && ((0, jsx_runtime_1.jsx)("div", { className: "af-approval-meta", children: remaining })), decided && ((0, jsx_runtime_1.jsx)("div", { className: `af-approval-pill af-approval-pill-${decided}`, children: decided === 'approved' ? copy.approvedPill : copy.deniedPill })), !decided && !readOnly && session && ((0, jsx_runtime_1.jsxs)("div", { className: "af-approval-actions", children: [(0, jsx_runtime_1.jsx)("button", { type: "button", className: "af-approval-btn af-approval-approve", disabled: busy !== null, onClick: () => act('approve'), children: busy === 'approve' ? copy.approveBusyLabel : copy.approveLabel }), (0, jsx_runtime_1.jsx)("button", { type: "button", className: "af-approval-btn af-approval-deny", disabled: busy !== null, onClick: () => act('deny'), children: busy === 'deny' ? copy.denyBusyLabel : copy.denyLabel })] })), !decided && readOnly && ((0, jsx_runtime_1.jsx)("div", { className: "af-approval-hint", children: copy.readOnlyHint })), err && (0, jsx_runtime_1.jsx)("div", { className: "af-approval-error", children: err })] }));
384
385
  }
385
386
  /**
386
387
  * Terminal "tool blocked" bubble. No action — just informs the visitor
387
388
  * that the tool the agent tried isn't available.
388
389
  */
389
- function BlockedBubble({ message, copy, }) {
390
+ function BlockedBubble({ message, }) {
390
391
  const meta = message.metadata;
391
392
  if (!meta)
392
393
  return null;
393
- return ((0, jsx_runtime_1.jsxs)("div", { className: "af-msg af-msg-blocked", children: [(0, jsx_runtime_1.jsx)("div", { className: "af-blocked-title", children: copy.blockedTitle }), (0, jsx_runtime_1.jsx)("div", { className: "af-blocked-body", children: copy.blockedBody({
394
- connectorName: meta.connectorName,
395
- reason: meta.reason,
396
- }) })] }));
394
+ const copy = meta.copy ?? fallbackCopy({
395
+ connectorName: meta.connectorName,
396
+ toolName: meta.toolName,
397
+ reason: meta.reason,
398
+ });
399
+ return ((0, jsx_runtime_1.jsxs)("div", { className: "af-msg af-msg-blocked", children: [(0, jsx_runtime_1.jsx)("div", { className: "af-blocked-title", children: copy.blockedTitle }), (0, jsx_runtime_1.jsx)("div", { className: "af-blocked-body", children: copy.blockedBody })] }));
397
400
  }
398
401
  /** Live "expires in" countdown. Returns null after the deadline.
399
- * The format function is host-supplied so the label stays localisable —
400
- * the hook itself doesn't hard-code any user-facing string. */
401
- function useExpiresIn(iso, format) {
402
+ * The prefix label is server-authored so the language matches the
403
+ * rest of the bubble; the hook just appends the localised digits. */
404
+ function useExpiresIn(iso, prefix) {
402
405
  const [, force] = (0, react_1.useState)(0);
403
406
  (0, react_1.useEffect)(() => {
404
407
  if (!iso)
@@ -414,7 +417,10 @@ function useExpiresIn(iso, format) {
414
417
  const totalSeconds = Math.floor(ms / 1000);
415
418
  const m = Math.floor(totalSeconds / 60);
416
419
  const s = totalSeconds % 60;
417
- return format(m, s);
420
+ const tail = m >= 60
421
+ ? `${Math.floor(m / 60)}h ${m % 60}m`
422
+ : `${m}m ${s.toString().padStart(2, '0')}s`;
423
+ return `${prefix} ${tail}`.trim();
418
424
  }
419
425
  function ChatIcon() {
420
426
  return ((0, jsx_runtime_1.jsx)("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", "aria-hidden": true, children: (0, jsx_runtime_1.jsx)("path", { d: "M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z" }) }));
package/dist/session.d.ts CHANGED
@@ -36,6 +36,13 @@ export declare class ChatSession {
36
36
  * continue chatting.
37
37
  */
38
38
  end(): Promise<void>;
39
+ /**
40
+ * Resolve a pending tool-approval bubble. The SDK owns the transport
41
+ * call so hosts don't have to know the approval endpoint or assemble
42
+ * the `browserSessionId` payload. Errors propagate so the calling View
43
+ * layer can keep the bubble in pending state and show the message.
44
+ */
45
+ resolveApproval(approvalId: string, action: 'approve' | 'deny'): Promise<void>;
39
46
  /** Tear down. Future sends throw. Useful for SPA unmount. */
40
47
  destroy(): void;
41
48
  /**
package/dist/session.js CHANGED
@@ -129,6 +129,15 @@ class ChatSession {
129
129
  this.handleError(err);
130
130
  }
131
131
  }
132
+ /**
133
+ * Resolve a pending tool-approval bubble. The SDK owns the transport
134
+ * call so hosts don't have to know the approval endpoint or assemble
135
+ * the `browserSessionId` payload. Errors propagate so the calling View
136
+ * layer can keep the bubble in pending state and show the message.
137
+ */
138
+ async resolveApproval(approvalId, action) {
139
+ await this.transport.resolveApproval(approvalId, action, this.browserSessionId);
140
+ }
132
141
  /** Tear down. Future sends throw. Useful for SPA unmount. */
133
142
  destroy() {
134
143
  this.listeners.clear();
@@ -214,12 +223,12 @@ class ChatSession {
214
223
  expiresAt: c.expiresAt,
215
224
  connectorName: c.connectorName,
216
225
  toolDescription: c.toolDescription,
226
+ copy: c.copy,
217
227
  };
218
- assistant.content =
219
- full ||
220
- (c.connectorName
221
- ? `Necesito tu permiso para usar ${c.connectorName}.`
222
- : `Necesito tu permiso para continuar.`);
228
+ // Plain-text fallback for legacy clients that don't render
229
+ // metadata.kind. Capable widgets ignore `content` and read
230
+ // `metadata.copy` directly.
231
+ assistant.content = full || c.copy?.body || (c.connectorName ?? '');
223
232
  this.updateMessage(assistant);
224
233
  continue;
225
234
  }
@@ -231,13 +240,10 @@ class ChatSession {
231
240
  reason: c.reason,
232
241
  connectorName: c.connectorName,
233
242
  toolDescription: c.toolDescription,
243
+ copy: c.copy,
234
244
  };
235
245
  assistant.content =
236
- full ||
237
- c.reason ||
238
- (c.connectorName
239
- ? `No puedo usar ${c.connectorName} en esta cuenta.`
240
- : `No puedo usar esa herramienta en esta cuenta.`);
246
+ full || c.copy?.blockedBody || c.reason || (c.connectorName ?? '');
241
247
  this.updateMessage(assistant);
242
248
  continue;
243
249
  }
@@ -83,6 +83,15 @@ export declare class HttpTransport {
83
83
  metadata?: Record<string, unknown>;
84
84
  }>;
85
85
  } | null>;
86
+ /**
87
+ * Resolve a pending tool-approval bubble. The server verifies the row
88
+ * is owned by `browser:<browserSessionId>` before mutating it, so the
89
+ * SDK forwards the same browser session it uses for messaging. The
90
+ * route lives under `/public/chat/approvals/*` — siblings of this
91
+ * transport's `/public/chat/:token/*` routes — and does not need the
92
+ * widget token (the approvalId is the capability).
93
+ */
94
+ resolveApproval(approvalId: string, action: 'approve' | 'deny', browserSessionId: string): Promise<void>;
86
95
  endConversation(conversationId: string, browserSessionId: string): Promise<void>;
87
96
  /** Parse the server's SSE response body into typed events. */
88
97
  private readSse;
package/dist/transport.js CHANGED
@@ -111,6 +111,26 @@ class HttpTransport {
111
111
  })),
112
112
  };
113
113
  }
114
+ /**
115
+ * Resolve a pending tool-approval bubble. The server verifies the row
116
+ * is owned by `browser:<browserSessionId>` before mutating it, so the
117
+ * SDK forwards the same browser session it uses for messaging. The
118
+ * route lives under `/public/chat/approvals/*` — siblings of this
119
+ * transport's `/public/chat/:token/*` routes — and does not need the
120
+ * widget token (the approvalId is the capability).
121
+ */
122
+ async resolveApproval(approvalId, action, browserSessionId) {
123
+ const base = this.apiBaseUrl.replace(/\/$/, '');
124
+ const res = await fetch(`${base}/public/chat/approvals/${encodeURIComponent(approvalId)}/${action}`, {
125
+ method: 'POST',
126
+ headers: { 'Content-Type': 'application/json' },
127
+ body: JSON.stringify({ browserSessionId }),
128
+ });
129
+ if (!res.ok && res.status !== 204) {
130
+ const data = await safeJson(res);
131
+ throw makeTransportError(data?.message ?? `HTTP ${res.status}`, res.status, data?.error);
132
+ }
133
+ }
114
134
  async endConversation(conversationId, browserSessionId) {
115
135
  const res = await fetch(this.url(`/conversations/${encodeURIComponent(conversationId)}/end`), {
116
136
  method: 'POST',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agentforge-io/chat-sdk",
3
- "version": "2.0.19",
3
+ "version": "2.0.21",
4
4
  "description": "Framework-free chat session SDK for AgentForge public chat tokens. Headless — no DOM. Drop into any frontend (React, Vue, Svelte, vanilla) and listen for events.",
5
5
  "license": "MIT",
6
6
  "main": "dist/index.js",