By Sagar Shankaran, Founder of CallSphere
Design the schema for calls, turns, tool calls, transcripts, sentiment, and lead scoring. Real Prisma schema, indexes that matter, and query patterns that scale to 1M calls.
Key takeaways
TL;DR — Don't dump JSON blobs in one table. Split call → turn → tool_call → transcript → analytics into normalized tables with the right indexes. CallSphere runs 115+ tables; this post shows the 6 that matter for voice.
A Prisma schema that captures every call, every turn, every tool invocation, plus post-call sentiment and lead scoring — and queries it in <50ms even at 1M calls. You'll see the indexes, the foreign keys, and the JSONB fields where they belong.
npm install prisma @prisma/client.erDiagram
CALL ||--o{ TURN : contains
CALL ||--o{ TOOL_CALL : invokes
CALL ||--|| ANALYTICS : has
TURN ||--o{ TRANSCRIPT_CHUNK : has
AGENT ||--o{ CALL : handles
```prisma // schema.prisma generator client { provider = "prisma-client-js" } datasource db { provider = "postgresql"; url = env("DATABASE_URL") }
model Agent { id String @id @default(cuid()) name String vertical String // healthcare, salon, real_estate, ... promptHash String voice String createdAt DateTime @default(now()) calls Call[] }
model Call { id String @id @default(cuid()) agentId String agent Agent @relation(fields: [agentId], references: [id]) fromNumber String toNumber String startedAt DateTime @default(now()) endedAt DateTime? durationMs Int? status CallStatus @default(in_progress) recordingUrl String? meta Json? turns Turn[] toolCalls ToolCall[] analytics Analytics? @@index([agentId, startedAt]) @@index([fromNumber, startedAt]) @@index([status]) }
enum CallStatus { in_progress completed failed transferred }
model Turn { id String @id @default(cuid()) callId String call Call @relation(fields: [callId], references: [id], onDelete: Cascade) role String // "user" | "assistant" text String @db.Text startedAt DateTime endedAt DateTime audioUrl String? @@index([callId, startedAt]) }
model ToolCall { id String @id @default(cuid()) callId String call Call @relation(fields: [callId], references: [id], onDelete: Cascade) name String args Json result Json? errorText String? latencyMs Int invokedAt DateTime @default(now()) @@index([callId, invokedAt]) @@index([name, invokedAt]) }
Hear it before you finish reading
Talk to a live CallSphere AI voice agent in your browser — 60 seconds, no signup.
model Analytics { callId String @id call Call @relation(fields: [callId], references: [id], onDelete: Cascade) sentiment Float // -1.0 to 1.0 leadScore Int // 0-100 intent String entities Json computedAt DateTime @default(now()) @@index([leadScore]) @@index([sentiment]) } ```
```bash npx prisma migrate dev --name init_voice_schema ```
```ts import { PrismaClient } from "@prisma/client"; const db = new PrismaClient();
// On Twilio "start" event const call = await db.call.create({ data: { agentId, fromNumber: from, toNumber: to, meta: { streamSid, callSid }, }, });
// On every model turn await db.turn.create({ data: { callId: call.id, role: "assistant", text, startedAt, endedAt }, });
// On every tool execution const t0 = Date.now(); const result = await runTool(name, args); await db.toolCall.create({ data: { callId: call.id, name, args, result, latencyMs: Date.now() - t0 }, });
// On call end await db.call.update({ where: { id: call.id }, data: { endedAt: new Date(), durationMs: Date.now() - call.startedAt.getTime(), status: "completed", }, }); ```
```ts import OpenAI from "openai"; const oai = new OpenAI();
async function analyze(callId: string) { const turns = await db.turn.findMany({ where: { callId }, orderBy: { startedAt: "asc" }}); const transcript = turns.map(t => `${t.role}: ${t.text}`).join("\n");
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.
const r = await oai.chat.completions.create({ model: "gpt-4o-mini", response_format: { type: "json_object" }, messages: [{ role: "user", content: `Score this call. Return JSON {sentiment:-1..1, lead_score:0..100, intent:string, entities:object}.
${transcript}` }], }); const data = JSON.parse(r.choices[0].message.content!); await db.analytics.create({ data: { callId, ...data, leadScore: data.lead_score }}); } ```
```ts // Top leads in last 7 days for a vertical const topLeads = await db.call.findMany({ where: { agent: { vertical: "healthcare" }, startedAt: { gte: new Date(Date.now() - 7 * 86400_000) }, analytics: { leadScore: { gte: 70 } }, }, include: { analytics: true }, orderBy: { analytics: { leadScore: "desc" } }, take: 50, }); ```
The compound index (agentId, startedAt) plus (leadScore) keeps this under 30ms at 1M rows.
When Call crosses 10M rows, switch to monthly partitions:
```sql CREATE TABLE call_2026_05 PARTITION OF call FOR VALUES FROM ('2026-05-01') TO ('2026-06-01'); ```
onDelete: Cascade: orphan turns when calls get deleted. Always cascade child rows.CallSphere runs 115+ DB tables across 6 verticals on Postgres 15. The schema above is a simplified slice — production adds tenant isolation, soft-delete, and 14 indexes per call table. Healthcare's analytics worker computes sentiment + lead_score within 60s of call end and writes back via this exact Analytics table. Pricing covers all storage; trial here.
MongoDB instead? Possible, but joins on call → turns → analytics are clean in Postgres and harder in Mongo. We chose Postgres.
Why store the full transcript text? Search, eval datasets, and BI. Disk is cheap; embeddings are derived.
How big does this get? ~50KB per 5-minute call (turns + tool calls + analytics). 1M calls ≈ 50GB. Manageable on a single db.r6g.large.
Audio recordings in DB? No — S3 with a URL pointer. Postgres for metadata only.
Written by
Sagar Shankaran· Founder, CallSphere
Sagar 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.
See how AI voice agents work for your industry. Live demo available -- no signup required.
Haystack 2.7's Agent component plus an Ollama-served Llama 3.2 gives you tool-calling RAG with citations. Here's a complete pipeline against your own document store.
Run STT, LLM, and TTS entirely on Cloudflare's edge — no OpenAI, no ElevenLabs. Real working code with Whisper, Llama 3.3 70B, and Deepgram Aura.
Version your prompts in git, run a 50-case eval suite on every PR, block merges below threshold, and ship a new agent prompt with confidence — full GitHub Actions tutorial.
Replace expensive outbound SDR tooling with a self-hosted dialer that runs OpenAI Realtime agents at 100 concurrent calls. Full architecture and code.
HVAC companies miss 40–60% of inbound. Build a 4-agent dispatch (intake, scheduling, parts, emergency) that integrates with ServiceTitan in 600 lines.
LangChain v1 + LangGraph v1 in JS, paired with Ollama, gives you a fully local chat agent with tools, memory, and structured output. No OpenAI key required.
© 2026 CallSphere LLC. All rights reserved.
Watch how CallSphere handles real customer calls, schedules appointments, and processes payments — live.
Try Live DemoBook a DemoCalculate Your ROI