@27works/chat-core 0.1.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.
package/README.md ADDED
@@ -0,0 +1,776 @@
1
+ # @27works/chat-core
2
+
3
+ Shared server utilities, headless React components, hooks, and contexts for building AI chat applications on top of [Caruuto](https://caruuto.com) and the [Vercel AI SDK](https://sdk.vercel.ai).
4
+
5
+ This package provides the plumbing — state management, streaming, RAG, rate limiting, caching, tool confirmation, and acquisition tracking — so each app only needs to supply its own UI, system prompt, and tool definitions.
6
+
7
+ ---
8
+
9
+ ## Contents
10
+
11
+ - [@27works/chat-core](#27workschat-core)
12
+ - [Contents](#contents)
13
+ - [Installation](#installation)
14
+ - [Setup](#setup)
15
+ - [Server: the chat API route](#server-the-chat-api-route)
16
+ - [Approach A — Caruuto context (recommended)](#approach-a--caruuto-context-recommended)
17
+ - [Approach B — createRagHandler](#approach-b--createraghandler)
18
+ - [Other required routes](#other-required-routes)
19
+ - [Link click tracking](#link-click-tracking)
20
+ - [Client: rendering a chat UI](#client-rendering-a-chat-ui)
21
+ - [ChatSessionProvider](#chatsessionprovider)
22
+ - [MessageList](#messagelist)
23
+ - [UserInput](#userinput)
24
+ - [useChatSession](#usechatsession)
25
+ - [Hooks](#hooks)
26
+ - [`useChatVisibility(chatId, pathname)`](#usechatvisibilitychatid-pathname)
27
+ - [`useEmailForm(messages, options?)`](#useemailformmessages-options)
28
+ - [`useForkConversation()`](#useforkconversation)
29
+ - [`useNavigateWithQuestion(question)`](#usenavigatewithquestionquestion)
30
+ - [`useParentRouteSync(pathname)`](#useparentroutesyncpathname)
31
+ - [Contexts](#contexts)
32
+ - [`ChatProvider` / `useChatContext`](#chatprovider--usechatcontext)
33
+ - [`ToastProvider` / `useToast`](#toastprovider--usetoast)
34
+ - [Utils](#utils)
35
+ - [`cn(...inputs)`](#cninputs)
36
+ - [`APPROVAL`](#approval)
37
+ - [`getToolsRequiringConfirmation(tools)`](#gettoolsrequiringconfirmationtools)
38
+ - [`trackLinkClick({ conversationId, url, linkLabel?, messageIndex? })`](#tracklinkclick-conversationid-url-linklabel-messageindex-)
39
+ - [Styling](#styling)
40
+ - [Environment variables](#environment-variables)
41
+
42
+ ---
43
+
44
+ ## Installation
45
+
46
+ ```sh
47
+ npm install @27works/chat-core
48
+ ```
49
+
50
+ or
51
+
52
+ ```sh
53
+ yarn add @27works/chat-core
54
+ ```
55
+
56
+ Install peer dependencies alongside it:
57
+
58
+ ```sh
59
+ npm install @ai-sdk/openai @ai-sdk/react @caruuto/caruuto-js @supabase/supabase-js @upstash/ratelimit @upstash/redis ai clsx next react tailwind-merge server-only
60
+ ```
61
+
62
+ or
63
+
64
+ ```sh
65
+ yarn add @ai-sdk/openai @ai-sdk/react @caruuto/caruuto-js @supabase/supabase-js @upstash/ratelimit @upstash/redis ai clsx next react tailwind-merge server-only
66
+ ```
67
+
68
+ ---
69
+
70
+ ## Setup
71
+
72
+ Before any server functions run, call `configure()` once — typically in your app's `lib/server/services.js`:
73
+
74
+ ```js
75
+ // lib/server/services.js
76
+ import 'server-only'
77
+
78
+ import { configure } from '@27works/chat-core/server'
79
+ import { createClient as createCaruutoClient } from '@caruuto/caruuto-js'
80
+ import { createClient as createSupabaseClient } from '@supabase/supabase-js'
81
+
82
+ configure({
83
+ supabase: createSupabaseClient(
84
+ process.env.SUPABASE_URL,
85
+ process.env.SUPABASE_SERVICE_ROLE_KEY
86
+ ),
87
+ caruuto: createCaruutoClient(
88
+ process.env.CARUUTO_URL,
89
+ process.env.CARUUTO_API_KEY
90
+ )
91
+ })
92
+ ```
93
+
94
+ If `configure()` is never called the package falls back to the `SUPABASE_URL`, `SUPABASE_SERVICE_ROLE_KEY`, `CARUUTO_URL`, and `CARUUTO_API_KEY` environment variables automatically — so existing apps work without any changes.
95
+
96
+ ---
97
+
98
+ ## Server: the chat API route
99
+
100
+ Every app needs a `POST /api/chat` route that receives messages from the browser, calls the AI, and streams the response back. There are two patterns — pick the one that matches your setup.
101
+
102
+ ### Approach A — Caruuto context (recommended)
103
+
104
+ Use this when Caruuto manages your knowledge base, vocabulary, tone guide, and entity detection. The `caruutoAdmin.ai.context()` call handles RAG, cache lookup, and system prompt assembly on Caruuto's side; your route drives OpenAI and streams the result.
105
+
106
+ ```js
107
+ // app/api/chat/route.js
108
+ import 'server-only'
109
+
110
+ import { openai } from '@ai-sdk/openai'
111
+ import {
112
+ convertToModelMessages,
113
+ createUIMessageStream,
114
+ createUIMessageStreamResponse,
115
+ generateId,
116
+ stepCountIs,
117
+ streamText
118
+ } from 'ai'
119
+
120
+ import {
121
+ apiErrors,
122
+ caruutoAdmin,
123
+ chatRateLimit,
124
+ checkRateLimit,
125
+ getProjectId,
126
+ handleAPIError
127
+ } from '@27works/chat-core/server'
128
+
129
+ import { tools } from '@/lib/tools'
130
+ import { AI_CHAT_MODEL } from '@/lib/constants'
131
+
132
+ export const maxDuration = 30
133
+
134
+ export async function POST(req) {
135
+ try {
136
+ const rateLimitResponse = await checkRateLimit(req, chatRateLimit)
137
+ if (rateLimitResponse) return rateLimitResponse
138
+
139
+ const { messages, acquisition, clientSessionId } = await req.json()
140
+ const lastMessage = messages[messages.length - 1]
141
+ const messageText = lastMessage?.parts?.[0]?.text
142
+
143
+ if (!messageText) {
144
+ throw apiErrors.validation('Message text is required')
145
+ }
146
+
147
+ // The conversationId travels in assistant message metadata after the
148
+ // first turn — null on the first call, which makes Caruuto create one.
149
+ const priorAssistant = [...messages]
150
+ .reverse()
151
+ .find(m => m.role === 'assistant')
152
+ const caruutoConversationId =
153
+ priorAssistant?.metadata?.caruutoConversationId ?? null
154
+
155
+ const projectId = await getProjectId()
156
+
157
+ const ctx = await caruutoAdmin.ai.context({
158
+ projectId,
159
+ message: messageText,
160
+ conversationId: caruutoConversationId,
161
+ source: 'widget',
162
+ clientSessionId,
163
+ referrerUrl: acquisition?.referrer_url,
164
+ utmSource: acquisition?.utm_source,
165
+ utmMedium: acquisition?.utm_medium,
166
+ utmCampaign: acquisition?.utm_campaign,
167
+ utmTerm: acquisition?.utm_term,
168
+ utmContent: acquisition?.utm_content
169
+ })
170
+
171
+ const stream = createUIMessageStream({
172
+ originalMessages: messages,
173
+ execute: async ({ writer }) => {
174
+ // Cache hit — stream the cached answer directly, no LLM call needed
175
+ if (ctx.from_cache) {
176
+ const messageId = generateId()
177
+ const textId = generateId()
178
+ writer.write({ type: 'start', messageId })
179
+ writer.write({ type: 'text-start', id: textId })
180
+ writer.write({
181
+ type: 'text-delta',
182
+ id: textId,
183
+ delta: ctx.cached_answer
184
+ })
185
+ writer.write({ type: 'text-end', id: textId })
186
+ writer.write({
187
+ type: 'finish',
188
+ messageMetadata: { caruutoConversationId: ctx.conversation_id }
189
+ })
190
+
191
+ caruutoAdmin.ai
192
+ .saveTurn({
193
+ conversationId: ctx.conversation_id,
194
+ projectId,
195
+ userContent: messageText,
196
+ assistantContent: ctx.cached_answer,
197
+ inputTokens: 0,
198
+ outputTokens: 0
199
+ })
200
+ .catch(err =>
201
+ console.error(
202
+ '[chat] Failed to persist cached turn:',
203
+ err?.message
204
+ )
205
+ )
206
+
207
+ return
208
+ }
209
+
210
+ const result = streamText({
211
+ model: openai(AI_CHAT_MODEL),
212
+ system: ctx.system_prompt,
213
+ messages: [
214
+ ...ctx.history,
215
+ ...(await convertToModelMessages([
216
+ { id: lastMessage.id, role: 'user', parts: lastMessage.parts }
217
+ ]))
218
+ ],
219
+ tools,
220
+ stopWhen: stepCountIs(2),
221
+ async onStepFinish({ text, toolCalls, usage }) {
222
+ if (!text && toolCalls?.length) return
223
+
224
+ if (text) {
225
+ caruutoAdmin.ai
226
+ .saveTurn({
227
+ conversationId: ctx.conversation_id,
228
+ projectId,
229
+ userContent: messageText,
230
+ assistantContent: text,
231
+ modelId: AI_CHAT_MODEL,
232
+ inputTokens: usage?.promptTokens,
233
+ outputTokens: usage?.completionTokens
234
+ })
235
+ .catch(err =>
236
+ console.error('[chat] Failed to persist turn:', err?.message)
237
+ )
238
+ }
239
+ }
240
+ })
241
+
242
+ await writer.merge(
243
+ result.toUIMessageStream({
244
+ originalMessages: messages,
245
+ messageMetadata: () => ({
246
+ caruutoConversationId: ctx.conversation_id
247
+ })
248
+ })
249
+ )
250
+ }
251
+ })
252
+
253
+ return createUIMessageStreamResponse({ stream })
254
+ } catch (error) {
255
+ return handleAPIError(error)
256
+ }
257
+ }
258
+ ```
259
+
260
+ ### Approach B — createRagHandler
261
+
262
+ Use this when your knowledge base lives directly in Supabase (via the `match_content_chunks` vector search RPC) and you want the package to own the full RAG pipeline — cache lookup, embedding, vector search, context augmentation, streaming, and cache write.
263
+
264
+ ```js
265
+ // app/api/chat/route.js
266
+ import { createRagHandler } from '@27works/chat-core/server'
267
+ import { tools } from '@/lib/tools'
268
+ import { SYSTEM_PROMPT } from '@/lib/prompts'
269
+ import { AI_CHAT_MODEL, AI_EMBEDDING_MODEL } from '@/lib/constants'
270
+ import { submitLeadCapture } from '@/lib/server/utils'
271
+
272
+ export const maxDuration = 30
273
+
274
+ export const { POST } = createRagHandler({
275
+ model: AI_CHAT_MODEL,
276
+ embeddingModel: AI_EMBEDDING_MODEL,
277
+ systemPrompt: SYSTEM_PROMPT,
278
+ tools,
279
+ toolHandlers: {
280
+ // Called server-side when the user confirms the emailCapture tool
281
+ async emailCapture({ email, name, phone, message }) {
282
+ await submitLeadCapture(email, name, message)
283
+ return { success: true }
284
+ }
285
+ },
286
+ useCache: true, // default: true
287
+ ragOptions: {
288
+ matchThreshold: 0.05, // default
289
+ matchCount: 10, // default
290
+ minContentLength: 50 // default
291
+ }
292
+ })
293
+ ```
294
+
295
+ `createRagHandler` returns `{ POST }`, which is a Next.js App Router route handler.
296
+
297
+ **`ragOptions`**
298
+
299
+ | Option | Type | Default | Description |
300
+ | ------------------ | ---------------- | ------- | --------------------------------------------------------- |
301
+ | `matchThreshold` | `number` | `0.05` | Minimum cosine similarity for a chunk to be included |
302
+ | `matchCount` | `number` | `10` | Maximum chunks to retrieve |
303
+ | `minContentLength` | `number` | `50` | Minimum characters for a chunk to be considered |
304
+ | `similarityFilter` | `number \| null` | `null` | Post-retrieval filter — drops chunks below this threshold |
305
+
306
+ ### Other required routes
307
+
308
+ These routes are app-implemented (they're too app-specific for a factory), but the helpers that power them all come from `@27works/chat-core/server`.
309
+
310
+ **`GET /api/chat/load/[id]`** — load an existing conversation
311
+
312
+ ```js
313
+ import {
314
+ caruutoAdmin,
315
+ getProjectId,
316
+ handleAPIError
317
+ } from '@27works/chat-core/server'
318
+
319
+ export async function GET(req, { params }) {
320
+ const { id } = await params
321
+ try {
322
+ const projectId = await getProjectId()
323
+ const conversation = await caruutoAdmin.ai.loadConversation({
324
+ conversationId: id,
325
+ projectId
326
+ })
327
+ return Response.json({
328
+ id: conversation.id,
329
+ messages: conversation.messages || [],
330
+ created_at: conversation.started_at
331
+ })
332
+ } catch (error) {
333
+ return handleAPIError(error)
334
+ }
335
+ }
336
+ ```
337
+
338
+ **`POST /api/chat/fork`** — fork a conversation thread from a question
339
+
340
+ ```js
341
+ import {
342
+ caruutoAdmin,
343
+ getProjectId,
344
+ handleAPIError
345
+ } from '@27works/chat-core/server'
346
+
347
+ export async function POST(req) {
348
+ try {
349
+ const { questionId, sourceChatId } = await req.json()
350
+ const projectId = await getProjectId()
351
+ const { id: chatId } = await caruutoAdmin.ai.forkConversation({
352
+ questionId,
353
+ sourceChatId,
354
+ projectId
355
+ })
356
+ return Response.json({ chatId })
357
+ } catch (error) {
358
+ return handleAPIError(error)
359
+ }
360
+ }
361
+ ```
362
+
363
+ **`GET|POST /api/chat/share`** — get or toggle public/private visibility
364
+
365
+ **`POST /api/chat/create`** — create a conversation with a pending question (used by `useNavigateWithQuestion`)
366
+
367
+ ### Link click tracking
368
+
369
+ Add a route that proxies browser link-click beacons to Caruuto. Uses `sendBeacon` on the client, so it always returns `204` regardless of outcome — tracking failures must never surface to the user.
370
+
371
+ ```js
372
+ // app/api/chat/link-click/route.js
373
+ import { createLinkClickHandler } from '@27works/chat-core/server'
374
+
375
+ export const { POST } = createLinkClickHandler()
376
+ ```
377
+
378
+ On the client, call `trackLinkClick` when a link in an assistant message is clicked:
379
+
380
+ ```js
381
+ import { trackLinkClick } from '@27works/chat-core/utils'
382
+
383
+ trackLinkClick({
384
+ conversationId,
385
+ url,
386
+ linkLabel: 'Book a tour',
387
+ messageIndex: 2
388
+ })
389
+ ```
390
+
391
+ ---
392
+
393
+ ## Client: rendering a chat UI
394
+
395
+ The component layer is headless — the package manages state, and your app renders whatever JSX it likes via render props. There is no pre-built Chat.js component in this package.
396
+
397
+ ### ChatSessionProvider
398
+
399
+ Wrap your chat UI with `ChatSessionProvider`. It initialises the message stream, handles acquisition/anonymous-ID capture, rate limit state, and conversation loading.
400
+
401
+ ```jsx
402
+ import { ChatSessionProvider } from '@27works/chat-core/components'
403
+
404
+ export default function ChatPage({ chatId }) {
405
+ return (
406
+ <ChatSessionProvider
407
+ chatId={chatId}
408
+ apiPath='/api/chat' // default
409
+ shouldLoadConversation={true} // default — fetches existing messages on mount
410
+ onFinish={() => console.log('first stream complete')}
411
+ onError={err => console.error(err)}
412
+ >
413
+ <YourChatUI />
414
+ </ChatSessionProvider>
415
+ )
416
+ }
417
+ ```
418
+
419
+ **Props**
420
+
421
+ | Prop | Type | Default | Description |
422
+ | ------------------------ | ------------------------------ | -------------- | --------------------------------------------------------------------- |
423
+ | `chatId` | `string` | required | Conversation ID — drives `useChat` deduplication and the load request |
424
+ | `apiPath` | `string` | `'/api/chat'` | URL of the chat POST endpoint |
425
+ | `shouldLoadConversation` | `boolean` | `true` | Fetch existing messages from `/api/chat/load/[chatId]` on mount |
426
+ | `thinkingOptions` | `string[]` | built-in array | Rotated randomly while awaiting the first streamed token |
427
+ | `onFinish` | `(message: UIMessage) => void` | — | Called once when the first stream completes |
428
+ | `onError` | `(err: Error) => void` | — | Called on stream errors (after toast notification) |
429
+
430
+ **Reading `caruutoConversationId` for navigation**
431
+
432
+ When using Approach A, Caruuto returns a conversation ID in the assistant message's `metadata`. The AI SDK sets this on the message object _after_ `onFinish` fires, so reading it from the `onFinish` argument will always return `undefined`. Use a `useEffect` on `messages` instead:
433
+
434
+ ```js
435
+ import { useChatSession } from '@27works/chat-core/components'
436
+
437
+ const { messages } = useChatSession()
438
+
439
+ useEffect(() => {
440
+ const lastAssistant = [...messages]
441
+ .reverse()
442
+ .find(m => m.role === 'assistant')
443
+ const caruutoConversationId = lastAssistant?.metadata?.caruutoConversationId
444
+
445
+ if (caruutoConversationId && window.location.pathname === '/') {
446
+ window.location.href = `/conversation/${caruutoConversationId}`
447
+ }
448
+ }, [messages])
449
+ ```
450
+
451
+ ### MessageList
452
+
453
+ Iterates the message array and delegates rendering to your render props. The component itself renders nothing — it only calls your functions.
454
+
455
+ ```jsx
456
+ import { MessageList } from '@27works/chat-core/components'
457
+
458
+ function ChatMessages() {
459
+ return (
460
+ <MessageList
461
+ renderMessage={({
462
+ key,
463
+ message,
464
+ parts,
465
+ isUser,
466
+ isStreaming,
467
+ addToolResult
468
+ }) => (
469
+ <div key={key} className={isUser ? 'user-bubble' : 'assistant-bubble'}>
470
+ {parts.map((part, i) => {
471
+ if (part.type === 'text') return <p key={i}>{part.text}</p>
472
+ // render tool confirmation UI, images, etc.
473
+ })}
474
+ </div>
475
+ )}
476
+ renderThinking={({ message }) => (
477
+ <div className='thinking-indicator'>{message}</div>
478
+ )}
479
+ renderStreamingIndicator={() => <div className='streaming-dots'>...</div>}
480
+ />
481
+ )
482
+ }
483
+ ```
484
+
485
+ **Render prop arguments for `renderMessage`**
486
+
487
+ | Arg | Type | Description |
488
+ | --------------- | ------------------ | ---------------------------------------------------------------------- |
489
+ | `key` | `string \| number` | Stable message key for React |
490
+ | `message` | `UIMessage` | Full AI SDK message object |
491
+ | `parts` | `UIMessagePart[]` | `message.parts` — text, tool-invocation, and tool-result parts |
492
+ | `isUser` | `boolean` | Whether this is a user message |
493
+ | `isStreaming` | `boolean` | True for the last assistant message while streaming |
494
+ | `addToolResult` | `fn` | Call with `{ toolCallId, result }` to resolve a human-in-the-loop tool |
495
+
496
+ `renderThinking` receives `{ message: string }` — the randomly selected thinking string.
497
+ `renderStreamingIndicator` receives nothing — shown when streaming has started but no text token has arrived yet.
498
+
499
+ ### UserInput
500
+
501
+ Manages input state, submission, keyboard shortcuts, and disabled conditions (rate limiting, pending tool confirmation, loading). Renders nothing itself.
502
+
503
+ ```jsx
504
+ import { UserInput } from '@27works/chat-core/components'
505
+
506
+ function ChatInput() {
507
+ return (
508
+ <UserInput
509
+ renderInput={({
510
+ value,
511
+ onChange,
512
+ onSubmit,
513
+ onKeyDown,
514
+ disabled,
515
+ isLoading,
516
+ countdownSeconds
517
+ }) => (
518
+ <div className='input-row'>
519
+ <textarea
520
+ value={value}
521
+ onChange={onChange}
522
+ onKeyDown={onKeyDown}
523
+ placeholder='Ask anything...'
524
+ disabled={isLoading}
525
+ />
526
+ <button onClick={onSubmit} disabled={disabled}>
527
+ {countdownSeconds > 0 ? `Wait ${countdownSeconds}s` : 'Send'}
528
+ </button>
529
+ </div>
530
+ )}
531
+ />
532
+ )
533
+ }
534
+ ```
535
+
536
+ **Render prop arguments**
537
+
538
+ | Arg | Type | Description |
539
+ | ------------------ | ----------------------- | -------------------------------------------------------------------- |
540
+ | `value` | `string` | Controlled input value |
541
+ | `onChange` | `(e \| string) => void` | Accepts a change event or a raw string |
542
+ | `onSubmit` | `() => void` | Submits the current value; no-ops if disabled |
543
+ | `onKeyDown` | `(e) => void` | Enter submits (Shift+Enter inserts newline) |
544
+ | `disabled` | `boolean` | True when rate-limited, loading, pending tool confirmation, or empty |
545
+ | `isLoading` | `boolean` | True while `status` is `submitted` or `streaming` |
546
+ | `countdownSeconds` | `number` | Seconds remaining on the rate limit (0 when not limited) |
547
+
548
+ ### useChatSession
549
+
550
+ Access any part of the session context directly — useful when building components that don't fit neatly into `MessageList` or `UserInput`:
551
+
552
+ ```js
553
+ import { useChatSession } from '@27works/chat-core/components'
554
+
555
+ const {
556
+ messages,
557
+ sendMessage,
558
+ status, // 'ready' | 'submitted' | 'streaming' | 'error'
559
+ addToolResult,
560
+ setMessages,
561
+ clearError,
562
+ streamingWithNoText,
563
+ thinkingMessage,
564
+ pendingToolCallConfirmation,
565
+ rateLimitSeconds,
566
+ conversationLoading,
567
+ loadFailed,
568
+ lastFailedInput,
569
+ acquisition,
570
+ anonymousUserId
571
+ } = useChatSession()
572
+ ```
573
+
574
+ ---
575
+
576
+ ## Hooks
577
+
578
+ All hooks are client components — import from `@27works/chat-core/hooks`.
579
+
580
+ ### `useChatVisibility(chatId, pathname)`
581
+
582
+ Loads and toggles the public/private visibility of a conversation. Calls `/api/chat/share` internally.
583
+
584
+ ```js
585
+ const { visibility, toggle } = useChatVisibility(chatId, pathname)
586
+ // visibility: 'public' | 'private'
587
+ // toggle(): flips visibility and copies the share URL to the clipboard when making public
588
+ ```
589
+
590
+ ### `useEmailForm(messages, options?)`
591
+
592
+ Manages email capture form state — fields, validation, auto-population from the `emailCapture` tool's `summary` input, and reset.
593
+
594
+ ```js
595
+ const {
596
+ email,
597
+ name,
598
+ phone,
599
+ message,
600
+ setEmail,
601
+ setName,
602
+ setPhone,
603
+ setMessage,
604
+ error,
605
+ setError,
606
+ isValid, // () => boolean — requires email + name
607
+ validateEmail, // (value) => boolean — sets error state
608
+ reset
609
+ } = useEmailForm(messages, { toolName: 'emailCapture' })
610
+ ```
611
+
612
+ ### `useForkConversation()`
613
+
614
+ Creates a copy of a conversation thread and navigates to it. Waits for DB confirmation before navigating.
615
+
616
+ ```js
617
+ const { forkConversation, isForking } = useForkConversation()
618
+
619
+ // forkConversation({ questionId, sourceChatId })
620
+ ```
621
+
622
+ ### `useNavigateWithQuestion(question)`
623
+
624
+ Creates a new conversation pre-seeded with a question and navigates to it. Calls `POST /api/chat/create`.
625
+
626
+ ```js
627
+ const { navigate, isNavigating } = useNavigateWithQuestion()
628
+
629
+ // navigate('What are your opening hours?')
630
+ ```
631
+
632
+ ### `useParentRouteSync(pathname)`
633
+
634
+ When the app runs inside an iframe, posts the current pathname to the parent window whenever it changes. The parent can listen for `{ type: 'route', path }` messages to keep its URL bar in sync.
635
+
636
+ ```js
637
+ useParentRouteSync(pathname)
638
+ ```
639
+
640
+ ---
641
+
642
+ ## Contexts
643
+
644
+ Import from `@27works/chat-core/contexts`.
645
+
646
+ ### `ChatProvider` / `useChatContext`
647
+
648
+ Cross-component communication channel for the chat UI — message triggering, share handlers, transition state, and share modal state. `ChatSessionProvider` mounts this automatically; you only need it directly if building outside the standard provider stack.
649
+
650
+ ```js
651
+ const {
652
+ triggerMessage, // (text: string) => void — programmatically send a message
653
+ shareConversation, // () => void
654
+ shareAnswer, // () => void
655
+ canShare, // boolean
656
+ registerShareHandlers, // attach share callbacks from Chat.js
657
+ chatControls, // { firstQuestionId, onMakePublic } | null
658
+ registerChatControls,
659
+ hasTransitioned, // whether the intro → chat transition has fired
660
+ setHasTransitioned,
661
+ shareModalOpen,
662
+ openShareModal,
663
+ closeShareModal
664
+ } = useChatContext()
665
+ ```
666
+
667
+ ### `ToastProvider` / `useToast`
668
+
669
+ Toast notification context. `ChatSessionProvider` mounts this automatically.
670
+
671
+ ```js
672
+ const { showToast } = useToast()
673
+
674
+ showToast('Copied to clipboard')
675
+ showToast('Something went wrong', 'error')
676
+ ```
677
+
678
+ ---
679
+
680
+ ## Utils
681
+
682
+ Import from `@27works/chat-core/utils`.
683
+
684
+ ### `cn(...inputs)`
685
+
686
+ Merges Tailwind class names, resolving conflicts via `tailwind-merge`.
687
+
688
+ ```js
689
+ import { cn } from '@27works/chat-core/utils'
690
+
691
+ cn('px-4 py-2', isActive && 'bg-black text-white', className)
692
+ ```
693
+
694
+ ### `APPROVAL`
695
+
696
+ Constants for resolving human-in-the-loop tool confirmations.
697
+
698
+ ```js
699
+ import { APPROVAL } from '@27works/chat-core/utils'
700
+
701
+ // APPROVAL.YES → 'Yes, confirmed.'
702
+ // APPROVAL.NO → 'No, denied.'
703
+
704
+ addToolResult({ toolCallId, result: APPROVAL.YES })
705
+ ```
706
+
707
+ ### `getToolsRequiringConfirmation(tools)`
708
+
709
+ Returns the names of tools that have no `execute` function — i.e., tools that pause for human confirmation before running server-side.
710
+
711
+ ```js
712
+ import { getToolsRequiringConfirmation } from '@27works/chat-core/utils'
713
+
714
+ // In lib/tools.ts
715
+ export const confirmationTools = getToolsRequiringConfirmation(tools)
716
+ ```
717
+
718
+ ### `trackLinkClick({ conversationId, url, linkLabel?, messageIndex? })`
719
+
720
+ Sends a fire-and-forget `sendBeacon` to `/api/chat/link-click`. Safe to call in click handlers — never throws, never blocks navigation.
721
+
722
+ ```js
723
+ import { trackLinkClick } from '@27works/chat-core/utils'
724
+ ;<a
725
+ href={url}
726
+ onClick={() =>
727
+ trackLinkClick({ conversationId, url, linkLabel: link.label, messageIndex })
728
+ }
729
+ >
730
+ {link.label}
731
+ </a>
732
+ ```
733
+
734
+ ---
735
+
736
+ ## Styling
737
+
738
+ This package ships no CSS. Components use Tailwind utility classes internally; your app is responsible for providing the Tailwind build and setting the theme variables.
739
+
740
+ Add these CSS custom properties to your `globals.css`:
741
+
742
+ ```css
743
+ @import 'tailwindcss';
744
+
745
+ @theme inline {
746
+ --color-primary: #your-brand-colour;
747
+ --color-primary-hover: #your-brand-colour-darker;
748
+ --color-foreground: #231f20;
749
+ --color-foreground-secondary: #414042;
750
+ --color-foreground-muted: #6b7280;
751
+ --color-surface: #f9fafb;
752
+ --color-card: #ffffff;
753
+ --color-border: #e5e7eb;
754
+ --color-border-light: #f3f4f6;
755
+ --color-error: #dc2626;
756
+ --color-success: #16a34a;
757
+ }
758
+ ```
759
+
760
+ ---
761
+
762
+ ## Environment variables
763
+
764
+ | Variable | Used by | Description |
765
+ | --------------------------- | ---------------------------- | --------------------------------------------------------- |
766
+ | `CARUUTO_URL` | `services.js` fallback | Base URL of your Caruuto instance |
767
+ | `CARUUTO_API_KEY` | `services.js` fallback | Project API key |
768
+ | `SUPABASE_URL` | `services.js` fallback | Supabase project URL |
769
+ | `SUPABASE_SERVICE_ROLE_KEY` | `services.js` fallback | Service-role key (server only) |
770
+ | `OPENAI_API_KEY` | `rag-handler.js`, app routes | OpenAI API key |
771
+ | `CARUUTO_PROJECT_ID` | `getProjectId()` | Project ID scoping all Caruuto/Supabase queries |
772
+ | `UPSTASH_REDIS_REST_URL` | `rate-limit.js` | Upstash Redis URL — rate limiting is disabled if absent |
773
+ | `UPSTASH_REDIS_REST_TOKEN` | `rate-limit.js` | Upstash Redis token |
774
+ | `RATE_LIMIT_TEST` | `rate-limit.js` | Set to `"true"` to apply a tight test limit (2 req / 15s) |
775
+
776
+ `SUPABASE_URL` / `SUPABASE_SERVICE_ROLE_KEY` / `CARUUTO_URL` / `CARUUTO_API_KEY` are only needed as env vars if you skip `configure()`. If you call `configure()` at startup you can name your env vars whatever you like.