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 description or handler is routed to useFrontendTool โ€” 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.
  • render returns ReactElement | null, not ReactNode. React Native's FlatList cannot 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, status is "inProgress" and parameters is partial โ€” fields arrive progressively. Write renderers that tolerate missing fields; that is what lets UI build as the agent writes it.
  • render is captured at registration. It is not refreshed on every render โ€” only when the renderer re-registers, which happens when name changes or when deps compare as changed. If your render closes over component state or props that change over time, list a JSON-comparable form of them in deps, 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 a Map, a Set, 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 a Map in deps type-checks, reads like a fix, and re-registers nothing.
  • Circular values throw. JSON.stringify raises a TypeError while 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 callRouted toWarns in developmentRegisters in
name: "*", whatever else the config carriesreact-core's useRenderTool โ€” a renderer-only fallbackyes (and names description, handler or parameters if present โ€” all three are ignored on this path)useEffect
any other name, carrying description or handleruseFrontendTool โ€” the tool and its renderer, which is what the old hook didyesuseLayoutEffect
any other name, carrying neitherreact-core's useRenderToolyes โ€” see belowuseEffect

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 toWhere it stands today
useRenderTool({ name, description, parameters, handler, render }, deps)useFrontendTool({ โ€ฆidentical object }, deps) โ€” rename onlystill registers the tool and the renderer, through the shim; warns
renderer-only registration: impossible in TypeScript (description was required), reachable from plain JSuseRenderTool({ name, parameters, render, agentId? }, deps) โ€” parameters required on a named rendererworks, and warns: see the shape routing cannot discriminate
wildcard: registers a tool named *useRenderTool({ name: "*", render }) โ€” the one case that takes no schemaalready 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:

  1. A hoisted config object. Assign the config to a variable first and pass the variable, and the excess description is 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 clean

    Through the shim this call is routed to useFrontendTool, so the tool stays registered and handler keeps 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, and handler would never run again. A hoisted config whose render does destructure args is caught by the compiler either way โ€” the render parameter is contravariant, so it fails on render โ€” so this is specifically the "hoisted and render ignores its props" combination.

  2. 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 description is not flagged.

    const base = {
      name: "showWeather",
      description: "Show weather",
      parameters: z.object({ city: z.string() }),
    };
    useRenderTool({ ...base, render: () => <WeatherCard city="Berlin" /> }); // compiles clean

    Routed and warned about exactly as above โ€” and, exactly as above, a render that does destructure args is caught on contravariance.

  3. 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, so args still arrives at runtime. An old render: ({ 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