Skip to content

Fix #133 Add React Native Support #193

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 9 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/tame-beds-shake.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@openai/agents-realtime': minor
'@openai/agents-core': minor
---

Add React Native platform support
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ Explore the [`examples/`](examples/) directory to see the SDK in action.
- Node.js 22 or later
- Deno
- Bun
- React Native

Experimental support:

Expand Down
1 change: 1 addition & 0 deletions docs/src/content/docs/guides/troubleshooting.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ The OpenAI Agents SDK is supported on the following server environments:
- Node.js 22+
- Deno 2.35+
- Bun 1.2.5+
- React Native 0.79+

### Limited support

Expand Down
1 change: 1 addition & 0 deletions docs/src/content/docs/guides/voice-agents/quickstart.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ import thinClientExample from '../../../../../../examples/docs/voice-agents/thin
From here you can start designing and building your own voice agent. Voice agents include a lot of the same features as regular agents, but have some of their own unique features.

- Learn how to give your voice agent:

- [Tools](/openai-agents-js/guides/voice-agents/build#tools)
- [Handoffs](/openai-agents-js/guides/voice-agents/build#handoffs)
- [Guardrails](/openai-agents-js/guides/voice-agents/build#guardrails)
Expand Down
13 changes: 11 additions & 2 deletions packages/agents-core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,11 @@
"types": "./dist/shims/shims-node.d.ts",
"default": "./dist/shims/shims-node.mjs"
},
"react-native": {
"require": "./dist/shims/shims-react-native.js",
"types": "./dist/shims/shims-react-native.d.ts",
"default": "./dist/shims/shims-react-native.mjs"
},
"require": {
"types": "./dist/shims/shims-node.d.ts",
"default": "./dist/shims/shims-node.js"
Expand All @@ -89,8 +94,11 @@
},
"dependencies": {
"@openai/zod": "npm:[email protected] - 3.25.67",
"@ungap/structured-clone": "^1.3.0",
"debug": "^4.4.0",
"openai": "^5.10.1"
"openai": "^5.10.1",
"events": "^3.3.0",
"react-native-uuid": "^2.0.3"
},
"peerDependencies": {
"zod": "3.25.40 - 3.25.67"
Expand Down Expand Up @@ -121,7 +129,8 @@
},
"devDependencies": {
"@types/debug": "^4.1.12",
"zod": "3.25.40 - 3.25.67"
"zod": "3.25.40 - 3.25.67",
"@types/ungap__structured-clone": "^1.2.0"
},
"files": [
"dist"
Expand Down
104 changes: 104 additions & 0 deletions packages/agents-core/src/shims/shims-react-native.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
/// <reference lib="dom" />
export { EventEmitter, EventEmitterEvents } from './interface';
import type { EventEmitterEvents, Timeout, Timer } from './interface';

import { EventEmitter as NodeEventEmitter } from 'events';
import structuredClone from '@ungap/structured-clone';
import uuid from 'react-native-uuid';

if (!('structuredClone' in globalThis)) {
// @ts-expect-error - This is the recommended approach from ungap/structured-clone
globalThis.structuredClone = structuredClone;
}
export const randomUUID = (): string => uuid.v4();

export function loadEnv(): Record<string, string | undefined> {
return {};
}

export class ReactNativeEventEmitter<
Events extends EventEmitterEvents = Record<string, any[]>,
> extends NodeEventEmitter {
override on<K extends keyof Events & (string | symbol)>(
type: K,
listener: (...args: Events[K]) => void,
): this {
// Node's typings accept string | symbol; cast is safe.
return super.on(type as string | symbol, listener);
}

override off<K extends keyof Events & (string | symbol)>(
type: K,
listener: (...args: Events[K]) => void,
): this {
return super.off(type as string | symbol, listener);
}

override emit<K extends keyof Events & (string | symbol)>(
type: K,
...args: Events[K]
): boolean {
return super.emit(type as string | symbol, ...args);
}

override once<K extends keyof Events & (string | symbol)>(
type: K,
listener: (...args: Events[K]) => void,
): this {
return super.once(type as string | symbol, listener);
}
}

export { ReactNativeEventEmitter as RuntimeEventEmitter };

// Streams – placeholders (unused by the SDK on RN)
export const Readable = class {};
export const ReadableStream = globalThis.ReadableStream;
export const ReadableStreamController =
globalThis.ReadableStreamDefaultController;
export const TransformStream = globalThis.TransformStream;

export class AsyncLocalStorage {
#ctx: unknown = null;

run<T>(store: T, fn: () => unknown) {
this.#ctx = store;
return fn();
}
getStore<T>() {
return this.#ctx as T;
}
enterWith<T>(store: T) {
this.#ctx = store;
}
}

export function isBrowserEnvironment(): boolean {
return true;
}

export function isTracingLoopRunningByDefault(): boolean {
return false;
}

/* MCP not supported on mobile; export browser stubs */
export { MCPServerStdio, MCPServerStreamableHttp } from './mcp-server/browser';

class RNTimer implements Timer {
setTimeout(cb: () => void, ms: number): Timeout {
const id: any = setTimeout(cb, ms);
// RN timers don’t expose ref/unref; shim them
id.ref ??= () => id;
id.unref ??= () => id;
id.hasRef ??= () => true;
id.refresh ??= () => id;
return id;
}

clearTimeout(id: Timeout | string | number | undefined) {
clearTimeout(id as number);
}
}

const timer = new RNTimer();
export { timer };
2 changes: 1 addition & 1 deletion packages/agents-openai/src/openaiChatCompletionsModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ export class OpenAIChatCompletionsModel implements Model {
const output: protocol.OutputModelItem[] = [];
if (response.choices && response.choices[0]) {
const message = response.choices[0].message;

if (
message.content !== undefined &&
message.content !== null &&
Expand Down
10 changes: 9 additions & 1 deletion packages/agents-realtime/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,11 @@
"types": "./dist/shims/shims-browser.d.ts",
"default": "./dist/shims/shims-browser.mjs"
},
"react-native": {
"require": "./dist/shims/shims-react-native.js",
"types": "./dist/shims/shims-react-native.d.ts",
"default": "./dist/shims/shims-react-native.mjs"
},
"node": {
"require": "./dist/shims/shims-node.js",
"types": "./dist/shims/shims-node.d.ts",
Expand Down Expand Up @@ -79,5 +84,8 @@
},
"files": [
"dist"
]
],
"peerDependencies": {
"react-native-webrtc": "^124.0.5"
}
}
28 changes: 16 additions & 12 deletions packages/agents-realtime/src/openaiRealtimeWebRtc.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
/// <reference lib="dom" />

import { isBrowserEnvironment } from '@openai/agents-core/_shims';
import {
isBrowserEnvironment,
mediaDevices as shimMediaDevices,
RTCPeerConnection as RTCPeerConnectionCtor,
} from '@openai/agents-realtime/_shims';
import {
RealtimeTransportLayer,
RealtimeTransportLayerConnectOptions,
Expand Down Expand Up @@ -94,7 +98,7 @@ export class OpenAIRealtimeWebRTC
#muted = false;

constructor(private readonly options: OpenAIRealtimeWebRTCOptions = {}) {
if (typeof RTCPeerConnection === 'undefined') {
if (typeof RTCPeerConnectionCtor === 'undefined') {
throw new Error('WebRTC is not supported in this environment');
}
super(options);
Expand Down Expand Up @@ -166,7 +170,7 @@ export class OpenAIRealtimeWebRTC

const connectionUrl = new URL(baseUrl);

let peerConnection: RTCPeerConnection = new RTCPeerConnection();
let peerConnection: RTCPeerConnection = new RTCPeerConnectionCtor();
const dataChannel = peerConnection.createDataChannel('oai-events');

this.#state = {
Expand Down Expand Up @@ -219,19 +223,19 @@ export class OpenAIRealtimeWebRTC
});

// set up audio playback
const audioElement =
this.options.audioElement ?? document.createElement('audio');
audioElement.autoplay = true;
peerConnection.ontrack = (event) => {
audioElement.srcObject = event.streams[0];
};
if (isBrowserEnvironment()) {
const audioElement =
this.options.audioElement ?? document.createElement('audio');
audioElement.autoplay = true;
peerConnection.ontrack = (event) => {
audioElement.srcObject = event.streams[0];
};
}

// get microphone stream
const stream =
this.options.mediaStream ??
(await navigator.mediaDevices.getUserMedia({
audio: true,
}));
(await shimMediaDevices.getUserMedia({ audio: true }));
peerConnection.addTrack(stream.getAudioTracks()[0]);

if (this.options.changePeerConnection) {
Expand Down
7 changes: 7 additions & 0 deletions packages/agents-realtime/src/shims/shims-browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,10 @@ export function isBrowserEnvironment(): boolean {
return true;
}
export const useWebSocketProtocols = true;

export const RTCPeerConnection = globalThis.RTCPeerConnection;
export const RTCIceCandidate = globalThis.RTCIceCandidate;
export const RTCSessionDescription = globalThis.RTCSessionDescription;
export const MediaStream = globalThis.MediaStream;
export const MediaStreamTrack = globalThis.MediaStreamTrack;
export const mediaDevices = navigator.mediaDevices;
12 changes: 12 additions & 0 deletions packages/agents-realtime/src/shims/shims-node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,15 @@ export function isBrowserEnvironment(): boolean {
return false;
}
export const useWebSocketProtocols = false;

export const RTCPeerConnection =
undefined as unknown as typeof globalThis.RTCPeerConnection;
export const RTCIceCandidate =
undefined as unknown as typeof globalThis.RTCIceCandidate;
export const RTCSessionDescription =
undefined as unknown as typeof globalThis.RTCSessionDescription;
export const MediaStream =
undefined as unknown as typeof globalThis.MediaStream;
export const MediaStreamTrack =
undefined as unknown as typeof globalThis.MediaStreamTrack;
export const mediaDevices = undefined as unknown as MediaDevices;
24 changes: 24 additions & 0 deletions packages/agents-realtime/src/shims/shims-react-native.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import {
RTCPeerConnection,
RTCIceCandidate,
RTCSessionDescription,
MediaStream,
MediaStreamTrack,
mediaDevices,
registerGlobals,
} from 'react-native-webrtc';

registerGlobals();
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think we are using the globals which is good so I would say let's drop the registerGlobals() that way it's still up to the implementor if they want to register them for additional use cases rather than us magically importing them

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@dkundel-openai When I remove the registerGlobals() line and test this in expo go on my android, neither the mic nor the speaker work. It does work if I then call registerGlobals() within my expo code - I believe this is because there are other places in the agents-realtime package where the globals are called.

It seems to me like our options are:

  1. Remove registerGlobals() here, and then the user must call registerGlobals() within their RN app to get the mic/speaker working.
  2. Remove registerGlobals() here, but make it so that the agents-realtime package doesn't use the globals anywhere, and have all of the shims export more stuff.
  3. Keep registerGlobals() here, and then the user can optionally call registerGlobals() within their RN app if they want to change anything.

Maybe I'm missing something though. Could you let me know which you prefer?


export const WebSocket = global.WebSocket;
export const isBrowserEnvironment = (): boolean => false;
export const useWebSocketProtocols = true;

export {
RTCPeerConnection,
RTCIceCandidate,
RTCSessionDescription,
MediaStream,
MediaStreamTrack,
mediaDevices,
};
12 changes: 12 additions & 0 deletions packages/agents-realtime/src/shims/shims-workerd.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,15 @@ export function isBrowserEnvironment(): boolean {
return false;
}
export const useWebSocketProtocols = true;

export const RTCPeerConnection =
undefined as unknown as typeof globalThis.RTCPeerConnection;
export const RTCIceCandidate =
undefined as unknown as typeof globalThis.RTCIceCandidate;
export const RTCSessionDescription =
undefined as unknown as typeof globalThis.RTCSessionDescription;
export const MediaStream =
undefined as unknown as typeof globalThis.MediaStream;
export const MediaStreamTrack =
undefined as unknown as typeof globalThis.MediaStreamTrack;
export const mediaDevices = undefined as unknown as MediaDevices;
Loading