What is not built
Documentation that describes a feature which does not work is worse than no page, because someone will build against it. This page is the list of things that look finished and are not.
Everything here was checked against the code. Where a page elsewhere on this site describes one of these features, it carries the same warning and links back here.
How to read the labels
works proven in code and exercised. partial some of it works; the row says which part does not. not built specified, or written and never called. Do not plan around it.
The short version
If you are choosing whether CallAgent fits, these five decide it.
| Twilio is the only carrier that can reach the AI pipeline. | Five other adapters exist. Only Twilio has a working media path from a phone call to the gateway. |
| No SIP trunk call has ever reached an assistant. | Registration, IP authentication and ACL admission are proven against a lab peer. The media handoff after that is not built. |
| Speech-to-speech is ignored at run time. | The field is stored, versioned and published, and the live call session never reads it. |
| Post-call analysis never runs. | Summary, sentiment, topics, success evaluation and structured-output extraction all depend on a job nothing dispatches. |
| No provider adapter has ever been run against a live vendor in CI. | The wire formats are implemented from vendor documentation and reviewed. That is not the same as proven. |
Telephony
SIP trunking stops at the media handoff
partial
What is proven, against a real SIP peer (a Kamailio container in telephony/sip-peer/, driven by prove.sh):
- a challenged
REGISTERthat succeeds; - a wrong password that fails with
401and surfaces the reason on the trunk row; - an
INVITEadmitted from an address in the trunk ACL; - an
INVITErefused with403from an address that is not; - re-registration at roughly 45 s against a 60 s expiry.
What has never happened: an inbound trunk call reaching an assistant. The call is admitted, stamped with its DID and trunk, transferred to the media-handoff extension, and dies there:
CallAgent inbound did=+15551234567 from=+15550001111 trunk=<gateway-ulid>
CallAgent handoff mode=esl_socket target=host.docker.internal:8084
CallAgent handoff-failed mode=esl_socket reason=inbound_did [CRIT]The cause is three components built to three different integration seams:
- the FreeSWITCH dialplan expects an outbound ESL socket on port 8084;
- the voice gateway binds one port, HTTP and WebSocket on 3010, and contains no ESL server and no SIP stack. You can see this yourself:
lsof -nP -iTCP -sTCP:LISTENwhile the gateway runs shows 3010 and nothing else; - the one media seam the gateway does implement, a WebSocket at
/media/freeswitch, needsmod_audio_forkormod_audio_streamon the FreeSWITCH side, and the shipped FreeSWITCH image ships neither.
This is not a SIP problem and fixing SIP will not fix it.
Also unproven regardless of the handoff, because a local peer cannot be a carrier: SHA-256 digest (RFC 8760 — the prober is MD5 only), TLS and SRTP against a real SBC, NAT traversal and carrier keepalive quirks, real DIDs, caller-ID presentation, billing, and 302/Diversion behaviour.
If a runbook tells you to check port 8084
nc -z voice-gateway 8084 will always print CLOSED. Nothing listens there. Any instruction that treats an open 8084 as the healthy state is out of date.
Carrier adapters
partial
Nine provider ids are declared. Six have an adapter class; three (zadarma, didlogic, chime) throw a 501 naming themselves. Of the six, the capability matrices differ enormously and each adapter declares its own — read the matrix, not the name. The one that matters most:
- Telnyx declares
media_streams: implemented, and nothing in the codebase builds a Telnyx media-stream instruction.placeOutboundCall()posts a Call-Control webhook URL and stops. A Telnyx call will not reach the AI pipeline. This is a declared capability that disagrees with the runtime. - Plivo declares
media_streams,inbound_voice,transfer,recordingandamdas not implemented. It can buy numbers and place a call; it cannot run an assistant. - DIDWW implements three things: listing account numbers, validating a credential, and inbound voice carried over a CallAgent SIP trunk. Everything else throws.
See Telephony for the full matrix.
Outbound DTMF on a Twilio media leg
not built
Sending digits out of a call requires the Twilio REST API, and the gateway deliberately holds no telephony credentials. The gateway posts the intent to the control plane instead. Inbound DTMF detection works.
The pipeline
Speech to speech
not built
speechToSpeechModel is a real field. It validates, it is stored, it is versioned, it is published, and it appears in the snapshot the gateway receives.
CallSession — the class that runs live calls — never reads it. There is a complete, unit-tested S2sPipeline in the tree; nothing constructs one. An assistant configured with speechToSpeechModel runs the ordinary STT → LLM → TTS pipeline and says nothing about it.
Of the four realtime provider ids declared, only openai_realtime has an adapter at all. The other three are absent rather than stubbed, which is the honest shape.
Provider adapters are reviewed, not proven
partial
The provider SDK carries a verified flag on each adapter. Its meaning is precise and narrower than it sounds:
verified: truemeans the wire format is implemented from the vendor's current, authoritative protocol documentation and has been reviewed against it. No adapter in this package has been run against a real endpoint in CI.
No test in the SDK gates on a live credential. Every test is fixture-based.
| Class | Ids declared | Adapters registered | Flagged verified |
|---|---|---|---|
| Model | 18 | 17 + custom_llm shim | 13 |
| Transcriber | 14 | 13 + custom shim | 3 |
| Voice | 19 | 16 + shims | 2 (ElevenLabs, OpenAI) |
| Speech to speech | 4 | 1 | 1, and unreachable — see above |
Two voice ids — sesame and xai — carry an explicit in-code note that they are not implemented against the vendor's API; they only work against an endpoint you supply that speaks CallAgent's own TTS protocol. callagent as a voice provider is an indirection layer whose catalogue ships empty on purpose.
The practical reading: ElevenLabs is the voice provider the team has the most confidence in, twelve more have real HTTP clients pointed at real vendor URLs with an unverified wire format, and the rest need your own endpoint. None has a green tick it earned from a live call.
Azure: three adapters written, none proven
not built
azure_speech (voice), azure_speech (transcriber) and azure_openai (model) are complete adapters with fixture-backed tests, and all three are seeded not_implemented, which means no tenant can select any of them. That is not an oversight; it is the rule in §188. None has produced audio, a transcript or a completion against a real Azure endpoint, and implemented is a claim that something has been seen to work, not a description of how finished the code looks.
What each one is waiting for is one live run:
| Provider | Needs | The single test that would settle it |
|---|---|---|
azure_speech voice | A Speech resource key and its region | Synthesize one utterance as µ-law 8k and hear audio, i.e. TtsFirstAudio on a real call |
azure_speech transcriber | The same key and region | Open the recognition socket, speak, and see a speech.hypothesis then a speech.phrase |
azure_openai model | Resource name, deployment name, api-version and a key | One streamed completion from /openai/deployments/{deployment}/chat/completions |
Two details in the transcriber were implemented from the protocol reference and have never been observed: the streaming RIFF header's length convention, and whether the endSilenceTimeoutMs query parameter is accepted on the recognition socket. Both are called out in the adapter's docblock.
The Azure Speech key and region are one credential for both products — connect it once and it satisfies transcription and voices — because the two registry rows ask for the same fields and ProviderAccountScope derives sharing from that rather than from a list.
The REST API cannot set most of an assistant's provider settings
not built
POST and PATCH /assistants use Laravel's $request->validated(), which returns only the keys that carried a validation rule. Inside transcriber, model, voice and server, the request class declares rules for five sub-keys and no more:
transcriber.provider · model.provider · model.model · voice.provider · server.url
Everything else in those four blocks is discarded before it is stored — voice.voice_id, voice.speed, voice.stability, model.temperature, model.max_tokens, model.tool_choice, model.num_fast_turns, model.context_window_messages, transcriber.model, transcriber.language, transcriber.smart_format and the rest. The request succeeds with a 201, and the pruned value comes back in the response.
Reproduced on a live control plane:
sent "voice": { "provider": "elevenlabs", "voice_id": "rachel", "speed": 1.1 }
stored "voice": { "provider": "elevenlabs" }voice.voice_id is one of the five fields the gateway's call config cannot be built without, so the consequence is not cosmetic: an assistant whose voice block was last written through the REST API produces a null call config and cannot start a call. validate checks the other four and not this one, so it validates and publishes cleanly. The CLI posts to the same endpoint and inherits the problem.
The dashboard writes the model directly rather than through this request class, so assistants configured there are complete. The plan blocks have no sub-rules and pass through whole.
Two seeded demo assistants cannot start a call
not built
A related spelling problem, visible in the shipped seed data. The wire converter reads voice['voice_id']; two seeded assistants — Outbound — appointment reminders and Blue Olive — reservations — carry only voiceId in camelCase and no voice_id:
{ "model": "eleven_flash_v2_5", "speed": 1, "voiceId": "burt", "provider": "elevenlabs" }Their call config resolves to null for the same reason as above.
callagent:doctor is killed by the faults it exists to report
partial
php artisan callagent:doctor autoloads the application's classes to find a broken one, so a fatal class-load error takes the command down with it and you get the raw PHP error instead of a report. At the time of writing a Filament widget does exactly this — CallTrendsWidget::render() is declared private where the parent declares it public — and doctor and php artisan tinker both die on it.
The fatal still names the file and the line, so it is not useless. callagent:check does not autoload the dashboard and keeps working.
Assistant hooks fire for one event out of fourteen
partial
hooks[] declares an action at one of fourteen trigger points. The control plane converts them and ships them to the gateway. The gateway source contains no reference to hooks at all.
One trigger does run, in the control plane rather than the gateway: handoff, through HandoffHookRunner. The other thirteen do nothing.
Conversion also narrows the actions: only say, transfer, tool and end_call survive it. A handoff, setVariable or webhook action is dropped silently.
Assistant fields the gateway never reads
not built
Each of these is accepted by the API, stored, versioned and — except where noted — converted onto the wire. Nothing in the gateway reads any of them.
| Field | Intended effect |
|---|---|
model.num_fast_turns | Route the opening turns to a cheaper model. |
model.fast_model | Which cheaper model. Not even converted — contract only. |
model.emotion_recognition | Emotion signals to the model. |
voice_formatting (contract chunkPlan) | How text is normalised and chunked before synthesis. |
pronunciations (contract pronunciationDictionary) | Per-word pronunciation overrides. |
compliance_plan | Including identity_disclosure_required. |
artifact_plan.storage_path_template | Where a recording is written. |
speech_to_speech_model | The whole realtime pipeline. |
Smart endpointing providers
partial
startSpeakingPlan.smartEndpointing.provider accepts callagent, livekit and model. Only callagent is implemented. Choosing either of the other two logs a warning and silently runs the built-in scorer.
After the call
Post-call analysis
not built
Summary, sentiment, topics and success evaluation have a complete pipeline and a job class. The call finaliser runs redaction, artifacts, post-call transcription and retention — and never dispatches the analysis job. There is no test covering it.
calls.summary is populated only when the gateway supplies it in the finalisation payload.
Structured output extraction
not built
Same cause. Definitions, versioning, validation and storage all work. The extraction step is performed by the analysis pipeline above, which does not run.
Scorecards on live calls
partial
The scoring engine is real and is exercised by simulations. Scoring a live call depends on the analysis pipeline.
Observability trace export
not built
Langfuse and OTLP exporters exist. Nothing invokes them. The gateway logs it plainly: tags and sampling are applied, spans are not shipped. Do not plan an integration on it.
Post-call transcription
partial
The provider list comes from the registry; Sonix is the only adapter. Any other choice has no client behind it, and the interface says so rather than failing later.
Recording and compliance
Recording starts before consent is announced
partial
This one matters legally, so read it carefully.
The recorder is constructed and started, and playout begins, before the consent announcement is spoken. The announcement follows.
Worse, the gateway can only ever report one consent state to the control plane: announced. It never reports granted or denied, because there is no acknowledgement mechanism — no DTMF prompt, no phrase matcher. The onConsentDenied setting is carried in the call config, is not read, and cannot fire. The control plane has a applyConsentDecision() that pauses recording; nothing on a real call triggers it.
So: an announcement can be played. A refusal cannot be honoured. Treat "recording consent" as "an announcement was made", and satisfy any stricter obligation outside CallAgent.
Region rules on the recording policy are operator-facing documentation. Nothing branches on them to decide whether a recording is lawful.
AI disclosure, and EU AI Act Article 50
not built
Nothing in CallAgent makes an assistant disclose that it is an AI.
compliance_planon an assistant (includingidentity_disclosure_required) is stored, returned by the API and included in the published snapshot. The voice gateway never reads it.- The first message is validated for length and template syntax only. No content rule requires a disclosure.
- The only Article 50 disclosure in the whole codebase is hardcoded into CallAgent's own outbound sales demo, which refuses to place a call if the disclosure string is empty. That mechanism is not exposed to customer assistants.
If you need a disclosure, put the sentence in first_message yourself and leave first_message_interruptions_enabled at false so it cannot be talked over. See AI disclosure & consent.
PII redaction does not touch audio
partial
Redaction runs once, at finalisation, over text: message contents, the transcript, the summary and event payloads. artifactPlan.piiRedaction.redactRecording exists as a field. The recording audio is never modified.
artifactPlan.storagePathTemplate is ignored for recordings
not built
The recorder hardcodes org/{organizationId}/calls/{callId}/{channel}.wav. The template field is never read anywhere in the gateway.
Contact centre
Supervisor listen, whisper and barge
partial
Proven: the permission gate, the audit trail (every attempt writes a row, including denials and failures), and the endpoint contract. end is fully proven, because it owns the call-state transition.
Not proven: that audio ever bridges. The FreeSWITCH eavesdrop path has no test in which the switch is enabled — the test suite says so in a comment. The newer gateway-participant-leg path is tested against a faked HTTP gateway, not a running one. That path is also off by default (contact_center.media.gateway_leg_enabled).
Warm transfer is FreeSWITCH-only
partial
A consultative (warm) transfer is a three-step hold → originate → bridge sequence using FreeSWITCH commands. There is no Twilio equivalent implemented. On the Twilio conference path, a private consultation between two operators is not offered rather than faked — both are in the conference with the caller.
Extension transfer from a hosted carrier
partial
A Twilio call cannot reach a bare internal SIP extension. The transfer is refused with extension_not_reachable_from_carrier unless the extension has an always-forward destination.
Dynamic transfer destinations from a tool
not built
When the assistant's transfer tool asks for a dynamic destination, the dispatcher refuses it with dynamic_expression_unresolved. Dynamic destinations resolve only on the operator- and supervisor-initiated path.
Operator web phone
not built
The architecture is settled — it reuses the Web SDK's WebRTC transport. It is not built. Operators register a SIP device.
Tools
Five tool types never reach a call
not built
bash, computer, text_editor, client_side and custom_provider are valid values of the tool type enum. You can create them through the API and attach them to an assistant.
The mapping that converts a stored tool into the gateway's wire format has no entry for any of the five. A tool of one of those types is silently dropped from every call's tool list. The model never sees it.
client_side tools do work through the Web SDK's own registration path — that is a different mechanism from a stored client_side Tool row.
The 24-tool MCP server in packages/mcp is not mounted
not built
There are two "CallAgent as an MCP server" implementations. The TypeScript one in packages/mcp declares 24 tools and is imported by nothing — a scaffold, not a running server.
The live one is POST /mcp in the control plane, API-key authenticated, and it publishes 13 tools. Absent tools are absent on purpose: the registry's comment is that advertising a tool whose subsystem is unfinished is worse than omitting it. See MCP.
MCP OAuth refresh in PHP
partial
The TypeScript MCP client refreshes an expired OAuth token. The PHP side — used for discovery, not for tool calls — reports it as expired instead.
Platform
The API
| Expected | Reality |
|---|---|
POST /api/v1/client/calls | not built Starting a browser call through the API. The token exchange (POST /client-tokens) is live; this is not. |
/api/v1/policies | not built The CLI validates policies/ locally and says it cannot push them. |
/api/v1/files, /api/v1/storage | not built Storage destinations are configured in the dashboard only. The storage.write scope exists with nothing behind it. |
/api/v1/chat | not built A ChatController exists in the tree and no route file references it. The chat.read / chat.write scopes are unreachable. |
/api/v1/sms | not built The sms tool type and the send contract exist; there is no endpoint and no adapter. |
Idempotency beyond POST /calls
partial
POST /calls deduplicates on (organization_id, idempotency_key) against a unique index on the calls table. That is real and enforced by the database.
There is also an idempotency_keys table with a request_hash column, an idempotency_conflict error code and a documented 409. No code writes to or reads from that table, the service class its own docblock points at does not exist, and nothing raises idempotency_conflict. A generic Idempotency-Key on other endpoints does nothing.
Note the consequence on POST /calls: replaying a key with a different body returns the original call rather than erroring. There is no request-hash comparison.
Per-tenant rate-limit overrides
partial
A plan's api_rate_limit_per_minute genuinely overrides the 300/minute default. A rate_limit_overrides table exists with bucket, per_minute, burst and expires_at columns, and no code reads it. See Rate limits.
SAML SSO and SCIM groups
not built
The SSO service returns "SAML is not implemented in this build" and there is deliberately no route; it is blocked on an XML-DSig library. SCIM provisions users; groups are absent.
Vector stores and embeddings
partial
pgvector and custom are implemented. Qdrant, Pinecone and Weaviate exist as enum cases so the interface can say "not yet" honestly. OpenAI is the only embedding provider.
Stripe has never been called with a real key
not built
Every Stripe path is covered by tests that drive the real Stripe SDK — real request signing, real serialisation — against canned fixtures. No live or test-mode Stripe endpoint has ever been contacted. STRIPE_SECRET is empty in the shipped example environment, and the gateway throws a named error rather than degrading quietly.
Webhook signature verification, idempotent event handling and ledger-authoritative crediting are all real code. None of it has met Stripe.
This now includes the paid-signup checkout. StripeGateway::startCheckout() builds a real Checkout Session payload and checkout.session.completed is handled, idempotently, by the webhook. Neither has met Stripe. What is proven is everything on this side of the wire: a signed event grants credit exactly once, a replayed event grants nothing further, two different events describing the same session still credit once, an unsigned body is refused, a live-mode event delivered to a test-key deployment is ignored, and a session that completed with payment_status: unpaid credits nothing. What is not proven is that Stripe accepts the session parameters at all.
One test in Stripe test mode would settle it: create a checkout through SignupCheckoutService::start() with sk_test_ keys, pay it with card 4242 4242 4242 4242, and confirm through stripe listen --forward-to that the resulting checkout.session.completed credits the wallet once. A second run of the same session id proves the replay guard against the real event stream rather than against a fixture.
Revolut Merchant is sandbox-only and unproven end to end
partial
A second processor sits behind App\Services\Billing\Contracts\PaymentGateway, routed by the payments.provider column that has been in the schema since it was created.
What is confirmed against the real sandbox: orders are created (POST /orders → 201, returning a working checkout_url), retrieved, and refused a refund with 422 bad_state on an unpaid order — so the routes and schemas are right. A webhook endpoint is registered (POST /api/1.0/webhooks → state: ACTIVE), which confirms the four event names ORDER_COMPLETED, ORDER_AUTHORISED, ORDER_CANCELLED and ORDER_PAYMENT_FAILED are real, and produced the wsk_… signing secret now in the environment. Note that endpoint is versioned (/api/1.0/webhooks) while /orders is not.
No delivery has ever been received, so the signature scheme is still unconfirmed. The implementation follows what Revolut documents — HMAC-SHA256 over v1.<timestamp>.<raw body>, presented as v1=<hex> in Revolut-Signature — but that documentation could not be fetched to verify it, so it is recollection rather than a citation. If it is wrong, every genuine delivery is rejected with 400 and the endpoint looks healthy from the outside while nothing is ever credited.
REVOLUT_CAPTURE_UNVERIFIED_WEBHOOKS=true is the way to settle it: a rejected delivery is still rejected and still credits nothing, but its headers and body are recorded so one real sandbox payment shows exactly what arrived. Turn it off afterwards — with it on, the endpoint stores whatever anybody posts at it.
REVOLUT_PUBLIC_KEY is still empty. Server-side orders and the hosted checkout work without it; only Revolut's embedded JS widget needs it.
Sandbox credentials on a production deployment
The sandbox webhook is registered against the production URL, because an endpoint has to be reachable to be registered. Sandbox money is fake and the credit it grants is real, so a sandbox payment must not be able to fund a production tenant.
Stripe catches the equivalent misrouting with the event's livemode flag. Revolut provides neither a flag on the event nor an environment marker in the secret key — a real Merchant key is sk_ plus an opaque string — so there is nothing on the delivery to inspect. The guard is therefore configuration: a sandbox-configured deployment running in production records the event and settles nothing, unless REVOLUT_ALLOW_SANDBOX_SETTLEMENT_IN_PRODUCTION=true is deliberately set for a test. The configured base URL is checked as well as REVOLUT_ENV, because the variable is a claim and the host is a fact.
Romanian e-Factura is built and has never been submitted
partial
The invoice data model carries what RO_CIUS needs — fiscal series and sequence, both parties' VAT and trade-register identifiers, ISO 3166-2 county codes, UN/ECE unit codes and UNCL5305 VAT category codes per line — and EFacturaBuilder emits UBL 2.1 that parses and validates against a readable subset of the rules. AnafClient implements the SPV upload, status and download calls.
No document has ever been submitted, and none has been accepted. ANAF issues an SPV token only against a qualified digital certificate enrolled to the company, which this deployment does not have. Also still missing: the token-refresh flow, a configured invoice series (so every invoice issued today is legacy-numbered and the builder correctly reports it as not fiscally numbered), the seller's own identifiers, a RON restatement of the VAT total at a BNR reference rate, and the official schematron.
Self-serve onboarding hands over a number it did not buy
partial
Paying for a signup on a solution page provisions a real tenant on a real plan, generates an assistant from the business's own website, publishes it and gives them a telephone number that answers — on the same screen. Four things about that are deliberately not what they look like.
Nothing at all happens before the payment is confirmed. Finishing the form writes one row saying what was asked for. No organization, no assistant, no number, no model call. That single gate replaces the three rate limits an earlier version needed: there is no unauthenticated path to any spend, so there is nothing to bound.
No number is ever bought by the flow. Numbers come out of a pool an operator bought in advance with callagent:telephony:stock-number-pool --yes, which is the only place in this product that spends carrier money and is reachable from no HTTP route, no Filament action and no queued job. A signup claims one, which is an UPDATE, and it goes back to the pool if the tenant is never used. A recycled number is scrubbed first — its dynamic hook above all, which would otherwise ask the previous owner's server where to route the next owner's calls.
The tenant runs on platform provider keys, not its own. §31 stays the default for every organization created any other way. A self-serve tenant is flagged provider_key_mode = platform and falls back to credentials belonging to the platform's own organization — which it cannot list, name, test or export, because they are another organization's rows. Its own key, added later, wins from the next call.
Provisioning after a payment is retried, not refunded. A blocked step — an empty pool, a voice provider not answering — is retried with backoff, and the attempt count, the next attempt and whether anybody was told are on the row. At the bound the provision is stamped gave_up_at, the log carries a CRITICAL line and an operator is emailed.
Two caveats worth reading twice. With COST_FX_USD_EUR unset, a euro tenant's usage meters correctly and values at zero, because every vendor in the cost registry publishes in dollars and ExchangeRate refuses to invent a rate; that is recorded on the provisioning row and said on the handover screen. And credit exhaustion ends the call in progress — LiveCreditGuard estimates an in-flight call's spend at the quoted retail rate on each cost report from the gateway and terminates when the wallet cannot cover it. There is no grace period and the balance does not go negative. The resolution is the gateway's reporting interval, so a caller can overshoot by a few seconds.
Browser session protocol
partial
Audio and barge-in work end to end. The conversation events (transcript, message, state, tool_call, ended) are still landing on the gateway. See The browser.
Things that look like a switch and are not
The feature-flag ledger. FeatureFlagSeeder records a maturity for each module and is the closest thing to an official list. Treat it as documentation of intent, not as an enforced switch — nothing outside the flag resource itself reads the table to gate navigation. It is also behind reality in places: module.mcp_server, module.web_sdk and module.cli are all seeded not_implemented and all three are built.
Blocked on an account, not on code
Complete in code, waiting only on credentials:
- A Twilio account, a purchased number and a public tunnel URL — live PSTN calls.
- Deepgram, OpenAI and ElevenLabs keys — real provider latency measurement. Every adapter is tested against fixtures only.
- Stripe test keys — the pay-as-you-go purchase flow.
- A real SIP carrier — everything a lab peer cannot be.
- A Sonix account — post-call transcription.
If you find this page wrong
The page is the bug, not the code. It is written against the repository and it will go stale. Check the behaviour before you rely on either.