@sales-bot-llm/sdk 0.2.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 (54) hide show
  1. package/biome.json +36 -0
  2. package/docs/superpowers/plans/2026-05-08-sales-bot-sdk-plan.md +258 -0
  3. package/docs/superpowers/plans/2026-05-11-w3-sales-tool-polish-plan.md +476 -0
  4. package/docs/superpowers/specs/2026-05-08-sales-bot-sdk-design.md +587 -0
  5. package/example/.env.example +5 -0
  6. package/example/README.md +90 -0
  7. package/example/index.html +12 -0
  8. package/example/package.json +27 -0
  9. package/example/public/vanilla.global.js +345 -0
  10. package/example/src/App.tsx +50 -0
  11. package/example/src/main.tsx +16 -0
  12. package/example/src/routes/HookDemo.tsx +174 -0
  13. package/example/src/routes/VanillaDemo.tsx +67 -0
  14. package/example/src/routes/WidgetDemo.tsx +55 -0
  15. package/example/src/styles.css +18 -0
  16. package/example/tsconfig.json +19 -0
  17. package/example/tsconfig.tsbuildinfo +1 -0
  18. package/example/vite.config.ts +4 -0
  19. package/package.json +106 -0
  20. package/pnpm-workspace.yaml +3 -0
  21. package/src/core/client.ts +245 -0
  22. package/src/core/conversation.ts +34 -0
  23. package/src/core/index.ts +6 -0
  24. package/src/core/sse-parser.ts +87 -0
  25. package/src/core/storage.ts +72 -0
  26. package/src/core/transport.ts +271 -0
  27. package/src/core/types.ts +314 -0
  28. package/src/core/visitor.ts +21 -0
  29. package/src/react/index.ts +2 -0
  30. package/src/react/use-sales-bot.tsx +182 -0
  31. package/src/vanilla/index.ts +38 -0
  32. package/src/vue/index.ts +2 -0
  33. package/src/vue/use-sales-bot.ts +152 -0
  34. package/src/widget/index.ts +3 -0
  35. package/src/widget/markdown.ts +69 -0
  36. package/src/widget/styles.ts +350 -0
  37. package/src/widget/widget.ts +442 -0
  38. package/tests/contract/wire-format.test.ts +158 -0
  39. package/tests/core/client.test.ts +292 -0
  40. package/tests/core/conversation.test.ts +41 -0
  41. package/tests/core/sse-parser.test.ts +142 -0
  42. package/tests/core/storage.test.ts +78 -0
  43. package/tests/core/transport.test.ts +204 -0
  44. package/tests/core/visitor.test.ts +42 -0
  45. package/tests/react/use-sales-bot.test.tsx +188 -0
  46. package/tests/sales-tool-discriminator.test.ts +45 -0
  47. package/tests/setup.ts +3 -0
  48. package/tests/vanilla/vanilla.test.ts +37 -0
  49. package/tests/vue/use-sales-bot.test.ts +163 -0
  50. package/tests/widget/markdown.test.ts +113 -0
  51. package/tests/widget/widget.test.ts +388 -0
  52. package/tsconfig.json +28 -0
  53. package/tsup.config.ts +38 -0
  54. package/vitest.config.ts +26 -0
@@ -0,0 +1,72 @@
1
+ import type { StorageAdapter } from './types'
2
+
3
+ /**
4
+ * In-memory storage adapter — used as a fallback when localStorage is
5
+ * unavailable (SSR, Node, private browsing quota errors).
6
+ */
7
+ export class MemoryStorageAdapter implements StorageAdapter {
8
+ private readonly store = new Map<string, string>()
9
+
10
+ getItem(key: string): string | null {
11
+ return this.store.get(key) ?? null
12
+ }
13
+
14
+ setItem(key: string, value: string): void {
15
+ this.store.set(key, value)
16
+ }
17
+
18
+ removeItem(key: string): void {
19
+ this.store.delete(key)
20
+ }
21
+ }
22
+
23
+ /**
24
+ * localStorage-backed adapter.
25
+ * Throws at construction if window.localStorage is unavailable.
26
+ * Callers should catch and fall back to MemoryStorageAdapter.
27
+ */
28
+ export class LocalStorageAdapter implements StorageAdapter {
29
+ private readonly ls: Storage
30
+
31
+ constructor() {
32
+ if (typeof window === 'undefined' || !window.localStorage) {
33
+ throw new Error('localStorage is not available in this environment')
34
+ }
35
+ this.ls = window.localStorage
36
+ }
37
+
38
+ getItem(key: string): string | null {
39
+ return this.ls.getItem(key)
40
+ }
41
+
42
+ setItem(key: string, value: string): void {
43
+ this.ls.setItem(key, value)
44
+ }
45
+
46
+ removeItem(key: string): void {
47
+ this.ls.removeItem(key)
48
+ }
49
+ }
50
+
51
+ /**
52
+ * Creates the best available storage adapter.
53
+ * Prefers localStorage; falls back silently to MemoryStorageAdapter.
54
+ */
55
+ export function createDefaultStorage(): StorageAdapter {
56
+ try {
57
+ const adapter = new LocalStorageAdapter()
58
+ // Probe for quota errors in private browsing
59
+ const probe = '__sb_probe__'
60
+ adapter.setItem(probe, '1')
61
+ adapter.removeItem(probe)
62
+ return adapter
63
+ } catch {
64
+ if (typeof console !== 'undefined') {
65
+ console.warn(
66
+ '[SalesBot] localStorage unavailable — falling back to in-memory storage. ' +
67
+ 'Visitor token will not persist across page reloads.',
68
+ )
69
+ }
70
+ return new MemoryStorageAdapter()
71
+ }
72
+ }
@@ -0,0 +1,271 @@
1
+ import { SalesBotError } from './types'
2
+ import type { PostTurnInput, SalesBotErrorCode } from './types'
3
+
4
+ export interface TransportOptions {
5
+ embedKey: string
6
+ baseUrl: string
7
+ customHeaders?: Record<string, string>
8
+ }
9
+
10
+ /**
11
+ * POST /api/chat/turns — starts a new agent turn.
12
+ * Returns the raw SSE ReadableStream on success.
13
+ * Throws SalesBotError on HTTP errors or network failures.
14
+ */
15
+ export async function postTurn(
16
+ input: PostTurnInput,
17
+ opts: TransportOptions,
18
+ ): Promise<ReadableStream<Uint8Array>> {
19
+ const headers: Record<string, string> = {
20
+ Authorization: `Bearer ${opts.embedKey}`,
21
+ 'Content-Type': 'application/json',
22
+ Accept: 'text/event-stream',
23
+ 'Idempotency-Key': crypto.randomUUID(),
24
+ ...opts.customHeaders,
25
+ }
26
+
27
+ let response: Response
28
+ try {
29
+ response = await fetch(`${opts.baseUrl}/api/chat/turns`, {
30
+ method: 'POST',
31
+ headers,
32
+ body: JSON.stringify(input),
33
+ })
34
+ } catch (err) {
35
+ throw new SalesBotError({
36
+ code: 'network_error',
37
+ message: err instanceof Error ? err.message : 'Network request failed',
38
+ retryable: true,
39
+ })
40
+ }
41
+
42
+ if (!response.ok) {
43
+ await throwHttpError(response)
44
+ }
45
+
46
+ if (!response.body) {
47
+ throw new SalesBotError({ code: 'internal', message: 'Response body is null', retryable: false })
48
+ }
49
+
50
+ return response.body
51
+ }
52
+
53
+ /**
54
+ * Public bot config keyed by the embed key. Read once on widget mount;
55
+ * shape is what GET /api/chat/bots/me returns.
56
+ */
57
+ export interface PublicBotConfig {
58
+ name: string
59
+ greetingMessage: string | null
60
+ }
61
+
62
+ /**
63
+ * GET /api/chat/bots/me — returns the public-safe config (name, greeting)
64
+ * for the bot this embed key authenticates as. Used by the widget to render
65
+ * the welcome message on first open.
66
+ */
67
+ export async function getBotConfig(opts: TransportOptions): Promise<PublicBotConfig> {
68
+ const headers: Record<string, string> = {
69
+ Authorization: `Bearer ${opts.embedKey}`,
70
+ Accept: 'application/json',
71
+ ...opts.customHeaders,
72
+ }
73
+
74
+ let response: Response
75
+ try {
76
+ response = await fetch(`${opts.baseUrl}/api/chat/bots/me`, { method: 'GET', headers })
77
+ } catch (err) {
78
+ throw new SalesBotError({
79
+ code: 'network_error',
80
+ message: err instanceof Error ? err.message : 'Network request failed',
81
+ retryable: true,
82
+ })
83
+ }
84
+
85
+ if (!response.ok) {
86
+ await throwHttpError(response)
87
+ }
88
+
89
+ return (await response.json()) as PublicBotConfig
90
+ }
91
+
92
+ /**
93
+ * Persisted message as returned by GET /api/chat/conversations/:id/messages.
94
+ * Tool-call rows are filtered server-side — the SDK only ever sees user +
95
+ * assistant turns. createdAt is an ISO 8601 string.
96
+ */
97
+ export interface HistoryMessage {
98
+ id: string
99
+ role: 'user' | 'assistant'
100
+ content: string
101
+ createdAt: string
102
+ }
103
+
104
+ /**
105
+ * GET /api/chat/conversations/:id/messages — fetches the persisted transcript
106
+ * for a conversation scoped to (embedKey's bot, visitorToken's end-user).
107
+ * The backend silently returns { messages: [] } for unknown visitors or
108
+ * stale conversation ids so a fresh widget never errors on a missing
109
+ * client-side persisted id.
110
+ */
111
+ export async function getConversationHistory(
112
+ conversationId: string,
113
+ visitorToken: string,
114
+ opts: TransportOptions,
115
+ ): Promise<HistoryMessage[]> {
116
+ const headers: Record<string, string> = {
117
+ Authorization: `Bearer ${opts.embedKey}`,
118
+ Accept: 'application/json',
119
+ ...opts.customHeaders,
120
+ }
121
+ const url = `${opts.baseUrl}/api/chat/conversations/${encodeURIComponent(
122
+ conversationId,
123
+ )}/messages?visitorToken=${encodeURIComponent(visitorToken)}`
124
+
125
+ let response: Response
126
+ try {
127
+ response = await fetch(url, { method: 'GET', headers })
128
+ } catch (err) {
129
+ throw new SalesBotError({
130
+ code: 'network_error',
131
+ message: err instanceof Error ? err.message : 'Network request failed',
132
+ retryable: true,
133
+ })
134
+ }
135
+
136
+ if (!response.ok) {
137
+ await throwHttpError(response)
138
+ }
139
+
140
+ const body = (await response.json()) as { messages: HistoryMessage[] }
141
+ return body.messages ?? []
142
+ }
143
+
144
+ /**
145
+ * POST /api/chat/conversations/:id/end — mark the conversation as ended.
146
+ * Server soft-deletes the row; future history fetches return empty. The
147
+ * SDK clears its persisted conversationId after a successful call. The
148
+ * server is idempotent — already-ended conversations return ok:true.
149
+ */
150
+ export async function postEndConversation(
151
+ conversationId: string,
152
+ visitorToken: string,
153
+ opts: TransportOptions,
154
+ ): Promise<void> {
155
+ const headers: Record<string, string> = {
156
+ Authorization: `Bearer ${opts.embedKey}`,
157
+ 'Content-Type': 'application/json',
158
+ Accept: 'application/json',
159
+ ...opts.customHeaders,
160
+ }
161
+ let response: Response
162
+ try {
163
+ response = await fetch(
164
+ `${opts.baseUrl}/api/chat/conversations/${encodeURIComponent(conversationId)}/end`,
165
+ { method: 'POST', headers, body: JSON.stringify({ visitorToken }) },
166
+ )
167
+ } catch (err) {
168
+ throw new SalesBotError({
169
+ code: 'network_error',
170
+ message: err instanceof Error ? err.message : 'Network request failed',
171
+ retryable: true,
172
+ })
173
+ }
174
+ if (!response.ok) await throwHttpError(response)
175
+ }
176
+
177
+ /**
178
+ * GET /api/chat/turns/:turnId/stream — re-attaches to an in-flight turn.
179
+ * Returns the raw SSE ReadableStream on success.
180
+ */
181
+ export async function getResumeStream(
182
+ turnId: string,
183
+ opts: TransportOptions,
184
+ ): Promise<ReadableStream<Uint8Array>> {
185
+ const headers: Record<string, string> = {
186
+ Authorization: `Bearer ${opts.embedKey}`,
187
+ Accept: 'text/event-stream',
188
+ ...opts.customHeaders,
189
+ }
190
+
191
+ let response: Response
192
+ try {
193
+ response = await fetch(`${opts.baseUrl}/api/chat/turns/${turnId}/stream`, {
194
+ method: 'GET',
195
+ headers,
196
+ })
197
+ } catch (err) {
198
+ throw new SalesBotError({
199
+ code: 'network_error',
200
+ message: err instanceof Error ? err.message : 'Network request failed',
201
+ retryable: true,
202
+ })
203
+ }
204
+
205
+ if (!response.ok) {
206
+ await throwHttpError(response)
207
+ }
208
+
209
+ if (!response.body) {
210
+ throw new SalesBotError({ code: 'internal', message: 'Response body is null', retryable: false })
211
+ }
212
+
213
+ return response.body
214
+ }
215
+
216
+ // ---------------------------------------------------------------------------
217
+ // Error mapping
218
+ // ---------------------------------------------------------------------------
219
+
220
+ async function throwHttpError(response: Response): Promise<never> {
221
+ let body: { code?: string; message?: string; retryable?: boolean; details?: Record<string, unknown> } = {}
222
+
223
+ try {
224
+ const contentType = response.headers.get('content-type') ?? ''
225
+ if (contentType.includes('application/json')) {
226
+ body = (await response.json()) as typeof body
227
+ }
228
+ } catch {
229
+ // ignore parse errors — fall through to status-based mapping
230
+ }
231
+
232
+ // Prefer the code from the response body; fall back to status-based mapping
233
+ const code: SalesBotErrorCode = isValidErrorCode(body.code)
234
+ ? body.code
235
+ : statusToCode(response.status)
236
+
237
+ throw new SalesBotError({
238
+ code,
239
+ message: body.message ?? response.statusText,
240
+ retryable: body.retryable ?? isRetryableStatus(response.status),
241
+ details: body.details,
242
+ })
243
+ }
244
+
245
+ const VALID_CODES = new Set<SalesBotErrorCode>([
246
+ 'invalid_embed_key', 'origin_not_allowed', 'rate_limited', 'out_of_credits',
247
+ 'unauthorized', 'forbidden', 'not_found', 'bad_request', 'unprocessable_entity',
248
+ 'conflict', 'internal', 'llm_unavailable', 'mcp_unavailable', 'network_error', 'parse_error',
249
+ ])
250
+
251
+ function isValidErrorCode(code: unknown): code is SalesBotErrorCode {
252
+ return typeof code === 'string' && VALID_CODES.has(code as SalesBotErrorCode)
253
+ }
254
+
255
+ function statusToCode(status: number): SalesBotErrorCode {
256
+ switch (status) {
257
+ case 400: return 'bad_request'
258
+ case 401: return 'unauthorized'
259
+ case 402: return 'out_of_credits'
260
+ case 403: return 'forbidden'
261
+ case 404: return 'not_found'
262
+ case 409: return 'conflict'
263
+ case 422: return 'unprocessable_entity'
264
+ case 429: return 'rate_limited'
265
+ default: return 'internal'
266
+ }
267
+ }
268
+
269
+ function isRetryableStatus(status: number): boolean {
270
+ return status === 429 || status >= 500
271
+ }
@@ -0,0 +1,314 @@
1
+ /**
2
+ * Core wire-format types for the Sales Bot SDK.
3
+ *
4
+ * These interfaces are kept verbatim-compatible with the backend's
5
+ * sse-events.ts (sub-project #1). Any change here is a breaking change
6
+ * to the wire format and must be coordinated with the backend team.
7
+ */
8
+
9
+ // ---------------------------------------------------------------------------
10
+ // SSE event names (locked wire format)
11
+ // ---------------------------------------------------------------------------
12
+
13
+ export type SseEventName =
14
+ | 'turn_started'
15
+ | 'delta'
16
+ | 'tool_call_started'
17
+ | 'tool_call_finished'
18
+ | 'message_complete'
19
+ | 'usage'
20
+ | 'done'
21
+ | 'error'
22
+
23
+ // ---------------------------------------------------------------------------
24
+ // SSE event payload types (verbatim from backend sse-events.ts)
25
+ // ---------------------------------------------------------------------------
26
+
27
+ export interface TurnStartedEvent {
28
+ turnId: string
29
+ conversationId: string
30
+ endUserId: string
31
+ }
32
+
33
+ export interface DeltaEvent {
34
+ content: string
35
+ }
36
+
37
+ export interface ToolCallStartedEvent {
38
+ id: string
39
+ name: string
40
+ args: unknown
41
+ }
42
+
43
+ export interface ToolCallFinishedEvent {
44
+ id: string
45
+ ok: boolean
46
+ result?: unknown
47
+ error?: string
48
+ durationMs: number
49
+ }
50
+
51
+ export interface MessageCompleteEvent {
52
+ messageId: string
53
+ content: string
54
+ modelId: string
55
+ promptTokens: number
56
+ completionTokens: number
57
+ }
58
+
59
+ export interface UsageEventData {
60
+ kind: 'chat_completion' | 'mcp_tool_call' | 'dns_verification'
61
+ quantity: number
62
+ costBasisCents: number
63
+ }
64
+
65
+ export interface DoneEvent {
66
+ turnId: string
67
+ }
68
+
69
+ export interface ErrorEvent {
70
+ code: string
71
+ message: string
72
+ retryable: boolean
73
+ details?: Record<string, unknown>
74
+ }
75
+
76
+ // ---------------------------------------------------------------------------
77
+ // Discriminated union over all SSE events
78
+ // ---------------------------------------------------------------------------
79
+
80
+ export type SalesBotEvent =
81
+ | { event: 'turn_started'; data: TurnStartedEvent }
82
+ | { event: 'delta'; data: DeltaEvent }
83
+ | { event: 'tool_call_started'; data: ToolCallStartedEvent }
84
+ | { event: 'tool_call_finished'; data: ToolCallFinishedEvent }
85
+ | { event: 'message_complete'; data: MessageCompleteEvent }
86
+ | { event: 'usage'; data: UsageEventData }
87
+ | { event: 'done'; data: DoneEvent }
88
+ | { event: 'error'; data: ErrorEvent }
89
+
90
+ // ---------------------------------------------------------------------------
91
+ // Error
92
+ // ---------------------------------------------------------------------------
93
+
94
+ /** All error codes the SDK may produce — backend codes + SDK-only codes. */
95
+ export type SalesBotErrorCode =
96
+ // Backend error codes
97
+ | 'invalid_embed_key'
98
+ | 'origin_not_allowed'
99
+ | 'rate_limited'
100
+ | 'out_of_credits'
101
+ | 'unauthorized'
102
+ | 'forbidden'
103
+ | 'not_found'
104
+ | 'bad_request'
105
+ | 'unprocessable_entity'
106
+ | 'conflict'
107
+ | 'internal'
108
+ | 'llm_unavailable'
109
+ | 'mcp_unavailable'
110
+ // SDK-only codes
111
+ | 'network_error'
112
+ | 'parse_error'
113
+
114
+ export class SalesBotError extends Error {
115
+ readonly code: SalesBotErrorCode
116
+ readonly retryable: boolean
117
+ readonly details: Record<string, unknown> | undefined
118
+
119
+ constructor(opts: {
120
+ code: SalesBotErrorCode
121
+ message: string
122
+ retryable?: boolean
123
+ details?: Record<string, unknown>
124
+ }) {
125
+ super(opts.message)
126
+ this.name = 'SalesBotError'
127
+ this.code = opts.code
128
+ this.retryable = opts.retryable ?? false
129
+ this.details = opts.details !== undefined ? opts.details : undefined
130
+ }
131
+ }
132
+
133
+ // ---------------------------------------------------------------------------
134
+ // Client input types
135
+ // ---------------------------------------------------------------------------
136
+
137
+ export interface IdentifyInput {
138
+ externalId?: string
139
+ email?: string
140
+ name?: string
141
+ traits?: Record<string, unknown>
142
+ }
143
+
144
+ export interface AskOptions {
145
+ conversationId?: string
146
+ metadata?: Record<string, unknown>
147
+ }
148
+
149
+ export interface PostTurnInput {
150
+ visitorToken: string
151
+ message: string
152
+ identify?: IdentifyInput
153
+ conversationId?: string
154
+ metadata?: Record<string, unknown>
155
+ }
156
+
157
+ // ---------------------------------------------------------------------------
158
+ // Storage adapter
159
+ // ---------------------------------------------------------------------------
160
+
161
+ export interface StorageAdapter {
162
+ getItem(key: string): string | null
163
+ setItem(key: string, value: string): void
164
+ removeItem(key: string): void
165
+ }
166
+
167
+ // ---------------------------------------------------------------------------
168
+ // Client options
169
+ // ---------------------------------------------------------------------------
170
+
171
+ export interface SalesBotClientOptions {
172
+ /** The embed key from the bot config — format: pk_live_<32 chars> */
173
+ embedKey: string
174
+ /** Backend base URL. Defaults to 'http://localhost:3000' */
175
+ baseUrl?: string
176
+ /** Custom storage adapter. Defaults to localStorage with MemoryStorage fallback */
177
+ storage?: StorageAdapter
178
+ /** Extra headers merged into every request (e.g. { Origin: 'https://...' } for Node) */
179
+ customHeaders?: Record<string, string>
180
+ }
181
+
182
+ // ---------------------------------------------------------------------------
183
+ // Message (used by framework adapters)
184
+ // ---------------------------------------------------------------------------
185
+
186
+ export interface Message {
187
+ id: string
188
+ role: 'user' | 'assistant'
189
+ content: string
190
+ /** True while the assistant is still streaming this message */
191
+ streaming?: boolean
192
+ }
193
+
194
+ // ---------------------------------------------------------------------------
195
+ // Unsubscribe function returned by on()
196
+ // ---------------------------------------------------------------------------
197
+
198
+ export type Unsubscribe = () => void
199
+
200
+ // ---------------------------------------------------------------------------
201
+ // Widget options
202
+ // ---------------------------------------------------------------------------
203
+
204
+ /**
205
+ * Typed CSS-variable theme. Every field maps to a `--sb-<kebab-case>` custom
206
+ * property set on the widget's shadow host. Pass any subset; omitted keys
207
+ * inherit the default. For anything not exposed here, use `customCss`.
208
+ */
209
+ export interface WidgetTheme {
210
+ // ─── Colors ─────────────────────────────────────────────────────────────
211
+ primary?: string
212
+ primaryHover?: string
213
+ /** Text color used on top of `primary` surfaces (header, send button, user bubble). Default: white. */
214
+ primaryText?: string
215
+ text?: string
216
+ textLight?: string
217
+ bg?: string
218
+ bgUser?: string
219
+ bgAssistant?: string
220
+ bgError?: string
221
+ textError?: string
222
+ border?: string
223
+ focusRing?: string
224
+
225
+ // ─── Layout & sizing ────────────────────────────────────────────────────
226
+ /** Outer panel border-radius. */
227
+ radius?: string
228
+ /** Message bubble border-radius. */
229
+ messageRadius?: string
230
+ /** Input pill border-radius. */
231
+ inputRadius?: string
232
+ z?: string | number
233
+ /** Launcher button diameter. */
234
+ launcherSize?: string
235
+ /** Panel width when open. */
236
+ panelWidth?: string
237
+ /** Panel height when open. */
238
+ panelHeight?: string
239
+ /** Panel max-height on small screens. */
240
+ panelMaxHeight?: string
241
+ /** Distance between launcher and viewport bottom edge. */
242
+ bottomOffset?: string
243
+ /** Distance between launcher and viewport left/right edge. */
244
+ sideOffset?: string
245
+ /** Vertical gap between launcher and the open panel. */
246
+ panelGap?: string
247
+ /** Send-button diameter. */
248
+ sendSize?: string
249
+
250
+ // ─── Typography ─────────────────────────────────────────────────────────
251
+ fontFamily?: string
252
+ /** Base font size used by message bubbles and input. */
253
+ fontSize?: string
254
+ fontSizeHeader?: string
255
+ fontSizeSmall?: string
256
+
257
+ // ─── Effects ────────────────────────────────────────────────────────────
258
+ shadow?: string
259
+ /** CSS transition shorthand used by buttons and hover states. */
260
+ transition?: string
261
+ }
262
+
263
+ export interface WidgetOptions extends SalesBotClientOptions {
264
+ container?: HTMLElement
265
+ position?: 'bottom-right' | 'bottom-left'
266
+ title?: string
267
+ placeholder?: string
268
+ /**
269
+ * Typed CSS-variable overrides. Granular control of colors, sizing,
270
+ * typography, and effects without leaving the shadow DOM.
271
+ */
272
+ theme?: WidgetTheme
273
+ /**
274
+ * Escape hatch: raw CSS injected into the shadow root AFTER the built-in
275
+ * styles. Overrides anything by selector (e.g. `.sb-launcher { ... }`).
276
+ * Use sparingly — class names are not a stable API.
277
+ */
278
+ customCss?: string
279
+ /** @deprecated Use `theme.primary` instead. */
280
+ primaryColor?: string
281
+ }
282
+
283
+ export interface WidgetInstance {
284
+ open(): void
285
+ close(): void
286
+ toggle(): void
287
+ destroy(): void
288
+ }
289
+
290
+ // ---------------------------------------------------------------------------
291
+ // Sales-workflow tool name discriminators
292
+ // ---------------------------------------------------------------------------
293
+ //
294
+ // The backend's SalesWorkflowDispatcher registers these tools with the LLM
295
+ // alongside MCP tools. They surface to the SDK via standard `tool_call_started`
296
+ // / `tool_call_finished` events with the names below. Consumers can narrow on
297
+ // these names via `isSalesWorkflowTool` for typed handling.
298
+
299
+ /** All sales-workflow tool names. Order is intentional (set_field/get_current
300
+ * first, quotes after). */
301
+ export const SALES_WORKFLOW_TOOL_NAMES = [
302
+ 'project_brief__set_field',
303
+ 'project_brief__get_current',
304
+ 'quotes__generate',
305
+ 'quotes__send_as_pdf',
306
+ 'quotes__send_as_proposal',
307
+ ] as const
308
+
309
+ export type SalesWorkflowToolName = (typeof SALES_WORKFLOW_TOOL_NAMES)[number]
310
+
311
+ /** Type guard for narrowing tool-call event names to SalesWorkflowToolName. */
312
+ export function isSalesWorkflowTool(name: string): name is SalesWorkflowToolName {
313
+ return (SALES_WORKFLOW_TOOL_NAMES as readonly string[]).includes(name)
314
+ }
@@ -0,0 +1,21 @@
1
+ import type { StorageAdapter } from './types'
2
+
3
+ export const VISITOR_TOKEN_KEY_PREFIX = 'salesbot:visitor:'
4
+
5
+ /**
6
+ * Returns the persisted visitor token for a given embed key,
7
+ * or generates and persists a new UUID if none exists.
8
+ *
9
+ * The token is a stable identifier for this browser+bot combination,
10
+ * stored in the provided storage adapter under:
11
+ * salesbot:visitor:<embedKey>
12
+ */
13
+ export function getOrCreateVisitorToken(embedKey: string, storage: StorageAdapter): string {
14
+ const key = `${VISITOR_TOKEN_KEY_PREFIX}${embedKey}`
15
+ const existing = storage.getItem(key)
16
+ if (existing !== null) return existing
17
+
18
+ const token = crypto.randomUUID()
19
+ storage.setItem(key, token)
20
+ return token
21
+ }
@@ -0,0 +1,2 @@
1
+ export { useSalesBot } from './use-sales-bot'
2
+ export type { UseSalesBotOptions, UseSalesBotResult } from './use-sales-bot'