AI chat

A complete chat, added with one command

The package ships the whole AI chat assembled: the panel in its docked, overlay and full-screen forms, the streaming replies with their status and reasoning, the cards a reply can carry, the thread history and tabs, the agent pane, the artifact panel for what a reply makes, the composer with files and mentions, and the server handlers behind it all. The chat in the corner of this page is it. Every option is a prop, listed on its component page, and the playground stages it with every option live.

There are three ways to add it. The full install adds everything to a Next.js app with one command; bring a key. UI only puts the chat panel over an endpoint you already have. Headless gives you the engine under your own design. The packages are at the end.

Full install

For a Next.js app. The command writes the chat routes, their limits, a mount and an env example. It never overwrites a file, so it is safe to run again.

Run it

bash
npm install rift-ds
npx rift-ds init chat

What it writes:

text
app/api/chat/route.ts
app/api/chat/followups/route.ts
app/api/chat/feedback/route.ts
app/api/chat/guardrails.ts
components/chat/ChatMount.tsx
components/chat/models.ts
.env.chat.example

Mount it once

At the end of the root layout, after the page content, so the chat survives page changes and sits last in the tab order.

tsx
// app/layout.tsx
import { ChatMount } from '../components/chat/ChatMount';

export default function RootLayout({ children }) {
  return (
    <html lang="en">
      <body>
        {children}
        <ChatMount />
      </body>
    </html>
  );
}

Set the env

Copy what you need from the example into your local env. Demo mode works with nothing set, Redis is optional, and the caps have defaults.

Environment variables
VariableWhat it does
NEXT_PUBLIC_CHAT_DEMO
Plays a written conversation in the browser. No key needed.
CHAT_ENABLED
Lets the routes call a model.
ANTHROPIC_API_KEY
Your Anthropic key. It stays on the server.
KV_REST_API_URL
Upstash Redis, for rate limits and the daily budget. Optional.
KV_REST_API_TOKEN
The token for that Redis. Optional.
CHAT_VISITOR_SALT
Salts the visitor key the rate limiter uses. Optional.
CHAT_DAILY_MESSAGE_CAP
Messages per day before the chat pauses.
CHAT_DAILY_SPEND_CAP_CENTS
Spend per day, in US cents, before the chat pauses.

Go live

Demo mode plays a written conversation with no key. Turn it off, add the key and enable the routes. Until both are set, the routes answer with a notice. The key never reaches the browser.

bash
# .env.local
NEXT_PUBLIC_CHAT_DEMO=false
CHAT_ENABLED=true
ANTHROPIC_API_KEY=<your key>

The routes

Each handler is a plain function from a request to a response, so it mounts on any framework. You set the prompt, the models, the tools and the limits. A tool can answer with a chart, a plan or a card. With askUser on, a reply can end in a question with answers to pick, drawn in place of the follow-ups. A limit answers as a normal reply, never a broken chat.

ts
// app/api/chat/route.ts, trimmed
import { createChatHandler } from 'rift-ds/chat/server';
import { createUpstashGuardrails } from 'rift-ds/chat/upstash';

const handler = createChatHandler({
  apiKey: process.env.ANTHROPIC_API_KEY,
  system: SYSTEM_PROMPT,
  models: CHAT_MODELS,
  tools: [lookupTool],   // a tool may answer with a card
  askUser: true,         // let a reply end in answers to pick
  guardrails: createUpstashGuardrails({ url, token, salt }),
});

export const POST = (request: Request) => handler(request);

Limits

With Redis set, every visitor is rate limited and the day has a spend cap: the chat steps down to the cheaper model as the day fills, locks the dearer one near the cap, and logs each exchange for the thumbs. Without Redis, everything is allowed and nothing is logged.

/api/chat · one exchangestreams the whole way
Visitorthe chat widget, on any page
Guardrailsper-visitor rate + daily budget · pick the model tier
System contextpersona + the whole site corpus · cached 1h
Lookup toolsget_component · get_design_tokens
Generated contractsprop API + token registry, read from memory
Anthropic APIClaude answers from the corpus, and asks for a lookup when contracts matter
/api/mcpthe same lookups, served to agents
question + the last 10 turnsone streamed model calla prop or token question triggers a lookupin-memory readtool result, then the answerthe answer streams back · lookups show as trace points · it can end in a question to tapshared implementations
One exchange on this site: the prose answers from the corpus, the contracts from the lookup tools, and the limits pick the model.

UI only

The chat panel over your own endpoint. Your server sends the events; the panel does the streaming, the history, the cards and the screens. Three components, set up with props.

Install

The package and the two peers the panel reads markdown with.

bash
npm install rift-ds react-markdown remark-gfm

Mount it once

The provider holds the conversation and points at your endpoint. The launcher is the button and the panel. Turn follow-ups and feedback on when you have routes for them.

tsx
import { ChatProvider, ChatLauncher } from 'rift-ds/chat';
import 'rift-ds/chat/chat.css';

export function Chat() {
  return (
    <ChatProvider endpoint="/api/chat" followups={false} feedback={false}>
      <ChatLauncher
        label="Ask"
        widget={{
          title: 'Assistant',
          starters: [{ id: 'start', label: 'How do I get started?' }],
        }}
      />
    </ChatProvider>
  );
}

Try it with no key

A script is a transport. Write the exchange in advance and the panel plays it back with everything: the thinking, the reveal, the cards, the chips. The examples on the component page are these.

tsx
import { ChatProvider, ChatLauncher, scriptChat } from 'rift-ds/chat';

// A conversation written in advance: no model, no route, no key.
const transport = scriptChat()
  .user('How do I get started?')
  .assistant('Create a workspace, then invite your team.', {
    steps: [{ status: 'Reading the first-run flow' }],
    followups: ['What do the plans include?'],
  })
  .user('What do the plans include?')
  .assistant('Every plan includes unlimited projects.')
  .transport();

export function Preview() {
  return (
    <ChatProvider transport={transport} followups={false} feedback={false}>
      <ChatLauncher label="Ask" widget={{ title: 'Assistant' }} />
    </ChatProvider>
  );
}

Three components

A provider, a surface and a launcher. A transport, one function, joins them to any backend.

rift-ds/chatmounted once, high in the tree
ChatProviderthe conversation, the open state, the placement, the thread lifecycle
useChatturns · the live turn · send, stop, reset, answerQuestion
useThreadsborn on the first utterance · pin · rename · delete · tabs
ChatWidgetheader · thread · composer · starters · rails · cards
ChatLauncherthe button and the panel: docked, overlay, floating, takeover
rendererscard kind → component: charts, plans, diffs, tool calls
A transport(messages, signal) => AsyncIterable<ChatEvent>
createFetchTransportNDJSON from createChatHandler
scriptChat()a conversation written in advance, no key
ownsownscontextopen · placement · viewdraws a reply's cardstatus, delta, card, followups, question, doneone implementationanother
The client half: one provider, two surfaces, and a transport that is the whole integration.

The host page

The launcher marks the page while the panel is open. Your stylesheet makes room, and only when it docks: beside the page at min-width: 1440px, the whole screen under max-width: 719px. The rails sit inline from 720px and 760px of panel width. A suggestion chip holds 40 characters, and a longer one is dropped.

css
/* Make room for the panel only when it docks. */
@media (min-width: 1440px) {
  html[data-chat="docked"] body { padding-right: var(--ds-chat-width); }
  html[data-chat="docked-left"] body { padding-left: var(--ds-chat-width); }
}

/* On a phone the chat is the page. Hide what is behind it. */
@media (max-width: 719px) {
  html[data-chat="takeover"] .site-chrome { display: none; }
}

In your language

Every word the panel writes is a prop, with the English text as its default: the names of its controls, the copy on screen, and the words of the components inside it, one grouped prop per component (composerLabels, questionCardLabels and the rest). Leave a key out and it keeps its English. Under your own UI, useThreads takes labels for the names it writes into the history.

tsx
<ChatLauncher
  label="Preguntar"
  widget={{
    title: 'Asistente',
    sendLabel: 'Enviar',
    closeLabel: 'Cerrar el chat',
    composerLabels: { attachLabel: 'Adjuntar archivos' },
    questionCardLabels: { skip: 'Omitir', skipped: 'Omitida' },
  }}
/>

What your endpoint sends

A stream of events, one JSON object per line. Start with status, end with done. Send a card for a chart, a plan or a diff, and a notice for a limit, never an error page.

json
// POST /api/chat, one JSON object per line
{"type":"status","label":"Thinking"}
{"type":"delta","text":"Create a workspace from the dashboard, "}
{"type":"delta","text":"then invite your team."}
{"type":"card","card":{"kind":"chart","data":{"title":"Trial signups"}}}
{"type":"done"}
status
What the model is doing, shown while it thinks. An optional trace point collects under it.
model
The model serving this reply, as a label.
models
The default model and any the budget has locked. The picker follows it.
delta
A piece of reply text. The first one ends the thinking phase.
exchange
The server's log id for the reply. Its presence shows the thumbs.
followups
Questions to offer under the finished reply.
content
Rich content as a React node. Only a transport in the browser can send one.
card
Rich content as data, drawn by your renderers by kind. What a server sends.
question
Questions the agent asks with answers to pick, drawn in place of the follow-ups.
done
The reply is complete. Always last.
notice
A limit's reply, shown as an ordinary message.
error
A failure the person should see. Only before the first delta.

Artifacts

An artifact is something a reply makes that wants more room than a turn: a dashboard, a page, a file. Send it as a card of kind artifact and turn artifacts on. The thread shows a tile, and the tile opens the artifact in a panel beside the conversation, from 760px of chat width, or as a sheet below that. An html document or a url is drawn in a sandboxed frame with no reach into your page. For live components, give the artifact a kind of your own and draw it with the provider's artifactRenderers.

tsx
// What the server sends, as one line of the stream:
// {"type":"card","card":{"kind":"artifact","data":{
//   "id":"launch-page","title":"Launch page","kind":"html",
//   "meta":"Web page","html":"<!doctype html>..."}}}

<ChatProvider
  endpoint="/api/chat"
  artifactRenderers={{
    // A kind of your own, drawn as live components.
    "sales-board": (artifact) => <SalesBoard data={artifact.data} />,
  }}
>
  <ChatLauncher widget={{ artifacts: true }} />
</ChatProvider>

Headless

The engine under your own UI. Two hooks, two transports and one event contract. Render the conversation however you like; the pacing, the history and the protocol are already handled.

The conversation

Give the hook a transport. It hands back the turns, the reply in flight, and the controls: send, stop and reset. The reveal is paced for you. A turn that asks carries its question; draw it with the question card, then pass what was picked to answerQuestion, which sends it as the next message, or call skipQuestion. Sending a follow-up chip through chooseFollowup keeps the choice on its turn.

tsx
import { useChat, createFetchTransport } from 'rift-ds/chat';

const transport = createFetchTransport('/api/chat');

export function MyChat() {
  const chat = useChat(transport);

  return (
    <>
      {chat.turns.map((turn) => (
        <p key={turn.id}>{turn.text}</p>
      ))}
      {chat.live && <p>{chat.live.text}</p>}
      <form onSubmit={(e) => { e.preventDefault(); chat.send(draft); }}>
        ...
      </form>
    </>
  );
}

The threads

A second hook turns one conversation into many: born on the first message, named a beat later. Pin, rename and delete, tabs that follow the open threads, your own namer and your own store.

ts
import { useChat, useThreads } from 'rift-ds/chat';

const chat = useChat(transport);
const threads = useThreads(chat, {
  groups: savedGroups,          // where to start from
  nameThread: (n) => `Chat ${n}`,  // or a promise from your own namer
  onRename: save,
  onDelete: remove,
});

// threads.groups, threads.activeThreadId, threads.select(id),
// threads.rename(id, title), threads.remove(id), threads.togglePin(id)

Your own transport

A transport is a function from the conversation to a stream of events. Write one for any backend. The fetch transport reads the server handler's stream and a script plays a written one; the hook cannot tell them apart. The events are listed under UI only above.

ts
import type { ChatTransport } from 'rift-ds/chat';

// A transport is one function: the conversation in, events out.
const transport: ChatTransport = async function* (messages, signal) {
  yield { type: 'status', label: 'Thinking' };
  for await (const text of myBackend(messages, signal)) {
    yield { type: 'delta', text };
  }
  yield { type: 'done' };
};

Drive it from anywhere

Under a provider, any component can open the chat, fill the draft, send, or stage a turn. A staged turn never reaches the model.

ts
import { useChatWidget } from 'rift-ds/chat';

// Under a ChatProvider: drive the chat from anywhere in the tree.
const { send, setDraft, injectTurn, stop, reset, setOpen } = useChatWidget();

setOpen(true);
setDraft('How is the trial going?');
send('How is the trial going?');
injectTurn({ role: 'assistant', text: 'A turn the model never said.' });

Packages

The chat lives in its own subpaths, each with the peers it needs. Or copy the source into your app with shadcn.

rift-ds/chat
The widget, the provider, the launcher and the engine under them.
Peers: react-markdown, remark-gfm
rift-ds/chat/server
The streaming chat handler, with an opt-in tool for asking the visitor questions, and its follow-ups and feedback siblings, for any web-standard route.
Peers: @anthropic-ai/sdk
rift-ds/chat/upstash
Rate limits, a daily budget and the exchange log over Upstash Redis, as the handlers' guardrails.
Peers: @upstash/ratelimit, @upstash/redis
rift-ds/chat/demo
A scripted conversation with rich cards and fixtures, for a preview that needs no key.
Peers: recharts
rift-ds/chat/chat.css
The widget's stylesheet.
bash
npx shadcn@latest add https://rift-ds.com/r/ai-chat.json