@meetreeve/chat-kit
v0.16.0
Published
React chat kit: transport adapters + primitives + drop-in presets for Reeve.Chat surfaces
Keywords
Readme
@meetreeve/chat-kit
React chat kit for Reeve.Chat surfaces — transport adapters, headless primitives, and drop-in presets.
@meetreeve/chat-kit is the canonical surface for embedding Reeve-powered chat in any React app. It ships:
- Transport adapters for WebSocket (Reeve gateway), SSE, REST, voice, and a no-network echo for demos.
- Headless primitives with stable class names + CSS custom properties — no opinion on styling.
- Opinionated presets that compose the above into ready-to-render surfaces (
<ReeveChat>,<ReeveChat.Terminal>, etc.).
You pick the abstraction level. All three tiers compose with each other.
Install
pnpm add @meetreeve/chat-kit
# or
npm install @meetreeve/chat-kit
# or
yarn add @meetreeve/chat-kitreact and react-dom (^18 || ^19) are peer dependencies.
Quick start
import { ReeveChat } from '@meetreeve/chat-kit';
import '@meetreeve/chat-kit/styles/tokens.css';
export function ChatPage() {
return (
<ReeveChat
mode="auto"
gatewayUrl="wss://gw.meetreeve.com"
wsToken={process.env.NEXT_PUBLIC_WS_TOKEN}
sessionKey="chat-kit-demo:main"
title="Chat with Reeve"
placeholder="Ask me anything…"
/>
);
}mode="auto" picks the WebSocket adapter when wsToken is present, and falls back to the echo adapter (a local synthetic reply) otherwise. Set mode="live" to require WS, or mode="echo" to force the demo transport.
Three escalation tiers
Tier 1 — opinionated preset
A single component, sensible defaults. Best for new surfaces and for replacing legacy chat panels with minimal glue.
| Preset | Default adapter | Use when |
|---|---|---|
| <ReeveChat> | WS or Echo (auto) | General-purpose Reeve chat surface |
| <ReeveChat.Terminal> | WS | Power-user / dev console with monospace lines, no bubbles |
| <ReeveChat.Readiness> | SSE (optional REST fallback) | Intake/qualification flow with progress meter + capture card |
| <ReeveChat.Voice> | WS wrapped by useChatVoice | Mic-first surface with tap-to-record or push-to-talk |
| <ReeveChat.Library> | REST | Book / library / knowledge-base style chat with custom bubble renderer |
import { ReeveChat } from '@meetreeve/chat-kit';
<ReeveChat.Readiness
endpoint="/api/v1/readiness-conversations"
token={authToken}
conversationId={conversationId}
onComplete={({ email, phone }) => saveLead({ email, phone })}
/>Tier 2 — compose primitives
When a preset is close but not quite, drop one level and compose. Every primitive reads from the ChatProvider context, so swapping adapters is a one-line change.
import {
ChatProvider,
ChatRoot,
ChatHeader,
ChatErrorBanner,
ChatMessageList,
ChatStreamIndicator,
ChatComposer,
} from '@meetreeve/chat-kit';
import { useChatWS } from '@meetreeve/chat-kit';
import '@meetreeve/chat-kit/styles/tokens.css';
export function CustomChat() {
const adapter = useChatWS({
gatewayUrl: 'wss://gw.meetreeve.com',
wsToken: getToken(),
sessionKey: 'custom:main',
});
return (
<ChatProvider adapter={adapter} theme={{ density: 'compact' }}>
<ChatRoot variant="panel">
<ChatHeader title="Custom chat" actions={<MyMenu />} />
<ChatErrorBanner />
<ChatMessageList renderBubble={(m) => <MyBubble message={m} />} />
<ChatStreamIndicator />
<ChatComposer
attachmentSlot={<MyDropzone />}
voiceSlot={<MyVoiceButton />}
autosize
/>
</ChatRoot>
</ChatProvider>
);
}Tier 3 — bring your own adapter
Any object that satisfies the ChatAdapter contract works as a drop-in for the bundled adapters. Useful when a custom backend doesn't fit WS / SSE / REST cleanly, or when wrapping an existing state store.
import type { ChatAdapter, ChatMessage } from '@meetreeve/chat-kit';
import { ChatProvider, ChatRoot, ChatMessageList, ChatComposer } from '@meetreeve/chat-kit';
function useMyAdapter(): ChatAdapter {
const [messages, setMessages] = useState<ChatMessage[]>([]);
return {
status: 'connected',
messages,
streamState: { phase: 'idle' },
error: null,
send: (text) => {
// Talk to your backend, push the reply into setMessages.
},
};
}
export function MyChat() {
const adapter = useMyAdapter();
return (
<ChatProvider adapter={adapter}>
<ChatRoot>
<ChatMessageList />
<ChatComposer />
</ChatRoot>
</ChatProvider>
);
}The full contract is in src/protocol/adapter.ts. Optional fields (suggestions, progress, stop, retry, clear, connect, disconnect, loadHistory) are read by primitives that need them and degrade gracefully when absent.
Adapters
| Adapter | Transport | Key options | Use when |
|---|---|---|---|
| useChatEcho | none (in-process) | reply?, replyDelayMs? | Storybook / demos / unauthenticated fallback |
| useChatWS | WebSocket (Reeve gateway, protocol v3) | gatewayUrl, wsToken, sessionKey?, autoConnect?, onStatusChange?, onTokenExpired? | Live Reeve.Chat streaming |
| useChatSSE | POST + EventStream | endpoint, token?, sessionKey?, headers? | AgentPik-style /chat2 SSE backends |
| useChatREST<TConv> | REST POST + optional GET polling | endpoint, conversationId, token?, toMessages, toSuggestions?, toProgress?, pollIntervalMs? | Non-streaming or custom-DTO backends (Freya, AgentPik v1) |
| useChatVoice | wraps a text adapter | textAdapter, transcribe, recordingMode, onAudioLevel?, cancelDragThresholdPx? | Mic input layered over any text adapter |
useChatWS, useChatSSE, and useChatREST also expose a __raw escape hatch (typed as UseChatWSReturn / UseChatSSEReturn / UseChatRESTReturn) for migration safety from the legacy hooks they were ported from.
Adapter notes
useChatWSspeaks the Reeve gateway protocol v3 (connect.challengehandshake,chat.send/chat.history,chatevents withstate: delta | final | error | aborted). Reconnect uses exponential backoff capped at 30s / 10 attempts. ReturningnullfromonTokenExpiredsurfaces "Authentication failed".useChatSSEposts to${endpoint}/${sessionKey}/messagesand parses the kit's canonical frame protocol (message-start,part-start,text-delta,part-end,message-end). The legacy AgentPik v1 part shape (part: { type, ... }) is accepted as a fallback.useChatRESTis generic overTConversation. You providetoMessages(required) and optionaltoSuggestions/toProgressprojectors so the kit stays agnostic to your DTO. Polling is opt-in viapollIntervalMs > 0and requires a non-nullconversationId.- Idempotency keys (DEV-12705). Every send carries a UUID minted once per user message:
idempotency_keyin theuseChatREST/useChatSSEPOST body, andparams.idempotencyKeyon theuseChatWSchat.sendframe.retry()(and the ChatErrorBanner / ChatStreamIndicator Retry and the composer's unedited resend, which route through it) resends the same key, so a backend that dedupes on it runs the turn once. So does asend()of the byte-identical message right after a failed send. After a turn completes, the key is spent: a laterretry()(for example MessageActions' default Regenerate) mints a fresh key. Host backends should forward the field to reeve-services'POST /v1/chat/conversations/{id}/messages(idempotency_key, 1–128 chars). - Non-retryable failures (DEV-12723). reeve-services (DEV-12700) answers a same-key repeat it will not run again with
retryable: false: a failed turn that already dispatched a tool (its failure is replayed),409 turn_outcome_unknown, and422 idempotency_key_reused. The adapters read that flag and expose it asadapter.error.retryable === false(the error is aChatAdapterError, which also carriescode). ChatErrorBanner and ChatStreamIndicator then hide Retry, even when you passonRetry. The key is spent, so the nextsend(), or aretry(), mints a fresh one, and the composer sends an unedited resend of the restored text as a new message. A retryable failure (network, a 5xx without the flag,409 turn_in_progress) keeps the same-key behavior above. Check the flag withisNonRetryable(adapter.error). A custom adapter can opt in by exposing aChatAdapterError, or anyErrorwithretryable: false.- What the host must forward. The adapters read DEV-12700's own shapes, so passing the upstream body or frame through is enough. REST, and SSE before the stream opens: a non-2xx JSON body
{ "error": { "code", "message", "retryable" } }. The same envelope wrapped once in FastAPI's{ "detail": … }also works. SSE stream: achat:errorframe withretryable(and optionallycode) at the top level, next toerror. WS: achat.sendreswhoseerrorobject carriesretryable, or achatevent withstate: 'error'andretryablein its payload. Only an explicit booleanfalsecounts. If a host drops the field, the failure stays retryable: Retry shows, and a same-key retry replays the failure until the user edits the text. That matches the behavior before this change.
- What the host must forward. The adapters read DEV-12700's own shapes, so passing the upstream body or frame through is enough. REST, and SSE before the stream opens: a non-2xx JSON body
useChatVoiceis a recording-state machine; STT is injected viatranscribe(blob, mimeType). Pick'tap-to-record'or'push-to-talk'. Wrap anyChatAdapter— voice is composable with WS, SSE, REST, or Echo.
Primitives
All primitives consume the ChatProvider context (no prop drilling) and emit rkit-* class names you can target from your own stylesheet.
| Primitive | Key props | Behavior |
|---|---|---|
| ChatProvider | adapter, theme?, children | Context root. theme.density ∈ compact \| comfortable, theme.variant ∈ panel \| terminal \| floating \| fullscreen |
| ChatRoot | variant?, className? | Outer flex container; resolves variant from prop or theme |
| ChatHeader | title?, subtitle?, actions?, children? | Top bar with title/subtitle/actions; children overrides the default layout |
| ChatMessageList | renderBubble?, smartAutoScroll? | Renders adapter.messages; auto-scrolls only when user is near the bottom |
| ChatBubble | message, renderAttachment?, showTimestamp? | Single message bubble; shows streaming cursor when meta.isStreaming === true |
| ChatComposer | placeholder?, autosize?, maxLength?, attachmentSlot?, voiceSlot? | Textarea + send/stop button; Enter sends, Shift-Enter newline, an Enter that confirms an IME candidate never sends (reuse the exported isImeEnter(e) in host-local inputs) |
| ChatStatusPill | labels? | Renders adapter.status with a colored dot + label |
| ChatStreamIndicator | onStop?, onRetry?, onDismiss?, labels? | Shows current stream phase + elapsed timer + Stop / Retry / Dismiss actions |
| ChatErrorBanner | onRetry?, onDismiss? | Renders adapter.error.message with Retry / Dismiss; hides itself when no error |
| ChatProgressMeter | progress?, showLabelAt? | Renders adapter.progress; shows stage label once percent ≥ threshold |
| ChatSuggestionChips | chips?, showSkip?, onPick | Renders adapter.suggestions; optional synthetic Skip chip |
| ChatEmptyState | greeting?, hero?, suggestions?, onPick? | Rendered only when adapter.messages.length === 0 |
| ChatVoiceButton | voiceAdapter, mode?, cancelDragThresholdPx? | Mic button; drives a ChatVoiceAdapter; exposes --audio-level CSS var |
| ChatCaptureCard | onCapture, consentText?, tcpaText?, placeholder?, submitLabel?, onSkip? | Email/phone capture with consent + TCPA disclosure |
| ChatCompletionScreen | score?, stageTitle?, bullets?, emailGate?, children? | Post-flow summary with optional score + email gate |
Composer drafts and failed sends (DEV-12702)
Pass conversationId to ChatComposer to keep the draft per conversation. The draft survives the composer unmounting, and every composer showing that conversation (a sidebar and a full chat, say) shares it. draftPersistence="session" also mirrors the text to sessionStorage; the default keeps drafts in memory. Hosts with their own inputs use the same store through useConversationDraft(conversationId) (or get/set/clearConversationDraft).
When a send fails, the composer puts the exact text and images back, as long as the user hasn't started typing something else. useConversationDraft(id).failed exposes the failed send either way. A send counts as failed when the adapter's streamState.phase goes to 'error' for it, or when its send() returns a promise that rejects. The built-in REST and SSE adapters report HTTP and network failures this way. The WS adapter doesn't yet report a send dropped on a closed socket (DEV-12719). Shim adapters that don't drive the phase should return that promise. A restore only writes into the conversation the message was sent from. The text goes back into the shared draft, but the images are restored only to the composer that sent them. Pass restoreOnFailure={false} to opt out.
If the user re-sends the restored text unedited, the composer calls adapter.retry() rather than send(), so the adapter replays the same message (same idempotency key, no duplicate bubble). Edited text is a new message. After a non-retryable failure (adapter.error.retryable === false, DEV-12723), the unedited resend goes through send() as a new message with a fresh key instead. If the error banner's Retry re-sends the message instead, the restored copy is cleared so it can't go out twice.
Typed content blocks (DEV-1077)
ChatBubble walks message.content and dispatches each item via a BlockRenderer registry. String items render as text; structured items render via their registered renderer (with a JSON-pretty fallback for unknown types).
| Block | Source | Default renderer |
| --- | --- | --- |
| tool_call | studio-handler tool invocations | Collapsed header with condensed args summary + expand toggle for full JSON |
| tool_result | studio-handler tool results | Status pill + summary + body dispatched by tool_name (cost chip / render-status badge / brand card / chain preview / memory list / agent output / JSON fallback) |
| render_confirm_required | DEV-1074 cost-threshold gate | Total cost + per-stage breakdown + Approve / Cancel buttons |
| auto_run_confirm_required | DEV-1091 auto-run gate | URL + cost range + step list + est duration + Approve / Cancel |
| auto_run_progress | DEV-1091 webhook progress stream | Step / message header + <progress> bar when pct is known |
| chain_preview | DEV-1075 (materialize_chain_from_dna, load_chain_session) | Per-stage BFS chips + optional CTA buttons |
Action button clicks fire adapter.action({ run_id, action_id }). The WS adapter emits a chat.action event over the open socket; SSE / REST / custom adapters implement the optional action method themselves.
ChatStreamIndicator is tool-call aware. During the streaming phase, it inspects the last block of the most recent assistant message:
tool_call→ "Reeve is using<tool_name>…"auto_run_progress(non-terminal) → "<step>:<message>"- otherwise the default
Streaming…label
Overriding a renderer
Pass blockRenderers to <ChatProvider> to override individual renderers without losing the defaults for the rest:
import { ChatProvider, type BlockProps } from '@meetreeve/chat-kit';
function BidCard({ block }: BlockProps) {
// ...custom UI keyed to your app's bid_card / agent shape
return <div>...</div>;
}
<ChatProvider
adapter={adapter}
blockRenderers={{ tool_result: BidCard as never }}
>
{/* every other block type still uses the default */}
</ChatProvider>Presets
| Preset | Default transport | Layout | Use when |
|---|---|---|---|
| <ReeveChat> | useChatWS if wsToken, else useChatEcho | Header + messages + stream indicator + composer | General Reeve chat surface |
| <ReeveChat.Terminal> | useChatWS | Status pill bar + monospace lines + monospace composer | Power-user / dev console |
| <ReeveChat.Readiness> | useChatSSE (with restFallback → useChatREST) | Progress meter + messages + suggestion chips (with Skip) + capture card at 100% | Intake / qualification flow |
| <ReeveChat.Voice> | useChatWS wrapped by useChatVoice | Header with status pill + messages + composer with mic button | Voice-first chat surface |
| <ReeveChat.Library> | useChatREST | Header + empty state with starter prompts + messages with book-card bubble override | Book / library / knowledge-base chat |
Every preset accepts an adapter prop that, when provided, replaces the auto-configured one. Pass any ChatAdapter to override.
Theming
The kit ships an opt-in CSS file that defines every token under :root and a few data-rkit-* selectors. Import once at your app root:
import '@meetreeve/chat-kit/styles/tokens.css';Override tokens at any scope by re-declaring the CSS custom properties:
:root {
--rkit-accent: #2563eb; /* brand accent — used by user bubbles + voice button */
--rkit-bubble-radius: 0.5rem; /* tighten bubble corners */
--rkit-font-base: 'Inter', system-ui, sans-serif;
}
/* Scope a single surface */
.my-chat-panel {
--rkit-accent: #16a34a;
}Theme hooks
Toggle bundled themes by setting a data attribute on any ancestor of <ChatRoot> (typically <html> or <body>):
[data-rkit-theme='dark']— dark surface palette[data-rkit-density='compact']— tighter spacing + smaller base text[data-rkit-variant='terminal']— monospace + transparent bubbles (also applied automatically by<ReeveChat.Terminal>)
Token groups
| Group | Sample tokens | Purpose |
|---|---|---|
| Surface | --rkit-bg, --rkit-bg-raised, --rkit-bg-muted, --rkit-fg, --rkit-fg-muted, --rkit-border | Base background / foreground stack |
| Brand | --rkit-accent, --rkit-accent-fg | Primary action color |
| Bubbles | --rkit-bubble-user-bg, --rkit-bubble-assistant-bg, --rkit-bubble-system-bg, --rkit-bubble-radius, --rkit-bubble-max-width | Per-role bubble styling |
| Composer | --rkit-composer-bg, --rkit-composer-border, --rkit-composer-radius | Composer surface |
| Chips | --rkit-chip-bg, --rkit-chip-fg, --rkit-chip-radius | Suggestion / empty-state chips |
| Capture card | --rkit-capture-card-bg, --rkit-capture-card-radius | Lead capture card |
| Voice | --rkit-voice-idle-bg, --rkit-voice-recording-bg, --rkit-voice-ring-color | Voice button + audio ring |
| Status | --rkit-status-connected, --rkit-status-connecting, --rkit-status-authenticating, --rkit-status-reconnecting, --rkit-status-disconnected, --rkit-status-idle, --rkit-status-error, --rkit-status-demo | Status pill colors per ChatStatus |
| Spacing | --rkit-space-1 … --rkit-space-5 | Density scale (overridden by [data-rkit-density='compact']) |
| Type | --rkit-font-base, --rkit-font-mono, --rkit-text-sm, --rkit-text-base, --rkit-text-lg | Type stack + scale |
| Z indices | --rkit-z-panel, --rkit-z-banner, --rkit-z-overlay | Stacking |
See src/styles/tokens.css for the full enumerated list (~50 tokens across 12 groups, plus the dark / compact / terminal theme blocks).
Examples
examples/standalone.tsx— Tier 1:<ReeveChat>with auto-mode + token refresh.examples/composed.tsx— Tier 2: hand-composed primitives with a custom bubble renderer + custom voice slot.
License
MIT
