npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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


Installation

npm install @27works/chat-core

or

yarn add @27works/chat-core

Install 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-only

or

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

Setup

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 public

useEmailForm(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.