@voctiv/agent-sdk 0.3.1 → 0.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -70,18 +70,36 @@ export default defineScript(async ({ channel, logger, context }) => {
70
70
  });
71
71
  ```
72
72
 
73
+ This snippet assumes a **live answered** channel: inbound after you `answer()`, or a campaign
74
+ outbound that started on `'on_answer'` (the default). It is not how you pick up ringing, 1xx, or
75
+ a 4xx. Those are two different outbound stories:
76
+
77
+ - **Campaign / `platform.call`** — `defineScript(fn, { outboundCallMode })` decides *when* the
78
+ host starts this session. See [Campaign Outbound And `outboundCallMode`](#campaign-outbound-and-outboundcallmode).
79
+ - **A second leg from an already-running script** — `channel.sip.makeCall()`, then
80
+ `waitForEarly()` / `waitForAnswer()` on the **returned** channel. See
81
+ [B-leg outbound (already in session)](#b-leg-outbound-already-in-session).
82
+
73
83
  ## Examples
74
84
 
75
- The [`examples/`](./examples/) folder contains copy-paste-ready scripts:
85
+ The `[examples/](./examples/)` folder contains copy-paste-ready scripts:
86
+
87
+
88
+ | Example | Description |
89
+ | --------------------------------------------------------------------------- | -------------------------------------------------------------------------- |
90
+ | [outbound-on-answer.ts](./examples/outbound-on-answer.ts) | Campaign outbound on `200 OK` (`outboundCallMode: 'on_answer'`, the default) |
91
+ | [outbound-from-invite.ts](./examples/outbound-from-invite.ts) | Campaign outbound from INVITE (`outboundCallMode: 'from_invite'`) |
92
+ | [outbound-script-dial.ts](./examples/outbound-script-dial.ts) | Script dials the campaign leg (`outboundCallMode: 'script_dial'`) |
93
+ | [outbound-with-recall.ts](./examples/outbound-with-recall.ts) | Outbound with `recallCount` / `recallDelay` (automatic redial on failure) |
94
+ | [recall-routing-by-attempt.ts](./examples/recall-routing-by-attempt.ts) | Online recall: branch on `context.attempt` (`getScriptPhase` → `'online'`) |
95
+ | [schedule-call-with-defaults.ts](./examples/schedule-call-with-defaults.ts) | `platform.call()` without explicit recall — CMS defaults from `context` |
96
+ | [after-call-continuation.ts](./examples/after-call-continuation.ts) | `onSuccessCall` / `onFailedCall` vs recall (mutually exclusive) |
97
+ | [read-recall-from-params.ts](./examples/read-recall-from-params.ts) | `parseRecallDelaySeconds()` / `parseRecallCount()` on legacy params |
98
+ | [custom-media-providers.ts](./examples/custom-media-providers.ts) | Host-only `mediaProviders` module (`ScriptAsrConnector` / `ScriptTtsConnector`) |
99
+ | [custom-media-providers-script.ts](./examples/custom-media-providers-script.ts) | Sandboxed `defineScript` entry that uses custom vendors (no `ws` import) |
100
+
101
+
76
102
 
77
- | Example | Description |
78
- |---------|-------------|
79
- | [outbound-with-recall.ts](./examples/outbound-with-recall.ts) | Outbound with `recallCount` / `recallDelay` (automatic redial on failure) |
80
- | [recall-routing-by-attempt.ts](./examples/recall-routing-by-attempt.ts) | Online recall: branch on `context.attempt` (`getScriptPhase` → `'online'`) |
81
- | [schedule-call-with-defaults.ts](./examples/schedule-call-with-defaults.ts) | `platform.call()` without explicit recall — CMS defaults from `context` |
82
- | [after-call-continuation.ts](./examples/after-call-continuation.ts) | `onSuccessCall` / `onFailedCall` vs recall (mutually exclusive) |
83
- | [read-recall-from-params.ts](./examples/read-recall-from-params.ts) | `parseRecallDelaySeconds()` / `parseRecallCount()` on legacy params |
84
- | [custom-media-providers.ts](./examples/custom-media-providers.ts) | `defineMediaProviders` + custom ASR/TTS via `createAsr` / `createTts` |
85
103
 
86
104
  ### Quick recall example
87
105
 
@@ -113,19 +131,30 @@ const delaySec = parseRecallDelaySeconds(context.dialogParams?.recall_delay); //
113
131
  const maxAttempts = parseRecallCount(context.dialogParams?.recall_count);
114
132
  ```
115
133
 
134
+
135
+
116
136
  ## What The SDK Contains
117
137
 
118
138
  `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.
119
139
 
120
- `defineMediaProviders(def)` marks a named `mediaProviders` export for custom ASR/TTS factories (trusted packages only). See [Custom ASR / TTS Providers](#custom-asr--tts-providers).
140
+ `defineMediaProviders(def)` marks a named `mediaProviders` export for custom ASR/TTS factories.
141
+ Ship it in a **sibling** module (`media-providers/`), not in the sandboxed script entry — see
142
+ [Custom ASR / TTS Providers](#custom-asr--tts-providers).
143
+
144
+ `defineScript(fn, options)` additionally declares `ScriptCallOptions`, whose `outboundCallMode`
145
+ (`OutboundCallMode`: `'on_answer'` | `'from_invite'` | `'script_dial'`) the host reads
146
+ **before** the script runs to decide when the session starts — see
147
+ [Campaign Outbound And `outboundCallMode`](#campaign-outbound-and-outboundcallmode).
121
148
 
122
149
  **Three different “context” names:**
123
150
 
124
- | Name | Meaning |
125
- | --- | --- |
126
- | `ScriptContext` | Top-level injection: `{ channel, logger, context, platform }` |
127
- | `context` (`ScriptDialogContext`) | Dialog identity, params, routing snapshot, `env$`, headless — see [Dialog Context](#dialog-context-and-persisted-env) |
128
- | `options.context` on `platform.nlu.extract` | Opaque NLU disambiguation string/JSON**not** dialog context |
151
+
152
+ | Name | Meaning |
153
+ | ------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- |
154
+ | `ScriptContext` | Top-level injection: `{ channel, logger, context, platform }` |
155
+ | `context` (`ScriptDialogContext`) | Dialog identity, params, routing snapshot, `env$`, headless see [Dialog Context](#dialog-context-and-persisted-env) |
156
+ | `options.context` on `platform.nlu.extract` | Opaque NLU disambiguation string/JSON — **not** dialog context |
157
+
129
158
 
130
159
  `ScriptContext` is the top-level object passed to a script:
131
160
 
@@ -146,6 +175,8 @@ const maxAttempts = parseRecallCount(context.dialogParams?.recall_count);
146
175
  - `channel.events` exposes speech, interrupt, termination, WS data message, and media error observables.
147
176
  - `channel.textInput` injects synthetic ASR results for tests and debug clients.
148
177
 
178
+
179
+
149
180
  ## SIP And Pre-Answer Media
150
181
 
151
182
  SIP sessions expose call state through `channel.sip.state`, `state$`, `progress$`, `early$`, and `answered$`.
@@ -157,6 +188,13 @@ The important states are:
157
188
  - `active`: final 200 OK has been received or sent.
158
189
  - `terminated`: the call ended and no more audio is possible.
159
190
 
191
+ `channel.sip.state === 'early'` is **media** (RTP before `200 OK`). It is not
192
+ `getScriptPhase(context) === 'early'`. That phase is a snapshot of *when the live session started*
193
+ (campaign `'from_invite'` or inbound pre-answer) and **does not flip** when the call answers — use
194
+ `channel.sip.isAnswered` / `answered$` for the live state.
195
+
196
+
197
+
160
198
  ### How Pre-Answer Works
161
199
 
162
200
  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.
@@ -179,9 +217,20 @@ For outbound calls, pre-answer is controlled by the remote side. If the remote e
179
217
 
180
218
  `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.
181
219
 
182
- ### Outbound Pre-Answer
220
+ ### Two outbound stories
183
221
 
184
- For outbound calls, early media starts when the remote side sends a provisional response with SDP, usually `183 Session Progress`. This is useful for IVRs that speak before answering.
222
+ Do not mix these up. Both can see 183 / early media, but they start in different places.
223
+
224
+ | You want… | Use | Conversation on |
225
+ | --------- | --- | --------------- |
226
+ | 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` |
227
+ | A **second** outbound from a script that is already running (transfer target, IVR, operator) | `channel.sip.makeCall()` — next subsection | the **returned** B-leg |
228
+
229
+ `outboundCallMode` never changes inbound or a B-leg you opened yourself.
230
+
231
+ ### B-leg outbound (already in session)
232
+
233
+ 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.
185
234
 
186
235
  ```ts
187
236
  const bLeg = await channel.sip.makeCall({
@@ -200,6 +249,175 @@ asr.result$.subscribe((text) => {
200
249
 
201
250
  `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.
202
251
 
252
+ ### Campaign Outbound And `outboundCallMode`
253
+
254
+ This is **when the platform starts the script** for a campaign / `platform.call` row — not how you
255
+ open a B-leg from inside a live session ([that is `makeCall`](#b-leg-outbound-already-in-session)).
256
+
257
+ `OutboundCallMode` (`'on_answer'` | `'from_invite'` | `'script_dial'`) is the second argument of
258
+ `defineScript`. The host reads it **before** the session starts, because it decides *when* (and
259
+ whether) the platform dials. The names describe that moment.
260
+
261
+ ```ts
262
+ import { defineScript } from '@voctiv/agent-sdk';
263
+
264
+ defineScript(async () => undefined); // same as 'on_answer'
265
+ defineScript(async () => undefined, { outboundCallMode: 'on_answer' });
266
+ defineScript(async () => undefined, { outboundCallMode: 'from_invite' });
267
+ defineScript(async () => undefined, { outboundCallMode: 'script_dial' });
268
+ ```
269
+
270
+ A script that passes nothing stays on `'on_answer'` — the Basic Script above is that path.
271
+ Inbound legs and `channel.sip.makeCall()` B-legs are unchanged: they already hand the script a
272
+ channel before the final answer.
273
+
274
+ `answer_date` and billing never move: both come from the `200 OK`, never from `183`.
275
+ `getScriptPhase` is a snapshot of how the run **started** and does not flip when the answer arrives
276
+ — use `channel.sip.isAnswered` or `answered$` for the live state. (`defineScript` stores the
277
+ options on the function as `callOptions` — type `ScriptFnWithCallOptions` — and the host copies
278
+ that into `context.startedBeforeAnswer` / `context.startedWithoutLeg`.)
279
+
280
+
281
+ | `outboundCallMode` | Who dials | Script starts on | `getScriptPhase` at start | Conversation channel |
282
+ | ----------------------- | ----------------------- | ----------------------------------- | ------------------------------------------------------ | ---------------------- |
283
+ | `'on_answer'` (default) | host | the `200 OK` | `'online'` | `channel` |
284
+ | `'from_invite'` | host (after script up) | INVITE — session is already running | `'early'` (stays `'early'` after answer) | `channel` |
285
+ | `'script_dial'` | script (`sip.makeCall`) | nothing — no leg exists yet | `'dialing'` (stays `'dialing'` after the script dials) | the **returned** `leg` |
286
+
287
+ Copy-paste: [outbound-on-answer.ts](./examples/outbound-on-answer.ts),
288
+ [outbound-from-invite.ts](./examples/outbound-from-invite.ts),
289
+ [outbound-script-dial.ts](./examples/outbound-script-dial.ts).
290
+
291
+ #### `'on_answer'` — start on answer (default)
292
+
293
+ The host sends the INVITE and **holds the script** until the remote party answers (`200 OK`). Busy,
294
+ no-answer, reject, or the dialer timeout never reach the live handler: the campaign writes
295
+ `call.result` and may run `after_call_failed` / recall instead.
296
+
297
+ Use this when the script is a conversation: greeting, turn watchdog, max-duration timer. They all
298
+ assume a human is already on the line. Existing agents that do not pass options keep this behavior.
299
+
300
+ ```ts
301
+ import { defineScript, getScriptPhase } from '@voctiv/agent-sdk';
302
+
303
+ export default defineScript(async ({ channel, context }) => {
304
+ // Live outbound: always 'online'. Headless before_call / after_call_* still apply.
305
+ if (getScriptPhase(context) !== 'online') return;
306
+
307
+ await channel.audio.say('Hello!');
308
+ const asr = await channel.createAsr();
309
+ });
310
+ ```
311
+
312
+ How to work with it:
313
+
314
+ - Talk on `channel` — audio, ASR, SIP waiters are already live and answered.
315
+ - Do **not** wait for `183` / `progress$`: the script was not running then.
316
+ - Do **not** call `channel.sip.makeCall()` for the campaign number; the host already dialed it.
317
+ - Inbound is unchanged: the same export still sees `'online'` (or `'early'` only if you also opted
318
+ into `'from_invite'`).
319
+
320
+ #### `'from_invite'` — script first, host dials
321
+
322
+ The host starts the script **before** any INVITE, then automatically dials the campaign number on
323
+ the same `channel`. Subscribe first, then wait: 180/183 arrive on `progress$` / `sipSignal$`, and a
324
+ 4xx/5xx rejects `waitForAnswer()` with the SIP result in the message (`486 Busy Here`). The
325
+ greeting still belongs after the answer — otherwise it plays into ringback.
326
+
327
+ ```ts
328
+ import { defineScript, getScriptPhase } from '@voctiv/agent-sdk';
329
+
330
+ export default defineScript(
331
+ async ({ channel, logger, context }) => {
332
+ if (getScriptPhase(context) === 'early') {
333
+ channel.sip.progress$.subscribe(({ statusCode }) => {
334
+ logger.log('progress', { statusCode });
335
+ });
336
+ try {
337
+ await channel.sip.waitForAnswer();
338
+ } catch (err) {
339
+ logger.warn('outbound failed', { err });
340
+ return;
341
+ }
342
+ }
343
+
344
+ await channel.audio.say('Hello!');
345
+ },
346
+ { outboundCallMode: 'from_invite' },
347
+ );
348
+ ```
349
+
350
+ How to work with it:
351
+
352
+ - Talk on `channel` — the host already dialed; do **not** call `makeCall()` for the campaign number.
353
+ - `getScriptPhase` is `'early'` for the whole live run (it does not flip on `200 OK`). Read
354
+ `channel.sip.isAnswered` / `answered$` for the live state.
355
+ - `audio.say` / `createAsr` wait until the leg has media, then run on this channel.
356
+ - On inbound, `'from_invite'` still reports `'early'` when the session starts before the answer.
357
+ - Unlike `'on_answer'`, a 4xx/5xx **is** visible in this handler — catch `waitForAnswer()` or subscribe
358
+ to `sipSignal$` / `events.terminated$`.
359
+
360
+ #### `'script_dial'` — dial the leg yourself
361
+
362
+ The host does **not** send an INVITE. The script starts with no leg: `sip.state === 'idle'`, no
363
+ audio, phase `'dialing'`. It opens the leg itself and talks on the channel that **comes back** —
364
+ the same shape as a `makeCall()` B-leg. The starting `channel` can only dial.
365
+
366
+ ```ts
367
+ import { defineScript, getScriptPhase } from '@voctiv/agent-sdk';
368
+
369
+ export default defineScript(
370
+ async ({ channel, logger, context }) => {
371
+ if (getScriptPhase(context) !== 'dialing') return;
372
+
373
+ // Empty options: campaign msisdn + the call row's trunk.
374
+ const leg = await channel.sip.makeCall({});
375
+
376
+ leg.sip.progress$.subscribe(({ statusCode }) => {
377
+ logger.log('progress', { statusCode });
378
+ });
379
+ await leg.sip.waitForAnswer();
380
+
381
+ await leg.audio.say('Hello!');
382
+ const asr = await leg.createAsr();
383
+ },
384
+ { outboundCallMode: 'script_dial' },
385
+ );
386
+ ```
387
+
388
+ How to work with it:
389
+
390
+ - **Conversation is on `leg`, not on `channel`.** `channel.audio.say` / `createAsr` before
391
+ `makeCall` are no-ops with a warning. `channel.sip.waitForAnswer()` rejects — there is no leg.
392
+ - `makeCall({})` dials the campaign number over the row's trunk. Pass `msisdn` (and `channel` for a
393
+ different trunk) to dial somewhere else, exactly as in a live session.
394
+ - `makeCall` **resolves on media** (183/200) and **rejects** when the leg fails (`486 Busy Here`) or
395
+ times out. A retry loop is `try` / `catch`, not a race on `terminated$`.
396
+ - The **first** successful `makeCall` is the one the platform reports on: `call.result`,
397
+ `answer_date`, duration, recording, transcription. Later legs are B-legs. A run that never dials
398
+ leaves the row with `-ERR Script dialed no call`.
399
+ - The dialer's answer timeout no longer applies to the campaign row. The worker slot is held from
400
+ the moment the row is claimed until the script returns. Returning hangs up any leg still up.
401
+ - `getScriptPhase` stays `'dialing'` after the script opens a leg. Gate on it to decide *whether*
402
+ to dial; use `leg.sip.isAnswered` for *whether someone picked up*.
403
+
404
+ An existing agent that already talks on `ctx.channel` does not need a rewrite: take the returned
405
+ leg once, then keep the rest of the script as-is.
406
+
407
+ ```ts
408
+ import { defineScript, getScriptPhase } from '@voctiv/agent-sdk';
409
+
410
+ export default defineScript(
411
+ async (ctx) => {
412
+ const phase = getScriptPhase(ctx.context);
413
+ const channel =
414
+ phase === 'dialing' ? await ctx.channel.sip.makeCall({}) : ctx.channel;
415
+ await channel.audio.say('Hello!');
416
+ },
417
+ { outboundCallMode: 'script_dial' },
418
+ );
419
+ ```
420
+
203
421
  ### Inbound Pre-Answer
204
422
 
205
423
  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.
@@ -234,14 +452,16 @@ carrier routing, diversion chains, and vendor SDP attributes.
234
452
 
235
453
  #### When to use what
236
454
 
237
- | Need | API | When |
238
- | --- | --- | --- |
239
- | Routing / identity / locale from the **inbound INVITE** (`Diversion`, `X-Trunk-Id`, `X-Neuro-UUID`, `X-language`, …) | `channel.sip.inviteSipHeaders` | Call **start** only (snapshot) |
240
- | Peer signal **during** the call (e.g. mid-call language in INFO body) | `channel.sip.sipInfo$` | After subscribe; each INFO |
241
- | DTMF digits | `channel.sip.dtmf$` | Prefer over parsing INFO |
242
- | SDP / codec / connection | `remoteSdp` / `getRemoteSdpDetails()` | May change (183 / 200 / re-INVITE) |
243
- | SIP response code / phrase (180, 486, ) | `sipSignal$` | Low-level; **no** response headers |
244
- | Live SIP headers on 200 / re-INVITE / BYE | | **Not exposed** |
455
+
456
+ | Need | API | When |
457
+ | -------------------------------------------------------------------------------------------------------------------- | ------------------------------------- | ---------------------------------- |
458
+ | Routing / identity / locale from the **inbound INVITE** (`Diversion`, `X-Trunk-Id`, `X-Neuro-UUID`, `X-language`, ) | `channel.sip.inviteSipHeaders` | Call **start** only (snapshot) |
459
+ | Peer signal **during** the call (e.g. mid-call language in INFO body) | `channel.sip.sipInfo$` | After subscribe; each INFO |
460
+ | DTMF digits | `channel.sip.dtmf$` | Prefer over parsing INFO |
461
+ | SDP / codec / connection | `remoteSdp` / `getRemoteSdpDetails()` | May change (183 / 200 / re-INVITE) |
462
+ | SIP response code / phrase (180, 486, …) | `sipSignal$` | Low-level; **no** response headers |
463
+ | Live SIP headers on 200 / re-INVITE / BYE | — | **Not exposed** |
464
+
245
465
 
246
466
  Decision guide:
247
467
 
@@ -260,10 +480,12 @@ Outbound INVITE headers you **send** go through `platform.call({ protoAdditional
260
480
  Snapshot of SIP headers from an **inbound INVITE** at call setup. Includes standard and
261
481
  extension headers (`Diversion`, `P-Asserted-Identity`, `X-Trunk-Id`, `X-Neuro-UUID`, `X-language`, …).
262
482
 
263
- | Property | Updates during call? |
264
- | --- | --- |
483
+
484
+ | Property | Updates during call? |
485
+ | ------------------ | ---------------------------------------------------------------------------------- |
265
486
  | `inviteSipHeaders` | **No** — INVITE snapshot only; outbound B-legs / WS / headless usually `undefined` |
266
487
 
488
+
267
489
  ```ts
268
490
  const h = channel.sip.inviteSipHeaders;
269
491
  const diversion = h?.Diversion; // string | string[] when multiple hops
@@ -276,10 +498,12 @@ Header names match what the host stack exposes (case-sensitive). Duplicate heade
276
498
 
277
499
  #### Remote SDP (`remoteSdp`, `getRemoteSdpDetails()`)
278
500
 
279
- | Property / method | Updates during call? |
280
- | --- | --- |
281
- | `remoteSdp` | **Yes** — latest negotiated remote SDP (INVITE, 183, 200, re-INVITE) |
282
- | `getRemoteSdpDetails()` | **Yes** — re-parses current `remoteSdp` on each call |
501
+
502
+ | Property / method | Updates during call? |
503
+ | ----------------------- | -------------------------------------------------------------------- |
504
+ | `remoteSdp` | **Yes** — latest negotiated remote SDP (INVITE, 183, 200, re-INVITE) |
505
+ | `getRemoteSdpDetails()` | **Yes** — re-parses current `remoteSdp` on each call |
506
+
283
507
 
284
508
  ```ts
285
509
  const raw = channel.sip.remoteSdp;
@@ -309,11 +533,13 @@ Events before subscribe are not replayed.
309
533
 
310
534
  #### Relating streams to SDP
311
535
 
312
- | Stream | `sdp` field |
313
- | --- | --- |
314
- | `progress$` | Present when a 1xx response carries SDP (e.g. 183 early media) |
536
+
537
+ | Stream | `sdp` field |
538
+ | ------------ | ------------------------------------------------------------------------- |
539
+ | `progress$` | Present when a 1xx response carries SDP (e.g. 183 early media) |
315
540
  | `sipSignal$` | Present only on callbacks that include SDP; often empty on final `active` |
316
541
 
542
+
317
543
  For the **persisted** negotiated SDP, use `remoteSdp` / `getRemoteSdpDetails()`, not only
318
544
  the per-event `sdp` on `sipSignal$`.
319
545
 
@@ -340,31 +566,33 @@ unnoticed. Guard them with `context.headless` or `channel.type` when a script ru
340
566
  SIP-only unless noted. WS/headless: most methods are no-ops and `state` behaves as synthetic
341
567
  `active`, except `makeCall()` and `bridge()`, which throw.
342
568
 
343
- | Member | Description |
344
- | --- | --- |
345
- | `state` | Sync getter: `idle` \| `ringing` \| `early` \| `active` \| `holding` \| `terminated` |
346
- | `isAnswered` | `true` after 200 OK (outbound received / inbound sent via `answer()`) |
347
- | `state$` | Emits on every state transition |
348
- | `progress$` | SIP 1xx provisional responses (`SipProgressEvent`) |
349
- | `early$` | Emits once when RTP is up before final answer |
350
- | `answered$` | Emits once on 200 OK |
351
- | `dtmf$` | Remote DTMF digits (`DtmfEvent`: `digit`, `duration`) |
352
- | `sipInfo$` | Mid-call SIP INFO (`contentType` + `body` only; INFO headers not exposed) |
353
- | `sipSignal$` | Low-level stack events (`statusCode` / `statusPhrase` / optional `sdp`; no headers) |
354
- | `remoteSdp` | Latest negotiated remote SDP body; updates when new SDP arrives |
355
- | `inviteSipHeaders` | Start-of-call inbound INVITE header snapshot; does not update |
356
- | `getRemoteSdpDetails()` | Parse `remoteSdp` `ParsedSdpDetails` (session + `a=` attributes) |
357
- | `sendProgress()` | Inbound: send 183 Session Progress `early` |
358
- | `waitForEarly()` | Await `early` or `active` (Promise) |
359
- | `waitForAnswer()` | Await final 200 OK (Promise) |
360
- | `answer()` | Inbound: send final 200 OK → `active` |
361
- | `sendDtmf(digit, duration?)` | Send DTMF tone |
362
- | `sendInfo(contentType, body)` | Send SIP INFO |
363
- | `hold()` / `unhold()` | SIP hold |
364
- | `mute()` / `unmute()` | Suppress local outgoing audio |
365
- | `hangup()` | Terminate call |
366
- | `makeCall(opts)` | Outbound B-leg (`MediaChannel`); `sipUri` or `msisdn` |
367
- | `bridge(other)` | Cross-connect two SIP calls; returns teardown `() => void` |
569
+
570
+ | Member | Description |
571
+ | ----------------------------- | ----------------------------------------------------------------------------------- |
572
+ | `state` | Sync getter: `idle` | `ringing` | `early` | `active` | `holding` | `terminated` |
573
+ | `isAnswered` | `true` after 200 OK (outbound received / inbound sent via `answer()`) |
574
+ | `state$` | Emits on every state transition |
575
+ | `progress$` | SIP 1xx provisional responses (`SipProgressEvent`) |
576
+ | `early$` | Emits once when RTP is up before final answer |
577
+ | `answered$` | Emits once on 200 OK |
578
+ | `dtmf$` | Remote DTMF digits (`DtmfEvent`: `digit`, `duration`) |
579
+ | `sipInfo$` | Mid-call SIP INFO (`contentType` + `body` only; INFO headers not exposed) |
580
+ | `sipSignal$` | Low-level stack events (`statusCode` / `statusPhrase` / optional `sdp`; no headers) |
581
+ | `remoteSdp` | Latest negotiated remote SDP body; updates when new SDP arrives |
582
+ | `inviteSipHeaders` | Start-of-call inbound INVITE header snapshot; does not update |
583
+ | `getRemoteSdpDetails()` | Parse `remoteSdp` `ParsedSdpDetails` (session + `a=` attributes) |
584
+ | `sendProgress()` | Inbound: send 183 Session Progress → `early` |
585
+ | `waitForEarly()` | Await `early` or `active` (Promise) |
586
+ | `waitForAnswer()` | Await final 200 OK (Promise) |
587
+ | `answer()` | Inbound: send final 200 OK → `active` |
588
+ | `sendDtmf(digit, duration?)` | Send DTMF tone |
589
+ | `sendInfo(contentType, body)` | Send SIP INFO |
590
+ | `hold()` / `unhold()` | SIP hold |
591
+ | `mute()` / `unmute()` | Suppress local outgoing audio |
592
+ | `hangup()` | Terminate call |
593
+ | `makeCall(opts)` | Outbound B-leg (`MediaChannel`); `sipUri` or `msisdn` |
594
+ | `bridge(other)` | Cross-connect two SIP calls; returns teardown `() => void` |
595
+
368
596
 
369
597
  Prefer `dtmf$` over `sipInfo$` for DTMF. Prefer `state$` / `early$` / `answered$` over raw
370
598
  `sipSignal$` for call lifecycle. Use `remoteSdp` / `getRemoteSdpDetails()` for negotiated
@@ -523,20 +751,22 @@ const asr = await channel.createAsr({
523
751
 
524
752
  Each vendor connector accepts its native parameter names:
525
753
 
526
- | Vendor | Accepted `data` keys |
527
- | --- | --- |
528
- | **Azure** | `subscription_key` or `api_key`, `region` |
529
- | **Yandex** | `api_key` or `token`, `folder_id` |
530
- | **ElevenLabs** | `api_key` (or `xi_api_key`), `model` |
531
- | **Deepgram** | `api_key` |
532
- | **Google** | `email`, `private_key`, `project_id` |
533
- | **Whisper** | `url`, `rate`, `toFloat` |
754
+
755
+ | Vendor | Accepted `data` keys |
756
+ | -------------- | ----------------------------------------- |
757
+ | **Azure** | `subscription_key` or `api_key`, `region` |
758
+ | **Yandex** | `api_key` or `token`, `folder_id` |
759
+ | **ElevenLabs** | `api_key` (or `xi_api_key`), `model` |
760
+ | **Deepgram** | `api_key` |
761
+ | **Google** | `email`, `private_key`, `project_id` |
762
+ | **Whisper** | `url`, `rate`, `toFloat` |
763
+
534
764
 
535
765
  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.
536
766
 
537
767
  ### Platform ASR Key Selection
538
768
 
539
- When platform credential catalogs are enabled, select ASR keys by **`name`**:
769
+ When platform credential catalogs are enabled, select ASR keys by `name`:
540
770
 
541
771
  ```ts
542
772
  const asr = await channel.createAsr({
@@ -551,7 +781,7 @@ When both `name` (platform key) and explicit `data` are provided, `data` values
551
781
 
552
782
  ## TTS, Playback, And Mixer Queues
553
783
 
554
- All audio playback goes through **`channel.audio`** (`ChannelAudio`). There are no top-level
784
+ All audio playback goes through `channel.audio` (`ChannelAudio`). There are no top-level
555
785
  `channel.say()` / `channel.play()` shortcuts on `MediaChannel`.
556
786
 
557
787
  Create a reusable TTS session with `channel.createTts(config?)` — same pattern as `createAsr`.
@@ -615,20 +845,22 @@ terminate the Observable via **error** (`MediaError`), and are also mirrored on
615
845
  You can still use `channel.audio.say(..., { tts })` if you prefer the channel API; a matching
616
846
  pre-warmed session is also reused when vendor+config align.
617
847
 
618
- | Method | Purpose |
619
- | --- | --- |
620
- | `channel.createTts(config?)` | Pre-warm a TTS connector / streaming socket; returns `TtsHandle` |
621
- | `tts.say(textOrObservable, options?)` | Synthesize via the handle's cached connection (`Promise`) |
622
- | `tts.say$(textOrObservable, options?)` | Same path with per-utterance status events (`Observable`) |
623
- | `tts.presay(text, options?)` | Pre-synthesize into the host TTS cache via the handle |
624
- | `channel.audio.say(textOrObservable, options?)` | Synthesize text with TTS and play on a mixer queue |
625
- | `channel.audio.play(source, options?)` | Play raw audio (URL, path, or platform phrase record) |
626
- | `channel.audio.presay(text, options?)` | Pre-synthesize TTS into the host cache (no playback) |
627
- | `channel.audio.preload(source, options?)` | Decode/warm a raw audio source (no playback) |
628
- | `channel.audio.queue(index)` | Per-queue control handle (`MixerQueueControl`) |
629
- | `channel.audio.remove(alias, queue?)` | Remove one queued item by alias |
630
- | `channel.audio.stop(queue)` | Clear a queue **and** abort in-flight sentence TTS for it |
631
- | `channel.audio.stopAll()` | Clear every queue (WS clients also get an audio interrupt) |
848
+
849
+ | Method | Purpose |
850
+ | ----------------------------------------------- | ---------------------------------------------------------------- |
851
+ | `channel.createTts(config?)` | Pre-warm a TTS connector / streaming socket; returns `TtsHandle` |
852
+ | `tts.say(textOrObservable, options?)` | Synthesize via the handle's cached connection (`Promise`) |
853
+ | `tts.say$(textOrObservable, options?)` | Same path with per-utterance status events (`Observable`) |
854
+ | `tts.presay(text, options?)` | Pre-synthesize into the host TTS cache via the handle |
855
+ | `channel.audio.say(textOrObservable, options?)` | Synthesize text with TTS and play on a mixer queue |
856
+ | `channel.audio.play(source, options?)` | Play raw audio (URL, path, or platform phrase record) |
857
+ | `channel.audio.presay(text, options?)` | Pre-synthesize TTS into the host cache (no playback) |
858
+ | `channel.audio.preload(source, options?)` | Decode/warm a raw audio source (no playback) |
859
+ | `channel.audio.queue(index)` | Per-queue control handle (`MixerQueueControl`) |
860
+ | `channel.audio.remove(alias, queue?)` | Remove one queued item by alias |
861
+ | `channel.audio.stop(queue)` | Clear a queue **and** abort in-flight sentence TTS for it |
862
+ | `channel.audio.stopAll()` | Clear every queue (WS clients also get an audio interrupt) |
863
+
632
864
 
633
865
  `channel.audio.say(textOrObservable, options?)` synthesizes text and plays it through the mixer.
634
866
 
@@ -661,6 +893,8 @@ await channel.audio.play('/opt/prompts/welcome.wav', {
661
893
  });
662
894
  ```
663
895
 
896
+
897
+
664
898
  ### Pre-synthesis And Preload
665
899
 
666
900
  `channel.audio.presay(text, options?)` runs TTS ahead of time and stores PCM in the host TTS
@@ -708,26 +942,28 @@ When using an `Observable<string>` input, WS clients also receive text progress
708
942
 
709
943
  ### Mixer Queues
710
944
 
711
- The mixer has queues **`0` through `4`**. Use separate queues for main speech, earcons, hold
945
+ The mixer has queues `0` **through** `4`. Use separate queues for main speech, earcons, hold
712
946
  music, or background audio so barge-in on one queue does not cut unrelated audio.
713
947
 
714
- Obtain a per-queue handle with **`channel.audio.queue(index)`** (`MixerQueueControl`):
948
+ Obtain a per-queue handle with `channel.audio.queue(index)` (`MixerQueueControl`):
715
949
 
716
- | Member | Description |
717
- | --- | --- |
718
- | `index` | Queue index **0–4** |
719
- | `volume` | Linear gain **0.0–1.0** for the entire queue (get/set) |
720
- | `itemStarted$` | Emits item **`alias`** when playback starts |
721
- | `itemFinished$` | Emits **`alias`** when an item finishes, is removed, or is skipped by clear |
722
- | `queueEmpty$` | Emits when the queue is empty after all PCM has been mixed out |
723
- | `remove(alias)` | Drop one item on this queue |
724
- | `clear()` | Drop all items on this queue (does **not** abort in-flight TTS generation) |
725
950
 
726
- Top-level helpers on **`channel.audio`**:
951
+ | Member | Description |
952
+ | --------------- | -------------------------------------------------------------------------- |
953
+ | `index` | Queue index **0–4** |
954
+ | `volume` | Linear gain **0.0–1.0** for the entire queue (get/set) |
955
+ | `itemStarted$` | Emits item `alias` when playback starts |
956
+ | `itemFinished$` | Emits `alias` when an item finishes, is removed, or is skipped by clear |
957
+ | `queueEmpty$` | Emits when the queue is empty after all PCM has been mixed out |
958
+ | `remove(alias)` | Drop one item on this queue |
959
+ | `clear()` | Drop all items on this queue (does **not** abort in-flight TTS generation) |
960
+
727
961
 
728
- - **`remove(alias, queue?)`** — when `queue` is omitted, searches all five queues; when set, only that queue is checked.
729
- - **`stop(queue)`** — same as `clear()` **plus** aborts in-flight sentence TTS for that queue.
730
- - **`stopAll()`** — `stop()` on every queue; WS clients also receive an audio interrupt signal.
962
+ Top-level helpers on `channel.audio`:
963
+
964
+ - `remove(alias, queue?)`when `queue` is omitted, searches all five queues; when set, only that queue is checked.
965
+ - `stop(queue)` — same as `clear()` **plus** aborts in-flight sentence TTS for that queue.
966
+ - `stopAll()` — `stop()` on every queue; WS clients also receive an audio interrupt signal.
731
967
 
732
968
  ```ts
733
969
  const tts = channel.audio.queue(0);
@@ -765,34 +1001,89 @@ For sentence-split TTS, queue item aliases are suffixed as `alias-0`, `alias-1`,
765
1001
 
766
1002
  Shared by `say()`, `play()`, and (where noted) `presay()`:
767
1003
 
768
- | Field | Type | Applies to | Description |
769
- | --- | --- | --- | --- |
770
- | `queue` | `number?` | `say`, `play` | Mixer queue **0–4** (default **0**). |
771
- | `alias` | `string?` | `say`, `play` | Stable item id for `remove()` and queue events. |
772
- | `loop` | `boolean?` | `say`, `play` | Restart after finish until stopped/removed. |
773
- | `loopDelayMs` | `number?` | `say`, `play` | Silence between loop iterations. |
774
- | `volume` | `number?` | `say`, `play` | Sets **whole queue** gain **0.0–1.0** (not per-item). |
775
- | `ttsStrategy` | `TtsStrategy?` | `say`, `presay` | `sentence` \| `streaming` \| `full`. |
776
- | `ttsVendor` | `TtsVendor?` | `say`, `presay` | Override `channel.params.ttsVendor`. |
777
- | `name` | `string?` | `say`, `presay` | Platform TTS credential **`name`** (key catalog selector). |
778
- | `ttsConfig` | `Record<string, unknown>?` | `say`, `presay` | Vendor params; `name` key is stripped before send. |
779
- | `cache` | `true \| CacheOptions?` | `say`, `presay` | TTS file cache; optional platform phrase persist. |
1004
+
1005
+ | Field | Type | Applies to | Description |
1006
+ | ------------- | -------------------------- | --------------- | ------------------------------------------------------ |
1007
+ | `queue` | `number?` | `say`, `play` | Mixer queue **0–4** (default **0**). |
1008
+ | `alias` | `string?` | `say`, `play` | Stable item id for `remove()` and queue events. |
1009
+ | `loop` | `boolean?` | `say`, `play` | Restart after finish until stopped/removed. |
1010
+ | `loopDelayMs` | `number?` | `say`, `play` | Silence between loop iterations. |
1011
+ | `volume` | `number?` | `say`, `play` | Sets **whole queue** gain **0.0–1.0** (not per-item). |
1012
+ | `ttsStrategy` | `TtsStrategy?` | `say`, `presay` | `sentence` | `streaming` | `full`. |
1013
+ | `ttsVendor` | `TtsVendor?` | `say`, `presay` | Override `channel.params.ttsVendor`. |
1014
+ | `name` | `string?` | `say`, `presay` | Platform TTS credential `name` (key catalog selector). |
1015
+ | `ttsConfig` | `Record<string, unknown>?` | `say`, `presay` | Vendor params; `name` key is stripped before send. |
1016
+ | `cache` | `true | CacheOptions?` | `say`, `presay` | TTS file cache; optional platform phrase persist. |
1017
+
780
1018
 
781
1019
  `play()` ignores `tts*` and `cache` for raw audio. `preload()` only accepts `cache` overrides.
782
1020
 
783
1021
  ## Custom ASR / TTS Providers
784
1022
 
785
- Trusted logic packages can ship their own ASR/TTS engines next to `defineScript`. The host
786
- `require`s a static **`export const mediaProviders`** **outside** the script sandbox (same
787
- process privileges as the API). Use this only for packages you trust.
1023
+ Trusted logic packages can ship their own ASR/TTS engines. The host loads
1024
+ `export const mediaProviders` with a normal Node `require` **outside** the script VM
1025
+ (same privileges as the API). Use this only for packages you trust.
788
1026
 
789
1027
  Mid-script `registerAsr` / `registerTts` is **not** supported — declare vendors at module load.
790
1028
 
791
- ### Export shape
1029
+ ### Package layout (required for connectors that use `ws` / Node APIs)
1030
+
1031
+ Put providers in a **sibling module**. Do **not** re-export them from the sandboxed entry
1032
+ (`dist/index.js`). The script worker evaluates the entry under a restricted VM: there is
1033
+ **no** global `process`, and loading `ws` (or similar) inside that graph fails even when
1034
+ `net` / `tls` are allowlisted.
1035
+
1036
+ Recommended layout after `tsc`:
1037
+
1038
+ ```text
1039
+ dist/
1040
+ index.js ← defineScript only (sandboxed)
1041
+ media-providers/
1042
+ index.js ← export const mediaProviders (host-only)
1043
+ my-asr.js
1044
+ my-tts.js
1045
+ ```
1046
+
1047
+ Host resolution order for `mediaProviders`:
1048
+
1049
+ 1. `<entryDir>/media-providers/index.js`
1050
+ 2. `<entryDir>/media-providers.js`
1051
+ 3. `<scriptRoot>/media-providers/…` and `dist/media-providers/…` fallbacks
1052
+ 4. the script entry itself (backward compatible — avoid for `ws`-based connectors)
1053
+
1054
+ Sandboxed script code may import **lightweight** helpers from the providers package
1055
+ (e.g. vendor id constants) if those files do **not** `require('ws')` / touch `process`.
1056
+ Never import the connector classes or `mediaProviders` index from the entry.
1057
+
1058
+ Examples:
1059
+
1060
+ - Host module: [`examples/custom-media-providers.ts`](./examples/custom-media-providers.ts)
1061
+ - Script entry: [`examples/custom-media-providers-script.ts`](./examples/custom-media-providers-script.ts)
1062
+
1063
+ ### Contracts (implement the interfaces)
1064
+
1065
+ There are no abstract base classes — implement the SDK interfaces so TypeScript checks
1066
+ the full surface and IDEs autocomplete correctly:
1067
+
1068
+ | Interface | Role |
1069
+ | --- | --- |
1070
+ | `ScriptAsrConnector` | Custom STT connector returned by an `asr` factory |
1071
+ | `ScriptAsrConnectorError` | Error payload on `ScriptAsrConnector.error$` |
1072
+ | `ScriptTtsConnector` | Unified batch + optional streaming TTS connector |
1073
+ | `ScriptTtsSynthesisContext` | Synthesis context passed into a TTS factory (`TtsSynthesisContext`) |
1074
+ | `MediaConnectorContext` | `id`, flattened `config`, `dialogUuid`, `debug$` passed into factories |
1075
+ | `MediaProviderShared` | Optional per-dialog object from `createShared` |
1076
+ | `AsrProviderFactory` / `TtsProviderFactory` | Factory functions in `MediaProvidersDefinition` (`asr` / `tts` maps) |
1077
+
1078
+ `defineMediaProviders` is an identity helper for typing. Factories must return objects that
1079
+ satisfy those interfaces (classes with `implements` recommended). The argument is a
1080
+ `MediaProvidersDefinition`.
1081
+
1082
+ ### Export shape (host module)
792
1083
 
793
1084
  ```ts
1085
+ // media-providers/index.ts — host-only (may use ws, https, process.env, …)
794
1086
  import {
795
- defineScript,
796
1087
  defineMediaProviders,
797
1088
  type MediaConnectorContext,
798
1089
  type MediaProviderShared,
@@ -802,6 +1093,9 @@ import {
802
1093
  import { Subject } from 'rxjs';
803
1094
  import { Readable } from 'stream';
804
1095
 
1096
+ class MyAsr implements ScriptAsrConnector { /* … */ }
1097
+ class MyTts implements ScriptTtsConnector { /* … */ }
1098
+
805
1099
  export const mediaProviders = defineMediaProviders({
806
1100
  // Optional: one object per dialog, shared by ASR + TTS factories
807
1101
  createShared: (ctx) => ({
@@ -815,17 +1109,24 @@ export const mediaProviders = defineMediaProviders({
815
1109
  'my-tts': (ctx, shared) => new MyTts(ctx, shared),
816
1110
  },
817
1111
  });
1112
+ ```
1113
+
1114
+ ```ts
1115
+ // index.ts — sandboxed entry (do NOT import ./media-providers or ws)
1116
+ import { defineScript } from '@voctiv/agent-sdk';
818
1117
 
819
1118
  export default defineScript(async ({ channel }) => {
1119
+ // Credentials: pass via createAsr/createTts `data` (or platform/channel params).
1120
+ // Do not rely on process.env here — `process` is not defined in the script VM.
820
1121
  const asr = await channel.createAsr({
821
1122
  vendor: 'my-asr',
822
1123
  language: 'ru-RU',
823
- data: { api_key: process.env.MY_ASR_KEY! },
1124
+ data: { api_key: String(channel.params.api_key ?? '') },
824
1125
  });
825
1126
  const tts = await channel.createTts({
826
1127
  vendor: 'my-tts',
827
1128
  data: {
828
- api_key: process.env.MY_TTS_KEY!,
1129
+ api_key: String(channel.params.api_key ?? ''),
829
1130
  voice_id: '…',
830
1131
  output_format: 'pcm_16000', // preferred for telephony
831
1132
  },
@@ -836,22 +1137,19 @@ export default defineScript(async ({ channel }) => {
836
1137
  ttsStrategy: 'streaming', // used when supportsStreaming() === true
837
1138
  });
838
1139
 
839
- tts.say$('One. Two.', { alias: 'reply' }).subscribe((ev) => {
1140
+ tts.say$('One. Two.', { alias: 'reply' }).subscribe((_ev) => {
840
1141
  // queued | speaking | done | cancelled — same events as builtin TTS
841
1142
  });
842
1143
 
843
- // Reuse the warm handle:
844
- await channel.audio.say('Again', { tts });
1144
+ await channel.audio.say('Again', { tts }); // warm handle reuse
845
1145
 
846
1146
  asr.destroy();
847
1147
  tts.destroy();
848
1148
  });
849
1149
  ```
850
1150
 
851
- `defineMediaProviders` is an identity helper for typing. The host looks up
852
- `module.exports.mediaProviders` (or `default.mediaProviders`) on the script entry file.
853
-
854
- Full stub: [`examples/custom-media-providers.ts`](./examples/custom-media-providers.ts).
1151
+ Host-side factories **may** read `process.env` (e.g. `ELEVENLABS_API_KEY`) because they run
1152
+ outside the VM. Prefer also accepting the same keys via `ctx.config` from `data`.
855
1153
 
856
1154
  ### Implement ASR (`ScriptAsrConnector`)
857
1155
 
@@ -925,7 +1223,10 @@ class MyTts implements ScriptTtsConnector {
925
1223
  return true; // or false for HTTP-only
926
1224
  }
927
1225
 
928
- async textToSpeechStream(rawtext: string, _ctx?: { previousText?: string; nextText?: string }) {
1226
+ async textToSpeechStream(
1227
+ rawtext: string,
1228
+ _ctx?: { previousText?: string; nextText?: string },
1229
+ ) {
929
1230
  // Return PCM (preferred) or MP3/OGG. Hint format via createTts data:
930
1231
  // output_format: 'pcm_16000' | 'mp3_…' or audioFormat: 'pcm' | 'mp3'
931
1232
  return Readable.from([/* bytes */]);
@@ -946,12 +1247,12 @@ class MyTts implements ScriptTtsConnector {
946
1247
  ### Using custom vendors in the script
947
1248
 
948
1249
  ```ts
949
- const asr = await channel.createAsr({ vendor: 'my-asr', data: { … } });
950
- const tts = await channel.createTts({ vendor: 'my-tts', data: { … } });
1250
+ const asr = await channel.createAsr({ vendor: 'my-asr', data: { /* */ } });
1251
+ const tts = await channel.createTts({ vendor: 'my-tts', data: { /* */ } });
951
1252
 
952
1253
  await tts.say('Hi', { alias: 'greet' });
953
1254
  await tts.presay('Warm cache');
954
- tts.say$('Next', { alias: 'reply', ttsStrategy: 'streaming' }).subscribe(…);
1255
+ tts.say$('Next', { alias: 'reply', ttsStrategy: 'streaming' }).subscribe(/* */);
955
1256
 
956
1257
  await channel.audio.say('Reuse', { tts }); // same warm session
957
1258
  ```
@@ -970,14 +1271,24 @@ Vendor keys must be **multi-character** names. Builtin single-letter codes
970
1271
  Call `createAsr` / `createTts` early to warm sockets. Repeated `say` with the same handle (or
971
1272
  matching fingerprint) must **not** open a new TCP/WS per utterance.
972
1273
 
1274
+ The host also loads the same `mediaProviders` module into the **pipeline worker** process
1275
+ (separate `require`) when `PIPELINE_WORKERS` is enabled — keep the module side-effect free
1276
+ aside from exporting factories.
1277
+
973
1278
  ### Security
974
1279
 
975
1280
  - Host `require` of `mediaProviders` runs with API privileges — **trusted packages only**.
976
- - Entry path is restricted to the script root (path traversal denied).
1281
+ - Keep connector/`ws` code out of the sandboxed entry so the VM never evaluates it.
1282
+ - The script VM does **not** expose `process` (no `process.env` in `defineScript`).
1283
+ - Entry / provider paths are restricted to the script root (path traversal denied).
977
1284
  - Optional host allowlists may further restrict which packages may export providers.
978
1285
 
1286
+
1287
+
979
1288
  ## TTS Credentials And Vendor Parameters
980
1289
 
1290
+
1291
+
981
1292
  ### Direct TTS Vendor Parameters
982
1293
 
983
1294
  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.
@@ -997,10 +1308,12 @@ await channel.audio.say('Hello!', {
997
1308
 
998
1309
  Each TTS vendor connector accepts its native parameter names:
999
1310
 
1000
- | Vendor | Accepted `ttsConfig` keys |
1001
- | --- | --- |
1311
+
1312
+ | Vendor | Accepted `ttsConfig` keys |
1313
+ | -------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
1002
1314
  | **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` |
1003
- | **Voctiv** | `url`, `voice_id`, `language`, `emotion`, `speaking_rate`, `chunk_schedule` |
1315
+ | **Voctiv** | `url`, `voice_id`, `language`, `emotion`, `speaking_rate`, `chunk_schedule` |
1316
+
1004
1317
 
1005
1318
  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.
1006
1319
 
@@ -1023,8 +1336,8 @@ When both `name` (platform key) and explicit `ttsConfig` values are provided, `t
1023
1336
 
1024
1337
  `cache` enables TTS result caching for `say()` and `presay()`:
1025
1338
 
1026
- - **`cache: true`** — read/write host TTS file cache.
1027
- - **`cache: { phraseName, flag?, language? }`** — TTS cache **plus** persist as a platform phrase so `platform.getRecords()` can retrieve the audio later.
1339
+ - `cache: true` — read/write host TTS file cache.
1340
+ - `cache: { phraseName, flag?, language? }` — TTS cache **plus** persist as a platform phrase so `platform.getRecords()` can retrieve the audio later.
1028
1341
 
1029
1342
  ```ts
1030
1343
  // Cache only (no platform persist):
@@ -1074,6 +1387,8 @@ try {
1074
1387
  }
1075
1388
  ```
1076
1389
 
1390
+
1391
+
1077
1392
  ### ASR Errors — `error$` Observable
1078
1393
 
1079
1394
  Runtime ASR errors (gRPC disconnect, auth failure, quota exceeded) are emitted on `AsrHandle.error$`:
@@ -1113,20 +1428,22 @@ channel.events.error$.subscribe((err) => {
1113
1428
 
1114
1429
  `MediaError` fields:
1115
1430
 
1116
- | Field | Type | Description |
1117
- | --- | --- | --- |
1118
- | `source` | `'asr' \| 'tts' \| 'sip' \| 'channel' \| 'llm'` | Which subsystem produced the error. |
1119
- | `phase` | `'create' \| 'start' \| 'stream' \| 'playback' \| 'finalize' \| 'destroy'?` | Lifecycle phase where the error happened. |
1120
- | `operation` | `string?` | Public SDK operation, e.g. `createAsr`, `createTts`, `audio.say`, or `audio.play`. |
1121
- | `recoverable` | `boolean?` | Whether the runtime can keep the session alive after this error. |
1122
- | `handleId` | `string?` | ASR handle id when the error belongs to a recognizer instance. |
1123
- | `queue` | `number?` | Mixer queue index when the error belongs to an audio operation. |
1124
- | `alias` | `string?` | Queue item alias when the error belongs to playback. |
1125
- | `message` | `string` | Human-readable description. |
1126
- | `code` | `number \| string?` | HTTP status, gRPC status, provider code, or WebSocket close code. |
1127
- | `vendor` | `string?` | Vendor identifier, e.g. `"yandex"`, `"elevenlabs"`, `"azure"`. |
1128
- | `details` | `unknown?` | Arbitrary provider-specific payload. |
1129
- | `cause` | `unknown?` | Original underlying error when available. |
1431
+
1432
+ | Field | Type | Description |
1433
+ | ------------- | ---------------------------------------------------------------------- | ---------------------------------------------------------------------------------- |
1434
+ | `source` | `'asr' | 'tts' | 'sip' | 'channel' | 'llm'` | Which subsystem produced the error. |
1435
+ | `phase` | `'create' | 'start' | 'stream' | 'playback' | 'finalize' | 'destroy'?` | Lifecycle phase where the error happened. |
1436
+ | `operation` | `string?` | Public SDK operation, e.g. `createAsr`, `createTts`, `audio.say`, or `audio.play`. |
1437
+ | `recoverable` | `boolean?` | Whether the runtime can keep the session alive after this error. |
1438
+ | `handleId` | `string?` | ASR handle id when the error belongs to a recognizer instance. |
1439
+ | `queue` | `number?` | Mixer queue index when the error belongs to an audio operation. |
1440
+ | `alias` | `string?` | Queue item alias when the error belongs to playback. |
1441
+ | `message` | `string` | Human-readable description. |
1442
+ | `code` | `number | string?` | HTTP status, gRPC status, provider code, or WebSocket close code. |
1443
+ | `vendor` | `string?` | Vendor identifier, e.g. `"yandex"`, `"elevenlabs"`, `"azure"`. |
1444
+ | `details` | `unknown?` | Arbitrary provider-specific payload. |
1445
+ | `cause` | `unknown?` | Original underlying error when available. |
1446
+
1130
1447
 
1131
1448
  Subscribing to `error$` is optional. Old scripts that do not subscribe are not affected — the observables simply go unobserved.
1132
1449
 
@@ -1134,14 +1451,16 @@ Subscribing to `error$` is optional. Old scripts that do not subscribe are not a
1134
1451
 
1135
1452
  `channel.events` exposes session-level observables that are **not** tied to a single ASR handle:
1136
1453
 
1137
- | Observable | Emits when |
1138
- | --- | --- |
1139
- | `speechStart$` | User started speaking (VAD, socket event, or synthetic text input). |
1140
- | `speechEnd$` | User stopped speaking (VAD end, ASR final, or synthetic text input). |
1141
- | `interrupt$` | Barge-in: user speech interrupted bot audio (may be inert without VAD). |
1142
- | `terminated$` | Session ending hangup, WS disconnect, or `channel.destroy()`. |
1143
- | `message$` | Structured WS data messages (`DataMessage`: `{ event, payload }`). |
1144
- | `error$` | Unified media/runtime errors (see [Error Handling](#error-handling)). |
1454
+
1455
+ | Observable | Emits when |
1456
+ | -------------- | ----------------------------------------------------------------------- |
1457
+ | `speechStart$` | User started speaking (VAD, socket event, or synthetic text input). |
1458
+ | `speechEnd$` | User stopped speaking (VAD end, ASR final, or synthetic text input). |
1459
+ | `interrupt$` | Barge-in: user speech interrupted bot audio (may be inert without VAD). |
1460
+ | `terminated$` | Session ending hangup, WS disconnect, or `channel.destroy()`. |
1461
+ | `message$` | Structured WS data messages (`DataMessage`: `{ event, payload }`). |
1462
+ | `error$` | Unified media/runtime errors (see [Error Handling](#error-handling)). |
1463
+
1145
1464
 
1146
1465
  ```ts
1147
1466
  channel.events.speechStart$.subscribe(() => {
@@ -1166,15 +1485,24 @@ create ASR or want one subscription for the whole channel).
1166
1485
 
1167
1486
  `channel.llm` talks to the Omni LLM backend.
1168
1487
 
1169
- | Method | Returns | Description |
1170
- | --- | --- | --- |
1171
- | `ask(message, options?)` | `Promise<string>` | Single-shot completion (consumes SSE stream). |
1172
- | `stream(message, options?)` | `Observable<LlmStreamChunk>` | Token/chunk stream; use `chunk.content` for TTS. |
1173
- | `extract(options?)` | `Promise<Record<string, any>>` | Structured extraction via Omni extract API. |
1174
- | `makePersistentStream(options?)` | `PersistentLlmStreamHandle` | Long-lived stream for multi-turn chat. |
1488
+
1489
+ | Method | Returns | Description |
1490
+ | -------------------------------- | ------------------------------ | ------------------------------------------------ |
1491
+ | `ask(message, options?)` | `Promise<string>` | Single-shot completion (consumes SSE stream). |
1492
+ | `stream(message, options?)` | `Observable<LlmStreamChunk>` | Token/chunk stream; use `chunk.content` for TTS. |
1493
+ | `extract(options?)` | `Promise<Record<string, any>>` | Structured extraction via Omni extract API. |
1494
+ | `createDialog(options)` | `Promise<DialogInfo>` | Create dialog (`CreateDialogOptions`, optional `payload`) (HTTP). |
1495
+ | `updateDialog(options)` | `Promise<DialogInfo>` | Patch dialog (`UpdateDialogOptions` / `payload`) (HTTP `/meta`). |
1496
+ | `makePersistentStream(options?)` | `PersistentLlmStreamHandle` | Long-lived stream for multi-turn chat. |
1497
+
1498
+ `send()` returns a `PersistentLlmSendHandle` (`requestId`, per-turn `stream$`, `abort()`). Several sends may run in parallel on one socket.
1499
+
1500
+ Persistent handle also exposes `createDialog` / `updateDialog` over Socket.IO (`dialog.create` / `dialog.update`).
1501
+
1175
1502
 
1176
1503
  Common `LlmOptions`: `dialogUuid`, `agentUuid`, `role`, `hidden`, `name` (LLM speaker label — **not**
1177
- the TTS credential `name`), `payload`, `debug`, `agentAliasFilter`, `currentAgentAlias`.
1504
+ the TTS credential `name`), `payload`, `history`, `debug`, `agentAliasFilter`, `currentAgentAlias`,
1505
+ `pseudoReasoning` (manual same-turn hint; skips the assigned reasoner LLM), `requestId`.
1178
1506
 
1179
1507
  ```ts
1180
1508
  const answer = await channel.llm.ask('Summarize the user request', {
@@ -1210,24 +1538,54 @@ const fields = await channel.llm.extract({
1210
1538
  });
1211
1539
  ```
1212
1540
 
1213
- Persistent multi-turn stream:
1541
+ Persistent multi-turn stream (one Socket.IO connection, multiplexed by `requestId`):
1214
1542
 
1215
1543
  ```ts
1544
+ // HTTP: create / update dialog payload for prompt templates
1545
+ const created = await channel.llm.createDialog({
1546
+ agentUuid: context.agentUuid,
1547
+ payload: { customerName: 'Alex', language: 'ru' },
1548
+ });
1549
+ await channel.llm.updateDialog({
1550
+ dialogUuid: context.dialogUuid,
1551
+ payload: { ...created.payload, balance: 1200 },
1552
+ });
1553
+
1216
1554
  const chat = channel.llm.makePersistentStream({
1217
1555
  agentUuid: context.agentUuid,
1218
1556
  dialogUuid: context.dialogUuid,
1219
1557
  });
1220
1558
 
1559
+ // Same over the persistent socket
1560
+ await chat.createDialog({
1561
+ agentUuid: context.agentUuid,
1562
+ payload: { customerName: 'Alex' },
1563
+ });
1564
+ await chat.updateDialog({ payload: { customerName: 'Alex', tier: 'gold' } });
1565
+
1221
1566
  chat.stream$.pipe(map((c) => c.content)).subscribe((text) => logger.debug('LLM chunk', { text }));
1222
1567
 
1223
1568
  chat.send('What is my balance?');
1224
1569
  chat.send('And my last payment date?');
1570
+
1571
+ // Parallel agents on the same socket:
1572
+ const realtime = chat.send(userText, {
1573
+ currentAgentAlias: 'fast',
1574
+ pseudoReasoning: 'User wants to cancel. Confirm identity first.',
1575
+ });
1576
+ const deep = chat.send(userText, { currentAgentAlias: 'researcher' });
1577
+ realtime.stream$.subscribe((c) => { /* TTS */ });
1578
+ deep.stream$.subscribe((c) => { /* notes / later TTS */ });
1579
+ // deep.abort(); // cancel only the deep turn
1580
+
1225
1581
  chat.disconnect();
1226
1582
  ```
1227
1583
 
1584
+
1585
+
1228
1586
  ## Script Return Value
1229
1587
 
1230
- Scripts may return `void` or a **`ScriptResult`**:
1588
+ Scripts may return `void` or a `ScriptResult`:
1231
1589
 
1232
1590
  ```ts
1233
1591
  return {
@@ -1235,33 +1593,37 @@ return {
1235
1593
  };
1236
1594
  ```
1237
1595
 
1238
- - **`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)).
1239
- - **`error`** — optional; usually auto-populated on crash, but scripts may set it explicitly.
1596
+ - `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)).
1597
+ - `error` — optional; usually auto-populated on crash, but scripts may set it explicitly.
1240
1598
 
1241
1599
  **Do not** return `env` from the script. Persist state via `context.env$`; the runtime snapshots it
1242
- after completion into **`PersistedScriptResult.env`**.
1600
+ after completion into `PersistedScriptResult.env`.
1243
1601
 
1244
1602
  ## Script Lifecycle (`getScriptPhase`)
1245
1603
 
1246
- The host always runs your single **`defineScript` export** — there is no separate runtime entry per
1247
- name (unlike logic-executor Python `run_unit(entry_point=...)`). Use **`getScriptPhase(context)`**
1604
+ The host always runs your single `defineScript` **export** — there is no separate runtime entry per
1605
+ name (unlike logic-executor Python `run_unit(entry_point=...)`). Use `getScriptPhase(context)`
1248
1606
  to tell *why* the script is running now: live call, pre-call queue, post-call continuation,
1249
1607
  messaging, etc.
1250
1608
 
1251
- **`context.headless === true` only means “no live SIP/media”.** It does **not** tell you whether
1609
+ `context.headless === true` **only means “no live SIP/media”.** It does **not** tell you whether
1252
1610
  the run is before or after a call. For that, use `getScriptPhase`.
1253
1611
 
1254
1612
  The return value is the `ScriptPhase` union — one of the phases in the table below.
1255
1613
 
1256
- | Phase | `context.headless` | When | Typical `context.entryPoint` |
1257
- |-------|-------------------|------|--------------------------------|
1258
- | `before_call` | `true` | Dialog queue / bulk outbound **before** the first platform call (often schedules `platform.call`) | empty, `main`, `default` |
1259
- | `online` | `false` | Live SIP session (inbound, outbound, **and automatic recall redials**) | any (ignored for phase) |
1260
- | `after_call_success` | `true` | Headless run **after** a successful call | `on_success_call`, `after_call_success`, `on_done_call` |
1261
- | `after_call_failed` | `true` | Headless run **after** failed attempts (when `onFailedCall` was configured) | `on_failed_call`, `after_call_failed` |
1262
- | `messaging` | `true` | Inbound message triggered the run | `on_message_api_received`, or `context.inboundMessage` set |
1263
- | `recall` | `true` | Headless recall leg (legacy `entry_point`) | `on_recall`, `recall` |
1264
- | `headless_other` | `true` | Any other headless run with a custom `entry_point` | your custom name |
1614
+
1615
+ | Phase | `context.headless` | When | Typical `context.entryPoint` |
1616
+ | -------------------- | ------------------ | ------------------------------------------------------------------------------------------------- | ---------------------------------------------------------- |
1617
+ | `before_call` | `true` | Dialog queue / bulk outbound **before** the first platform call (often schedules `platform.call`) | empty, `main`, `default` |
1618
+ | `dialing` | `false` | Live session with **no leg yet** script opted into `outboundCallMode: 'script_dial'` and must `makeCall()` | any (ignored for phase; snapshot stays `'dialing'`) |
1619
+ | `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) |
1620
+ | `online` | `false` | Live SIP session (inbound, outbound, **and automatic recall redials**) | any (ignored for phase) |
1621
+ | `after_call_success` | `true` | Headless run **after** a successful call | `on_success_call`, `after_call_success`, `on_done_call` |
1622
+ | `after_call_failed` | `true` | Headless run **after** failed attempts (when `onFailedCall` was configured) | `on_failed_call`, `after_call_failed` |
1623
+ | `messaging` | `true` | Inbound message triggered the run | `on_message_api_received`, or `context.inboundMessage` set |
1624
+ | `recall` | `true` | Headless recall leg (legacy `entry_point`) | `on_recall`, `recall` |
1625
+ | `headless_other` | `true` | Any other headless run with a custom `entry_point` | your custom name |
1626
+
1265
1627
 
1266
1628
  **Automatic recall** (`recallCount` + `recallDelay`) creates new **online** SIP legs with an
1267
1629
  incremented `context.attempt`. Branch with `(context.attempt ?? 0) > 0`, **not** `phase === 'recall'`.
@@ -1299,6 +1661,16 @@ export default defineScript(async ({ channel, context, logger, platform }) => {
1299
1661
  logger.log('Inbound message', { text: context.inboundMessage?.payload });
1300
1662
  return { output: { phase } };
1301
1663
 
1664
+ case 'dialing': {
1665
+ const leg = await channel.sip.makeCall({});
1666
+ await leg.sip.waitForAnswer();
1667
+ await leg.audio.say('Hello.');
1668
+ return;
1669
+ }
1670
+
1671
+ case 'early':
1672
+ await channel.sip.waitForAnswer();
1673
+ // fall through to the conversation
1302
1674
  case 'online':
1303
1675
  channel.sip.answer();
1304
1676
  if ((context.attempt ?? 0) > 0) {
@@ -1315,6 +1687,8 @@ export default defineScript(async ({ channel, context, logger, platform }) => {
1315
1687
  });
1316
1688
  ```
1317
1689
 
1690
+
1691
+
1318
1692
  ## Platform API
1319
1693
 
1320
1694
  `platform` exposes platform operations.
@@ -1340,28 +1714,32 @@ Platform APIs (`platform.nlu`, `platform.call`, dialog writes, messaging, phrase
1340
1714
 
1341
1715
  #### What `platform.dialog.result` is
1342
1716
 
1343
- `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.
1717
+ `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.
1718
+
1719
+
1720
+ | Value | Meaning |
1721
+ | --------- | -------------------------------------------------------------------------------- |
1722
+ | `null` | Often after-call continuation: dialog re-enters queue-api, then becomes `queued` |
1723
+ | `queued` | In the offline queue, not yet claimed |
1724
+ | `pending` | In progress (live SIP/WS or claimed queue row) |
1725
+ | `done` | Dialog closed successfully (terminal for the pipeline) |
1726
+ | `error` | Dialog closed with a logic/runtime error |
1344
1727
 
1345
- | Value | Meaning |
1346
- | --- | --- |
1347
- | `null` | Often after-call continuation: dialog re-enters queue-api, then becomes `queued` |
1348
- | `queued` | In the offline queue, not yet claimed |
1349
- | `pending` | In progress (live SIP/WS or claimed queue row) |
1350
- | `done` | Dialog closed successfully (terminal for the pipeline) |
1351
- | `error` | Dialog closed with a logic/runtime error |
1352
1728
 
1353
1729
  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()`.
1354
1730
 
1355
1731
  #### Do not confuse
1356
1732
 
1357
- | API | Layer | Does | Does not |
1358
- | --- | --- | --- | --- |
1359
- | `platform.dialog.result` | LE `dialog` row | Lifecycle status (`done`, `pending`, …) | Hang up SIP; equal `call.result`; equal `ScriptResult.output` |
1360
- | `platform.dialog.entryPoint` | LE `dialog.params` | Persist routing hint for later headless/queue runs | Change SIP state; select another script export |
1361
- | `context.entryPoint` | `ScriptDialogContext` | **Snapshot** of `entry_point` at script start (`getScriptPhase`) | Persist if you assign it write via `platform.dialog.entryPoint` |
1362
- | `return { output }` / `ScriptResult` | Script return | dialog_stats / host dump | LE `dialog.result` column |
1363
- | `channel.sip.hangup()` / `terminated$` | Media leg | End or observe SIP/WS media | Set `platform.dialog.result` by itself |
1364
- | `call.result` (LE call row) | Per-call | SIP terminal code/phrase for CMS logs | Same as `dialog.result` |
1733
+
1734
+ | API | Layer | Does | Does not |
1735
+ | -------------------------------------- | --------------------- | ---------------------------------------------------------------- | ----------------------------------------------------------------- |
1736
+ | `platform.dialog.result` | LE `dialog` row | Lifecycle status (`done`, `pending`, …) | Hang up SIP; equal `call.result`; equal `ScriptResult.output` |
1737
+ | `platform.dialog.entryPoint` | LE `dialog.params` | Persist routing hint for later headless/queue runs | Change SIP state; select another script export |
1738
+ | `context.entryPoint` | `ScriptDialogContext` | **Snapshot** of `entry_point` at script start (`getScriptPhase`) | Persist if you assign it write via `platform.dialog.entryPoint` |
1739
+ | `return { output }` / `ScriptResult` | Script return | dialog_stats / host dump | LE `dialog.result` column |
1740
+ | `channel.sip.hangup()` / `terminated$` | Media leg | End or observe SIP/WS media | Set `platform.dialog.result` by itself |
1741
+ | `call.result` (LE call row) | Per-call | SIP terminal code/phrase for CMS logs | Same as `dialog.result` |
1742
+
1365
1743
 
1366
1744
  There is no `context.result` field — read/write dialog lifecycle only through `platform.dialog.result`.
1367
1745
 
@@ -1406,7 +1784,7 @@ await platform.call('+12025551234', {
1406
1784
  });
1407
1785
  ```
1408
1786
 
1409
- 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=...)`).
1787
+ 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=...)`).
1410
1788
 
1411
1789
  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.
1412
1790
 
@@ -1436,21 +1814,23 @@ After a failed **outbound** call the platform must choose **one** failure-handli
1436
1814
  `recallCount` + `recallDelay` and `onFailedCall` answer the same question in different ways, so
1437
1815
  they are **mutually exclusive** on `platform.call()` (logic-executor `nn.call` parity).
1438
1816
 
1439
- | Strategy | `platform.call()` options | What happens on failure | Next script run |
1440
- |----------|---------------------------|-------------------------|-----------------|
1441
- | **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` |
1442
- | **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` |
1817
+
1818
+ | Strategy | `platform.call()` options | What happens on failure | Next script run |
1819
+ | --------------------------- | --------------------------------------------------------------- | ----------------------------------------------------------------------------------------- | --------------------------------------------------------------------- |
1820
+ | **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` |
1821
+ | **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` |
1822
+
1443
1823
 
1444
1824
  **Why not both?** Recall is fully platform-driven (dialer redials without running your script between
1445
1825
  attempts). `onFailedCall` is script-driven (your handler decides logging, CRM, manual retry, etc.).
1446
- If both were written to `call.params`, shutdown would be ambiguous. This host therefore **keeps
1447
- `onFailedCall` and drops recall** at schedule time so the outbound still starts and the after-call
1826
+ If both were written to `call.params`, shutdown would be ambiguous. This host therefore **keeps**
1827
+ `onFailedCall` **and drops recall** at schedule time so the outbound still starts and the after-call
1448
1828
  branch runs on failure. (If both somehow land on an existing `call.params` row, recall still wins
1449
1829
  at shutdown — avoid writing both.)
1450
1830
 
1451
1831
  **Do not** pass `onFailedCall` together with `recallCount` and `recallDelay` in the same
1452
1832
  `platform.call()` invocation. If both are present (script options, dialog params, or CMS
1453
- defaults), the host **keeps `onFailedCall` and drops recall** so the call still schedules —
1833
+ defaults), the host **keeps** `onFailedCall` **and drops recall** so the call still schedules —
1454
1834
  after-call continuation wins over automatic redial. Prefer configuring only one strategy
1455
1835
  explicitly.
1456
1836
 
@@ -1486,7 +1866,7 @@ export default defineScript(async ({ channel, context, logger }) => {
1486
1866
  See [examples/recall-routing-by-attempt.ts](./examples/recall-routing-by-attempt.ts).
1487
1867
 
1488
1868
  When you omit recall options, the host fills them from `context.recallCount` / `context.recallDelay`
1489
- (effective values for the current dialog) **only when `onFailedCall` is not configured** for that
1869
+ (effective values for the current dialog) **only when** `onFailedCall` **is not configured** for that
1490
1870
  scheduled call. Those values fall back to CMS agent contact-rules
1491
1871
  (`context.agent?.recallCount` / `context.agent?.recallDelay`, legacy `nn.get_recall_count()` /
1492
1872
  `nn.get_recall_delay()`). See [Failed outbound: automatic recall vs after-call continuation](#failed-outbound-automatic-recall-vs-after-call-continuation).
@@ -1500,6 +1880,8 @@ Other scheduling options:
1500
1880
  - `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.
1501
1881
  - `protoAdditional`: extra protocol-level parameters, such as SIP headers expected by your telephony setup.
1502
1882
 
1883
+
1884
+
1503
1885
  ### Messaging
1504
1886
 
1505
1887
  ```ts
@@ -1517,9 +1899,9 @@ await platform.messaging.send({
1517
1899
  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.
1518
1900
 
1519
1901
  The script entry point is still the same `defineScript()` handler. Detect offline mode with
1520
- `context.headless`, then use **`getScriptPhase(context)`** to distinguish pre-call queue runs
1902
+ `context.headless`, then use `getScriptPhase(context)` to distinguish pre-call queue runs
1521
1903
  (`before_call`) from post-call continuations (`after_call_success` / `after_call_failed`). See
1522
- [Script Lifecycle (`getScriptPhase`)](#script-lifecycle-getscriptphase).
1904
+ [Script Lifecycle (](#script-lifecycle-getscriptphase)`getScriptPhase`[)](#script-lifecycle-getscriptphase).
1523
1905
 
1524
1906
  ```ts
1525
1907
  import { defineScript, getScriptPhase } from '@voctiv/agent-sdk';
@@ -1564,7 +1946,7 @@ These APIs are available and are the intended tools for offline scripts:
1564
1946
  - `platform.messaging.send()` for outbound messages through the configured platform messaging transport.
1565
1947
  - `platform.call()` for scheduling outbound platform-managed calls.
1566
1948
  - `platform.dialog.entryPoint` and `platform.dialog.result` for updating dialog routing and outcome.
1567
- - `channel.llm.ask()`, `channel.llm.stream()`, and `channel.llm.extract()` for Omni LLM operations.
1949
+ - `channel.llm.ask()`, `channel.llm.stream()`, `channel.llm.extract()`, `channel.llm.createDialog()`, and `channel.llm.updateDialog()` for Omni LLM operations.
1568
1950
  - `logger` for structured logs.
1569
1951
 
1570
1952
  Audio and telephony APIs are intentionally inert:
@@ -1662,6 +2044,8 @@ export default defineScript(async ({ channel, context, platform }) => {
1662
2044
  });
1663
2045
  ```
1664
2046
 
2047
+
2048
+
1665
2049
  ## Dialog Context And Persisted Env
1666
2050
 
1667
2051
  `context` includes identity, telephony fields, params, routing metadata, and runtime helpers.
@@ -1681,14 +2065,18 @@ const counter = await context.agent?.env?.<number>('visitCount');
1681
2065
  await context.agent?.env?.('visitCount', 42, { expire: 30 });
1682
2066
  ```
1683
2067
 
2068
+
2069
+
1684
2070
  #### Recall settings (agent defaults vs effective)
1685
2071
 
1686
2072
  Recall behavior uses **two layers** on `context`:
1687
2073
 
1688
- | Layer | Fields | Source | Use when |
1689
- |-------|--------|--------|----------|
1690
- | 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 |
1691
- | 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** |
2074
+
2075
+ | Layer | Fields | Source | Use when |
2076
+ | ---------------------- | ---------------------------------------------------------- | ----------------------------------------------------------------- | --------------------------------------------------------------------------------------- |
2077
+ | 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 |
2078
+ | 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** |
2079
+
1692
2080
 
1693
2081
  Precedence for effective values: **dialog/call params** (`recall_count`, `recall_delay`) **>** agent CMS defaults.
1694
2082
 
@@ -1794,7 +2182,7 @@ Headless channels are for offline, queue, or messaging sessions:
1794
2182
 
1795
2183
  - Audio methods are no-ops that log warnings.
1796
2184
  - SIP methods are no-ops, except `makeCall()` and `bridge()`, which throw: there is no real leg to
1797
- create, and a silent no-op would hide the mistake.
2185
+ create, and a silent no-op would hide the mistake.
1798
2186
  - `createAsr()` returns an inert handle whose observables complete immediately.
1799
2187
  - `createTts()` returns an inert handle (no vendor connection is opened).
1800
2188
  - LLM, NLU, messaging, platform calls, dialog state, and `env$` still work.
@@ -1802,7 +2190,7 @@ Headless channels are for offline, queue, or messaging sessions:
1802
2190
  Use `context.headless` plus `getScriptPhase(context)` when a script must behave differently without a
1803
2191
  real media channel or across pre-call / post-call headless runs. See
1804
2192
  [Offline / Headless Logic](#offline--headless-logic) and
1805
- [Script Lifecycle (`getScriptPhase`)](#script-lifecycle-getscriptphase).
2193
+ [Script Lifecycle (](#script-lifecycle-getscriptphase)`getScriptPhase`[)](#script-lifecycle-getscriptphase).
1806
2194
 
1807
2195
  ## Text Input For Tests
1808
2196
 
@@ -1824,3 +2212,4 @@ The package ships as CommonJS with TypeScript declarations. Import from `@voctiv
1824
2212
  ```ts
1825
2213
  import { defineScript, type MediaChannel, type AsrHandle, type MediaError } from '@voctiv/agent-sdk';
1826
2214
  ```
2215
+