Skip to content

Add TanStack Start Realtime Demo Example #60

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 4 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
1 change: 1 addition & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export default tseslint.config(
'**/node_modules/**',
'**/docs/.astro/**',
'examples/realtime-next/**',
'examples/realtime-tantsack-start/**',
'examples/realtime-demo/**',
'integration-tests//**',
]),
Expand Down
2 changes: 2 additions & 0 deletions examples/realtime-tantsack-start/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
OPENAI_API_KEY=

10 changes: 10 additions & 0 deletions examples/realtime-tantsack-start/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
node_modules
.DS_Store
dist
dist-ssr
*.local
.env
.output
.vinxi
.tanstack-start
.nitro
11 changes: 11 additions & 0 deletions examples/realtime-tantsack-start/.vscode/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"files.watcherExclude": {
"**/routeTree.gen.ts": true
},
"search.exclude": {
"**/routeTree.gen.ts": true
},
"files.readonlyInclude": {
"**/routeTree.gen.ts": true
}
}
17 changes: 17 additions & 0 deletions examples/realtime-tantsack-start/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Realtime TanStack Start Demo

This example shows how to combine TanStack Start with the OpenAI Agents SDK to create a realtime voice agent using TanStack Router for routing.

## Run the example

Set the `OPENAI_API_KEY` environment variable and run:

```bash
pnpm examples:realtime-tantsack-start
```

Open [http://localhost:3000](http://localhost:3000) in your browser and start talking.

## Endpoints

- **`/`** – Realtime voice demo with agent handoffs, tools, and guardrails using the `RealtimeSession` class. Code in `src/routes/index.tsx`.
37 changes: 37 additions & 0 deletions examples/realtime-tantsack-start/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
{
"name": "realtime-tantsack-start",
"private": true,
"type": "module",
"scripts": {
"dev": "vite dev --port 3000",
"build": "vite build",
"start": "node .output/server/index.mjs",
"serve": "vite preview",
"test": "vitest run"
},
"dependencies": {
"@openai/agents": "workspace:*",
"@tailwindcss/vite": "^4.1.8",
"@tanstack/react-router": "^1.121.0-alpha.22",
"@tanstack/react-router-devtools": "^1.121.0-alpha.22",
"@tanstack/react-router-with-query": "^1.121.0-alpha.22",
"@tanstack/react-start": "^1.121.0-alpha.25",
"@tanstack/router-plugin": "^1.120.20",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"tailwindcss": "^4.1.8",
"vite-tsconfig-paths": "^5.1.4"
},
"devDependencies": {
"@testing-library/dom": "^10.4.0",
"@testing-library/react": "^16.3.0",
"@types/react": "^19.1.6",
"@types/react-dom": "^19.1.6",
"@vitejs/plugin-react": "^4.5.1",
"jsdom": "^26.1.0",
"typescript": "^5.8.3",
"vite": "^6.3.5",
"vitest": "^3.2.3",
"web-vitals": "^5.0.2"
}
}
Binary file not shown.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
25 changes: 25 additions & 0 deletions examples/realtime-tantsack-start/public/manifest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
{
"short_name": "TanStack App",
"name": "Create TanStack App Sample",
"icons": [
{
"src": "favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
},
{
"src": "logo192.png",
"type": "image/png",
"sizes": "192x192"
},
{
"src": "logo512.png",
"type": "image/png",
"sizes": "512x512"
}
],
"start_url": ".",
"display": "standalone",
"theme_color": "#000000",
"background_color": "#ffffff"
}
3 changes: 3 additions & 0 deletions examples/realtime-tantsack-start/public/robots.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:
86 changes: 86 additions & 0 deletions examples/realtime-tantsack-start/src/components/App.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import {
type RealtimeItem,
OutputGuardrailTripwireTriggered,
type TransportEvent,
} from '@openai/agents/realtime';
import { History } from '@/components/History';
import { Button } from '@/components/ui/Button';

export type AppProps = {
title?: string;
isConnected: boolean;
isMuted: boolean;
toggleMute: () => void;
connect: () => void;
history?: RealtimeItem[];
outputGuardrailResult?: OutputGuardrailTripwireTriggered<any> | null;
events: TransportEvent[];
};

export function App({
title = 'Realtime Agent Demo',
isConnected,
isMuted,
toggleMute,
connect,
history,
outputGuardrailResult,
events,
}: AppProps) {
return (
<div className="flex justify-center">
<div className="p-4 md:max-h-screen overflow-hidden h-screen flex flex-col max-w-6xl w-full">
<header className="flex-none flex justify-between items-center pb-4 w-full max-w-6xl">
<h1 className="text-2xl font-bold">{title}</h1>
<div className="flex gap-2">
{isConnected && (
<Button
onClick={toggleMute}
variant={isMuted ? 'primary' : 'outline'}
>
{isMuted ? 'Unmute' : 'Mute'}
</Button>
)}
<Button
onClick={connect}
variant={isConnected ? 'stop' : 'primary'}
>
{isConnected ? 'Disconnect' : 'Connect'}
</Button>
</div>
</header>
<div className="flex gap-10 flex-col md:flex-row h-full max-h-full overflow-y-hidden">
<div className="flex-2/3 flex-grow overflow-y-scroll pb-24">
{history ? (
<History history={history} />
) : (
<div className="h-full flex items-center justify-center text-center text-gray-500">
No history available
</div>
)}
</div>
<div className="flex-1/3 flex flex-col flex-grow gap-4">
{outputGuardrailResult && (
<div className="flex-0 w-full p-2 border border-blue-300 rounded-md bg-blue-50 text-blue-900 text-xs self-end shadow-sm">
<span className="font-semibold">Guardrail:</span>{' '}
{outputGuardrailResult?.message ||
JSON.stringify(outputGuardrailResult)}
</div>
)}
<div
className="overflow-scroll w-96 max-h-64 md:h-full md:max-h-none flex-1 p-4 border border-gray-300 rounded-lg [&_pre]:bg-gray-100 [&_pre]:p-4 [&_summary]:mb-2 [&>details]:border-b [&>details]:border-gray-200 [&>details]:py-2 text-xs"
id="eventLog"
>
{events?.map((event, index) => (
<details key={index}>
<summary>{event.type}</summary>
<pre>{JSON.stringify(event, null, 2)}</pre>
</details>
))}
</div>
</div>
</div>
</div>
</div>
);
}
54 changes: 54 additions & 0 deletions examples/realtime-tantsack-start/src/components/History.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import type { RealtimeItem } from '@openai/agents/realtime';
import { TextMessage } from './messages/TextMessage';
import { FunctionCallMessage } from './messages/FunctionCall';

export type HistoryProps = {
history: RealtimeItem[];
};

export function History({ history }: HistoryProps) {
return (
<div
className="overflow-y-scroll pl-4 flex-1 rounded-lg bg-white space-y-4 max-w-2xl"
id="chatHistory"
>
{history.map((item) => {
if (item.type === 'function_call') {
return <FunctionCallMessage message={item} key={item.itemId} />;
}

if (item.type === 'message') {
return (
<TextMessage
text={
item.content.length > 0
? item.content
.map((content) => {
if (
content.type === 'text' ||
content.type === 'input_text'
) {
return content.text;
}
if (
content.type === 'input_audio' ||
content.type === 'audio'
) {
return content.transcript ?? '⚫︎⚫︎⚫︎';
}
return '';
})
.join('\n')
: '⚫︎⚫︎⚫︎'
}
isUser={item.role === 'user'}
key={item.itemId}
/>
);
}

return null;
})}
</div>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import * as React from 'react';

const ClockIcon = (props: React.SVGProps<SVGSVGElement>) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width={24}
height={24}
fill="currentColor"
viewBox="0 0 24 24"
{...props}
>
<path
fillRule="evenodd"
d="M4 12a8 8 0 1 1 16 0 8 8 0 0 1-16 0Zm8-10C6.477 2 2 6.477 2 12s4.477 10 10 10 10-4.477 10-10S17.523 2 12 2Zm1 5a1 1 0 1 0-2 0v4.586l-2.207 2.207a1 1 0 1 0 1.414 1.414l2.5-2.5A1 1 0 0 0 13 12V7Z"
clipRule="evenodd"
/>
</svg>
);

export default ClockIcon;
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import * as React from 'react';

const FunctionsIcon = (props: React.SVGProps<SVGSVGElement>) => (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="currentColor"
{...props}
viewBox="0 0 24 24"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M7 3a4 4 0 0 0-4 4c0 .277.005.55.01.805l.001.038c.005.271.01.523.009.764-.003.488-.031.88-.108 1.207-.074.314-.186.54-.346.718-.16.177-.418.364-.875.517A.994.994 0 0 0 1 12a.998.998 0 0 0 .692.951c.438.147.69.328.847.503.159.176.273.402.35.717.08.327.114.722.122 1.211.005.297.001.577-.003.88C3.003 16.49 3 16.73 3 17a4 4 0 0 0 4 4 1 1 0 1 0 0-2 2 2 0 0 1-2-2c0-.204.003-.426.006-.653.004-.34.009-.69.004-.997-.009-.541-.046-1.111-.179-1.655-.136-.554-.378-1.104-.809-1.581a3.285 3.285 0 0 0-.1-.107c.044-.045.088-.09.13-.137.436-.485.676-1.04.807-1.6.128-.546.157-1.116.16-1.651a32.24 32.24 0 0 0-.008-.815v-.032C5.004 7.512 5 7.257 5 7a2 2 0 0 1 2-2 1 1 0 0 0 0-2Zm13.06 9c-.04.04-.08.08-.117.123-.44.482-.681 1.036-.811 1.594-.127.545-.154 1.115-.155 1.65 0 .269.005.544.011.812v.007c.006.273.012.542.012.814a2 2 0 0 1-2 2 1 1 0 1 0 0 2 4 4 0 0 0 4-4c0-.296-.006-.584-.012-.854v-.004c-.006-.274-.011-.527-.01-.77 0-.491.027-.88.101-1.2.072-.308.183-.528.341-.702.16-.174.421-.362.889-.519A.994.994 0 0 0 23 12a1 1 0 0 0-.692-.951c-.468-.157-.73-.345-.889-.52-.159-.173-.269-.393-.34-.7-.075-.321-.102-.71-.103-1.201 0-.243.005-.496.01-.77l.001-.004c.006-.27.012-.558.012-.854a4 4 0 0 0-4-4 1 1 0 1 0 0 2 2 2 0 0 1 2 2c0 .272-.006.54-.012.815v.006c-.006.268-.011.543-.01.811 0 .536.027 1.106.154 1.65.13.56.37 1.113.81 1.595.039.042.078.083.118.123Zm-5.084-5.217a1 1 0 0 1-.76 1.193c-.335.075-.534.22-.68.415-.166.218-.304.55-.397 1.042-.035.18-.062.37-.082.567h.443a1 1 0 1 1 0 2h-.507l.003.418c.002.27.004.547.004.832 0 1.466-.261 2.656-.882 3.5-.665.902-1.622 1.25-2.618 1.25a1 1 0 1 1 0-2c.504 0 .797-.152 1.007-.437.254-.344.493-1.029.493-2.313 0-.237-.002-.481-.004-.73l-.004-.52H10.5a1 1 0 1 1 0-2h.55c.027-.327.067-.644.124-.943.125-.653.346-1.318.767-1.873.44-.58 1.053-.985 1.842-1.16a1 1 0 0 1 1.193.759Z"
/>
</svg>
);

export default FunctionsIcon;
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import React from 'react';

import ClockIcon from '@/components/icons/ClockIcon';
import type { RealtimeToolCallItem } from '@openai/agents/realtime';
import FunctionsIcon from '@/components/icons/FunctionsIcon';

type FunctionCallMessageProps = {
message: RealtimeToolCallItem;
};

const getOutput = (message: RealtimeToolCallItem) => {
let output = message?.output;
try {
if (message.output) {
output = JSON.stringify(JSON.parse(message.output), null, 2);
}
} catch {
output = message.output;
}
return output;
};

export function FunctionCallMessage({ message }: FunctionCallMessageProps) {
const output = getOutput(message);

return (
<div className="flex flex-col w-[70%] relative mb-[8px]">
<div>
<div className="flex flex-col text-sm rounded-[16px]">
<div className="font-semibold p-3 pl-0 text-gray-700 rounded-b-none flex gap-2">
<div className="flex gap-2 items-center text-blue-500 ml-[-8px] fill-blue-500">
<FunctionsIcon width={16} height={16} />
<div className="text-sm font-medium">
{message.status === 'completed'
? `Called ${message.name}`
: `Calling ${message.name}...`}
</div>
</div>
</div>

<div className="bg-[#fafafa] rounded-xl py-2 ml-4 mt-2">
<div className="max-h-96 overflow-y-scroll text-xs border-b mx-6 p-2">
<pre>
{JSON.stringify(JSON.parse(message.arguments), null, 2)}
</pre>
</div>
<div className="max-h-80 overflow-y-scroll mx-6 p-2 text-xs">
{output ? (
<pre>{output}</pre>
) : (
<div className="text-zinc-500 flex items-center gap-2 py-2">
<ClockIcon width={16} height={16} /> Waiting for result...
</div>
)}
</div>
</div>
</div>
</div>
</div>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import clsx from 'clsx';

type TextMessageProps = {
text: string;
isUser: boolean;
};

export function TextMessage({ text, isUser }: TextMessageProps) {
return (
<div
className={clsx('flex flex-row gap-2', {
'justify-end py-2': isUser,
})}
>
<div
className={clsx('rounded-[16px]', {
'px-4 py-2 max-w-[90%] ml-4 text-stone--900 bg-[#ededed]': isUser,
'px-4 py-2 max-w-[90%] mr-4 text-black bg-white': !isUser,
})}
>
{text}
</div>
</div>
);
}
Loading