@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 +603 -214
- package/dist/define-script.d.ts +31 -2
- package/dist/define-script.d.ts.map +1 -1
- package/dist/define-script.js +30 -3
- package/dist/define-script.js.map +1 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/types/call-options.d.ts +53 -0
- package/dist/types/call-options.d.ts.map +1 -0
- package/dist/types/call-options.js +3 -0
- package/dist/types/call-options.js.map +1 -0
- package/dist/types/llm.d.ts +82 -2
- package/dist/types/llm.d.ts.map +1 -1
- package/dist/types/media-providers.d.ts +4 -0
- package/dist/types/media-providers.d.ts.map +1 -1
- package/dist/types/media-providers.js +4 -0
- package/dist/types/media-providers.js.map +1 -1
- package/dist/types/script-context.d.ts +30 -1
- package/dist/types/script-context.d.ts.map +1 -1
- package/dist/types/script-context.js +13 -2
- package/dist/types/script-context.js.map +1 -1
- package/examples/README.md +5 -1
- package/examples/custom-media-providers-script.ts +47 -0
- package/examples/custom-media-providers.ts +15 -31
- package/examples/outbound-from-invite.ts +30 -0
- package/examples/outbound-on-answer.ts +18 -0
- package/examples/outbound-script-dial.ts +20 -0
- package/package.json +1 -1
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 [
|
|
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
|
|
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
|
-
|
|
125
|
-
|
|
|
126
|
-
|
|
|
127
|
-
| `
|
|
128
|
-
| `
|
|
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
|
-
###
|
|
220
|
+
### Two outbound stories
|
|
183
221
|
|
|
184
|
-
|
|
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
|
-
|
|
238
|
-
|
|
|
239
|
-
|
|
|
240
|
-
|
|
|
241
|
-
|
|
|
242
|
-
|
|
|
243
|
-
|
|
|
244
|
-
|
|
|
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
|
-
|
|
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
|
-
|
|
280
|
-
|
|
|
281
|
-
|
|
|
282
|
-
| `
|
|
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
|
-
|
|
313
|
-
|
|
|
314
|
-
|
|
|
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
|
-
|
|
344
|
-
|
|
|
345
|
-
|
|
|
346
|
-
| `
|
|
347
|
-
| `
|
|
348
|
-
| `
|
|
349
|
-
| `
|
|
350
|
-
| `
|
|
351
|
-
| `
|
|
352
|
-
| `
|
|
353
|
-
| `
|
|
354
|
-
| `
|
|
355
|
-
| `
|
|
356
|
-
| `
|
|
357
|
-
| `
|
|
358
|
-
| `
|
|
359
|
-
| `
|
|
360
|
-
| `
|
|
361
|
-
| `
|
|
362
|
-
| `
|
|
363
|
-
| `
|
|
364
|
-
| `
|
|
365
|
-
| `
|
|
366
|
-
| `
|
|
367
|
-
| `
|
|
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
|
-
|
|
527
|
-
|
|
|
528
|
-
|
|
|
529
|
-
| **
|
|
530
|
-
| **
|
|
531
|
-
| **
|
|
532
|
-
| **
|
|
533
|
-
| **
|
|
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
|
|
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
|
|
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
|
-
|
|
619
|
-
|
|
|
620
|
-
|
|
|
621
|
-
| `
|
|
622
|
-
| `tts.say
|
|
623
|
-
| `tts.
|
|
624
|
-
| `
|
|
625
|
-
| `channel.audio.
|
|
626
|
-
| `channel.audio.
|
|
627
|
-
| `channel.audio.
|
|
628
|
-
| `channel.audio.
|
|
629
|
-
| `channel.audio.
|
|
630
|
-
| `channel.audio.
|
|
631
|
-
| `channel.audio.
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
-
|
|
729
|
-
|
|
730
|
-
-
|
|
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
|
-
|
|
769
|
-
|
|
|
770
|
-
|
|
|
771
|
-
| `
|
|
772
|
-
| `
|
|
773
|
-
| `
|
|
774
|
-
| `
|
|
775
|
-
| `
|
|
776
|
-
| `
|
|
777
|
-
| `
|
|
778
|
-
| `
|
|
779
|
-
| `
|
|
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
|
|
786
|
-
`
|
|
787
|
-
|
|
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
|
-
###
|
|
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:
|
|
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:
|
|
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((
|
|
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
|
-
|
|
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
|
-
|
|
852
|
-
|
|
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(
|
|
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
|
-
-
|
|
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
|
-
|
|
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**
|
|
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
|
-
-
|
|
1027
|
-
-
|
|
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
|
-
|
|
1117
|
-
|
|
|
1118
|
-
|
|
|
1119
|
-
| `
|
|
1120
|
-
| `
|
|
1121
|
-
| `
|
|
1122
|
-
| `
|
|
1123
|
-
| `
|
|
1124
|
-
| `
|
|
1125
|
-
| `
|
|
1126
|
-
| `
|
|
1127
|
-
| `
|
|
1128
|
-
| `
|
|
1129
|
-
| `
|
|
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
|
-
|
|
1138
|
-
|
|
|
1139
|
-
|
|
|
1140
|
-
| `
|
|
1141
|
-
| `
|
|
1142
|
-
| `
|
|
1143
|
-
| `
|
|
1144
|
-
| `
|
|
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
|
-
|
|
1170
|
-
|
|
|
1171
|
-
|
|
|
1172
|
-
| `
|
|
1173
|
-
| `
|
|
1174
|
-
| `
|
|
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
|
|
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
|
-
-
|
|
1239
|
-
-
|
|
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
|
|
1600
|
+
after completion into `PersistedScriptResult.env`.
|
|
1243
1601
|
|
|
1244
1602
|
## Script Lifecycle (`getScriptPhase`)
|
|
1245
1603
|
|
|
1246
|
-
The host always runs your single
|
|
1247
|
-
name (unlike logic-executor Python `run_unit(entry_point=...)`). Use
|
|
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
|
-
|
|
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
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
|
1259
|
-
| `
|
|
1260
|
-
| `
|
|
1261
|
-
| `
|
|
1262
|
-
| `
|
|
1263
|
-
| `
|
|
1264
|
-
| `
|
|
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
|
|
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
|
-
|
|
1358
|
-
|
|
|
1359
|
-
|
|
|
1360
|
-
| `platform.dialog.
|
|
1361
|
-
| `
|
|
1362
|
-
| `
|
|
1363
|
-
| `
|
|
1364
|
-
| `
|
|
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
|
|
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
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
|
1442
|
-
| **
|
|
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
|
|
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.
|
|
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
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
|
1691
|
-
|
|
|
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
|
-
|
|
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
|
+
|