Overview

Icephone

A self-hosted AI voice-operations app for teams that live on the phone. Configurable voice agents, a built-in mini CRM, campaigns, call queues, a full calendar, and multi-provider telephony (Twilio, Telnyx, Vonage) in one Next.js app, built around one idea: "place a call" is not a single API request, so the whole outbound lifecycle is modeled as durable state instead of a fire-and-forget fetch.

TypeScriptReactNext.jsPostgreSQLDrizzle
AI VoiceOpenAI RealtimeTelephonyCRMCal.com
Icephone

IcePhone is the cold-calling infrastructure a sales team would otherwise have to rent. If you want AI-powered outbound calling today, you buy it as a SaaS and hope their CRM integration matches yours. IcePhone is that whole product, including voice agents, campaigns, call queues, lead tracking, and scheduling, as one self-hosted Next.js app a team deploys on their own server and operates themselves. It’s also the project where I set out to prove something specific: that I can think like a product owner and ship a complex full-stack application end to end, not just the AI demo at the center of it. The voice model is maybe 10% of the work. The other 90% is everything a team needs around it, plus one engineering fact I refused to paper over: “place a call” is not one API request. So I never modeled it as one.

A full product, not a voice demo

Start with what a team actually gets. There’s a mini CRM built in. Leads live on a Kanban-style pipeline board, so a small team that isn’t on HubSpot or Salesforce yet doesn’t need one to run campaigns; they can track leads, move them through stages, and dial straight from the board. If they are on a CRM, IcePhone syncs to HubSpot, Salesforce, GoHighLevel, or Pipedrive instead of replacing it.

There’s a full calendar application inside the app, integrated with Cal.com, because the natural output of an AI phone agent is a booked meeting. When an agent schedules an appointment mid-call, it lands somewhere a human can actually see and manage it, not in a webhook log. Call queues get their own operational UI, teams can hold multiple phone numbers and assign them per campaign or per agent, and you can define as many voice agents as you have jobs: one for customer service, one for appointment setting, one for sales, each with its own prompt, personality, voice, and operating mode. The knowledge base is deliberately visual too. You can see at a glance which sources exist, which files are indexed, and which of them a given agent is allowed to search. Adding a source is drag-a-file easy, because the person doing it is an ops manager, not a developer.

Every one of those is a feature a team would otherwise pay for separately. Getting them to coexist in one coherent app, where the agent, the pipeline board, the queue, and the calendar all read from the same domain model, is the product-design work this project exists to show.

The engineering spine: a call is a lifecycle, not a fetch

Under the product sits the decision everything else follows from. A dispatch to Twilio doesn’t mean a call happened; it means Twilio accepted a request. The call might ring, connect, complete, fail, hit a busy signal, go to voicemail, or hand you a recording minutes later as a separate event. Collapse all of that into one calls row you mutate in place and you’ve thrown away every intermediate truth the moment a provider contradicts itself. So there are separate durable objects, each owning one slice of reality: the internal queue item (dial this lead, with these retries), the user-facing call record a human reads in the UI, the provider-level call keyed by the provider’s own ID, every raw webhook event with its full payload, and recordings as their own artifacts.

The queue processor is deliberately not a function that “makes a call.” It claims a batch of pending rows and, for each one, resolves provider, outbound number, and config through three levels: explicit selection on the call, then the team or agent’s assigned number, then environment defaults. It caches each lookup for the batch so fifty calls isn’t fifty repeated reads. Then it hands off to a provider adapter whose execute() returns a discriminated union: completed, retryable_failure, or failed. That union is the whole retry policy in one shape. A retryable failure under the max gets rescheduled with backoff; everything else is terminal. Provider code returns those outcomes instead of throwing, so an operational “the number was busy” never looks like a crash.

Webhooks are an adapter boundary, and every one is a receipt

The inbound half has its own discipline. Twilio speaks form-encoded, Telnyx nests its payloads, Vonage signs with JWTs, and all three name their events differently. It’s the kind of thing that rots into a giant if (provider === ...) handler if you let it. Instead each provider is an adapter with exactly two jobs, verify the signature and normalize the events, and the route never learns a single provider’s dialect. A few decisions in that route are the “you only write this after production burned you” kind, written defensively up front:

  • Idempotency is a dedupe key, not a hope. Every event hashes provider + event ID + type + raw body, and inserts conflict-away on that key. Providers retry webhooks; without this you apply “call completed” twice and corrupt your own durations.
  • Rejected webhooks are still recorded. A bad signature never touches call state, but it lands in the event log marked rejected, so you can see attempted traffic without trusting it.
  • Recording events don’t overwrite call lifecycle. A recording callback often carries a status like completed, but that refers to the recording, not the call. A guard keeps a late recording from quietly marking a live call as finished.
  • The full payload is always kept. Normalized fields for the app, raw payload for the day a provider’s docs are wrong or a new event type shows up. It always shows up.

It’s the same lesson Gooselake is built around, model the operation, store receipts, and never confuse “I sent a request” with “the thing happened,” arriving from the product side instead of the runtime side. Campaigns respect the same boundary: a campaign never dials. It applies its own rules (business hours, daily limits, per-lead attempts) and emits generic call work; one execution path does all the dialing, so manual calls, retries, and campaign calls share the same pipe and there’s no second, drifting copy of “how a call gets made.”

Agents are compiled, and their tools are scoped

The agent builder is where product abstraction meets model contract. What you configure (prompt, first message, personality, operating mode, voice, allowed knowledge sources) gets compiled into an OpenAI Realtime session when a call starts: the session route reads the agent row, folds in mode and script direction, mints a short-lived client secret, and assembles the knowledge scope by hand. You can test the result live in the browser, with transcripts and speaking state, before it ever touches a phone line.

The tool design is the part I’d point an engineer at. Like Zodex, the agent gets a small, deliberate tool surface, schedule an appointment and search the knowledge base, rather than a sprawling API. And the knowledge-search tool is scoped twice: configured source IDs are intersected against the team’s actually-owned sources before they’re ever named in the instructions, and then the tool-execution layer refuses out-of-scope IDs anyway, because model instructions are not a security boundary. They’re a suggestion the model usually takes. The six operating modes (support, cold-calling, collections, appointment-setting, onboarding, retention) are typed template data rather than branches scattered through the UI, so a new mode is a new record, not a new if.

Delegate the commodity, own the mapping

Two integration decisions I’d defend. The knowledge base leans on OpenAI’s managed vector stores for the live path: files go to R2, get uploaded to OpenAI, and the app keeps only operational metadata in its own tables. Owning a perfect vector pipeline was never the mission; making knowledge safely available to a realtime agent was. The upload path even cleans up its own R2 object if the OpenAI registration fails afterward, so a half-failed upload doesn’t orphan storage.

And CRM sync never pollutes the domain tables: no hubspotId, salesforceId, or pipedriveId bolted onto every lead. External IDs live in one mapping table keyed by team, provider, entity type, and entity ID, and call-outcome sync writes the provider’s activity ID back into that same mapping. Four CRMs coexist while the core schema stays provider-neutral: the same adapter-normalizes-external-chaos move as the telephony layer, one floor down.

Safe by default, because a bug here calls real people

Outbound telephony is the rare web feature where a bug dials a real human. So the dangerous side effect is opt-in at every layer: real call execution requires an explicit CALL_EXECUTION_ENABLED=true, the processing endpoints require bearer secrets, unsigned webhooks are a documented dev-only escape hatch that’s off by default, signup is gated behind an env flag, and the mock telephony provider is the default. A fresh clone explores the entire product without ringing anyone. That costs setup friction, and it’s the correct trade for a self-hosted app that can spend real money on real calls. Every integration past the database/auth baseline is optional and degrades only its own feature; the core app comes up with nothing but a database, an auth secret, and a URL.

What it proves

The domain model spans teams, leads, campaigns, calls, telephony state, agents, knowledge, CRM mappings, calendars, and audit logs, with checked-in migrations and multi-tenant scoping throughout. What I’m proud of isn’t any single feature. It’s that the flashy part, an AI that talks on the phone, sits inside a real operations product with the boring, necessary things done properly: pipelines, queues, reconciliation, scheduling, safety gates. Anyone can wire a voice model to a phone number. Building the thing a sales team could actually run their outbound on is a different job, and that’s the one this project does.

The stack

Next.js and Bun, Drizzle over PostgreSQL, Better Auth with team-scoped access, OpenAI Realtime for in-browser and on-call voice, Cloudflare R2 for file storage, OpenAI vector stores for knowledge search, Cal.com for scheduling, and telephony across Twilio, Telnyx, and Vonage behind one adapter interface, with a mock provider as the default so nothing calls a real number until you say so.

Use and to navigate