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
npm install rift-ds
npx rift-ds init chatWhat it writes:
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.exampleMount 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.
// 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.
| Variable | What 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.
# .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.
// 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.
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.
npm install rift-ds react-markdown remark-gfmMount 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.
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.
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.
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.
/* 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.
<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.
// 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.
// 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.
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.
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.
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.
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.
npx shadcn@latest add https://rift-ds.com/r/ai-chat.json