# Events & Callbacks

> A reference for each callback in the Voice SDK: per-frame audio, transport session state, and the human-agent control channel. It also shows where there is no event stream.

The Voice SDK is a **callback surface, not an event bus**. There is no single
`voice.on("...")` event stream. You register a small set of callbacks:
one for each direction of audio, and one for transport session state.
Human-agent softphones also get a real push channel. This page lists every
callback. It also shows where the SDK gives you a stream and where you must
poll.

> **NOTE:**
> The data plane uses our media-over-QUIC (MoQT) transport and is fully
> callback-driven. The **call control plane is request/response** — there is
> **no server-push socket for call state**. Only the human-agent control channel
> (`Agent`) is a live EventEmitter. Design your integration around that split.

## The three surfaces at a glance

  - **Audio frame callbacks** — `onUplink` / `onDownlink` fire once per encoded 20 ms frame, in order,
    with a microsecond timestamp. This is your real-time media loop.
  - **Transport session state** — The client reports `ConnectionState` transitions (Connecting → Connected →
    Reconnecting → Closed / Failed). Auto-reconnect runs underneath.
  - **Human-agent control** — `Agent`, the softphone control channel, is a real push
    EventEmitter (`callOffered`, `stateChanged`, `forcedLogout`, …).

## Audio frame callbacks (data plane)

An `AudioBridge` gives you one callback per direction. Both deliver **encoded
frames** (for example, a 20 ms Opus packet or a PCM16 chunk) exactly as they
arrive from the media plane. The SDK does not decode, resample, or buffer them
for you.

- **`onUplink`** (`(frame: Uint8Array, timestampUs: bigint) => void`) — Inbound **caller audio** — what the caller says. You register this callback   when you `attach()` the bridge to a `call_sid`. It subscribes to the   `voice/<sid>/uplink` track.

- **`onDownlink`** (`(frame: Uint8Array, timestampUs: bigint) => void`) — Inbound **cloud→caller audio** — the audio that plays back to the caller.   You register this callback when you call `attachCaller()` (the browser-caller   mirror of `attach`). It subscribes to the `voice/<sid>/downlink` track.

`timestampUs` is the media timestamp in microseconds. Use it to sequence
frames, measure inter-frame gaps, or align frames with your own clock. The SDK
delivers frames **in order, one at a time**. There is no batching.

```ts
import { Voice } from "clutchcall_connect/voice";

const voice = new Voice({ baseUrl, apiKey, orgId });

// Server side: consume caller audio, publish agent audio back.
const bridge = await voice.audioBridge.attach(call.sid, {
  codec: "opus",
  onUplink(frame, tsUs) {
    // one encoded caller frame — feed it to your ASR / recorder
    asr.push(frame, tsUs);
  },
});

// Send audio the other way, frame by frame.
tts.onChunk((opus) => bridge.publishDownlink(opus));

// Browser-caller side is the mirror: subscribe downlink, publish mic uplink.
const caller = await voice.audioBridge.attachCaller(call.sid, {
  codec: "opus",
  onDownlink(frame, tsUs) {
    player.enqueue(frame, tsUs); // play cloud audio to the human
  },
});
mic.onEncodedFrame((opus) => caller.publishUplink(opus));
```

```python
from clutchcall.voice import Voice

voice = Voice(base_url=base_url, api_key=api_key, org_id=org_id)
call = voice.calls.originate(to="+15551234567", from_="+15557654321", trunk_id=trunk)

def on_uplink(frame: bytes, ts_us: int) -> None:
    asr.push(frame, ts_us)  # one encoded caller frame

bridge = voice.audio_bridge.attach(call.sid, codec="opus", on_uplink=on_uplink)
tts.on_chunk(lambda opus: bridge.publish_downlink(opus))
```

`codec` accepts `opus` (default), `pcm16`, `g711_ulaw`, or `g711_alaw`. This is
the same `AudioCodec` set in every language SDK. Voice is audio-only. There are
no video frame callbacks.

> **WARNING:**
> You bind the callback **when you call `attach()` / `attachCaller()`**, in the
> options object. The instance methods `bridge.onUplink(cb)` / `bridge.onDownlink(cb)`
> keep a stable setter-style surface, but today they are **reserved
> no-ops**. A call to them does not swap the consumer mid-call. Register your
> handler in the `attach` call, not after.

## Transport session state & auto-reconnect

The media plane keeps itself alive. The underlying MoQT transport client
**reconnects transparently**. After a drop, it re-opens the session,
**re-announces every publication, and re-subscribes every subscription**. Your
`onUplink` / `onDownlink` callbacks resume without new wiring.

You can observe those transitions through the transport client's `onState`
callback, which reports a `ConnectionState`:

- **`ConnectionState`** (`enum`) — `Connecting` (0) · `Connected` (1) · `Reconnecting` (2) · `Closed` (3) ·   `Failed` (4). The callback signature is `(state, reason?) => void`.

The state machine is:

  1. **Connecting**
The client reports this state immediately when you open it, before the
    first session is up.
  2. **Connected**
The session is established. On a *re-connect* (not the first connect), the
    client replays your publications and subscriptions automatically.
  3. **Reconnecting**
A live session dropped. The client retries with **capped exponential
    backoff**. The backoff starts at 250 ms, doubles to a 5 s ceiling, and
    resets on success.
  4. **Failed**
The **first** connect never succeeded (bad relay host, bad auth, or blocked
    UDP). This state is terminal for that client and rejects the connect
    promise. It is different from `Reconnecting`, which occurs only after at
    least one successful connect.
  5. **Closed**
You called `close()`, or the retry loop received a stop. This state is
    terminal and clean.

```ts
import { MoqtClient, ConnectionState } from "clutchcall_connect/moqt";

const client = await MoqtClient.connect(
  `moq://relay.${brandDomain}/voice/${callSid}`,
  apiKey,
  (state, reason) => {
    if (state === ConnectionState.Reconnecting) showBanner("reconnecting…", reason);
    if (state === ConnectionState.Connected)    hideBanner();
    if (state === ConnectionState.Failed)        showError(reason);
  },
);
```

> **NOTE:**
> Known gap: the high-level `AudioBridge` (`attach` / `attachCaller`) opens the
> transport for you, but it does **not currently forward an `onState` callback**.
> It reconnects silently. To observe session state today, you have two options.
> Drive the MoQT transport client directly (the primitive under the bridge) and
> layer the bridge's publish/subscribe calls on top. Or infer a stall when your
> frame callbacks go quiet. `onState` support in `AudioBridge` is planned, not
> shipped.

### Reconnect leaves a gap, and the SDK tells you

A transparent reconnect is **not** gap-free. Objects published while the session
was down are lost. (True gap-free replay needs a durable origin, which is not
the default.) The transport does not hide this gap — it reports it. Each
subscription exposes two optional lifecycle hooks on the MoQT primitive:

- **`onEnded`** (`(reason: string) => void`) — The track ended (the publisher unpublished, or the subscription was torn   down).
- **`onDropped`** (`(info: DropInfo) => void`) — A delivery gap occurred. After a reconnect, you receive one `DropInfo` with   `reason: "reconnect"` and sentinel `fromGroup/fromObject/toGroup/toObject`   values of `-1`. Use it, for example, to flush a jitter buffer or re-prime a   decoder.

These hooks live on the transport subscription objects. The `AudioBridge`
wrapper wires only the frame callback. If you need `onEnded` / `onDropped`,
work with the MoQT subscription directly. See
[Realtime tracks](/modalities/voice/concepts/sessions-calls-tracks-streams) for
the track/namespace model these hooks belong to.

## There is no call-state event stream

This is the most important point on this page. **Call control is
request/response over the control-plane API** (tRPC to the control-plane host).
The SDK does **not** open an event socket for the call lifecycle. There is no
`call.on("answered")`, no `onRinging`, and no server-push CDR event in the
Voice SDK.

A `Call` carries a `status` snapshot from the moment you fetched it
(`dialing | ringing | in_progress | completed | failed | no_answer`). To observe
a transition, **re-fetch** the call:

```ts
// Poll for the state you care about — there is no push event for this.
async function waitForAnswer(sid: string, timeoutMs = 30_000): Promise<Call> {
  const deadline = Date.now() + timeoutMs;
  while (Date.now() < deadline) {
    const call = await voice.calls.get({ sid });
    if (call.status === "in_progress") return call;
    if (["completed", "failed", "no_answer"].includes(call.status)) {
      throw new VoiceError(`call ended: ${call.status}`);
    }
    await new Promise((r) => setTimeout(r, 1000));
  }
  throw new VoiceError("timed out waiting for answer");
}
```

```python
import time

def wait_for_answer(sid: str, timeout_s: int = 30) -> "Call":
    deadline = time.monotonic() + timeout_s
    while time.monotonic() < deadline:
        call = voice.calls.get(sid=sid)
        if call.status == "in_progress":
            return call
        if call.status in ("completed", "failed", "no_answer"):
            raise VoiceError(f"call ended: {call.status}")
        time.sleep(1)
    raise VoiceError("timed out waiting for answer")
```

> **TIP:**
> If you need push-style call lifecycle notifications for automation or CRM
> sync, consume them **server-side** from the platform's call-event and CDR
> feeds, not from the client SDK. See
> [Observability](/modalities/voice/observability/overview) and
> [Webhooks](/modalities/voice/api/webhooks). By design, the client SDK's
> real-time surface is audio frames and transport state.

## Human-agent control channel (the one real event stream)

The **human-agent softphone control channel** is the exception to everything
above. When you build a browser softphone for a live human agent, the
`Agent` class is a real EventEmitter over a persistent connection (a
control-plane WebSocket at `/agent/control`). It pushes these events:

- **`welcome`** (`{ deviceId }`) — The gateway accepted the connection and assigned a device id.
- **`ack`** (`{ state, ts }`) — The gateway acknowledged a `setState` transition.
- **`stateChanged`** (`{ state, auxReason?, ts }`) — The agent presence/aux state changed (server-driven, for example forced to   `acw`).
- **`callOffered`** (`CallOffer`) — The gateway offers a call to this agent. Confirm or reject before   `ringUntilMs`.
- **`callOfferedExpired`** (`{ callId, missedCount, forcedRona }`) — The ring window ended with no confirm. `forcedRona` is true when the missed   counter crossed the RONA threshold and the gateway forced `aux:rona`.
- **`callConfirmed`** (`{ callId }`) — The gateway accepted your confirm. This is a one-shot event, different from   `stateChanged`.
- **`error`** (`{ code, msg }`) — A protocol or authorization error occurred on the channel.
- **`forcedLogout`** (`{ reason }`) — The gateway evicted this device (for example, a single-device kick).
- **`close`** (`{ clean, reason? }`) — The channel closed. `clean` shows whether the close was graceful or a drop.

```ts
import { Agent } from "clutchcall_connect/voice";

const agent = new Agent({ wssUrl, token });

agent.on("callOffered", (offer) => ui.ring(offer));
agent.on("callConfirmed", ({ callId }) => ui.showActive(callId));
agent.on("callOfferedExpired", ({ forcedRona }) => forcedRona && ui.showRona());
agent.on("stateChanged", ({ state, auxReason }) => ui.setPresence(state, auxReason));
agent.on("forcedLogout", ({ reason }) => ui.kick(reason));

await agent.connect();          // resolves on `welcome`, rejects on early `close`
await agent.setState("ready");  // resolves on the matching `ack`
```

> **NOTE:**
> This channel runs over the control-plane WebSocket today. `Agent` accepts a
> `preferTransport: 'wt' | 'wss' | 'auto'` hint, but WebTransport carriage of
> `/agent/control` is **not yet shipped**. `'auto'` resolves to WSS.

To learn how the platform routes offers to a human, and how it bridges audio
after the agent confirms, see
[AI → human handoff](/modalities/voice/transport-telephony/ai-human-handoff).

## Callback inventory

| Surface | Callback | Fires | Push or poll |
|---|---|---|---|
| `AudioBridge` (server) | `onUplink(frame, tsUs)` | per caller frame | push |
| `AudioBridge` (browser caller) | `onDownlink(frame, tsUs)` | per cloud→caller frame | push |
| MoQT transport client | `onState(state, reason?)` | on session transition | push |
| MoQT subscription | `onEnded(reason)` | track ended | push |
| MoQT subscription | `onDropped(info)` | delivery/reconnect gap | push |
| `Calls` | *(none)* | call status via `calls.get` | **poll** |
| `Agent` (human softphone) | `on("callOffered" \| "stateChanged" \| …)` | control events | push |

## Related

  - **[TypeScript SDK](/modalities/voice/sdk/javascript)** — Full `Voice` / `Calls` / `AudioBridge` / `Agents` reference.
  - **[Python SDK](/modalities/voice/sdk/python)** — The same callback shapes in Python (`on_uplink`, `publish_downlink`).
  - **[Sessions, calls & tracks](/modalities/voice/concepts/sessions-calls-tracks-streams)** — The `voice/<sid>/{uplink,downlink}` track model behind these callbacks.
  - **[Transport API](/modalities/voice/api/transport)** — Connection states, reconnect, and the MoQT client surface in depth.
