An open-source call center where the AI is the default answer, not an add-on.
Calls arrive at FreeSWITCH and are answered by a voice model over a speech-to-speech connection the application terminates itself. The model talks; a flow steers the conversation and decides when a person is needed. When one is, the caller is transferred into a real queue where real agents are waiting, their SIP leg held by a companion Chrome extension. Everything the two halves do lands in one call record.
It is one Go binary — REST API, event stream and the whole web interface inside it — plus PostgreSQL and FreeSWITCH.
Linux:
curl -fsSL https://github.com/rasonyang/ai-native-callcenter/releases/latest/download/install.sh | sudo shmacOS:
curl -fsSL https://github.com/rasonyang/ai-native-callcenter/releases/latest/download/install.sh | shDetails: deploy/one-line-installer.md.
Answers with a model, not a menu. The AI leg is a SIP endpoint inside the application: FreeSWITCH bridges the caller to it and audio goes straight to the provider — G.711 passed through byte for byte where the provider accepts it, so nothing decodes or resamples on the way.
Steers without scripting the conversation. The model owns the dialogue; the
flow owns the phase. A phase carries instructions and a list of tools the model
may use; transitions fire on tool results. A phase may also carry a line of its
own — a greeting, a hand-over script, a goodbye — which the bot is meant to say
as written rather than paraphrase: doubao is handed the words to speak, the
other four providers are instructed to repeat them word for word. The built-in
tools may refuse —
"the queue is closed" is something to talk about, not an error — and the
persona, the rules and the bot's voice are published and versioned together.
Hands over to people properly. Transfers go into mod_callcenter queues,
and the agent's screen has popped by the time the phone rings: a
PARTY_RINGING event over SSE says who is calling and carries the call's
userData, and the bot-phase transcript is already there to read, because the
call id is minted before any leg exists and survives the transfer. Agents work
in the browser: presence, call control, callbacks — but not the audio, which
belongs to the web-sip-phone
Chrome extension, a separate repository. It holds the agent's SIP registration
with credentials this application issues at sign-in, and it has no dialpad of
its own: answering, holding and hanging up are REST calls here, carried to the
phone over ESL uuid_phone_event. Supervisors get a live wallboard, the queue
view and the roster.
Keeps one record per conversation. A call that a bot answered, handed to a queue and an agent finished is one CDR with one transcript and one recording — not three fragments. Recordings go to a filesystem or to any S3-compatible store.
Speaks two languages, and admits which provider it runs. English and
Chinese throughout, interface and bot. One provider answers every call in a
deployment, chosen at startup: qwen or doubao inside mainland China,
openai or gemini elsewhere, or gateway for a Realtime gateway of your
own. A call's language never selects it. Three of those are profiles of one
protocol; doubao and gemini each speak a different one and have a client of
their own (how a provider is added).
┌──────────── one Go binary ────────────┐
caller ──▶ FreeSWITCH ──▶ SIP UAS ──▶ provider (Realtime, speech-to-speech)
│ │
│ └─ flow engine: phases, tools, transfers
│
├─ mod_callcenter queues ──▶ agents (browser + web-sip-phone)
│
└─ ESL ──▶ call registry ──▶ REST + SSE ──▶ web interface
│
PostgreSQL
FreeSWITCH reads its directory and its mod_callcenter queues from the
database, through Lua. The dialplan is static XML, but its rules decide
nothing by themselves: each one hands the call to a Lua script that looks the
answer up. Adding an extension, a queue or a number is a database change; the
switch's own files change only when the routing itself does.
Two design notes worth knowing before reading the code:
- The domain model is Genesys-lineage. A call aggregates parties; leg
events are
PARTY_*, call-scoped ones areCALL_*. - Every live call is an actor — one goroutine as its sole mutator, snapshots by
mailbox. Nothing in
internal/telephonylocks call state, because there is no shared call state; the AI leg is the exception, holding mutexes over the playback and recording state its own goroutines share.
The full design is in docs/design/, starting with
the overview.
make dev-up # PostgreSQL in Docker
cd web && npm install && npm run build && cd ..
make build # bin/aicc, with the interface embedded
./bin/aicc useradd -username admin -password '…' -role ADMIN
./bin/aiccFor frontend work, make web-dev runs Vite on 5173 against the API on 8080.
go test -race ./... # always -race; it has caught real bugs here
make lint # go vet, gofmt, oxlint
make api-check # the API contract gateThe HTTP API is spec-first: docs/openapi.json is the
single source of truth, and the Go server and the TypeScript client are
generated from it. Edit the contract, run make api-generate, then implement.
Never the other way round.
- A new voice provider is a profile, not a client:
docs/provider-extension.md. - A new flow is a JSON document validated at load — see
internal/seed/flows/for a working bilingual one. - A new screen follows the design system in
web/CLAUDE.md, which is binding rather than advisory. - An integration with another product is a service behind the flow's
backend URL, never code in this tree:
CONTRIBUTING.md.
The human path, the AI path, the product surface and the packaging are built and verified against live FreeSWITCH and live providers.
Performance is not yet a claim this project makes. The design has a capacity budget and a latency target (design 06), and the harness to test them against is in the repository (docs/load-tests.md) — but the benchmark campaign itself is still to come, so treat the budget as an intention rather than a measurement.
What is deliberately not here, and will not be: any cascaded ASR + LLM + TTS pipeline inside this process. That composition belongs in a separate service speaking the same protocol.
Apache-2.0. See LICENSE.
