useRenderTool
Register a React Native renderer for a tool call, by tool name or wildcard.
Overview
useRenderTool registers a renderer only. It supplies UI for a tool call
somebody else owns โ a server-side tool, or, with name: "*", every tool call
that has no renderer of its own. It does not register a tool: nothing is
advertised to the model and nothing becomes callable.
That contract is react-core's, so this page and the React (V2)
useRenderTool describe the same behaviour;
only the import path differs.
Registering a tool is a different hook
If you want the agent to be able to call your function and draw its UI, that
is useFrontendTool โ it
accepts description, handler and render, and it advertises the tool to
the model on every run. A renderer-only registration does neither: nothing
offered to the model, nothing callable.
On React Native this name is a deprecated shim
React Native used to export a local hook under this name whose whole body
forwarded to useFrontendTool, so it shipped one capability under the other's
name. @copilotkit/react-native now exports useRenderTool as a temporary
compatibility shim over react-core's two render-tool hooks โ deprecated, and
scheduled for removal in the next minor (#6976).
Your existing call still works. It is routed by shape, and warns in development when it carries a field only the old hook accepted:
name: "*"always registers a renderer-only fallback, whatever else the config carries. The old fields are ignored, and warned about.- otherwise, a config carrying
descriptionorhandleris routed touseFrontendToolโ a tool and its renderer, which is what the old hook did โ and warned about. - otherwise, react-core's
useRenderTool, silently: that is the call this page documents.
See Migrating from the old React Native
useRenderTool for the
warnings, the two things that still fail to compile, and what to change the
call to.
useRenderToolCall is exported on React Native โ import it from
@copilotkit/react-native to render a registered tool call on any surface,
not just inside the chat (see Rendering a tool call outside the
chat). The remaining web rendering
hooks (useDefaultRenderTool, useRenderActivityMessage,
useRenderCustomMessages) are not exported on React Native, because they
render host DOM or link the web chat-message stack.
Signature
There are two overloads: a wildcard fallback that takes no schema, and a name-scoped renderer that requires one.
Wildcard overload
import { useRenderTool } from "@copilotkit/react-native";
useRenderTool(
{
name: "*",
render: (props: any) => React.ReactElement | null,
agentId?: string,
},
deps?: ReadonlyArray<unknown>,
);Named overload
import { useRenderTool } from "@copilotkit/react-native";
import { z } from "zod";
useRenderTool<S extends StandardSchemaV1>(
{
name: string,
parameters: S,
render: (props: RenderToolProps<S>) => React.ReactElement | null,
agentId?: string,
},
deps?: ReadonlyArray<unknown>,
);S is the schema type, not the parsed argument object โ the shape of
props.parameters inside render is inferred from it.
Both overloads additionally accept description and handler for as long as the
shim is in place. Both are @deprecated, both route the call to
useFrontendTool on the named overload, both are ignored on the wildcard, and
supplying either warns in development. They are not part of the contract this
page documents โ see Migrating from the old React Native
useRenderTool.
Parameters
Prop
Type
Prop
Type
RenderToolProps<S>
Props passed to your render function on the named overload.
@copilotkit/react-native re-exports this type from react-core, so React Native
and web cannot drift apart:
import type { RenderToolProps } from "@copilotkit/react-native";It is a three-arm union discriminated on status. parameters is
Partial<InferSchemaOutput<S>> on the in-progress arm and the full
InferSchemaOutput<S> on the executing and complete arms; result is a
string only on the complete arm. Narrow on status before reading
parameters fields or result.
S has no default, so the type argument is required: a bare
RenderToolProps is TS2314: Generic type 'RenderToolProps' requires 1 type argument(s). Pass the schema's type โ RenderToolProps<typeof mySchema> โ or,
for a renderer with no schema of its own, RenderToolProps<z.ZodTypeAny>.
Prop
Type
Prop
Type
Prop
Type
Prop
Type
Prop
Type
RenderToolInProgressProps, RenderToolExecutingProps, RenderToolCompleteProps
@copilotkit/react-native re-exports these three types from react-core. They are
the arms of the RenderToolProps<S> union above, so you can name a single
state directly:
import type { RenderToolCompleteProps } from "@copilotkit/react-native";
import { Text } from "react-native";
import { z } from "zod";
const schema = z.object({ city: z.string() });
function WeatherResult(props: RenderToolCompleteProps<typeof schema>) {
// `props.parameters.city` is a `string`, and `props.result` is a `string`.
return <Text>{props.parameters.city}: {props.result}</Text>;
}Each is generic over the schema (S extends StandardSchemaV1), matching
RenderToolProps<S>.
Usage
import { useRenderTool } from "@copilotkit/react-native";
import { CopilotChat } from "@copilotkit/react-native/components";
import { ActivityIndicator, Text, View } from "react-native";
import { z } from "zod";
function ChatScreen() {
useRenderTool(
{
// A tool your agent executes server-side; this only draws it.
name: "showWeather",
parameters: z.object({
city: z.string(),
temp: z.number(),
condition: z.string(),
}),
render: ({ status, parameters }) => {
if (status === "inProgress") return <ActivityIndicator />;
return (
<View
style={{ padding: 12, backgroundColor: "#f0f0f0", borderRadius: 8 }}
>
<Text style={{ fontWeight: "bold" }}>{parameters.city}</Text>
<Text>
{parameters.temp}ยฐC ยท {parameters.condition}
</Text>
</View>
);
},
},
[],
);
return <CopilotChat agentName="default" />;
}Import CopilotChat from the /components subpath. That is the prebuilt UI
that reads the render registry and paints tool calls inline; the CopilotChat
exported from the @copilotkit/react-native root is
headless and renders no message
list, so a registered render would never appear.
Wildcard fallback
A "*" entry draws any tool call that has no exact-name renderer. It takes no
schema, so its render props are untyped:
useRenderTool(
{
name: "*",
render: ({ name, status }) => (
<Text>
{status === "complete" ? "โ" : "โณ"} {name}
</Text>
),
},
[],
);Returning null from a renderer draws nothing, so render: () => null is how
you silence a tool call โ one named tool, or, with "*", every tool that has no
renderer of its own.
Behavior
- Renderer only. Nothing is added to the tool list the runtime hands the agent, and the model cannot call a name registered here.
renderreturnsReactElement | null, notReactNode. React Native'sFlatListcannot render bare strings or portals, and this signature enforces it.- Deduplicated by
agentId:nameโ the latest registration under a key wins. - Arguments stream. While the agent is still writing the call,
statusis"inProgress"andparametersis partial โ fields arrive progressively. Write renderers that tolerate missing fields; that is what lets UI build as the agent writes it. renderis captured at registration. It is not refreshed on every render โ only when the renderer re-registers, which happens whennamechanges or whendepscompare as changed. If yourrendercloses over component state or props that change over time, list a JSON-comparable form of them indeps, or read them through a ref (see below); otherwise the chat keeps invoking the stale closure and paints outdated UI.- No cleanup on unmount. The renderer entry is deliberately kept, so tool calls already in the chat history still render after you navigate away.
Values deps cannot see
The hook decides whether to re-register by comparing
JSON.stringify(deps)
against the previous render's. Serialization, not reference identity, is the
comparison โ which has consequences worth knowing before you reach for deps:
- Non-serializable deps are inert. Inside the array, a function or symbol serializes to
null, and aMap, aSet, or a class instance keeping its state in private fields or getters serializes to{}โ the same string on every render, forever. Listing a callback or aMapindepstype-checks, reads like a fix, and re-registers nothing. - Circular values throw.
JSON.stringifyraises aTypeErrorwhile the hook renders, so a dep with a cycle in it crashes the screen instead of failing quietly. - Key order counts. Two plain objects holding the same entries in a different insertion order serialize differently and do re-register, even though nothing meaningful changed.
For a value JSON cannot compare, do not put it in deps โ it will not work.
Either derive a primitive that tracks the change ([selection.size] rather than
[selection]), or keep the value in a ref that render dereferences when it
runs:
function SeatPicker({ onSelect }: { onSelect: (id: string) => void }) {
// Reassigned on every render; the captured `render` reads it at call time.
const onSelectRef = useRef(onSelect);
onSelectRef.current = onSelect;
useRenderTool(
{
name: "pickSeat",
parameters: z.object({ seats: z.array(z.string()) }),
render: ({ parameters }) => (
<SeatGrid
seats={parameters.seats ?? []}
onSelect={(id) => onSelectRef.current(id)}
/>
),
},
[],
);
}The captured render is still the stale one, but the ref it reads is current, so
the renderer never has to re-register to reach the newest callback. That makes the
ref pattern the more reliable default whenever what changes is behavior rather
than displayed data.
Rendering a tool call outside the chat
CopilotChat renders tool calls inline. To render a registered component anywhere else โ a dashboard, a kiosk, a full-screen stage the agent composes โ call useRenderToolCall() inside a component mounted under CopilotKitProvider, and read the tool calls off the agent's own message list:
import { useAgent, useRenderToolCall } from "@copilotkit/react-native";
import { View } from "react-native";
function ToolCallStage({ agentId = "default" }: { agentId?: string }) {
const { agent } = useAgent({ agentId });
const renderToolCall = useRenderToolCall();
const messages = agent.messages ?? [];
// Pair each call with its result message โ the same correlation the prebuilt
// chat does. `toolMessage` is what selects the complete arm: pass it and the
// call resolves to `"complete"` with `result`. Without one, `result` is
// `undefined` and the status comes from the provider instead โ `"executing"`
// while this call id is one the provider is tracking as executing, and
// `"inProgress"` otherwise.
const toolMessages = new Map(
messages.flatMap((message) =>
message.role === "tool" ? [[message.toolCallId, message] as const] : [],
),
);
const toolCalls = messages.flatMap((message) =>
message.role === "assistant" ? (message.toolCalls ?? []) : [],
);
// Each returned element is already keyed by tool-call id.
return (
<View>
{toolCalls.map((toolCall) =>
renderToolCall({
toolCall,
toolMessage: toolMessages.get(toolCall.id),
}),
)}
</View>
);
}Prop
Type
Migrating from the old React Native useRenderTool
React Native used to export a local hook under this name. It was not
react-core's useRenderTool: its whole body forwarded to react-core's other
hook, useFrontendTool, so it registered a tool and a renderer while
carrying the renderer-only hook's name.
That hook is now a deprecated compatibility shim rather than a removed API.
@copilotkit/react-native still exports useRenderTool; it owns no render-tool
registry of its own, and every path through the shim delegates to one of
react-core's two hooks. Your existing call still works this release. The shim
is scheduled for removal in the next minor, so this is a migration you have
one release to do rather than one you must finish before upgrading.
What your existing call does this release
The shim routes by the shape of the config, and the route is fixed at the component's first render:
| Your call | Routed to | Warns in development | Registers in |
|---|---|---|---|
name: "*", whatever else the config carries | react-core's useRenderTool โ a renderer-only fallback | yes (and names description, handler or parameters if present โ all three are ignored on this path) | useEffect |
any other name, carrying description or handler | useFrontendTool โ the tool and its renderer, which is what the old hook did | yes | useLayoutEffect |
| any other name, carrying neither | react-core's useRenderTool | yes โ see below | useEffect |
The wildcard rule is unconditional on purpose. description was required on
the old hook, so every wildcard renderer anyone ever wrote necessarily carries
it; routing "has the old fields" to useFrontendTool would recreate the old
*-named-tool bug for exactly the people who had been trying to use the
wildcard.
The effect phase in the last column is a real, if small, difference: the old
hook always registered through useFrontendTool, which uses a
useLayoutEffect, while core's useRenderTool uses a useEffect. A call that
now takes a renderer-only route therefore registers one phase later than it used
to. Only untyped-JavaScript call sites can be affected โ a typed old call site
had to supply the required description, which routes to useFrontendTool and
keeps the layout phase โ and the later phase is what core's useRenderTool has
always done on the web, so this is stated rather than compensated for.
The warning names what it received, which hook the call was routed to, and what to change the call to. For a named tool:
[CopilotKit] `useRenderTool({ name: "showWeather" })` from @copilotkit/react-native
received `description`, `handler`. Those are TOOL fields, so this call was routed to
`useFrontendTool` โ the hook that registers a tool AND its renderer, which is what
the old React Native `useRenderTool` did. React Native's `useRenderTool` is a
temporary compatibility shim (deprecated, scheduled for removal in the next minor).
Rename the call to `useFrontendTool` (same config object; render props rename `args`
to `parameters`). If you only meant to supply UI for a tool somebody else owns, drop
`description`, `handler` and keep `useRenderTool`.For a wildcard it says instead that the fields were ignored, that no tool
named * is registered โ and what the old hook's * tool actually did, which
was to auto-answer every otherwise-unanswered tool call with an empty tool
result and ask for a follow-up turn โ and that a real frontend tool needs a real
name and useFrontendTool.
It fires once per distinct tool name โ from an effect, not from the render path โ and only outside production. See What the warning does not cover.
A config whose shape changes between renders (handler: enabled ? fn : undefined) keeps the route it was first registered under, because a hook cannot
be called conditionally unless the condition is stable for the component's
lifetime. That is reported rather than acted on: you get a separate warning
saying the call "changed shape between renders" and that it stays where it
started. Call the hook you actually want instead of varying description /
handler.
What to change, and by when
All of this is due before the next minor, when the shim goes away. Two rows are compile errors today, so the compiler will bring you here for those two whether or not you read this page first.
| Before (old RN hook) | Change it to | Where it stands today |
|---|---|---|
useRenderTool({ name, description, parameters, handler, render }, deps) | useFrontendTool({ โฆidentical object }, deps) โ rename only | still registers the tool and the renderer, through the shim; warns |
renderer-only registration: impossible in TypeScript (description was required), reachable from plain JS | useRenderTool({ name, parameters, render, agentId? }, deps) โ parameters required on a named renderer | works, and warns: see the shape routing cannot discriminate |
wildcard: registers a tool named * | useRenderTool({ name: "*", render }) โ the one case that takes no schema | already renderer-only; description / handler / parameters are ignored and warned about |
render props { args, status, โฆ } | { parameters, status, โฆ } | TS2339: Property 'args' does not exist on type 'RenderToolProps<โฆ>'. The shim restores the old config fields, not the old render-prop names. (The wildcard overload types its props as any, so args still compiles there.) |
RenderToolProps<T> (args-shaped, generic over parsed args) | RenderToolProps<S> (parameters-shaped, generic over schema) | re-exported from react-core |
bare RenderToolProps โ RN's had T = Record<string, unknown> | RenderToolProps<typeof mySchema> | TS2314: Generic type 'RenderToolProps' requires 1 type argument(s). core's S has no default, so the natural spelling for an untyped renderer no longer compiles |
UseRenderToolOptions<T> | gone โ write the config inline (RenderToolConfig is core-internal) | not reintroduced by the shim |
RenderToolFunction<T> | for a useRenderTool renderer, nothing: the hook already declares render as ReactElement | null. For a useFrontendTool renderer, FrontendToolRenderFunction<T> | not reintroduced under its old name |
There is no exported config type to annotate a call with, and that is deliberate
rather than an oversight. RenderToolConfig โ core's internal name for the
shape, declared without export and referenced only by the implementation
signature โ is looser than what the overloads accept: it declares
parameters?: S, so it would permit { name: "showWeather", render } with no
schema, which the named overload rejects. Exporting it would hand you a type that
describes calls the compiler refuses. Write the config as an inline object literal
and let the overload infer it. RenderToolProps<S> is exported, and is what
you annotate a standalone render function with.
If your call site wanted a callable tool, useFrontendTool is a rename and
nothing else โ it takes the same object, description and handler included.
If it only ever drew UI for a tool the agent already had, drop description
(and handler, if you passed one) and rename args to parameters.
The wildcard is the case that changed behaviour for the better rather than merely
routing differently. name: "*" on the old hook registered a frontend tool
literally named *, with no description and no schema.
That tool was never offered to the model โ core filters the name * out of the
tool list it hands the agent, precisely so the agent is not offered a tool whose
name is a glob. The consequence was different, and worse for the person who
wrote it. * is core's catch-all handler name: when a tool call has no
matching frontend tool and no result yet, core reaches for the * tool and runs
its wildcard path โ and in that path, the tool-result insertion and the
follow-up-turn request sit outside the check for whether the wildcard tool
actually has a handler. A handler-less * tool, which is exactly what someone
who wanted a display-only fallback wrote, therefore still answered the call with
an empty tool result and still asked for another turn.
Driving a single turn through both spellings shows it directly: an assistant message calling a tool nobody registered produces two turns and an empty tool result through the old hook, and one turn with no tool result through the shim. The scope is bounded โ only a tool call with no exact-name tool and no result yet reaches that path, so a server-side call whose result has already arrived was never affected.
Through the shim, and on core's hook afterwards, "*" is what it is on the web:
a schema-less fallback renderer that registers no tool at all.
Three shapes the compiler cannot see
These three are why the shim exists. A call whose old fields the compiler cannot see is a call that could reach react-core's renderer-only hook without anyone having been told โ so the shim routes them the way the old hook did, and warns. They are still worth auditing by hand, for two reasons: the warning is development-only, and when the shim is removed all three go back to compiling clean and degrading silently.
Excess-property checking is what would otherwise reject the old description,
and it reaches only properties written inline in a fresh object literal:
-
A hoisted config object. Assign the config to a variable first and pass the variable, and the excess
descriptionis no longer excess:const cfg = { name: "showWeather", description: "Show weather info", parameters: z.object({ city: z.string() }), handler: async ({ city }: { city: string }) => fetchWeather(city), render: () => <WeatherCard city="Berlin" />, // ignores its props }; useRenderTool(cfg); // compiles cleanThrough the shim this call is routed to
useFrontendTool, so the tool stays registered andhandlerkeeps running, and you get the warning above instead of nothing. Without the shim it would silently become renderer-only: the tool would stop being registered and advertised to the model, andhandlerwould never run again. A hoisted config whoserenderdoes destructureargsis caught by the compiler either way โ the render parameter is contravariant, so it fails onrenderโ so this is specifically the "hoisted andrenderignores its props" combination. -
Spread-carried fields. Same cause, and it survives a fresh literal at the call site: excess-property checking does not reach a property that arrives via a spread, so the old
descriptionis not flagged.const base = { name: "showWeather", description: "Show weather", parameters: z.object({ city: z.string() }), }; useRenderTool({ ...base, render: () => <WeatherCard city="Berlin" /> }); // compiles cleanRouted and warned about exactly as above โ and, exactly as above, a
renderthat does destructureargsis caught on contravariance. -
An untyped call site. A plain-JavaScript screen, or one under
@ts-nocheck, is checked by nothing at all. Here the runtime detail matters: both the shim's and core's renderer bridge spread{ ...props, parameters: props.args }into your renderer, soargsstill arrives at runtime. An oldrender: ({ args }) => โฆkeeps painting exactly as it did. The shim's warning is the only thing that distinguishes a correctly routed call from a silently renderer-only one here โ without it, the UI looks identical while the tool has stopped existing.
If you have JavaScript React Native screens, grep them for useRenderTool and
decide per call site whether it wanted a tool (useFrontendTool) or only a
renderer. The compiler will never do it for you on those files.
The shape routing cannot discriminate
{ name, parameters, render } โ no description, no handler โ is the one
call the shim cannot route by shape, because it is simultaneously:
- the correct new renderer-only spelling, which is what this page documents; and
- an old plain-JS call that used to register and advertise a real tool.
Both are the same object. description was required by the old hook's types, so
a TypeScript caller could never write this shape โ but an untyped JavaScript
caller could, and untyped JavaScript is the whole population the shim exists
for. On the old hook, handler was optional and nothing filtered a tool out for
lacking one, so { name, parameters, render } registered and advertised
name to the model with an empty description.
So this route loses tool registration and advertisement, and routing cannot
detect it. The warning is therefore unconditional on this path: it says the
call registers a renderer only, and that if you were relying on it advertising a
tool you want useFrontendTool
instead. If you only ever wanted to draw UI for a tool somebody else owns, your
call is already correct โ the warning is telling you to move it to react-core's
useRenderTool, which is where it lives once the shim goes.
It is deduped per tool name and development-only, like every other warning here.
What the warning does not cover
The routing is unconditional, and so is the notice โ every route warns, the renderer-only one included. Three gaps remain, all worth knowing before you treat a quiet console as "nothing to migrate":
- Development only. The warning is gated on
process.env.NODE_ENV !== "production", so a release bundle prints nothing. The call is still routed the same way โ what a production build loses is the signal, not the behaviour. A team that only ever runs release builds gets no notice at all and has to audit by hand. - Once per tool name, for the process's lifetime. The dedup key is the tool name and it lives as long as the module, so remounting the screen does not re-warn. Scrollback that has been cleared has been lost.
- Only for screens you actually mount. The warning is emitted from an effect, so a screen you did not navigate to in development never warns.
status is not a break
The old hook's render props typed status as the ToolCallStatus enum;
core's RenderToolProps types it as the string literals "inProgress" | "executing" | "complete". No migration work follows from that. A
string-enum member is assignable to its own literal type, so
status === ToolCallStatus.Complete compiles and narrows against the literal
union โ existing enum comparisons keep working untouched. Either form is fine;
pick one and be consistent.
ToolCallStatus remains a value export of @copilotkit/react-native, and it is
still the shape you meet where the canonical ReactToolCallRenderer contract is
in play: a renderer built with defineToolCallRenderer, or a render passed to
useFrontendTool, receives status as an enum member.
Related
useFrontendTool: register a tool the agent can call, with an optional rendereruseComponent: register a tool whose renderer is a componentuseHumanInTheLoop: gate a tool call on user approval