Build a Convex-Backed Voice Agent with a Reactive Database
By Sagar Shankaran, Founder of CallSphere
Convex's reactive queries auto-push every transcript update to every subscribed client. Real working code for actions, mutations, and OpenAI Realtime streaming.
Key takeaways
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

Written by
Sagar Shankaran· Founder, CallSphere
LinkedInSagar Shankaran is the founder of CallSphere, where he builds production AI voice and chat agents deployed across healthcare, hospitality, real estate, and home services. He writes about agentic AI, LLM engineering, and shipping voice agents that handle real calls in production.
Try CallSphere AI Voice Agents
See how AI voice agents work for your industry. Live demo available -- no signup required.