@pyai/sdk 0.1.2 → 0.2.1

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/src/index.ts CHANGED
@@ -59,18 +59,73 @@ export interface TranscriptionJob {
59
59
  error?: string;
60
60
  }
61
61
 
62
+ /**
63
+ * Output container/codec for `audio.speech`. This is the **exact** set the
64
+ * server accepts on `POST /v1/audio/speech` — any other value is rejected with
65
+ * `400 unsupported_format`. The default (when `response_format` is omitted) is
66
+ * `mp3`. Omit `sample_rate` for the engine's native 24 kHz (`g711_*` is always
67
+ * 8 kHz).
68
+ *
69
+ * | format | rates (Hz) | Content-Type |
70
+ * |---|---|---|
71
+ * | `mp3` (default) | 8000/16000/24000/48000 | `audio/mpeg` |
72
+ * | `wav` | 8000/16000/24000/48000 | `audio/wav` |
73
+ * | `opus` | 8000/16000/24000/48000 | `audio/ogg` |
74
+ * | `aac` | 8000/16000/24000/48000 | `audio/aac` |
75
+ * | `flac` | 8000/16000/24000/48000 | `audio/flac` |
76
+ * | `pcm` | 8000/16000/24000/48000 | `audio/pcm` (raw int16 LE mono, no header) |
77
+ * | `g711_ulaw` | 8000 (forced) | `audio/basic` |
78
+ * | `g711_alaw` | 8000 (forced) | `audio/basic` |
79
+ */
80
+ export type SpeechFormat = "wav" | "mp3" | "opus" | "aac" | "flac" | "pcm" | "g711_ulaw" | "g711_alaw";
81
+
82
+ /**
83
+ * The runtime list of accepted `audio.speech` formats (mirrors {@link SpeechFormat}),
84
+ * for building dropdowns / validating input before a request. Branch on the
85
+ * named values; the order matches the contract doc.
86
+ */
87
+ export const SPEECH_FORMATS = ["wav", "mp3", "opus", "aac", "flac", "pcm", "g711_ulaw", "g711_alaw"] as const;
88
+
89
+ /** Sample rates (Hz) the server accepts for `audio.speech` (`g711_*` is always 8 kHz). */
90
+ export const SPEECH_SAMPLE_RATES = [8000, 16000, 24000, 48000] as const;
91
+ export type SpeechSampleRate = (typeof SPEECH_SAMPLE_RATES)[number];
92
+
62
93
  export interface SpeechParams {
63
94
  input: string;
64
95
  voice?: string;
65
96
  model?: string;
66
- response_format?: "wav" | "mp3" | "opus" | "aac" | "flac" | "pcm";
67
97
  /**
68
- * Output sample rate in Hz (8000-48000). Omit for the native 24 kHz. Most
69
- * useful with `response_format: "pcm"` (raw 16-bit mono samples), e.g. set
70
- * `8000`/`16000` for telephony pipelines.
98
+ * Output container/codec, resampled+encoded server-side. One of
99
+ * {@link SpeechFormat} anything else is a `400 unsupported_format`. Omit for
100
+ * the default of `mp3`.
101
+ *
102
+ * `g711_ulaw`/`g711_alaw` return raw 8 kHz mono G.711 — the bytes Twilio/SIP
103
+ * media streams expect, so you can hand the response straight to a telephony
104
+ * frame without a client-side resampler or μ-law encoder. `sample_rate` is
105
+ * forced to 8000 for those (omit it, or set exactly 8000). `pcm` is raw,
106
+ * headerless int16 LE mono at `sample_rate`. `mp3`/`opus` are buffered (not
107
+ * chunk-streamed) — use them with `speech`, not `speechStream`.
71
108
  */
72
- sample_rate?: number;
109
+ response_format?: SpeechFormat;
110
+ /**
111
+ * Output sample rate in Hz. One of {@link SpeechSampleRate}
112
+ * (8000/16000/24000/48000) — anything else is a `400`. Omit for the engine's
113
+ * native 24 kHz; `g711_*` is always 8000 (forced). Set `8000`/`16000` for
114
+ * telephony pipelines, most often with `response_format: "pcm"`.
115
+ */
116
+ sample_rate?: SpeechSampleRate;
73
117
  speed?: number;
118
+ /**
119
+ * Deterministic sampling seed for reproducible eval runs. Forward-compatible:
120
+ * honored once the engine supports it (otherwise ignored server-side), so it's
121
+ * always safe to send.
122
+ */
123
+ seed?: number;
124
+ /**
125
+ * Sampling temperature (lower = more deterministic). Forward-compatible —
126
+ * honored once the engine supports it, otherwise ignored.
127
+ */
128
+ temperature?: number;
74
129
  }
75
130
 
76
131
  export interface CreateJobParams {
@@ -84,14 +139,885 @@ export interface CreateJobParams {
84
139
  }
85
140
 
86
141
  export interface RealtimeOptions {
87
- /** "omni" (agentic, needs agentId) or "flow" (voice duplex). Default "omni". */
142
+ /** "omni" (agentic voice) or "flow" (legacy voice duplex). Default "omni". */
88
143
  product?: "omni" | "flow";
89
- /** Required for omni: the agent to drive. */
144
+ /**
145
+ * Optional opaque tag echoed to your `kb_endpoint` and recorded on the call.
146
+ * Omni is zero-state: the session is authorized by the key's org, so there is
147
+ * nothing to create first and this tag is never required.
148
+ */
149
+ sessionLabel?: string;
150
+ /**
151
+ * @deprecated Use {@link sessionLabel}. Kept for back-compat — emitted as the
152
+ * `agent_id` query alias, which the gateway still accepts. Ignored if
153
+ * `sessionLabel` is set.
154
+ */
90
155
  agentId?: string;
91
156
  /** Extra query params (e.g. format, rate). */
92
157
  query?: Record<string, string>;
93
158
  }
94
159
 
160
+ /** Parameters for minting an ephemeral browser Omni session token (server-side). */
161
+ export interface OmniSessionParams {
162
+ /**
163
+ * Browser origins (scheme://host[:port]) the minted token may connect from.
164
+ * Required and non-empty — a browser token must be origin-locked. `*` is not
165
+ * allowed.
166
+ */
167
+ allowedOrigins: string[];
168
+ /** Token lifetime in seconds. Default 60 server-side; max 600. Keep it short. */
169
+ ttlSeconds?: number;
170
+ /** Optional opaque tag echoed back to your `kb_endpoint` and recorded on the call. */
171
+ sessionLabel?: string;
172
+ }
173
+
174
+ /**
175
+ * The result of {@link PyAI.omni}.createSession — a short-lived credential safe
176
+ * to hand to the browser. Use {@link OmniSession.token} as the WebSocket
177
+ * subprotocol `pyai-key.<token>` against {@link OmniSession.url}.
178
+ */
179
+ export interface OmniSession {
180
+ object?: "omni.session";
181
+ /** The ephemeral session token. Short-lived + origin-locked; browser-safe. */
182
+ token: string;
183
+ /** Token expiry, Unix epoch milliseconds. */
184
+ expires_at: number;
185
+ /** The Omni realtime WebSocket URL to connect to. */
186
+ url: string;
187
+ /** Echoed back when supplied on the request. */
188
+ session_label?: string;
189
+ }
190
+
191
+ /* ------------------------------------------------------------------------- *
192
+ * Stable enums — mirror the server so callers branch on named constants, not
193
+ * magic strings, and a contract change surfaces in one place. These are plain
194
+ * `as const` objects (not TS `enum`s) so the source still runs directly under
195
+ * Node's type-stripping and stays tree-shakeable.
196
+ * ------------------------------------------------------------------------- */
197
+
198
+ /** Frame `type`s emitted by the Hear streaming-STT WebSocket. */
199
+ export const HearFrameType = {
200
+ /** Eager live hypothesis for the current utterance. */
201
+ Partial: "partial",
202
+ /** Partial whose prefix has stabilized (won't be revised). */
203
+ PartialStable: "partial_stable",
204
+ /** Stable transcript at end-of-utterance (endpoint or commit). */
205
+ SpeechFinal: "speech_final",
206
+ /** Corrected, full-context transcript following `speech_final`. */
207
+ Final: "final",
208
+ /** Final billed-usage summary, emitted just before a graceful close. */
209
+ Usage: "usage",
210
+ /** Server-side fault frame. */
211
+ Error: "error",
212
+ } as const;
213
+ export type HearFrameType = (typeof HearFrameType)[keyof typeof HearFrameType];
214
+
215
+ /** WebSocket close codes used across the PyAI realtime/streaming surfaces. */
216
+ export const WSCloseCode = {
217
+ /** Normal closure. */
218
+ Normal: 1000,
219
+ /** Auth/policy: bad key, missing scope, or revoked token. */
220
+ PolicyViolation: 1008,
221
+ /** Engine/internal error. */
222
+ InternalError: 1011,
223
+ /** Over the concurrency cap (PyAI-specific; mirrors HTTP 429). */
224
+ OverCapacity: 4429,
225
+ } as const;
226
+ export type WSCloseCode = (typeof WSCloseCode)[keyof typeof WSCloseCode];
227
+
228
+ /**
229
+ * Stable, machine-readable error `code`s (the documented contract). Branch on
230
+ * these. The set is treated as open — `PyAIError.code` stays `string` — so a
231
+ * new server code never breaks the build, but the known ones are named here.
232
+ */
233
+ export const ErrorCode = {
234
+ Unauthorized: "unauthorized",
235
+ Forbidden: "forbidden",
236
+ OriginNotAllowed: "origin_not_allowed",
237
+ InvalidAgentId: "invalid_agent_id",
238
+ CreditExhausted: "credit_exhausted",
239
+ KeyBudgetExceeded: "key_budget_exceeded",
240
+ InsufficientQuota: "insufficient_quota",
241
+ RateLimitExceeded: "rate_limit_exceeded",
242
+ ConcurrencyLimitExceeded: "concurrency_limit_exceeded",
243
+ DailyCapExceeded: "daily_cap_exceeded",
244
+ IdempotencyConflict: "idempotency_conflict",
245
+ NotFound: "not_found",
246
+ NumberInUse: "number_in_use",
247
+ } as const;
248
+ export type ErrorCode = (typeof ErrorCode)[keyof typeof ErrorCode];
249
+
250
+ /* ------------------------------------------------------------------------- *
251
+ * Hear streaming STT (and Cue grounding)
252
+ * ------------------------------------------------------------------------- */
253
+
254
+ /** A top-3 knowledge-base passage attached to Cue (grounded) finals. */
255
+ export interface HearGroundingPassage {
256
+ content: string;
257
+ score: number;
258
+ }
259
+
260
+ /** Live hypothesis frame (`partial` / `partial_stable`). */
261
+ export interface HearPartialFrame {
262
+ type: "partial" | "partial_stable";
263
+ text: string;
264
+ /** Prefix that has stabilized (won't change). */
265
+ stable_text?: string;
266
+ /** The still-changing tail. */
267
+ active_text?: string;
268
+ utterance_id: string;
269
+ /** Audio-timeline position of the hypothesis, ms. */
270
+ t_ms: number;
271
+ }
272
+
273
+ /** Finalized-utterance frame (`speech_final` / `final`). */
274
+ export interface HearFinalFrame {
275
+ type: "speech_final" | "final";
276
+ text: string;
277
+ utterance_id: string;
278
+ t_ms: number;
279
+ /** Active-speech length of the utterance (the billed signal), ms. */
280
+ audio_ms: number;
281
+ /** Present only with Cue grounding enabled (top KB passages). */
282
+ grounding?: HearGroundingPassage[];
283
+ }
284
+
285
+ /** Final billed-usage summary (`usage`), emitted just before a graceful close
286
+ * so realtime spend can be reconciled in-band (a realtime WS carries no
287
+ * `x-pyai-units` response header). Best-effort: absent if there was no billable
288
+ * audio or the close was abnormal. */
289
+ export interface HearUsageFrame {
290
+ type: "usage";
291
+ /** `hear` for plain streaming, `cue` when grounding was enabled. */
292
+ product: "hear" | "cue";
293
+ /** The billed meter (`hear.requests` or `cue.minutes`). */
294
+ meter: string;
295
+ /** Summed active-speech audio billed for the session, in seconds. */
296
+ audio_seconds: number;
297
+ /** The same quantity in minutes. */
298
+ minutes: number;
299
+ }
300
+
301
+ /** Server fault frame (`error`). */
302
+ export interface HearErrorFrame {
303
+ type: "error";
304
+ code?: string;
305
+ message: string;
306
+ }
307
+
308
+ export type HearFrame = HearPartialFrame | HearFinalFrame | HearUsageFrame | HearErrorFrame;
309
+
310
+ /**
311
+ * Minimal structural WebSocket — matches both the browser/Node global
312
+ * `WebSocket` and the `ws` package, and lets tests inject a mock.
313
+ */
314
+ export interface WebSocketLike {
315
+ send(data: string | ArrayBufferLike | ArrayBufferView | Blob): void;
316
+ close(code?: number, reason?: string): void;
317
+ readonly readyState: number;
318
+ onopen: ((ev: unknown) => void) | null;
319
+ onmessage: ((ev: { data: unknown }) => void) | null;
320
+ onerror: ((ev: unknown) => void) | null;
321
+ onclose: ((ev: { code: number; reason: string }) => void) | null;
322
+ }
323
+
324
+ export type WebSocketCtor = new (url: string, protocols?: string | string[]) => WebSocketLike;
325
+
326
+ export interface HearStreamOptions {
327
+ /** Streaming STT model. Server default `pyai-hear`. */
328
+ model?: string;
329
+ /** ISO-639-1 hint, e.g. "en". */
330
+ language?: string;
331
+ /** Input PCM sample rate in Hz. Default 16000 server-side. */
332
+ sampleRate?: number;
333
+ /** Audio frame encoding. Default "pcm16". */
334
+ encoding?: "pcm16" | "opus";
335
+ /** Emit eager partial hypotheses. Default true server-side. */
336
+ interimResults?: boolean;
337
+ /**
338
+ * Format spoken numbers as digits in the transcript (e.g. "one two three" →
339
+ * "123"). Useful for voice agents that read back phone numbers, codes, and
340
+ * amounts. Default false (spoken form). Forwards `?numerals=true` on the URL.
341
+ */
342
+ numerals?: boolean;
343
+ /**
344
+ * Turn-segmentation tuning: trailing-pause (ms, 50–2000) that ends an
345
+ * utterance. Forwards `?endpointing_ms=` — clamped + honored once the engine
346
+ * supports it; a no-op when omitted. Drive end-of-turn yourself with
347
+ * {@link HearStream.commit} for full control today.
348
+ */
349
+ endpointingMs?: number;
350
+ /**
351
+ * Enable Cue knowledge-base grounding: sends `{type:"config",grounding:true}`
352
+ * on open, after which `speech_final`/`final` frames carry a `grounding`
353
+ * array. Bills a single `cue.minutes` line instead of the Hear rate.
354
+ */
355
+ grounding?: boolean;
356
+ /** Cue: number of KB passages to retrieve per turn (1–20, default 3). */
357
+ groundingK?: number;
358
+ /** Cue: drop passages scoring below this (0–1, default 0 = keep all). */
359
+ groundingMinScore?: number;
360
+ /** Cue: max ms to wait for retrieval at the final before failing open to
361
+ * `grounding: []` (50–2000, default 450). */
362
+ groundingTimeoutMs?: number;
363
+ /** Extra query params merged onto the connect URL. */
364
+ query?: Record<string, string>;
365
+ /** Fired once the socket opens (after the optional grounding config). */
366
+ onOpen?: () => void;
367
+ /** Fired on `partial` / `partial_stable`. */
368
+ onPartial?: (frame: HearPartialFrame) => void;
369
+ /** Fired on `speech_final` / `final`. */
370
+ onFinal?: (frame: HearFinalFrame) => void;
371
+ /** Fired on the final `usage` summary (in-band realtime reconciliation). */
372
+ onUsage?: (frame: HearUsageFrame) => void;
373
+ /** Fired on an `error` frame or a transport-level error. */
374
+ onError?: (err: HearErrorFrame | Error) => void;
375
+ /** Fired when the socket closes (code per `WSCloseCode`). */
376
+ onClose?: (code: number, reason: string) => void;
377
+ /** Injectable WebSocket constructor (defaults to the global). */
378
+ webSocket?: WebSocketCtor;
379
+ }
380
+
381
+ /**
382
+ * A live Hear streaming-STT session. Hides the frame protocol: stream audio
383
+ * with {@link HearStream.sendAudio}, get `onPartial`/`onFinal`/`onError`
384
+ * callbacks, force-finalize with {@link HearStream.commit}, and flush+close
385
+ * with {@link HearStream.close}. Construct via `pyai.audio.transcriptions.stream()`.
386
+ */
387
+ export class HearStream {
388
+ private readonly ws: WebSocketLike;
389
+ private readonly opts: HearStreamOptions;
390
+ private closed = false;
391
+
392
+ constructor(url: string, subprotocol: string, opts: HearStreamOptions) {
393
+ this.opts = opts;
394
+ const WS = opts.webSocket ?? (globalThis as { WebSocket?: WebSocketCtor }).WebSocket;
395
+ if (!WS) {
396
+ throw new Error(
397
+ "No global WebSocket available; pass options.webSocket (e.g. the `ws` package) to transcriptions.stream()",
398
+ );
399
+ }
400
+ this.ws = new WS(url, [subprotocol]);
401
+ this.ws.onopen = () => {
402
+ if (opts.grounding) {
403
+ try {
404
+ const cfg: Record<string, unknown> = { type: "config", grounding: true };
405
+ if (opts.groundingK != null) cfg.grounding_k = opts.groundingK;
406
+ if (opts.groundingMinScore != null) cfg.grounding_min_score = opts.groundingMinScore;
407
+ if (opts.groundingTimeoutMs != null) cfg.grounding_timeout_ms = opts.groundingTimeoutMs;
408
+ this.ws.send(JSON.stringify(cfg));
409
+ } catch {
410
+ /* surfaced via onerror */
411
+ }
412
+ }
413
+ opts.onOpen?.();
414
+ };
415
+ this.ws.onmessage = (ev) => this.handleMessage(ev.data);
416
+ this.ws.onerror = (ev) => opts.onError?.(ev instanceof Error ? ev : new Error("WebSocket error"));
417
+ this.ws.onclose = (ev) => {
418
+ this.closed = true;
419
+ opts.onClose?.(ev.code, ev.reason);
420
+ };
421
+ }
422
+
423
+ private handleMessage(data: unknown): void {
424
+ // Hear emits JSON text frames; ignore any unexpected binary.
425
+ if (typeof data !== "string") return;
426
+ let frame: HearFrame;
427
+ try {
428
+ frame = JSON.parse(data) as HearFrame;
429
+ } catch {
430
+ this.opts.onError?.(new Error(`Unparseable Hear frame: ${data.slice(0, 120)}`));
431
+ return;
432
+ }
433
+ switch (frame.type) {
434
+ case HearFrameType.Partial:
435
+ case HearFrameType.PartialStable:
436
+ this.opts.onPartial?.(frame);
437
+ break;
438
+ case HearFrameType.SpeechFinal:
439
+ case HearFrameType.Final:
440
+ this.opts.onFinal?.(frame);
441
+ break;
442
+ case HearFrameType.Usage:
443
+ this.opts.onUsage?.(frame);
444
+ break;
445
+ case HearFrameType.Error:
446
+ this.opts.onError?.(frame);
447
+ break;
448
+ default:
449
+ // Unknown/forward-compatible frame — ignore.
450
+ break;
451
+ }
452
+ }
453
+
454
+ /** Send a chunk of audio (PCM16 or opus per `encoding`). */
455
+ sendAudio(chunk: ArrayBufferLike | ArrayBufferView | Blob): void {
456
+ this.ws.send(chunk);
457
+ }
458
+
459
+ /** Force-finalize the current utterance (e.g. on VAD end-of-turn). */
460
+ commit(): void {
461
+ this.ws.send(JSON.stringify({ type: "commit" }));
462
+ }
463
+
464
+ /** Close the socket; the server flushes a final for any buffered audio. */
465
+ close(code: number = WSCloseCode.Normal, reason = ""): void {
466
+ if (!this.closed) this.ws.close(code, reason);
467
+ }
468
+
469
+ /** The underlying socket (escape hatch for advanced use). */
470
+ get socket(): WebSocketLike {
471
+ return this.ws;
472
+ }
473
+
474
+ /** Current WebSocket readyState. */
475
+ get readyState(): number {
476
+ return this.ws.readyState;
477
+ }
478
+ }
479
+
480
+ /* ------------------------------------------------------------------------- *
481
+ * Omni realtime (agentic voice) — typed client over the /v1/omni WebSocket
482
+ * ------------------------------------------------------------------------- */
483
+
484
+ /**
485
+ * Event names on Omni **server → client** frames. ⚠️ Note the asymmetry:
486
+ * inbound frames are keyed on `event`, but your **outbound** control frames
487
+ * (`configure`, `dtmf`, …) are keyed on `type`. {@link OmniConnection} handles
488
+ * both sides for you; this map is for matching frames in `onEvent`.
489
+ */
490
+ export const OmniEvent = {
491
+ /** Handshake; advertises protocol version + audio formats. */
492
+ Hello: "hello",
493
+ /** Ack for your `configure` frame (echoes the resolved `voice_id`). */
494
+ Configured: "configured",
495
+ /** Session is live; includes the resolved agent + audio caps. */
496
+ SessionStarted: "session_started",
497
+ /** Turn boundary (user/assistant speaking). */
498
+ Turn: "turn",
499
+ /** Incremental/final transcript text. */
500
+ Transcript: "transcript",
501
+ /** User interrupted; assistant audio is being cut. */
502
+ BargeIn: "barge_in",
503
+ /** Alias for barge-in on some engine builds. */
504
+ Flush: "flush",
505
+ /** Session is closing; see close code. */
506
+ SessionEnd: "session_end",
507
+ /** Server fault frame. */
508
+ Error: "error",
509
+ } as const;
510
+ export type OmniEvent = (typeof OmniEvent)[keyof typeof OmniEvent];
511
+
512
+ /** A server → client Omni frame. Keyed on `event` (not `type`); open for forward-compat. */
513
+ export interface OmniServerFrame {
514
+ event: string;
515
+ [k: string]: unknown;
516
+ }
517
+
518
+ /** A binary agent-audio chunk delivered to {@link OmniConnectOptions.onAudio}. */
519
+ export type OmniAudioChunk = ArrayBuffer | ArrayBufferView | Blob;
520
+
521
+ /**
522
+ * The agent config sent as the `configure` control frame. These are the wire
523
+ * (snake_case) fields the engine reads; it's an **open bag**, so forward-compat
524
+ * fields (e.g. `greeting` / `language` once the engine acks them) pass straight
525
+ * through. The SDK supplies the `{"type":"configure"}` envelope for you — which
526
+ * is the whole point: a hand-rolled `{"event":"configure"}` is acked but
527
+ * silently dropped, leaving the agent with no brain and zero turns.
528
+ */
529
+ export interface OmniConfigure {
530
+ /** Voice to speak with (stock / clone / designed id). */
531
+ voice_id?: string;
532
+ /** System prompt / role + instructions for the agent. */
533
+ persona?: string;
534
+ /** Customer-hosted URL the engine calls per turn for grounding. */
535
+ kb_endpoint?: string;
536
+ /** Bearer the engine presents to `kb_endpoint`. */
537
+ kb_token?: string;
538
+ /** Forward-compatible: any other key the engine honors. */
539
+ [k: string]: unknown;
540
+ }
541
+
542
+ export interface OmniConnectOptions {
543
+ /** Optional opaque per-session tag echoed to your `kb_endpoint`. Omni is zero-state. */
544
+ sessionLabel?: string;
545
+ /**
546
+ * Ephemeral browser token from `omni.createSession`. When set it is used as
547
+ * the WS subprotocol (`pyai-key.<token>`) instead of the client's secret key,
548
+ * so a page never holds a secret key.
549
+ */
550
+ token?: string;
551
+ /** Connect-URL sample rate: 24000 browser, 16000 wideband telephony, 8000 G.711/Twilio. */
552
+ rate?: 24000 | 16000 | 8000;
553
+ /** Connect-URL audio format. Default `pcm16`. */
554
+ format?: "pcm16";
555
+ /**
556
+ * Agent config sent as `{"type":"configure",...}` the instant the socket
557
+ * opens. Omit to send it yourself later via {@link OmniConnection.configure}.
558
+ */
559
+ configure?: OmniConfigure;
560
+ /** Extra query params merged onto the connect URL. */
561
+ query?: Record<string, string>;
562
+ /** Fired once the socket opens (after the optional auto-configure). */
563
+ onOpen?: () => void;
564
+ /** Fired for each binary agent-audio chunk — play it out as it arrives. */
565
+ onAudio?: (chunk: OmniAudioChunk) => void;
566
+ /** Fired on the `hello` handshake frame. */
567
+ onHello?: (frame: OmniServerFrame) => void;
568
+ /** Fired on the `configured` ack. */
569
+ onConfigured?: (frame: OmniServerFrame) => void;
570
+ /** Fired on `session_started`. */
571
+ onSessionStarted?: (frame: OmniServerFrame) => void;
572
+ /** Fired on `turn` boundaries. */
573
+ onTurn?: (frame: OmniServerFrame) => void;
574
+ /** Fired on `transcript` text frames. */
575
+ onTranscript?: (frame: OmniServerFrame) => void;
576
+ /** Fired on `barge_in` / `flush` (user interrupted). */
577
+ onBargeIn?: (frame: OmniServerFrame) => void;
578
+ /** Fired on `session_end`. */
579
+ onSessionEnd?: (frame: OmniServerFrame) => void;
580
+ /** Fired on EVERY JSON frame (including unknown/forward-compat ones). */
581
+ onEvent?: (frame: OmniServerFrame) => void;
582
+ /** Fired on an `error` frame or a transport-level error. */
583
+ onError?: (err: OmniServerFrame | Error) => void;
584
+ /** Fired when the socket closes (code per {@link WSCloseCode}). */
585
+ onClose?: (code: number, reason: string) => void;
586
+ /** Injectable WebSocket constructor (defaults to the global). */
587
+ webSocket?: WebSocketCtor;
588
+ }
589
+
590
+ /**
591
+ * A live Omni agentic-voice session over `/v1/omni`. Hides the wire protocol's
592
+ * frame-key asymmetry: it sends control frames keyed on `type` (`configure`,
593
+ * `dtmf`) and parses server frames keyed on `event`, so you cannot trip the #1
594
+ * Omni integration bug (mirroring the server's `event` key on outbound, which
595
+ * is silently dropped). Construct via `pyai.omni.connect()`.
596
+ *
597
+ * @example
598
+ * const omni = pyai.omni.connect({
599
+ * rate: 16000,
600
+ * configure: { voice_id: "stock_emma_en_gb", persona: "You are a receptionist." },
601
+ * onAudio: (chunk) => speaker.write(chunk),
602
+ * onTranscript: (f) => console.log(f.text),
603
+ * });
604
+ * omni.sendAudio(pcm16Chunk); // stream caller audio continuously
605
+ */
606
+ export class OmniConnection {
607
+ private readonly ws: WebSocketLike;
608
+ private readonly opts: OmniConnectOptions;
609
+ private closed = false;
610
+
611
+ constructor(url: string, subprotocol: string, opts: OmniConnectOptions) {
612
+ this.opts = opts;
613
+ const WS = opts.webSocket ?? (globalThis as { WebSocket?: WebSocketCtor }).WebSocket;
614
+ if (!WS) {
615
+ throw new Error(
616
+ "No global WebSocket available; pass options.webSocket (e.g. the `ws` package) to omni.connect()",
617
+ );
618
+ }
619
+ this.ws = new WS(url, [subprotocol]);
620
+ this.ws.onopen = () => {
621
+ if (opts.configure) {
622
+ try {
623
+ this.configure(opts.configure);
624
+ } catch {
625
+ /* surfaced via onerror */
626
+ }
627
+ }
628
+ opts.onOpen?.();
629
+ };
630
+ this.ws.onmessage = (ev) => this.handleMessage(ev.data);
631
+ this.ws.onerror = (ev) => opts.onError?.(ev instanceof Error ? ev : new Error("WebSocket error"));
632
+ this.ws.onclose = (ev) => {
633
+ this.closed = true;
634
+ opts.onClose?.(ev.code, ev.reason);
635
+ };
636
+ }
637
+
638
+ private handleMessage(data: unknown): void {
639
+ // Binary = agent audio (play it out). JSON text = an `event`-keyed frame.
640
+ if (typeof data !== "string") {
641
+ this.opts.onAudio?.(data as OmniAudioChunk);
642
+ return;
643
+ }
644
+ let frame: OmniServerFrame;
645
+ try {
646
+ frame = JSON.parse(data) as OmniServerFrame;
647
+ } catch {
648
+ this.opts.onError?.(new Error(`Unparseable Omni frame: ${data.slice(0, 120)}`));
649
+ return;
650
+ }
651
+ this.opts.onEvent?.(frame);
652
+ switch (frame.event) {
653
+ case OmniEvent.Hello:
654
+ this.opts.onHello?.(frame);
655
+ break;
656
+ case OmniEvent.Configured:
657
+ this.opts.onConfigured?.(frame);
658
+ break;
659
+ case OmniEvent.SessionStarted:
660
+ this.opts.onSessionStarted?.(frame);
661
+ break;
662
+ case OmniEvent.Turn:
663
+ this.opts.onTurn?.(frame);
664
+ break;
665
+ case OmniEvent.Transcript:
666
+ this.opts.onTranscript?.(frame);
667
+ break;
668
+ case OmniEvent.BargeIn:
669
+ case OmniEvent.Flush:
670
+ this.opts.onBargeIn?.(frame);
671
+ break;
672
+ case OmniEvent.SessionEnd:
673
+ this.opts.onSessionEnd?.(frame);
674
+ break;
675
+ case OmniEvent.Error:
676
+ this.opts.onError?.(frame);
677
+ break;
678
+ default:
679
+ // Unknown/forward-compatible frame — already delivered via onEvent.
680
+ break;
681
+ }
682
+ }
683
+
684
+ /**
685
+ * Send (or update) the agent config. Always emitted as
686
+ * `{"type":"configure", ...}` — the correct key. (A hand-rolled
687
+ * `{"event":"configure"}` is acked but silently dropped by the engine.)
688
+ */
689
+ configure(cfg: OmniConfigure): void {
690
+ this.ws.send(JSON.stringify({ type: "configure", ...cfg }));
691
+ }
692
+
693
+ /** Stream a chunk of caller audio (PCM16 LE mono at the negotiated rate). */
694
+ sendAudio(chunk: ArrayBufferLike | ArrayBufferView | Blob): void {
695
+ this.ws.send(chunk);
696
+ }
697
+
698
+ /** Send DTMF digits as a `{"type":"dtmf"}` control frame. */
699
+ sendDtmf(digits: string): void {
700
+ this.ws.send(JSON.stringify({ type: "dtmf", digits }));
701
+ }
702
+
703
+ /**
704
+ * Send an arbitrary control frame for forward-compat control types the SDK
705
+ * does not model yet. Reminder: client → server frames are keyed on `type`,
706
+ * never `event`.
707
+ */
708
+ send(frame: Record<string, unknown>): void {
709
+ this.ws.send(JSON.stringify(frame));
710
+ }
711
+
712
+ /** Close the session. */
713
+ close(code: number = WSCloseCode.Normal, reason = ""): void {
714
+ if (!this.closed) this.ws.close(code, reason);
715
+ }
716
+
717
+ /** The underlying socket (escape hatch for advanced use). */
718
+ get socket(): WebSocketLike {
719
+ return this.ws;
720
+ }
721
+
722
+ /** Current WebSocket readyState. */
723
+ get readyState(): number {
724
+ return this.ws.readyState;
725
+ }
726
+ }
727
+
728
+ /* ------------------------------------------------------------------------- *
729
+ * Key introspection — GET /v1/me
730
+ * ------------------------------------------------------------------------- */
731
+
732
+ /** Shape of `GET /v1/me`. Fields are best-effort / forward-compatible. */
733
+ export interface MeResponse {
734
+ object?: string;
735
+ key_id?: string;
736
+ org_id?: string;
737
+ /** "live" | "test" (a.k.a. environment). */
738
+ environment?: string;
739
+ env?: string;
740
+ scopes?: string[];
741
+ limits?: Record<string, unknown>;
742
+ [k: string]: unknown;
743
+ }
744
+
745
+ /* ------------------------------------------------------------------------- *
746
+ * Telephony
747
+ * ------------------------------------------------------------------------- */
748
+
749
+ export interface TelephonyCapabilities {
750
+ voice?: boolean;
751
+ sms?: boolean;
752
+ }
753
+
754
+ export interface TelephonyAvailableNumber {
755
+ object?: "telephony.available_number";
756
+ /** E.164. */
757
+ phone_number: string;
758
+ country?: string;
759
+ area_code?: string | null;
760
+ locality?: string | null;
761
+ region?: string | null;
762
+ capabilities?: TelephonyCapabilities;
763
+ monthly_cost_cents?: number;
764
+ }
765
+
766
+ export interface TelephonyNumber {
767
+ object?: "telephony.number";
768
+ id: string;
769
+ /** E.164. */
770
+ phone_number: string;
771
+ country?: string;
772
+ area_code?: string | null;
773
+ capabilities?: TelephonyCapabilities;
774
+ /** Agent that answers inbound calls to this number. */
775
+ agent_id?: string | null;
776
+ recording?: boolean;
777
+ monthly_cost_cents?: number;
778
+ status?: "active" | "released";
779
+ created_at?: number;
780
+ released_at?: number | null;
781
+ }
782
+
783
+ /* ------------------------------------------------------------------------- *
784
+ * Trace (compliance)
785
+ * ------------------------------------------------------------------------- */
786
+
787
+ export type TraceVerdict = "PASS" | "WARN" | "FAIL";
788
+ export type TraceSeverity = "low" | "medium" | "high" | "critical";
789
+
790
+ export interface TraceInteraction {
791
+ object?: "trace.interaction";
792
+ /** call_id */
793
+ id: string;
794
+ agent_id?: string | null;
795
+ product?: string | null;
796
+ verdict: TraceVerdict;
797
+ findings?: number;
798
+ blocked_turns?: number;
799
+ modified_turns?: number;
800
+ packs_enforced?: string;
801
+ scored_at?: number;
802
+ }
803
+
804
+ export interface TraceFinding {
805
+ object?: "trace.finding";
806
+ id: string;
807
+ /** call_id */
808
+ interaction_id: string;
809
+ agent_id?: string | null;
810
+ check_id: string;
811
+ severity: TraceSeverity;
812
+ verdict?: string;
813
+ confidence?: number;
814
+ action?: "flag" | "preempt_next" | "escalate";
815
+ speaker?: "agent" | "caller" | "any" | null;
816
+ reason?: string;
817
+ preempt_instruction?: string | null;
818
+ at_t?: number | null;
819
+ tier?: number;
820
+ }
821
+
822
+ /* --- per-call eval scorecard (scorecard-v1) -----------------------------
823
+ * Forward-compatible operational timeline + quality metrics for a single call,
824
+ * surfaced on the interaction detail. All fields are optional and present only
825
+ * once the engine emits them (see docs/PLATFORM_ASK_EVALS_ENGINE_2026-06-16.md),
826
+ * so existing callers keep working unchanged.
827
+ */
828
+
829
+ /** A tool invocation recorded on a timeline turn. */
830
+ export interface TraceToolCall {
831
+ name: string;
832
+ /** Arguments the agent passed (engine-shaped JSON). */
833
+ args?: unknown;
834
+ /** Tool result returned to the agent (engine-shaped JSON). */
835
+ result?: unknown;
836
+ /** Audio-timeline position of the call, ms. */
837
+ t_ms?: number;
838
+ }
839
+
840
+ /** Barge-in timing for a timeline turn. */
841
+ export interface TraceBargeMetrics {
842
+ /** Time from caller speech onset to barge-in detection, ms. */
843
+ detect_ms?: number;
844
+ /** Whether the agent cleanly recovered after the barge-in. */
845
+ recovered?: boolean;
846
+ }
847
+
848
+ /**
849
+ * Known timeline-turn roles. Left open (see {@link TraceTimelineTurn.role}) so a
850
+ * new server-side role never breaks the build — branch defensively.
851
+ */
852
+ export type TraceTimelineRole = "agent" | "caller" | "system" | "tool";
853
+
854
+ /** One turn of a per-call operational timeline (eval scorecard-v1). */
855
+ export interface TraceTimelineTurn {
856
+ /** Monotonic turn index within the call. */
857
+ seq: number;
858
+ /** Audio-timeline position of the turn, ms. */
859
+ t_ms: number;
860
+ /** Who spoke/acted this turn. Open-ended for forward-compat. */
861
+ role: TraceTimelineRole | (string & {});
862
+ /** Transcript/text for the turn, when available. */
863
+ text?: string;
864
+ /** Time-to-first-audio for the turn (latency scoring), ms. */
865
+ ttfb_ms?: number;
866
+ /** Turn-detection (endpointing) latency, ms. */
867
+ endpointing_ms?: number;
868
+ /** Barge-in detect/recovery timing for the turn. */
869
+ barge?: TraceBargeMetrics;
870
+ /** Tool calls made during the turn (tool-use scoring). */
871
+ tool_calls?: TraceToolCall[];
872
+ }
873
+
874
+ /**
875
+ * Aggregate per-call quality metrics (eval scorecard-v1). All optional and
876
+ * forward-compatible — present once the engine emits them.
877
+ */
878
+ export interface QualityMetrics {
879
+ /** Word error rate vs. reference transcript, 0–1 (lower is better). */
880
+ wer?: number;
881
+ /** Representative time-to-first-audio across turns, ms. */
882
+ ttfb_ms?: number;
883
+ /** 95th-percentile end-to-end turn latency, ms. */
884
+ turn_p95_ms?: number;
885
+ /** Barge-in recovery rate, 0–1. */
886
+ barge_recovery?: number;
887
+ /** Task-success score, 0–1. */
888
+ task_success?: number;
889
+ /** Composite voice-agent quality index (engine-defined scale). */
890
+ vaqi?: number;
891
+ [k: string]: unknown;
892
+ }
893
+
894
+ export interface TraceInteractionDetail extends TraceInteraction {
895
+ /** Hash-chain link proving the record is unaltered. */
896
+ audit_hash?: string;
897
+ scorecard?: Record<string, unknown>;
898
+ tier2_findings?: TraceFinding[];
899
+ /**
900
+ * Scorecard schema version, e.g. `"trace-scorecard-v1"`. Present once the
901
+ * engine emits the eval block below.
902
+ */
903
+ scorecard_version?: string;
904
+ /**
905
+ * Per-call operational timeline. Forward-compatible: empty/undefined until
906
+ * the engine emits per-turn timing (Ask 1 of the evals engine plan).
907
+ */
908
+ timeline?: TraceTimelineTurn[];
909
+ /** Aggregate per-call quality metrics. Forward-compatible (see above). */
910
+ quality_metrics?: QualityMetrics;
911
+ }
912
+
913
+ export interface TraceViolation {
914
+ object?: "trace.violation";
915
+ id: string;
916
+ /** call_id */
917
+ interaction_id: string;
918
+ agent_id?: string | null;
919
+ rule_id: string;
920
+ pack_id?: string | null;
921
+ severity: TraceSeverity;
922
+ action_taken?: "pass" | "flag" | "modify" | "block";
923
+ citation?: string;
924
+ reason?: string;
925
+ at_t?: number | null;
926
+ }
927
+
928
+ export interface TraceConfigInput {
929
+ agent_id?: string;
930
+ enabled?: boolean;
931
+ channels?: Array<"voice" | "text">;
932
+ /** Map of pack_id → { enabled, version }. */
933
+ rule_packs?: Record<string, { enabled?: boolean; version?: string | null }>;
934
+ guardrails?: Record<string, unknown>;
935
+ [k: string]: unknown;
936
+ }
937
+
938
+ export interface TraceConfig {
939
+ object?: "trace.config";
940
+ agent_id?: string | null;
941
+ enabled?: boolean;
942
+ mode?: "warn" | "modify" | "block" | "human_handoff";
943
+ etag?: string;
944
+ updated_at?: number;
945
+ config?: TraceConfigInput;
946
+ }
947
+
948
+ export interface TraceRulePackSpec {
949
+ pack_id: string;
950
+ version: string;
951
+ jurisdiction?: string;
952
+ legal_status?: string;
953
+ rules: Array<Record<string, unknown>>;
954
+ }
955
+
956
+ export interface TraceRulePack {
957
+ object?: "trace.rule_pack";
958
+ id?: string;
959
+ pack_id: string;
960
+ version?: string;
961
+ builtin?: boolean;
962
+ jurisdiction?: string | null;
963
+ legal_status?: string | null;
964
+ etag?: string;
965
+ status?: "active" | "deprecated";
966
+ created_at?: number;
967
+ /** The authored DSL (only on the single-pack GET). */
968
+ spec?: Record<string, unknown>;
969
+ }
970
+
971
+ export interface TraceExposure {
972
+ object?: "trace.exposure";
973
+ window_days?: number;
974
+ interactions_scanned?: number;
975
+ with_a_gap?: number;
976
+ gap_rate?: number;
977
+ by_rule?: Array<{ rule_id: string; pack_id?: string | null; count: number; rate: number }>;
978
+ by_verdict?: { PASS?: number; WARN?: number; FAIL?: number };
979
+ top_exposure?: string | null;
980
+ }
981
+
982
+ export interface RecapConfigInput {
983
+ enabled?: boolean;
984
+ webhook_url?: string | null;
985
+ default_pack_id?: string;
986
+ }
987
+
988
+ export interface RecapConfig {
989
+ object?: "recap.config";
990
+ enabled?: boolean;
991
+ webhook_url?: string | null;
992
+ default_pack_id?: string;
993
+ updated_at?: number;
994
+ }
995
+
996
+ export interface RecapCallSummary {
997
+ object?: "recap.call";
998
+ call_id: string;
999
+ pack_id?: string;
1000
+ status?: "pending" | "processing" | "complete" | "failed";
1001
+ call_duration_s?: number | null;
1002
+ created_at?: number;
1003
+ completed_at?: number | null;
1004
+ }
1005
+
1006
+ export interface RecapCall extends RecapCallSummary {
1007
+ record?: unknown;
1008
+ error?: string | null;
1009
+ crm_write_status?: string | null;
1010
+ }
1011
+
1012
+ export interface RecapCallTriggerInput {
1013
+ utterances: Array<{ speaker_role?: "agent" | "customer"; text: string; offset_s?: number; duration_s?: number }>;
1014
+ pack_id?: string;
1015
+ call_duration_s?: number;
1016
+ call_direction?: "inbound" | "outbound";
1017
+ customer_name?: string;
1018
+ crm_fields?: Record<string, unknown>;
1019
+ }
1020
+
95
1021
  const RETRYABLE = new Set([429, 500, 502, 503, 504]);
96
1022
 
97
1023
  export class PyAI {
@@ -155,6 +1081,28 @@ export class PyAI {
155
1081
  return (await res.json()) as T;
156
1082
  }
157
1083
 
1084
+ private async postJson<T>(path: string, payload: unknown, extraHeaders: Record<string, string> = {}): Promise<T> {
1085
+ const res = await this.request(path, {
1086
+ method: "POST",
1087
+ headers: this.authHeaders({ "Content-Type": "application/json", ...extraHeaders }),
1088
+ body: JSON.stringify(payload),
1089
+ });
1090
+ return (await res.json()) as T;
1091
+ }
1092
+
1093
+ private async putJson<T>(path: string, payload: unknown): Promise<T> {
1094
+ const res = await this.request(path, {
1095
+ method: "PUT",
1096
+ headers: this.authHeaders({ "Content-Type": "application/json" }),
1097
+ body: JSON.stringify(payload),
1098
+ });
1099
+ return (await res.json()) as T;
1100
+ }
1101
+
1102
+ private deleteReq(path: string): Promise<Response> {
1103
+ return this.request(path, { method: "DELETE", headers: this.authHeaders() });
1104
+ }
1105
+
158
1106
  // --- models -------------------------------------------------------------
159
1107
 
160
1108
  models = {
@@ -209,10 +1157,23 @@ export class PyAI {
209
1157
  file: Blob;
210
1158
  filename?: string;
211
1159
  model?: string;
1160
+ language?: string;
1161
+ response_format?: "json" | "text" | "verbose_json";
1162
+ /**
1163
+ * Deterministic seed for reproducible eval runs. Forward-compatible:
1164
+ * honored once the engine supports it, otherwise ignored.
1165
+ */
1166
+ seed?: number;
1167
+ /** Sampling temperature. Forward-compatible (honored when supported). */
1168
+ temperature?: number;
212
1169
  }): Promise<{ text: string; [k: string]: unknown }> => {
213
1170
  const form = new FormData();
214
1171
  form.set("file", params.file, params.filename ?? "audio.wav");
215
1172
  form.set("model", params.model ?? "pyai-hear");
1173
+ if (params.language) form.set("language", params.language);
1174
+ if (params.response_format) form.set("response_format", params.response_format);
1175
+ if (params.seed !== undefined) form.set("seed", String(params.seed));
1176
+ if (params.temperature !== undefined) form.set("temperature", String(params.temperature));
216
1177
  const res = await this.request("/v1/audio/transcriptions", {
217
1178
  method: "POST",
218
1179
  headers: this.authHeaders(),
@@ -220,6 +1181,14 @@ export class PyAI {
220
1181
  });
221
1182
  return (await res.json()) as { text: string };
222
1183
  },
1184
+ /**
1185
+ * Open a live streaming-STT WebSocket (Hear). Hides the frame protocol
1186
+ * behind `onPartial`/`onFinal`/`onError`; stream audio with `sendAudio`,
1187
+ * force-finalize with `commit()`, flush+close with `close()`. Set
1188
+ * `grounding: true` for Cue (turn detection + KB context).
1189
+ */
1190
+ stream: (opts: HearStreamOptions = {}): HearStream =>
1191
+ new HearStream(this.hearStreamURL(opts), this.realtimeSubprotocol(), opts),
223
1192
  },
224
1193
  };
225
1194
 
@@ -247,6 +1216,229 @@ export class PyAI {
247
1216
  },
248
1217
  };
249
1218
 
1219
+ // --- key introspection --------------------------------------------------
1220
+
1221
+ /**
1222
+ * Introspect the calling key: scopes, environment, and limits. Useful for a
1223
+ * preflight/doctor check. (New route; older deployments may 404 — handle it.)
1224
+ */
1225
+ me = (): Promise<MeResponse> => this.getJson<MeResponse>("/v1/me");
1226
+
1227
+ // --- voice clones -------------------------------------------------------
1228
+
1229
+ clones = {
1230
+ /** List the org's cloned voices. */
1231
+ list: (): Promise<ListResponse<Voice>> => this.getJson("/v1/voice/clones"),
1232
+ /** Enroll a custom voice from reference audio (>= ~10s). Scope `voice:clone`. */
1233
+ create: async (params: { name: string; file: Blob; filename?: string }): Promise<Voice> => {
1234
+ const form = new FormData();
1235
+ form.set("name", params.name);
1236
+ form.set("file", params.file, params.filename ?? "sample.wav");
1237
+ const res = await this.request("/v1/voice/clones", { method: "POST", headers: this.authHeaders(), body: form });
1238
+ return (await res.json()) as Voice;
1239
+ },
1240
+ /**
1241
+ * Fetch a single cloned voice by id. The API exposes no GET-by-id for
1242
+ * clones, so this filters `list()` client-side and throws a 404 `PyAIError`
1243
+ * when the id isn't found.
1244
+ */
1245
+ get: async (id: string): Promise<Voice> => {
1246
+ const { data } = await this.clones.list();
1247
+ const match = data.find((v) => v.id === id);
1248
+ if (!match) throw new PyAIError(404, `No cloned voice ${id}`, ErrorCode.NotFound);
1249
+ return match;
1250
+ },
1251
+ /** Delete a cloned voice (tenant-isolated). Scope `voice:clone`. */
1252
+ delete: async (id: string): Promise<void> => {
1253
+ await this.deleteReq(`/v1/voice/clones/${encodeURIComponent(id)}`);
1254
+ },
1255
+ };
1256
+
1257
+ // --- telephony (managed numbers) ---------------------------------------
1258
+
1259
+ telephony = {
1260
+ numbers: {
1261
+ /** Search the carrier's available US local numbers. */
1262
+ available: (
1263
+ params: { areaCode?: string; contains?: string; limit?: number } = {},
1264
+ ): Promise<ListResponse<TelephonyAvailableNumber>> => {
1265
+ const q = new URLSearchParams();
1266
+ if (params.areaCode) q.set("area_code", params.areaCode);
1267
+ if (params.contains) q.set("contains", params.contains);
1268
+ if (params.limit !== undefined) q.set("limit", String(params.limit));
1269
+ const qs = q.toString();
1270
+ return this.getJson(`/v1/telephony/available${qs ? `?${qs}` : ""}`);
1271
+ },
1272
+ /** List the org's managed numbers (active only unless `includeReleased`). */
1273
+ list: (params: { includeReleased?: boolean } = {}): Promise<ListResponse<TelephonyNumber>> => {
1274
+ const qs = params.includeReleased ? "?include_released=true" : "";
1275
+ return this.getJson(`/v1/telephony/numbers${qs}`);
1276
+ },
1277
+ /** Provision (buy) a specific available number, optionally bound to an agent. */
1278
+ buy: (params: { phone_number: string; agent_id?: string | null }): Promise<TelephonyNumber> =>
1279
+ this.postJson("/v1/telephony/numbers", params),
1280
+ /** Route a number to an agent (`agentId: null` to unassign). */
1281
+ assign: (id: string, agentId: string | null): Promise<TelephonyNumber> =>
1282
+ this.postJson(`/v1/telephony/numbers/${encodeURIComponent(id)}/assign`, { agent_id: agentId }),
1283
+ /** Release a number back to the carrier (idempotent). */
1284
+ release: async (id: string): Promise<TelephonyNumber> => {
1285
+ const res = await this.deleteReq(`/v1/telephony/numbers/${encodeURIComponent(id)}`);
1286
+ return (await res.json()) as TelephonyNumber;
1287
+ },
1288
+ },
1289
+ };
1290
+
1291
+ // --- trace (compliance) -------------------------------------------------
1292
+
1293
+ trace = {
1294
+ interactions: {
1295
+ /** List scanned interactions (scorecards), newest first. Scope `trace:read`. */
1296
+ list: (
1297
+ params: { verdict?: TraceVerdict; agentId?: string; limit?: number; cursor?: string } = {},
1298
+ ): Promise<ListResponse<TraceInteraction>> => {
1299
+ const q = new URLSearchParams();
1300
+ if (params.verdict) q.set("verdict", params.verdict);
1301
+ if (params.agentId) q.set("agent_id", params.agentId);
1302
+ if (params.limit !== undefined) q.set("limit", String(params.limit));
1303
+ if (params.cursor) q.set("cursor", params.cursor);
1304
+ const qs = q.toString();
1305
+ return this.getJson(`/v1/trace/interactions${qs ? `?${qs}` : ""}`);
1306
+ },
1307
+ /** The full per-call evidence view (findings, redactions, audit hash). */
1308
+ get: (id: string): Promise<TraceInteractionDetail> =>
1309
+ this.getJson(`/v1/trace/interactions/${encodeURIComponent(id)}`),
1310
+ },
1311
+ violations: {
1312
+ /** Drill-down of every fired Tier-0 rule across scorecards. Scope `trace:read`. */
1313
+ list: (
1314
+ params: { ruleId?: string; severity?: TraceSeverity; interactionId?: string; limit?: number; cursor?: string } = {},
1315
+ ): Promise<ListResponse<TraceViolation>> => {
1316
+ const q = new URLSearchParams();
1317
+ if (params.ruleId) q.set("rule_id", params.ruleId);
1318
+ if (params.severity) q.set("severity", params.severity);
1319
+ if (params.interactionId) q.set("interaction_id", params.interactionId);
1320
+ if (params.limit !== undefined) q.set("limit", String(params.limit));
1321
+ if (params.cursor) q.set("cursor", params.cursor);
1322
+ const qs = q.toString();
1323
+ return this.getJson(`/v1/trace/violations${qs ? `?${qs}` : ""}`);
1324
+ },
1325
+ },
1326
+ findings: {
1327
+ /** List Tier-2 (async semantic) findings — advisory, non-blocking. Scope `trace:read`. */
1328
+ list: (
1329
+ params: {
1330
+ checkId?: string;
1331
+ action?: "flag" | "preempt_next" | "escalate";
1332
+ severity?: TraceSeverity;
1333
+ interactionId?: string;
1334
+ limit?: number;
1335
+ cursor?: string;
1336
+ } = {},
1337
+ ): Promise<ListResponse<TraceFinding>> => {
1338
+ const q = new URLSearchParams();
1339
+ if (params.checkId) q.set("check_id", params.checkId);
1340
+ if (params.action) q.set("action", params.action);
1341
+ if (params.severity) q.set("severity", params.severity);
1342
+ if (params.interactionId) q.set("interaction_id", params.interactionId);
1343
+ if (params.limit !== undefined) q.set("limit", String(params.limit));
1344
+ if (params.cursor) q.set("cursor", params.cursor);
1345
+ const qs = q.toString();
1346
+ return this.getJson(`/v1/trace/findings${qs ? `?${qs}` : ""}`);
1347
+ },
1348
+ },
1349
+ config: {
1350
+ /** Read per-agent Trace config (omit `agentId` for the org default). Scope `trace:configure`. */
1351
+ get: (agentId?: string): Promise<TraceConfig> =>
1352
+ this.getJson(`/v1/trace/config${agentId ? `?agent_id=${encodeURIComponent(agentId)}` : ""}`),
1353
+ /** Upsert per-agent Trace config. Scope `trace:configure`. */
1354
+ set: (input: TraceConfigInput): Promise<TraceConfig> => this.putJson("/v1/trace/config", input),
1355
+ },
1356
+ rulePacks: {
1357
+ /** List built-in + custom rule packs. Scope `trace:configure`. */
1358
+ list: (): Promise<ListResponse<TraceRulePack>> => this.getJson("/v1/trace/rule-packs"),
1359
+ /** Upload a custom rule pack (Trace DSL). Scope `trace:configure`. */
1360
+ create: (spec: TraceRulePackSpec): Promise<TraceRulePack> => this.postJson("/v1/trace/rule-packs", spec),
1361
+ /** Resolve a rule pack by id (latest active, or pin `version`). */
1362
+ get: (id: string, version?: string): Promise<TraceRulePack> =>
1363
+ this.getJson(`/v1/trace/rule-packs/${encodeURIComponent(id)}${version ? `?version=${encodeURIComponent(version)}` : ""}`),
1364
+ },
1365
+ /** Compliance exposure summary over a trailing window. Scope `trace:read`. */
1366
+ exposure: (windowDays?: number): Promise<TraceExposure> =>
1367
+ this.getJson(`/v1/trace/exposure${windowDays !== undefined ? `?window_days=${windowDays}` : ""}`),
1368
+ /**
1369
+ * Convenience: the per-call operational timeline (eval scorecard-v1). A thin
1370
+ * wrapper over `interactions.get(id)` that returns the `timeline` array, or
1371
+ * `[]` when the engine hasn't emitted one yet (forward-compatible). Scope
1372
+ * `trace:read`.
1373
+ */
1374
+ callTimeline: async (id: string): Promise<TraceTimelineTurn[]> => {
1375
+ const detail = await this.trace.interactions.get(id);
1376
+ return detail.timeline ?? [];
1377
+ },
1378
+ };
1379
+
1380
+ // --- recap (conversation intelligence) --------------------------------
1381
+
1382
+ recap = {
1383
+ config: {
1384
+ get: (): Promise<RecapConfig> => this.getJson("/v1/recap/config"),
1385
+ set: (input: RecapConfigInput): Promise<RecapConfig> => this.putJson("/v1/recap/config", input),
1386
+ },
1387
+ calls: {
1388
+ list: (params: { limit?: number; cursor?: string; status?: string } = {}): Promise<ListResponse<RecapCallSummary>> => {
1389
+ const q = new URLSearchParams();
1390
+ if (params.limit !== undefined) q.set("limit", String(params.limit));
1391
+ if (params.cursor) q.set("cursor", params.cursor);
1392
+ if (params.status) q.set("status", params.status);
1393
+ const qs = q.toString();
1394
+ return this.getJson(`/v1/recap/calls${qs ? `?${qs}` : ""}`);
1395
+ },
1396
+ get: (callId: string): Promise<RecapCall> =>
1397
+ this.getJson(`/v1/recap/calls/${encodeURIComponent(callId)}`),
1398
+ trigger: (callId: string, input: RecapCallTriggerInput): Promise<RecapCallSummary> =>
1399
+ this.postJson(`/v1/recap/calls/${encodeURIComponent(callId)}`, input),
1400
+ },
1401
+ };
1402
+
1403
+ // --- omni (agentic voice) ----------------------------------------------
1404
+
1405
+ omni = {
1406
+ /**
1407
+ * Mint an ephemeral, origin-locked Omni session token a browser can use to
1408
+ * open ONE realtime session **directly** — the public/private split for
1409
+ * realtime. **Call this from your server** with a secret key holding
1410
+ * `omni:session`; never ship the secret key to a page. Hand the returned
1411
+ * `token` to the browser, which connects with
1412
+ * `new WebSocket(session.url, ["pyai-key." + session.token])`. The token
1413
+ * expires after `ttlSeconds` (default 60s) and only works from
1414
+ * `allowedOrigins`. Scope `omni:session`.
1415
+ */
1416
+ createSession: (params: OmniSessionParams): Promise<OmniSession> =>
1417
+ this.postJson("/v1/omni/sessions", {
1418
+ allowed_origins: params.allowedOrigins,
1419
+ ...(params.ttlSeconds !== undefined ? { ttl_seconds: params.ttlSeconds } : {}),
1420
+ ...(params.sessionLabel !== undefined ? { session_label: params.sessionLabel } : {}),
1421
+ }),
1422
+
1423
+ /**
1424
+ * Open a live Omni agentic-voice session over `/v1/omni`. Returns an
1425
+ * {@link OmniConnection} that handles the wire protocol's frame-key
1426
+ * asymmetry for you — it sends `type`-keyed control frames (`configure`,
1427
+ * `dtmf`) and parses `event`-keyed server frames — so you can't trip the #1
1428
+ * Omni integration bug. Omni is zero-state: nothing to create first; the
1429
+ * agent's behavior travels in the `configure` frame. Pass `token` (from
1430
+ * `createSession`) to connect from a browser without the secret key.
1431
+ */
1432
+ connect: (opts: OmniConnectOptions = {}): OmniConnection => {
1433
+ const query: Record<string, string> = { ...(opts.query ?? {}) };
1434
+ if (opts.format) query.format = opts.format;
1435
+ if (opts.rate) query.rate = String(opts.rate);
1436
+ const url = this.realtimeURL({ product: "omni", sessionLabel: opts.sessionLabel, query });
1437
+ const sub = opts.token ? `pyai-key.${opts.token}` : this.realtimeSubprotocol();
1438
+ return new OmniConnection(url, sub, opts);
1439
+ },
1440
+ };
1441
+
250
1442
  // --- realtime (WebSocket) ----------------------------------------------
251
1443
 
252
1444
  /** Build the realtime WebSocket URL for the chosen product. */
@@ -254,10 +1446,12 @@ export class PyAI {
254
1446
  const wsBase = this.baseURL.replace(/^http/, "ws");
255
1447
  const q = new URLSearchParams(opts.query ?? {});
256
1448
  if ((opts.product ?? "omni") === "omni") {
257
- // Omni's native realtime surface is /v1/omni. agentId is an opaque label
258
- // authorized by the key's org. format/rate are load-bearing on the
259
- // connect URL, so default to browser-grade PCM16/24kHz.
260
- if (opts.agentId) q.set("agent_id", opts.agentId);
1449
+ // Omni's native realtime surface is /v1/omni. The session is authorized by
1450
+ // the key's org (zero-state) sessionLabel is an optional opaque tag.
1451
+ // format/rate are load-bearing on the connect URL, so default to
1452
+ // browser-grade PCM16/24kHz.
1453
+ if (opts.sessionLabel) q.set("session_label", opts.sessionLabel);
1454
+ else if (opts.agentId) q.set("agent_id", opts.agentId); // deprecated alias
261
1455
  if (!q.has("format")) q.set("format", "pcm16");
262
1456
  if (!q.has("rate")) q.set("rate", "24000");
263
1457
  const qs = q.toString();
@@ -272,6 +1466,21 @@ export class PyAI {
272
1466
  return `pyai-key.${this.apiKey}`;
273
1467
  }
274
1468
 
1469
+ /** Build the Hear streaming-STT WebSocket URL (`/v1/audio/transcriptions/stream`). */
1470
+ hearStreamURL(opts: HearStreamOptions = {}): string {
1471
+ const wsBase = this.baseURL.replace(/^http/, "ws");
1472
+ const q = new URLSearchParams(opts.query ?? {});
1473
+ if (opts.model) q.set("model", opts.model);
1474
+ if (opts.language) q.set("language", opts.language);
1475
+ if (opts.sampleRate !== undefined) q.set("sample_rate", String(opts.sampleRate));
1476
+ if (opts.encoding) q.set("encoding", opts.encoding);
1477
+ if (opts.interimResults !== undefined) q.set("interim_results", String(opts.interimResults));
1478
+ if (opts.numerals !== undefined) q.set("numerals", String(opts.numerals));
1479
+ if (opts.endpointingMs !== undefined) q.set("endpointing_ms", String(opts.endpointingMs));
1480
+ const qs = q.toString();
1481
+ return `${wsBase}/v1/audio/transcriptions/stream${qs ? `?${qs}` : ""}`;
1482
+ }
1483
+
275
1484
  /**
276
1485
  * Open a realtime WebSocket. Uses the global WebSocket (browser, Node 22+).
277
1486
  * The key travels as a subprotocol so it works from the browser without