---
title: "Build a Convex-Backed Voice Agent with a Reactive Database"
description: "Convex's reactive queries auto-push every transcript update to every subscribed client. Real working code for actions, mutations, and OpenAI Realtime streaming."
canonical: https://callsphere.ai/blog/vw2h-build-convex-voice-agent-reactive-database
category: "AI Infrastructure"
tags: ["Tutorial", "Build", "Convex", "Reactive", "TypeScript"]
author: "CallSphere Team"
published: 2026-04-17T00:00:00.000Z
updated: 2026-05-07T09:27:41.192Z
---

# 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  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);
  },
});

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 `.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";

export default function CallView({ callId }: { callId: string }) {
  const messages = useQuery(api.messages.list, { callId: callId as any }) ?? [];

return (

      {messages.map(m => (

          **{m.role}:** {m.text}

      ))}

  );
}
```

`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`/`httpAction` may.
- **Massive single-row updates** — write words/sentences, not characters.
- **Forgetting indexes** — `useQuery(list)` over a 100k-row table without `.withIndex` will 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](/affiliate) tracks 22% commissions in real time using a similar reactive pattern. See [/pricing](/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

- [Convex Tutorial](https://docs.convex.dev/tutorial/)
- [AI Chat with HTTP Streaming](https://stack.convex.dev/ai-chat-with-http-streaming)
- [Convex docs](https://docs.convex.dev/home)
- [Convex AI building blocks](https://www.convex.dev/)

---

Source: https://callsphere.ai/blog/vw2h-build-convex-voice-agent-reactive-database
