showcase/shell-docs/src/content/docs/frontends/react-native.mdx
@copilotkit/react-native gives you CopilotKit's provider and hooks for React Native, plus optional pre-built chat components. This guide builds a fully custom chat UI with the hooks, backed by a Copilot Runtime endpoint on your server.
The package has three import surfaces — /headless (provider + hooks, no native peer dependencies), the root barrel, and /components (the rendered chat UI). See Import surfaces for what each one pulls in; this guide uses /headless throughout.
If you don't have one already:
```bash
npx @react-native-community/cli@latest init MyCopilotApp
cd MyCopilotApp
```
</Step>
<Step>
### Install CopilotKit
Install the React Native frontend package and `@copilotkit/runtime` for your local Copilot Runtime server:
<Tabs groupId="package-manager" items={['npm', 'pnpm', 'yarn']}>
<Tab value="npm">
```bash
npm install @copilotkit/react-native @copilotkit/runtime
npm install -D tsx typescript @types/node
```
</Tab>
<Tab value="pnpm">
```bash
pnpm add @copilotkit/react-native @copilotkit/runtime
pnpm add -D tsx typescript @types/node
```
</Tab>
<Tab value="yarn">
```bash
yarn add @copilotkit/react-native @copilotkit/runtime
yarn add -D tsx typescript @types/node
```
</Tab>
</Tabs>
</Step>
<Step>
### Add polyfills
React Native's JS runtime (Hermes) lacks several Web APIs that CopilotKit depends on. The package installs these polyfills itself on first import, but two things must happen **at the very top of your entry point, before any CopilotKit import**: a secure random source, then the polyfill barrel.
Install the secure RNG first:
```bash
npm install react-native-get-random-values
```
```js title="index.js"
import "react-native-get-random-values"; // [!code highlight:2] secure RNG — must be first
import "@copilotkit/react-native/polyfills";
import { AppRegistry } from "react-native";
import App from "./App";
import { name as appName } from "./app.json";
AppRegistry.registerComponent(appName, () => App);
```
<Callout type="warn" title="Import order is load-bearing for crypto">
CopilotKit's `crypto` polyfill and `react-native-get-random-values` both install only if `crypto.getRandomValues` is still undefined — first writer wins. If any CopilotKit module is evaluated first, CopilotKit's **non-cryptographic** `Math.random` fallback is locked in permanently and `react-native-get-random-values` becomes a silent no-op. Keep it on the first line. See [Polyfills](#polyfills).
</Callout>
<Callout type="info" title="Granular polyfills">
If you already polyfill some of these APIs (e.g. `ReadableStream`), you can import only what you need instead of the barrel:
```js
import "@copilotkit/react-native/polyfills/streams";
import "@copilotkit/react-native/polyfills/encoding";
import "@copilotkit/react-native/polyfills/crypto";
import "@copilotkit/react-native/polyfills/dom";
import "@copilotkit/react-native/polyfills/location";
```
</Callout>
</Step>
<Step>
### Create the Copilot Runtime
Add a small Node server that hosts Copilot Runtime at `/api/copilotkit` and registers a `default` built-in agent:
```ts title="server.ts"
import { createServer } from "node:http";
import { BuiltInAgent, CopilotRuntime } from "@copilotkit/runtime/v2";
import { createCopilotNodeListener } from "@copilotkit/runtime/v2/node";
const runtime = new CopilotRuntime({
agents: {
default: new BuiltInAgent({
model: "openai:gpt-5-mini",
prompt: "You are a helpful assistant for a React Native app.",
}),
},
});
const port = Number(process.env.PORT ?? 8200);
createServer(
createCopilotNodeListener({
runtime,
basePath: "/api/copilotkit",
cors: true,
}),
).listen(port, () => {
console.log(
`Copilot Runtime listening at http://localhost:${port}/api/copilotkit`,
);
});
```
</Step>
<Step>
### Wrap your app with CopilotKitProvider
Point the provider at the runtime endpoint. Android emulators reach the host machine at `10.0.2.2`; iOS simulators can use `localhost`.
```tsx title="App.tsx"
import { CopilotKitProvider } from "@copilotkit/react-native/headless"; // [!code highlight]
import { Platform } from "react-native";
import { ChatScreen } from "./src/ChatScreen";
const runtimeUrl =
Platform.OS === "android"
? "http://10.0.2.2:8200/api/copilotkit"
: "http://localhost:8200/api/copilotkit";
export default function App() {
return (
<CopilotKitProvider runtimeUrl={runtimeUrl}>
<ChatScreen />
</CopilotKitProvider>
);
}
```
<Callout type="info" title="Testing on a physical device">
Replace `localhost` or `10.0.2.2` with your development machine's LAN IP address, for example `http://192.168.1.23:8200/api/copilotkit`.
</Callout>
</Step>
<Step>
### Build your chat UI
The platform-agnostic hooks work the same way here as on the web. Below is a minimal chat screen using `useAgent` and `useCopilotKit`:
```tsx title="src/ChatScreen.tsx"
import { useCallback, useRef, useState } from "react";
import {
FlatList,
KeyboardAvoidingView,
Platform,
Text,
TextInput,
TouchableOpacity,
View,
} from "react-native";
import { useAgent, useCopilotKit } from "@copilotkit/react-native/headless"; // [!code highlight]
export function ChatScreen() {
const [inputText, setInputText] = useState("");
const flatListRef = useRef<FlatList>(null);
const { copilotkit } = useCopilotKit(); // [!code highlight:2]
const { agent } = useAgent({ agentId: "default" });
const messages = agent?.messages ?? [];
const isLoading = agent?.isRunning ?? false;
const chatMessages = messages.flatMap((message) => {
if (
(message.role === "user" || message.role === "assistant") &&
typeof message.content === "string" &&
message.content.length > 0
) {
return [
{
id: message.id,
content: message.content,
},
];
}
return [];
});
const handleSend = useCallback(async () => {
const text = inputText.trim();
if (!text || isLoading || !agent) return;
setInputText("");
agent.addMessage({ // [!code highlight:5]
id: `user-${Date.now()}`,
role: "user",
content: text,
});
await copilotkit.runAgent({ agent }); // [!code highlight]
}, [inputText, isLoading, agent, copilotkit]);
return (
<KeyboardAvoidingView
style={{ flex: 1 }}
behavior={Platform.OS === "ios" ? "padding" : "height"}
>
<FlatList
ref={flatListRef}
data={chatMessages}
renderItem={({ item }) => (
<View style={{ padding: 12, maxWidth: "80%" }}>
<Text>{item.content}</Text>
</View>
)}
keyExtractor={(item, i) => item.id ?? String(i)}
onContentSizeChange={() =>
flatListRef.current?.scrollToEnd({ animated: true })
}
/>
<View style={{ flexDirection: "row", padding: 8 }}>
<TextInput
style={{ flex: 1, borderWidth: 1, borderRadius: 8, padding: 8 }}
value={inputText}
onChangeText={setInputText}
placeholder="Type a message..."
/>
<TouchableOpacity onPress={handleSend} style={{ padding: 8 }}>
<Text>Send</Text>
</TouchableOpacity>
</View>
</KeyboardAvoidingView>
);
}
```
<Callout type="info" title="Headless by design">
`@copilotkit/react-native/headless` provides hooks, not UI components, so you have full control over your chat interface using standard React Native components. If you'd rather not build one, a rendered chat is available from `@copilotkit/react-native/components` — see [Import surfaces](#import-surfaces).
</Callout>
</Step>
<Step>
### Run the runtime and app
Start Copilot Runtime in one terminal:
```bash
export OPENAI_API_KEY=sk-...
npx tsx server.ts
```
Run the React Native app in another terminal:
<Tabs groupId="platform" items={['iOS', 'Android']}>
<Tab value="iOS">
```bash
npx react-native run-ios
```
</Tab>
<Tab value="Android">
```bash
npx react-native run-android
```
</Tab>
</Tabs>
</Step>
<Step>
### Start chatting!
Your React Native app is now connected to Copilot Runtime. The shared hooks are available here:
- `useAgent` - connect to an agent and manage messages
- `useFrontendTool` - let the agent call functions in your app
- `useHumanInTheLoop` - add approval flows for agent actions
- `useCopilotKit` - access the CopilotKit instance directly
- `useRenderTool` - React Native's own hook for drawing tool UI inside the rendered chat (see [Frontend tools](#frontend-tools-and-generative-ui))
<Accordions className="mb-4">
<Accordion title="Troubleshooting">
- **Metro can't resolve modules**: Clear the cache with `npx react-native start --reset-cache`.
- **Streaming not working**: Make sure polyfills are imported before any CopilotKit code in your entry point.
- **No response from the runtime**: Confirm the runtime server is running, `http://localhost:8200/api/copilotkit/info` returns the `default` agent, and the device can reach `runtimeUrl`. For physical devices, use your machine's LAN IP instead of `localhost`.
- **Model auth errors**: Confirm `OPENAI_API_KEY` is set in the terminal running `npx tsx server.ts`.
- **Existing polyfill conflicts**: Use the granular imports instead of the barrel `polyfills` import.
</Accordion>
</Accordions>
</Step>
@copilotkit/react-native has three JavaScript entry points. They differ in what UI they give you and — crucially for release bundles — which native peer dependencies Metro has to resolve.
| Import | What you get | Native peers Metro must resolve |
|---|---|---|
@copilotkit/react-native/headless | CopilotKitProvider and every platform-agnostic hook. No components. | None |
@copilotkit/react-native (root) | Everything in /headless, plus useAttachments, the two headless chat wrappers (CopilotChat, CopilotModal) and the CopilotSidebar / CopilotPopup chrome | expo-document-picker, expo-file-system (via useAttachments) |
@copilotkit/react-native/components | The rendered chat UI: a CopilotChat with a message list, a bottom-sheet CopilotModal, CopilotMarkdown, AssistantMessage, UserMessage | @gorhom/bottom-sheet (which itself needs react-native-reanimated + react-native-gesture-handler), react-native-streamdown |
Supporting entry points:
| Import | Description |
|---|---|
@copilotkit/react-native/polyfills | Barrel that installs every polyfill (streams, encoding, crypto, dom, location) plus the streaming-fetch shim |
@copilotkit/react-native/polyfills/{streams,encoding,crypto,dom,location} | Granular subpaths for installing a single polyfill |
@copilotkit/runtime | Server-side runtime (CopilotRuntime / BuiltInAgent / createCopilotNodeListener) used to host your agent |
Because these are static re-exports, Metro resolves a surface's peers at bundle time whether or not you call the code — so a custom-UI app importing the root barrel must still install expo-document-picker and expo-file-system (or stub them) or the release bundle fails with Unable to resolve module expo-document-picker. Importing from /headless avoids that entirely, which is why this guide uses it:
import {
CopilotKitProvider,
useAgent,
useFrontendTool,
} from "@copilotkit/react-native/headless"; // [!code highlight]
Importing the provider auto-installs the polyfills: /headless runs the polyfill barrel as its first statement, and the root barrel gets it through its /headless re-export. /components does not install them itself — it relies on the provider you import alongside it (from /headless or the root). And because the root barrel re-exports everything from /headless, existing @copilotkit/react-native imports keep working unchanged.
The package (both /headless and the root barrel) re-exports the platform-agnostic hooks from @copilotkit/react-core: useAgent, useFrontendTool, useHumanInTheLoop, useInterrupt, useSuggestions, useConfigureSuggestions, useAgentContext, useThreads, useCapabilities, useComponent, and useCopilotKit. These behave the same as on the web.
Two React-Native-specific differences are worth knowing:
useRenderTool is React Native's own hook, not react-core's. It registers the tool and its renderer, requires parameters, accepts a handler, must return ReactElement | null (React Native's FlatList cannot render bare strings), and does not support the "*" wildcard that the web hook does.useRenderToolCall, useDefaultRenderTool, useRenderCustomMessages, and useRenderActivityMessage.With package exports enabled, a release bundle can fail with Unable to resolve module node:crypto (or another node: specifier) pointing at jose. This surfaces only at bundle time, never at typecheck.
jose is not a direct dependency. It arrives transitively through telemetry: @copilotkit/shared → @segment/analytics-node → jose. jose publishes separate Node and browser builds behind its exports map, and its Node build imports node:-prefixed modules that Hermes cannot bundle.
The fix is to resolve jose specifically against its browser condition, using resolveRequest:
const { getDefaultConfig, mergeConfig } = require("@react-native/metro-config");
const config = {
resolver: {
resolveRequest: (context, moduleImport, platform) => {
// jose reaches the bundle via @copilotkit/shared -> @segment/analytics-node.
// Its Node build imports node:* modules that Hermes can't bundle.
if (moduleImport === "jose" || moduleImport.startsWith("jose/")) {
return context.resolveRequest(
{ ...context, unstable_conditionNames: ["browser"] }, // [!code highlight]
moduleImport,
platform,
);
}
return context.resolveRequest(context, moduleImport, platform);
},
},
};
module.exports = mergeConfig(getDefaultConfig(__dirname), config);
On Expo, swap in getDefaultConfig from expo/metro-config; the resolver block is identical.
unstable_conditionNames is an unordered set of asserted conditions, not a priority list. The winning target is chosen by the key order in each package's own exports map, so reordering this array changes nothing.jose.["require", "react-native"]. Replacing it wholesale drops the react-native condition, which changes resolution for any package that ships one.Scoping with resolveRequest keeps Metro's defaults intact everywhere else.
</Callout>
Hermes lacks several Web APIs CopilotKit relies on: ReadableStream / WritableStream / TransformStream, TextEncoder / TextDecoder, DOMException and Headers, crypto.getRandomValues, and window.location. It also lacks streaming fetch bodies (response.body.getReader()), which AG-UI's SSE transport needs.
Importing the provider installs these automatically: /headless runs the polyfill barrel as its first statement (and the root barrel gets it through /headless), also installing an XHR-based streaming-fetch shim on load. The /components surface does not install them on its own, so it relies on the provider imported alongside it. Each polyfill is skipped if the global it defines already exists, so importing more than once is safe.
Keep the explicit import "@copilotkit/react-native/polyfills" from the quickstart at the top of your entry point anyway. Because every polyfill is first writer wins, the entry point is the only place you control what gets installed before CopilotKit's own defaults do — which is what makes the secure-RNG ordering below work.
For granular control — for example, if you already polyfill ReadableStream yourself — import only the pieces you need instead of the barrel:
import "@copilotkit/react-native/polyfills/streams";
import "@copilotkit/react-native/polyfills/encoding";
import "@copilotkit/react-native/polyfills/crypto";
import "@copilotkit/react-native/polyfills/dom";
import "@copilotkit/react-native/polyfills/location";
import "react-native-get-random-values"; // [!code highlight] secure RNG — must be first
import "@copilotkit/react-native/polyfills";
// ...
This ordering is a hard requirement, not a precaution. Both implementations install only when crypto.getRandomValues is undefined, so whichever loads first wins permanently. If any CopilotKit module is evaluated first, the Math.random fallback is locked in and react-native-get-random-values silently becomes a no-op — no warning, no error, and every ID stays non-cryptographic for the life of the process.
</Callout>
CopilotKitProvider is a context-only provider (it renders no view of its own), so it composes cleanly as the outermost wrapper around your app. runtimeUrl is the only required prop, but several others are available:
| Prop | Type | Description |
|---|---|---|
runtimeUrl | string | URL of the Copilot Runtime endpoint (required) |
headers | Record<string, string> | (() => Record<string, string>) | Custom headers sent with every request. Use this for auth — e.g. an Authorization bearer token. A function form is called when the provider renders, not per request (see the callout below) |
credentials | RequestCredentials | Fetch credentials mode, e.g. "include" to send HTTP-only cookies on cross-origin requests |
properties | Record<string, unknown> | Custom properties forwarded to agents (e.g. a scoped user id) |
onError | (event) => void | Called for all error types (runtime connection, agent, tool). Defaults to console.error |
debug | DebugConfig | Enables verbose client-side event logging |
defaultThrottleMs | number | Default re-render throttle for streaming updates; individual useAgent calls can override |
useSingleEndpoint | boolean | Whether the runtime uses a single-route endpoint (defaults to auto-detect) |
<CopilotKitProvider
runtimeUrl={runtimeUrl}
headers={{ Authorization: `Bearer ${token}` }} // [!code highlight]
properties={{ userId }}
>
<ChatScreen />
</CopilotKitProvider>
So a refreshed token is only picked up if the provider re-renders and the resulting headers differ. Drive the token from React state (or context) so a refresh triggers a render:
const [token, setToken] = useState(initialToken);
<CopilotKitProvider runtimeUrl={runtimeUrl} headers={{ Authorization: `Bearer ${token}` }}>
A token kept in a ref, module variable, or async SDK cache with no accompanying render will keep sending the stale value indefinitely. If you can't re-render, call copilotkit.setHeaders({ ... }) directly when the token rotates.
</Callout>
The runtime from the quickstart hosts a BuiltInAgent and serves it with createCopilotNodeListener, which plugs straight into Node's built-in node:http createServer — no Hono or Express dependency required. createCopilotNodeListener({ runtime, basePath, cors }) returns a request listener.
cors: true adds Access-Control-* response headers and answers OPTIONS preflights. That matters for browser clients — Expo Web or react-native-web — but it has no bearing on whether a native iOS or Android build can reach the server: CORS is enforced by the browser, and React Native's native networking stack (NSURLSession / OkHttp) neither sends an Origin header nor consults Access-Control-Allow-Origin. Native reachability is decided by the server's bind address, LAN routing and firewall, and platform transport security — see Connecting from a device or bench.
BuiltInAgent accepts a model string in provider:model (or provider/model) form for OpenAI, Anthropic, and Google:
new BuiltInAgent({ model: "openai:gpt-5-mini", prompt: "..." }); // OPENAI_API_KEY
new BuiltInAgent({ model: "anthropic:claude-sonnet-4.5", prompt: "..." }); // ANTHROPIC_API_KEY
new BuiltInAgent({ model: "google:gemini-2.5-pro", prompt: "..." }); // GOOGLE_API_KEY
The string form reads the matching provider API key from the environment. You can also pass apiKey on the config, or an already-constructed AI SDK LanguageModel instance in place of the string.
To point the string model form at an OpenAI-compatible endpoint (a gateway, Azure OpenAI, a self-hosted server, etc.), set the base-URL environment variable — the runtime honors it when it constructs the provider:
export OPENAI_BASE_URL=https://your-gateway.example.com/v1
export OPENAI_API_KEY=sk-...
npx tsx server.ts
The same applies to ANTHROPIC_BASE_URL and GOOGLE_GENERATIVE_AI_BASE_URL. When the variable is unset, the provider falls back to its default endpoint, so this is fully backward compatible — you do not need to build a custom model instance just to change the base URL.
runtimeUrl must be an address the device can reach — localhost on a physical device or emulator resolves to the device itself, not your dev machine.
| Setup | runtimeUrl host |
|---|---|
| iOS simulator | localhost reaches the host machine |
| Android emulator | 10.0.2.2 is the emulator's alias for the host's localhost |
| Android device over USB | run adb reverse tcp:8200 tcp:8200, then use localhost on the device. adb reverse is Android-only — there is no iOS equivalent |
| iOS device | your dev machine's LAN IP, on the same Wi-Fi network — plus the ATS exception a plain http:// address needs |
| Physical device on the same LAN (a bench) | your dev machine's LAN IP, e.g. http://192.168.1.23:8200/api/copilotkit |
| Production | a hosted runtime URL over HTTPS |
The quickstart already branches on Platform.OS for the simulator/emulator case. Keep the URL a single named constant with a comment so it's easy to swap per environment.
Both platforms block plaintext (non-TLS) HTTP by default, so a device silently fails to reach a plain http://<lan-ip> dev runtime. These are dev-only affordances — a hosted TLS runtime needs none of them.
On Expo, configure both through app.json so they survive the android/ and ios/ regeneration that expo prebuild performs (never hand-edit the generated AndroidManifest.xml or Info.plist — they're discarded on the next prebuild). Install the config plugin first:
npx expo install expo-build-properties
Android — an Android release build needs cleartext opted in:
{
"expo": {
"plugins": [
["expo-build-properties", { "android": { "usesCleartextTraffic": true } }]
]
}
}
iOS — App Transport Security applies to debug and release builds alike, and expo-build-properties has no iOS ATS option, so this goes in ios.infoPlist. Since iOS 17, ATS refuses raw IP addresses unless the address is listed in NSExceptionDomains, so name your dev machine's IP explicitly (no port):
{
"expo": {
"ios": {
"infoPlist": {
"NSAppTransportSecurity": {
"NSExceptionDomains": {
"192.168.1.23": { "NSExceptionAllowsInsecureHTTPLoads": true }
}
},
"NSLocalNetworkUsageDescription": "Connects to the local Copilot Runtime during development."
}
}
}
}
NSLocalNetworkUsageDescription is required separately: iOS gates local-network access behind a user permission prompt, and without the string the connection fails regardless of ATS.
Frontend tools let the agent call functions in your app — and render UI — while the model runs. Register them with useFrontendTool. parameters is a Standard Schema, so a zod schema (zod ≥ 3.24 implements Standard Schema) is the expected shape. zod is an optional peer dependency of @copilotkit/react-native, so add it to your app:
import { useFrontendTool } from "@copilotkit/react-native/headless";
import { z } from "zod";
export function useComposeStage() {
useFrontendTool({
name: "composeStage",
description: "Render POI panels on the screen.",
agentId: "default", // scope the tool to one agent
parameters: z.object({
mode: z.enum(["home", "nearby", "hotels"]),
panels: z.array(
z.object({ title: z.string(), body: z.string().optional() }),
),
}),
handler: async (args) => {
// Runs on the device. Push args into your own state/reducer here.
return "rendered";
},
});
}
Because the tool is registered on the device, the model's call reaches your handler on the client — the ideal place to drive a state reducer that your React Native views render declaratively (the agent sends state, the client owns rendering). Discriminated-union / oneOf / anyOf param schemas are preserved through the runtime's schema conversion, so z.discriminatedUnion(...) params work. It's still good practice to normalize defensively in the handler — coerce a partially populated tool call into valid state rather than letting a malformed one render broken UI.
import { useRenderTool } from "@copilotkit/react-native/headless";
useRenderTool({
name: "composeStage",
description: "Render POI panels on the screen.",
parameters: z.object({ mode: z.string() }),
render: ({ args, status }) => <StagePreview mode={args.mode} pending={status !== "complete"} />,
});
Use useFrontendTool({ handler }) when the tool drives your own state and your views render it declaratively (what this guide does); use useRenderTool when you want UI drawn inline in the rendered chat. useRenderTool registers both the tool and its renderer, so don't also register the same tool with useFrontendTool.
For agent-authored content whose length you can't predict, render it in a ScrollView with a maxHeight set to the available band — it sizes to content when short and scrolls when long, so it never hard-clips.
</Callout>
Running a user turn is the two-step the quickstart shows: agent.addMessage(...) then copilotkit.runAgent({ agent }) (the runner lives on the core, not the agent). To cancel an in-flight run, call copilotkit.stopAgent({ agent }).
When another component needs only the run flag — a "thinking" indicator, say — subscribe with useAgent (a plain read that registers nothing) and read agent.isRunning. Call the tool-registering hook exactly once per agent so a tool isn't registered twice.
A client-executed frontend tool renders its UI mid-run: the tool fires, your handler paints the screen, and the agent's run keeps going (further reasoning, a trailing summary). So agent.isRunning stays true past the moment the content is visible — the more so with a reasoning-tier model. Design run-gated UI around that gap:
isRunning (render nothing when it's false) so an errored turn can't leave it stuck on. Keep always-available controls (a home or cancel button) above the overlay so they stay tappable while the run finishes.isRunning (true → false) rather than on the content-arrived state change — otherwise the animation plays while the overlay is still up and the user never sees it.maxSteps on the agent so it doesn't linger longer than needed.@copilotkit/voice has not been adapted for React Native, so there's no drop-in voice hook. Voice is a bring-your-own-STT concern: capture a transcript with a React Native speech recognizer, then feed the final text through the same send-a-turn path (agent.addMessage + copilotkit.runAgent).
@copilotkit/react-native/components renders markdown for you (via CopilotMarkdown, backed by react-native-streamdown). If you build your own UI, a plain Text shows markdown as-is — reuse CopilotMarkdown or bring your own renderer.@copilotkit/voice has not been adapted for React Native; wire your own STT as above.threadId on useAgent — not supported yet (#6141). Thread scoping comes from a chat-configuration provider; a threadId passed to useAgent does not typecheck and is ignored.publicApiKey / licenseToken are not supported on the React Native provider; connect via runtimeUrl.useRenderToolCall, useDefaultRenderTool, useRenderCustomMessages, and useRenderActivityMessage are not exported; use React Native's useRenderTool.