${posts
+ .filter((other) => other.slug !== post.slug)
+ .slice(0, 2)
+ .map(card)
+ .join('')}
+`,
+ schema,
+ post,
+ )
+}
+
+/** Render trusted, repository-authored Markdown to complete HTML at build time. */
+export function blogAssets(): Map {
+ const posts = readPosts()
+ const assets = new Map()
+ assets.set(
+ 'blog/index.html',
+ layout(
+ 'Voice AI blog',
+ 'Practical tutorials and platform comparisons for developers building voice agents.',
+ '/blog',
+ `
The orb-ui blog
Notes on building voice AI.
Practical tutorials, platform comparisons, and a closer look at the tools behind the conversation.
${posts.map(card).join('')}`,
+ {
+ '@context': 'https://schema.org',
+ '@type': 'Blog',
+ name: 'orb-ui blog',
+ url: `${origin}/blog`,
+ publisher: author,
+ },
+ ),
+ )
+ for (const post of posts) assets.set(`blog/${post.slug}/index.html`, article(post, posts))
+ for (const extension of ['css', 'js'])
+ assets.set(`blog/blog.${extension}`, readFileSync(`${directory}/blog.${extension}`, 'utf8'))
+ assets.set(
+ 'site-sitemap.xml',
+ `\n${['/', '/blog', ...posts.map((post) => `/blog/${post.slug}`)].map((path) => `${origin}${path}`).join('')}\n`,
+ )
+ return assets
+}
+
+export function blogPlugin(): Plugin {
+ return {
+ name: 'orb-ui-static-blog',
+ configurePreviewServer(server) {
+ server.middlewares.use((request, response, next) => {
+ const [path, query] = (request.url ?? '').split('?')
+ if (!/^\/blog(?:\/[a-z0-9-]+)?\/?$/.test(path)) return next()
+ const file = `${path.replace(/\/$/, '')}/index.html`
+ if (!existsSync(resolve(server.config.root, server.config.build.outDir, file.slice(1)))) {
+ response.statusCode = 404
+ response.end('Article not found')
+ return
+ }
+ request.url = `${file}${query ? `?${query}` : ''}`
+ next()
+ })
+ },
+ configureServer(server) {
+ server.watcher.add(directory)
+ server.middlewares.use((request, response, next) => {
+ const path = (request.url ?? '').split('?')[0]
+ if (path !== '/site-sitemap.xml' && path !== '/blog' && !path.startsWith('/blog/'))
+ return next()
+ try {
+ const assets = blogAssets()
+ const key = path.slice(1).replace(/\/$/, '')
+ const body = assets.get(key) ?? assets.get(`${key}/index.html`)
+ if (!body) {
+ response.statusCode = 404
+ response.end('Article not found')
+ return
+ }
+ const type = path.endsWith('.css')
+ ? 'text/css'
+ : path.endsWith('.js')
+ ? 'text/javascript'
+ : path.endsWith('.xml')
+ ? 'application/xml'
+ : 'text/html'
+ response.setHeader('Content-Type', `${type}; charset=utf-8`)
+ response.end(body)
+ } catch (error) {
+ next(error)
+ }
+ })
+ },
+ generateBundle() {
+ for (const [fileName, source] of blogAssets())
+ this.emitFile({ type: 'asset', fileName, source })
+ },
+ }
+}
diff --git a/demo/blog/posts/elevenlabs-conversational-ai-react.md b/demo/blog/posts/elevenlabs-conversational-ai-react.md
new file mode 100644
index 0000000..c096b5c
--- /dev/null
+++ b/demo/blog/posts/elevenlabs-conversational-ai-react.md
@@ -0,0 +1,377 @@
+---
+title: 'Build an ElevenLabs Conversational AI Agent in React'
+description: Build a React voice agent with ElevenLabs, a server-side conversation-token endpoint, WebRTC, transcript events, interruption handling, and safe session cleanup.
+date: '2026-09-28'
+category: Tutorial
+---
+
+**An ElevenLabs conversational AI app needs an agent, a server endpoint that issues a short-lived session credential, and a browser client that owns the microphone session.** This tutorial builds all three boundaries with React, Vite, Express, and the ElevenLabs JavaScript SDK.
+
+The result has Start and Stop controls, connection and speaking status, a live text-event feed, and cleanup when the component unmounts. It uses a private agent and explicitly selects WebRTC. The standard ElevenLabs API key stays on your server.
+
+Sources checked **September 28, 2026**. The example targets `@elevenlabs/client@1.9.0`. Its code is checked against that SDK and its lifecycle is tested with a simulated provider; an authenticated, billed ElevenLabs conversation has not been verified for this article. Run the final microphone and agent checks with your own account before deploying it.
+
+## How the connection works
+
+```text
+React → your POST /api/conversation-token → ElevenLabs token endpoint
+React + conversation token → ElevenLabs WebRTC session
+```
+
+Your backend uses the standard API key and a fixed agent ID to request a conversation token. The browser receives only the conversation token. Treat that token as a credential too: keep it in memory and avoid logging or persisting it.
+
+ElevenLabs also supports signed URLs for WebSocket sessions. They are a different credential path: use `signedUrl` with `connectionType: 'websocket'`, or `conversationToken` with `connectionType: 'webrtc'`. This example uses the latter.
+
+## 1. Create and configure an agent
+
+Create an agent in the [ElevenLabs dashboard](https://elevenlabs.io/app/conversational-ai). Choose a voice and language model, set a short first message, and give it a simple system prompt. For a first test, use an agent that answers questions about a small topic and has no tools with external side effects.
+
+Enable authentication for the agent and copy its agent ID. Create a server API key with the permissions required to obtain conversation tokens. Configure allowed domains for your intended deployment where applicable; domain restrictions do not replace authentication in your application.
+
+In the agent's Advanced settings, enable the client events used by the interface, including user transcripts, agent responses, and interruptions. Review the turn-taking and interruption settings for the behavior you want. If speech works but text events do not arrive, inspect these settings first.
+
+## 2. Create the React application
+
+Use Node.js 22.12 or newer and run:
+
+```bash
+npm create vite@latest elevenlabs-react-agent -- --template react-ts
+cd elevenlabs-react-agent
+npm install
+npm install @elevenlabs/client@1.9.0 express
+```
+
+Append `.env` to `.gitignore`. Create `.env` at the project root:
+
+```dotenv
+ELEVENLABS_API_KEY=replace_with_your_server_key
+ELEVENLABS_AGENT_ID=replace_with_your_private_agent_id
+```
+
+Do not prefix these variables with `VITE_`: Vite exposes variables with that prefix to browser code.
+
+## 3. Add a local conversation-token server
+
+Create `server.mjs` in the project root:
+
+```js
+import express from 'express'
+
+const apiKey = process.env.ELEVENLABS_API_KEY
+const agentId = process.env.ELEVENLABS_AGENT_ID
+if (!apiKey || !agentId) throw new Error('Set both ElevenLabs environment variables')
+
+const app = express()
+app.post('/api/conversation-token', async (req, res) => {
+ res.set('Cache-Control', 'no-store')
+ // This server is for the local tutorial only. Reject other browser origins.
+ if (req.get('origin') !== 'http://localhost:5173') {
+ return res.status(403).json({ error: 'Origin not allowed' })
+ }
+
+ try {
+ const url = new URL('https://api.elevenlabs.io/v1/convai/conversation/token')
+ url.searchParams.set('agent_id', agentId)
+ const upstream = await fetch(url, {
+ headers: { 'xi-api-key': apiKey },
+ signal: AbortSignal.timeout(10_000),
+ })
+ if (!upstream.ok) throw new Error('Token request failed')
+ const body = await upstream.json()
+ if (typeof body.token !== 'string' || !body.token) {
+ throw new Error('Missing conversation token')
+ }
+ return res.json({ token: body.token })
+ } catch {
+ // Do not forward provider responses or credentials to the browser.
+ return res.status(502).json({ error: 'Unable to start a conversation' })
+ }
+})
+
+app.listen(3001, '127.0.0.1', () => {
+ console.log('Local token server listening on port 3001')
+})
+```
+
+The server fixes the agent ID rather than accepting an arbitrary agent from the browser. It binds only to loopback and rejects requests from other browser origins. This is a local example, **not a public authentication system**. Before exposing the endpoint, require your application's authenticated session, authorize access to the agent, and enforce per-user usage limits.
+
+Replace `vite.config.ts` with:
+
+```ts
+import { defineConfig } from 'vite'
+import react from '@vitejs/plugin-react'
+
+export default defineConfig({
+ plugins: [react()],
+ server: {
+ host: 'localhost',
+ port: 5173,
+ strictPort: true,
+ proxy: { '/api': 'http://127.0.0.1:3001' },
+ },
+})
+```
+
+The browser uses `/api/conversation-token` on its own origin; Vite proxies it to Express during development. Production needs an equivalent route to your deployed backend.
+
+## 4. Own one voice session at a time
+
+Create `src/voice.ts`. Keeping the session controller outside React makes startup, cancellation, and cleanup explicit:
+
+```ts
+import { Conversation } from '@elevenlabs/client'
+
+type Session = Awaited>
+type Phase = 'idle' | 'connecting' | 'connected' | 'stopping'
+type Events = {
+ phase: (value: Phase) => void
+ mode: (value: 'speaking' | 'listening') => void
+ message: (value: string) => void
+ error: (value: string) => void
+}
+type Attempt = {
+ cancelled: boolean
+ ended: boolean
+ starting: boolean
+ abort: AbortController
+ session?: Session
+}
+
+export function createVoice(events: Events) {
+ let current: Attempt | undefined
+ const live = (attempt: Attempt) => current === attempt && !attempt.cancelled
+ const finish = (attempt: Attempt) => {
+ if (current !== attempt) return
+ current = undefined
+ events.phase('idle')
+ }
+ const end = async (session: Session) => {
+ try {
+ await session.endSession()
+ } catch {
+ events.error('Could not confirm session cleanup. Reload before reconnecting.')
+ // Keep the controller locked if cleanup could not be confirmed.
+ throw new Error('Session cleanup failed')
+ }
+ }
+
+ return {
+ async start() {
+ if (current) return
+ const attempt: Attempt = {
+ cancelled: false,
+ ended: false,
+ starting: true,
+ abort: new AbortController(),
+ }
+ current = attempt
+ events.error('')
+ events.phase('connecting')
+ try {
+ const response = await fetch('/api/conversation-token', {
+ method: 'POST',
+ signal: attempt.abort.signal,
+ })
+ if (!response.ok) throw new Error('Unable to obtain a conversation token')
+ const { token } = await response.json()
+ if (typeof token !== 'string' || !token) throw new Error('Invalid token response')
+ if (!live(attempt)) return
+ const session = await Conversation.startSession({
+ conversationToken: token,
+ connectionType: 'webrtc',
+ onMessage: ({ role, message }) => {
+ if (live(attempt)) events.message(`${role}: ${message}`)
+ },
+ onModeChange: ({ mode }) => {
+ if (live(attempt)) events.mode(mode)
+ },
+ onInterruption: () => {
+ if (live(attempt)) events.mode('listening')
+ },
+ onError: (message) => {
+ if (live(attempt)) events.error(message)
+ },
+ onDisconnect: () => {
+ attempt.ended = true
+ if (!attempt.starting && !attempt.cancelled) finish(attempt)
+ },
+ })
+ attempt.session = session
+ if (live(attempt) && !attempt.ended) {
+ events.phase('connected')
+ }
+ } catch (error) {
+ attempt.ended = true
+ if (live(attempt)) {
+ events.error(error instanceof Error ? error.message : 'Connection failed')
+ }
+ } finally {
+ attempt.starting = false
+ if (attempt.cancelled || attempt.ended) {
+ events.phase('stopping')
+ try {
+ if (attempt.session) await end(attempt.session)
+ finish(attempt)
+ } catch {
+ /* stay locked */
+ }
+ }
+ }
+ },
+ async stop() {
+ const attempt = current
+ if (!attempt || attempt.cancelled) return
+ attempt.cancelled = true
+ attempt.abort.abort()
+ events.phase('stopping')
+ if (attempt.session && !attempt.starting) {
+ try {
+ await end(attempt.session)
+ finish(attempt)
+ } catch {
+ /* stay locked */
+ }
+ }
+ },
+ }
+}
+```
+
+Stopping during the token request aborts that request. Once SDK startup has begun, this example cannot immediately cancel a pending microphone permission prompt or WebRTC negotiation. It keeps Start locked until startup settles, then ends any late session. Dismiss a pending permission prompt if necessary; do not start another connection around it.
+
+The SDK owns microphone capture and playback. There is no extra `getUserMedia()` call here that would create a second stream for your application to clean up.
+
+## 5. Add the React interface
+
+Replace `src/App.tsx` with:
+
+```tsx
+import { useEffect, useRef, useState } from 'react'
+import { createVoice } from './voice'
+
+export default function App() {
+ const voice = useRef | null>(null)
+ const [phase, setPhase] = useState('idle')
+ const [mode, setMode] = useState('listening')
+ const [error, setError] = useState('')
+ const [messages, setMessages] = useState([])
+
+ useEffect(() => {
+ let mounted = true
+ const controller = createVoice({
+ phase: (value) => {
+ if (mounted) setPhase(value)
+ },
+ mode: (value) => {
+ if (mounted) setMode(value)
+ },
+ error: (value) => {
+ if (mounted) setError(value)
+ },
+ message: (value) => {
+ if (mounted) setMessages((items) => [...items.slice(-49), value])
+ },
+ })
+ voice.current = controller
+ return () => {
+ mounted = false
+ voice.current = null
+ void controller.stop()
+ }
+ }, [])
+
+ function start() {
+ setMessages([])
+ setMode('listening')
+ void voice.current?.start()
+ }
+
+ return (
+
+
Talk to your ElevenLabs agent
+
Start enables your microphone and sends audio to the agent.
+
{phase === 'connected' ? mode : phase}
+
+
+ {error &&
{error}
}
+
Live text events
+
+ {messages.map((message, index) => (
+
{message}
+ ))}
+
+
+ )
+}
+```
+
+Remove the generated contents of `src/index.css` to keep Vite's starter styling from controlling the layout. Keep the generated `src/main.tsx`; React Strict Mode can remain enabled.
+
+This is a bounded **event feed**, not a finalized transcript. `onMessage` may include tentative text, and interrupted agent replies can be corrected later. For a durable transcript, reconcile event IDs and correction events or retrieve the final conversation record after the session. Do not treat every received text fragment as words the user actually heard.
+
+## 6. Run and test a conversation
+
+In one terminal:
+
+```bash
+node --env-file=.env server.mjs
+```
+
+In another:
+
+```bash
+npm run dev
+```
+
+Open `http://localhost:5173`, click **Start conversation**, and allow microphone access. Expect the agent's first message and a change between listening and speaking. Click **Stop** and confirm the browser's microphone indicator clears.
+
+Test these cases before calling the integration ready:
+
+| Action | Expected result |
+| --------------------------------------- | ----------------------------------------------------------------------------- |
+| Deny microphone permission | An error appears and a retry becomes possible |
+| Stop while the token request is pending | No conversation starts; the UI returns to idle |
+| Stop while SDK startup is pending | Start stays locked; any late session is ended |
+| Speak while the agent is speaking | With interruptions enabled, playback stops and the agent handles the new turn |
+| Stop and then start again | Only one provider conversation is active |
+| Navigate away from the component | The controller stops the active or pending session |
+| Use an invalid server key | A generic token error appears without exposing the key or upstream response |
+
+Check the ElevenLabs conversation dashboard as well as the browser. A disconnected interface alone does not prove your billing or server-side conversation has ended as intended.
+
+## Common ElevenLabs React integration problems
+
+**401 or 403 from the token flow:** verify the server key's permissions, private agent ID, and agent authentication settings. For this local server, open exactly `http://localhost:5173`; a different hostname fails the origin check.
+
+**No microphone prompt:** use localhost or HTTPS, check browser permissions, and start from a user click. Test embedded pages separately because iframe permissions can restrict microphone access.
+
+**Audio works but transcript events are missing:** check the agent's enabled client events. The SDK callback alone does not enable every event in the agent configuration.
+
+**Stop remains pending:** dismiss a pending microphone prompt and allow SDK startup to resolve or reject. This example deliberately prevents a second concurrent startup. If cleanup reports an error, reload before reconnecting.
+
+**The agent does not stop speaking when interrupted:** check turn-taking settings and test with headphones to separate genuine speech from speaker echo. The interface's interruption callback updates a label; it does not implement server-side turn detection.
+
+## Deploy the application
+
+Build the frontend with `npm run build`. Deploy the token route on a backend and route `/api/conversation-token` to it; Vite's development proxy is not part of the static build. Use HTTPS and keep the standard API key in the backend's secret store.
+
+Replace the local origin guard with your production origin policy and real application authentication. Add authorization, request limits, and usage controls before allowing users to create billed sessions. Protect backend tools independently of what the agent says or what arguments it sends.
+
+If you want an animated voice indicator, follow the [ElevenLabs orb-ui adapter guide](/docs/adapters/elevenlabs). Choose one session owner: either adapt the existing conversation's events into controlled UI state or replace the controller with the adapter-owned connection. Do not start a second conversation just to display an orb.
+
+For alternative architectures, see [voice AI platforms compared](/blog/voice-ai-platforms) and the [OpenAI Realtime React tutorial](/blog/openai-realtime-api-tutorial).
+
+## Sources
+
+- [ElevenLabs JavaScript SDK](https://elevenlabs.io/docs/eleven-agents/libraries/java-script)
+- [ElevenLabs React SDK](https://elevenlabs.io/docs/eleven-agents/libraries/react) — an alternative if you prefer provider hooks
+- [Agent authentication](https://elevenlabs.io/docs/eleven-agents/customization/authentication)
+- [Conversation flow and interruptions](https://elevenlabs.io/docs/eleven-agents/customization/conversation-flow)
+- [Client events](https://elevenlabs.io/docs/eleven-agents/customization/events)
diff --git a/demo/blog/posts/openai-realtime-api-tutorial.md b/demo/blog/posts/openai-realtime-api-tutorial.md
new file mode 100644
index 0000000..cb867c1
--- /dev/null
+++ b/demo/blog/posts/openai-realtime-api-tutorial.md
@@ -0,0 +1,368 @@
+---
+title: 'OpenAI Realtime API Tutorial: React and WebRTC'
+description: Build a React voice agent with the OpenAI Realtime API, WebRTC, a server-side token endpoint, interruptions, function calling, and explicit cleanup.
+date: '2026-09-28'
+category: Tutorial
+---
+
+The OpenAI Realtime API lets a voice agent receive microphone audio and speak back over one persistent connection. This tutorial builds a React application with native WebRTC, a small Node.js server, and a harmless function the agent can call to read the browser's local time.
+
+You do not need orb-ui to follow the tutorial. At the end, you can choose an orb-ui adapter if you want an audio-reactive interface without maintaining the connection code yourself.
+
+API and pricing sources checked **September 28, 2026**. This guide uses **`gpt-realtime-2.1` and the GA Realtime API**. GPT-Live has a different protocol; see the [GPT-Live adapter guide](/docs/adapters/openai-live) if that is the API you intend to use.
+
+## What you will build
+
+The application has Start and Stop controls, visible connection status, assistant audio playback, automatic turn detection, and function calling. Its connection path is:
+
+1. React asks for microphone access after a click.
+2. Your server creates a short-lived client secret with `POST /v1/realtime/client_secrets`.
+3. The browser exchanges a WebRTC SDP offer for an answer at `POST /v1/realtime/calls`, using that secret.
+4. Microphone and assistant audio use media tracks. JSON session events and tool results use a data channel.
+
+The standard OpenAI API key stays on your server. The browser receives only a short-lived credential. OpenAI also offers a [unified connection flow](https://developers.openai.com/api/docs/guides/voice-webrtc?api=realtime) where your server exchanges the SDP directly; this example uses client secrets to match the orb-ui adapter's connection path.
+
+## 1. Create the React project
+
+Use Node.js 22.12 or later, an OpenAI API project with Realtime access and billing configured, and a browser with a microphone. API usage is billed separately from a ChatGPT subscription.
+
+```bash
+npm create vite@latest realtime-voice-demo -- --template react
+cd realtime-voice-demo
+npm install
+npm install express
+```
+
+Create `.env` in the project root and add your server key:
+
+```dotenv
+OPENAI_API_KEY=replace-with-your-project-key
+```
+
+Add `.env` to `.gitignore`. Do not prefix the key with `VITE_`: Vite exposes those variables to browser code. Do not commit or paste your key into `src/App.jsx`.
+
+Replace `vite.config.js` with this configuration. The fixed port makes the server's local origin check predictable:
+
+```js
+import { defineConfig } from 'vite'
+import react from '@vitejs/plugin-react'
+
+export default defineConfig({
+ plugins: [react()],
+ server: {
+ host: '127.0.0.1',
+ port: 5173,
+ strictPort: true,
+ proxy: { '/api': 'http://127.0.0.1:3001' },
+ },
+})
+```
+
+## 2. Create the server-side token endpoint
+
+Save this as `server.mjs` in the project root. It binds only to your machine and accepts requests from the local Vite page. It sets the model, voice, turn detection, and available tool on the server.
+
+```js
+import express from 'express'
+
+const app = express()
+
+app.post('/api/realtime-token', async (req, res) => {
+ res.set('Cache-Control', 'no-store')
+ if (req.headers.origin !== 'http://127.0.0.1:5173') {
+ return res.status(403).json({ error: 'Unexpected request origin' })
+ }
+ if (!process.env.OPENAI_API_KEY) {
+ return res.status(503).json({ error: 'Set OPENAI_API_KEY on the server' })
+ }
+
+ try {
+ const response = await fetch('https://api.openai.com/v1/realtime/client_secrets', {
+ method: 'POST',
+ signal: AbortSignal.timeout(10000),
+ headers: {
+ Authorization: `Bearer ${process.env.OPENAI_API_KEY}`,
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({
+ session: {
+ type: 'realtime',
+ model: 'gpt-realtime-2.1',
+ instructions: 'Answer briefly. Use get_local_time when asked for the local time.',
+ audio: {
+ input: {
+ turn_detection: {
+ type: 'server_vad',
+ create_response: true,
+ interrupt_response: true,
+ },
+ },
+ output: { voice: 'marin' },
+ },
+ tools: [
+ {
+ type: 'function',
+ name: 'get_local_time',
+ description: 'Read the current time and timezone from the user browser.',
+ parameters: {
+ type: 'object',
+ properties: {},
+ required: [],
+ additionalProperties: false,
+ },
+ },
+ ],
+ tool_choice: 'auto',
+ },
+ }),
+ })
+ if (!response.ok) {
+ console.error('Realtime token request failed:', response.status)
+ return res.status(response.status).json({ error: 'Could not create a Realtime token' })
+ }
+ const data = await response.json()
+ if (typeof data.value !== 'string') throw new Error('Missing client secret')
+ return res.json({ value: data.value })
+ } catch {
+ return res.status(502).json({ error: 'Realtime token service unavailable' })
+ }
+})
+
+app.listen(3001, '127.0.0.1', () => console.log('Token server on http://127.0.0.1:3001'))
+```
+
+This is a local learning server. Before deploying it, add application authentication, per-user authorization and rate limits, and HTTPS. An origin check does not authenticate a user. Keep credentials out of logs and use a stable, privacy-preserving [safety identifier](https://developers.openai.com/api/docs/guides/safety-best-practices#implement-safety-identifiers) from your trusted backend when associating sessions with end users.
+
+## 3. Connect React to the Realtime API
+
+Replace `src/App.jsx` with the following. Start creates a fresh session; Stop aborts pending requests and releases the microphone, audio element, data channel, and peer connection. The same cleanup runs when React unmounts the component.
+
+```jsx
+import { useEffect, useRef, useState } from 'react'
+
+export default function App() {
+ const session = useRef(null)
+ const audio = useRef(null)
+ const [active, setActive] = useState(false)
+ const [status, setStatus] = useState('Idle')
+
+ function release() {
+ const current = session.current
+ session.current = null
+ if (!current) return
+ clearTimeout(current.timer)
+ current.abort.abort()
+ current.stream?.getTracks().forEach((track) => track.stop())
+ current.channel?.close()
+ current.peer.close()
+ if (audio.current) audio.current.srcObject = null
+ }
+
+ useEffect(() => () => release(), [])
+
+ function stop() {
+ release()
+ setActive(false)
+ setStatus('Stopped')
+ }
+
+ async function start() {
+ if (session.current) return
+ const current = { peer: new RTCPeerConnection(), abort: new AbortController() }
+ session.current = current
+ setActive(true)
+ setStatus('Connecting')
+ const isCurrent = () => session.current === current
+ const fail = (message) => {
+ if (!isCurrent()) return
+ release()
+ setActive(false)
+ setStatus(message)
+ }
+ current.timer = setTimeout(() => fail('Connection timed out. Try again.'), 20000)
+
+ try {
+ const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
+ if (!isCurrent()) {
+ stream.getTracks().forEach((track) => track.stop())
+ return
+ }
+ current.stream = stream
+ stream.getAudioTracks().forEach((track) => current.peer.addTrack(track, stream))
+ current.peer.ontrack = ({ track }) => {
+ if (!isCurrent() || !audio.current) return
+ audio.current.srcObject = new MediaStream([track])
+ audio.current.play().catch(() => {
+ if (isCurrent()) setStatus('Connected. Press play below to hear the agent.')
+ })
+ }
+ current.peer.onconnectionstatechange = () => {
+ if (['failed', 'disconnected', 'closed'].includes(current.peer.connectionState)) {
+ fail('Connection ended. Start a new session.')
+ }
+ }
+
+ const channel = current.peer.createDataChannel('oai-events')
+ current.channel = channel
+ channel.onclose = () => fail('Event channel closed. Start a new session.')
+ channel.onerror = () => fail('Event channel failed. Try again.')
+ channel.onmessage = ({ data }) => {
+ if (!isCurrent()) return
+ const event = JSON.parse(data)
+ if (event.type === 'session.created') {
+ clearTimeout(current.timer)
+ setStatus('Connected. Ask a question.')
+ }
+ if (event.type === 'input_audio_buffer.speech_started') setStatus('Listening')
+ if (event.type === 'input_audio_buffer.speech_stopped') setStatus('Thinking')
+ if (event.type === 'output_audio_buffer.started') setStatus('Speaking')
+ if (['output_audio_buffer.stopped', 'output_audio_buffer.cleared'].includes(event.type)) {
+ setStatus('Listening')
+ }
+ if (event.type === 'error') fail(event.error?.message ?? 'Realtime error')
+
+ if (event.type === 'response.done' && event.response.status === 'completed') {
+ const calls = (event.response.output ?? []).filter(
+ (item) => item.type === 'function_call',
+ )
+ for (const call of calls) {
+ // Only this harmless, explicitly registered browser tool is allowed.
+ const result =
+ call.name === 'get_local_time'
+ ? {
+ time: new Date().toLocaleString(),
+ timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
+ }
+ : { error: 'Unknown tool' }
+ channel.send(
+ JSON.stringify({
+ type: 'conversation.item.create',
+ item: {
+ type: 'function_call_output',
+ call_id: call.call_id,
+ output: JSON.stringify(result),
+ },
+ }),
+ )
+ }
+ if (calls.length) channel.send(JSON.stringify({ type: 'response.create' }))
+ }
+ }
+
+ const offer = await current.peer.createOffer()
+ if (!isCurrent()) return
+ await current.peer.setLocalDescription(offer)
+ if (!isCurrent()) return
+ const tokenResponse = await fetch('/api/realtime-token', {
+ method: 'POST',
+ signal: current.abort.signal,
+ })
+ if (!tokenResponse.ok) throw new Error(`Token request failed (${tokenResponse.status})`)
+ const { value } = await tokenResponse.json()
+ if (!isCurrent()) return
+ const response = await fetch('https://api.openai.com/v1/realtime/calls', {
+ method: 'POST',
+ signal: current.abort.signal,
+ headers: { Authorization: `Bearer ${value}`, 'Content-Type': 'application/sdp' },
+ body: offer.sdp,
+ })
+ if (!response.ok) throw new Error(`WebRTC negotiation failed (${response.status})`)
+ const sdp = await response.text()
+ if (!isCurrent()) return
+ await current.peer.setRemoteDescription({ type: 'answer', sdp })
+ } catch (error) {
+ fail(error instanceof Error ? error.message : 'Could not connect')
+ }
+ }
+
+ return (
+
+
Realtime voice agent
+
{status}
+
+
+
+
+ )
+}
+```
+
+## 4. Run and check the conversation
+
+In one terminal, start the token server:
+
+```bash
+node --env-file=.env server.mjs
+```
+
+In a second terminal, start Vite:
+
+```bash
+npm run dev
+```
+
+Open **http://127.0.0.1:5173**, click **Start conversation**, and allow microphone access. Say “Explain WebRTC in one sentence.” The page should move through Listening, Thinking, and Speaking as you hear the answer.
+
+Then check these cases:
+
+| Action | Expected behavior |
+| ------------------------------------- | ------------------------------------------------------------------------------------ |
+| Ask “What time is it here?” | The model calls `get_local_time`, receives its result, and speaks the answer. |
+| Ask for a long answer, then interrupt | The API cancels the ongoing response and handles unplayed audio through WebRTC. |
+| Stop, then start again | The old microphone tracks close and a fresh client secret is requested. |
+| Stop while connecting | Pending requests abort; microphone access granted afterward is immediately released. |
+| Deny microphone access | An error appears and Start becomes available again. |
+
+Each connected conversation uses paid API resources. Stop when finished.
+
+## How interruptions and function calling work
+
+`server_vad` detects speech boundaries. With `create_response` and `interrupt_response` enabled, the API responds after a turn and interrupts an answer when new speech begins. For WebRTC, the server manages output buffering and [truncates unplayed audio](https://developers.openai.com/api/docs/guides/realtime-conversations#interruption-and-truncation). You do not need to stream PCM chunks or calculate playback truncation yourself.
+
+The tool handler waits for a completed `response.done`, collects function calls, and sends a `function_call_output` for each matching `call_id`. One subsequent `response.create` asks the model to speak using those results. It ignores cancelled responses so an interrupted answer does not execute a stale tool call.
+
+Reading a browser clock is safe to demonstrate client-side. Put database access, private credentials, and actions such as booking or payment on an authenticated server. Validate arguments and user permissions independently of the model; use [server-side controls](https://developers.openai.com/api/docs/guides/voice-server-controls?api=realtime) for trusted tools. The browser is not an authorization boundary.
+
+## OpenAI Realtime API pricing
+
+[OpenAI's pricing page](https://developers.openai.com/api/docs/pricing) lists these `gpt-realtime-2.1` rates as of September 28, 2026, in USD per one million tokens:
+
+| Modality | Input | Cached input | Output |
+| -------- | ------- | ------------ | ------- |
+| Audio | \$32.00 | \$0.40 | \$64.00 |
+| Text | \$4.00 | \$0.40 | \$24.00 |
+
+For example, **1,000 uncached audio input tokens and 1,000 audio output tokens cost \$0.096 for those audio tokens**: `(1,000 × 32 + 1,000 × 64) / 1,000,000`. Text, reasoning, additional context, and any separate services add to that amount. This is a token arithmetic example, not a measured per-minute price.
+
+Realtime does not have one universal per-minute cost: speaking time, conversation context, caching, and reasoning effort affect the bill. Inspect response usage and your project billing dashboard using representative conversations. Set spending controls, keep answers concise, and end unused sessions. GPT-Live's duration pricing is a different billing model.
+
+## Troubleshooting
+
+| Symptom | Check |
+| -------------------------------- | ----------------------------------------------------------------------------------------------------------- |
+| Token request returns 403 | Open `127.0.0.1:5173`, not `localhost:5173`; this example intentionally checks that exact origin. |
+| Token request returns 401 or 429 | Check the server key, model access, available billing credit, and API rate limits. |
+| WebRTC negotiation fails | Mint a fresh secret, check the HTTP status, and confirm your network permits WebRTC. |
+| Connected but silent | Press play on the audio controls; check the microphone, speaker, permissions, and browser autoplay rules. |
+| Agent does not call the tool | Ask explicitly for local time; inspect `response.done` and the server's tool configuration. |
+| Old tutorial code fails | Use GA `client_secrets` and `calls` endpoints; do not combine Realtime events with GPT-Live session events. |
+
+## Add an orb-ui interface
+
+For a voice interface with normalized listening, thinking, speaking, and audio levels, the [OpenAI Realtime adapter](/docs/adapters/openai-realtime) can own the browser connection. Its `getClientSecret` callback can use the endpoint above **after removing the demo tool configuration**, because this adapter does not implement the tutorial's custom function handler.
+
+Choose one connection owner. Replace the raw WebRTC client when adopting the adapter; running both would open two sessions. If you need to keep custom function handling, retain your own connection and render orb-ui in [controlled mode](/docs/adapters/custom) instead.
+
+## Related guides and sources
+
+- [Build an ElevenLabs conversational AI agent in React](/blog/elevenlabs-conversational-ai-react)
+- [Choose a voice AI platform](/blog/voice-ai-platforms)
+- [OpenAI WebRTC connection guide](https://developers.openai.com/api/docs/guides/voice-webrtc?api=realtime)
+- [OpenAI Realtime conversations and function calling](https://developers.openai.com/api/docs/guides/realtime-conversations)
+- [GPT-Realtime-2.1 model](https://developers.openai.com/api/docs/models/gpt-realtime-2.1)
+- [Voice agent UI architecture](/docs/guides/voice-agent-ui)
+- [Vapi vs Retell comparison](/blog/vapi-vs-retell)
diff --git a/demo/blog/posts/vapi-alternatives.md b/demo/blog/posts/vapi-alternatives.md
new file mode 100644
index 0000000..af4e434
--- /dev/null
+++ b/demo/blog/posts/vapi-alternatives.md
@@ -0,0 +1,110 @@
+---
+title: 'Vapi Alternatives: Managed Platforms and Open-Source Options'
+description: Compare Retell, ElevenLabs, LiveKit, Pipecat, and direct realtime APIs as Vapi alternatives, with migration tradeoffs for web and phone agents.
+date: '2026-09-28'
+category: Comparison
+---
+
+**Shortlist Retell for managed phone-agent workflows, ElevenLabs for a managed conversational agent with its voice stack, LiveKit for room-based realtime applications, and Pipecat for a Python pipeline you control.** A direct realtime model API is another option when you want to build the application orchestration yourself.
+
+These are alternatives at different layers. Moving from Vapi to another managed platform changes configuration and integrations. Moving to an open-source framework also transfers deployment and operational responsibilities to your team. Start with the reason you want to leave before comparing feature lists.
+
+Sources checked **September 28, 2026**. This guide is written by the maintainers of orb-ui, a React voice UI library with adapters for several of these providers. Recommendations are based on official documentation, not a controlled latency benchmark or a claim that one platform is cheapest.
+
+## Which Vapi alternative fits your reason for switching?
+
+| Your reason | First option to evaluate | What to validate before migrating |
+| ------------------------------------------------------------------ | ------------------------ | ------------------------------------------------------------------------------- |
+| You want explicit conversation flows and managed phone operations | Retell AI | Transfers, phone-number migration, tool behavior, and custom-LLM limitations |
+| You want ElevenLabs voices and agent configuration in one service | ElevenLabs Agents | Your languages, interruption behavior, knowledge retrieval, and telephony route |
+| You need browser/mobile media sessions and control over agent code | LiveKit Agents | Room/token/dispatch setup, agent hosting, and provider integrations |
+| You want to customize the speech pipeline in Python | Pipecat | Transport, processor behavior, deployment, and monitoring ownership |
+| You want to build directly around a realtime speech model | OpenAI Realtime API | Session lifecycle, tools, transcripts, and phone operations you must implement |
+
+For a detailed two-platform comparison, read [Vapi vs Retell](/blog/vapi-vs-retell). For a broader architecture decision, use the [voice AI platform guide](/blog/voice-ai-platforms).
+
+## Retell AI: a managed alternative for phone workflows
+
+[Retell](https://docs.retellai.com/general/introduction) offers single-prompt agents and conversation flows with nodes and transitions. It also documents phone transfers, imported numbers, custom telephony, and browser calling. That makes it a practical candidate when you want to keep a managed operating model while changing how calls are configured.
+
+A useful migration pilot is one existing inbound call flow: identify the caller, collect a request, call your backend, and transfer to a person when necessary. Recreate the successful path and the failure paths before moving a phone number. In particular, test a failed tool request, an unavailable transfer destination, and a caller who changes their answer halfway through.
+
+**The main migration trap is assuming custom LLM integrations are interchangeable.** Vapi documents an OpenAI-compatible endpoint; Retell documents a custom LLM WebSocket interface. Retell also documents limitations for custom LLM agents, including built-in simulation and batch testing. Budget for adapting your backend and replacing any testing features your chosen configuration does not support.
+
+Choose Retell for its workflow and operating fit, then compare your full bill. A lower headline price is not enough evidence to migrate.
+
+## ElevenLabs: a managed agent around its voice stack
+
+[ElevenLabs Agents](https://elevenlabs.io/docs/eleven-agents/overview) combines speech recognition, a selected or custom language model, text-to-speech, and turn-taking. It includes agent configuration, knowledge, tools, workflows, web SDKs, and phone integrations.
+
+This is worth evaluating if you already use ElevenLabs voices through Vapi and want to compare a more integrated setup. Start by porting one prompt, one backend tool, and a small set of representative knowledge documents. Compare whether the agent completes the same tasks; using the same voice does not guarantee equivalent turn-taking, retrieval, or tool execution.
+
+For browser applications, use a server-issued conversation token for private WebRTC agents or a signed URL for WebSocket sessions. Our [ElevenLabs React tutorial](/blog/elevenlabs-conversational-ai-react) walks through that boundary and a complete client.
+
+Do not assume that a voice subscription, a conversational-agent plan, and third-party model usage have identical billing. Confirm the selected agent configuration and deployment terms. ElevenLabs also documents enterprise private deployment options; that is a procurement discussion, separate from running an open-source framework yourself.
+
+## LiveKit Agents: application and media control
+
+[LiveKit Agents](https://docs.livekit.io/agents/start/voice-ai/) is an open-source framework with Python and Node.js options. Agents and users participate in realtime rooms, with browser and native client SDKs, agent dispatch, and SIP telephony integration. You can use LiveKit Cloud or assemble a self-hosted deployment.
+
+Evaluate it when your application needs more control over the session: custom agent logic, multiple participants, or voice alongside other realtime media. The change from Vapi is architectural. Your backend issues participant credentials, your application joins a room, and an agent process handles the conversation.
+
+Self-hosting the media server is only part of that deployment. You also need agent workers, scaling, monitoring, network configuration, and access to the models your agent uses. Cloud-specific capabilities may need replacements in a self-hosted setup; the [official quickstart](https://docs.livekit.io/agents/start/voice-ai/) calls out differences explicitly.
+
+For a pilot, build a browser session first, then add telephony. Verify that dispatch starts exactly one intended agent, credentials cannot join arbitrary rooms, and the worker exits when a session ends. Our [LiveKit adapter reference](/docs/adapters/livekit) covers the optional React orb integration.
+
+## Pipecat: control over a Python speech pipeline
+
+[Pipecat](https://docs.pipecat.ai/overview/pipecat) is an open-source Python framework that connects transports and processors into a realtime pipeline. It can compose speech recognition, an LLM, and text-to-speech, or integrate realtime model services. Browser clients connect through supported transports and client SDKs.
+
+Evaluate it when you need to change how frames move through the conversation: custom audio processing, provider selection, context handling, or specialized tool behavior. It is a framework for building the agent application, so there is more code to own than an assistant configuration in a managed platform.
+
+Open source does not require self-hosting everything. [Pipecat Cloud](https://docs.pipecat.ai/overview/cloud) provides managed agent hosting; self-hosting and enterprise deployment options are separate choices. Model inference, media transport, phone service, and operations still have costs whichever hosting model you choose.
+
+Port the smallest complete workflow first. Test interruption while speech is playing, tool responses that arrive late, and cleanup after the browser disconnects. If you use orb-ui, its [Pipecat adapter](/docs/adapters/pipecat) consumes client events while your app retains transport and backend ownership.
+
+## Direct realtime APIs: fewer layers, more application work
+
+A direct API such as OpenAI Realtime can be a good candidate for a focused browser voice feature. Your server creates a short-lived client credential; the browser establishes a realtime session; your application handles UI, tools, and lifecycle. See our [React/WebRTC tutorial](/blog/openai-realtime-api-tutorial) for a complete starting point.
+
+This is not a drop-in replacement for a managed phone-agent platform. You need to account for the operating features your Vapi application actually uses: routing, transfers, retries, call review, evaluations, and billing controls. Some capabilities exist through provider APIs or other infrastructure, but assembling them is part of your implementation.
+
+Choose this route when that control is useful to the application and your team can maintain it. Removing an orchestration fee does not establish a lower total cost.
+
+## How to compare migration cost
+
+Use the same workload for every candidate. Record your language, approximate call duration, model, voice, tool usage, transfer behavior, peak concurrency, and whether calls use the browser or phone network.
+
+Calculate:
+
+```text
+Monthly operating cost =
+ platform and subscription charges
+ + speech/model usage
+ + media and telephony charges
+ + hosting, concurrency, and add-ons
+ + engineering and ongoing operations
+```
+
+Avoid adding bundled components twice. Use each provider's actual usage report from your pilot to map the formula to its billing model. Compare **cost per completed task** alongside cost per minute: a cheap conversation that repeats questions or fails to finish can cost more in practice.
+
+Migration effort is separate from steady-state cost. Include rewriting tool schemas, authenticating webhooks, changing transcript consumers, porting prompts and knowledge, moving phone numbers, and rebuilding dashboards. Keep your current provider available until the replacement can handle the same failure cases.
+
+## A migration pilot you can finish this week
+
+1. **Pick two candidates and one workflow.** Choose candidates that solve the specific problem with your current setup.
+2. **Create a fixed conversation set.** Include interruptions, silence, accented speech, a failed tool, and a handoff. Use the same inputs and expected outcomes for each candidate.
+3. **Measure the full session.** Record completed tasks, first audible response, turn gaps, tool failures, disconnects, and billed usage. Define your timing boundaries so the results are comparable.
+4. **Test the production constraints.** Include the expected concurrent calls, authentication, number/carrier configuration, and data retention requirements.
+5. **Migrate a limited slice.** Keep rollback possible and compare real results before moving all traffic.
+
+There is no universal best Vapi alternative. For managed operations, start with Retell or ElevenLabs. For ownership of agent code and deployment, evaluate LiveKit or Pipecat. For a narrow browser feature, include a direct realtime API in the pilot.
+
+## Sources
+
+- [Vapi documentation](https://docs.vapi.ai/introduction), [custom LLMs](https://docs.vapi.ai/customization/custom-llm/using-your-server), and [pricing](https://vapi.ai/pricing)
+- [Retell conversation flows](https://docs.retellai.com/build/conversation-flow), [custom LLMs](https://docs.retellai.com/integrate-llm/overview), and [pricing](https://www.retellai.com/pricing)
+- [ElevenLabs Agents overview](https://elevenlabs.io/docs/eleven-agents/overview), [authentication](https://elevenlabs.io/docs/eleven-agents/customization/authentication), and [private deployment](https://elevenlabs.io/docs/overview/capabilities/private-deployment)
+- [LiveKit voice AI quickstart](https://docs.livekit.io/agents/start/voice-ai/) and [self-hosting](https://docs.livekit.io/transport/self-hosting/)
+- [Pipecat framework](https://docs.pipecat.ai/overview/pipecat), [Cloud](https://docs.pipecat.ai/overview/cloud), and [deployment](https://docs.pipecat.ai/pipecat/deployment/overview)
+- [OpenAI Realtime API](https://developers.openai.com/api/docs/guides/realtime)
diff --git a/demo/blog/posts/vapi-vs-retell.md b/demo/blog/posts/vapi-vs-retell.md
new file mode 100644
index 0000000..dd53d6e
--- /dev/null
+++ b/demo/blog/posts/vapi-vs-retell.md
@@ -0,0 +1,137 @@
+---
+title: 'Vapi vs Retell: Pricing, Features, and Developer Tradeoffs'
+description: Compare Vapi and Retell AI pricing, browser SDKs, custom LLMs, call routing, concurrency, and hosting, with sourced costs and practical selection criteria.
+date: '2026-09-28'
+category: Comparison
+---
+
+**Start with Vapi if choosing and integrating individual speech and model providers is central to your application. Start with Retell if your team wants to configure a structured call flow and operate it through an integrated platform.** Both support browser voice applications, phone calls, APIs, and custom backends. Neither choice removes the need to test your real conversations.
+
+The useful pricing comparison is the cost of the same workload with the same requirements. Vapi's \$0.05/minute hosting fee and Retell's advertised \$0.07–\$0.31/minute range do not describe identical bundles.
+
+Sources checked **September 28, 2026**. This is a documentation-based comparison by the maintainers of orb-ui, a React voice UI library with a built-in Vapi adapter and custom integration support. We have not run a controlled Vapi-versus-Retell latency or voice-quality benchmark. Recommendations below are judgments based on the documented capabilities, not measured performance rankings.
+
+## Vapi vs Retell at a glance
+
+| Decision | Vapi | Retell AI |
+| -------------------------- | --------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ |
+| Configuration approach | Configure assistants, speech/model providers, tools, and assistant handoffs. | Choose single-prompt agents or conversation flows with explicit nodes and transitions. |
+| Browser calls | `@vapi-ai/web`, a public key, an assistant ID, and call events. | `retell-client-js-sdk`, public-key authentication, and call sessions with lifecycle hooks. |
+| Custom response generation | Connect an OpenAI-compatible custom LLM endpoint. | Connect a custom LLM WebSocket server that streams replies. |
+| Phone infrastructure | Managed calling plus bring-your-own telephony and SIP integration. | Managed numbers plus imported numbers and custom SIP telephony. |
+| Human handoff | Documented blind and warm transfer modes; validate the selected mode with your carrier. | Documented cold, warm, and agentic warm transfers; validate your number and carrier setup. |
+| Included concurrency | 4 calls on usage-only; 10 on Core; 30 on Pro. | 20 calls on pay-as-you-go. |
+| Higher concurrency | \$10 per additional line/month on the public pricing page. | \$8 per additional concurrent call/month on the public pricing page. |
+| Hosting | Managed platform; your custom services can run on your infrastructure. | Managed platform; your custom LLM and application services can run on your infrastructure. |
+
+Capability sources: [Vapi web calls](https://docs.vapi.ai/quickstart/web), [Vapi custom LLMs](https://docs.vapi.ai/customization/custom-llm/using-your-server), [Retell web calls](https://docs.retellai.com/deploy/web-call), [Retell conversation flows](https://docs.retellai.com/build/conversation-flow/overview), and [Retell custom LLMs](https://docs.retellai.com/integrate-llm/overview). Prices and plan limits come from the two pricing pages linked below.
+
+## Pricing: what goes into the bill?
+
+### Vapi pricing
+
+[Vapi's public pricing page](https://vapi.ai/pricing) separates its **\$0.05/minute hosting fee** from model-provider costs, transport, and optional Success Packages. Usage-only has no monthly package fee. Core is \$29/month; Pro is 10% of Vapi hosting fees with a \$999/month minimum. Provider costs are passed through without markup according to the page.
+
+The pricing calculator currently shows this example for **1,000 minutes**, with no Success Package and Vapi Telephony/SIP selected:
+
+| Component | Published calculator rate | Cost for 1,000 minutes |
+| ------------------------- | ------------------------- | ---------------------- |
+| Vapi hosting | \$0.05/min | \$50.00 |
+| Deepgram transcription | \$0.0095–\$0.0099/min | \$9.50–\$9.90 |
+| OpenAI intelligence model | \$0.0077–\$0.0452/min | \$7.70–\$45.20 |
+| ElevenLabs voice | \$0.0146–\$0.0238/min | \$14.60–\$23.80 |
+| Selected transport | Calculator displays \$0 | \$0 in this estimate |
+| **Component subtotal** | **\$0.0818–\$0.1289/min** | **\$81.80–\$128.90** |
+
+These are the calculator's provider ranges, not a quote for a single named model configuration. Carrier charges, paid packages, compliance options, and other add-ons can change the total. A \$0 transport line in the platform calculator does not erase bills from a carrier you bring yourself.
+
+### Retell AI pricing
+
+[Retell's public pricing page](https://www.retellai.com/pricing) advertises \$0.07–\$0.31/minute for voice agents, but its detailed component table is the better budgeting source. The default pipeline lists **\$0.055/minute for voice infrastructure**, with voice, LLM, telephony, and optional features charged separately. Some listed model configurations can exceed the headline range.
+
+For example, choosing **GPT 4.1 mini, Retell Platform Voices, and the listed US Twilio telephony rate** gives:
+
+| Component | Published rate | Cost for 1,000 minutes |
+| --------------------------- | ---------------- | ---------------------- |
+| Retell voice infrastructure | \$0.055/min | \$55.00 |
+| Retell Platform Voices | \$0.015/min | \$15.00 |
+| GPT 4.1 mini, standard tier | \$0.0128/min | \$12.80 |
+| US Twilio telephony | \$0.015/min | \$15.00 |
+| **Usage subtotal** | **\$0.0978/min** | **\$97.80** |
+
+One standard Retell phone number adds \$2/month, making this example **\$99.80/month before taxes or other extras**. Knowledge-base usage adds \$0.005/minute, or \$5 for these 1,000 minutes. Other optional features and capacity are additional. For browser-only calls, omit the telephone number and telephony charge: the same selected AI components total **\$82.80 for 1,000 minutes**.
+
+Retell says silence and hold time count toward call duration. After a transfer, its AI agent fee stops while the telephony fee continues. Include those behaviors in your workload estimate.
+
+### Which is cheaper?
+
+The examples above use different model and voice assumptions, so **they do not establish a price winner**. Vapi exposes provider ranges in its calculator; Retell lists individual component rates. First choose the model, voice, carrier, add-ons, and concurrency you actually need, then compare the resulting totals.
+
+At 10,000 minutes, a \$0.01/minute difference is \$100/month. Fixed fees and peak concurrency can matter as much as a small usage difference. Twenty simultaneous calls for a few minutes require more capacity than the same minutes spread across a day.
+
+## Browser SDKs and React integration
+
+Both platforms can power a voice agent in a website without making a telephone call.
+
+Vapi's web SDK starts a configured assistant with a public key and emits events such as `call-start`, `call-end`, and transcript messages. Register listeners before starting calls, release them when your component unmounts, and keep private API keys on your server. The [Vapi adapter guide](/docs/adapters/vapi) shows how orb-ui can manage that lifecycle and display listening and speaking activity.
+
+Retell's current browser guide uses `RetellClient` and `createWebCall()`, with public keys restricted to allowed domains. It exposes status, end, error, and optional audio/transcript hooks. Live transcripts use a separate monitoring connection and must be enabled explicitly. Follow the [current SDK guide](https://docs.retellai.com/deploy/web-call) rather than copying older `RetellWebClient` examples: Retell's [browser SDK migration notice](https://docs.retellai.com/deprecation-notice/2026/09-30_create_web_call_v2) currently gives October 18, 2026 as the deprecation date for the legacy client.
+
+orb-ui currently has no built-in Retell adapter. Use [controlled mode or a custom adapter](/docs/adapters/custom) if you choose Retell. That is an integration-effort difference for orb-ui users, not a reason to assume one platform has better voice quality.
+
+For either SDK, browser-provided metadata and agent overrides are untrusted input. Authorize access to private records and tools in your backend, and test microphone denial, interrupted responses, connection failure, and repeated start/stop behavior.
+
+## Conversation flows, tools, and custom models
+
+**Vapi is worth evaluating first when provider composition is a core requirement.** Its model endpoint, transcription, voice, and tools can be configured separately. Its [custom LLM guide](https://docs.vapi.ai/customization/custom-llm/using-your-server) describes an OpenAI-compatible server, which can fit a backend already exposing that interface. Use [assistant handoffs](https://docs.vapi.ai/squads/handoff) when different assistants own different parts of the conversation.
+
+**Retell is worth evaluating first when the team wants explicit call-flow control.** Its conversation-flow nodes describe dialogue, functions, branching, and handoffs. Retell itself recommends beginning with a single prompt unless the additional structure is needed; a flow graph can become awkward when callers change their minds or provide information out of order.
+
+Retell also supports custom LLM servers, so “Vapi is customizable and Retell is not” is too simplistic. The tradeoff is operational: Retell's [custom LLM documentation](https://docs.retellai.com/integrate-llm/overview) says you take responsibility for latency and reconnection, and lose access to some built-in testing capabilities, including simulation and batch testing for those agents. Check the requirements of the exact agent type you plan to ship.
+
+## Phone calls, transfers, and hosting
+
+Both document SIP integration and human transfers. Vapi has [SIP trunking](https://docs.vapi.ai/advanced/sip/sip-trunk) and [warm-transfer modes](https://docs.vapi.ai/tools/transfer-call/warm-transfer); Retell has [custom telephony](https://docs.retellai.com/deploy/custom-telephony) and a [transfer-call tool](https://docs.retellai.com/build/single-multi-prompt/transfer-call). A feature appearing in a table does not guarantee that every carrier and transfer mode behave identically.
+
+For a receptionist, test a successful transfer, a busy destination, an unanswered destination, and the fallback back to the agent. For outbound calling, include voicemail and failed connections in the evaluation. Confirm caller ID and post-transfer billing with the carrier you intend to use.
+
+Running a custom LLM on your server does not self-host the whole Vapi or Retell platform. If control over the entire media and agent stack is a requirement, evaluate frameworks such as [LiveKit](/docs/adapters/livekit) or [Pipecat](/docs/adapters/pipecat) separately. Expect to own more deployment and reliability work.
+
+For regulated workloads, compare the actual contract, BAA availability, retention controls, and enabled services. For example, Vapi currently lists HIPAA handling as a \$2,000/month add-on, while Retell separates enterprise terms and features on its pricing page. A logo or a competitor's checklist is not enough to establish that your particular deployment meets its requirements.
+
+## How to choose with a small pilot
+
+Use the same task, knowledge, voice language, and success criteria on both platforms. Where the same model or voice is unavailable, record the difference rather than attributing it entirely to the platform.
+
+1. **Run representative conversations.** Include interruptions, background noise, ambiguous requests, tool failures, and requests to speak to a person.
+2. **Record outcomes.** Count completed tasks, incorrect answers, successful transfers, and abandoned calls. Measure end-of-user-speech to first audible response, including slower cases, rather than relying on a vendor's headline latency.
+3. **Calculate the complete bill.** Include AI usage, carrier charges, numbers, peak concurrent calls, paid features, and support packages.
+4. **Check who maintains it.** Have the actual developer or operations teammate change a prompt, debug a failed call, and roll back an agent update.
+
+Choose Vapi if its provider configuration and API fit reduce your team's integration work. Choose Retell if its agent builder and operating workflow make the target call flow easier to maintain. If the pilot reverses that expectation, follow the evidence from your calls.
+
+## Frequently asked questions
+
+### Is Vapi's \$0.05/minute the total price?
+
+No. It is the hosting fee. Model-provider costs, transport, selected packages, and add-ons can increase the total.
+
+### Does Retell charge a flat \$0.07/minute for every agent?
+
+No. Its detailed pricing varies with the model, voice, telephony, and optional features. Use the component table for the configuration you choose.
+
+### Can both work with React and custom LLMs?
+
+Yes. Both have browser SDKs and custom LLM integrations, with different connection and event contracts. Vapi documents an OpenAI-compatible model endpoint; Retell documents a custom LLM WebSocket protocol.
+
+### Which has lower latency?
+
+This guide does not establish a winner. Measure the same workload over the same channel and region with comparable model and voice configurations. A browser demo and a telephone call are not equivalent tests.
+
+## Continue building
+
+- [Vapi alternatives: managed platforms and open-source options](/blog/vapi-alternatives)
+- [Voice AI platforms and architecture choices](/blog/voice-ai-platforms)
+- [Build directly with the OpenAI Realtime API](/blog/openai-realtime-api-tutorial)
+- [Vapi and orb-ui integration](/docs/adapters/vapi)
+- [Custom voice UI integrations](/docs/adapters/custom)
diff --git a/demo/blog/posts/voice-ai-platforms.md b/demo/blog/posts/voice-ai-platforms.md
new file mode 100644
index 0000000..03ed118
--- /dev/null
+++ b/demo/blog/posts/voice-ai-platforms.md
@@ -0,0 +1,119 @@
+---
+title: 'Voice AI Platforms Compared: Which Stack Should You Choose?'
+description: Choose a voice AI stack by comparing managed platforms, open-source agent frameworks, and direct realtime APIs for phone, browser, and mobile applications.
+date: '2026-09-28'
+category: Guide
+---
+
+**Choose a voice AI platform by deciding which parts of a conversation your team wants to operate.** Managed platforms package agent configuration and call operations. Frameworks give you agent code and deployment control. Direct realtime APIs give you a model session that your application turns into a product.
+
+For a phone workflow with transfers and call review, start by evaluating managed platforms. For a custom browser or mobile experience, include frameworks and direct APIs. All three approaches can support serious applications; they put the work in different places.
+
+Sources checked **September 28, 2026**. This is a documentation-based architecture guide from the maintainers of orb-ui, a React voice UI library. It is not a measured ranking of latency, voice quality, or total cost.
+
+## The three kinds of voice AI stack
+
+| Approach | Examples | What you configure or build | What your team still owns |
+| ---------------------------------------------------- | ------------------------------- | ------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- |
+| Managed agent platform | Vapi, Retell, ElevenLabs Agents | Agents, prompts, tools, knowledge, workflows, and integrations | Business logic, permissions, evaluation, application UI, and rollout |
+| Agent framework with cloud or self-hosted deployment | LiveKit Agents, Pipecat | Agent code, model/provider integrations, sessions, and deployment configuration | Agent behavior and the operations not covered by your chosen hosting service |
+| Direct realtime model API | OpenAI Realtime | Model sessions, media connections, events, and tool execution | Application orchestration, evaluation, and integrations around the session |
+
+These categories can overlap. LiveKit Cloud and Pipecat Cloud offer managed infrastructure for framework-based agents. A managed agent platform can call your custom backend. A framework can connect to a realtime model instead of assembling separate speech-to-text, LLM, and text-to-speech services.
+
+The useful distinction is **who runs each part of your application**, not whether a vendor uses the word “platform.”
+
+## A decision matrix for six options
+
+| Option | Start evaluating it when… | Main architectural commitment | First thing to prove |
+| ----------------- | -------------------------------------------------------------------------- | ------------------------------------------------------------------ | ------------------------------------------------------------------- |
+| Vapi | You want managed voice orchestration with configurable providers and tools | Platform assistants and call APIs | Your selected provider combination and tool flow work together |
+| Retell | You want managed calls with explicit conversation-flow configuration | Platform agents, flow nodes, and call operations | Transfers and exception paths match your real workflow |
+| ElevenLabs Agents | You want an integrated voice and agent service | Agent configuration plus its client and phone integrations | Your voice, languages, turn-taking, and retrieval meet the use case |
+| LiveKit Agents | You need agent code connected to realtime rooms and client media | Room participants, credentials, agent dispatch, and workers | Sessions connect reliably and dispatch the intended agent |
+| Pipecat | You want to compose and customize a Python audio pipeline | Transports, processors, provider services, and agent hosting | The pipeline handles interruptions and slow dependencies correctly |
+| OpenAI Realtime | You want to build around a direct speech-to-speech model session | Session credentials, realtime events, media, and application tools | Your app can own session lifecycle and tool execution end to end |
+
+For provider migration details, see [Vapi alternatives](/blog/vapi-alternatives). For pricing and call-operation differences between two managed options, see [Vapi vs Retell](/blog/vapi-vs-retell).
+
+## Phone agents: evaluate the call operation
+
+For appointment booking, inbound support, qualification, or outbound follow-up, start with the full call journey. The AI response is one part of it.
+
+Before choosing a platform, implement a call that looks up a record, handles a missing result, and transfers to a person. Test what happens when the destination does not answer. Check whether the caller hears hold audio, whether the recipient gets context, and whether the call stays connected after the handoff.
+
+Vapi and Retell document managed telephony and custom telephony integrations. ElevenLabs documents Twilio and SIP integrations. LiveKit provides SIP integration into rooms, and Pipecat documents telephony transports and integrations. Their existence does not establish equivalent transfer behavior on every carrier; test the exact route you will deploy.
+
+Phone numbers, concurrency, outbound policies, regional availability, recordings, and retention can eliminate a candidate before model quality matters. Write those requirements down before your pilot. A browser demo cannot verify them.
+
+## Browser and mobile agents: evaluate session ownership
+
+For an in-app assistant, onboarding guide, tutor, or voice interface, the critical path includes authentication, microphone permission, connection startup, playback, interruption, and cleanup.
+
+Managed SDKs can reduce how much media plumbing you write. LiveKit is worth evaluating when rooms, participants, and cross-platform realtime media are central to the product. Pipecat is worth evaluating when the backend pipeline needs custom processing. A direct realtime API can fit a focused application whose team wants to implement the surrounding behavior itself.
+
+For every option, answer these questions in a working prototype:
+
+- Can the user stop a session during startup and reconnect without creating duplicate calls?
+- Does an expired credential produce a useful error, and can the client retry safely?
+- Does interruption stop playback and leave the conversation in a consistent state?
+- What happens when a tool finishes after the user has already disconnected?
+- Does navigating away release microphone tracks and end the provider session?
+
+Build the same small UI for the candidates you compare. Our [ElevenLabs React tutorial](/blog/elevenlabs-conversational-ai-react) and [OpenAI Realtime tutorial](/blog/openai-realtime-api-tutorial) show two different session architectures. The UI can remain simple while you compare the backend behavior.
+
+## Cascaded speech versus realtime speech models
+
+A common voice pipeline is:
+
+```text
+Microphone → speech-to-text → language model → text-to-speech → playback
+```
+
+This makes provider choices explicit and gives you text boundaries to inspect. It also means that turn detection, buffering, inference, synthesis, and transport all contribute to the response time.
+
+A realtime speech model can accept and generate audio within the model session. That changes the integration and often the event model. It does not remove the need for application tools, access control, transcript handling, or a reliable media connection.
+
+Neither design guarantees the fastest experience. Compare **end of user speech to first audible agent response** under the same network conditions, and separate tool-dependent turns from simple replies. Also measure how quickly playback stops when the user interrupts. A single average hides the slow conversations your users will notice.
+
+## What open source and self-hosting actually mean
+
+LiveKit and Pipecat provide open-source building blocks. You can use hosted services with those frameworks or operate more of the stack yourself. That is different from having a turnkey voice platform deployed inside your infrastructure.
+
+If you self-host, identify the components separately: media transport, agent workers, model inference, knowledge stores, recordings, and monitoring. Running your agent worker in your own cloud does not keep audio or transcripts there if it still calls external speech and model APIs.
+
+If private deployment is a requirement, compare the exact deployment boundary and contract. ElevenLabs documents enterprise private deployment options; availability and scope need confirmation for your workload. Avoid equating “managed” with “no private deployment” or “open source” with “all data stays local.”
+
+## Compare the cost of completed work
+
+An advertised per-minute price can include different components. One service may separate orchestration and model costs; another may bundle some usage; an infrastructure service may also meter media or worker resources. Phone charges and concurrency can be separate again.
+
+For a useful comparison, run the same task set and collect the actual billable units. Record:
+
+| Metric | Why it matters |
+| ---------------------------------- | ------------------------------------------------- |
+| Completed tasks / attempted tasks | Establishes whether the system does the job |
+| Cost per completed task | Includes retries and unsuccessful conversations |
+| Median and slow-tail response time | Shows both the typical experience and long pauses |
+| Interruption recovery | Reveals overlapping playback and lost context |
+| Transfer and tool success | Tests dependencies outside the model |
+| Engineering and operating effort | Captures the cost that usage pricing omits |
+
+Use your observed call lengths and peak concurrency for the monthly projection. Do not extrapolate an unusually short demo into a production budget.
+
+## A practical shortlist
+
+For a **managed phone agent**, compare Vapi, Retell, and ElevenLabs against one real call flow and your carrier requirements. For a **custom realtime application**, compare LiveKit and Pipecat against your language, media, and deployment needs. For a **focused model-driven browser feature**, include a direct realtime API and budget for the application code around it.
+
+Limit the first pilot to two candidates. Define a few pass/fail requirements, run the same representative conversations, and choose from observed results. You can make that initial decision in days without pretending the pilot proves every production condition.
+
+Once the backend is chosen, a UI library such as orb-ui can display connection, listening, thinking, and speaking states. It does not replace the platform or framework. See the [adapter overview](/docs/adapters/overview) for supported integrations.
+
+## Sources
+
+- [Vapi documentation](https://docs.vapi.ai/introduction)
+- [Retell documentation](https://docs.retellai.com/general/introduction)
+- [ElevenLabs Agents](https://elevenlabs.io/docs/eleven-agents/overview) and [private deployment](https://elevenlabs.io/docs/overview/capabilities/private-deployment)
+- [LiveKit Agents quickstart](https://docs.livekit.io/agents/start/voice-ai/), [telephony](https://docs.livekit.io/telephony/), and [self-hosting](https://docs.livekit.io/transport/self-hosting/)
+- [Pipecat framework](https://docs.pipecat.ai/overview/pipecat), [Cloud](https://docs.pipecat.ai/overview/cloud), and [telephony](https://docs.pipecat.ai/pipecat/telephony/overview)
+- [OpenAI Realtime API](https://developers.openai.com/api/docs/guides/realtime)
diff --git a/demo/package.json b/demo/package.json
index 0e14460..297c003 100644
--- a/demo/package.json
+++ b/demo/package.json
@@ -23,6 +23,8 @@
"@types/react": "^18.3.29",
"@types/react-dom": "^18.3.7",
"@vitejs/plugin-react": "^6.0.2",
+ "gray-matter": "^4.0.3",
+ "marked": "^18.0.14",
"react": "^18.3.0",
"react-dom": "^18.3.0",
"typescript": "^5.9.3",
diff --git a/demo/public/site-sitemap.xml b/demo/public/site-sitemap.xml
deleted file mode 100644
index cdba6fe..0000000
--- a/demo/public/site-sitemap.xml
+++ /dev/null
@@ -1,6 +0,0 @@
-
-
-
- https://orb-ui.com/
-
-
diff --git a/demo/src/App.tsx b/demo/src/App.tsx
index 8f19e19..b48c207 100644
--- a/demo/src/App.tsx
+++ b/demo/src/App.tsx
@@ -228,6 +228,7 @@ function useConversationSimulation(startedAt: number) {
const NAV_LINKS = [
{ href: '#quick-start', label: 'Quick start' },
{ href: '/docs', label: 'Docs' },
+ { href: '/blog', label: 'Blog' },
{ href: '/playground', label: 'Playground' },
] as const
diff --git a/demo/tsconfig.json b/demo/tsconfig.json
index 8191a82..e6f3c68 100644
--- a/demo/tsconfig.json
+++ b/demo/tsconfig.json
@@ -9,5 +9,5 @@
"skipLibCheck": true,
"esModuleInterop": true
},
- "include": ["src", "api"]
+ "include": ["src", "api", "blog/**/*.ts"]
}
diff --git a/demo/vercel.json b/demo/vercel.json
index 3b255e8..c641255 100644
--- a/demo/vercel.json
+++ b/demo/vercel.json
@@ -4,6 +4,21 @@
"buildCommand": "cd .. && pnpm build && pnpm build:demo",
"outputDirectory": "dist",
"redirects": [
+ {
+ "source": "/blog/:path*/index.html",
+ "destination": "/blog/:path*",
+ "permanent": true
+ },
+ {
+ "source": "/blog/",
+ "destination": "/blog",
+ "permanent": true
+ },
+ {
+ "source": "/blog/:slug/",
+ "destination": "/blog/:slug",
+ "permanent": true
+ },
{
"source": "/index",
"destination": "/",
@@ -251,6 +266,14 @@
}
],
"rewrites": [
+ {
+ "source": "/blog",
+ "destination": "/blog/index.html"
+ },
+ {
+ "source": "/blog/:slug([a-z0-9-]+)",
+ "destination": "/blog/:slug/index.html"
+ },
{
"source": "/demos/voice-orb",
"destination": "/demos/voice-orb/index.html"
diff --git a/demo/vite.config.ts b/demo/vite.config.ts
index d10febe..462c81e 100644
--- a/demo/vite.config.ts
+++ b/demo/vite.config.ts
@@ -2,6 +2,7 @@ import { defineConfig, type Plugin } from 'vite'
import react from '@vitejs/plugin-react'
import { fileURLToPath, URL } from 'node:url'
import { openAILiveDevPlugin } from './openai-live-dev'
+import { blogPlugin } from './blog/plugin'
function resolveInput(path: string) {
return fileURLToPath(new URL(path, import.meta.url))
@@ -35,7 +36,7 @@ function playgroundRoutePlugin(): Plugin {
}
export default defineConfig({
- plugins: [react(), playgroundRoutePlugin(), openAILiveDevPlugin()],
+ plugins: [react(), playgroundRoutePlugin(), openAILiveDevPlugin(), blogPlugin()],
build: {
rollupOptions: {
input: {
diff --git a/docs/adapters/elevenlabs.mdx b/docs/adapters/elevenlabs.mdx
index a3f6de5..a6453c2 100644
--- a/docs/adapters/elevenlabs.mdx
+++ b/docs/adapters/elevenlabs.mdx
@@ -3,6 +3,9 @@ title: ElevenLabs Orb for React with Custom Themes
description: Connect your ElevenLabs agent to orb-ui's circle, cloud, radial, or bars themes. Build a custom React voice orb with audio response, presets, and accessible controls.
---
+Building your first agent application? The [ElevenLabs React tutorial](https://orb-ui.com/blog/elevenlabs-conversational-ai-react)
+includes a private-agent token endpoint, WebRTC session lifecycle, and a complete React interface.
+
Keep your ElevenLabs agent and give it a voice orb that fits your product. orb-ui connects to
ElevenLabs Conversational AI through `@elevenlabs/client`, with a choice of circle, cloud, radial,
and bars themes, motion presets, and audio-reactive listening and speaking states.
diff --git a/docs/adapters/openai-realtime.mdx b/docs/adapters/openai-realtime.mdx
index 64742e2..f08ab5a 100644
--- a/docs/adapters/openai-realtime.mdx
+++ b/docs/adapters/openai-realtime.mdx
@@ -149,6 +149,7 @@ provider profile to compensate for one theme's visual range.
## Related
+- [OpenAI Realtime API tutorial: build a complete React voice agent](https://orb-ui.com/blog/openai-realtime-api-tutorial)
- [Custom integrations](/adapters/custom)
- [React voice agent UI lifecycle guide](/guides/voice-agent-ui)
- [OpenAI Realtime WebRTC guide](https://developers.openai.com/api/docs/guides/realtime-webrtc)
diff --git a/docs/adapters/vapi.mdx b/docs/adapters/vapi.mdx
index 6d8b893..d6bfaad 100644
--- a/docs/adapters/vapi.mdx
+++ b/docs/adapters/vapi.mdx
@@ -3,6 +3,8 @@ title: Vapi Voice UI for React
description: Add a Vapi voice UI to React with orb-ui's adapter, animated orb visuals, and state-aware voice agent visuals.
---
+Comparing platforms first? Read [Vapi vs Retell: pricing, features, and developer tradeoffs](https://orb-ui.com/blog/vapi-vs-retell).
+
Vapi handles the voice agent platform layer. orb-ui handles the visible React UI layer: an animated voice orb, audio-reactive feedback, and predictable states that make a Vapi assistant feel present in your app.
## Install
diff --git a/docs/docs.json b/docs/docs.json
index 7fa964e..c61c918 100644
--- a/docs/docs.json
+++ b/docs/docs.json
@@ -26,6 +26,10 @@
"label": "Home",
"href": "https://orb-ui.com"
},
+ {
+ "label": "Blog",
+ "href": "https://orb-ui.com/blog"
+ },
{
"type": "github",
"href": "https://github.com/exprmntl/orb-ui"
diff --git a/docs/guides/voice-agent-platforms.mdx b/docs/guides/voice-agent-platforms.mdx
index d03776d..efbb4d1 100644
--- a/docs/guides/voice-agent-platforms.mdx
+++ b/docs/guides/voice-agent-platforms.mdx
@@ -16,7 +16,26 @@ orb-ui is intentionally narrow. It is the React UI layer that can sit on top of
| Voice generation | ElevenLabs | Render conversational state and audio activity |
| Custom backend | WebRTC, WebSocket, telephony | Use controlled mode or write an adapter |
-## Comparison posture
+## Choose a stack for the work you need to do
+
+Start with the [voice AI platform guide](https://orb-ui.com/blog/voice-ai-platforms) to compare
+managed platforms, agent frameworks, and direct realtime APIs. If you are replacing an existing
+Vapi integration, the [Vapi alternatives guide](https://orb-ui.com/blog/vapi-alternatives) covers migration tradeoffs.
+
+For managed browser and phone agents, start with a concrete platform comparison:
+[Vapi vs Retell](https://orb-ui.com/blog/vapi-vs-retell) covers pricing components, browser SDKs, custom models,
+transfers, and capacity. Budget for the configured model and carrier as well as the platform fee.
+
+If you want to own a browser voice application directly, the
+[OpenAI Realtime API tutorial](https://orb-ui.com/blog/openai-realtime-api-tutorial) walks through a React client,
+server-side credentials, WebRTC, interruptions, and function calling. You own authentication,
+tool execution, and application operations; a model API alone does not supply a complete telephone workflow.
+
+For more control over the agent and media stack, evaluate LiveKit or Pipecat and decide which
+parts you will host. Their frontend adapters connect your UI to the agent deployment; they do not
+deploy that backend for you.
+
+## Keep the layers distinct
When comparing tools, keep the distinction clear:
diff --git a/docs/index.mdx b/docs/index.mdx
index 9928311..c0961e1 100644
--- a/docs/index.mdx
+++ b/docs/index.mdx
@@ -36,6 +36,8 @@ orb-ui is not a voice agent platform. It does not host calls, manage prompts, ru
## Next steps
+- [OpenAI Realtime API tutorial: React and WebRTC](https://orb-ui.com/blog/openai-realtime-api-tutorial)
+- [Vapi vs Retell: pricing and developer tradeoffs](https://orb-ui.com/blog/vapi-vs-retell)
- [Quickstart](/quickstart)
- [Installation](/installation)
- [Orb component API](/reference/orb-component)
diff --git a/eslint.config.mjs b/eslint.config.mjs
index 79d2032..e63813b 100644
--- a/eslint.config.mjs
+++ b/eslint.config.mjs
@@ -17,7 +17,7 @@ export default [
js.configs.recommended,
...tseslint.configs.recommended,
{
- files: ['**/*.{ts,tsx}'],
+ files: ['**/*.{ts,tsx}', 'demo/blog/blog.js'],
languageOptions: {
ecmaVersion: 'latest',
globals: {
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 4f5fe33..d06e614 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -110,6 +110,12 @@ importers:
'@vitejs/plugin-react':
specifier: ^6.0.2
version: 6.0.2(vite@8.0.14(@types/node@26.1.1))
+ gray-matter:
+ specifier: ^4.0.3
+ version: 4.0.3
+ marked:
+ specifier: ^18.0.14
+ version: 18.0.14
react:
specifier: ^18.3.0
version: 18.3.1
@@ -1650,6 +1656,13 @@ packages:
integrity: sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==,
}
+ extend-shallow@2.0.1:
+ resolution:
+ {
+ integrity: sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==,
+ }
+ engines: { node: '>=0.10.0' }
+
extend@3.0.2:
resolution:
{
@@ -1852,6 +1865,13 @@ packages:
integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==,
}
+ gray-matter@4.0.3:
+ resolution:
+ {
+ integrity: sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q==,
+ }
+ engines: { node: '>=6.0' }
+
https-proxy-agent@7.0.6:
resolution:
{
@@ -1894,6 +1914,13 @@ packages:
}
engines: { node: '>=0.8.19' }
+ is-extendable@0.1.1:
+ resolution:
+ {
+ integrity: sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==,
+ }
+ engines: { node: '>=0.10.0' }
+
is-extglob@2.1.1:
resolution:
{
@@ -2228,6 +2255,14 @@ packages:
integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==,
}
+ marked@18.0.14:
+ resolution:
+ {
+ integrity: sha512-mBHK6FBHuBAlhgRe88w9F0O1AbwwXJUcQibUbC/QcdTbVGAD7aWza+xt3N6oT/jCZx3/OMeS+8rnuiHZcQ9s7A==,
+ }
+ engines: { node: '>= 20' }
+ hasBin: true
+
merge2@1.4.1:
resolution:
{
@@ -2624,6 +2659,13 @@ packages:
integrity: sha512-xZocWwfyp4hkbN4hLWxMjmv2Q8aNa9MhmOZ7L9aCZPT+dZsgRr6wZRrSYE3HTdyk/2pZKPSgqI7ns7Een1xMSA==,
}
+ section-matter@1.0.0:
+ resolution:
+ {
+ integrity: sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA==,
+ }
+ engines: { node: '>=4' }
+
semver@7.8.1:
resolution:
{
@@ -2711,6 +2753,13 @@ packages:
}
engines: { node: '>=8' }
+ strip-bom-string@1.0.0:
+ resolution:
+ {
+ integrity: sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g==,
+ }
+ engines: { node: '>=0.10.0' }
+
strip-bom@3.0.0:
resolution:
{
@@ -3977,6 +4026,10 @@ snapshots:
exsolve@1.0.8: {}
+ extend-shallow@2.0.1:
+ dependencies:
+ is-extendable: 0.1.1
+
extend@3.0.2: {}
extendable-error@0.1.7: {}
@@ -4105,6 +4158,13 @@ snapshots:
graceful-fs@4.2.11: {}
+ gray-matter@4.0.3:
+ dependencies:
+ js-yaml: 3.14.2
+ kind-of: 6.0.3
+ section-matter: 1.0.0
+ strip-bom-string: 1.0.0
+
https-proxy-agent@7.0.6:
dependencies:
agent-base: 7.1.4
@@ -4124,6 +4184,8 @@ snapshots:
imurmurhash@0.1.4: {}
+ is-extendable@0.1.1: {}
+
is-extglob@2.1.1: {}
is-glob@4.0.3:
@@ -4303,6 +4365,8 @@ snapshots:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
+ marked@18.0.14: {}
+
merge2@1.4.1: {}
micromatch@4.0.8:
@@ -4550,6 +4614,11 @@ snapshots:
sdp@3.2.2: {}
+ section-matter@1.0.0:
+ dependencies:
+ extend-shallow: 2.0.1
+ kind-of: 6.0.3
+
semver@7.8.1: {}
shallow-clone@3.0.1:
@@ -4585,6 +4654,8 @@ snapshots:
dependencies:
ansi-regex: 5.0.1
+ strip-bom-string@1.0.0: {}
+
strip-bom@3.0.0: {}
term-size@2.2.1: {}
diff --git a/tests/demo/blog.test.ts b/tests/demo/blog.test.ts
new file mode 100644
index 0000000..acddcc0
--- /dev/null
+++ b/tests/demo/blog.test.ts
@@ -0,0 +1,67 @@
+import { describe, expect, it } from 'vitest'
+import { existsSync, readFileSync } from 'node:fs'
+import { resolve } from 'node:path'
+import { blogAssets } from '../../demo/blog/plugin'
+
+describe('static blog publishing', () => {
+ const assets = blogAssets()
+ const pages = [...assets].filter(([path]) => path.endsWith('.html'))
+
+ it('ships complete article HTML, canonical metadata, and valid structured data without client rendering', () => {
+ expect(pages).toHaveLength(6)
+ for (const [path, html] of pages) {
+ const canonical = `https://orb-ui.com/${path.replace('/index.html', '')}`
+ expect(html).toContain(``)
+ expect(html.match(/