{"_id":"@27works/chat-core","_rev":"2-40e3377b91268c98985bad006cbdc6cf","name":"@27works/chat-core","dist-tags":{"latest":"0.2.0"},"versions":{"0.1.0":{"name":"@27works/chat-core","version":"0.1.0","author":{"name":"Jim Lambie","email":"jim@27.works"},"license":"UNLICENSED","_id":"@27works/chat-core@0.1.0","maintainers":[{"name":"jimlambie","email":"jameslambie@gmail.com"},{"name":"woogz","email":"jayraydon@gmail.com"}],"homepage":"https://github.com/27Works/chat-core#readme","bugs":{"url":"https://github.com/27Works/chat-core/issues"},"dist":{"shasum":"bcebcbdf7d26ac0a381eebabb243b0408bd2c1fd","tarball":"https://registry.npmjs.org/@27works/chat-core/-/chat-core-0.1.0.tgz","fileCount":14,"integrity":"sha512-b8TVSD6rHodAVSd8pGvhB2FECapCficZWDmaSzl5P1TITh9IERT6RFlfrvPRLA2JxgKt8oublAMN77H87lvDJw==","signatures":[{"sig":"MEYCIQDA9fPric5j6XrwAkuUoJMYDXinLHSfXDbf695syDpubgIhAMO8aQh3gg+ZEos4BtVU1KmlL2XTjLlwlKhunhp0A+m9","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":650753},"type":"module","exports":{"./hooks":{"types":"./dist/hooks/index.d.ts","default":"./dist/hooks/index.js"},"./types":{"types":"./dist/types/index.d.ts","default":"./dist/types/index.js"},"./utils":{"types":"./dist/utils/index.d.ts","default":"./dist/utils/index.js"},"./server":{"types":"./dist/server/index.d.ts","default":"./dist/server/index.js"},"./contexts":{"types":"./dist/contexts/index.d.ts","default":"./dist/contexts/index.js"},"./components":{"types":"./dist/components/index.d.ts","default":"./dist/components/index.js"}},"gitHead":"987db1596d2bbebf43c1c18766d34ffe52a94562","scripts":{"lint":"eslint src","build":"tsup","prepublishOnly":"npm run build"},"_npmUser":{"name":"jimlambie","email":"jameslambie@gmail.com"},"repository":{"url":"git+https://github.com/27Works/chat-core.git","type":"git"},"_npmVersion":"10.9.8","description":"Core utilities and components for 27works chat applications.","directories":{},"_nodeVersion":"22.22.3","_hasShrinkwrap":false,"devDependencies":{"tsup":"^8.5.1","eslint":"^9","globals":"^16","@eslint/js":"^9","typescript":"^5","eslint-plugin-react":"^7","eslint-plugin-import":"^2.32.0","@stylistic/eslint-plugin":"^5.10.0","eslint-plugin-react-hooks":"^7"},"peerDependencies":{"ai":">=6","clsx":">=2","next":">=15","react":">=19","server-only":"*","@ai-sdk/react":">=3","@ai-sdk/openai":">=3","@upstash/redis":">=1","tailwind-merge":">=3","@upstash/ratelimit":">=2","@caruuto/caruuto-js":">=0.9.1","@supabase/supabase-js":">=2"},"_npmOperationalInternal":{"tmp":"tmp/chat-core_0.1.0_1780986259947_0.17628858437573935","host":"s3://npm-registry-packages-npm-production"}},"0.2.0":{"name":"@27works/chat-core","version":"0.2.0","description":"Core utilities and components for 27works chat applications.","type":"module","repository":{"type":"git","url":"git+https://github.com/27Works/chat-core.git"},"bugs":{"url":"https://github.com/27Works/chat-core/issues"},"license":"UNLICENSED","author":{"name":"Jim Lambie","email":"jim@27.works"},"exports":{"./server":{"types":"./dist/server/index.d.ts","default":"./dist/server/index.js"},"./hooks":{"types":"./dist/hooks/index.d.ts","default":"./dist/hooks/index.js"},"./contexts":{"types":"./dist/contexts/index.d.ts","default":"./dist/contexts/index.js"},"./components":{"types":"./dist/components/index.d.ts","default":"./dist/components/index.js"},"./types":{"types":"./dist/types/index.d.ts","default":"./dist/types/index.js"},"./utils":{"types":"./dist/utils/index.d.ts","default":"./dist/utils/index.js"}},"scripts":{"build":"tsup","lint":"eslint src","prepublishOnly":"npm run build"},"peerDependencies":{"@ai-sdk/openai":">=3","@ai-sdk/react":">=3","@caruuto/caruuto-js":">=0.9.1","@supabase/supabase-js":">=2","@upstash/ratelimit":">=2","@upstash/redis":">=1","ai":">=6","clsx":">=2","next":">=15","react":">=19","server-only":"*","tailwind-merge":">=3"},"devDependencies":{"@eslint/js":"^9","@stylistic/eslint-plugin":"^5.10.0","eslint":"^9","eslint-plugin-import":"^2.32.0","eslint-plugin-react":"^7","eslint-plugin-react-hooks":"^7","globals":"^16","tsup":"^8.5.1","typescript":"^5"},"_id":"@27works/chat-core@0.2.0","gitHead":"f9c056eac498306776579990112e2e4add1845ea","homepage":"https://github.com/27Works/chat-core#readme","_nodeVersion":"22.22.3","_npmVersion":"10.9.8","dist":{"integrity":"sha512-dUcX4s9dV7wq1rr0hxUpCItXBms9PR5Q9wAZWJNtmTy2FhvESQL4FT1FvmmlOtQ7y86+fyqNxeKhpVs47b8Ywg==","shasum":"1dbb866837850c647b6bb6a83299432ecf102538","tarball":"https://registry.npmjs.org/@27works/chat-core/-/chat-core-0.2.0.tgz","fileCount":14,"unpackedSize":650923,"signatures":[{"keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U","sig":"MEUCIF/TAAdnOPmkMCuc308Qt0eMqbYsDIhqy45l2aKkFdAxAiEA+ICCntlZhksiROIArMj3/QGCJ8rVv1X+x0UBni2uOqk="}]},"_npmUser":{"name":"jimlambie","email":"jameslambie@gmail.com"},"directories":{},"maintainers":[{"name":"jimlambie","email":"jameslambie@gmail.com"},{"name":"woogz","email":"jayraydon@gmail.com"}],"_npmOperationalInternal":{"host":"s3://npm-registry-packages-npm-production","tmp":"tmp/chat-core_0.2.0_1782710856633_0.1878180314218838"},"_hasShrinkwrap":false}},"time":{"created":"2026-06-09T06:24:19.651Z","modified":"2026-06-29T05:27:36.931Z","0.1.0":"2026-06-09T06:24:20.111Z","0.2.0":"2026-06-29T05:27:36.791Z"},"bugs":{"url":"https://github.com/27Works/chat-core/issues"},"author":{"name":"Jim Lambie","email":"jim@27.works"},"license":"UNLICENSED","homepage":"https://github.com/27Works/chat-core#readme","repository":{"type":"git","url":"git+https://github.com/27Works/chat-core.git"},"description":"Core utilities and components for 27works chat applications.","maintainers":[{"name":"jimlambie","email":"jameslambie@gmail.com"},{"name":"woogz","email":"jayraydon@gmail.com"}],"readme":"# @27works/chat-core\n\nShared 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).\n\nThis 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.\n\n---\n\n## Contents\n\n- [@27works/chat-core](#27workschat-core)\n  - [Contents](#contents)\n  - [Installation](#installation)\n  - [Setup](#setup)\n  - [Server: the chat API route](#server-the-chat-api-route)\n    - [Approach A — Caruuto context (recommended)](#approach-a--caruuto-context-recommended)\n    - [Approach B — createRagHandler](#approach-b--createraghandler)\n    - [Other required routes](#other-required-routes)\n    - [Link click tracking](#link-click-tracking)\n  - [Client: rendering a chat UI](#client-rendering-a-chat-ui)\n    - [ChatSessionProvider](#chatsessionprovider)\n    - [MessageList](#messagelist)\n    - [UserInput](#userinput)\n    - [useChatSession](#usechatsession)\n  - [Hooks](#hooks)\n    - [`useChatVisibility(chatId, pathname)`](#usechatvisibilitychatid-pathname)\n    - [`useEmailForm(messages, options?)`](#useemailformmessages-options)\n    - [`useForkConversation()`](#useforkconversation)\n    - [`useNavigateWithQuestion(question)`](#usenavigatewithquestionquestion)\n    - [`useParentRouteSync(pathname)`](#useparentroutesyncpathname)\n  - [Contexts](#contexts)\n    - [`ChatProvider` / `useChatContext`](#chatprovider--usechatcontext)\n    - [`ToastProvider` / `useToast`](#toastprovider--usetoast)\n  - [Utils](#utils)\n    - [`cn(...inputs)`](#cninputs)\n    - [`APPROVAL`](#approval)\n    - [`getToolsRequiringConfirmation(tools)`](#gettoolsrequiringconfirmationtools)\n    - [`trackLinkClick({ conversationId, url, linkLabel?, messageIndex? })`](#tracklinkclick-conversationid-url-linklabel-messageindex-)\n  - [Styling](#styling)\n  - [Environment variables](#environment-variables)\n\n---\n\n## Installation\n\n```sh\nnpm install @27works/chat-core\n```\n\nor\n\n```sh\nyarn add @27works/chat-core\n```\n\nInstall peer dependencies alongside it:\n\n```sh\nnpm 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\n```\n\nor\n\n```sh\nyarn 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\n```\n\n---\n\n## Setup\n\nBefore any server functions run, call `configure()` once — typically in your app's `lib/server/services.js`:\n\n```js\n// lib/server/services.js\nimport 'server-only'\n\nimport { configure } from '@27works/chat-core/server'\nimport { createClient as createCaruutoClient } from '@caruuto/caruuto-js'\nimport { createClient as createSupabaseClient } from '@supabase/supabase-js'\n\nconfigure({\n  supabase: createSupabaseClient(\n    process.env.SUPABASE_URL,\n    process.env.SUPABASE_SERVICE_ROLE_KEY\n  ),\n  caruuto: createCaruutoClient(\n    process.env.CARUUTO_URL,\n    process.env.CARUUTO_API_KEY\n  )\n})\n```\n\nIf `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.\n\n---\n\n## Server: the chat API route\n\nEvery 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.\n\n### Approach A — Caruuto context (recommended)\n\nUse 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.\n\n```js\n// app/api/chat/route.js\nimport 'server-only'\n\nimport { openai } from '@ai-sdk/openai'\nimport {\n  convertToModelMessages,\n  createUIMessageStream,\n  createUIMessageStreamResponse,\n  generateId,\n  stepCountIs,\n  streamText\n} from 'ai'\n\nimport {\n  apiErrors,\n  caruutoAdmin,\n  chatRateLimit,\n  checkRateLimit,\n  getProjectId,\n  handleAPIError\n} from '@27works/chat-core/server'\n\nimport { tools } from '@/lib/tools'\nimport { AI_CHAT_MODEL } from '@/lib/constants'\n\nexport const maxDuration = 30\n\nexport async function POST(req) {\n  try {\n    const rateLimitResponse = await checkRateLimit(req, chatRateLimit)\n    if (rateLimitResponse) return rateLimitResponse\n\n    const { messages, acquisition, clientSessionId } = await req.json()\n    const lastMessage = messages[messages.length - 1]\n    const messageText = lastMessage?.parts?.[0]?.text\n\n    if (!messageText) {\n      throw apiErrors.validation('Message text is required')\n    }\n\n    // The conversationId travels in assistant message metadata after the\n    // first turn — null on the first call, which makes Caruuto create one.\n    const priorAssistant = [...messages]\n      .reverse()\n      .find(m => m.role === 'assistant')\n    const caruutoConversationId =\n      priorAssistant?.metadata?.caruutoConversationId ?? null\n\n    const projectId = await getProjectId()\n\n    const ctx = await caruutoAdmin.ai.context({\n      projectId,\n      message: messageText,\n      conversationId: caruutoConversationId,\n      source: 'widget',\n      clientSessionId,\n      referrerUrl: acquisition?.referrer_url,\n      utmSource: acquisition?.utm_source,\n      utmMedium: acquisition?.utm_medium,\n      utmCampaign: acquisition?.utm_campaign,\n      utmTerm: acquisition?.utm_term,\n      utmContent: acquisition?.utm_content\n    })\n\n    const stream = createUIMessageStream({\n      originalMessages: messages,\n      execute: async ({ writer }) => {\n        // Cache hit — stream the cached answer directly, no LLM call needed\n        if (ctx.from_cache) {\n          const messageId = generateId()\n          const textId = generateId()\n          writer.write({ type: 'start', messageId })\n          writer.write({ type: 'text-start', id: textId })\n          writer.write({\n            type: 'text-delta',\n            id: textId,\n            delta: ctx.cached_answer\n          })\n          writer.write({ type: 'text-end', id: textId })\n          writer.write({\n            type: 'finish',\n            messageMetadata: { caruutoConversationId: ctx.conversation_id }\n          })\n\n          caruutoAdmin.ai\n            .saveTurn({\n              conversationId: ctx.conversation_id,\n              projectId,\n              userContent: messageText,\n              assistantContent: ctx.cached_answer,\n              inputTokens: 0,\n              outputTokens: 0,\n              fromCache: true\n            })\n            .catch(err =>\n              console.error(\n                '[chat] Failed to persist cached turn:',\n                err?.message\n              )\n            )\n\n          return\n        }\n\n        const result = streamText({\n          model: openai(AI_CHAT_MODEL),\n          system: ctx.system_prompt,\n          messages: [\n            ...ctx.history,\n            ...(await convertToModelMessages([\n              { id: lastMessage.id, role: 'user', parts: lastMessage.parts }\n            ]))\n          ],\n          tools,\n          stopWhen: stepCountIs(2),\n          async onStepFinish({ text, toolCalls, usage }) {\n            if (!text && toolCalls?.length) return\n\n            if (text) {\n              caruutoAdmin.ai\n                .saveTurn({\n                  conversationId: ctx.conversation_id,\n                  projectId,\n                  userContent: messageText,\n                  assistantContent: text,\n                  modelId: AI_CHAT_MODEL,\n                  inputTokens: usage?.promptTokens,\n                  outputTokens: usage?.completionTokens,\n                  cachedTokens: usage?.cachedInputTokens\n                })\n                .catch(err =>\n                  console.error('[chat] Failed to persist turn:', err?.message)\n                )\n            }\n          }\n        })\n\n        await writer.merge(\n          result.toUIMessageStream({\n            originalMessages: messages,\n            messageMetadata: () => ({\n              caruutoConversationId: ctx.conversation_id\n            })\n          })\n        )\n      }\n    })\n\n    return createUIMessageStreamResponse({ stream })\n  } catch (error) {\n    return handleAPIError(error)\n  }\n}\n```\n\n### Approach B — createRagHandler\n\nUse 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.\n\n```js\n// app/api/chat/route.js\nimport { createRagHandler } from '@27works/chat-core/server'\nimport { tools } from '@/lib/tools'\nimport { SYSTEM_PROMPT } from '@/lib/prompts'\nimport { AI_CHAT_MODEL, AI_EMBEDDING_MODEL } from '@/lib/constants'\nimport { submitLeadCapture } from '@/lib/server/utils'\n\nexport const maxDuration = 30\n\nexport const { POST } = createRagHandler({\n  model: AI_CHAT_MODEL,\n  embeddingModel: AI_EMBEDDING_MODEL,\n  systemPrompt: SYSTEM_PROMPT,\n  tools,\n  toolHandlers: {\n    // Called server-side when the user confirms the emailCapture tool\n    async emailCapture({ email, name, phone, message }) {\n      await submitLeadCapture(email, name, message)\n      return { success: true }\n    }\n  },\n  useCache: true, // default: true\n  ragOptions: {\n    matchThreshold: 0.05, // default\n    matchCount: 10, // default\n    minContentLength: 50 // default\n  }\n})\n```\n\n`createRagHandler` returns `{ POST }`, which is a Next.js App Router route handler.\n\n**`ragOptions`**\n\n| Option             | Type             | Default | Description                                               |\n| ------------------ | ---------------- | ------- | --------------------------------------------------------- |\n| `matchThreshold`   | `number`         | `0.05`  | Minimum cosine similarity for a chunk to be included      |\n| `matchCount`       | `number`         | `10`    | Maximum chunks to retrieve                                |\n| `minContentLength` | `number`         | `50`    | Minimum characters for a chunk to be considered           |\n| `similarityFilter` | `number \\| null` | `null`  | Post-retrieval filter — drops chunks below this threshold |\n\n### Other required routes\n\nThese 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`.\n\n**`GET /api/chat/load/[id]`** — load an existing conversation\n\n```js\nimport {\n  caruutoAdmin,\n  getProjectId,\n  handleAPIError\n} from '@27works/chat-core/server'\n\nexport async function GET(req, { params }) {\n  const { id } = await params\n  try {\n    const projectId = await getProjectId()\n    const conversation = await caruutoAdmin.ai.loadConversation({\n      conversationId: id,\n      projectId\n    })\n    return Response.json({\n      id: conversation.id,\n      messages: conversation.messages || [],\n      created_at: conversation.started_at\n    })\n  } catch (error) {\n    return handleAPIError(error)\n  }\n}\n```\n\n**`POST /api/chat/fork`** — fork a conversation thread from a question\n\n```js\nimport {\n  caruutoAdmin,\n  getProjectId,\n  handleAPIError\n} from '@27works/chat-core/server'\n\nexport async function POST(req) {\n  try {\n    const { questionId, sourceChatId } = await req.json()\n    const projectId = await getProjectId()\n    const { id: chatId } = await caruutoAdmin.ai.forkConversation({\n      questionId,\n      sourceChatId,\n      projectId\n    })\n    return Response.json({ chatId })\n  } catch (error) {\n    return handleAPIError(error)\n  }\n}\n```\n\n**`GET|POST /api/chat/share`** — get or toggle public/private visibility\n\n**`POST /api/chat/create`** — create a conversation with a pending question (used by `useNavigateWithQuestion`)\n\n### Link click tracking\n\nAdd 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.\n\n```js\n// app/api/chat/link-click/route.js\nimport { createLinkClickHandler } from '@27works/chat-core/server'\n\nexport const { POST } = createLinkClickHandler()\n```\n\nOn the client, call `trackLinkClick` when a link in an assistant message is clicked:\n\n```js\nimport { trackLinkClick } from '@27works/chat-core/utils'\n\ntrackLinkClick({\n  conversationId,\n  url,\n  linkLabel: 'Book a tour',\n  messageIndex: 2\n})\n```\n\n---\n\n## Client: rendering a chat UI\n\nThe 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.\n\n### ChatSessionProvider\n\nWrap your chat UI with `ChatSessionProvider`. It initialises the message stream, handles acquisition/anonymous-ID capture, rate limit state, and conversation loading.\n\n```jsx\nimport { ChatSessionProvider } from '@27works/chat-core/components'\n\nexport default function ChatPage({ chatId }) {\n  return (\n    <ChatSessionProvider\n      chatId={chatId}\n      apiPath='/api/chat' // default\n      shouldLoadConversation={true} // default — fetches existing messages on mount\n      onFinish={() => console.log('first stream complete')}\n      onError={err => console.error(err)}\n    >\n      <YourChatUI />\n    </ChatSessionProvider>\n  )\n}\n```\n\n**Props**\n\n| Prop                     | Type                           | Default        | Description                                                           |\n| ------------------------ | ------------------------------ | -------------- | --------------------------------------------------------------------- |\n| `chatId`                 | `string`                       | required       | Conversation ID — drives `useChat` deduplication and the load request |\n| `apiPath`                | `string`                       | `'/api/chat'`  | URL of the chat POST endpoint                                         |\n| `shouldLoadConversation` | `boolean`                      | `true`         | Fetch existing messages from `/api/chat/load/[chatId]` on mount       |\n| `thinkingOptions`        | `string[]`                     | built-in array | Rotated randomly while awaiting the first streamed token              |\n| `onFinish`               | `(message: UIMessage) => void` | —              | Called once when the first stream completes                           |\n| `onError`                | `(err: Error) => void`         | —              | Called on stream errors (after toast notification)                    |\n\n**Reading `caruutoConversationId` for navigation**\n\nWhen 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:\n\n```js\nimport { useChatSession } from '@27works/chat-core/components'\n\nconst { messages } = useChatSession()\n\nuseEffect(() => {\n  const lastAssistant = [...messages]\n    .reverse()\n    .find(m => m.role === 'assistant')\n  const caruutoConversationId = lastAssistant?.metadata?.caruutoConversationId\n\n  if (caruutoConversationId && window.location.pathname === '/') {\n    window.location.href = `/conversation/${caruutoConversationId}`\n  }\n}, [messages])\n```\n\n### MessageList\n\nIterates the message array and delegates rendering to your render props. The component itself renders nothing — it only calls your functions.\n\n```jsx\nimport { MessageList } from '@27works/chat-core/components'\n\nfunction ChatMessages() {\n  return (\n    <MessageList\n      renderMessage={({\n        key,\n        message,\n        parts,\n        isUser,\n        isStreaming,\n        addToolResult\n      }) => (\n        <div key={key} className={isUser ? 'user-bubble' : 'assistant-bubble'}>\n          {parts.map((part, i) => {\n            if (part.type === 'text') return <p key={i}>{part.text}</p>\n            // render tool confirmation UI, images, etc.\n          })}\n        </div>\n      )}\n      renderThinking={({ message }) => (\n        <div className='thinking-indicator'>{message}</div>\n      )}\n      renderStreamingIndicator={() => <div className='streaming-dots'>...</div>}\n    />\n  )\n}\n```\n\n**Render prop arguments for `renderMessage`**\n\n| Arg             | Type               | Description                                                            |\n| --------------- | ------------------ | ---------------------------------------------------------------------- |\n| `key`           | `string \\| number` | Stable message key for React                                           |\n| `message`       | `UIMessage`        | Full AI SDK message object                                             |\n| `parts`         | `UIMessagePart[]`  | `message.parts` — text, tool-invocation, and tool-result parts         |\n| `isUser`        | `boolean`          | Whether this is a user message                                         |\n| `isStreaming`   | `boolean`          | True for the last assistant message while streaming                    |\n| `addToolResult` | `fn`               | Call with `{ toolCallId, result }` to resolve a human-in-the-loop tool |\n\n`renderThinking` receives `{ message: string }` — the randomly selected thinking string.\n`renderStreamingIndicator` receives nothing — shown when streaming has started but no text token has arrived yet.\n\n### UserInput\n\nManages input state, submission, keyboard shortcuts, and disabled conditions (rate limiting, pending tool confirmation, loading). Renders nothing itself.\n\n```jsx\nimport { UserInput } from '@27works/chat-core/components'\n\nfunction ChatInput() {\n  return (\n    <UserInput\n      renderInput={({\n        value,\n        onChange,\n        onSubmit,\n        onKeyDown,\n        disabled,\n        isLoading,\n        countdownSeconds\n      }) => (\n        <div className='input-row'>\n          <textarea\n            value={value}\n            onChange={onChange}\n            onKeyDown={onKeyDown}\n            placeholder='Ask anything...'\n            disabled={isLoading}\n          />\n          <button onClick={onSubmit} disabled={disabled}>\n            {countdownSeconds > 0 ? `Wait ${countdownSeconds}s` : 'Send'}\n          </button>\n        </div>\n      )}\n    />\n  )\n}\n```\n\n**Render prop arguments**\n\n| Arg                | Type                    | Description                                                          |\n| ------------------ | ----------------------- | -------------------------------------------------------------------- |\n| `value`            | `string`                | Controlled input value                                               |\n| `onChange`         | `(e \\| string) => void` | Accepts a change event or a raw string                               |\n| `onSubmit`         | `() => void`            | Submits the current value; no-ops if disabled                        |\n| `onKeyDown`        | `(e) => void`           | Enter submits (Shift+Enter inserts newline)                          |\n| `disabled`         | `boolean`               | True when rate-limited, loading, pending tool confirmation, or empty |\n| `isLoading`        | `boolean`               | True while `status` is `submitted` or `streaming`                    |\n| `countdownSeconds` | `number`                | Seconds remaining on the rate limit (0 when not limited)             |\n\n### useChatSession\n\nAccess any part of the session context directly — useful when building components that don't fit neatly into `MessageList` or `UserInput`:\n\n```js\nimport { useChatSession } from '@27works/chat-core/components'\n\nconst {\n  messages,\n  sendMessage,\n  status, // 'ready' | 'submitted' | 'streaming' | 'error'\n  addToolResult,\n  setMessages,\n  clearError,\n  streamingWithNoText,\n  thinkingMessage,\n  pendingToolCallConfirmation,\n  rateLimitSeconds,\n  conversationLoading,\n  loadFailed,\n  lastFailedInput,\n  acquisition,\n  anonymousUserId\n} = useChatSession()\n```\n\n---\n\n## Hooks\n\nAll hooks are client components — import from `@27works/chat-core/hooks`.\n\n### `useChatVisibility(chatId, pathname)`\n\nLoads and toggles the public/private visibility of a conversation. Calls `/api/chat/share` internally.\n\n```js\nconst { visibility, toggle } = useChatVisibility(chatId, pathname)\n// visibility: 'public' | 'private'\n// toggle(): flips visibility and copies the share URL to the clipboard when making public\n```\n\n### `useEmailForm(messages, options?)`\n\nManages email capture form state — fields, validation, auto-population from the `emailCapture` tool's `summary` input, and reset.\n\n```js\nconst {\n  email,\n  name,\n  phone,\n  message,\n  setEmail,\n  setName,\n  setPhone,\n  setMessage,\n  error,\n  setError,\n  isValid, // () => boolean — requires email + name\n  validateEmail, // (value) => boolean — sets error state\n  reset\n} = useEmailForm(messages, { toolName: 'emailCapture' })\n```\n\n### `useForkConversation()`\n\nCreates a copy of a conversation thread and navigates to it. Waits for DB confirmation before navigating.\n\n```js\nconst { forkConversation, isForking } = useForkConversation()\n\n// forkConversation({ questionId, sourceChatId })\n```\n\n### `useNavigateWithQuestion(question)`\n\nCreates a new conversation pre-seeded with a question and navigates to it. Calls `POST /api/chat/create`.\n\n```js\nconst { navigate, isNavigating } = useNavigateWithQuestion()\n\n// navigate('What are your opening hours?')\n```\n\n### `useParentRouteSync(pathname)`\n\nWhen 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.\n\n```js\nuseParentRouteSync(pathname)\n```\n\n---\n\n## Contexts\n\nImport from `@27works/chat-core/contexts`.\n\n### `ChatProvider` / `useChatContext`\n\nCross-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.\n\n```js\nconst {\n  triggerMessage, // (text: string) => void — programmatically send a message\n  shareConversation, // () => void\n  shareAnswer, // () => void\n  canShare, // boolean\n  registerShareHandlers, // attach share callbacks from Chat.js\n  chatControls, // { firstQuestionId, onMakePublic } | null\n  registerChatControls,\n  hasTransitioned, // whether the intro → chat transition has fired\n  setHasTransitioned,\n  shareModalOpen,\n  openShareModal,\n  closeShareModal\n} = useChatContext()\n```\n\n### `ToastProvider` / `useToast`\n\nToast notification context. `ChatSessionProvider` mounts this automatically.\n\n```js\nconst { showToast } = useToast()\n\nshowToast('Copied to clipboard')\nshowToast('Something went wrong', 'error')\n```\n\n---\n\n## Utils\n\nImport from `@27works/chat-core/utils`.\n\n### `cn(...inputs)`\n\nMerges Tailwind class names, resolving conflicts via `tailwind-merge`.\n\n```js\nimport { cn } from '@27works/chat-core/utils'\n\ncn('px-4 py-2', isActive && 'bg-black text-white', className)\n```\n\n### `APPROVAL`\n\nConstants for resolving human-in-the-loop tool confirmations.\n\n```js\nimport { APPROVAL } from '@27works/chat-core/utils'\n\n// APPROVAL.YES  →  'Yes, confirmed.'\n// APPROVAL.NO   →  'No, denied.'\n\naddToolResult({ toolCallId, result: APPROVAL.YES })\n```\n\n### `getToolsRequiringConfirmation(tools)`\n\nReturns the names of tools that have no `execute` function — i.e., tools that pause for human confirmation before running server-side.\n\n```js\nimport { getToolsRequiringConfirmation } from '@27works/chat-core/utils'\n\n// In lib/tools.ts\nexport const confirmationTools = getToolsRequiringConfirmation(tools)\n```\n\n### `trackLinkClick({ conversationId, url, linkLabel?, messageIndex? })`\n\nSends a fire-and-forget `sendBeacon` to `/api/chat/link-click`. Safe to call in click handlers — never throws, never blocks navigation.\n\n```js\nimport { trackLinkClick } from '@27works/chat-core/utils'\n;<a\n  href={url}\n  onClick={() =>\n    trackLinkClick({ conversationId, url, linkLabel: link.label, messageIndex })\n  }\n>\n  {link.label}\n</a>\n```\n\n---\n\n## Styling\n\nThis package ships no CSS. Components use Tailwind utility classes internally; your app is responsible for providing the Tailwind build and setting the theme variables.\n\nAdd these CSS custom properties to your `globals.css`:\n\n```css\n@import 'tailwindcss';\n\n@theme inline {\n  --color-primary: #your-brand-colour;\n  --color-primary-hover: #your-brand-colour-darker;\n  --color-foreground: #231f20;\n  --color-foreground-secondary: #414042;\n  --color-foreground-muted: #6b7280;\n  --color-surface: #f9fafb;\n  --color-card: #ffffff;\n  --color-border: #e5e7eb;\n  --color-border-light: #f3f4f6;\n  --color-error: #dc2626;\n  --color-success: #16a34a;\n}\n```\n\n---\n\n## Environment variables\n\n| Variable                    | Used by                      | Description                                               |\n| --------------------------- | ---------------------------- | --------------------------------------------------------- |\n| `CARUUTO_URL`               | `services.js` fallback       | Base URL of your Caruuto instance                         |\n| `CARUUTO_API_KEY`           | `services.js` fallback       | Project API key                                           |\n| `SUPABASE_URL`              | `services.js` fallback       | Supabase project URL                                      |\n| `SUPABASE_SERVICE_ROLE_KEY` | `services.js` fallback       | Service-role key (server only)                            |\n| `OPENAI_API_KEY`            | `rag-handler.js`, app routes | OpenAI API key                                            |\n| `CARUUTO_PROJECT_ID`        | `getProjectId()`             | Project ID scoping all Caruuto/Supabase queries           |\n| `UPSTASH_REDIS_REST_URL`    | `rate-limit.js`              | Upstash Redis URL — rate limiting is disabled if absent   |\n| `UPSTASH_REDIS_REST_TOKEN`  | `rate-limit.js`              | Upstash Redis token                                       |\n| `RATE_LIMIT_TEST`           | `rate-limit.js`              | Set to `\"true\"` to apply a tight test limit (2 req / 15s) |\n\n`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.\n","readmeFilename":"README.md"}