Skip to content

Contact centre ​

CallAgent is a PBX and a human contact centre as well as a voice AI platform, so an AI call can escalate to a person without leaving the system. The operator gets the transcript, the caller identity and the reason the assistant gave up, and the caller does not start again.

Use it when calls sometimes need a human. If they never do, skip this page.

The pieces ​

ConceptWhat it is
ExtensionAn internal number: an operator, a ring group, an IVR or a voicemail box. SIP devices register against it.
QueueCallers waiting, with a distribution strategy, an SLA, announcements and overflow.
OperatorA person with skills, languages, a state and a session.
SkillA competency with a proficiency, used to route.
Ring groupSeveral operators rung together or in sequence.
IVRA published node graph: prompts, digit edges, variable capture, webhooks.
Time conditionRoutes by weekly hours, holidays and a manual override.
DispositionThe outcome an operator records during wrap-up.
Conference roomPIN-protected, optionally recorded, moderator-gated.
Voicemail boxGreetings, transcription, notification, retention.

Queues ​

Every setting, with its default ​

FieldDefaultRange
strategyfifothe nine below
priority1000–1000
max_wait_seconds3000–7200
max_waiting_calls1001–10000
offer_timeout_seconds205–120
retry_delay_seconds5
max_offers_per_call101–50, and clamped to 25 at offer time
avoid_recent_rejectortrue
wrap_up_seconds300–900
service_level_seconds201–600
service_level_target_percent801–100
announce_position / announce_hold_timefalse
announce_interval_seconds305–600
agent_announce_queue_nametrue
callback_enabledfalse
callback_offer_after_seconds60
callback_expires_minutes240
overflow_when_no_agentstrue
overflow_after_seconds, overflow_when_waiting_overnull
overflow_destination_type / _id / _valuenullthe ten destination types
voicemail_fallback_enabled, ai_fallback_enabled, human_fallback_enabledfalse
recording_policyinheritinherit · disabled · mono · dual_channel
require_dispositiontrue
skill_strictfalse
required_languagenull
is_activetrue

avoid_recent_rejector is a small setting that matters: an operator who just declined a call should not be offered it again immediately.

Callback is the humane option. Rather than holding someone for twelve minutes, the queue offers to call back and keeps their place.

A queue with callers waiting cannot be deleted — that is a 409, not a cascade.

The nine distribution strategies ​

Every strategy sorts eligible operators and offers to the first. The queue member's penalty is the first sort key in all nine, so penalty tiers always win before the strategy's own rule applies within a tier.

strategyRanks by
fifoHow long the operator has been available. Longest first.
longest_idleWhen the operator last answered a call. Someone who has never answered sorts first.
round_robinWhen the operator was last offered a call. The rotation cursor is a database column, so it survives a restart.
least_callsFewest calls answered in this queue, then longest since last answer.
skill_basedSkill match score, descending, after filtering out operators who fail a mandatory requirement.
priorityThe operator's configured queue priority, highest first, longest-idle within a tier.
vipA VIP caller (is_vip, or priority > 0) gets the highest-priority operators. Everyone else gets the tier order reversed, so senior operators stay free for VIPs.
language_matchOperators who speak the caller's language first. Non-speakers are only excluded when skill_strict is on; otherwise they sort last.
customA declarative sort spec in custom_rules.

custom_rules is deliberately not an expression engine — no eval, no webhook, nothing on the hot path that can hang a call:

json
{
  "require": { "min_priority": 5 },
  "order": [
    { "by": "skill_score", "dir": "desc" },
    { "by": "idle_seconds", "dir": "desc" }
  ]
}

by accepts penalty, priority, calls_answered, last_answered, last_offered, idle_seconds, skill_score. An empty order falls back to penalty, then longest since last answer.

Who is eligible at all ​

Before any strategy runs, an operator must be is_active, can_receive_inbound, in state available, and have a live heartbeat. State and presence are separate: a browser that died leaves the state saying available, and the heartbeat check excludes them from routing before the reaper gets round to logging them out.

Selection holds a per-queue cache lock, and the claim itself is a compare-and-set: UPDATE operators SET state='ringing' WHERE id = ? AND state='available'. One row updated means you won the race. Two workers cannot offer the same operator the same call.

Skills ​

A skill has a category (general, language, product, tier, certification), an optional language_tag and a max_proficiency (default 5). An operator holds it at a proficiency (default 3) with an optional certified_at and expires_at — an expired certification stops counting.

A queue requires a skill with a minimum_proficiency (default 1), is_mandatory (default true) and a weight (default 1).

Matching fails closed: an operator missing a mandatory requirement is excluded entirely, not scored low. The score is a weighted sum of normalised proficiencies — each proficiency is divided by that skill's own max_proficiency first, so 3-out-of-5 and 6-out-of-10 compare equally — plus a flat 2.0 bonus for speaking the caller's language.

"Speaks the language" is checked in three places, in order: the operator's languages array, their language scalar, then any language-category skill held at proficiency above zero.

Operators ​

States ​

Ten states: offline, available, busy, ringing, on_call, wrap_up, break, training, meeting, custom_pause.

An operator may select six of them: offline, available, break, training, meeting, custom_pause. ringing, on_call and wrap_up are set by the engine and cannot be chosen.

Transitions are a strict graph. One class — the operator state machine — is the only thing permitted to write operators.state, and an invalid transition throws rather than being tidied up. Every accepted transition closes the open row in an insert-only state ledger and opens a new one, which is why occupancy and adherence can be reconstructed after the fact instead of trusted from a live counter.

Presence ​

Presence is a heartbeat, not a state. The browser beats every 15 seconds by default; after 60 seconds the operator is stale and excluded from routing; the reaper eventually force-logs them out.

POST /api/v1/contact-center/operators/{operator}/login
POST /api/v1/contact-center/operators/{operator}/heartbeat
POST /api/v1/contact-center/operators/{operator}/state
POST /api/v1/contact-center/operators/{operator}/logout

An offer that is not answered ​

offer_expires_at is set to now plus the operator's no_answer_timeout_seconds, or the queue's offer_timeout_seconds, or 20. A scheduled tick (php artisan callagent:contact-center:tick) finds expired offers and rejects them as no_answer.

The operator returns to available — unless this was their auto_pause_after_missedth consecutive miss (default 3), in which case they are pushed into custom_pause with reason auto_pause_missed_calls. Missing calls quietly is the failure mode this prevents.

Wrap-up ​

wrap_up_until is a deadline, not a countdown, and it lives on the server. A countdown owned by the browser means an operator comes back from lunch still wrapping up a call from an hour ago.

POST /api/v1/contact-center/operators/{operator}/wrap-up

The duration is the queue's wrap_up_seconds, else the operator's, else 30.

Completing wrap-up writes a disposition: disposition_code, outcome, note, tags, first_contact_resolution, quality_score, the elapsed wrap_up_seconds, and an optional callback_scheduled_for / callback_number. It is refused if the queue has require_disposition (the default) and none was given, or if the chosen disposition requires a note and none was supplied.

extend pushes the deadline out. When a deadline expires with no disposition on a queue that requires one, the operator is parked in custom_pause with reason disposition_required rather than released — a skipped disposition becomes visible instead of silently missing.

Transfers and handover ​

The mechanism, and why the caller is not re-dialled ​

This is the part people get wrong when they build it themselves, so it is worth being exact.

  1. The assistant calls the transferCall tool. The gateway takes the caller off hold, moves the call to Transferring, and posts a transfer_request artifact to the control plane. The gateway holds no telephony credentials and never dials anybody.
  2. The control plane matches the destination type. queue, number, sip and extension dispatch. assistant is refused here — that is a same-call persona swap, not a human transfer.
  3. For a queue, two independent halves run and are reported separately: the bookkeeping (queued) and the media (bridged).
  4. The media half never places a new call to the caller:
    • On a call already on our own switch, the caller is simply held.
    • On a Twilio call, the existing call SID is redirected in place to new TwiML that puts the caller into a named conference. The operator's leg is originated separately and joined to the same conference. A test asserts the Twilio request goes to /Calls/{the same provider_call_id}.json with a new Url.
    • Where the gateway participant-leg path is enabled, the operator's browser is minted a second leg on the same live media session and no carrier call is placed at all.
  5. Once bridged, the briefing is attached — transcript, summary, intent, sentiment, tool results, CRM context, caller history, recommended action, all frozen at enqueue — and a handoff seam row is written into the transcript.

Operator-to-operator handover has the same property: the colleague is rung into the caller's existing conference, and only once they are in is the departing operator's leg cleared and hung up. Doing that in the other order drops the caller.

Modes ​

Eight: blind, warm, assistant_warm, dynamic, sip, phone, queue, on_hold_specialist. Consultative: warm, assistant_warm, on_hold_specialist.

assistant_warm differs from warm only in that the AI-generated briefing is spoken to the specialist before the bridge, instead of a human explaining live.

Limits worth knowing before you design around them

  • Warm transfer works only on FreeSWITCH-carried calls. The hold → originate → park → bridge sequence uses FreeSWITCH commands and has no Twilio equivalent implemented.
  • On the Twilio conference path a private consultation does not exist. Both operators are in the conference with the caller. The product says so rather than faking privacy it cannot deliver.
  • A Twilio call cannot reach a bare internal extension. The transfer is refused with extension_not_reachable_from_carrier unless the extension has an always-forward destination.
  • dynamic destinations from the assistant's tool are refused (dynamic_expression_unresolved). They resolve only on the operator- and supervisor-initiated path.

Declaring the tool ​

yaml
name: escalateToBilling
type: transferCall
description: Hand the caller to a billing specialist.
destinations:
  - type: queue
    queueId: 01JQ…
    priority: 10
    message: Let me put you through to billing. One moment.
plan:
  mode: warm_with_summary
  summaryPrompt: Summarise the caller's problem in two sentences for the operator.
  timeoutSeconds: 60

GET /contact-center/queue-calls/{queueCall}/briefing is what the operator's screen pop reads.

A transferCall to a queue moves the call from assistant_speaking to human_queue and then, when an operator answers, to human_connected. Those are real call states on the same record — AI and human legs share one call.

Supervisors ​

POST /api/v1/contact-center/supervisor/calls/{call}/listen
POST /api/v1/contact-center/supervisor/calls/{call}/whisper
POST /api/v1/contact-center/supervisor/calls/{call}/barge
POST /api/v1/contact-center/supervisor/calls/{call}/end
POST /api/v1/contact-center/supervisor/operators/{operator}/force-state
POST /api/v1/contact-center/supervisor/operators/{operator}/force-logout
POST /api/v1/contact-center/supervisor/queue-calls/{queueCall}/move

Body: { "channel_uuid": "…" }, optional.

ActionWho hears itFreeSWITCH eavesdrop flags
ListenNobody but the supervisorneither leg bridged
WhisperThe operator onlyA-leg bridged
BargeEveryoneboth legs bridged

Every action goes through one gated, audited path. A permission check runs first, and an audit row is written on success, on denial and on exception — so an attempt is never invisible, whichever way it went.

What is proven and what is not

The permission gate, the audit trail and end are proven. end owns the call-state transition, so it is unambiguous.

Listen, whisper and barge are not proven to move audio. The FreeSWITCH eavesdrop path has no test with the switch enabled — the test file says so in a comment. The newer gateway participant-leg path is tested against a faked HTTP gateway, not a running one, and is off by default (contact_center.media.gateway_leg_enabled). See What is not built.

Statistics ​

Offered, answered, abandoned, service level, average speed of answer, average handle time, longest wait, operator occupancy — computed per queue and per interval, and shown live on the wallboard.

GET /api/v1/contact-center/queues/overview
GET /api/v1/contact-center/queues/{queue}/statistics
GET /api/v1/contact-center/queues/{queue}/history
GET /api/v1/contact-center/operators/{operator}/statistics

overview returns the whole set at once, which is what the wallboard reads.

Operator web phone ​

Not built

The browser softphone (module.web_phone) is specified and its architecture is settled — it reuses the same WebRTC transport as the Web SDK — and it is not built. Operators register a SIP device against their extension.

API ​

GET/POST         /api/v1/contact-center/queues
GET/PATCH/DELETE /api/v1/contact-center/queues/{queue}
GET              /api/v1/contact-center/queues/overview
GET              /api/v1/contact-center/queues/{queue}/statistics
GET              /api/v1/contact-center/queues/{queue}/history
POST/DELETE      /api/v1/contact-center/queues/{queue}/members[/{operator}]
POST             /api/v1/contact-center/queues/{queue}/members/{operator}/pause

GET/POST         /api/v1/contact-center/operators
POST             /api/v1/contact-center/operators/{operator}/login|logout|heartbeat|state|wrap-up
POST             /api/v1/contact-center/operators/{operator}/calls/{queueCall}/accept|reject
GET              /api/v1/contact-center/operators/{operator}/statistics
GET              /api/v1/contact-center/queue-calls/{queueCall}/briefing

POST             /api/v1/contact-center/calls/{call}/transfer
GET              /api/v1/contact-center/transfers/{transfer}
POST             /api/v1/contact-center/transfers/{transfer}/brief|complete|cancel

GET/POST         /api/v1/contact-center/extensions | skills | dispositions | ring-groups | ivrs

Full detail: queues, operators, supervisor, transfers, extensions, catalogue.

Next ​

Documented against the code. Anything not yet implemented is labelled, never described as working.