●TOOLING — Rork's developer repos keep moving: rork-xcode was updated on July 16, rork-device on July 15, and rork-plist on July 13●OPUS46 — Claude Opus 4.6 is live in Rork, and Rork Max is built to assemble apps on top of Claude Code●SIM — A cloud iOS simulator runs in the browser, with one click to install on a device and two clicks to publish to the App Store●MAX — Rork Max emits pure Swift rather than React Native, reaching iPhone, iPad, Apple Watch, Apple TV, Vision Pro, and even iMessage●NATIVE — That opens up HealthKit, ARKit and LiDAR, NFC, Dynamic Island, Live Activities, 3D through Metal, and on-device inference with Core ML●SEED — Rork raised a $15M seed led by Left Lane Capital, with Peak XV and a16z Speedrun joining the round●TOOLING — Rork's developer repos keep moving: rork-xcode was updated on July 16, rork-device on July 15, and rork-plist on July 13●OPUS46 — Claude Opus 4.6 is live in Rork, and Rork Max is built to assemble apps on top of Claude Code●SIM — A cloud iOS simulator runs in the browser, with one click to install on a device and two clicks to publish to the App Store●MAX — Rork Max emits pure Swift rather than React Native, reaching iPhone, iPad, Apple Watch, Apple TV, Vision Pro, and even iMessage●NATIVE — That opens up HealthKit, ARKit and LiDAR, NFC, Dynamic Island, Live Activities, 3D through Metal, and on-device inference with Core ML●SEED — Rork raised a $15M seed led by Left Lane Capital, with Peak XV and a16z Speedrun joining the round
"I want a voice-first AI assistant baked into my app." I've been getting this question almost weekly since OpenAI opened up the Realtime API and voice-first platforms like Vapi and Retell dropped their entry price. Getting a demo to talk back is easy. Shipping something that survives a subway tunnel and a monthly billing cycle is an entirely different game.
My first voice chat app crashed every time a user rode under a bridge. Users don't type an angry review in that moment — they just leave. Reviewers who stuck around gave me one-star reviews with the subject "doesn't work on the train." The painful truth is that voice AI is one of those categories where anything above one second of latency makes users stop speaking, and a three-second reconnect means a 90-second churn.
This guide is for anyone stuck at that "demo works, production falls over" stage. We'll walk through the entire blueprint of a voice agent app using LiveKit Agents as the backend and a Rork-generated React Native client. Why LiveKit? Because writing your own WebRTC stack means you'll eventually face SRTP retransmission bugs and jitter-buffer issues that will eat weeks of your life. LiveKit Agents bundles the SFU and a Python/Node agent runtime together, and that one decision removes an entire category of production incidents from your roadmap.
Chapter 1: Designing the Overall Architecture
Before typing a single line, separate the system into four layers. If you start with "just make it work," you'll end up rewriting the whole thing when you add billing or logging later. I've done that three times already.
Four Responsibilities
Mobile client (Rork-generated React Native): microphone and speaker permissions, joining a LiveKit Room, UI state, emitting a call-end log
Token API (Hono on Cloudflare Workers): verifies the Supabase session, checks remaining call time against the plan, and issues a LiveKit JWT
LiveKit Agent (Python or Node.js): a long-running worker that joins rooms and runs the Whisper → GPT/Claude → TTS pipeline
Billing and log store (Stripe + Supabase Postgres): the source of truth for monthly quotas, call history, and error events
Why Four Layers Beat Two
Your first instinct might be to open a WebSocket from the app directly to OpenAI's Realtime API. I tried that. It fails on three fronts.
API key exposure: the OpenAI key ends up in the binary. A weekend of reverse-engineering and your bill explodes.
No enforceable quota: when the client talks to the provider directly, you can't stop a free user at 10 minutes from the server side.
No observability: quality, error rates, user behavior — all live on the client. You can't improve what you can't see.
With LiveKit in the middle, keys stay on your server, quota decisions happen at token issuance, and per-room metrics come for free.
Directory Layout
The shape I've been running in production:
my-voice-app/
├── app/ # Expo Router screens generated by Rork
│ ├── (tabs)/
│ │ └── voice.tsx # the call screen
│ └── _layout.tsx
├── lib/
│ ├── livekit.ts # LiveKit client wrapper
│ ├── billing.ts # quota check
│ └── api.ts # token API client
├── server/ # backend (can live in a separate repo)
│ ├── token-api/ # Hono on Cloudflare Workers
│ └── agent/ # LiveKit Agent (Python)
└── app.config.ts
Chapter 2: Building the LiveKit Agent
Always build the backend first. If you start with the client, you'll end up with pretty UI that connects to nothing and a full day lost.
Environment Variables
Create a project on LiveKit Cloud to get three values:
LIVEKIT_URL: wss://your-project.livekit.cloud
LIVEKIT_API_KEY: APIxxxxxxxxxxxxxxxx
LIVEKIT_API_SECRET: the secret
You'll also need an AI provider key. I'll use OpenAI here.
LiveKit's Python Agents SDK compresses the VAD → STT → LLM → TTS pipeline into about thirty lines. Here's a minimal friendly assistant.
# server/agent/main.pyimport asyncioimport loggingfrom livekit.agents import AutoSubscribe, JobContext, WorkerOptions, cli, llmfrom livekit.agents.voice_assistant import VoiceAssistantfrom livekit.plugins import openai, silerologger = logging.getLogger("voice-agent")async def entrypoint(ctx: JobContext): # Initial prompt: defines personality and hard limits. initial_ctx = llm.ChatContext().append( role="system", text=( "You are a friendly assistant. Keep responses to 1-2 sentences, " "with natural pauses. Do not give medical, legal, or investment " "advice. Suggest consulting a professional instead." ), ) # Subscribe only to audio tracks - skipping video saves bandwidth and cost. await ctx.connect(auto_subscribe=AutoSubscribe.AUDIO_ONLY) assistant = VoiceAssistant( vad=silero.VAD.load(), stt=openai.STT(model="whisper-1"), llm=openai.LLM(model="gpt-4o-mini"), tts=openai.TTS(voice="nova"), chat_ctx=initial_ctx, ) assistant.start(ctx.room) # Greeting on connection await asyncio.sleep(0.5) await assistant.say("Hi there! How can I help you today?", allow_interruptions=True)if __name__ == "__main__": cli.run_app(WorkerOptions(entrypoint_fnc=entrypoint))
Run it and the worker stays resident on LiveKit Cloud, spinning up on every incoming room join. Expected log:
voice-agent - INFO - worker started, id=AW_xxxxxxx
voice-agent - INFO - accepting job, room=voice-user-123
voice-agent - INFO - agent connected to room
Why the VoiceAssistant Class Earns Its Keep
Wiring STT → LLM → TTS by hand means you also have to write "don't fire the LLM while the user is still speaking" and "stop TTS if the user interrupts." VoiceAssistant handles all that, and with allow_interruptions=True the back-and-forth feels natural.
My first attempt wired LangChain agents straight through and I couldn't understand why the bot refused to respond until I finished speaking for two full seconds. Switching to VoiceAssistant made that symptom vanish in one line.
Where to Deploy
Agents are long-lived processes, so Cloudflare Workers is out. Three options I trust:
Fly.io: Python containers, $5/month to start. My default for solo projects.
LiveKit Cloud Agents (paid add-on): no ops, but pricey.
Railway / Render: comparable to Fly.io in DX.
I run Fly.io with fly launch --dockerfile and fly scale count 2 for redundancy. One agent container handles 10–20 concurrent calls, so scaling is mostly "add more machines when MRR grows."
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦If you've been stuck on latency, dropped sessions, or flaky reconnects when prototyping voice AI, you'll come out with a production-ready infrastructure you can copy into your own app
✦You'll get the full three-layer stack — LiveKit Agents server, Rork-generated client, and billing/permission wiring — as code you can paste and run
✦You'll pick up the pattern for capping call minutes per subscription tier and the reconnect UX that cuts churn, giving you a real foundation for growing MRR
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
Connecting from the client to LiveKit requires a JWT. Embed "which room" and "how many seconds allowed" into that token.
Why Quota Checks Belong Here
It might seem simpler to do quota checks inside the agent. But then the user's room connects, and the agent silently refuses to respond. That reads as "the app connected but nothing happens" — a sure path to a one-star review. Reject the connection at token issuance.
If you ship with ttl: 86400 (one day), a user can reuse the same token repeatedly and bypass your quota. Rule of thumb: one token per call, 30 minutes maximum.
Chapter 4: The React Native Client from Rork
Now the client. When prompting Rork, give it something concrete like "a voice call app using LiveKit React Native, with a record button that starts a conversation and a stop button that ends it." Rork will scaffold the screen structure; the logic below is what we plug in.
Remember the microphone permission in app.config.ts. Forgetting this means iOS silently fails to acquire the audio session and the app stays mute forever.
// app.config.ts (excerpt)export default { expo: { // ... ios: { infoPlist: { NSMicrophoneUsageDescription: "We use the microphone so you can have a voice conversation with the AI assistant.", }, }, android: { permissions: ["RECORD_AUDIO", "MODIFY_AUDIO_SETTINGS"], }, plugins: ["@livekit/react-native-expo-plugin", "@config-plugins/react-native-webrtc"], },};
LiveKit Client Wrapper
Keeping the connection logic out of the UI makes reconnect handling centralizable and tests actually writable.
Prompt Rork for "connecting, active, and error states with a large central button and subtle animation" and it will produce the scaffolding. The minimal logic looks like this.
Expected behavior: tapping the button flips state to connecting, then connected, and the agent plays its greeting.
Chapter 5: Production Pitfalls
Here's what actually bit me in production, in the order it happened.
Pitfall 1: No Audio on iOS
Symptom: the app connects, but you can't hear the agent, and the mic doesn't pick up anything either.
Cause: iOS needs AVAudioSession in playAndRecord category before LiveKit can open the mic. @livekit/react-native sets this up when you call registerGlobals() — forgetting that call fails silently.
Fix: call registerGlobals() at the top of the root component. If Rork's generated code doesn't include it, add it manually.
Pitfall 2: Works on TestFlight, Crashes in Production
Symptom: the build is fine in dev and TestFlight, then crashes right after connect on the App Store release.
Cause: Hermes aggressive optimization occasionally mangles a WebAssembly-adjacent helper that LiveKit uses internally.
Fix: in metro.config.js, preserve function names to keep the bundled runtime addressable by Hermes.
Cause: I had NSMicrophoneUsageDescription set to a vague "The app uses the microphone."
Fix: say exactly what it's for. "To hold a voice conversation with the AI assistant." Apple rejects ambiguous permission copy on purpose.
Chapter 6: Designing the Business Model
Shipping this in production only makes sense if you attach pricing to call minutes. For solo developers targeting ¥300,000+/month, I recommend three tiers.
Standard: ¥980/month, 3 hours (10,800 seconds). The sweet spot for most users.
Pro: ¥2,480/month, 15 hours (54,000 seconds). For heavy users.
Stripe's metered billing is overkill at first. "Flat monthly + bolt-on packs when you hit the cap" covers the 90% case.
Server-Side Usage Update
-- Stored procedure in SupabaseCREATE OR REPLACE FUNCTION add_voice_seconds(uid uuid, secs int)RETURNS void AS $$DECLARE cur_period text := to_char(now(), 'YYYY-MM'); user_limit int;BEGIN SELECT CASE WHEN plan = 'pro' THEN 54000 WHEN plan = 'standard' THEN 10800 ELSE 600 END INTO user_limit FROM users WHERE id = uid; INSERT INTO voice_usage (user_id, period, seconds_used, seconds_limit) VALUES (uid, cur_period, secs, user_limit) ON CONFLICT (user_id, period) DO UPDATE SET seconds_used = voice_usage.seconds_used + EXCLUDED.seconds_used;END;$$ LANGUAGE plpgsql;
Reconnect UX That Saves Churn
Apps that show "Call disconnected" when the network flickers get uninstalled. Instead:
On disconnect, show "Reconnecting…" and let LiveKit try its built-in auto-reconnect (5-second window)
If recovery fails after 5 seconds, say "Please check your connection and try again" — warm, not scolding
Cache the last 3–4 conversation turns locally so reconnect preserves context and doesn't feel like a cold start
These three changes moved my 30-day retention from 18% to 34%.
Chapter 7: Unit Economics and Break-Even
The scariest scenario in a voice product is "more users = bigger losses." Know your cost structure up front.
A Pro user (¥2,480/month, 900-minute cap) who actually uses the full quota costs you ¥3,600/month at those rates. You go underwater. Mitigations:
Cap the cap: Pro is 15 hours, period; bursts are add-on packs
Cheaper LLM: gpt-4o-mini → Gemini 2.5 Flash-Lite cuts this by ~3x
Cheaper TTS: Cartesia Sonic at ~$0.005/min is perceptually indistinguishable for conversational use
That brings you down to ~¥2/min and keeps Pro at 60%+ gross margin.
Monitoring
Build a daily view so "underwater users" are visible.
CREATE VIEW voice_cost_monitor ASSELECT u.email, vu.seconds_used, ROUND(vu.seconds_used * 0.0000667::numeric, 2) AS cost_usd, p.monthly_revenue_usd, CASE WHEN vu.seconds_used * 0.0000667 > p.monthly_revenue_usd THEN 'LOSS' ELSE 'OK' END AS statusFROM voice_usage vuJOIN users u ON u.id = vu.user_idJOIN plans p ON p.id = u.plan_idWHERE vu.period = to_char(now(), 'YYYY-MM');
Check status = 'LOSS' users daily. Most are unintentional heavy users; a friendly email with usage tips usually resolves it.
Chapter 7.5: Observability — What You'll Actually Watch at 2 AM
The moment you have paying users, you'll find yourself waking up to support emails like "the app just stopped talking halfway through a call." Without the right traces, you'll have no idea whether the STT dropped, the LLM rate-limited you, or LiveKit rotated a media server. Invest one afternoon in observability up front — it pays back the first time production flinches.
The Three Signals That Matter
Not every metric is worth tracking. For a voice agent, these three tell you 90% of what's happening.
End-of-turn latency: the time between "user stops speaking" and "agent starts speaking." Target under 1,200ms at p95. Above 2,000ms, people hang up.
Mid-call disconnect rate: the fraction of calls that end before the user tapped stop. A healthy number is under 4%. Climbing past 8% usually points to a mobile-network regression, not your code.
Per-call cost: the dollar cost of each call, broken down by provider. Spikes usually mean a runaway tool call loop or a TTS provider quietly switching to a higher-tier voice.
Wire It into Supabase in Under 50 Lines
A single call_events table plus two RPC calls is enough to answer the questions above.
CREATE TABLE call_events ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), user_id uuid REFERENCES users(id), room text NOT NULL, event text NOT NULL, -- 'turn_start', 'turn_end', 'error', 'disconnect' latency_ms int, cost_usd numeric(10, 6), metadata jsonb, created_at timestamptz DEFAULT now());CREATE INDEX ON call_events (user_id, created_at DESC);CREATE INDEX ON call_events (room);
Emit events from the agent process. Python makes this trivial.
The two-second timeout matters. Telemetry must be strictly best-effort; blocking the audio loop to log something is worse than losing the log entry.
The Dashboard Query I Run Every Morning
SELECT date_trunc('hour', created_at) AS hour, COUNT(*) FILTER (WHERE event = 'turn_end') AS turns, ROUND(AVG(latency_ms) FILTER (WHERE event = 'turn_end'))::int AS avg_latency_ms, ROUND(PERCENTILE_CONT(0.95) WITHIN GROUP (ORDER BY latency_ms) FILTER (WHERE event = 'turn_end'))::int AS p95_latency_ms, COUNT(*) FILTER (WHERE event = 'error') AS errors, ROUND(SUM(cost_usd)::numeric, 2) AS cost_usdFROM call_eventsWHERE created_at > now() - interval '24 hours'GROUP BY 1ORDER BY 1 DESC;
When p95 latency crosses 2,000ms for more than two consecutive hours, I know to investigate before users start complaining.
Why Client-Side Metrics Lie
You may be tempted to ship the end-of-turn latency from the phone instead of the agent. Don't. Client-side latency numbers include the round-trip to LiveKit plus audio buffering, and they vary wildly by network. The agent-side measurement — between "VAD said 'user stopped'" and "TTS started outputting" — is the number that reflects your stack. That's what you optimize.
One Rule About Error Logs
Log the error, then swallow it, then continue. The user is still on the line. They'd rather hear "sorry, could you repeat that?" than hear nothing at all. In the Python VoiceAssistant subclass, override on_error to speak a graceful apology and continue listening. I learned this after shipping a version that silently died on the first rate-limit error, leaving users on a frozen call.
Chapter 8: What to Do Next
What you've gained here is the minimum map for a production voice agent. Where you grow from here depends on your goals.
Direction 1: Deepen Conversation Quality
The next stop is LiveKit Agents' function_calling. The pattern looks like this: define a set of tool functions the LLM can invoke mid-conversation, and the voice assistant will transparently pause, resolve the tool call, and continue speaking with the result embedded in the response. I use this for calendar booking ("can you schedule a 30-minute sync with Sato-san next Tuesday?"), RAG lookups against a knowledge base, and fetching the user's latest subscription status from Stripe. The trick is keeping each tool's execution under 300ms, because anything longer leaves the user in an awkward pause. Pre-warm your database connections and cache frequently used embeddings.
Direction 2: Scale Geographically
A single Fly.io region serves one continent comfortably. Past ~500 concurrent calls, or if you have a meaningful user base outside your home region, split workers across Tokyo, Singapore, and Virginia (or whichever trio covers your users). LiveKit Cloud routes users to the nearest media server automatically; you only need to make sure the agent worker is reachable from that server with low latency. Fly.io's fly scale count 2 --region nrt,sjc,iad does exactly this in one line.
Direction 3: Bring Your Own LLM
LiveKit Agents accepts arbitrary LLM plugins. If your product hinges on a fine-tuned model or an in-house inference stack, write a plugin that implements the LLM interface and swap it in. This also lets you bypass OpenAI's rate limits entirely when you scale past their production tier. The integration point is small — roughly 50 lines of adapter code between your inference endpoint and LiveKit's ChatContext.
What I Actually Shipped
Let me close with a concrete story, because abstract architecture talk only goes so far.
I used this stack to ship a voice-memo-with-AI-summary app and crossed ¥400,000 MRR three months after release. The real walls weren't technical — they were UX and unit economics. If you hit a wall while implementing any of the above, find me on X at @dolice. Happy to swap war stories.
The biggest lesson: ship a working voice call to ten real users before optimizing anything. Real users surface problems — background noise, weird accents, accidental barge-ins — that never appear in your test sessions. Everything in this guide came from watching real calls fail in specific ways and patching each failure one at a time.
Rork Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.