# @voctiv/agent-sdk

TypeScript SDK for voice and dialog scripts.

The package exports `defineScript()` and types for media channels, SIP, ASR, TTS, LLM, dialog context, logging, and platform APIs.

The SDK describes the objects injected into your script by the host runtime; it does not open calls or run services itself.

## Installation

```bash
npm install @voctiv/agent-sdk rxjs
```

`rxjs` is a peer dependency because the runtime API exposes observables for ASR, SIP, channel events, queues, and LLM streams.

## Basic Script

Scripts export a function created with `defineScript()`. The runtime loads the module and calls it with `{ channel, logger, context, platform }`.

```ts
import { defineScript } from '@voctiv/agent-sdk';
import { filter, map, merge } from 'rxjs';

const TTS_QUEUE = 1;

export default defineScript(async ({ channel, logger, context }) => {
  channel.sip.answer(); // No-op on WS/headless channels.
  channel.sendMessage({ event: 'status', payload: 'ready' });

  const asr = await channel.createAsr({
    language: context.language || 'ru-RU',
    vad: { preSpeechFrames: 20, postSpeechFrames: 4 },
    smartTurn: { enabled: true, triggerFrames: 3, confirmMs: 50 },
  });

  // Barge-in: user speech stops only the agent TTS queue.
  merge(asr.speechStart$, asr.partial$.pipe(filter((p) => !!p.text?.trim()))).subscribe(() => {
    channel.audio.stop(TTS_QUEUE);
  });

  await channel.audio.say('Hi! Say anything and I will answer briefly.', {
    queue: TTS_QUEUE,
    alias: 'greeting',
  });

  asr.result$.pipe(filter((text) => !!text.trim())).subscribe((userText) => {
    logger.log('User said', { userText });

    const reply$ = channel.llm
      .stream(`Reply briefly to the user: "${userText}"`, {
        agentUuid: context.agentUuid,
        dialogUuid: context.dialogUuid,
      })
      .pipe(map((chunk) => chunk.content));

    void channel.audio.say(reply$, {
      queue: TTS_QUEUE,
      alias: 'reply',
      ttsStrategy: 'sentence',
    });
  });

  return new Promise((resolve) => {
    channel.events.terminated$.subscribe(() => {
      asr.destroy();
      resolve({ output: { dialogUuid: context.dialogUuid } });
    });
  });
});
```

This snippet assumes a **live answered** channel: inbound after you `answer()`, or a campaign
outbound that started on `'on_answer'` (the default). It is not how you pick up ringing, 1xx, or
a 4xx. Those are two different outbound stories:

- **Campaign / `platform.call`** — `defineScript(fn, { outboundCallMode })` decides *when* the
  host starts this session. See [Campaign Outbound And `outboundCallMode`](#campaign-outbound-and-outboundcallmode).
- **A second leg from an already-running script** — `channel.sip.makeCall()`, then
  `waitForEarly()` / `waitForAnswer()` on the **returned** channel. See
  [B-leg outbound (already in session)](#b-leg-outbound-already-in-session).

## Examples

The `[examples/](./examples/)` folder contains copy-paste-ready scripts:


| Example                                                                     | Description                                                                |
| --------------------------------------------------------------------------- | -------------------------------------------------------------------------- |
| [outbound-on-answer.ts](./examples/outbound-on-answer.ts)                   | Campaign outbound on `200 OK` (`outboundCallMode: 'on_answer'`, the default) |
| [outbound-from-invite.ts](./examples/outbound-from-invite.ts)               | Campaign outbound from INVITE (`outboundCallMode: 'from_invite'`)          |
| [outbound-script-dial.ts](./examples/outbound-script-dial.ts)               | Script dials the campaign leg (`outboundCallMode: 'script_dial'`)          |
| [outbound-with-recall.ts](./examples/outbound-with-recall.ts)               | Outbound with `recallCount` / `recallDelay` (automatic redial on failure)  |
| [recall-routing-by-attempt.ts](./examples/recall-routing-by-attempt.ts)     | Online recall: branch on `context.attempt` (`getScriptPhase` → `'online'`) |
| [schedule-call-with-defaults.ts](./examples/schedule-call-with-defaults.ts) | `platform.call()` without explicit recall — CMS defaults from `context`    |
| [after-call-continuation.ts](./examples/after-call-continuation.ts)         | `onSuccessCall` / `onFailedCall` vs recall (mutually exclusive)            |
| [read-recall-from-params.ts](./examples/read-recall-from-params.ts)         | `parseRecallDelaySeconds()` / `parseRecallCount()` on legacy params        |
| [custom-media-providers.ts](./examples/custom-media-providers.ts)           | Host-only `mediaProviders` module (`ScriptAsrConnector` / `ScriptTtsConnector`) |
| [custom-media-providers-script.ts](./examples/custom-media-providers-script.ts) | Sandboxed `defineScript` entry that uses custom vendors (no `ws` import) |




### Quick recall example

```ts
import { defineScript } from '@voctiv/agent-sdk';

export default defineScript(async ({ channel, context, platform, logger }) => {
  channel.sip.answer();

  if ((context.attempt ?? 0) > 0) {
    logger.log('Online recall attempt', { attempt: context.attempt });
    await channel.audio.say('Sorry we missed you earlier. Trying again now.');
  }

  // Schedule first outbound with up to 3 retries, 5 min apart (no entryPoint — same defineScript runs each leg).
  await platform.call(context.msisdn!, {
    recallCount: context.recallCount ?? 3,
    recallDelay: context.recallDelay ?? 300,
  });
});
```

Parse legacy time strings from params when needed:

```ts
import { parseRecallDelaySeconds, parseRecallCount } from '@voctiv/agent-sdk';

const delaySec = parseRecallDelaySeconds(context.dialogParams?.recall_delay); // "00:05:00" → 300
const maxAttempts = parseRecallCount(context.dialogParams?.recall_count);
```



## What The SDK Contains

`defineScript(fn)` marks the default export as the script entry point. It returns the same function and exists to give TypeScript the correct `ScriptContext` shape.

`defineMediaProviders(def)` marks a named `mediaProviders` export for custom ASR/TTS factories.
Ship it in a **sibling** module (`media-providers/`), not in the sandboxed script entry — see
[Custom ASR / TTS Providers](#custom-asr--tts-providers).

`defineScript(fn, options)` additionally declares `ScriptCallOptions`, whose `outboundCallMode`
(`OutboundCallMode`: `'on_answer'` | `'from_invite'` | `'script_dial'`) the host reads
**before** the script runs to decide when the session starts — see
[Campaign Outbound And `outboundCallMode`](#campaign-outbound-and-outboundcallmode).

**Three different “context” names:**


| Name                                        | Meaning                                                                                                               |
| ------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- |
| `ScriptContext`                             | Top-level injection: `{ channel, logger, context, platform }`                                                         |
| `context` (`ScriptDialogContext`)           | Dialog identity, params, routing snapshot, `env$`, headless — see [Dialog Context](#dialog-context-and-persisted-env) |
| `options.context` on `platform.nlu.extract` | Opaque NLU disambiguation string/JSON — **not** dialog context                                                        |


`ScriptContext` is the top-level object passed to a script:

- `channel` is the media channel for SIP, WS, ASR, TTS, audio playback, LLM, and structured data messages.
- `logger` writes structured script logs and can stream logs to a debug endpoint.
- `context` contains dialog identity, caller/called numbers, language, flags, params, entry point, persisted env, and runtime budget.
- `platform` exposes platform operations: NLU, dialog state, outbound call scheduling, messaging, and phrase records.

`MediaChannel` is the main real-time API:

- `channel.type` is `"sip"` for telephony and `"ws"` for WebSocket/script-manager sessions. Headless sessions currently expose a synthetic `"ws"` channel; check `context.headless` to detect them.
- `channel.params` is the merged runtime parameter map. Treat unknown keys as host-specific.
- `channel.createAsr()` creates an ASR handle.
- `channel.createTts()` returns a TTS handle (`tts.say` / `tts.say$` / `tts.presay`) with a reused SSL / WebSocket connection.
- `channel.audio` controls channel-level TTS, raw playback, pre-synthesis, and mixer queues.
- `channel.sip` controls SIP state, pre-answer media, DTMF, hold/mute/hangup, outbound calls, and bridging.
- `channel.llm` talks to the Omni LLM backend.
- `channel.events` exposes speech, interrupt, termination, WS data message, and media error observables.
- `channel.textInput` injects synthetic ASR results for tests and debug clients.



## SIP And Pre-Answer Media

SIP sessions expose call state through `channel.sip.state`, `state$`, `progress$`, `early$`, and `answered$`.

The important states are:

- `ringing`: INVITE is in progress, but no media is available yet.
- `early`: RTP is ready before the final 200 OK answer. ASR, TTS, playback, and DTMF work in this state.
- `active`: final 200 OK has been received or sent.
- `terminated`: the call ended and no more audio is possible.

`channel.sip.state === 'early'` is **media** (RTP before `200 OK`). It is not
`getScriptPhase(context) === 'early'`. That phase is a snapshot of *when the live session started*
(campaign `'from_invite'` or inbound pre-answer) and **does not flip** when the call answers — use
`channel.sip.isAnswered` / `answered$` for the live state.



### How Pre-Answer Works

Pre-answer means the SIP media path is open before the call is finally answered with `200 OK`. In this state the caller can already hear TTS, the script can already receive audio for ASR, and DTMF can be exchanged.

Use pre-answer when you need to do something before committing the call to the final answer state:

- play an informational greeting or disclaimer;
- collect a short value with ASR, such as account number or menu choice;
- detect and navigate an IVR that speaks before answering;
- delay `answer()` until the script is ready to transfer, bridge, or continue.

For inbound calls, the script controls this explicitly:

1. Call `channel.sip.sendProgress()` to send `183 Session Progress` with SDP.
2. Wait for `channel.sip.waitForEarly()` if your next logic step needs media to be ready.
3. Use `channel.audio.say()`, `channel.audio.play()`, `channel.createAsr()`, or `channel.sip.sendDtmf()` normally.
4. Call `channel.sip.answer()` when you want to send the final `200 OK`.

For outbound calls, pre-answer is controlled by the remote side. If the remote endpoint sends `183 Session Progress` with SDP, the host runtime moves the call to `early`. If it answers directly, `waitForEarly()` resolves when the call becomes `active`.

`early` is a media-ready state, not a final answer state. `answer()` is still the explicit transition that sends final `200 OK` for inbound calls. External billing behavior depends on the carrier.

### Two outbound stories

Do not mix these up. Both can see 183 / early media, but they start in different places.

| You want… | Use | Conversation on |
| --------- | --- | --------------- |
| The **campaign** (or `platform.call`) to start this script at 200 OK, at INVITE, or with no leg yet | `defineScript(fn, { outboundCallMode })` — [below](#campaign-outbound-and-outboundcallmode) | `channel`, except `'script_dial'` which talks on the **returned** `leg` |
| A **second** outbound from a script that is already running (transfer target, IVR, operator) | `channel.sip.makeCall()` — next subsection | the **returned** B-leg |

`outboundCallMode` never changes inbound or a B-leg you opened yourself.

### B-leg outbound (already in session)

Early media on a B-leg starts when the remote side sends a provisional response with SDP, usually `183 Session Progress`. This is useful for IVRs that speak before answering. The parent script is already live; `outboundCallMode` does not apply here.

```ts
const bLeg = await channel.sip.makeCall({
  sipUri: 'sip:+12025551234@trunk.example.com',
});

await bLeg.sip.waitForEarly();

const asr = await bLeg.createAsr({ language: 'en-US' });
asr.result$.subscribe((text) => {
  if (/press one/i.test(text)) {
    bLeg.sip.sendDtmf('1');
  }
});
```

`waitForEarly()` resolves when the call reaches either `early` or `active`. If a carrier skips early media and answers directly, it resolves on the final answer.

### Campaign Outbound And `outboundCallMode`

This is **when the platform starts the script** for a campaign / `platform.call` row — not how you
open a B-leg from inside a live session ([that is `makeCall`](#b-leg-outbound-already-in-session)).

`OutboundCallMode` (`'on_answer'` | `'from_invite'` | `'script_dial'`) is the second argument of
`defineScript`. The host reads it **before** the session starts, because it decides *when* (and
whether) the platform dials. The names describe that moment.

```ts
import { defineScript } from '@voctiv/agent-sdk';

defineScript(async () => undefined); // same as 'on_answer'
defineScript(async () => undefined, { outboundCallMode: 'on_answer' });
defineScript(async () => undefined, { outboundCallMode: 'from_invite' });
defineScript(async () => undefined, { outboundCallMode: 'script_dial' });
```

A script that passes nothing stays on `'on_answer'` — the Basic Script above is that path.
Inbound legs and `channel.sip.makeCall()` B-legs are unchanged: they already hand the script a
channel before the final answer.

`answer_date` and billing never move: both come from the `200 OK`, never from `183`.
`getScriptPhase` is a snapshot of how the run **started** and does not flip when the answer arrives
— use `channel.sip.isAnswered` or `answered$` for the live state. (`defineScript` stores the
options on the function as `callOptions` — type `ScriptFnWithCallOptions` — and the host copies
that into `context.startedBeforeAnswer` / `context.startedWithoutLeg`.)


| `outboundCallMode`      | Who dials               | Script starts on                    | `getScriptPhase` at start                              | Conversation channel   |
| ----------------------- | ----------------------- | ----------------------------------- | ------------------------------------------------------ | ---------------------- |
| `'on_answer'` (default) | host                    | the `200 OK`                        | `'online'`                                             | `channel`              |
| `'from_invite'`         | host (after script up)  | INVITE — session is already running | `'early'` (stays `'early'` after answer)               | `channel`              |
| `'script_dial'`         | script (`sip.makeCall`) | nothing — no leg exists yet         | `'dialing'` (stays `'dialing'` after the script dials) | the **returned** `leg` |

Copy-paste: [outbound-on-answer.ts](./examples/outbound-on-answer.ts),
[outbound-from-invite.ts](./examples/outbound-from-invite.ts),
[outbound-script-dial.ts](./examples/outbound-script-dial.ts).

#### `'on_answer'` — start on answer (default)

The host sends the INVITE and **holds the script** until the remote party answers (`200 OK`). Busy,
no-answer, reject, or the dialer timeout never reach the live handler: the campaign writes
`call.result` and may run `after_call_failed` / recall instead.

Use this when the script is a conversation: greeting, turn watchdog, max-duration timer. They all
assume a human is already on the line. Existing agents that do not pass options keep this behavior.

```ts
import { defineScript, getScriptPhase } from '@voctiv/agent-sdk';

export default defineScript(async ({ channel, context }) => {
  // Live outbound: always 'online'. Headless before_call / after_call_* still apply.
  if (getScriptPhase(context) !== 'online') return;

  await channel.audio.say('Hello!');
  const asr = await channel.createAsr();
});
```

How to work with it:

- Talk on `channel` — audio, ASR, SIP waiters are already live and answered.
- Do **not** wait for `183` / `progress$`: the script was not running then.
- Do **not** call `channel.sip.makeCall()` for the campaign number; the host already dialed it.
- Inbound is unchanged: the same export still sees `'online'` (or `'early'` only if you also opted
  into `'from_invite'`).

#### `'from_invite'` — script first, host dials

The host starts the script **before** any INVITE, then automatically dials the campaign number on
the same `channel`. Subscribe first, then wait: 180/183 arrive on `progress$` / `sipSignal$`, and a
4xx/5xx rejects `waitForAnswer()` with the SIP result in the message (`486 Busy Here`). The
greeting still belongs after the answer — otherwise it plays into ringback.

```ts
import { defineScript, getScriptPhase } from '@voctiv/agent-sdk';

export default defineScript(
  async ({ channel, logger, context }) => {
    if (getScriptPhase(context) === 'early') {
      channel.sip.progress$.subscribe(({ statusCode }) => {
        logger.log('progress', { statusCode });
      });
      try {
        await channel.sip.waitForAnswer();
      } catch (err) {
        logger.warn('outbound failed', { err });
        return;
      }
    }

    await channel.audio.say('Hello!');
  },
  { outboundCallMode: 'from_invite' },
);
```

How to work with it:

- Talk on `channel` — the host already dialed; do **not** call `makeCall()` for the campaign number.
- `getScriptPhase` is `'early'` for the whole live run (it does not flip on `200 OK`). Read
  `channel.sip.isAnswered` / `answered$` for the live state.
- `audio.say` / `createAsr` wait until the leg has media, then run on this channel.
- On inbound, `'from_invite'` still reports `'early'` when the session starts before the answer.
- Unlike `'on_answer'`, a 4xx/5xx **is** visible in this handler — catch `waitForAnswer()` or subscribe
  to `sipSignal$` / `events.terminated$`.

#### `'script_dial'` — dial the leg yourself

The host does **not** send an INVITE. The script starts with no leg: `sip.state === 'idle'`, no
audio, phase `'dialing'`. It opens the leg itself and talks on the channel that **comes back** —
the same shape as a `makeCall()` B-leg. The starting `channel` can only dial.

```ts
import { defineScript, getScriptPhase } from '@voctiv/agent-sdk';

export default defineScript(
  async ({ channel, logger, context }) => {
    if (getScriptPhase(context) !== 'dialing') return;

    // Empty options: campaign msisdn + the call row's trunk.
    const leg = await channel.sip.makeCall({});

    leg.sip.progress$.subscribe(({ statusCode }) => {
      logger.log('progress', { statusCode });
    });
    await leg.sip.waitForAnswer();

    await leg.audio.say('Hello!');
    const asr = await leg.createAsr();
  },
  { outboundCallMode: 'script_dial' },
);
```

How to work with it:

- **Conversation is on `leg`, not on `channel`.** `channel.audio.say` / `createAsr` before
  `makeCall` are no-ops with a warning. `channel.sip.waitForAnswer()` rejects — there is no leg.
- `makeCall({})` dials the campaign number over the row's trunk. Pass `msisdn` (and `channel` for a
  different trunk) to dial somewhere else, exactly as in a live session.
- `makeCall` **resolves on media** (183/200) and **rejects** when the leg fails (`486 Busy Here`) or
  times out. A retry loop is `try` / `catch`, not a race on `terminated$`.
- The **first** successful `makeCall` is the one the platform reports on: `call.result`,
  `answer_date`, duration, recording, transcription. Later legs are B-legs. A run that never dials
  leaves the row with `-ERR Script dialed no call`.
- The dialer's answer timeout no longer applies to the campaign row. The worker slot is held from
  the moment the row is claimed until the script returns. Returning hangs up any leg still up.
- `getScriptPhase` stays `'dialing'` after the script opens a leg. Gate on it to decide *whether*
  to dial; use `leg.sip.isAnswered` for *whether someone picked up*.

An existing agent that already talks on `ctx.channel` does not need a rewrite: take the returned
leg once, then keep the rest of the script as-is.

```ts
import { defineScript, getScriptPhase } from '@voctiv/agent-sdk';

export default defineScript(
  async (ctx) => {
    const phase = getScriptPhase(ctx.context);
    const channel =
      phase === 'dialing' ? await ctx.channel.sip.makeCall({}) : ctx.channel;
    await channel.audio.say('Hello!');
  },
  { outboundCallMode: 'script_dial' },
);
```

### Inbound Pre-Answer

For inbound calls, call `channel.sip.sendProgress()` to send `183 Session Progress` with SDP. This enters `early` state and enables full-duplex audio before the final answer.

```ts
import { firstValueFrom } from 'rxjs';

channel.sip.sendProgress();
await channel.sip.waitForEarly();

const asr = await channel.createAsr({ language: 'en-US' });
await channel.audio.say('Please say your account number.');

const account = await firstValueFrom(asr.result$);

channel.sip.answer();
await channel.audio.say(`Thank you. Looking up account ${account}.`);
```

The API does not mark the call as answered until `answer()` sends final `200 OK`. External billing still depends on carrier policy.

### Audio Auto-Wait

On SIP channels, `channel.audio.say()` and `channel.audio.play()` automatically wait until RTP is ready (`early` or `active`). You only need explicit `waitForEarly()` / `waitForAnswer()` when your script logic depends on the state transition.

If the call terminates before media becomes available, deferred audio resolves as a no-op.

### SIP Signalling Metadata

On **SIP channels**, `channel.sip` exposes raw signalling beyond call state — useful for
carrier routing, diversion chains, and vendor SDP attributes.

#### When to use what


| Need                                                                                                                 | API                                   | When                               |
| -------------------------------------------------------------------------------------------------------------------- | ------------------------------------- | ---------------------------------- |
| Routing / identity / locale from the **inbound INVITE** (`Diversion`, `X-Trunk-Id`, `X-Neuro-UUID`, `X-language`, …) | `channel.sip.inviteSipHeaders`        | Call **start** only (snapshot)     |
| Peer signal **during** the call (e.g. mid-call language in INFO body)                                                | `channel.sip.sipInfo$`                | After subscribe; each INFO         |
| DTMF digits                                                                                                          | `channel.sip.dtmf$`                   | Prefer over parsing INFO           |
| SDP / codec / connection                                                                                             | `remoteSdp` / `getRemoteSdpDetails()` | May change (183 / 200 / re-INVITE) |
| SIP response code / phrase (180, 486, …)                                                                             | `sipSignal$`                          | Low-level; **no** response headers |
| Live SIP headers on 200 / re-INVITE / BYE                                                                            | —                                     | **Not exposed**                    |


Decision guide:

- Value from the inbound INVITE at call start → `inviteSipHeaders`
- Mid-call signal from peer INFO → `sipInfo$` (`contentType` + `body` only)
- DTMF → `dtmf$`
- Media description → `remoteSdp` / `getRemoteSdpDetails()`
- Live SIP response headers → not available (`sipSignal$` has status/SDP only)

**Locale pattern:** read start language from `inviteSipHeaders` (e.g. `X-language`). Mid-call changes only work if the peer puts the language in the INFO **body** — INFO SIP headers are not forwarded. Monolingual agents can ignore both and use `context.language` / config.

Outbound INVITE headers you **send** go through `platform.call({ protoAdditional })` or `makeCall` options — not `inviteSipHeaders` (read-only inbound snapshot). SIP metadata does not auto-update `context` or `platform.dialog`.

#### INVITE headers (`inviteSipHeaders`)

Snapshot of SIP headers from an **inbound INVITE** at call setup. Includes standard and
extension headers (`Diversion`, `P-Asserted-Identity`, `X-Trunk-Id`, `X-Neuro-UUID`, `X-language`, …).


| Property           | Updates during call?                                                               |
| ------------------ | ---------------------------------------------------------------------------------- |
| `inviteSipHeaders` | **No** — INVITE snapshot only; outbound B-legs / WS / headless usually `undefined` |


```ts
const h = channel.sip.inviteSipHeaders;
const diversion = h?.Diversion; // string | string[] when multiple hops
const trunkId = h?.['X-Trunk-Id'];
const lang = h?.['X-language'];
```

Header names match what the host stack exposes (case-sensitive). Duplicate headers become
`string[]`.

#### Remote SDP (`remoteSdp`, `getRemoteSdpDetails()`)


| Property / method       | Updates during call?                                                 |
| ----------------------- | -------------------------------------------------------------------- |
| `remoteSdp`             | **Yes** — latest negotiated remote SDP (INVITE, 183, 200, re-INVITE) |
| `getRemoteSdpDetails()` | **Yes** — re-parses current `remoteSdp` on each call                 |


```ts
const raw = channel.sip.remoteSdp;
const details = channel.sip.getRemoteSdpDetails();

details?.session.connection; // e.g. "IN IP4 203.0.113.5"
details?.attributes.rtpmap; // e.g. "0 PCMU/8000"
details?.attributes['x-vendor-tag']; // custom a=x-vendor-tag:...
```

`getRemoteSdpDetails()` parses session lines (`o=`, `s=`, `c=`, `m=audio`) and all `a=`
attributes. It is not a full SDP validator — use `remoteSdp` when you need the raw body.

#### SIP INFO (`sipInfo$`)

Live stream of incoming SIP INFO messages. Each event is `{ contentType, body }` only —
**INFO request headers are not exposed**. Prefer `dtmf$` for DTMF. Does not update
`inviteSipHeaders`.

```ts
channel.sip.sipInfo$.subscribe(({ contentType, body }) => {
  logger.log('SIP INFO', { contentType, body });
});
```

Events before subscribe are not replayed.

#### Relating streams to SDP


| Stream       | `sdp` field                                                               |
| ------------ | ------------------------------------------------------------------------- |
| `progress$`  | Present when a 1xx response carries SDP (e.g. 183 early media)            |
| `sipSignal$` | Present only on callbacks that include SDP; often empty on final `active` |


For the **persisted** negotiated SDP, use `remoteSdp` / `getRemoteSdpDetails()`, not only
the per-event `sdp` on `sipSignal$`.

### SIP Controls

`channel.sip` also supports:

- `answer()` for inbound final answer.
- `sendDtmf(digit, duration?)` for IVR navigation.
- `sendInfo(contentType, body)` for SIP INFO messages.
- `hold()` / `unhold()` for SIP hold.
- `mute()` / `unmute()` for local outgoing audio suppression.
- `hangup()` to terminate the call.
- `makeCall()` to create an outbound SIP B-leg from the main SIP channel.
- `bridge(other)` to cross-connect two SIP channels.

`makeCall()` and `bridge()` are supported on SIP channels. The returned B-leg is a full
`MediaChannel` with the same API as the main channel. WS and headless channels cannot create real SIP
legs, so both methods **throw** there rather than returning an inert leg you could talk into
unnoticed. Guard them with `context.headless` or `channel.type` when a script runs in both modes.

### `channel.sip` Reference

SIP-only unless noted. WS/headless: most methods are no-ops and `state` behaves as synthetic
`active`, except `makeCall()` and `bridge()`, which throw.


| Member                        | Description                                                                         |
| ----------------------------- | ----------------------------------------------------------------------------------- |
| `state`                       | Sync getter: `idle` | `ringing` | `early` | `active` | `holding` | `terminated`     |
| `isAnswered`                  | `true` after 200 OK (outbound received / inbound sent via `answer()`)               |
| `state$`                      | Emits on every state transition                                                     |
| `progress$`                   | SIP 1xx provisional responses (`SipProgressEvent`)                                  |
| `early$`                      | Emits once when RTP is up before final answer                                       |
| `answered$`                   | Emits once on 200 OK                                                                |
| `dtmf$`                       | Remote DTMF digits (`DtmfEvent`: `digit`, `duration`)                               |
| `sipInfo$`                    | Mid-call SIP INFO (`contentType` + `body` only; INFO headers not exposed)           |
| `sipSignal$`                  | Low-level stack events (`statusCode` / `statusPhrase` / optional `sdp`; no headers) |
| `remoteSdp`                   | Latest negotiated remote SDP body; updates when new SDP arrives                     |
| `inviteSipHeaders`            | Start-of-call inbound INVITE header snapshot; does not update                       |
| `getRemoteSdpDetails()`       | Parse `remoteSdp` → `ParsedSdpDetails` (session + `a=` attributes)                  |
| `sendProgress()`              | Inbound: send 183 Session Progress → `early`                                        |
| `waitForEarly()`              | Await `early` or `active` (Promise)                                                 |
| `waitForAnswer()`             | Await final 200 OK (Promise)                                                        |
| `answer()`                    | Inbound: send final 200 OK → `active`                                               |
| `sendDtmf(digit, duration?)`  | Send DTMF tone                                                                      |
| `sendInfo(contentType, body)` | Send SIP INFO                                                                       |
| `hold()` / `unhold()`         | SIP hold                                                                            |
| `mute()` / `unmute()`         | Suppress local outgoing audio                                                       |
| `hangup()`                    | Terminate call                                                                      |
| `makeCall(opts)`              | Outbound B-leg (`MediaChannel`); `sipUri` or `msisdn`                               |
| `bridge(other)`               | Cross-connect two SIP calls; returns teardown `() => void`                          |


Prefer `dtmf$` over `sipInfo$` for DTMF. Prefer `state$` / `early$` / `answered$` over raw
`sipSignal$` for call lifecycle. Use `remoteSdp` / `getRemoteSdpDetails()` for negotiated
media description; use `inviteSipHeaders` for start-of-call INVITE headers and `sipInfo$`
for mid-call INFO body. See **When to use what** under SIP Signalling Metadata above.

### SIP Bridge

Use `channel.sip.makeCall()` when a live SIP script needs to create an outbound B-leg immediately and connect it to the current call. This is different from `platform.call()`, which schedules a separate platform-managed call.

For a normal SIP URI, pass `sipUri` explicitly:

```ts
const bLeg = await channel.sip.makeCall({
  sipUri: 'sip:+12025551234@trunk.example.com',
});

await bLeg.sip.waitForAnswer();

const teardown = channel.sip.bridge(bLeg);
```

You can pass only `msisdn` instead of `sipUri`. The host resolves the SIP URI from agent and trunk settings and applies trunk caller-id options when configured.

```ts
const bLeg = await channel.sip.makeCall({
  msisdn: '+12025551234',
});

await bLeg.sip.waitForAnswer();
const teardown = channel.sip.bridge(bLeg);
```

Pass `channel` as a trunk-name override when selecting which outbound trunk to use:

```ts
const bLeg = await channel.sip.makeCall({
  msisdn: '+12025551234',
  channel: 'carrier-main',
});

await bLeg.sip.waitForAnswer();
const teardown = channel.sip.bridge(bLeg);
```

The returned B-leg is a full `MediaChannel`. You can interact with it before or after bridging:

```ts
const bLeg = await channel.sip.makeCall({
  msisdn: '+12025551234',
});

await channel.audio.say('I am calling the second participant now.');

await bLeg.sip.waitForAnswer();
await bLeg.audio.say('You are about to be connected.');

const teardown = channel.sip.bridge(bLeg);

channel.events.terminated$.subscribe(() => {
  teardown();
  bLeg.sip.hangup();
});
```

```ts
const bLeg = await channel.sip.makeCall({
  msisdn: '+12025551234',
  channel: 'carrier-main',
});

await bLeg.sip.waitForEarly();

await bLeg.audio.say('Please wait while I connect the call.');

const asr = await bLeg.createAsr({ language: 'en-US' });
asr.result$.subscribe((text) => {
  if (/operator/i.test(text)) {
    bLeg.sip.sendDtmf('0');
  }
});

await bLeg.sip.waitForAnswer();

const teardown = channel.sip.bridge(bLeg);

channel.events.terminated$.subscribe(() => {
  teardown();
  bLeg.sip.hangup();
  asr.destroy();
});
```

`bridge()` returns a teardown function. Call it when you want to disconnect the audio bridge without necessarily hanging up either leg. Use `bLeg.sip.hangup()` or `channel.sip.hangup()` when you want to terminate a call leg.

While a bridge is active, `channel.audio.say()` still sends audio only to the A-leg and `bLeg.audio.say()` sends audio only to the B-leg. These syntheses are mixed into the selected leg while the participants can also hear each other, so pause or tear down the bridge first if you need a private prompt.

## ASR, VAD, And Smart Turn

Create ASR with `channel.createAsr(config?)`. Prefer creating it once at dialog start so the
host can warm the ASR TCP/WebSocket (one SSL handshake). A later `createAsr` with the same
resolved vendor/credentials reuses that channel; `destroy()` closes it.

```ts
const asr = await channel.createAsr({
  vendor: 'yandex',
  name: 'main-yandex-key',
  language: 'ru-RU',
  vad: {
    positiveThreshold: 0.55,
    negativeThreshold: 0.35,
    preSpeechFrames: 12,
    postSpeechFrames: 12,
  },
  smartTurn: {
    enabled: true,
    silenceTimeoutMs: 1200,
  },
});
```

`AsrHandle` exposes:

- `result$`: finalized utterances.
- `partial$`: streaming partial hypotheses as `{ text, isFinal }`.
- `speechStart$` / `speechEnd$`: VAD speech boundaries.
- `interrupt$`: barge-in / interrupt events where the host supports them.
- `vadProbability$`: normalized VAD probability when available.
- `error$`: runtime errors from the ASR provider (see [Error Handling](#error-handling)).
- `pause()` / `resume()` to stop or resume forwarding new audio frames.
- `finalize()` to force the current utterance to flush.
- `destroy()` to close the warm TCP/WS connector and subscriptions.

SIP sessions use the call-level telephony VAD when it is available. WS sessions create one VAD/SmartTurn instance for the socket session on the first `createAsr()` call. Headless sessions return an inert ASR handle with empty observables.

If ASR connector creation fails, SIP/WS return a degraded handle. VAD observables still mirror the channel where possible, but no real STT results are emitted. The creation failure is reported on `channel.events.error$`.

## ASR Credentials And Vendors

`AsrConfig.vendor` is an engine hint, for example `"yandex"`, `"deepgram"`, `"azure"`, `"elevenlabs"`, or `"neuro_v3"`, resolved by the host vendor alias mapping.

### Direct ASR Vendor Parameters

Pass vendor-native credentials and settings directly through `AsrConfig.data`. These values are forwarded to the connector as-is and override any defaults or platform-resolved credentials.

```ts
const asr = await channel.createAsr({
  vendor: 'azure',
  language: 'ru-RU',
  data: {
    subscription_key: 'your-azure-key',
    region: 'swedencentral',
  },
});
```

Each vendor connector accepts its native parameter names:


| Vendor         | Accepted `data` keys                      |
| -------------- | ----------------------------------------- |
| **Azure**      | `subscription_key` or `api_key`, `region` |
| **Yandex**     | `api_key` or `token`, `folder_id`         |
| **ElevenLabs** | `api_key` (or `xi_api_key`), `model`      |
| **Deepgram**   | `api_key`                                 |
| **Google**     | `email`, `private_key`, `project_id`      |
| **Whisper**    | `url`, `rate`, `toFloat`                  |


All vendors also accept the env-style names (`AZURE_SPEECH_KEY`, `ELEVENLABS_API_KEY`, etc.) for backwards compatibility, but vendor-native names are checked first and are preferred.

### Platform ASR Key Selection

When platform credential catalogs are enabled, select ASR keys by `name`:

```ts
const asr = await channel.createAsr({
  name: 'main-asr-key',
  language: 'ru-RU',
});
```

The host resolves credentials from `channel.params.authentication_data` for the current dialog agent and company. If `name` is omitted, `channel.params.defaultAsrName` may be used.

When both `name` (platform key) and explicit `data` are provided, `data` values win — they are applied last and override anything resolved from the platform.

## TTS, Playback, And Mixer Queues

All audio playback goes through `channel.audio` (`ChannelAudio`). There are no top-level
`channel.say()` / `channel.play()` shortcuts on `MediaChannel`.

Create a reusable TTS session with `channel.createTts(config?)` — same pattern as `createAsr`.
Call `tts.say` / `tts.say$` / `tts.presay` on the handle so synthesis reuses the warmed SSL / streaming WebSocket.

Custom engines from a logic package are documented in
[Custom ASR / TTS Providers](#custom-asr--tts-providers).

```ts
const tts = await channel.createTts({
  vendor: 'elevenlabs',
  name: 'elevenlabs-main',
});

await tts.say('Hello', {
  ttsStrategy: 'streaming',
  alias: 'greeting',
});

// Track queue / speaking / done per sentence for one say$ call:
tts.say$('One. Two.', { alias: 'reply', queue: 0 }).subscribe({
  next: (e) => {
    if (e.state === 'queued') {
      // e.text — full utterance (string) or '' until stream tokens arrive
    }
    if (e.state === 'speaking') {
      // e.sentenceText / e.sentenceIndex — sentence starting playback
      // e.itemAlias — e.g. reply-0
    }
    if (e.state === 'done') {
      // that sentence finished; e.sentenceText / e.sentenceIndex / e.itemAlias
    }
    if (e.state === 'cancelled') {
      // audio.stop / destroy; e.text so far
    }
  },
  complete: () => {
    // whole say$ finished (all sentences)
  },
  error: (err) => {
    // MediaError — synthesis/playback failed
  },
});

// Later turns reuse the same WebSocket:
await tts.say(tokenStream, {
  ttsStrategy: 'streaming',
  alias: 'reply-2',
});

tts.destroy();
```

`tts.say()` stays a `Promise` (await until finished). `tts.say$()` returns an `Observable`
of utterance lifecycle events for that call only (`queued` → (`speaking` → `done`)×N /
`cancelled`, then complete). `done` is per sentence/phrase finishing playback; use the
Observable `complete` callback for the end of the whole `say$` call. Synthesis failures
terminate the Observable via **error** (`MediaError`), and are also mirrored on
`tts.error$` / `channel.events.error$`. `channel.audio.say` remains Promise-only.

You can still use `channel.audio.say(..., { tts })` if you prefer the channel API; a matching
pre-warmed session is also reused when vendor+config align.


| Method                                          | Purpose                                                          |
| ----------------------------------------------- | ---------------------------------------------------------------- |
| `channel.createTts(config?)`                    | Pre-warm a TTS connector / streaming socket; returns `TtsHandle` |
| `tts.say(textOrObservable, options?)`           | Synthesize via the handle's cached connection (`Promise`)        |
| `tts.say$(textOrObservable, options?)`          | Same path with per-utterance status events (`Observable`)        |
| `tts.presay(text, options?)`                    | Pre-synthesize into the host TTS cache via the handle            |
| `channel.audio.say(textOrObservable, options?)` | Synthesize text with TTS and play on a mixer queue               |
| `channel.audio.play(source, options?)`          | Play raw audio (URL, path, or platform phrase record)            |
| `channel.audio.presay(text, options?)`          | Pre-synthesize TTS into the host cache (no playback)             |
| `channel.audio.preload(source, options?)`       | Decode/warm a raw audio source (no playback)                     |
| `channel.audio.queue(index)`                    | Per-queue control handle (`MixerQueueControl`)                   |
| `channel.audio.remove(alias, queue?)`           | Remove one queued item by alias                                  |
| `channel.audio.stop(queue)`                     | Clear a queue **and** abort in-flight sentence TTS for it        |
| `channel.audio.stopAll()`                       | Clear every queue (WS clients also get an audio interrupt)       |


`channel.audio.say(textOrObservable, options?)` synthesizes text and plays it through the mixer.

```ts
await channel.audio.say('Please wait while I check that.', {
  queue: 0,
  alias: 'main-response',
  ttsVendor: 'elevenlabs',
  ttsStrategy: 'sentence',
  ttsConfig: {
    api_key: 'sk_your-key',
    voice_id: 'bBLRWT6MSWBFAm76ZWXY',
    model_id: 'eleven_turbo_v2_5',
    base_url: 'https://api.eu.residency.elevenlabs.io',
    output_format: 'pcm_16000',
  },
});
```

Use full vendor names for `ttsVendor`. Dedicated TTS vendors include `"elevenlabs"`, `"google"`, and `"voctiv"`. The default TTS path can also accept compatible aliases such as `"azure"` or `"neuro_v3"`, depending on how the host runtime is configured.

Vendor-native parameter names (`api_key`, `voice_id`, `model_id`, `base_url`) are passed directly to the connector and override any platform defaults. See [TTS Credentials And Vendor Parameters](#tts-credentials-and-vendor-parameters) for the full list of accepted keys per vendor.

`channel.audio.play(source, options?)` plays raw audio from a URL/path or a phrase record from `platform.getRecords()`.

```ts
await channel.audio.play('/opt/prompts/welcome.wav', {
  queue: 1,
  alias: 'welcome-earcon',
});
```



### Pre-synthesis And Preload

`channel.audio.presay(text, options?)` runs TTS ahead of time and stores PCM in the host TTS
cache. Later `say()` calls with the same resolved TTS config and text can reuse the cached file.
Playback does **not** start. If the cache is unavailable, the runtime logs a warning and resolves
without throwing.

```ts
await channel.audio.presay('Your balance is one hundred dollars.', {
  ttsVendor: 'elevenlabs',
  ttsConfig: { voice_id: 'bBLRWT6MSWBFAm76ZWXY' },
});

// Later — cache hit, faster playback:
await channel.audio.say('Your balance is one hundred dollars.', {
  alias: 'balance',
  ttsVendor: 'elevenlabs',
  ttsConfig: { voice_id: 'bBLRWT6MSWBFAm76ZWXY' },
});
```

`PresayOptions` accepts `ttsVendor`, `name`, `ttsConfig`, `ttsStrategy`, and optional `cache`
overrides (same shape as `PlayOptions.cache`).

`channel.audio.preload(source, options?)` downloads/decodes a **raw audio** source through the
audio player path. It does **not** synthesize TTS and does **not** populate the TTS cache used
by `presay()`. Use it to warm the decoder before `play()`.

```ts
await channel.audio.preload('/opt/prompts/welcome.wav');
await channel.audio.play('/opt/prompts/welcome.wav', { alias: 'welcome' });
```

When phrase persistence is enabled, `preload()` can store decoded audio for later playback via `platform.getRecords()`. Pass `options.cache` to override phrase name, flag, or language.

### TTS Strategies

`ttsStrategy` controls how text is chunked:

- `sentence`: split on sentence boundaries and synthesize each sentence. This is the default. Only one sentence is synthesized at a time; when synthesis of N finishes, N+1 starts immediately while N continues playing from the mixer queue. Sentence N is fired as soon as its text is complete — it never waits for sentence N+1. For ElevenLabs HTTP TTS, each request gets `previous_text` / `next_text` when those neighbor texts are already available (e.g. full string already split); on an LLM stream typically only `previous_text` is known at fire time. Scripts do not need to set these fields. `eleven_v3` does not support those fields (API 400), so they are omitted for that model. The TTS cache key is still the sentence text only (neighbors are not part of the key), so cache hits may replay audio synthesized under a different neighbor context.
- `streaming`: send chunks incrementally for streaming-capable vendors.
- `full`: accumulate the whole input and synthesize it as one segment after the input completes.

When using an `Observable<string>` input, WS clients also receive text progress events for streamed chunks.

### Mixer Queues

The mixer has queues `0` **through** `4`. Use separate queues for main speech, earcons, hold
music, or background audio so barge-in on one queue does not cut unrelated audio.

Obtain a per-queue handle with `channel.audio.queue(index)` (`MixerQueueControl`):


| Member          | Description                                                                |
| --------------- | -------------------------------------------------------------------------- |
| `index`         | Queue index **0–4**                                                        |
| `volume`        | Linear gain **0.0–1.0** for the entire queue (get/set)                     |
| `itemStarted$`  | Emits item `alias` when playback starts                                    |
| `itemFinished$` | Emits `alias` when an item finishes, is removed, or is skipped by clear    |
| `queueEmpty$`   | Emits when the queue is empty after all PCM has been mixed out             |
| `remove(alias)` | Drop one item on this queue                                                |
| `clear()`       | Drop all items on this queue (does **not** abort in-flight TTS generation) |


Top-level helpers on `channel.audio`:

- `remove(alias, queue?)` — when `queue` is omitted, searches all five queues; when set, only that queue is checked.
- `stop(queue)` — same as `clear()` **plus** aborts in-flight sentence TTS for that queue.
- `stopAll()` — `stop()` on every queue; WS clients also receive an audio interrupt signal.

```ts
const tts = channel.audio.queue(0);
const music = channel.audio.queue(2);

tts.itemStarted$.subscribe((alias) => logger.log('TTS started', { alias }));
tts.queueEmpty$.subscribe(() => logger.log('Agent queue idle'));

music.volume = 0.25;

await channel.audio.play('/opt/audio/hold.wav', {
  queue: 2,
  alias: 'hold-music',
  loop: true,
  loopDelayMs: 500,
});

// Remove one earcon without touching TTS:
channel.audio.remove('hold-music', 2);

// Barge-in: stop agent speech and abort pending sentence synthesis:
channel.audio.stop(0);

// Or clear music only (no TTS abort on queue 0):
music.clear();
```

`PlayOptions.volume` changes the whole queue volume, not just one item.

For sentence-split TTS, queue item aliases are suffixed as `alias-0`, `alias-1`, and so on. Raw
`play()` and direct streaming TTS use the alias exactly. Pass the suffixed alias to
`remove()` when cancelling a single synthesized sentence.

### PlayOptions Reference

Shared by `say()`, `play()`, and (where noted) `presay()`:


| Field         | Type                       | Applies to      | Description                                            |
| ------------- | -------------------------- | --------------- | ------------------------------------------------------ |
| `queue`       | `number?`                  | `say`, `play`   | Mixer queue **0–4** (default **0**).                   |
| `alias`       | `string?`                  | `say`, `play`   | Stable item id for `remove()` and queue events.        |
| `loop`        | `boolean?`                 | `say`, `play`   | Restart after finish until stopped/removed.            |
| `loopDelayMs` | `number?`                  | `say`, `play`   | Silence between loop iterations.                       |
| `volume`      | `number?`                  | `say`, `play`   | Sets **whole queue** gain **0.0–1.0** (not per-item).  |
| `ttsStrategy` | `TtsStrategy?`             | `say`, `presay` | `sentence` | `streaming` | `full`.                     |
| `ttsVendor`   | `TtsVendor?`               | `say`, `presay` | Override `channel.params.ttsVendor`.                   |
| `name`        | `string?`                  | `say`, `presay` | Platform TTS credential `name` (key catalog selector). |
| `ttsConfig`   | `Record<string, unknown>?` | `say`, `presay` | Vendor params; `name` key is stripped before send.     |
| `cache`       | `true | CacheOptions?`     | `say`, `presay` | TTS file cache; optional platform phrase persist.      |


`play()` ignores `tts*` and `cache` for raw audio. `preload()` only accepts `cache` overrides.

## Custom ASR / TTS Providers

Trusted logic packages can ship their own ASR/TTS engines. The host loads
`export const mediaProviders` with a normal Node `require` **outside** the script VM
(same privileges as the API). Use this only for packages you trust.

Mid-script `registerAsr` / `registerTts` is **not** supported — declare vendors at module load.

### Package layout (required for connectors that use `ws` / Node APIs)

Put providers in a **sibling module**. Do **not** re-export them from the sandboxed entry
(`dist/index.js`). The script worker evaluates the entry under a restricted VM: there is
**no** global `process`, and loading `ws` (or similar) inside that graph fails even when
`net` / `tls` are allowlisted.

Recommended layout after `tsc`:

```text
dist/
  index.js                 ← defineScript only (sandboxed)
  media-providers/
    index.js               ← export const mediaProviders (host-only)
    my-asr.js
    my-tts.js
```

Host resolution order for `mediaProviders`:

1. `<entryDir>/media-providers/index.js`
2. `<entryDir>/media-providers.js`
3. `<scriptRoot>/media-providers/…` and `dist/media-providers/…` fallbacks
4. the script entry itself (backward compatible — avoid for `ws`-based connectors)

Sandboxed script code may import **lightweight** helpers from the providers package
(e.g. vendor id constants) if those files do **not** `require('ws')` / touch `process`.
Never import the connector classes or `mediaProviders` index from the entry.

Examples:

- Host module: [`examples/custom-media-providers.ts`](./examples/custom-media-providers.ts)
- Script entry: [`examples/custom-media-providers-script.ts`](./examples/custom-media-providers-script.ts)

### Contracts (implement the interfaces)

There are no abstract base classes — implement the SDK interfaces so TypeScript checks
the full surface and IDEs autocomplete correctly:

| Interface | Role |
| --- | --- |
| `ScriptAsrConnector` | Custom STT connector returned by an `asr` factory |
| `ScriptAsrConnectorError` | Error payload on `ScriptAsrConnector.error$` |
| `ScriptTtsConnector` | Unified batch + optional streaming TTS connector |
| `ScriptTtsSynthesisContext` | Synthesis context passed into a TTS factory (`TtsSynthesisContext`) |
| `MediaConnectorContext` | `id`, flattened `config`, `dialogUuid`, `debug$` passed into factories |
| `MediaProviderShared` | Optional per-dialog object from `createShared` |
| `AsrProviderFactory` / `TtsProviderFactory` | Factory functions in `MediaProvidersDefinition` (`asr` / `tts` maps) |

`defineMediaProviders` is an identity helper for typing. Factories must return objects that
satisfy those interfaces (classes with `implements` recommended). The argument is a
`MediaProvidersDefinition`.

### Export shape (host module)

```ts
// media-providers/index.ts  — host-only (may use ws, https, process.env, …)
import {
  defineMediaProviders,
  type MediaConnectorContext,
  type MediaProviderShared,
  type ScriptAsrConnector,
  type ScriptTtsConnector,
} from '@voctiv/agent-sdk';
import { Subject } from 'rxjs';
import { Readable } from 'stream';

class MyAsr implements ScriptAsrConnector { /* … */ }
class MyTts implements ScriptTtsConnector { /* … */ }

export const mediaProviders = defineMediaProviders({
  // Optional: one object per dialog, shared by ASR + TTS factories
  createShared: (ctx) => ({
    dialogUuid: ctx.dialogUuid,
    dispose() { /* release app state */ },
  }),
  asr: {
    'my-asr': (ctx, shared) => new MyAsr(ctx, shared),
  },
  tts: {
    'my-tts': (ctx, shared) => new MyTts(ctx, shared),
  },
});
```

```ts
// index.ts  — sandboxed entry (do NOT import ./media-providers or ws)
import { defineScript } from '@voctiv/agent-sdk';

export default defineScript(async ({ channel }) => {
  // Credentials: pass via createAsr/createTts `data` (or platform/channel params).
  // Do not rely on process.env here — `process` is not defined in the script VM.
  const asr = await channel.createAsr({
    vendor: 'my-asr',
    language: 'ru-RU',
    data: { api_key: String(channel.params.api_key ?? '') },
  });
  const tts = await channel.createTts({
    vendor: 'my-tts',
    data: {
      api_key: String(channel.params.api_key ?? ''),
      voice_id: '…',
      output_format: 'pcm_16000', // preferred for telephony
    },
  });

  await tts.say('Hello from a custom connector.', {
    alias: 'greet',
    ttsStrategy: 'streaming', // used when supportsStreaming() === true
  });

  tts.say$('One. Two.', { alias: 'reply' }).subscribe((_ev) => {
    // queued | speaking | done | cancelled — same events as builtin TTS
  });

  await channel.audio.say('Again', { tts }); // warm handle reuse

  asr.destroy();
  tts.destroy();
});
```

Host-side factories **may** read `process.env` (e.g. `ELEVENLABS_API_KEY`) because they run
outside the VM. Prefer also accepting the same keys via `ctx.config` from `data`.

### Implement ASR (`ScriptAsrConnector`)

The host pushes **PCM S16LE mono 16 kHz** frames into `send()`. Emit partials on
`transcription$` and finals on both `transcription$` (`isFinal: true`) and `result$`.

| Member | Role |
| --- | --- |
| `transcription$` | `{ text, isFinal }` partials and finals |
| `result$` | Final utterance strings (drives `AsrHandle.result$`) |
| `error$` | Vendor / transport failures |
| `send(audio)` | Accept inbound PCM (`ArrayBuffer` / `Buffer`) |
| `speech(active)` | Optional VAD gate from the host (`true` = utterance open) |
| `finalize()` | End-of-utterance nudge (flush / commit) |
| `isOpen()` | Whether the vendor socket is ready |
| `close()` | Tear down; complete Subjects |

```ts
class MyAsr implements ScriptAsrConnector {
  readonly transcription$ = new Subject<{ text: string; isFinal: boolean }>();
  readonly result$ = new Subject<string>();
  readonly error$ = new Subject<{ message: string; code?: number }>();

  constructor(
    private readonly ctx: MediaConnectorContext,
    private readonly shared: MediaProviderShared | undefined,
  ) {
    // ctx.config — flattened createAsr data + channel asrConfig
    // ctx.dialogUuid, ctx.role, ctx.debug$
  }

  isOpen() { return true; }
  send(audio: ArrayBufferLike) { /* forward PCM to vendor */ }
  speech(_active: boolean) {}
  finalize() {
    const text = '…';
    this.transcription$.next({ text, isFinal: true });
    this.result$.next(text);
  }
  close() {
    this.transcription$.complete();
    this.result$.complete();
    this.error$.complete();
  }
}
```

Factory `throw` during create → host degraded ASR handle (same as builtin). Prefer connecting
lazily in `send()`; the host does not call a separate `connect()` on custom connectors.

### Implement TTS (`ScriptTtsConnector`)

**One class per vendor** covers batch HTTP and optional streaming WebSocket. Do **not**
register a separate streaming map.

| Member | Role |
| --- | --- |
| `supportsStreaming()` | `true` → host may use WS path for `ttsStrategy: 'streaming'` |
| `textToSpeechStream(text, ctx?)` | Batch/HTTP synthesis → `Readable` of audio bytes |
| `audio$` / `done$` | Streaming audio chunks and end-of-generation |
| `open` / `startGeneration` / `sendText` / `flush` | Streaming lifecycle |
| `sendSeparatorFlush?()` | Optional sentence separator flush |
| `close()` | Soft-close sockets for **reuse** (do not complete Subjects if `open()` may run again) |

```ts
class MyTts implements ScriptTtsConnector {
  readonly audio$ = new Subject<Buffer>();
  readonly done$ = new Subject<void>();

  supportsStreaming() {
    return true; // or false for HTTP-only
  }

  async textToSpeechStream(
    rawtext: string,
    _ctx?: { previousText?: string; nextText?: string },
  ) {
    // Return PCM (preferred) or MP3/OGG. Hint format via createTts data:
    // output_format: 'pcm_16000' | 'mp3_…'  or  audioFormat: 'pcm' | 'mp3'
    return Readable.from([/* bytes */]);
  }

  async open() { /* open long-lived WS */ }
  async startGeneration() { /* BOS / new utterance */ }
  sendText(chunk: string) { /* stream tokens */ }
  flush() { /* EOS; later emit done$ */ }
  close() { /* close WS; keep Subjects alive for fingerprint reuse */ }
}
```

- `supportsStreaming() === false` → streaming strategy falls back to sentence/full HTTP path.
- Default audio assumption for custom vendors is **PCM** unless `output_format` / `audioFormat`
  indicates a compressed format (`mp3`, `ogg`, …).

### Using custom vendors in the script

```ts
const asr = await channel.createAsr({ vendor: 'my-asr', data: { /* … */ } });
const tts = await channel.createTts({ vendor: 'my-tts', data: { /* … */ } });

await tts.say('Hi', { alias: 'greet' });
await tts.presay('Warm cache');
tts.say$('Next', { alias: 'reply', ttsStrategy: 'streaming' }).subscribe(/* … */);

await channel.audio.say('Reuse', { tts }); // same warm session
```

Vendor keys must be **multi-character** names. Builtin single-letter codes
(`A`, `D`, `E`, `ES`, `G`, `V`, `W`, `W2`, …) **cannot** be overridden.

### Warm reuse vs `createShared` vs PCM cache

| Layer | What it caches | Scope |
| --- | --- | --- |
| Factory + session registries | One connector instance per `dialog + vendor + config fingerprint` (TCP/WS) | Host automatic |
| `createShared` | Your app/vendor state shared by ASR↔TTS factories | One object per dialog |
| `PlayOptions.cache` / `presay` | Synthesized PCM files | Host TTS file cache |

Call `createAsr` / `createTts` early to warm sockets. Repeated `say` with the same handle (or
matching fingerprint) must **not** open a new TCP/WS per utterance.

The host also loads the same `mediaProviders` module into the **pipeline worker** process
(separate `require`) when `PIPELINE_WORKERS` is enabled — keep the module side-effect free
aside from exporting factories.

### Security

- Host `require` of `mediaProviders` runs with API privileges — **trusted packages only**.
- Keep connector/`ws` code out of the sandboxed entry so the VM never evaluates it.
- The script VM does **not** expose `process` (no `process.env` in `defineScript`).
- Entry / provider paths are restricted to the script root (path traversal denied).
- Optional host allowlists may further restrict which packages may export providers.



## TTS Credentials And Vendor Parameters



### Direct TTS Vendor Parameters

Pass vendor-native credentials and settings directly through `PlayOptions.ttsConfig`. These values are forwarded to the TTS connector as-is and override any defaults or platform-resolved credentials.

```ts
await channel.audio.say('Hello!', {
  ttsVendor: 'elevenlabs',
  ttsStrategy: 'streaming',
  ttsConfig: {
    api_key: 'sk_your-elevenlabs-key',
    voice_id: 'bBLRWT6MSWBFAm76ZWXY',
    model_id: 'eleven_turbo_v2_5',
    base_url: 'https://api.eu.residency.elevenlabs.io',
  },
});
```

Each TTS vendor connector accepts its native parameter names:


| Vendor         | Accepted `ttsConfig` keys                                                                                                                                                                                                     |
| -------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **ElevenLabs** | `api_key` (or `xi_api_key`), `voice_id`, `model_id` (or `model`), `base_url`, `output_format`, `language_code`, `voice_settings_stability`, `voice_settings_similarity_boost`, `voice_settings_style`, `voice_settings_speed` |
| **Voctiv**     | `url`, `voice_id`, `language`, `emotion`, `speaking_rate`, `chunk_schedule`                                                                                                                                                   |


All vendors also accept the env-style names (`ELEVENLABS_API_KEY`, `ELEVENLABS_VOICE_ID`, etc.) for backwards compatibility, but vendor-native names are checked first and are preferred.

### Platform TTS Key Selection

TTS credentials can also be selected by `PlayOptions.name` or `ttsConfig.name`:

```ts
await channel.audio.say('Здравствуйте!', {
  name: 'main-tts-key',
  ttsConfig: {
    voice: 'alena',
  },
});
```

The host resolves credentials from `channel.params.authentication_data` for the current agent and company. If `name` is omitted, `channel.params.defaultTtsName` may be used.

When both `name` (platform key) and explicit `ttsConfig` values are provided, `ttsConfig` values win — they are applied last and override anything resolved from the platform.

`cache` enables TTS result caching for `say()` and `presay()`:

- `cache: true` — read/write host TTS file cache.
- `cache: { phraseName, flag?, language? }` — TTS cache **plus** persist as a platform phrase so `platform.getRecords()` can retrieve the audio later.

```ts
// Cache only (no platform persist):
await channel.audio.say('Hello!', { cache: true });

// Cache + persist to platform phrase storage:
await channel.audio.say('Welcome back.', {
  cache: {
    phraseName: 'welcome_back',
    flag: context.flag,
    language: context.language,
  },
});

const records = await platform.getRecords?.({
  phraseName: 'welcome_back',
  flag: context.flag,
  language: context.language,
});

if (records?.[0]) {
  await channel.audio.play(records[0]);
}
```

Phrase persistence requires platform phrase storage and TTS cache to be enabled on the host.

## Error Handling

ASR and TTS errors are propagated to the script. Unhandled errors are always logged server-side, but scripts can catch them to react: fall back to a different vendor, notify the caller, or abort the dialog.

`channel.events.error$` is the canonical stream for media/runtime errors. TTS methods still reject their own promise when the awaited operation fails, and `AsrHandle.error$` still exposes errors scoped to one recognizer, but the same ASR/TTS failures are also emitted on `channel.events.error$`.

### TTS Errors — Promise Rejection

`say()` and `play()` reject their promises when TTS/playback fails:

```ts
try {
  await channel.audio.say('Hello!', {
    ttsVendor: 'elevenlabs',
    ttsConfig: { api_key: 'invalid-key', voice_id: 'abc' },
  });
} catch (err) {
  logger.error('TTS failed', { error: String(err) });
  await channel.audio.say('Fallback message.'); // try default TTS
}
```



### ASR Errors — `error$` Observable

Runtime ASR errors (gRPC disconnect, auth failure, quota exceeded) are emitted on `AsrHandle.error$`:

```ts
const asr = await channel.createAsr({
  vendor: 'yandex',
  data: { api_key: 'my-key' },
});

asr.error$.subscribe((err) => {
  logger.error('ASR provider error', {
    message: err.message,
    code: err.code,
    vendor: err.vendor,
  });
});
```

A degraded handle (returned when connector creation itself failed) has an inert `error$` that never emits — the creation failure is reported on `channel.events.error$` instead.

### Channel Error Stream

`channel.events.error$` is a unified stream of all media errors — ASR creation/runtime failures, TTS synthesis/playback failures, SIP/channel failures, and other host media errors:

```ts
channel.events.error$.subscribe((err) => {
  logger.warn(`[${err.source}:${err.operation ?? 'unknown'}] ${err.message}`, {
    phase: err.phase,
    code: err.code,
    vendor: err.vendor,
    queue: err.queue,
    alias: err.alias,
  });
});
```

`MediaError` fields:


| Field         | Type                                                                   | Description                                                                        |
| ------------- | ---------------------------------------------------------------------- | ---------------------------------------------------------------------------------- |
| `source`      | `'asr' | 'tts' | 'sip' | 'channel' | 'llm'`                            | Which subsystem produced the error.                                                |
| `phase`       | `'create' | 'start' | 'stream' | 'playback' | 'finalize' | 'destroy'?` | Lifecycle phase where the error happened.                                          |
| `operation`   | `string?`                                                              | Public SDK operation, e.g. `createAsr`, `createTts`, `audio.say`, or `audio.play`. |
| `recoverable` | `boolean?`                                                             | Whether the runtime can keep the session alive after this error.                   |
| `handleId`    | `string?`                                                              | ASR handle id when the error belongs to a recognizer instance.                     |
| `queue`       | `number?`                                                              | Mixer queue index when the error belongs to an audio operation.                    |
| `alias`       | `string?`                                                              | Queue item alias when the error belongs to playback.                               |
| `message`     | `string`                                                               | Human-readable description.                                                        |
| `code`        | `number | string?`                                                     | HTTP status, gRPC status, provider code, or WebSocket close code.                  |
| `vendor`      | `string?`                                                              | Vendor identifier, e.g. `"yandex"`, `"elevenlabs"`, `"azure"`.                     |
| `details`     | `unknown?`                                                             | Arbitrary provider-specific payload.                                               |
| `cause`       | `unknown?`                                                             | Original underlying error when available.                                          |


Subscribing to `error$` is optional. Old scripts that do not subscribe are not affected — the observables simply go unobserved.

## Channel Events

`channel.events` exposes session-level observables that are **not** tied to a single ASR handle:


| Observable     | Emits when                                                              |
| -------------- | ----------------------------------------------------------------------- |
| `speechStart$` | User started speaking (VAD, socket event, or synthetic text input).     |
| `speechEnd$`   | User stopped speaking (VAD end, ASR final, or synthetic text input).    |
| `interrupt$`   | Barge-in: user speech interrupted bot audio (may be inert without VAD). |
| `terminated$`  | Session ending — hangup, WS disconnect, or `channel.destroy()`.         |
| `message$`     | Structured WS data messages (`DataMessage`: `{ event, payload }`).      |
| `error$`       | Unified media/runtime errors (see [Error Handling](#error-handling)).   |


```ts
channel.events.speechStart$.subscribe(() => {
  channel.audio.stop(0); // barge-in on agent TTS queue
});

channel.events.message$.subscribe(({ event, payload }) => {
  logger.log('WS client event', { event, payload });
});

channel.events.terminated$.subscribe(() => {
  asr.destroy();
  channel.destroy();
});
```

**ASR vs channel events:** `AsrHandle.speechStart$` / `speechEnd$` / `interrupt$` are scoped to one
recognizer instance. `channel.events.*` aggregates session-level signals (useful when you do not
create ASR or want one subscription for the whole channel).

## LLM API

`channel.llm` talks to the Omni LLM backend.


| Method                           | Returns                        | Description                                      |
| -------------------------------- | ------------------------------ | ------------------------------------------------ |
| `ask(message, options?)`         | `Promise<string>`              | Single-shot completion (consumes SSE stream).    |
| `stream(message, options?)`      | `Observable<LlmStreamChunk>`   | Token/chunk stream; use `chunk.content` for TTS. |
| `extract(options?)`              | `Promise<Record<string, any>>` | Structured extraction via Omni extract API.      |
| `createDialog(options)`          | `Promise<DialogInfo>`          | Create dialog (`CreateDialogOptions`, optional `payload`) (HTTP). |
| `updateDialog(options)`          | `Promise<DialogInfo>`          | Patch dialog (`UpdateDialogOptions` / `payload`) (HTTP `/meta`). |
| `makePersistentStream(options?)` | `PersistentLlmStreamHandle`    | Long-lived stream for multi-turn chat.           |

`send()` returns a `PersistentLlmSendHandle` (`requestId`, per-turn `stream$`, `abort()`). Several sends may run in parallel on one socket.

Persistent handle also exposes `createDialog` / `updateDialog` over Socket.IO (`dialog.create` / `dialog.update`).


Common `LlmOptions`: `dialogUuid`, `agentUuid`, `role`, `hidden`, `name` (LLM speaker label — **not**
the TTS credential `name`), `payload`, `history`, `debug`, `agentAliasFilter`, `currentAgentAlias`,
`pseudoReasoning` (manual same-turn hint; skips the assigned reasoner LLM), `requestId`.

```ts
const answer = await channel.llm.ask('Summarize the user request', {
  role: 'assistant',
  hidden: true,
  agentUuid: context.agentUuid,
});

await channel.audio.say(answer);
```

For streaming:

```ts
import { map } from 'rxjs';

const stream$ = channel.llm.stream('Answer briefly', {
  role: 'assistant',
});

await channel.audio.say(
  stream$.pipe(map((chunk) => chunk.content)),
  { ttsStrategy: 'streaming' },
);
```

Structured extraction:

```ts
const fields = await channel.llm.extract({
  prompt: 'Extract appointment date and time from the dialog.',
  temperature: 0.2,
});
```

Persistent multi-turn stream (one Socket.IO connection, multiplexed by `requestId`):

```ts
// HTTP: create / update dialog payload for prompt templates
const created = await channel.llm.createDialog({
  agentUuid: context.agentUuid,
  payload: { customerName: 'Alex', language: 'ru' },
});
await channel.llm.updateDialog({
  dialogUuid: context.dialogUuid,
  payload: { ...created.payload, balance: 1200 },
});

const chat = channel.llm.makePersistentStream({
  agentUuid: context.agentUuid,
  dialogUuid: context.dialogUuid,
});

// Same over the persistent socket
await chat.createDialog({
  agentUuid: context.agentUuid,
  payload: { customerName: 'Alex' },
});
await chat.updateDialog({ payload: { customerName: 'Alex', tier: 'gold' } });

chat.stream$.pipe(map((c) => c.content)).subscribe((text) => logger.debug('LLM chunk', { text }));

chat.send('What is my balance?');
chat.send('And my last payment date?');

// Parallel agents on the same socket:
const realtime = chat.send(userText, {
  currentAgentAlias: 'fast',
  pseudoReasoning: 'User wants to cancel. Confirm identity first.',
});
const deep = chat.send(userText, { currentAgentAlias: 'researcher' });
realtime.stream$.subscribe((c) => { /* TTS */ });
deep.stream$.subscribe((c) => { /* notes / later TTS */ });
// deep.abort(); // cancel only the deep turn

chat.disconnect();
```



## Script Return Value

Scripts may return `void` or a `ScriptResult`:

```ts
return {
  output: { intent: 'reschedule', score: 0.92 },
};
```

- `output` — stored in dialog stats / host persistence. This is **not** the LE `dialog.result` lifecycle column — that is `platform.dialog.result` (see [Dialog State](#dialog-state)).
- `error` — optional; usually auto-populated on crash, but scripts may set it explicitly.

**Do not** return `env` from the script. Persist state via `context.env$`; the runtime snapshots it
after completion into `PersistedScriptResult.env`.

## Script Lifecycle (`getScriptPhase`)

The host always runs your single `defineScript` **export** — there is no separate runtime entry per
name (unlike logic-executor Python `run_unit(entry_point=...)`). Use `getScriptPhase(context)`
to tell *why* the script is running now: live call, pre-call queue, post-call continuation,
messaging, etc.

`context.headless === true` **only means “no live SIP/media”.** It does **not** tell you whether
the run is before or after a call. For that, use `getScriptPhase`.

The return value is the `ScriptPhase` union — one of the phases in the table below.


| Phase                | `context.headless` | When                                                                                              | Typical `context.entryPoint`                               |
| -------------------- | ------------------ | ------------------------------------------------------------------------------------------------- | ---------------------------------------------------------- |
| `before_call`        | `true`             | Dialog queue / bulk outbound **before** the first platform call (often schedules `platform.call`) | empty, `main`, `default`                                   |
| `dialing`            | `false`            | Live session with **no leg yet** — script opted into `outboundCallMode: 'script_dial'` and must `makeCall()` | any (ignored for phase; snapshot stays `'dialing'`)     |
| `early`              | `false`            | Live session that started **before** the `200 OK` (campaign `'from_invite'` or inbound pre-answer). Not `channel.sip.state === 'early'` (RTP). Snapshot stays `'early'` after answer. | any (ignored for phase) |
| `online`             | `false`            | Live SIP session (inbound, outbound, **and automatic recall redials**)                            | any (ignored for phase)                                    |
| `after_call_success` | `true`             | Headless run **after** a successful call                                                          | `on_success_call`, `after_call_success`, `on_done_call`    |
| `after_call_failed`  | `true`             | Headless run **after** failed attempts (when `onFailedCall` was configured)                       | `on_failed_call`, `after_call_failed`                      |
| `messaging`          | `true`             | Inbound message triggered the run                                                                 | `on_message_api_received`, or `context.inboundMessage` set |
| `recall`             | `true`             | Headless recall leg (legacy `entry_point`)                                                        | `on_recall`, `recall`                                      |
| `headless_other`     | `true`             | Any other headless run with a custom `entry_point`                                                | your custom name                                           |


**Automatic recall** (`recallCount` + `recallDelay`) creates new **online** SIP legs with an
incremented `context.attempt`. Branch with `(context.attempt ?? 0) > 0`, **not** `phase === 'recall'`.
See [examples/recall-routing-by-attempt.ts](./examples/recall-routing-by-attempt.ts).

**After-call continuation** uses `onSuccessCall` / `onFailedCall` on `platform.call()`. When the call
ends, the host sets `dialog.params.entry_point` to that handler name and runs the same `defineScript`
headlessly. See [examples/after-call-continuation.ts](./examples/after-call-continuation.ts).

These two failure models are **mutually exclusive** on one scheduled outbound — see
[Failed outbound: automatic recall vs after-call continuation](#failed-outbound-automatic-recall-vs-after-call-continuation).

```ts
import { defineScript, getScriptPhase } from '@voctiv/agent-sdk';

export default defineScript(async ({ channel, context, logger, platform }) => {
  const phase = getScriptPhase(context);

  switch (phase) {
    case 'before_call':
      // Headless pre-call: queue worker, usually schedules outbound.
      await platform.call(context.msisdn!, { recallCount: 3, recallDelay: 300 });
      return { output: { phase } };

    case 'after_call_success':
      logger.log('Post-call success branch', { entryPoint: context.entryPoint });
      return { output: { phase } };

    case 'after_call_failed':
      logger.log('Post-call failure branch', { entryPoint: context.entryPoint });
      platform.dialog.result = 'done';
      return { output: { phase } };

    case 'messaging':
      logger.log('Inbound message', { text: context.inboundMessage?.payload });
      return { output: { phase } };

    case 'dialing': {
      const leg = await channel.sip.makeCall({});
      await leg.sip.waitForAnswer();
      await leg.audio.say('Hello.');
      return;
    }

    case 'early':
      await channel.sip.waitForAnswer();
    // fall through to the conversation
    case 'online':
      channel.sip.answer();
      if ((context.attempt ?? 0) > 0) {
        logger.log('Online recall leg', { attempt: context.attempt });
      }
      await channel.audio.say('Hello.');
      return;

    default:
      // headless_other, recall (headless), etc.
      logger.log('Other headless run', { phase, entryPoint: context.entryPoint });
      return { output: { phase } };
  }
});
```



## Platform API

`platform` exposes platform operations.

`platform.nlu.extract(utterance, options?)` runs intent/entity extraction. If `options.context` is omitted or `null`, the runtime sends **no** NLU context — there is no auto-fill from dialog params or `flag`. Pass it explicitly when needed (logic-executor scripts usually pass `context.flag`). Default NLU language comes from the LE agent (`_nlu.language`), not `context.lang`.

`platform.nlu.extract$()` is an Observable wrapper — one `extract()` call per subscription, not a streaming NLU session.

```ts
const result = await platform.nlu.extract('I want to reschedule', {
  intents: ['reschedule', 'cancel'],
  entities: ['date', 'time'],
  context: context.flag,
  use_synonyms: true,
});
```

Platform APIs (`platform.nlu`, `platform.call`, dialog writes, messaging, phrase records) are available when the host enables platform integration (`context.legacyV3Compat`).

### Dialog State

`platform.dialog` reads and writes the LE **dialog row** — not SIP media and not the script return value.

#### What `platform.dialog.result` is

`platform.dialog.result` maps to the PostgreSQL column `dialog.result`: the **lifecycle status** of the dialog entity in the CMS / offline queue (in queue, in progress, closed). It is **not** the outcome of a SIP leg.


| Value     | Meaning                                                                          |
| --------- | -------------------------------------------------------------------------------- |
| `null`    | Often after-call continuation: dialog re-enters queue-api, then becomes `queued` |
| `queued`  | In the offline queue, not yet claimed                                            |
| `pending` | In progress (live SIP/WS or claimed queue row)                                   |
| `done`    | Dialog closed successfully (terminal for the pipeline)                           |
| `error`   | Dialog closed with a logic/runtime error                                         |


The **host** also moves these statuses (live session → `pending`; shutdown without continuation → often `done` / `error`; automatic recall → `pending`; after-call chain → `null` + `entry_point` in params). Scripts set `platform.dialog.result` when they want to **explicitly** fix the LE dialog status (commonly `'done'` in a headless after-call handler). That does **not** replace `channel.sip.hangup()`.

#### Do not confuse


| API                                    | Layer                 | Does                                                             | Does not                                                          |
| -------------------------------------- | --------------------- | ---------------------------------------------------------------- | ----------------------------------------------------------------- |
| `platform.dialog.result`               | LE `dialog` row       | Lifecycle status (`done`, `pending`, …)                          | Hang up SIP; equal `call.result`; equal `ScriptResult.output`     |
| `platform.dialog.entryPoint`           | LE `dialog.params`    | Persist routing hint for later headless/queue runs               | Change SIP state; select another script export                    |
| `context.entryPoint`                   | `ScriptDialogContext` | **Snapshot** of `entry_point` at script start (`getScriptPhase`) | Persist if you assign it — write via `platform.dialog.entryPoint` |
| `return { output }` / `ScriptResult`   | Script return         | dialog_stats / host dump                                         | LE `dialog.result` column                                         |
| `channel.sip.hangup()` / `terminated$` | Media leg             | End or observe SIP/WS media                                      | Set `platform.dialog.result` by itself                            |
| `call.result` (LE call row)            | Per-call              | SIP terminal code/phrase for CMS logs                            | Same as `dialog.result`                                           |


There is no `context.result` field — read/write dialog lifecycle only through `platform.dialog.result`.

#### `entryPoint` write vs read

```ts
// Persist routing for the next offline run (dialog.params.entry_point)
platform.dialog.entryPoint = 'on_recall';

// Snapshot for this run — use with getScriptPhase(context)
logger.log('branch', { entryPoint: context.entryPoint });
```

`ScheduleCallOptions.entryPoint` on `platform.call()` is stored on the **call** row at schedule time — different from assigning `platform.dialog.entryPoint` mid-script.

#### Example

```ts
platform.dialog.entryPoint = 'on_recall';
platform.dialog.result = 'done';
```

Setters update the local value immediately and persist asynchronously. They are not awaitable and should not be used as transactional writes. See [examples/after-call-continuation.ts](./examples/after-call-continuation.ts).

### Platform-Scheduled Calls

Use `platform.call(msisdn, options?)` when the script needs to ask the platform to place an outbound SIP call. This is different from `channel.sip.makeCall()`: `makeCall()` creates a B-leg immediately inside the current live SIP session, while `platform.call()` schedules a separate platform-managed call that may happen now or later.

The destination number should be E.164 formatted.

```ts
await platform.call('+12025551234');
```

When options are omitted, the host fills scheduling and routing defaults from agent and dialog settings. Explicit `options` always win.

By default, the platform schedules the call for immediate processing. Use `date` to schedule it for the future:

```ts
await platform.call('+12025551234', {
  date: new Date(Date.now() + 15 * 60_000),
});
```

When the scheduled call connects, the host runs your `defineScript` **export** again. Branch inside that handler if needed (there is no separate runtime entry per name, unlike logic-executor Python `run_unit(entry_point=...)`).

Use `dateEnd` to define the latest time when the call is still useful. If the platform cannot place the call before that deadline, it can skip the attempt.

```ts
await platform.call('+12025551234', {
  date: new Date('2026-04-29T10:00:00Z'),
  dateEnd: new Date('2026-04-29T10:30:00Z'),
});
```

Retries are controlled with `recallCount` and `recallDelay` (delay is always **seconds** in SDK options):

```ts
await platform.call('+12025551234', {
  recallCount: 3,
  recallDelay: 300, // 5 minutes between failed outbound attempts
});
```

This schedules automatic redials: on each **failed outbound** the host creates a new platform call
with `date_added = now + recallDelay`. The dialer runs the script again with an incremented
`context.attempt`. See [examples/outbound-with-recall.ts](./examples/outbound-with-recall.ts).

### Failed outbound: automatic recall vs after-call continuation

After a failed **outbound** call the platform must choose **one** failure-handling strategy.
`recallCount` + `recallDelay` and `onFailedCall` answer the same question in different ways, so
they are **mutually exclusive** on `platform.call()` (logic-executor `nn.call` parity).


| Strategy                    | `platform.call()` options                                       | What happens on failure                                                                   | Next script run                                                       |
| --------------------------- | --------------------------------------------------------------- | ----------------------------------------------------------------------------------------- | --------------------------------------------------------------------- |
| **Automatic recall**        | `recallCount` + `recallDelay` (both required on the `call` row) | Host schedules another outbound `call` after `recallDelay`; bumps `dialog.params.attempt` | **Online** SIP leg — same `defineScript`, branch on `context.attempt` |
| **After-call continuation** | `onFailedCall` (and optionally `onSuccessCall`)                 | Host sets `dialog.params.entry_point` to the handler name and re-queues the dialog        | **Headless** run — `getScriptPhase(context)` → `after_call_failed`    |


**Why not both?** Recall is fully platform-driven (dialer redials without running your script between
attempts). `onFailedCall` is script-driven (your handler decides logging, CRM, manual retry, etc.).
If both were written to `call.params`, shutdown would be ambiguous. This host therefore **keeps**
`onFailedCall` **and drops recall** at schedule time so the outbound still starts and the after-call
branch runs on failure. (If both somehow land on an existing `call.params` row, recall still wins
at shutdown — avoid writing both.)

**Do not** pass `onFailedCall` together with `recallCount` and `recallDelay` in the same
`platform.call()` invocation. If both are present (script options, dialog params, or CMS
defaults), the host **keeps** `onFailedCall` **and drops recall** so the call still schedules —
after-call continuation wins over automatic redial. Prefer configuring only one strategy
explicitly.

**Host default resolution:** when you omit recall options, the host may default them from
`context.recallCount` / `context.recallDelay` (CMS contact-rules). That auto-fill applies only when
the script did **not** pass explicit `onFailedCall` in `platform.call()` options.
`onFailedCall` / `onSuccessCall` are **never** invented from `dialog.params` or Omni
`scheduleOutbound` defaults — they are script kwargs only (LE `nn.call` parity). Stale
`on_failed_call` left in dialog params must not block CMS recall.

Diagram-style legacy outbound (Megafon converter) typically uses `onFailedCall` plus
`on_failed_call_system` (sleep + call the main block again) — **not** CMS automatic recall.
See [examples/after-call-continuation.ts](./examples/after-call-continuation.ts).

Use either automatic recall **or** offline `onFailedCall` continuation — not both on one scheduled
outbound leg.

Branch inside the script on recall attempts (`context.attempt`, not a separate entry function):

```ts
import { defineScript } from '@voctiv/agent-sdk';

export default defineScript(async ({ channel, context, logger }) => {
  channel.sip.answer();

  if ((context.attempt ?? 0) > 0) {
    logger.log('Recall leg', { attempt: context.attempt });
    await channel.audio.say('Follow-up call. Please hold.');
  }
});
```

See [examples/recall-routing-by-attempt.ts](./examples/recall-routing-by-attempt.ts).

When you omit recall options, the host fills them from `context.recallCount` / `context.recallDelay`
(effective values for the current dialog) **only when** `onFailedCall` **is not configured** for that
scheduled call. Those values fall back to CMS agent contact-rules
(`context.agent?.recallCount` / `context.agent?.recallDelay`, legacy `nn.get_recall_count()` /
`nn.get_recall_delay()`). See [Failed outbound: automatic recall vs after-call continuation](#failed-outbound-automatic-recall-vs-after-call-continuation).

Other scheduling options:

- `priority`: higher-priority calls can be processed earlier by the dialer.
- `timezone`: timezone offset used by the platform when interpreting scheduled dates.
- `onSuccessCall`: headless handler name after a successful call (e.g. `'on_success_call'` → `getScriptPhase` `'after_call_success'`).
- `onFailedCall`: headless handler name after failed attempts (mutually exclusive with recall).
- `entryPoint` (optional): stored as `entry_point` in call params for LE DB compatibility. The host still runs the same `defineScript` export; use `context.entryPoint` only if **you** branch on it inside the handler.
- `protoAdditional`: extra protocol-level parameters, such as SIP headers expected by your telephony setup.



### Messaging

```ts
await platform.messaging.send({
  src: 'bot',
  destination: '+12025551234',
  text: 'Your appointment is confirmed.',
});
```

`platform.messaging.message$` replays the inbound message that started a headless messaging script; it is not a live subscription to all future messages.

## Offline / Headless Logic

Offline, or headless, sessions run a script without a live SIP call, WebSocket audio stream, RTP pipeline, ASR, or TTS playback. They are used for platform-driven background logic, queued dialog processing, and messaging events.

The script entry point is still the same `defineScript()` handler. Detect offline mode with
`context.headless`, then use `getScriptPhase(context)` to distinguish pre-call queue runs
(`before_call`) from post-call continuations (`after_call_success` / `after_call_failed`). See
[Script Lifecycle (](#script-lifecycle-getscriptphase)`getScriptPhase`[)](#script-lifecycle-getscriptphase).

```ts
import { defineScript, getScriptPhase } from '@voctiv/agent-sdk';

export default defineScript(async ({ channel, context, logger, platform }) => {
  if (!context.headless) {
    channel.sip.answer();
    await channel.audio.say('Hello.');
    return;
  }

  const phase = getScriptPhase(context);
  logger.log('Running offline logic', {
    phase,
    dialogUuid: context.dialogUuid,
    entryPoint: context.entryPoint,
  });

  if (phase === 'before_call') {
    await platform.call(context.msisdn!);
    return;
  }

  if (phase === 'after_call_success' || phase === 'after_call_failed') {
    // Post-call headless branch — see examples/after-call-continuation.ts
    return { output: { phase } };
  }

  // messaging, headless_other, etc.
});
```

Headless sessions can be started by the host for background processing, inbound messaging, or API-triggered runs without media.

### What Works In Headless

These APIs are available and are the intended tools for offline scripts:

- `context.dialogParams`, `context.initialData`, `context.dialogEntity`, and `context.callEntity` for platform data.
- `context.env$` for persisted per-dialog state.
- `platform.nlu.extract()` for text NLU when platform integration is enabled.
- `platform.messaging.send()` for outbound messages through the configured platform messaging transport.
- `platform.call()` for scheduling outbound platform-managed calls.
- `platform.dialog.entryPoint` and `platform.dialog.result` for updating dialog routing and outcome.
- `channel.llm.ask()`, `channel.llm.stream()`, `channel.llm.extract()`, `channel.llm.createDialog()`, and `channel.llm.updateDialog()` for Omni LLM operations.
- `logger` for structured logs.

Audio and telephony APIs are intentionally inert:

- `channel.audio.say()`, `play()`, `preload()`, and `presay()` do not play audio and only log warnings.
- `channel.createAsr()` returns an inert handle with empty observables.
- `channel.textInput` does not simulate ASR in headless mode.
- `channel.sip.state` behaves as an already-active synthetic channel, but real SIP actions such as nested calls and bridging are not available.

Use headless mode for text and platform workflows. Use SIP or WS sessions when the script needs real audio, ASR, TTS, DTMF, pre-answer media, or bridging.

### Inbound Messaging

When a headless script is triggered by an inbound message, the runtime exposes the message as `context.inboundMessage` and also replays it on `platform.messaging.message$`.

```ts
export default defineScript(async ({ context, platform, logger }) => {
  const inbound = context.inboundMessage;
  const payload = inbound?.payload ?? {};

  const text =
    typeof payload.text === 'string'
      ? payload.text
      : typeof payload.message === 'string'
        ? payload.message
        : '';

  logger.log('Inbound message received', {
    src: inbound?.src,
    dst: inbound?.dst,
    channelType: inbound?.channelType,
    text,
  });

  if (!text.trim()) {
    return { output: { reason: 'empty_message' } };
  }

  const nlu = await platform.nlu.extract(text, {
    intents: ['support_request', 'callback_request'],
  });

  await platform.messaging.send({
    src: inbound?.dst ?? 'bot',
    destination: inbound?.src ?? '',
    text: 'Thanks, I received your message.',
  });

  return {
    output: {
      text,
      nlu,
    },
  };
});
```

`context.inboundMessage.payload` is the raw transport payload. Different messaging providers may use different field names (`text`, `message`, `body`, `content`, etc.), so production scripts should normalize the text they need.

### Persisting Offline State

Use `context.env$` to keep state between offline runs for the same dialog:

```ts
const env = context.env$?.getValue() ?? {};
const messageCount = Number(env.messageCount ?? 0) + 1;

context.env$?.next({
  ...env,
  messageCount,
  lastMessageAt: new Date().toISOString(),
});
```

Do not return `env` from the script. The runtime snapshots `context.env$` after completion and persists it for the dialog.

### Combining Voice And Offline In One Script

One script can support both live calls and offline messages by branching on `context.headless`:

```ts
export default defineScript(async ({ channel, context, platform }) => {
  if (context.headless) {
    const text = String(context.inboundMessage?.payload?.text ?? '');

    if (text.includes('call me')) {
      await platform.call(context.msisdn);
    }

    return { output: { handledOffline: true } };
  }

  channel.sip.answer();
  await channel.audio.say('How can I help you?');
});
```



## Dialog Context And Persisted Env

`context` includes identity, telephony fields, params, routing metadata, and runtime helpers.

### Agent env, recall defaults, and storage

When the host exposes agent identity, `context.agent` provides agent-scoped helpers:

```ts
// Read all agent env keys
const all = await context.agent?.env?.();

// Read one key
const counter = await context.agent?.env?.<number>('visitCount');

// Write with optional TTL (days)
await context.agent?.env?.('visitCount', 42, { expire: 30 });
```



#### Recall settings (agent defaults vs effective)

Recall behavior uses **two layers** on `context`:


| Layer                  | Fields                                                     | Source                                                            | Use when                                                                                |
| ---------------------- | ---------------------------------------------------------- | ----------------------------------------------------------------- | --------------------------------------------------------------------------------------- |
| Agent defaults         | `context.agent?.recallCount`, `context.agent?.recallDelay` | CMS contact-rules (`agent.recall_count`, `agent.delay` → seconds) | Compare with CMS settings; legacy `nn.get_recall_count()` / `get_recall_delay()` parity |
| Effective for this run | `context.recallCount`, `context.recallDelay`               | `dialog.params` / `call.params`, then agent defaults              | Read in script; pass explicitly to `platform.call()` when using **automatic recall**    |


Precedence for effective values: **dialog/call params** (`recall_count`, `recall_delay`) **>** agent CMS defaults.

**Important:** CMS/effective recall on `context` does **not** mean every `platform.call()` gets
automatic recall. The host copies recall onto the scheduled `call` row only when you omit recall
options **and** `onFailedCall` is not configured (see
[Failed outbound: automatic recall vs after-call continuation](#failed-outbound-automatic-recall-vs-after-call-continuation)).
Agents with both CMS contact-rules and diagram `on_failed_call` handlers use the after-call path;
recall fields on `context` are informational unless your script passes them explicitly.

`context.attempt` is the current recall attempt counter from `dialog.params.attempt` (starts at 0).
`context.entryPoint` is the routing branch **snapshot** for this run (from `dialog.params.entry_point` at start). To persist a new value, assign `platform.dialog.entryPoint` — see [Dialog State](#dialog-state).

```ts
// Use effective values when scheduling the next outbound leg
await platform.call(context.msisdn!, {
  recallCount: context.recallCount,
  recallDelay: context.recallDelay,
});

// Log CMS defaults vs per-dialog override
logger.log('recall config', {
  effective: { count: context.recallCount, delay: context.recallDelay },
  agentDefault: {
    count: context.agent?.recallCount,
    delay: context.agent?.recallDelay,
  },
  attempt: context.attempt,
});
```

Values are loaded once at script start from the legacy agent row (cached with other LE agent
resolution). They are plain snapshots — not `BehaviorSubject`s and not re-fetched during the run.

`context.storage('key1', 'key2')` reads CMS/global variables from agent settings, then company-level
fallback. Always returns an object with every requested key (value or `null`).

### Execution budget (`runTime`)

Long-running async scripts can check and extend their time budget:

```ts
if ((context.runTime?.remainingMs() ?? Infinity) < 5000) {
  context.runTime?.extend(30_000);
}
```

`budgetMs` and `maxExtendMs` are fixed for the session; `extend()` grants up to the remaining quota.

Important fields:

- `context.dialogUuid`: current dialog UUID.
- `context.callerId` / `context.msisdn`: caller identity.
- `context.destinationNumber`: called number.
- `context.trunkId` / `context.trunkName`: LE trunk for this dialog/call (snapshot; name from `trunk` table).
- `context.language` / `context.lang`: language selected for the run.
- `context.flag`: business flag.
- `context.initialData`: shallow snapshot of params at script start.
- `context.dialogParams`: live param map for the run.
- `context.entryPoint`: routing entry point **snapshot** at script start (see [Script Lifecycle](#script-lifecycle-getscriptphase)); persist changes via `platform.dialog.entryPoint`.
- `context.attempt`: current recall attempt number (`dialog.params.attempt`; use with `phase === 'online'`).
- `context.recallCount` / `context.recallDelay`: effective recall settings for this dialog/call.
- `context.agent?.recallCount` / `context.agent?.recallDelay`: CMS agent defaults (immutable snapshot).
- `context.headless`: true for offline/queue/messaging sessions without a real media channel.
- `context.runTime`: async execution budget helper.
- `context.env$`: persisted dialog environment as an RxJS `BehaviorSubject`.

Dialog lifecycle status (`dialog.result`) is **not** on `context` — use `platform.dialog.result` ([Dialog State](#dialog-state)).

Use `env$` for persisted script state:

```ts
const current = context.env$?.getValue() ?? {};
context.env$?.next({
  ...current,
  lastIntent: 'reschedule',
});
```

Do not return `env` from the script. The runtime snapshots `context.env$` after completion and attaches it to the persisted result.

## Logging And Debugging

Use `logger.log()`, `warn()`, `error()`, and `debug()` for structured logs.

```ts
logger.log('ASR result received', { text });
logger.warn('Low confidence intent', { confidence });
```

`logger.enableDebug(endpoint)` streams logs from the current script instance to a remote debug endpoint. `logger.disableDebug()` stops streaming. `logger.breakpoint(label, snapshot?)` pauses only when an active debug session is connected; otherwise it resolves immediately.

## WS And Headless Behavior

WS channels behave like active media channels:

- `channel.sip.state` is effectively active.
- `sendDtmf()` emits `dtmf-send` to the WS client.
- `sendMessage()` emits a structured `data` event.
- ASR reads socket audio frames or synthetic text input.

Headless channels are for offline, queue, or messaging sessions:

- Audio methods are no-ops that log warnings.
- SIP methods are no-ops, except `makeCall()` and `bridge()`, which throw: there is no real leg to
create, and a silent no-op would hide the mistake.
- `createAsr()` returns an inert handle whose observables complete immediately.
- `createTts()` returns an inert handle (no vendor connection is opened).
- LLM, NLU, messaging, platform calls, dialog state, and `env$` still work.

Use `context.headless` plus `getScriptPhase(context)` when a script must behave differently without a
real media channel or across pre-call / post-call headless runs. See
[Offline / Headless Logic](#offline--headless-logic) and
[Script Lifecycle (](#script-lifecycle-getscriptphase)`getScriptPhase`[)](#script-lifecycle-getscriptphase).

## Text Input For Tests

`channel.textInput` injects synthetic ASR output into a live ASR handle.

```ts
const asr = await channel.createAsr();

channel.textInput.pushPartial(asr.id, 'hello', false);
channel.textInput.pushResult(asr.id, 'hello world');
```

This is mainly for WS debug clients and automated tests. Unknown ASR ids are ignored.

## Package Notes

The package ships as CommonJS with TypeScript declarations. Import from `@voctiv/agent-sdk`:

```ts
import { defineScript, type MediaChannel, type AsrHandle, type MediaError } from '@voctiv/agent-sdk';
```

