@27works/chat-core
v0.2.0
Published
Core utilities and components for 27works chat applications.
Readme
@27works/chat-core
Shared server utilities, headless React components, hooks, and contexts for building AI chat applications on top of Caruuto and the Vercel AI SDK.
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.
Contents
- @27works/chat-core
Installation
npm install @27works/chat-coreor
yarn add @27works/chat-coreInstall peer dependencies alongside it:
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-onlyor
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-onlySetup
Before any server functions run, call configure() once — typically in your app's lib/server/services.js:
// lib/server/services.js
import 'server-only'
import { configure } from '@27works/chat-core/server'
import { createClient as createCaruutoClient } from '@caruuto/caruuto-js'
import { createClient as createSupabaseClient } from '@supabase/supabase-js'
configure({
supabase: createSupabaseClient(
process.env.SUPABASE_URL,
process.env.SUPABASE_SERVICE_ROLE_KEY
),
caruuto: createCaruutoClient(
process.env.CARUUTO_URL,
process.env.CARUUTO_API_KEY
)
})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.
Server: the chat API route
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.
Approach A — Caruuto context (recommended)
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.
// app/api/chat/route.js
import 'server-only'
import { openai } from '@ai-sdk/openai'
import {
convertToModelMessages,
createUIMessageStream,
createUIMessageStreamResponse,
generateId,
stepCountIs,
streamText
} from 'ai'
import {
apiErrors,
caruutoAdmin,
chatRateLimit,
checkRateLimit,
getProjectId,
handleAPIError
} from '@27works/chat-core/server'
import { tools } from '@/lib/tools'
import { AI_CHAT_MODEL } from '@/lib/constants'
export const maxDuration = 30
export async function POST(req) {
try {
const rateLimitResponse = await checkRateLimit(req, chatRateLimit)
if (rateLimitResponse) return rateLimitResponse
const { messages, acquisition, clientSessionId } = await req.json()
const lastMessage = messages[messages.length - 1]
const messageText = lastMessage?.parts?.[0]?.text
if (!messageText) {
throw apiErrors.validation('Message text is required')
}
// The conversationId travels in assistant message metadata after the
// first turn — null on the first call, which makes Caruuto create one.
const priorAssistant = [...messages]
.reverse()
.find(m => m.role === 'assistant')
const caruutoConversationId =
priorAssistant?.metadata?.caruutoConversationId ?? null
const projectId = await getProjectId()
const ctx = await caruutoAdmin.ai.context({
projectId,
message: messageText,
conversationId: caruutoConversationId,
source: 'widget',
clientSessionId,
referrerUrl: acquisition?.referrer_url,
utmSource: acquisition?.utm_source,
utmMedium: acquisition?.utm_medium,
utmCampaign: acquisition?.utm_campaign,
utmTerm: acquisition?.utm_term,
utmContent: acquisition?.utm_content
})
const stream = createUIMessageStream({
originalMessages: messages,
execute: async ({ writer }) => {
// Cache hit — stream the cached answer directly, no LLM call needed
if (ctx.from_cache) {
const messageId = generateId()
const textId = generateId()
writer.write({ type: 'start', messageId })
writer.write({ type: 'text-start', id: textId })
writer.write({
type: 'text-delta',
id: textId,
delta: ctx.cached_answer
})
writer.write({ type: 'text-end', id: textId })
writer.write({
type: 'finish',
messageMetadata: { caruutoConversationId: ctx.conversation_id }
})
caruutoAdmin.ai
.saveTurn({
conversationId: ctx.conversation_id,
projectId,
userContent: messageText,
assistantContent: ctx.cached_answer,
inputTokens: 0,
outputTokens: 0,
fromCache: true
})
.catch(err =>
console.error(
'[chat] Failed to persist cached turn:',
err?.message
)
)
return
}
const result = streamText({
model: openai(AI_CHAT_MODEL),
system: ctx.system_prompt,
messages: [
...ctx.history,
...(await convertToModelMessages([
{ id: lastMessage.id, role: 'user', parts: lastMessage.parts }
]))
],
tools,
stopWhen: stepCountIs(2),
async onStepFinish({ text, toolCalls, usage }) {
if (!text && toolCalls?.length) return
if (text) {
caruutoAdmin.ai
.saveTurn({
conversationId: ctx.conversation_id,
projectId,
userContent: messageText,
assistantContent: text,
modelId: AI_CHAT_MODEL,
inputTokens: usage?.promptTokens,
outputTokens: usage?.completionTokens,
cachedTokens: usage?.cachedInputTokens
})
.catch(err =>
console.error('[chat] Failed to persist turn:', err?.message)
)
}
}
})
await writer.merge(
result.toUIMessageStream({
originalMessages: messages,
messageMetadata: () => ({
caruutoConversationId: ctx.conversation_id
})
})
)
}
})
return createUIMessageStreamResponse({ stream })
} catch (error) {
return handleAPIError(error)
}
}Approach B — createRagHandler
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.
// app/api/chat/route.js
import { createRagHandler } from '@27works/chat-core/server'
import { tools } from '@/lib/tools'
import { SYSTEM_PROMPT } from '@/lib/prompts'
import { AI_CHAT_MODEL, AI_EMBEDDING_MODEL } from '@/lib/constants'
import { submitLeadCapture } from '@/lib/server/utils'
export const maxDuration = 30
export const { POST } = createRagHandler({
model: AI_CHAT_MODEL,
embeddingModel: AI_EMBEDDING_MODEL,
systemPrompt: SYSTEM_PROMPT,
tools,
toolHandlers: {
// Called server-side when the user confirms the emailCapture tool
async emailCapture({ email, name, phone, message }) {
await submitLeadCapture(email, name, message)
return { success: true }
}
},
useCache: true, // default: true
ragOptions: {
matchThreshold: 0.05, // default
matchCount: 10, // default
minContentLength: 50 // default
}
})createRagHandler returns { POST }, which is a Next.js App Router route handler.
ragOptions
| Option | Type | Default | Description |
| ------------------ | ---------------- | ------- | --------------------------------------------------------- |
| matchThreshold | number | 0.05 | Minimum cosine similarity for a chunk to be included |
| matchCount | number | 10 | Maximum chunks to retrieve |
| minContentLength | number | 50 | Minimum characters for a chunk to be considered |
| similarityFilter | number \| null | null | Post-retrieval filter — drops chunks below this threshold |
Other required routes
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.
GET /api/chat/load/[id] — load an existing conversation
import {
caruutoAdmin,
getProjectId,
handleAPIError
} from '@27works/chat-core/server'
export async function GET(req, { params }) {
const { id } = await params
try {
const projectId = await getProjectId()
const conversation = await caruutoAdmin.ai.loadConversation({
conversationId: id,
projectId
})
return Response.json({
id: conversation.id,
messages: conversation.messages || [],
created_at: conversation.started_at
})
} catch (error) {
return handleAPIError(error)
}
}POST /api/chat/fork — fork a conversation thread from a question
import {
caruutoAdmin,
getProjectId,
handleAPIError
} from '@27works/chat-core/server'
export async function POST(req) {
try {
const { questionId, sourceChatId } = await req.json()
const projectId = await getProjectId()
const { id: chatId } = await caruutoAdmin.ai.forkConversation({
questionId,
sourceChatId,
projectId
})
return Response.json({ chatId })
} catch (error) {
return handleAPIError(error)
}
}GET|POST /api/chat/share — get or toggle public/private visibility
POST /api/chat/create — create a conversation with a pending question (used by useNavigateWithQuestion)
Link click tracking
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.
// app/api/chat/link-click/route.js
import { createLinkClickHandler } from '@27works/chat-core/server'
export const { POST } = createLinkClickHandler()On the client, call trackLinkClick when a link in an assistant message is clicked:
import { trackLinkClick } from '@27works/chat-core/utils'
trackLinkClick({
conversationId,
url,
linkLabel: 'Book a tour',
messageIndex: 2
})Client: rendering a chat UI
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.
ChatSessionProvider
Wrap your chat UI with ChatSessionProvider. It initialises the message stream, handles acquisition/anonymous-ID capture, rate limit state, and conversation loading.
import { ChatSessionProvider } from '@27works/chat-core/components'
export default function ChatPage({ chatId }) {
return (
<ChatSessionProvider
chatId={chatId}
apiPath='/api/chat' // default
shouldLoadConversation={true} // default — fetches existing messages on mount
onFinish={() => console.log('first stream complete')}
onError={err => console.error(err)}
>
<YourChatUI />
</ChatSessionProvider>
)
}Props
| Prop | Type | Default | Description |
| ------------------------ | ------------------------------ | -------------- | --------------------------------------------------------------------- |
| chatId | string | required | Conversation ID — drives useChat deduplication and the load request |
| apiPath | string | '/api/chat' | URL of the chat POST endpoint |
| shouldLoadConversation | boolean | true | Fetch existing messages from /api/chat/load/[chatId] on mount |
| thinkingOptions | string[] | built-in array | Rotated randomly while awaiting the first streamed token |
| onFinish | (message: UIMessage) => void | — | Called once when the first stream completes |
| onError | (err: Error) => void | — | Called on stream errors (after toast notification) |
Reading caruutoConversationId for navigation
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:
import { useChatSession } from '@27works/chat-core/components'
const { messages } = useChatSession()
useEffect(() => {
const lastAssistant = [...messages]
.reverse()
.find(m => m.role === 'assistant')
const caruutoConversationId = lastAssistant?.metadata?.caruutoConversationId
if (caruutoConversationId && window.location.pathname === '/') {
window.location.href = `/conversation/${caruutoConversationId}`
}
}, [messages])MessageList
Iterates the message array and delegates rendering to your render props. The component itself renders nothing — it only calls your functions.
import { MessageList } from '@27works/chat-core/components'
function ChatMessages() {
return (
<MessageList
renderMessage={({
key,
message,
parts,
isUser,
isStreaming,
addToolResult
}) => (
<div key={key} className={isUser ? 'user-bubble' : 'assistant-bubble'}>
{parts.map((part, i) => {
if (part.type === 'text') return <p key={i}>{part.text}</p>
// render tool confirmation UI, images, etc.
})}
</div>
)}
renderThinking={({ message }) => (
<div className='thinking-indicator'>{message}</div>
)}
renderStreamingIndicator={() => <div className='streaming-dots'>...</div>}
/>
)
}Render prop arguments for renderMessage
| Arg | Type | Description |
| --------------- | ------------------ | ---------------------------------------------------------------------- |
| key | string \| number | Stable message key for React |
| message | UIMessage | Full AI SDK message object |
| parts | UIMessagePart[] | message.parts — text, tool-invocation, and tool-result parts |
| isUser | boolean | Whether this is a user message |
| isStreaming | boolean | True for the last assistant message while streaming |
| addToolResult | fn | Call with { toolCallId, result } to resolve a human-in-the-loop tool |
renderThinking receives { message: string } — the randomly selected thinking string.
renderStreamingIndicator receives nothing — shown when streaming has started but no text token has arrived yet.
UserInput
Manages input state, submission, keyboard shortcuts, and disabled conditions (rate limiting, pending tool confirmation, loading). Renders nothing itself.
import { UserInput } from '@27works/chat-core/components'
function ChatInput() {
return (
<UserInput
renderInput={({
value,
onChange,
onSubmit,
onKeyDown,
disabled,
isLoading,
countdownSeconds
}) => (
<div className='input-row'>
<textarea
value={value}
onChange={onChange}
onKeyDown={onKeyDown}
placeholder='Ask anything...'
disabled={isLoading}
/>
<button onClick={onSubmit} disabled={disabled}>
{countdownSeconds > 0 ? `Wait ${countdownSeconds}s` : 'Send'}
</button>
</div>
)}
/>
)
}Render prop arguments
| Arg | Type | Description |
| ------------------ | ----------------------- | -------------------------------------------------------------------- |
| value | string | Controlled input value |
| onChange | (e \| string) => void | Accepts a change event or a raw string |
| onSubmit | () => void | Submits the current value; no-ops if disabled |
| onKeyDown | (e) => void | Enter submits (Shift+Enter inserts newline) |
| disabled | boolean | True when rate-limited, loading, pending tool confirmation, or empty |
| isLoading | boolean | True while status is submitted or streaming |
| countdownSeconds | number | Seconds remaining on the rate limit (0 when not limited) |
useChatSession
Access any part of the session context directly — useful when building components that don't fit neatly into MessageList or UserInput:
import { useChatSession } from '@27works/chat-core/components'
const {
messages,
sendMessage,
status, // 'ready' | 'submitted' | 'streaming' | 'error'
addToolResult,
setMessages,
clearError,
streamingWithNoText,
thinkingMessage,
pendingToolCallConfirmation,
rateLimitSeconds,
conversationLoading,
loadFailed,
lastFailedInput,
acquisition,
anonymousUserId
} = useChatSession()Hooks
All hooks are client components — import from @27works/chat-core/hooks.
useChatVisibility(chatId, pathname)
Loads and toggles the public/private visibility of a conversation. Calls /api/chat/share internally.
const { visibility, toggle } = useChatVisibility(chatId, pathname)
// visibility: 'public' | 'private'
// toggle(): flips visibility and copies the share URL to the clipboard when making publicuseEmailForm(messages, options?)
Manages email capture form state — fields, validation, auto-population from the emailCapture tool's summary input, and reset.
const {
email,
name,
phone,
message,
setEmail,
setName,
setPhone,
setMessage,
error,
setError,
isValid, // () => boolean — requires email + name
validateEmail, // (value) => boolean — sets error state
reset
} = useEmailForm(messages, { toolName: 'emailCapture' })useForkConversation()
Creates a copy of a conversation thread and navigates to it. Waits for DB confirmation before navigating.
const { forkConversation, isForking } = useForkConversation()
// forkConversation({ questionId, sourceChatId })useNavigateWithQuestion(question)
Creates a new conversation pre-seeded with a question and navigates to it. Calls POST /api/chat/create.
const { navigate, isNavigating } = useNavigateWithQuestion()
// navigate('What are your opening hours?')useParentRouteSync(pathname)
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.
useParentRouteSync(pathname)Contexts
Import from @27works/chat-core/contexts.
ChatProvider / useChatContext
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.
const {
triggerMessage, // (text: string) => void — programmatically send a message
shareConversation, // () => void
shareAnswer, // () => void
canShare, // boolean
registerShareHandlers, // attach share callbacks from Chat.js
chatControls, // { firstQuestionId, onMakePublic } | null
registerChatControls,
hasTransitioned, // whether the intro → chat transition has fired
setHasTransitioned,
shareModalOpen,
openShareModal,
closeShareModal
} = useChatContext()ToastProvider / useToast
Toast notification context. ChatSessionProvider mounts this automatically.
const { showToast } = useToast()
showToast('Copied to clipboard')
showToast('Something went wrong', 'error')Utils
Import from @27works/chat-core/utils.
cn(...inputs)
Merges Tailwind class names, resolving conflicts via tailwind-merge.
import { cn } from '@27works/chat-core/utils'
cn('px-4 py-2', isActive && 'bg-black text-white', className)APPROVAL
Constants for resolving human-in-the-loop tool confirmations.
import { APPROVAL } from '@27works/chat-core/utils'
// APPROVAL.YES → 'Yes, confirmed.'
// APPROVAL.NO → 'No, denied.'
addToolResult({ toolCallId, result: APPROVAL.YES })getToolsRequiringConfirmation(tools)
Returns the names of tools that have no execute function — i.e., tools that pause for human confirmation before running server-side.
import { getToolsRequiringConfirmation } from '@27works/chat-core/utils'
// In lib/tools.ts
export const confirmationTools = getToolsRequiringConfirmation(tools)trackLinkClick({ conversationId, url, linkLabel?, messageIndex? })
Sends a fire-and-forget sendBeacon to /api/chat/link-click. Safe to call in click handlers — never throws, never blocks navigation.
import { trackLinkClick } from '@27works/chat-core/utils'
;<a
href={url}
onClick={() =>
trackLinkClick({ conversationId, url, linkLabel: link.label, messageIndex })
}
>
{link.label}
</a>Styling
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.
Add these CSS custom properties to your globals.css:
@import 'tailwindcss';
@theme inline {
--color-primary: #your-brand-colour;
--color-primary-hover: #your-brand-colour-darker;
--color-foreground: #231f20;
--color-foreground-secondary: #414042;
--color-foreground-muted: #6b7280;
--color-surface: #f9fafb;
--color-card: #ffffff;
--color-border: #e5e7eb;
--color-border-light: #f3f4f6;
--color-error: #dc2626;
--color-success: #16a34a;
}Environment variables
| Variable | Used by | Description |
| --------------------------- | ---------------------------- | --------------------------------------------------------- |
| CARUUTO_URL | services.js fallback | Base URL of your Caruuto instance |
| CARUUTO_API_KEY | services.js fallback | Project API key |
| SUPABASE_URL | services.js fallback | Supabase project URL |
| SUPABASE_SERVICE_ROLE_KEY | services.js fallback | Service-role key (server only) |
| OPENAI_API_KEY | rag-handler.js, app routes | OpenAI API key |
| CARUUTO_PROJECT_ID | getProjectId() | Project ID scoping all Caruuto/Supabase queries |
| UPSTASH_REDIS_REST_URL | rate-limit.js | Upstash Redis URL — rate limiting is disabled if absent |
| UPSTASH_REDIS_REST_TOKEN | rate-limit.js | Upstash Redis token |
| RATE_LIMIT_TEST | rate-limit.js | Set to "true" to apply a tight test limit (2 req / 15s) |
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.
