Tool Call Rendering
Render your agent's tool calls with custom UI components.
"""LangGraph agent for the CopilotKit Tool Rendering demos.Backs the three tool-rendering cells: - tool-rendering-default-catchall (no frontend renderers) - tool-rendering-custom-catchall (wildcard renderer on frontend) - tool-rendering (per-tool + catch-all on frontend) - tool-rendering-reasoning-chain (testing — also streams reasoning)All cells share this backend — they differ only in how the frontendrenders the same tool calls. Kept separate from `agent.py` so thetool-rendering demo has a tightly-scoped tool set."""from random import choice, randintfrom langchain.agents import create_agentfrom langchain.tools import toolfrom langchain_openai import ChatOpenAIfrom copilotkit import CopilotKitMiddleware# Multi-tool chaining prompt.## The goal of this demo is to surface MULTIPLE tool-call cards per turn so# the rendering patterns (per-tool + catch-all) get exercised visibly. The# prompt nudges the model toward an explore-then-enrich pattern (e.g.# `get_weather("Tokyo")` -> `search_flights(..., "Tokyo")`) without forcing# a rigid recipe: we describe the *habit*, not a chain.SYSTEM_PROMPT = ( "You are a helpful travel & lifestyle concierge. You have mock tools " "for weather, flights, stock prices, and dice rolls - they all return " "fake data, so call them liberally.\n\n" "Your habit is to CHAIN tools when one answer naturally invites another. " "For a single user question, call at least TWO tools in succession when " "the topic allows before composing your final reply. Examples of " "helpful chains you should default to:\n" " - 'What's the weather in Tokyo?' -> call get_weather('Tokyo'), then " "call search_flights(origin='SFO', destination='Tokyo') so the user " "also sees how to get there.\n" " - 'How is AAPL doing?' -> call get_stock_price('AAPL'), then call " "get_stock_price on a related ticker (e.g. 'MSFT' or 'GOOGL') for " "comparison.\n" " - 'Roll a d20' -> call roll_dice(20), then call roll_dice again with " "a different number of sides so the user sees a contrast.\n" " - 'Find flights from SFO to JFK' -> call search_flights, then call " "get_weather on the destination city.\n\n" "Only skip chaining when the user has clearly asked for a single, " "atomic answer and more tool calls would feel intrusive. Never " "fabricate data that a tool could provide.")@tooldef get_weather(location: str) -> dict: """Get the current weather for a given location. Useful on its own for weather questions, and a great companion to `search_flights` - always consider checking the weather at a destination the user is flying to, and checking flights to any city whose weather the user has just asked about. """ return { "city": location, "temperature": 68, "humidity": 55, "wind_speed": 10, "conditions": "Sunny", }@tooldef search_flights(origin: str, destination: str) -> dict: """Search mock flights from an origin airport to a destination airport. Pairs naturally with `get_weather`: after searching flights, check the weather at the destination so the user can plan. When the user mentions a city without a matching origin, default the origin to 'SFO'. """ return { "origin": origin, "destination": destination, "flights": [ { "airline": "United", "flight": "UA231", "depart": "08:15", "arrive": "16:45", "price_usd": 348, }, { "airline": "Delta", "flight": "DL412", "depart": "11:20", "arrive": "19:55", "price_usd": 312, }, { "airline": "JetBlue", "flight": "B6722", "depart": "17:05", "arrive": "01:30", "price_usd": 289, }, ], }@tooldef get_stock_price(ticker: str) -> dict: """Get a mock current price for a stock ticker. When the user asks about a single ticker, consider also pulling a related ticker for context (e.g. if they ask about 'AAPL', also fetch 'MSFT' or 'GOOGL' so the reply can compare). """ return { "ticker": ticker.upper(), "price_usd": round(100 + randint(0, 400) + randint(0, 99) / 100, 2), "change_pct": round(choice([-1, 1]) * (randint(0, 300) / 100), 2), }@tooldef roll_dice(sides: int = 6) -> dict: """Roll a single die with the given number of sides. When the user asks for a roll, consider rolling twice with different numbers of sides so the reply can show a contrast (e.g. a d6 AND a d20). """ return {"sides": sides, "result": randint(1, max(2, sides))}model = ChatOpenAI(model="gpt-4o-mini")graph = create_agent( model=model, tools=[get_weather, search_flights, get_stock_price, roll_dice], middleware=[CopilotKitMiddleware()], system_prompt=SYSTEM_PROMPT,)What is this?#
Tools are how an LLM invokes predefined, typically-deterministic functions. Tool rendering lets you decide how each of those tool calls appears in the chat. Instead of showing raw JSON, you register a React component that draws a branded card for the call (arguments, live status, and the eventual result). This is the Generative UI variant CopilotKit calls tool rendering.
Free course: See this pattern built end-to-end in Build Interactive Agents with Generative UI — a free DeepLearning.AI short course taught by CopilotKit's CEO covering the full Generative UI spectrum (Controlled, Declarative, and Open-Ended).
When should I use this?#
Render tool calls when you want to:
- Show users exactly what tools the agent is invoking and with what arguments
- Display live progress indicators while a tool executes
- Render rich, polished results once a tool completes
- Give tool-heavy agents a transparent, on-brand chat experience
Default tool rendering (zero-config)#
First, add CopilotKitMiddleware to your create_agent call. The middleware
is what makes every CopilotKit feature on the frontend — frontend tools,
shared state, agent context, and generative UI components — visible to your
LangGraph agent on every turn.
from langchain.agents import create_agent
from langchain_openai import ChatOpenAI
from copilotkit import CopilotKitMiddleware
graph = create_agent(
model=ChatOpenAI(model="gpt-5.4"),
tools=[],
middleware=[CopilotKitMiddleware()],
system_prompt="You are a helpful, concise assistant.",
)Install the SDK
If copilotkit isn't already in your project, add it so the import
above resolves. Pick the package manager that matches your project:
uv add copilotkitpoetry add copilotkitpip install copilotkit --extra-index-url https://copilotkit.gateway.scarf.sh/simple/conda install copilotkit -c copilotkit-channelThe simplest entry point: call useDefaultRenderTool() with no arguments.
CopilotKit registers its built-in DefaultToolCallRenderer as the *
wildcard: every tool call renders as a tidy status card (tool name, live
Running → Done pill, collapsible arguments/result) without you writing
any UI.
Without this hook the runtime has no * renderer and tool calls are
invisible; the user only sees the assistant's final text summary.
// Opt in to CopilotKit's built-in default tool-call card. Called with // no config so the package-provided `DefaultToolCallRenderer` is used // as the wildcard renderer — this is the "out-of-the-box" UI the cell // is meant to showcase. useDefaultRenderTool();Here's what the built-in status card looks like for each tool call:
"use client";// Tool Rendering — DEFAULT CATCH-ALL variant (simplest).//// This cell is the simplest point in the three-way progression. The// backend exposes a handful of mock tools (get_weather, search_flights,// get_stock_price, roll_dice) and the frontend ONLY opts into// CopilotKit's built-in default tool-call card — no per-tool renderers,// no custom wildcard UI.//// `useDefaultRenderTool()` (called with no config) registers the built-// in `DefaultToolCallRenderer` under the `*` wildcard. That renderer// shows the tool name, a live status pill (Running → Done), and a// collapsible "Arguments / Result" section that fills in as the call// progresses. Without this hook the runtime has NO `*` renderer, so// `useRenderToolCall` falls through to `null` and tool calls are// invisible — the user only sees the assistant's final text summary.import React from "react";import { CopilotKit, CopilotChat, useDefaultRenderTool,} from "@copilotkit/react-core/v2";import { useSuggestions } from "./suggestions";export default function ToolRenderingDefaultCatchallDemo() { return ( <CopilotKit runtimeUrl="/api/copilotkit" agent="tool-rendering-default-catchall" > <div className="flex justify-center items-center h-screen w-full"> <div className="h-full w-full max-w-4xl"> <Chat /> </div> </div> </CopilotKit> );}function Chat() { // Opt in to CopilotKit's built-in default tool-call card. Called with // no config so the package-provided `DefaultToolCallRenderer` is used // as the wildcard renderer — this is the "out-of-the-box" UI the cell // is meant to showcase. useDefaultRenderTool(); useSuggestions(); return ( <CopilotChat agentId="tool-rendering-default-catchall" className="h-full rounded-2xl" /> );}Custom catch-all#
Once you want on-brand chrome, pass a render function to
useDefaultRenderTool. It's a convenience wrapper around
useRenderTool({ name: "*", ... }): one wildcard renderer handles every
tool call, named or not:
// `useDefaultRenderTool` is a convenience wrapper around // `useRenderTool({ name: "*", ... })` — a single wildcard renderer // that handles every tool call not claimed by a named renderer. useDefaultRenderTool( { render: ({ name, parameters, status, result }) => ( <CustomCatchallRenderer name={name} parameters={parameters} status={status as CatchallToolStatus} result={result} /> ), }, [], );Here's the branded catch-all in action, where every tool call gets the same on-brand card:
"use client";// Tool Rendering — CUSTOM CATCH-ALL variant (middle of the progression).//// Same backend tools as `tool-rendering-default-catchall`, but this// cell opts out of CopilotKit's built-in default tool-call UI by// registering a SINGLE custom wildcard renderer via// `useDefaultRenderTool`. The same branded card now paints every tool// call — no per-tool renderers yet.import React from "react";import { CopilotKit, CopilotChat, useDefaultRenderTool,} from "@copilotkit/react-core/v2";import { CustomCatchallRenderer, type CatchallToolStatus,} from "./custom-catchall-renderer";import { useSuggestions } from "./suggestions";export default function ToolRenderingCustomCatchallDemo() { return ( <CopilotKit runtimeUrl="/api/copilotkit" agent="tool-rendering-custom-catchall" > <div className="flex justify-center items-center h-screen w-full"> <div className="h-full w-full max-w-4xl"> <Chat /> </div> </div> </CopilotKit> );}function Chat() { // `useDefaultRenderTool` is a convenience wrapper around // `useRenderTool({ name: "*", ... })` — a single wildcard renderer // that handles every tool call not claimed by a named renderer. useDefaultRenderTool( { render: ({ name, parameters, status, result }) => ( <CustomCatchallRenderer name={name} parameters={parameters} status={status as CatchallToolStatus} result={result} /> ), }, [], ); useSuggestions(); return ( <CopilotChat agentId="tool-rendering-custom-catchall" className="h-full rounded-2xl" /> );}Per-tool renderers#
The most expressive path is one renderer per tool name. The primary
tool-rendering cell wires two: get_weather draws a branded
WeatherCard, search_flights draws a FlightListCard. Each renderer
receives the tool's parsed arguments, a live status, and (once the agent
returns) the result:
import React from "react";import { CopilotKit, CopilotChat, useRenderTool, useDefaultRenderTool,} from "@copilotkit/react-core/v2";import { z } from "zod";import { WeatherCard } from "./weather-card";import { FlightListCard, type Flight } from "./flight-list-card";import { StockCard } from "./stock-card";import { D20Card } from "./d20-card";import { CustomCatchallRenderer, type CatchallToolStatus,} from "./custom-catchall-renderer";import { parseJsonResult } from "../_shared/parse-json-result";import { useSuggestions } from "./suggestions";interface WeatherResult { city?: string; temperature?: number; humidity?: number; wind_speed?: number; conditions?: string;}interface FlightSearchResult { origin?: string; destination?: string; flights?: Flight[];}interface StockResult { ticker?: string; price_usd?: number; change_pct?: number;}interface D20Result { value?: number; result?: number; sides?: number;}export default function ToolRenderingDemo() { return ( <CopilotKit runtimeUrl="/api/copilotkit" agent="tool-rendering"> <div className="flex justify-center items-center h-screen w-full"> <div className="h-full w-full max-w-4xl"> <Chat /> </div> </div> </CopilotKit> );}function Chat() { // Per-tool renderer #1: get_weather → branded WeatherCard. useRenderTool( { name: "get_weather", parameters: z.object({ location: z.string(), }), render: ({ parameters, result, status }) => { const loading = status !== "complete"; const parsed = parseJsonResult<WeatherResult>(result); return ( <WeatherCard loading={loading} location={parameters?.location ?? parsed.city ?? ""} temperature={parsed.temperature} humidity={parsed.humidity} windSpeed={parsed.wind_speed} conditions={parsed.conditions} /> ); }, }, [], );The flight renderer follows the same pattern with a different component and schema:
import React from "react";import { CopilotKit, CopilotChat, useRenderTool, useDefaultRenderTool,} from "@copilotkit/react-core/v2";import { z } from "zod";import { WeatherCard } from "./weather-card";import { FlightListCard, type Flight } from "./flight-list-card";import { StockCard } from "./stock-card";import { D20Card } from "./d20-card";import { CustomCatchallRenderer, type CatchallToolStatus,} from "./custom-catchall-renderer";import { parseJsonResult } from "../_shared/parse-json-result";import { useSuggestions } from "./suggestions";interface WeatherResult { city?: string; temperature?: number; humidity?: number; wind_speed?: number; conditions?: string;}interface FlightSearchResult { origin?: string; destination?: string; flights?: Flight[];}interface StockResult { ticker?: string; price_usd?: number; change_pct?: number;}interface D20Result { value?: number; result?: number; sides?: number;}export default function ToolRenderingDemo() { return ( <CopilotKit runtimeUrl="/api/copilotkit" agent="tool-rendering"> <div className="flex justify-center items-center h-screen w-full"> <div className="h-full w-full max-w-4xl"> <Chat /> </div> </div> </CopilotKit> );}function Chat() { // Per-tool renderer #1: get_weather → branded WeatherCard. useRenderTool( { name: "get_weather", parameters: z.object({ location: z.string(), }), render: ({ parameters, result, status }) => { const loading = status !== "complete"; const parsed = parseJsonResult<WeatherResult>(result); return ( <WeatherCard loading={loading} location={parameters?.location ?? parsed.city ?? ""} temperature={parsed.temperature} humidity={parsed.humidity} windSpeed={parsed.wind_speed} conditions={parsed.conditions} /> ); }, }, [], ); // Per-tool renderer #2: search_flights → branded FlightListCard. useRenderTool( { name: "search_flights", parameters: z.object({ origin: z.string(), destination: z.string(), }), render: ({ parameters, result, status }) => { const loading = status !== "complete"; const parsed = parseJsonResult<FlightSearchResult>(result); return ( <FlightListCard loading={loading} origin={parameters?.origin ?? parsed.origin ?? ""} destination={parameters?.destination ?? parsed.destination ?? ""} flights={parsed.flights ?? []} /> ); }, }, [], );The name you pass to useRenderTool must match the tool name the agent
exposes; that's how the runtime routes the call to your component.
Per-tool renderers compose with a catch-all: named renderers claim the
"interesting" tools and a wildcard handles everything else. In the primary
cell, the same CustomCatchallRenderer from above catches get_stock_price
and roll_dice:
// Wildcard catch-all for anything that doesn't match a per-tool // renderer above. useDefaultRenderTool( { render: ({ name, parameters, status, result }) => ( <CustomCatchallRenderer name={name} parameters={parameters} status={status as CatchallToolStatus} result={result} /> ), }, [], );The backend tool definition#
The frontend renderer only sees what the agent sends down. Here's the
matching Python definition for get_weather, a standard LangChain tool,
no CopilotKit-specific plumbing required:
from random import choice, randintfrom langchain.agents import create_agentfrom langchain.tools import toolfrom langchain_openai import ChatOpenAIfrom copilotkit import CopilotKitMiddleware# Multi-tool chaining prompt.## The goal of this demo is to surface MULTIPLE tool-call cards per turn so# the rendering patterns (per-tool + catch-all) get exercised visibly. The# prompt nudges the model toward an explore-then-enrich pattern (e.g.# `get_weather("Tokyo")` -> `search_flights(..., "Tokyo")`) without forcing# a rigid recipe: we describe the *habit*, not a chain.SYSTEM_PROMPT = ( "You are a helpful travel & lifestyle concierge. You have mock tools " "for weather, flights, stock prices, and dice rolls - they all return " "fake data, so call them liberally.\n\n" "Your habit is to CHAIN tools when one answer naturally invites another. " "For a single user question, call at least TWO tools in succession when " "the topic allows before composing your final reply. Examples of " "helpful chains you should default to:\n" " - 'What's the weather in Tokyo?' -> call get_weather('Tokyo'), then " "call search_flights(origin='SFO', destination='Tokyo') so the user " "also sees how to get there.\n" " - 'How is AAPL doing?' -> call get_stock_price('AAPL'), then call " "get_stock_price on a related ticker (e.g. 'MSFT' or 'GOOGL') for " "comparison.\n" " - 'Roll a d20' -> call roll_dice(20), then call roll_dice again with " "a different number of sides so the user sees a contrast.\n" " - 'Find flights from SFO to JFK' -> call search_flights, then call " "get_weather on the destination city.\n\n" "Only skip chaining when the user has clearly asked for a single, " "atomic answer and more tool calls would feel intrusive. Never " "fabricate data that a tool could provide.")@tooldef get_weather(location: str) -> dict: """Get the current weather for a given location. Useful on its own for weather questions, and a great companion to `search_flights` - always consider checking the weather at a destination the user is flying to, and checking flights to any city whose weather the user has just asked about. """ return { "city": location, "temperature": 68, "humidity": 55, "wind_speed": 10, "conditions": "Sunny", }