Build a Convex-Backed Voice Agent with a Reactive Database
Convex's reactive queries auto-push every transcript update to every subscribed client. Real working code for actions, mutations, and OpenAI Realtime streaming.
TL;DR — Convex queries are reactive subscriptions by default. A voice transcript stored in Convex updates every connected client in <100ms — no Redis, no Channels, no extra glue.
What you'll build
A Next.js + Convex voice app where every assistant token is mutated into the messages table, and every browser using useQuery instantly re-renders. The OpenAI Realtime call lives in a Convex action that streams via HTTP and writes deltas via mutations.
Prerequisites
npx create-next-app@latest+npx convex devto bootstrap.- Convex 1.13+.
OPENAI_API_KEYset:npx convex env set OPENAI_API_KEY ....- Familiarity with React Server Components / TypeScript.
npm i convex @convex-dev/auth openai.
Architecture
flowchart LR
B[Browser useQuery] -- subscribe --> C[Convex DB]
B -- httpAction --> A[voice-stream action]
A -- WebSocket --> O[OpenAI Realtime]
A -- mutation --> C
Step 1 — Schema
convex/schema.ts:
```typescript import { defineSchema, defineTable } from "convex/server"; import { v } from "convex/values";
export default defineSchema({ calls: defineTable({ userId: v.string(), startedAt: v.number(), }), messages: defineTable({ callId: v.id("calls"), role: v.union(v.literal("user"), v.literal("assistant")), text: v.string(), }).index("by_call", ["callId"]), }); ```
Step 2 — Mutations and queries
convex/messages.ts:
```typescript import { v } from "convex/values"; import { mutation, query } from "./_generated/server";
export const append = mutation({ args: { callId: v.id("calls"), role: v.string(), text: v.string() }, handler: async (ctx, args) => { return await ctx.db.insert("messages", args as any); }, });
Hear it before you finish reading
Talk to a live CallSphere AI voice agent in your browser — 60 seconds, no signup.
export const list = query({ args: { callId: v.id("calls") }, handler: async (ctx, { callId }) => await ctx.db.query("messages") .withIndex("by_call", q => q.eq("callId", callId)) .collect(), }); ```
Step 3 — HTTP action for the voice stream
Convex actions can call external APIs (mutations cannot). httpAction exposes a fetch endpoint at <deployment>.convex.site/voice:
```typescript import { httpAction } from "./_generated/server"; import { api } from "./_generated/api"; import OpenAI from "openai";
export const voice = httpAction(async (ctx, req) => { const { callId, audioB64 } = await req.json(); const oai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
// Streaming text completion (simplified; for realtime audio see step 5) const stream = await oai.chat.completions.create({ model: "gpt-4o-mini", stream: true, messages: [ { role: "system", content: "You are CallSphere on Convex. Be brief." }, { role: "user", content: "[transcribed]: " + audioB64 }, ], });
let buffer = ""; for await (const chunk of stream) { const t = chunk.choices[0]?.delta?.content ?? ""; buffer += t; if (t.endsWith(" ") || t.endsWith(".")) { await ctx.runMutation(api.messages.append, { callId, role: "assistant", text: buffer, }); buffer = ""; } } return new Response("ok"); }); ```
Step 4 — Register the HTTP route
convex/http.ts:
```typescript import { httpRouter } from "convex/server"; import { voice } from "./voice";
const http = httpRouter(); http.route({ path: "/voice", method: "POST", handler: voice }); export default http; ```
Step 5 — React component with reactive transcript
```tsx "use client"; import { useQuery, useMutation } from "convex/react"; import { api } from "../convex/_generated/api";
Still reading? Stop comparing — try CallSphere live.
CallSphere ships complete AI voice agents per industry — 14 tools for healthcare, 10 agents for real estate, 4 specialists for salons. See how it actually handles a call before you book a demo.
export default function CallView({ callId }: { callId: string }) { const messages = useQuery(api.messages.list, { callId: callId as any }) ?? [];
return (
useQuery keeps an open subscription. New rows from the action appear without polling.
Step 6 — Wire microphone to the HTTP action
```typescript async function send(callId: string, blob: Blob) { const audioB64 = btoa(String.fromCharCode(...new Uint8Array(await blob.arrayBuffer()))); await fetch(`${process.env.NEXT_PUBLIC_CONVEX_HTTP}/voice`, { method: "POST", body: JSON.stringify({ callId, audioB64 }), }); } ```
Common pitfalls
- Calling fetch from a query/mutation — only
action/httpActionmay. - Massive single-row updates — write words/sentences, not characters.
- Forgetting indexes —
useQuery(list)over a 100k-row table without.withIndexwill time out. - Function-call cycles — actions can call mutations, not the other way around.
How CallSphere does this in production
CallSphere is on Postgres (115+ tables, 6 verticals) but we recommend Convex for solo founders building voice apps fast — the affiliate program at /affiliate tracks 22% commissions in real time using a similar reactive pattern. See /pricing for production-grade equivalents.
FAQ
Is Convex SQL? No — document-style with strong typing and indexes. SQL adapter exists.
Realtime audio? This post uses HTTP streaming; for true Realtime audio, pair Convex with a Deno relay (see post #7).
Cost? Generous free tier; ~$25 covers 1M function calls.
Auth? Built-in @convex-dev/auth with Google/GitHub/email.
Self-host? OSS version available.
Sources
Try CallSphere AI Voice Agents
See how AI voice agents work for your industry. Live demo available -- no signup required.