Slots (Subcomponents)
Customize any part of the chat UI by overriding individual sub-components via slots.
/** * LangGraph TypeScript agent — CopilotKit showcase integration * * Defines a graph with a chat node and all showcase tools, * wired to CopilotKit via the sdk-js LangGraph adapter so frontend actions * and shared state flow seamlessly. */import { z } from "zod";import { RunnableConfig } from "@langchain/core/runnables";import { tool } from "@langchain/core/tools";import { ToolNode } from "@langchain/langgraph/prebuilt";import { AIMessage, SystemMessage } from "@langchain/core/messages";import { MemorySaver, START, StateGraph, Annotation,} from "@langchain/langgraph";import { ChatOpenAI } from "@langchain/openai";import { convertActionsToDynamicStructuredTools, CopilotKitStateAnnotation,} from "@copilotkit/sdk-js/langgraph";import { getWeatherImpl, queryDataImpl, manageSalesTodosImpl, getSalesTodosImpl, scheduleMeetingImpl, searchFlightsImpl, generateA2uiImpl, buildA2uiOperationsFromToolCall,} from "../../shared-tools";// ---------------------------------------------------------------------------// 1. Agent state — extends CopilotKit state with a proverbs list// ---------------------------------------------------------------------------const AgentStateAnnotation = Annotation.Root({ ...CopilotKitStateAnnotation.spec, proverbs: Annotation<string[]>,});export type AgentState = typeof AgentStateAnnotation.State;// ---------------------------------------------------------------------------// 2. Tools — shared implementations wrapped for LangChain// ---------------------------------------------------------------------------const getWeather = tool( async ({ location }) => JSON.stringify(getWeatherImpl(location)), { name: "get_weather", description: "Get current weather for a location", schema: z.object({ location: z.string().describe("City name"), }), },);const queryData = tool( async ({ query }) => JSON.stringify(queryDataImpl(query)), { name: "query_data", description: "Query financial database for chart data", schema: z.object({ query: z.string().describe("Natural language query"), }), },);const manageSalesTodos = tool( async ({ todos }) => JSON.stringify(manageSalesTodosImpl(todos)), { name: "manage_sales_todos", description: "Create or update the sales todo list", schema: z.object({ todos: z .array( z.object({ id: z.string().optional(), title: z.string(), stage: z.string().optional(), value: z.number().optional(), dueDate: z.string().optional(), assignee: z.string().optional(), completed: z.boolean().optional(), }), ) .describe("Array of sales todo items"), }), },);const getSalesTodos = tool( async ({ currentTodos }) => JSON.stringify(getSalesTodosImpl(currentTodos)), { name: "get_sales_todos", description: "Get the current sales todo list", schema: z.object({ currentTodos: z .array( z.object({ id: z.string().optional(), title: z.string().optional(), stage: z.string().optional(), value: z.number().optional(), dueDate: z.string().optional(), assignee: z.string().optional(), completed: z.boolean().optional(), }), ) .optional() .nullable() .describe("Current todos if any"), }), },);const scheduleMeeting = tool( async ({ reason, durationMinutes }) => JSON.stringify(scheduleMeetingImpl(reason, durationMinutes)), { name: "schedule_meeting", description: "Schedule a meeting (requires user approval via HITL)", schema: z.object({ reason: z.string().describe("Reason for the meeting"), durationMinutes: z.number().optional().describe("Duration in minutes"), }), },);const searchFlights = tool( async ({ flights }) => JSON.stringify(searchFlightsImpl(flights)), { name: "search_flights", description: "Search for available flights", schema: z.object({ flights: z .array( z.object({ airline: z.string(), airlineLogo: z.string().optional(), flightNumber: z.string(), origin: z.string(), destination: z.string(), date: z.string(), departureTime: z.string(), arrivalTime: z.string(), duration: z.string(), status: z.string(), statusColor: z.string().optional(), price: z.string(), currency: z.string().optional(), }), ) .describe("Array of flight results"), }), },);const generateA2ui = tool( async ({ messages, contextEntries }) => { const prep = generateA2uiImpl({ messages, contextEntries }); const secondaryModel = new ChatOpenAI({ temperature: 0, model: "gpt-4.1" }); const renderTool = tool(async () => "rendered", { name: "render_a2ui", description: "Render a dynamic A2UI v0.9 surface.", schema: z.object({ surfaceId: z.string().describe("Unique surface identifier."), catalogId: z.string().describe("The catalog ID."), components: z .array(z.record(z.unknown())) .describe("A2UI v0.9 component array."), data: z .record(z.unknown()) .optional() .describe("Optional initial data model."), }), }); const modelWithTool = secondaryModel.bindTools!([renderTool], { tool_choice: { type: "function", function: { name: "render_a2ui" } }, }); const response = await modelWithTool.invoke([ new SystemMessage({ content: prep.systemPrompt }), ...prep.messages.map((m) => m as any), ]); const aiMsg = response as AIMessage; if (!aiMsg.tool_calls?.length) { return JSON.stringify({ error: "LLM did not call render_a2ui" }); } const args = aiMsg.tool_calls[0].args as Record<string, unknown>; return JSON.stringify(buildA2uiOperationsFromToolCall(args)); }, { name: "generate_a2ui", description: "Generate dynamic A2UI surface components", schema: z.object({ messages: z.array(z.record(z.unknown())).describe("Chat messages"), contextEntries: z .array(z.record(z.unknown())) .optional() .describe("Context entries"), }), },);const tools = [ getWeather, queryData, manageSalesTodos, getSalesTodos, scheduleMeeting, searchFlights, generateA2ui,];// ---------------------------------------------------------------------------// 3. Chat node — binds backend + frontend tools, invokes the model// ---------------------------------------------------------------------------async function chatNode(state: AgentState, config: RunnableConfig) { const model = new ChatOpenAI({ temperature: 0, model: "gpt-4o" }); const modelWithTools = model.bindTools!([ ...convertActionsToDynamicStructuredTools(state.copilotkit?.actions ?? []), ...tools, ]); const systemMessage = new SystemMessage({ content: `You are a helpful assistant. The current proverbs are ${JSON.stringify(state.proverbs)}.`, }); const response = await modelWithTools.invoke( [systemMessage, ...state.messages], config, ); return { messages: response };}// ---------------------------------------------------------------------------// 4. Routing — send tool calls to tool_node unless they're CopilotKit actions// ---------------------------------------------------------------------------function shouldContinue({ messages, copilotkit }: AgentState) { const lastMessage = messages[messages.length - 1] as AIMessage; if (lastMessage.tool_calls?.length) { const actions = copilotkit?.actions; const toolCallName = lastMessage.tool_calls![0].name; if (!actions || actions.every((action) => action.name !== toolCallName)) { return "tool_node"; } } return "__end__";}// ---------------------------------------------------------------------------// 5. Compile the graph// ---------------------------------------------------------------------------const workflow = new StateGraph(AgentStateAnnotation) .addNode("chat_node", chatNode) .addNode("tool_node", new ToolNode(tools)) .addEdge(START, "chat_node") .addEdge("tool_node", "chat_node") .addConditionalEdges("chat_node", shouldContinue as any);const memory = new MemorySaver();export const graph = workflow.compile({ checkpointer: memory,});What is this?#
Every CopilotKit chat component is built from composable slots, named sub-components you can override individually. The slot system gives you three levels of customization without needing to rebuild the entire UI:
- Tailwind classes — pass a string to add/override CSS classes
- Props override — pass an object to override specific props on the default component
- Custom component — pass your own React component to fully replace a slot
Slots are recursive: you can drill into nested sub-components at any depth.
What it looks like in code#
The chat-slots cell above overrides three slots on a single <CopilotChat> —
the welcome screen, the assistant message card, and the input's disclaimer.
Each slot is just a prop; the demo extracts them into locals so the override
points are easy to see.
Welcome screen slot#
The welcomeScreen prop replaces the empty-state view shown before the first
message is sent. The demo swaps in a gradient card that still renders the
default input and suggestions:
import type { CopilotChatAssistantMessage, CopilotChatInput, CopilotChatView,} from "@copilotkit/react-core/v2";declare const CustomWelcomeScreen: React.ComponentType;declare const CustomAssistantMessage: React.ComponentType;declare const CustomDisclaimer: React.ComponentType;export function ChatSlotsTeachingExtracts() { const welcomeScreen = CustomWelcomeScreen as unknown as typeof CopilotChatView.WelcomeScreen;Assistant message slot#
Drill into messageView={{ assistantMessage: ... }} to wrap every assistant
response. The cell wraps the default component with a tinted card and a small
"slot" badge so you can see the override is active during the message flow:
import type { CopilotChatAssistantMessage, CopilotChatInput, CopilotChatView,} from "@copilotkit/react-core/v2";declare const CustomWelcomeScreen: React.ComponentType;declare const CustomAssistantMessage: React.ComponentType;declare const CustomDisclaimer: React.ComponentType;export function ChatSlotsTeachingExtracts() { const welcomeScreen = CustomWelcomeScreen as unknown as typeof CopilotChatView.WelcomeScreen; const messageView = { assistantMessage: CustomAssistantMessage as unknown as typeof CopilotChatAssistantMessage, };Disclaimer slot#
The input={{ disclaimer: ... }} sub-slot lets you replace the small text
shown below the input. The demo uses it to display a visibly tagged disclaimer
so reviewers can tell the override is still in effect once the welcome screen
is gone:
import type { CopilotChatAssistantMessage, CopilotChatInput, CopilotChatView,} from "@copilotkit/react-core/v2";declare const CustomWelcomeScreen: React.ComponentType;declare const CustomAssistantMessage: React.ComponentType;declare const CustomDisclaimer: React.ComponentType;export function ChatSlotsTeachingExtracts() { const welcomeScreen = CustomWelcomeScreen as unknown as typeof CopilotChatView.WelcomeScreen; const messageView = { assistantMessage: CustomAssistantMessage as unknown as typeof CopilotChatAssistantMessage, }; const input = { disclaimer: CustomDisclaimer as unknown as typeof CopilotChatInput.Disclaimer, };Tailwind Classes#
The simplest way to customize a slot. Pass a Tailwind class string and it will be merged with the default component's classes.
import { CopilotChat } from "@copilotkit/react-core/v2";
export function Chat() {
return (
<CopilotChat
messageView="bg-gray-50 dark:bg-gray-900 p-4"
input="border-2 border-blue-400 rounded-xl"
/>
);
}Props Override#
Pass an object to override specific props on the default component. This is useful for adding className, event handlers, data attributes, or any other prop the default component accepts.
<CopilotChat
messageView={{
className: "my-custom-messages",
"data-testid": "message-view",
}}
input={{ autoFocus: true }}
/>Custom Components#
For full control, pass your own React component. It receives all the same props as the default component.
import { CopilotChat } from "@copilotkit/react-core/v2";
const CustomMessageView = ({ messages, isRunning }) => (
<div className="space-y-4 p-6">
{messages?.map((msg) => (
<div key={msg.id} className={msg.role === "user" ? "text-right" : "text-left"}>
{msg.content}
</div>
))}
{isRunning && <div className="animate-pulse">Thinking...</div>}
</div>
);
export function Chat() {
return <CopilotChat messageView={CustomMessageView} />;
}Nested Slots (Drill-Down)#
Slots are recursive. You can customize sub-components at any depth by nesting objects.
Two levels deep#
Override the assistant message's toolbar within the message view:
<CopilotChat
messageView={{
assistantMessage: {
toolbar: CustomToolbar,
copyButton: CustomCopyButton,
},
userMessage: CustomUserMessage,
}}
/>Three levels deep#
Override a specific button inside the assistant message toolbar:
<CopilotChat
messageView={{
assistantMessage: {
copyButton: ({ onClick }) => (
<button onClick={onClick}>Copy</button>
),
},
}}
/>Labels#
Customize any text string in the UI via the labels prop. This is a separate convenience prop on CopilotChat, CopilotSidebar, and CopilotPopup, not part of the slot system.
<CopilotChat
labels={{
chatInputPlaceholder: "Ask your agent anything...",
welcomeMessageText: "How can I help you today?",
chatDisclaimerText: "AI responses may be inaccurate.",
}}
/>Available Slots#
CopilotChat / CopilotSidebar / CopilotPopup#
These are the root-level slot props available on all chat components:
| Slot | Description |
|---|---|
messageView | The message list container. |
scrollView | The scroll container with auto-scroll behavior. |
input | The text input area with send/transcribe controls. |
suggestionView | The suggestion pills shown below messages. |
welcomeScreen | The initial empty-state screen (pass false to disable). |
CopilotSidebar and CopilotPopup also have:
| Slot | Description |
|---|---|
header | The modal header bar. |
toggleButton | The open/close toggle button. |
