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.

This used to be a different hook on React Native

Before 1.68, @copilotkit/react-native exported a local hook under this name whose whole body forwarded to useFrontendTool, so it shipped one capability under the other's name โ€” and name: "*" registered a frontend tool literally called *. 1.68 replaced it with react-core's hook and kept the old call shapes working behind a deprecated compatibility shim; the shim has since been removed (#6976), so description and handler no longer compile here and no longer register a tool.

If you are upgrading from a version that predates 1.68, see Migrating from the old React Native useRenderTool for what to change and what the compiler will not catch for you.

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.

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.

1.68 deleted that hook and pointed the name at react-core's, behind a deprecated compatibility shim that routed an old-shaped call the way the old hook did and warned about it in development. The shim is gone. @copilotkit/react-native now re-exports react-core's useRenderTool itself and carries no render-tool implementation of its own, so this page describes the whole of what the name does.

What to change

Most rows below are compile errors, so the compiler will bring you here for those whether or not you read this page first. The exception is the first row, which fails only where TypeScript's excess-property check reaches it โ€” see three shapes the compiler cannot see for the call shapes it does not.

Because the hook is overloaded, an excess description or handler is reported as TS2769: No overload matches this call rather than the bare TS2353 you would get on a single-signature function. The excess property is named in the nested per-overload detail, so read past the first line.

Before (old RN hook)Change it toWhat happens if you don't
useRenderTool({ name, description, parameters, handler, render }, deps)useFrontendTool({ โ€ฆidentical object }, deps) โ€” rename onlyTS2769: No overload matches this call, naming description under the named overload โ€” but see three shapes the compiler cannot see, where it compiles and the tool silently stops being registered
renderer-only registration: impossible in TypeScript (description was required), reachable from plain JSuseRenderTool({ name, parameters, render, agentId? }, deps) โ€” parameters required on a named rendereralready correct; nothing to do
wildcard: registers a tool named *useRenderTool({ name: "*", render }) โ€” the one case that takes no schemaTS2769 โ€” description / handler / parameters are not on this overload; drop them
render props { args, status, โ€ฆ }{ parameters, status, โ€ฆ }TS2339: Property 'args' does not exist on type 'RenderToolProps<โ€ฆ>'. (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)TS2305 on the import
RenderToolFunction<T>for a useRenderTool renderer, nothing: the hook already declares render as ReactElement | null. For a useFrontendTool renderer, FrontendToolRenderFunction<T>TS2305 on the import

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 moving. 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 this one. 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.

On core's hook, "*" is what it is on the web: a schema-less fallback renderer that registers no tool at all.

Three shapes the compiler cannot see

description and handler are compile errors on this hook now โ€” but only where TypeScript's excess-property check actually fires, which is on properties written inline in a fresh object literal. Three shapes slip past it, and all three compile clean while the tool silently stops being registered: nothing is advertised to the model any more, and handler never runs again.

They are also the shapes that look unchanged on screen. React-core's renderer bridge spreads { ...props, parameters: props.args } into your renderer, so an old render: ({ args }) => โ€ฆ keeps painting exactly as it did. The UI is identical; the tool is gone. The deprecated shim that shipped between 1.68 and its removal warned about precisely this population in development โ€” with the shim gone, nothing does. Audit these by hand.

  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, registers a renderer and nothing else

    A hoisted config whose render does destructure args is caught 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

    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.

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 that looks correct either way

{ name, parameters, render } โ€” no description, no handler โ€” is the one call that is simultaneously:

  • the correct 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, and nothing distinguishes them. description was required by the old hook's types, so a TypeScript caller could never write this shape โ€” but an untyped JavaScript caller could. 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.

Today it registers a renderer and nothing else. If you were relying on it advertising a tool, move the call to useFrontendTool. If you only ever wanted to draw UI for a tool somebody else owns, it is already correct.

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