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 | failedInside 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.
| Group | States |
|---|---|
| Setup | created, queued, ringing, connecting, connected |
| Conversation | listening, user_speaking, thinking, tool_executing, assistant_speaking |
| Held or moving | on_hold, transferring, human_queue, human_connected |
| Terminal | ending → 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.
| Group | Values |
|---|---|
| Someone hung up | customer_hangup, assistant_hangup, operator_hangup |
| The assistant decided | assistant_end_call_tool, assistant_end_call_phrase |
| Completed elsewhere | transfer_completed |
| A limit was hit | max_duration_reached, silence_timeout |
| Never connected | no_answer, busy, rejected, voicemail_reached |
| Refused before starting | concurrency_exceeded, insufficient_credits |
| Something broke | pipeline_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.
| Category | Events |
|---|---|
| Lifecycle | call.created, call.ringing, call.connected, call.ended, call.failed, call.state.changed, transport.connected, transport.disconnected |
| Speech in | user.speech.started, user.speech.ended, transcript.partial, transcript.final, endpoint.detected |
| Model | llm.started, llm.first_token, llm.completed, llm.failed, llm.aborted |
| Synthesis | tts.started, tts.first_audio, tts.completed, tts.failed, tts.aborted |
| Playback | assistant.speech.started, assistant.speech.interrupted, assistant.speech.completed |
| Speech to speech | s2s.started, s2s.first_audio, s2s.completed, s2s.failed |
| Tools | tool.started, tool.completed, tool.failed, tool.rejected |
| MCP | mcp.started, mcp.completed, mcp.failed |
| Knowledge | knowledge.queried, knowledge.insufficient |
| Routing | handoff.started, handoff.completed, transfer.started, transfer.completed, transfer.failed |
| Contact centre | operator.queued, operator.ringing, operator.connected, operator.rejected, operator.missed, operator.wrap_up, queue.abandoned, queue.overflowed |
| DTMF & SIP | dtmf.received, dtmf.sent, sip.request.sent, sip.response.received, voicemail.detected |
| Artifacts | recording.consent_announced, recording.consent_recorded, recording.started, recording.paused, recording.resumed, recording.ready, analysis.completed |
| Provider health | provider.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:
{
"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
curl "$CALLAGENT_API_URL/calls/01JQ…/events" \
-H "Authorization: Bearer $CALLAGENT_API_KEY"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');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
- How a call works — what produces these events.
- Calls & artifacts — what a finished call leaves behind.
- Webhooks — receiving events as they happen.