Migrating from the Chat SDK to the Agent SDK
Implementations built using the Chat SDK can be upgraded to use the Agent SDK instead. This upgrade lets you extend your AI assistant's abilities by giving it access to custom tools, such as calling your APIs or navigating your web application.
This guide covers the migration process from @kapaai/react-sdk (Chat SDK) to @kapaai/agent-react (Agent SDK).
Installation
# Remove old
npm uninstall @kapaai/react-sdk
# Install new
npm install @kapaai/agent-core @kapaai/agent-react
@kapaai/agent-core is a peer dependency of @kapaai/agent-react. If you plan to use Zod schemas for tool definitions, install zod and zod-to-json-schema as well.
Quickstart: using the built-in UI
The simplest migration path is to use the Agent SDK's built-in UI components. This gives you a complete chat experience out of the box.
import { AgentProvider, AgentChat } from '@kapaai/agent-react';
function App() {
return (
<AgentProvider
projectId="your-project-id"
integrationId="your-integration-id"
model="kapa-agent-1.0"
getSessionToken={async () => {
const res = await fetch('/api/kapa-session', { method: 'POST' });
return res.json(); // { session_token, expires_at }
}}
theme={{ colorScheme: 'dark', accentColor: '#2563eb' }}
>
<div style={{ height: 600 }}>
<AgentChat
branding={{
title: 'Support Agent',
subtitle: 'How can I help?',
examplePrompts: ['How do I get started?'],
}}
/>
</div>
</AgentProvider>
);
}
AgentChat fills its parent container and includes a header, message list with markdown rendering, tool call cards with sources, input with send/stop, and a footer. For a slide-in drawer, use AgentPanel instead:
<AgentPanel
open={isPanelOpen}
onClose={() => setIsPanelOpen(false)}
width={480}
branding={{ title: 'Support Agent' }}
/>
The full list of available components:
AgentChat: Complete chat UI (header, messages, input, footer)AgentPanel: Slide-in drawer wrapping AgentChatAgentInput: Text input with send/stop buttonAgentMessageBubble: Message rendering with markdown and tool callsToolCallCard: Tool call display with args/result/sources tabsSourceTiles: Overlapping favicon circles for sourcesExamplePrompts: Starter prompt buttons
You can use these directly, mix them with your own components, or ignore them entirely and build fully custom UI using just the useAgentChat() hook (same headless pattern as the Chat SDK).
Provider setup
Before (Chat SDK)
import { KapaProvider } from '@kapaai/react-sdk';
<KapaProvider
integrationId="your-integration-id"
customizationId="optional-customization-id"
sourceGroupIDsInclude={['group-1']}
uncertainAnswerCallout="This answer may not be accurate."
userTrackingMode="cookie"
botProtectionMechanism="recaptcha"
callbacks={{
askAI: {
onQuerySubmit: (p) => console.log(p),
onAnswerGenerationCompleted: (p) => console.log(p),
},
}}
>
<App />
</KapaProvider>
After (Agent SDK)
import { AgentProvider } from '@kapaai/agent-react';
<AgentProvider
projectId="your-project-id"
integrationId="your-integration-id"
model="kapa-agent-1.0"
getSessionToken={async () => {
const res = await fetch('/api/kapa-session', { method: 'POST' });
return res.json(); // { session_token, expires_at }
}}
user={{ email: 'user@example.com', unique_client_id: 'user-123' }}
customInstructions="Optional system prompt additions"
sourceGroupIdsInclude={['group-1']}
tools={myTools}
context={myToolContext}
onEvent={(event) => console.log(event.type, event.data)}
theme={{ colorScheme: 'dark', accentColor: '#9333ea' }}
>
<App />
</AgentProvider>
Key differences
| Concern | Chat SDK | Agent SDK |
|---|---|---|
| Authentication | Handled internally (captcha + integration ID via proxy) | You provide a getSessionToken function. Your backend creates session tokens using your API key via POST <https://api.kapa.ai/agent/v1/projects/{id}/agent/sessions/> with an X-API-KEY header. The SDK handles token refresh and 401 retry automatically. |
| Required props | integrationId | projectId, integrationId, model, getSessionToken |
| User identity | userTrackingMode (cookie/fingerprint/none) + window.kapaSettings.user | user prop with { email?, unique_client_id? } |
| Bot protection | Built-in reCAPTCHA/hCaptcha | Not applicable. Session token auth replaces captcha. |
| Prompt customization | customizationId prop (server-side) | customInstructions prop (injected into the system prompt) |
| Source filtering | sourceGroupIDsInclude | sourceGroupIdsInclude (same concept, slightly different casing) |
| Uncertainty callout | uncertainAnswerCallout prop | Not supported. The Agent SDK does not expose uncertainty detection. |
Headless usage: useChat -> useAgentChat
If you are building a fully custom UI with the Chat SDK's useChat hook, here is how each field maps to the Agent SDK's useAgentChat hook.
Before
import { useChat } from '@kapaai/react-sdk';
const {
submitQuery,
isPreparingAnswer,
isGeneratingAnswer,
resetConversation,
stopGeneration,
addFeedback,
error,
conversation,
threadId,
} = useChat();
After
import { useAgentChat } from '@kapaai/agent-react';
const {
sendMessage,
isStreaming,
resetConversation,
stopGeneration,
messages,
threadId,
inputValue,
setInputValue,
approveToolCall,
rejectToolCall,
getFaviconUrl,
} = useAgentChat();
Field-by-field mapping
submitQuery(query) -> sendMessage(text)
Same purpose. Both accept a string and trigger a request. sendMessage returns a Promise<void>.
isPreparingAnswer / isGeneratingAnswer -> isStreaming
The Agent SDK uses a single isStreaming boolean. It becomes true immediately when sendMessage is called (before the HTTP request is made) and stays true until the agent loop finishes. This means you can use isStreaming to both disable the input field and show a stop button. This is how the SDK's built-in AgentChat component works internally.
If you need to distinguish "request sent but no tokens yet" from "tokens are arriving", you can track this yourself by watching for the first assistant message content update while isStreaming is true.
resetConversation -> resetConversation
Direct equivalent. Also clears the input value.
stopGeneration -> stopGeneration
Direct equivalent. Works at any point while isStreaming is true, including before tokens arrive.
addFeedback -> not available
The Agent SDK does not support feedback/reactions and this is not currently planned. If you need thumbs up/down, you would need to build that independently against your own endpoint.
error -> not available as a top-level field
Errors surface in two ways:
- As assistant messages with
isError: trueand an error description incontent. - Via the
onEventcallback onAgentProviderwithtype: 'response_error'.
// Checking for error in messages
const lastMessage = messages[messages.length - 1];
if (lastMessage?.role === 'assistant' && lastMessage.isError) {
// Show error UI
}
// Or via events
<AgentProvider onEvent={(event) => {
if (event.type === 'response_error') {
showError(event.data.error);
}
}} />
conversation -> messages
This is the largest structural change. See the next section.
threadId -> threadId
Direct equivalent.
Conversation model
Before: Conversation class with QA objects
const { conversation } = useChat();
// Array-like class of QA pairs
const latest = conversation.getLatest();
console.log(latest.question); // user's question
console.log(latest.answer); // markdown string
console.log(latest.sources); // StreamSource[]
console.log(latest.status); // "streaming" | "pending_completion" | "completed"
console.log(latest.id); // questionAnswerId (null while streaming)
console.log(latest.reaction); // "upvote" | "downvote" | null
console.log(latest.metadata?.is_uncertain); // boolean
After: flat ConversationMessage[]
const { messages } = useAgentChat();
// Flat array of user and assistant messages
messages.forEach((msg) => {
if (msg.role === 'user') {
console.log(msg.content); // user's question
}
if (msg.role === 'assistant') {
console.log(msg.content); // full markdown answer
console.log(msg.blocks); // ContentBlock[] for interleaved rendering
console.log(msg.isError); // true if this is an error message
}
});
Key differences
No QA pairing. Messages are not grouped into question-answer pairs. They are a flat list of alternating user and assistant messages.
No message IDs. ConversationMessage objects do not have an id field. When rendering with .map(), use the array index as the React key. This is safe because messages are append-only (never reordered or removed mid-conversation). The SDK's built-in AgentChat component uses this pattern.
No status field. There is no streaming / pending_completion / completed status per message. Use isStreaming at the hook level plus the message's position (last message while streaming is the one being generated).
No uncertainty. The Agent SDK does not expose uncertainty detection. There is no equivalent to metadata.is_uncertain.
No reaction field. Since feedback is not supported, there is no reaction field on messages.
Rendering the assistant response
Before
const { conversation } = useChat();
const latest = conversation.getLatest();
// Render the markdown answer through your own parser
<MyMarkdownRenderer content={latest.answer} />
After: Option A. Flat markdown (closest to Chat SDK)
const { messages } = useAgentChat();
const lastAssistant = [...messages].reverse().find((m) => m.role === 'assistant');
// message.content is the full markdown text, same as qa.answer
<MyMarkdownRenderer content={lastAssistant?.content ?? ''} />
After: Option B. Block-based rendering (recommended)
The blocks array gives you interleaved text and tool call segments, which is how the agent naturally works (text, then tool calls, then more text, etc.):
{message.blocks.map((block, i) => {
if (block.type === 'text') {
return <MyMarkdownRenderer key={i} content={block.content} />;
}
if (block.type === 'tool_calls') {
return block.toolCalls.map((tc) => (
<MyToolCallCard key={tc.id} toolCall={tc} />
));
}
})}
Each text block's content is markdown you can pass through your own parser (including Latex rendering).
Sources
Before
const { conversation } = useChat();
const latest = conversation.getLatest();
// Sources are per-QA
latest.sources.forEach((s) => {
console.log(s.title, s.source_url, s.source_type);
});
After
Sources are per-tool-call, not per-message. They appear on the search_knowledge_base tool call results:
// Collect all sources from an assistant message
function getSourcesFromMessage(message: ConversationMessage): RelevantSource[] {
if (message.role !== 'assistant') return [];
return message.blocks
.filter((b) => b.type === 'tool_calls')
.flatMap((b) => b.toolCalls)
.flatMap((tc) => tc.sources ?? []);
}
The source type has slightly different field names:
Chat SDK (StreamSource) | Agent SDK (RelevantSource) |
|---|---|
title | title |
subtitle | (not present) |
source_url | sourceUrl |
source_type | sourceType |
Callbacks / Events
Before: callbacks prop
<KapaProvider
callbacks={{
askAI: {
onQuerySubmit: ({ question, conversation, threadId }) => {},
onAnswerGenerationCompleted: ({ questionAnswerId, question, answer, conversation, threadId }) => {},
onAnswerGenerationStop: ({ question, conversation, threadId }) => {},
onConversationReset: ({ conversation, threadId }) => {},
onFeedbackSubmit: ({ feedbackId, reaction, comment, questionAnswerId, question, answer }) => {},
},
}}
/>
After: onEvent prop
<AgentProvider
onEvent={(event) => {
switch (event.type) {
case 'message_sent':
// { messageLength: number }
break;
case 'response_completed':
// { threadId: string | null, toolCallCount: number }
break;
case 'response_error':
// { error: string, threadId: string | null }
break;
case 'generation_stopped':
// { threadId: string | null }
break;
case 'tool_executed':
// { toolName: string, status: 'completed' | 'error', durationMs: number, error?: string }
break;
case 'tool_approved':
// { toolName: string, toolCallId: string }
break;
case 'tool_denied':
// { toolName: string, toolCallId: string }
break;
case 'conversation_reset':
// {}
break;
}
}}
/>
Mapping
| Chat SDK callback | Agent SDK event | Notes |
|---|---|---|
onQuerySubmit | message_sent | Payload is simpler: just { messageLength }. No conversation snapshot. |
onAnswerGenerationCompleted | response_completed | Payload has { threadId, toolCallCount }. No question/answer text. Read from messages if needed. |
onAnswerGenerationStop | generation_stopped | Same concept. |
onConversationReset | conversation_reset | Same concept, empty payload. |
onFeedbackSubmit | Not available | Feedback is not supported. |
Theming
The Chat SDK has no built-in theming. The Agent SDK does.
<AgentProvider
theme={{
accentColor: '#2563eb', // hex color, auto-generates a 10-shade palette
colorScheme: 'dark', // 'dark' | 'light' | 'auto'
fontFamily: 'Inter, system-ui, sans-serif',
fontSize: 14, // base px, all sizes scale proportionally
radius: 'soft', // 'sharp' | 'soft' | 'round' | 'pill'
}}
/>
If building custom UI, access the color scheme via useAgentColorScheme() for dark/light switching. The SDK's built-in components consume the theme automatically.
Tool approval (new)
The Agent SDK supports human-in-the-loop approval for tools that have needsApproval: true. This is new, with no Chat SDK equivalent.
const { approveToolCall, rejectToolCall } = useAgentChat();
// In your tool call UI
<button onClick={() => approveToolCall(toolCall.id)}>Allow</button>
<button onClick={() => rejectToolCall(toolCall.id)}>Deny</button>
Tool calls with needsApproval will have status: 'approval_requested' until the user approves or denies them.
Features not available in the Agent SDK
| Feature | Chat SDK | Agent SDK |
|---|---|---|
| Feedback (thumbs up/down) | addFeedback() | Not supported, not planned |
| Uncertainty detection | metadata.is_uncertain | Not supported |
| Deep thinking mode | useDeepThinking() hook | Not available |
| Per-QA IDs on messages | qa.id (questionAnswerId) | Not surfaced |
| Separate streaming states | isPreparingAnswer + isGeneratingAnswer | Single isStreaming |
| Bot protection (captcha) | Built-in reCAPTCHA/hCaptcha | Not applicable (session token auth) |