Skip to content

Call states & events ​

A call is a state machine that emits an append-only event stream. The states tell you where a call is now; the events tell you everything that happened. Both vocabularies are defined once in @callagent/contracts and mirrored in PHP (App\Enums\CallState, App\Enums\CallEventType) with a test that reads the PHP source and fails if the two ever disagree.

End reasons are the exception, and they have drifted

The states and the event types really are mirrored, and a test enforces it. End reasons are not. CallEndedReasonSchema in the contracts has 19 values; App\Enums\Calls\EndedReason in the control plane has 20, with different spellings — customer_hangup against customer_hung_up, concurrency_exceeded against concurrency_limit, insufficient_credits against insufficient_credit.

GET /calls/{id} and the webhook payload return the PHP values. Those are on When something fails. Use the contracts list below only when you are reading @callagent/contracts itself.

The 17 states ​

created ─→ queued ─→ ringing ─→ connecting ─→ connected
                                                  │
   ┌──────────────────────────────────────────────┴───────────────────────────┐
   │  listening ⇄ user_speaking ⇄ thinking ⇄ tool_executing                   │
   │            ⇄ assistant_speaking ⇄ on_hold ⇄ transferring                 │
   │            ⇄ human_queue ⇄ human_connected                               │
   └──────────────────────────────────────────────────────────────────────────┘
                                                  │
                                       ending ─→ ended | failed

Inside the conversation loop almost every state can follow almost every other, because the caller can interrupt at any instant — that is what barge-in is, expressed as a transition table. ending, ended and failed are reachable from every non-terminal state.

GroupStates
Setupcreated, queued, ringing, connecting, connected
Conversationlistening, user_speaking, thinking, tool_executing, assistant_speaking
Held or movingon_hold, transferring, human_queue, human_connected
Terminalending → ended, failed

ending is deliberately a dead end: its only successor is ended. A call that has begun saying goodbye cannot be dragged back into a conversation.

Concurrency is held by every non-terminal state, which is why finalisation releases the slot explicitly — a crashed call that never reached ended would otherwise hold a slot forever.

The 19 end reasons in the contracts ​

These are the contracts' values. The REST API returns the control plane's 20, which are spelled differently — see the warning above.

GroupValues
Someone hung upcustomer_hangup, assistant_hangup, operator_hangup
The assistant decidedassistant_end_call_tool, assistant_end_call_phrase
Completed elsewheretransfer_completed
A limit was hitmax_duration_reached, silence_timeout
Never connectedno_answer, busy, rejected, voicemail_reached
Refused before startingconcurrency_exceeded, insufficient_credits
Something brokepipeline_error, provider_error, telephony_error, gateway_shutdown
—unknown

The distinction between assistant_end_call_tool and assistant_end_call_phrase matters when you are debugging: the first means the model chose to call endCall, the second means the caller said something in endCallPhrases.

The event stream ​

Seventy typed event types, grouped by prefix, identical in TypeScript and PHP. call_events is insert-only, and GET /calls/{call}/events returns them in order.

The table below groups the ones you are most likely to act on. GET /webhooks/events is a different, smaller vocabulary — the 28 outbound webhook types on Webhooks.

CategoryEvents
Lifecyclecall.created, call.ringing, call.connected, call.ended, call.failed, call.state.changed, transport.connected, transport.disconnected
Speech inuser.speech.started, user.speech.ended, transcript.partial, transcript.final, endpoint.detected
Modelllm.started, llm.first_token, llm.completed, llm.failed, llm.aborted
Synthesistts.started, tts.first_audio, tts.completed, tts.failed, tts.aborted
Playbackassistant.speech.started, assistant.speech.interrupted, assistant.speech.completed
Speech to speechs2s.started, s2s.first_audio, s2s.completed, s2s.failed
Toolstool.started, tool.completed, tool.failed, tool.rejected
MCPmcp.started, mcp.completed, mcp.failed
Knowledgeknowledge.queried, knowledge.insufficient
Routinghandoff.started, handoff.completed, transfer.started, transfer.completed, transfer.failed
Contact centreoperator.queued, operator.ringing, operator.connected, operator.rejected, operator.missed, operator.wrap_up, queue.abandoned, queue.overflowed
DTMF & SIPdtmf.received, dtmf.sent, sip.request.sent, sip.response.received, voicemail.detected
Artifactsrecording.consent_announced, recording.consent_recorded, recording.started, recording.paused, recording.resumed, recording.ready, analysis.completed
Provider healthprovider.fallback, provider.error

The per-event payload shapes are on the event types reference.

The envelope ​

Every event shares one envelope, so a consumer can order and correlate without knowing the type:

json
{
  "id": "01JQ…",
  "type": "endpoint.detected",
  "organizationId": "01JQ…",
  "callId": "01JQ…",
  "legId": "01JQ…",
  "assistantId": "01JQ…",
  "sequence": 42,
  "at": "2026-01-01T12:00:03.412+00:00",
  "elapsedMs": 3412,
  "generationId": "gen_7",
  "traceId": "…",
  "data": { "strategy": "hybrid", "silenceMs": 400, "confidence": 0.82 }
}

elapsedMs is milliseconds since the call started, which is what lets a timeline be drawn without joining on timestamps. generationId groups every event belonging to one assistant turn — the llm.*, tool.* and tts.* events of a single reply share it, so "what did turn 7 cost and how long did it take" is one filter.

Reading the stream ​

bash
curl "$CALLAGENT_API_URL/calls/01JQ…/events" \
  -H "Authorization: Bearer $CALLAGENT_API_KEY"
ts
const response = await fetch(`${process.env.CALLAGENT_API_URL}/calls/${callId}/events`, {
  headers: { Authorization: `Bearer ${process.env.CALLAGENT_API_KEY}` },
});
const { data: events } = await response.json();

// One turn, end to end
const turn = events.filter((event) => event.generationId === 'gen_7');
python
import os, requests

events = requests.get(
    f"{os.environ['CALLAGENT_API_URL']}/calls/{call_id}/events",
    headers={"Authorization": f"Bearer {os.environ['CALLAGENT_API_KEY']}"},
    timeout=30,
).json()["data"]

The same vocabulary is used for webhooks — subscribe to the events you care about rather than polling — and for the NATS subjects the two planes exchange internally (ca.call.<organizationId>.<action>).

Messages, not just events ​

GET /calls/{call}/messages returns the conversation itself as roles and content — system, user, assistant, tool, operator — with interrupted marking any assistant turn the caller cut off, and toolCalls / toolCallId linking a request to its result. This is the view to use for evaluation and for replaying a conversation into a model; the event stream is for debugging how it happened.

Next ​

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