> ## Documentation Index
> Fetch the complete documentation index at: https://docs.callers.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Events and transcript

> React to call status changes, stream the live transcript, and handle in-call form answers with Web SDK callbacks.

Pass callbacks in the `WebCall` config to follow the call from your own code: update your UI, log analytics, or save the transcript. All callbacks are optional, and all of them also work when you use the built-in widget.

```javascript theme={"dark"}
const webCall = new CallersWebSdk.WebCall({
  token,
  onStatusChange: (status) => console.log('status:', status),
  onCallStarted: () => console.log('connected'),
  onCallEnded: () => console.log('ended'),
  onError: (error) => console.error(error),
  onTranscriptMessage: (message) => console.log(message.role, message.content),
});
```

## Lifecycle callbacks

| Callback | Arguments | Fires when |
| - | - | - |
| `onStatusChange` | `status: PhoneStatus` | The connection status changes (see below) |
| `onCallStarted` | none | The call connects and audio flows |
| `onCallEnded` | none | The call ends: hang-up by either side, connection failure, or no answer within 30 seconds |
| `onError` | `error: Error` | Starting the call, sending a message or the connection fails |
| `onMuteChange` | `muted: boolean` | The microphone is muted or unmuted, by your code or the built-in button |
| `onDeafenChange` | `deafened: boolean` | The agent's audio is turned off or on |
| `onWidgetInputSent` | `{ content, widgetMessageId? }` | The visitor sends a message or form answer from the built-in UI |
| `onTranscriptMessage` | `message: TranscriptMessage` | One message is new or changed |
| `onTranscriptChanged` | `messages: TranscriptMessage[]` | The transcript changed; receives the whole list |

### `PhoneStatus` values

| Status | Meaning |
| - | - |
| `disconnected` | Not connected. Also the status after a call ends. |
| `connecting` | Connecting to the Callers voice service |
| `registered` | Connected and ready; the call is being placed |
| `unregistered` | The voice service session ended |
| `reconnecting` | The connection dropped and is being restored |
| `ringing` | The call is being set up with your agent |
| `in-call` | The call is live |
| `on-hold` | The call is on hold |
| `error` | Something failed. Details arrive in `onError`. |

### Errors

`onError` receives the raw error, which can include the API's message. The built-in window shows a generic message instead, such as **Unable to start the call. Please try again.**, so internal details never reach visitors.

A refused call start looks like this:

```
Failed to get call credentials (401): {"message":"Origin 'https://shop.example.com' is not allowed for this campaign", ...}
```

See [Troubleshooting](https://docs.callers.ai/sdk/troubleshooting.md) for each message.

## Transcript

The transcript streams in while the call runs. Pick the callback that fits how your UI stores state:

* **`onTranscriptMessage`** fires once for each message that is new or changed. Use it to append or update one row.
* **`onTranscriptChanged`** fires with the full, de-duplicated list (the most recent 500 messages) whenever anything changed. Use it when you keep the transcript as one piece of state, such as `setMessages(messages)` in React.

Both fire in the same tick with the same data, so you usually need only one. A message counts as changed when its `content`, `skip`, `nodeId`, `timestamp`, `widget` or `toolCall` differs from the last version with the same `id`. Messages update in place as the agent speaks, so always key by `id`.

> [!NOTE]
>
> `getTranscriptMessages()` returns the current snapshot on demand, and an empty array once the call ends. To keep the final transcript, store the last list from `onTranscriptChanged`.

### `TranscriptMessage`

**id** `string`

Stable ID for the message. The same ID is sent again when the message changes.

**role** `string`

`assistant` for the agent, `user` for the visitor. Other roles can appear for system and tool entries; filter on these two for a chat view.

**content** `string`

The message text.

**timestamp** `number`

When the message was created.

**event** `string`

The stream event type, usually `transcript`.

**skip** `boolean`

`true` on an agent message that was generated but never spoken, for example because the visitor interrupted before its audio started. The built-in widget hides these; hide them in your UI too.

**nodeId** `string`

The script block the message belongs to.

**widget** `object | null`

Present when the agent shows an [in-call form](https://docs.callers.ai/documentation/widget-agents/in-call-forms.md) with this message.

**properties**

**type** `'FORM' | 'STEPPED' | 'JSON' | 'A2UI' | 'A2UI_GENERATION'`

`FORM`: all fields on one screen. `STEPPED`: one field per screen with a progress bar. `JSON`: structured data plus a free-text reply. `A2UI`: a designed UI. `A2UI_GENERATION`: UI the agent generates during the call.

**formElements** `unknown`

The form definition.

**presentationMode** `'popover' | 'inline-card' | 'message-bubble'`

Where the form appears. Missing means `popover`.

**viewOnly** `boolean`

`true` when the form only displays information and expects no answer.

**correspondingToMessageId** `string`

On a visitor's form answer, the `id` of the form message it answers.

**toolCall** `unknown`

Present on entries that record an action the agent ran.

The stream can include additional fields. Ignore any you don't use.

### Show a clean chat

```javascript theme={"dark"}
const visible = messages.filter(
  (message) =>
    (message.role === 'assistant' || message.role === 'user') &&
    message.content &&
    !message.skip
);
```

## Form answers and typed messages

`onWidgetInputSent` fires when the visitor submits a message or a form answer from the built-in UI, before it is sent:

```javascript theme={"dark"}
onWidgetInputSent: ({ content, widgetMessageId }) => {
  if (widgetMessageId) {
    analytics.track('form_submitted', { formMessageId: widgetMessageId });
  } else {
    analytics.track('message_sent', { length: content.length });
  }
},
```

For form answers, `content` is a JSON string of the field values. The first message that starts a [text conversation](https://docs.callers.ai/sdk/text-and-voice.md) also fires this callback, without a `widgetMessageId`.

### A form, end to end

When the script reaches a widget step, the form arrives as an agent message with a `widget`:

```json theme={"dark"}
{
  "id": "msg_42",
  "role": "assistant",
  "content": "I've put a short form on your screen. Fill it in when you're ready.",
  "timestamp": 1761055001234,
  "event": "transcript",
  "nodeId": "collect-details",
  "widget": {
    "type": "FORM",
    "presentationMode": "popover",
    "viewOnly": false,
    "formElements": [ "…" ]
  }
}
```

The built-in UI renders `formElements` and, on **Submit**, fires `onWidgetInputSent` with the answers keyed by each field's **Name**, then sends them:

```javascript theme={"dark"}
onWidgetInputSent: ({ content, widgetMessageId }) => {
  // content:         '{"email":"dana@example.com","preferred_date":"2026-10-29"}'
  // widgetMessageId: 'msg_42'
  const answers = JSON.parse(content);
  analytics.track('form_submitted', { formMessageId: widgetMessageId, fields: Object.keys(answers) });
},
```

If you render forms yourself (a hidden widget or [headless](https://docs.callers.ai/sdk/headless-and-react-native.md) setup), send the answer with [`sendInput()`](https://docs.callers.ai/sdk/methods#sendinput), passing the form message's `id`:

```javascript theme={"dark"}
await webCall.sendInput(
  JSON.stringify({ email: 'dana@example.com', preferred_date: '2026-10-29' }),
  'msg_42'
);
```

Send `'{}'` to skip. The answer then appears in the transcript as a `user` message whose `correspondingToMessageId` is `msg_42`, and the agent continues with it. Skip messages where `widget.viewOnly` is `true`; they expect no answer.

## React example

```tsx CallTranscript.tsx theme={"dark"}
import { useEffect, useRef, useState } from 'react';

type Message = { id?: string; role?: string; content?: string; skip?: boolean };

export function CallTranscript({ token }: { token: string }) {
  const webCallRef = useRef<any>(null);
  const [status, setStatus] = useState('disconnected');
  const [messages, setMessages] = useState<Message[]>([]);

  useEffect(() => {
    const { WebCall } = (window as any).CallersWebSdk;
    const webCall = new WebCall({
      token,
      showFab: false,
      onStatusChange: setStatus,
      onTranscriptChanged: (all: Message[]) =>
        setMessages(all.filter((m) => (m.role === 'assistant' || m.role === 'user') && !m.skip)),
    });
    webCallRef.current = webCall;

    return () => webCall.destroy();
  }, [token]);

  return (
    <div>
      <button onClick={() => webCallRef.current?.call()}>Start call</button>
      <button onClick={() => webCallRef.current?.hangup()}>Hang up</button>
      <p>Status: {status}</p>
      <ul>
        {messages.map((m) => (
          <li key={m.id}>
            <strong>{m.role === 'assistant' ? 'Agent' : 'You'}:</strong> {m.content}
          </li>
        ))}
      </ul>
    </div>
  );
}
```

This assumes the SDK script is already on the page. To load it on demand, see [Headless and React Native](https://docs.callers.ai/sdk/headless-and-react-native.md#react).

## Related

> **[Methods](https://docs.callers.ai/sdk/methods.md)**
>
> Start, end and control the call.

> **[Watch events live](https://sdk-playground.callers.dev/)**
>
> The playground shows an event log and the raw transcript during a test call.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.