@pyai/sdk 0.2.1 → 0.2.2

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
@@ -1,10 +1,9 @@
1
1
  /**
2
- * @pyai/sdk official TypeScript/JavaScript client for the PyAI API.
2
+ * @pyai/sdk, official TypeScript/JavaScript client for the PyAI API.
3
3
  *
4
4
  * Thin, dependency-free wrapper over the public OpenAI-compatible surface at
5
5
  * https://api.pyai.com (contract: https://api.pyai.com/openapi.json). Runs in
6
- * the browser and Node 22+ (uses global fetch + WebSocket). Keys are opaque
7
- * never parsed.
6
+ * the browser and Node 22+ (uses global fetch + WebSocket). Keys are opaque, * never parsed.
8
7
  */
9
8
 
10
9
  export interface PyAIOptions {
@@ -61,7 +60,7 @@ export interface TranscriptionJob {
61
60
 
62
61
  /**
63
62
  * 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
63
+ * server accepts on `POST /v1/audio/speech`, any other value is rejected with
65
64
  * `400 unsupported_format`. The default (when `response_format` is omitted) is
66
65
  * `mp3`. Omit `sample_rate` for the engine's native 24 kHz (`g711_*` is always
67
66
  * 8 kHz).
@@ -96,20 +95,20 @@ export interface SpeechParams {
96
95
  model?: string;
97
96
  /**
98
97
  * Output container/codec, resampled+encoded server-side. One of
99
- * {@link SpeechFormat} anything else is a `400 unsupported_format`. Omit for
98
+ * {@link SpeechFormat}, anything else is a `400 unsupported_format`. Omit for
100
99
  * the default of `mp3`.
101
100
  *
102
- * `g711_ulaw`/`g711_alaw` return raw 8 kHz mono G.711 the bytes Twilio/SIP
101
+ * `g711_ulaw`/`g711_alaw` return raw 8 kHz mono G.711, the bytes Twilio/SIP
103
102
  * media streams expect, so you can hand the response straight to a telephony
104
103
  * frame without a client-side resampler or μ-law encoder. `sample_rate` is
105
104
  * forced to 8000 for those (omit it, or set exactly 8000). `pcm` is raw,
106
105
  * headerless int16 LE mono at `sample_rate`. `mp3`/`opus` are buffered (not
107
- * chunk-streamed) use them with `speech`, not `speechStream`.
106
+ * chunk-streamed), use them with `speech`, not `speechStream`.
108
107
  */
109
108
  response_format?: SpeechFormat;
110
109
  /**
111
110
  * Output sample rate in Hz. One of {@link SpeechSampleRate}
112
- * (8000/16000/24000/48000) anything else is a `400`. Omit for the engine's
111
+ * (8000/16000/24000/48000), anything else is a `400`. Omit for the engine's
113
112
  * native 24 kHz; `g711_*` is always 8000 (forced). Set `8000`/`16000` for
114
113
  * telephony pipelines, most often with `response_format: "pcm"`.
115
114
  */
@@ -122,8 +121,7 @@ export interface SpeechParams {
122
121
  */
123
122
  seed?: number;
124
123
  /**
125
- * Sampling temperature (lower = more deterministic). Forward-compatible
126
- * honored once the engine supports it, otherwise ignored.
124
+ * Sampling temperature (lower = more deterministic). Forward-compatible, * honored once the engine supports it, otherwise ignored.
127
125
  */
128
126
  temperature?: number;
129
127
  }
@@ -148,7 +146,7 @@ export interface RealtimeOptions {
148
146
  */
149
147
  sessionLabel?: string;
150
148
  /**
151
- * @deprecated Use {@link sessionLabel}. Kept for back-compat emitted as the
149
+ * @deprecated Use {@link sessionLabel}. Kept for back-compat, emitted as the
152
150
  * `agent_id` query alias, which the gateway still accepts. Ignored if
153
151
  * `sessionLabel` is set.
154
152
  */
@@ -161,7 +159,7 @@ export interface RealtimeOptions {
161
159
  export interface OmniSessionParams {
162
160
  /**
163
161
  * 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
162
+ * Required and non-empty, a browser token must be origin-locked. `*` is not
165
163
  * allowed.
166
164
  */
167
165
  allowedOrigins: string[];
@@ -172,7 +170,7 @@ export interface OmniSessionParams {
172
170
  }
173
171
 
174
172
  /**
175
- * The result of {@link PyAI.omni}.createSession a short-lived credential safe
173
+ * The result of {@link PyAI.omni}.createSession, a short-lived credential safe
176
174
  * to hand to the browser. Use {@link OmniSession.token} as the WebSocket
177
175
  * subprotocol `pyai-key.<token>` against {@link OmniSession.url}.
178
176
  */
@@ -189,7 +187,7 @@ export interface OmniSession {
189
187
  }
190
188
 
191
189
  /* ------------------------------------------------------------------------- *
192
- * Stable enums mirror the server so callers branch on named constants, not
190
+ * Stable enums, mirror the server so callers branch on named constants, not
193
191
  * magic strings, and a contract change surfaces in one place. These are plain
194
192
  * `as const` objects (not TS `enum`s) so the source still runs directly under
195
193
  * Node's type-stripping and stays tree-shakeable.
@@ -227,7 +225,7 @@ export type WSCloseCode = (typeof WSCloseCode)[keyof typeof WSCloseCode];
227
225
 
228
226
  /**
229
227
  * 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
228
+ * these. The set is treated as open, `PyAIError.code` stays `string`, so a
231
229
  * new server code never breaks the build, but the known ones are named here.
232
230
  */
233
231
  export const ErrorCode = {
@@ -308,7 +306,7 @@ export interface HearErrorFrame {
308
306
  export type HearFrame = HearPartialFrame | HearFinalFrame | HearUsageFrame | HearErrorFrame;
309
307
 
310
308
  /**
311
- * Minimal structural WebSocket matches both the browser/Node global
309
+ * Minimal structural WebSocket, matches both the browser/Node global
312
310
  * `WebSocket` and the `ws` package, and lets tests inject a mock.
313
311
  */
314
312
  export interface WebSocketLike {
@@ -341,8 +339,8 @@ export interface HearStreamOptions {
341
339
  */
342
340
  numerals?: boolean;
343
341
  /**
344
- * Turn-segmentation tuning: trailing-pause (ms, 502000) that ends an
345
- * utterance. Forwards `?endpointing_ms=` clamped + honored once the engine
342
+ * Turn-segmentation tuning: trailing-pause (ms, 50-2000) that ends an
343
+ * utterance. Forwards `?endpointing_ms=`, clamped + honored once the engine
346
344
  * supports it; a no-op when omitted. Drive end-of-turn yourself with
347
345
  * {@link HearStream.commit} for full control today.
348
346
  */
@@ -353,12 +351,12 @@ export interface HearStreamOptions {
353
351
  * array. Bills a single `cue.minutes` line instead of the Hear rate.
354
352
  */
355
353
  grounding?: boolean;
356
- /** Cue: number of KB passages to retrieve per turn (120, default 3). */
354
+ /** Cue: number of KB passages to retrieve per turn (1-20, default 3). */
357
355
  groundingK?: number;
358
- /** Cue: drop passages scoring below this (01, default 0 = keep all). */
356
+ /** Cue: drop passages scoring below this (0-1, default 0 = keep all). */
359
357
  groundingMinScore?: number;
360
358
  /** Cue: max ms to wait for retrieval at the final before failing open to
361
- * `grounding: []` (502000, default 450). */
359
+ * `grounding: []` (50-2000, default 450). */
362
360
  groundingTimeoutMs?: number;
363
361
  /** Extra query params merged onto the connect URL. */
364
362
  query?: Record<string, string>;
@@ -446,7 +444,7 @@ export class HearStream {
446
444
  this.opts.onError?.(frame);
447
445
  break;
448
446
  default:
449
- // Unknown/forward-compatible frame ignore.
447
+ // Unknown/forward-compatible frame, ignore.
450
448
  break;
451
449
  }
452
450
  }
@@ -477,8 +475,79 @@ export class HearStream {
477
475
  }
478
476
  }
479
477
 
478
+ /**
479
+ * A live AMD (answering-machine-detection) stream. The wire is Twilio's Media
480
+ * Streams protocol, so if you're already on Twilio the usual path is one line of
481
+ * TwiML pointing `<Stream url="wss://api.pyai.com/v1/amd/stream">` at PyAI, no
482
+ * SDK needed. This helper is for server-side clients that fork the media
483
+ * themselves: send Twilio `start`/`media`/`stop` frames with {@link AmdStream.send}
484
+ * (or raw μ-law audio with {@link AmdStream.sendAudio}) and receive the `amd`
485
+ * decision via `onDecision`. Construct via `pyai.amd.stream()`.
486
+ */
487
+ export class AmdStream {
488
+ private readonly ws: WebSocketLike;
489
+ private readonly opts: AmdStreamOptions;
490
+ private closed = false;
491
+
492
+ constructor(url: string, subprotocol: string, opts: AmdStreamOptions) {
493
+ this.opts = opts;
494
+ const WS = opts.webSocket ?? (globalThis as { WebSocket?: WebSocketCtor }).WebSocket;
495
+ if (!WS) {
496
+ throw new Error(
497
+ "No global WebSocket available; pass options.webSocket (e.g. the `ws` package) to amd.stream()",
498
+ );
499
+ }
500
+ this.ws = new WS(url, [subprotocol]);
501
+ this.ws.onopen = () => opts.onOpen?.();
502
+ this.ws.onmessage = (ev) => this.handleMessage(ev.data);
503
+ this.ws.onerror = (ev) => opts.onError?.(ev instanceof Error ? ev : new Error("WebSocket error"));
504
+ this.ws.onclose = (ev) => {
505
+ this.closed = true;
506
+ opts.onClose?.(ev.code, ev.reason);
507
+ };
508
+ }
509
+
510
+ private handleMessage(data: unknown): void {
511
+ if (typeof data !== "string") return; // AMD decisions are JSON text frames
512
+ let frame: Record<string, unknown>;
513
+ try {
514
+ frame = JSON.parse(data) as Record<string, unknown>;
515
+ } catch {
516
+ this.opts.onError?.(new Error(`Unparseable AMD frame: ${data.slice(0, 120)}`));
517
+ return;
518
+ }
519
+ if (frame.event === "amd") this.opts.onDecision?.(frame as AmdDecisionEvent);
520
+ this.opts.onMessage?.(frame);
521
+ }
522
+
523
+ /** Send a Twilio Media Streams control/media frame (JSON) or a raw string. */
524
+ send(frame: string | Record<string, unknown>): void {
525
+ this.ws.send(typeof frame === "string" ? frame : JSON.stringify(frame));
526
+ }
527
+
528
+ /** Send a raw audio chunk (G.711 μ-law 8 kHz for the Twilio-native path). */
529
+ sendAudio(chunk: ArrayBufferLike | ArrayBufferView | Blob): void {
530
+ this.ws.send(chunk);
531
+ }
532
+
533
+ /** Close the socket. */
534
+ close(code: number = WSCloseCode.Normal, reason = ""): void {
535
+ if (!this.closed) this.ws.close(code, reason);
536
+ }
537
+
538
+ /** The underlying socket (escape hatch for advanced use). */
539
+ get socket(): WebSocketLike {
540
+ return this.ws;
541
+ }
542
+
543
+ /** Current WebSocket readyState. */
544
+ get readyState(): number {
545
+ return this.ws.readyState;
546
+ }
547
+ }
548
+
480
549
  /* ------------------------------------------------------------------------- *
481
- * Omni realtime (agentic voice) typed client over the /v1/omni WebSocket
550
+ * Omni realtime (agentic voice), typed client over the /v1/omni WebSocket
482
551
  * ------------------------------------------------------------------------- */
483
552
 
484
553
  /**
@@ -502,6 +571,8 @@ export const OmniEvent = {
502
571
  BargeIn: "barge_in",
503
572
  /** Alias for barge-in on some engine builds. */
504
573
  Flush: "flush",
574
+ /** Engine requests a client-loop tool invocation. */
575
+ ToolCall: "tool_call",
505
576
  /** Session is closing; see close code. */
506
577
  SessionEnd: "session_end",
507
578
  /** Server fault frame. */
@@ -518,13 +589,59 @@ export interface OmniServerFrame {
518
589
  /** A binary agent-audio chunk delivered to {@link OmniConnectOptions.onAudio}. */
519
590
  export type OmniAudioChunk = ArrayBuffer | ArrayBufferView | Blob;
520
591
 
592
+ /** Extract a byte view from a binary WS frame (Buffer / ArrayBuffer / typed
593
+ * array). Returns null for a Blob or unknown (can't be read synchronously). */
594
+ function omniToBytes(data: unknown): Uint8Array | null {
595
+ if (data instanceof Uint8Array) return data; // also covers Node Buffer
596
+ if (data instanceof ArrayBuffer) return new Uint8Array(data);
597
+ if (ArrayBuffer.isView(data)) {
598
+ const v = data as ArrayBufferView;
599
+ return new Uint8Array(v.buffer, v.byteOffset, v.byteLength);
600
+ }
601
+ return null;
602
+ }
603
+
604
+ /** A 0x01-prefixed audio frame, the engine's client→server framing for caller
605
+ * PCM16 LE (see OMNI_PROTOCOL_V2.md §2). */
606
+ function omniAudioFrame(pcm: Uint8Array): Uint8Array {
607
+ const out = new Uint8Array(pcm.length + 1);
608
+ out[0] = 0x01;
609
+ out.set(pcm, 1);
610
+ return out;
611
+ }
612
+
613
+ /** A 0x03-prefixed control frame, the engine's client→server framing for
614
+ * `configure`/`dtmf`/`tool_result` (see OMNI_PROTOCOL_V2.md §2/§3). */
615
+ function omniControlFrame(obj: unknown): Uint8Array {
616
+ const json = new TextEncoder().encode(JSON.stringify(obj));
617
+ const out = new Uint8Array(json.length + 1);
618
+ out[0] = 0x03;
619
+ out.set(json, 1);
620
+ return out;
621
+ }
622
+
623
+ export interface OmniToolDef {
624
+ name: string;
625
+ description?: string;
626
+ parameters?: Record<string, unknown>;
627
+ /** When set, engine-POST mode. Omit for client-loop (default). */
628
+ endpoint?: string;
629
+ }
630
+
631
+ export interface OmniToolCallFrame {
632
+ type: "tool_call";
633
+ call_id: string;
634
+ name: string;
635
+ arguments?: Record<string, unknown>;
636
+ }
637
+
521
638
  /**
522
639
  * The agent config sent as the `configure` control frame. These are the wire
523
640
  * (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.
641
+ * fields (e.g. `greeting`, or `model_tier` once the engine acks it) pass
642
+ * straight through. The SDK supplies the `{"type":"configure"}` envelope for
643
+ * you, which is the whole point: a hand-rolled `{"event":"configure"}` is
644
+ * acked but silently dropped, leaving the agent with no brain and zero turns.
528
645
  */
529
646
  export interface OmniConfigure {
530
647
  /** Voice to speak with (stock / clone / designed id). */
@@ -535,6 +652,19 @@ export interface OmniConfigure {
535
652
  kb_endpoint?: string;
536
653
  /** Bearer the engine presents to `kb_endpoint`. */
537
654
  kb_token?: string;
655
+ /**
656
+ * Session language, end to end (recognition, reasoning, voice). Also
657
+ * settable on the agent profile (`language` on `POST /v1/agents`), which
658
+ * applies automatically when connecting with `session_label={agent_id}`;
659
+ * an inline value here wins for the session. Default `en`. Fail-safe: an
660
+ * unknown/not-yet-enabled language falls back to `en` (the `configured`
661
+ * ack carries `language_active` + `language_fallback: true`), the call
662
+ * proceeds and bills as what was served. Per-language availability is
663
+ * staged, see the Language support reference.
664
+ */
665
+ language?: "en" | "fr" | "es" | "de" | "hi";
666
+ /** Function calling definitions (client-loop when `endpoint` is omitted). */
667
+ tools?: OmniToolDef[];
538
668
  /** Forward-compatible: any other key the engine honors. */
539
669
  [k: string]: unknown;
540
670
  }
@@ -561,7 +691,7 @@ export interface OmniConnectOptions {
561
691
  query?: Record<string, string>;
562
692
  /** Fired once the socket opens (after the optional auto-configure). */
563
693
  onOpen?: () => void;
564
- /** Fired for each binary agent-audio chunk play it out as it arrives. */
694
+ /** Fired for each binary agent-audio chunk, play it out as it arrives. */
565
695
  onAudio?: (chunk: OmniAudioChunk) => void;
566
696
  /** Fired on the `hello` handshake frame. */
567
697
  onHello?: (frame: OmniServerFrame) => void;
@@ -575,6 +705,8 @@ export interface OmniConnectOptions {
575
705
  onTranscript?: (frame: OmniServerFrame) => void;
576
706
  /** Fired on `barge_in` / `flush` (user interrupted). */
577
707
  onBargeIn?: (frame: OmniServerFrame) => void;
708
+ /** Fired when the engine requests a client-loop tool invocation. */
709
+ onToolCall?: (frame: OmniToolCallFrame) => void;
578
710
  /** Fired on `session_end`. */
579
711
  onSessionEnd?: (frame: OmniServerFrame) => void;
580
712
  /** Fired on EVERY JSON frame (including unknown/forward-compat ones). */
@@ -607,6 +739,8 @@ export class OmniConnection {
607
739
  private readonly ws: WebSocketLike;
608
740
  private readonly opts: OmniConnectOptions;
609
741
  private closed = false;
742
+ /** Serializes async Blob reads in {@link sendAudio} so frames stay ordered. */
743
+ private blobTail: Promise<void> = Promise.resolve();
610
744
 
611
745
  constructor(url: string, subprotocol: string, opts: OmniConnectOptions) {
612
746
  this.opts = opts;
@@ -636,20 +770,52 @@ export class OmniConnection {
636
770
  }
637
771
 
638
772
  private handleMessage(data: unknown): void {
639
- // Binary = agent audio (play it out). JSON text = an `event`-keyed frame.
773
+ // Server client binary frames are TYPE-TAGGED by their first byte:
774
+ // 0x01 = agent audio (PCM16) · 0x02 = transcript JSON · 0x03 = control JSON.
775
+ // (Treating every binary frame as audio, the old behavior, plays the
776
+ // 0x03/0x02 frames as a glitch and drops every event/transcript.)
640
777
  if (typeof data !== "string") {
641
- this.opts.onAudio?.(data as OmniAudioChunk);
778
+ const bytes = omniToBytes(data);
779
+ if (!bytes) {
780
+ this.opts.onAudio?.(data as OmniAudioChunk); // Blob/unknown, best-effort
781
+ return;
782
+ }
783
+ const tag = bytes[0];
784
+ if (tag === 0x01) {
785
+ this.opts.onAudio?.(bytes.slice(1) as OmniAudioChunk); // copy → aligned PCM16
786
+ return;
787
+ }
788
+ if (tag === 0x02 || tag === 0x03) {
789
+ try {
790
+ const parsed = JSON.parse(new TextDecoder().decode(bytes.subarray(1))) as OmniServerFrame;
791
+ // The 0x02 tag is authoritative for transcript even if the JSON omits `event`.
792
+ this.dispatchFrame(tag === 0x02 ? { ...parsed, event: "transcript" } : parsed);
793
+ } catch {
794
+ this.opts.onError?.(new Error("Unparseable Omni binary frame"));
795
+ }
796
+ return;
797
+ }
798
+ this.opts.onAudio?.(data as OmniAudioChunk); // untagged, forward-compat as audio
642
799
  return;
643
800
  }
644
- let frame: OmniServerFrame;
801
+ // Text frame (e.g. a server-side broker relay).
645
802
  try {
646
- frame = JSON.parse(data) as OmniServerFrame;
803
+ this.dispatchFrame(JSON.parse(data) as OmniServerFrame);
647
804
  } catch {
648
805
  this.opts.onError?.(new Error(`Unparseable Omni frame: ${data.slice(0, 120)}`));
649
- return;
650
806
  }
651
- this.opts.onEvent?.(frame);
652
- switch (frame.event) {
807
+ }
808
+
809
+ private dispatchFrame(frame: OmniServerFrame): void {
810
+ const raw = frame as unknown as { event?: unknown; type?: unknown };
811
+ const eventName =
812
+ typeof raw.event === "string"
813
+ ? raw.event
814
+ : typeof raw.type === "string"
815
+ ? raw.type
816
+ : "";
817
+ this.opts.onEvent?.({ ...frame, event: eventName });
818
+ switch (eventName) {
653
819
  case OmniEvent.Hello:
654
820
  this.opts.onHello?.(frame);
655
821
  break;
@@ -669,6 +835,9 @@ export class OmniConnection {
669
835
  case OmniEvent.Flush:
670
836
  this.opts.onBargeIn?.(frame);
671
837
  break;
838
+ case OmniEvent.ToolCall:
839
+ this.opts.onToolCall?.(frame as unknown as OmniToolCallFrame);
840
+ break;
672
841
  case OmniEvent.SessionEnd:
673
842
  this.opts.onSessionEnd?.(frame);
674
843
  break;
@@ -676,28 +845,49 @@ export class OmniConnection {
676
845
  this.opts.onError?.(frame);
677
846
  break;
678
847
  default:
679
- // Unknown/forward-compatible frame already delivered via onEvent.
848
+ // Unknown/forward-compatible frame, already delivered via onEvent.
680
849
  break;
681
850
  }
682
851
  }
683
852
 
684
853
  /**
685
854
  * Send (or update) the agent config. Always emitted as
686
- * `{"type":"configure", ...}` the correct key. (A hand-rolled
855
+ * `{"type":"configure", ...}`, the correct key. (A hand-rolled
687
856
  * `{"event":"configure"}` is acked but silently dropped by the engine.)
688
857
  */
689
858
  configure(cfg: OmniConfigure): void {
690
- this.ws.send(JSON.stringify({ type: "configure", ...cfg }));
859
+ this.ws.send(omniControlFrame({ type: "configure", ...cfg }));
691
860
  }
692
861
 
693
- /** Stream a chunk of caller audio (PCM16 LE mono at the negotiated rate). */
862
+ /**
863
+ * Stream a chunk of caller audio (PCM16 LE mono at the negotiated rate) as a
864
+ * `0x01`-prefixed frame. The engine demuxes on the first byte and has no
865
+ * default branch, so an untagged chunk is dropped with no error.
866
+ */
694
867
  sendAudio(chunk: ArrayBufferLike | ArrayBufferView | Blob): void {
695
- this.ws.send(chunk);
868
+ const bytes = omniToBytes(chunk);
869
+ if (bytes) {
870
+ this.ws.send(omniAudioFrame(bytes));
871
+ return;
872
+ }
873
+ // A Blob can only be read asynchronously; queue behind the previous read so
874
+ // frames still reach the engine in call order.
875
+ this.blobTail = this.blobTail
876
+ .then(async () => {
877
+ const buf = await (chunk as Blob).arrayBuffer();
878
+ this.ws.send(omniAudioFrame(new Uint8Array(buf)));
879
+ })
880
+ .catch((err) => this.opts.onError?.(err instanceof Error ? err : new Error(String(err))));
696
881
  }
697
882
 
698
883
  /** Send DTMF digits as a `{"type":"dtmf"}` control frame. */
699
884
  sendDtmf(digits: string): void {
700
- this.ws.send(JSON.stringify({ type: "dtmf", digits }));
885
+ this.ws.send(omniControlFrame({ type: "dtmf", digits }));
886
+ }
887
+
888
+ /** Reply to a client-loop {@link OmniEvent.ToolCall}. */
889
+ toolResult(callId: string, payload: { result?: unknown; error?: string }): void {
890
+ this.ws.send(omniControlFrame({ type: "tool_result", call_id: callId, ...payload }));
701
891
  }
702
892
 
703
893
  /**
@@ -706,7 +896,7 @@ export class OmniConnection {
706
896
  * never `event`.
707
897
  */
708
898
  send(frame: Record<string, unknown>): void {
709
- this.ws.send(JSON.stringify(frame));
899
+ this.ws.send(omniControlFrame(frame));
710
900
  }
711
901
 
712
902
  /** Close the session. */
@@ -726,7 +916,7 @@ export class OmniConnection {
726
916
  }
727
917
 
728
918
  /* ------------------------------------------------------------------------- *
729
- * Key introspection GET /v1/me
919
+ * Key introspection, GET /v1/me
730
920
  * ------------------------------------------------------------------------- */
731
921
 
732
922
  /** Shape of `GET /v1/me`. Fields are best-effort / forward-compatible. */
@@ -847,7 +1037,7 @@ export interface TraceBargeMetrics {
847
1037
 
848
1038
  /**
849
1039
  * Known timeline-turn roles. Left open (see {@link TraceTimelineTurn.role}) so a
850
- * new server-side role never breaks the build branch defensively.
1040
+ * new server-side role never breaks the build, branch defensively.
851
1041
  */
852
1042
  export type TraceTimelineRole = "agent" | "caller" | "system" | "tool";
853
1043
 
@@ -873,18 +1063,18 @@ export interface TraceTimelineTurn {
873
1063
 
874
1064
  /**
875
1065
  * Aggregate per-call quality metrics (eval scorecard-v1). All optional and
876
- * forward-compatible present once the engine emits them.
1066
+ * forward-compatible, present once the engine emits them.
877
1067
  */
878
1068
  export interface QualityMetrics {
879
- /** Word error rate vs. reference transcript, 01 (lower is better). */
1069
+ /** Word error rate vs. reference transcript, 0-1 (lower is better). */
880
1070
  wer?: number;
881
1071
  /** Representative time-to-first-audio across turns, ms. */
882
1072
  ttfb_ms?: number;
883
1073
  /** 95th-percentile end-to-end turn latency, ms. */
884
1074
  turn_p95_ms?: number;
885
- /** Barge-in recovery rate, 01. */
1075
+ /** Barge-in recovery rate, 0-1. */
886
1076
  barge_recovery?: number;
887
- /** Task-success score, 01. */
1077
+ /** Task-success score, 0-1. */
888
1078
  task_success?: number;
889
1079
  /** Composite voice-agent quality index (engine-defined scale). */
890
1080
  vaqi?: number;
@@ -1018,6 +1208,103 @@ export interface RecapCallTriggerInput {
1018
1208
  crm_fields?: Record<string, unknown>;
1019
1209
  }
1020
1210
 
1211
+ // --- AMD (answering-machine detection) -----------------------------------
1212
+
1213
+ /** PyAI's richer answered-by vocabulary (superset of Twilio's enum). */
1214
+ export type AmdAnsweredBy =
1215
+ | "human"
1216
+ | "voicemail"
1217
+ | "live_voicemail"
1218
+ | "screening" // iPhone / Google Call Screen
1219
+ | "ivr"
1220
+ | "human_gatekeeper"
1221
+ | "sit_invalid" // dead / disconnected number
1222
+ | "fax"
1223
+ | "silence"
1224
+ | "unknown";
1225
+
1226
+ /** Twilio's `AnsweredBy` enum, echoed for drop-in migration parity. */
1227
+ export type AmdTwilioAnsweredBy =
1228
+ | "human"
1229
+ | "machine_start"
1230
+ | "machine_end_beep"
1231
+ | "machine_end_silence"
1232
+ | "machine_end_other"
1233
+ | "fax"
1234
+ | "unknown";
1235
+
1236
+ export interface AmdConfigInput {
1237
+ /** Operating point on the ROC curve, [0,1]. Near 0 = human-safe (never hang up
1238
+ * on a person); near 1 = fire `machine` fast. A per-call TwiML `<Parameter>`
1239
+ * overrides this. */
1240
+ aggressiveness?: number;
1241
+ /** Signed POST target for `amd.call.completed` events (https). */
1242
+ webhookUrl?: string | null;
1243
+ }
1244
+
1245
+ export interface AmdConfig {
1246
+ object?: "amd.config";
1247
+ aggressiveness?: number;
1248
+ webhook_url?: string | null;
1249
+ updated_at?: number;
1250
+ }
1251
+
1252
+ export interface AmdCallSummary {
1253
+ object?: "amd.call";
1254
+ call_id: string;
1255
+ session_label?: string | null;
1256
+ status?: "completed" | "failed";
1257
+ answered_by?: AmdAnsweredBy;
1258
+ /** Twilio-enum projection of `answered_by` for drop-in routing parity. */
1259
+ answered_by_twilio?: string | null;
1260
+ confidence?: number | null;
1261
+ /** Latency from answer to decision, in ms. */
1262
+ decision_ms?: number | null;
1263
+ created_at?: number;
1264
+ }
1265
+
1266
+ export interface AmdCall extends AmdCallSummary {
1267
+ /** Human-readable evidence, e.g. "machine phrase: 'leave a message' @1.2s". */
1268
+ reason?: string | null;
1269
+ aggressiveness?: number | null;
1270
+ started_at?: number | null;
1271
+ meta?: Record<string, unknown> | null;
1272
+ error?: string | null;
1273
+ }
1274
+
1275
+ /** A mid-call AMD decision event pushed on the stream (and to the webhook). */
1276
+ export interface AmdDecisionEvent {
1277
+ event?: "amd";
1278
+ call_id?: string;
1279
+ answered_by?: AmdAnsweredBy;
1280
+ answered_by_twilio?: string | null;
1281
+ confidence?: number | null;
1282
+ decision_ms?: number | null;
1283
+ reason?: string | null;
1284
+ [k: string]: unknown;
1285
+ }
1286
+
1287
+ export interface AmdStreamOptions {
1288
+ /** Per-call operating point override (0-1), sent on the connect URL. */
1289
+ aggressiveness?: number;
1290
+ /** Opaque per-call tag, echoed back on the decision. */
1291
+ sessionLabel?: string;
1292
+ /** Extra query params merged onto the connect URL. */
1293
+ query?: Record<string, string>;
1294
+ /** Fired once the socket opens. */
1295
+ onOpen?: () => void;
1296
+ /** Fired when PyAI emits the `amd` decision event mid-call. */
1297
+ onDecision?: (event: AmdDecisionEvent) => void;
1298
+ /** Fired on any other JSON frame (forward-compatible). */
1299
+ onMessage?: (frame: Record<string, unknown>) => void;
1300
+ /** Fired on a transport-level error. */
1301
+ onError?: (err: Error) => void;
1302
+ /** Fired when the socket closes. */
1303
+ onClose?: (code: number, reason: string) => void;
1304
+ /** Injectable WebSocket constructor (defaults to the global). */
1305
+ webSocket?: WebSocketCtor;
1306
+ }
1307
+
1021
1308
  const RETRYABLE = new Set([429, 500, 502, 503, 504]);
1022
1309
 
1023
1310
  export class PyAI {
@@ -1146,7 +1433,7 @@ export class PyAI {
1146
1433
  const res = await this.request("/v1/audio/speech", {
1147
1434
  method: "POST",
1148
1435
  headers: this.authHeaders({ "Content-Type": "application/json" }),
1149
- body: JSON.stringify({ model: "pyai-voice", ...params }),
1436
+ body: JSON.stringify({ model: "pyai-voice", ...params, stream: true }),
1150
1437
  });
1151
1438
  if (!res.body) throw new PyAIError(res.status, "Response had no body to stream");
1152
1439
  return res.body as ReadableStream<Uint8Array>;
@@ -1220,7 +1507,7 @@ export class PyAI {
1220
1507
 
1221
1508
  /**
1222
1509
  * Introspect the calling key: scopes, environment, and limits. Useful for a
1223
- * preflight/doctor check. (New route; older deployments may 404 handle it.)
1510
+ * preflight/doctor check. (New route; older deployments may 404, handle it.)
1224
1511
  */
1225
1512
  me = (): Promise<MeResponse> => this.getJson<MeResponse>("/v1/me");
1226
1513
 
@@ -1324,7 +1611,7 @@ export class PyAI {
1324
1611
  },
1325
1612
  },
1326
1613
  findings: {
1327
- /** List Tier-2 (async semantic) findings advisory, non-blocking. Scope `trace:read`. */
1614
+ /** List Tier-2 (async semantic) findings, advisory, non-blocking. Scope `trace:read`. */
1328
1615
  list: (
1329
1616
  params: {
1330
1617
  checkId?: string;
@@ -1400,12 +1687,48 @@ export class PyAI {
1400
1687
  },
1401
1688
  };
1402
1689
 
1690
+ // --- amd (answering-machine detection) ---------------------------------
1691
+
1692
+ amd = {
1693
+ config: {
1694
+ /** The org's AMD operating point + webhook. Scope `amd:configure`. */
1695
+ get: (): Promise<AmdConfig> => this.getJson("/v1/amd/config"),
1696
+ /** Set the account-default `aggressiveness` (0-1) and webhook. Scope `amd:configure`. */
1697
+ set: (input: AmdConfigInput): Promise<AmdConfig> =>
1698
+ this.postJson("/v1/amd/config", {
1699
+ ...(input.aggressiveness !== undefined ? { aggressiveness: input.aggressiveness } : {}),
1700
+ ...(input.webhookUrl !== undefined ? { webhook_url: input.webhookUrl } : {}),
1701
+ }),
1702
+ },
1703
+ calls: {
1704
+ /** Recent AMD decisions, newest first. Scope `amd:read`. */
1705
+ list: (params: { limit?: number; cursor?: string; sessionLabel?: string } = {}): Promise<ListResponse<AmdCallSummary>> => {
1706
+ const q = new URLSearchParams();
1707
+ if (params.limit !== undefined) q.set("limit", String(params.limit));
1708
+ if (params.cursor) q.set("cursor", params.cursor);
1709
+ if (params.sessionLabel) q.set("session_label", params.sessionLabel);
1710
+ const qs = q.toString();
1711
+ return this.getJson(`/v1/amd/calls${qs ? `?${qs}` : ""}`);
1712
+ },
1713
+ /** The full decision (answered_by, reason, …) for one call. Scope `amd:read`. */
1714
+ get: (callId: string): Promise<AmdCall> =>
1715
+ this.getJson(`/v1/amd/calls/${encodeURIComponent(callId)}`),
1716
+ },
1717
+ /**
1718
+ * Open a live AMD stream over `/v1/amd/stream` (Twilio Media Streams
1719
+ * protocol). Server-side helper for forking media yourself; the common
1720
+ * Twilio path is one line of TwiML, no SDK. Scope `amd:detect`.
1721
+ */
1722
+ stream: (opts: AmdStreamOptions = {}): AmdStream =>
1723
+ new AmdStream(this.amdStreamURL(opts), this.realtimeSubprotocol(), opts),
1724
+ };
1725
+
1403
1726
  // --- omni (agentic voice) ----------------------------------------------
1404
1727
 
1405
1728
  omni = {
1406
1729
  /**
1407
1730
  * Mint an ephemeral, origin-locked Omni session token a browser can use to
1408
- * open ONE realtime session **directly** the public/private split for
1731
+ * open ONE realtime session **directly**, the public/private split for
1409
1732
  * realtime. **Call this from your server** with a secret key holding
1410
1733
  * `omni:session`; never ship the secret key to a page. Hand the returned
1411
1734
  * `token` to the browser, which connects with
@@ -1423,8 +1746,8 @@ export class PyAI {
1423
1746
  /**
1424
1747
  * Open a live Omni agentic-voice session over `/v1/omni`. Returns an
1425
1748
  * {@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
1749
+ * asymmetry for you, it sends `type`-keyed control frames (`configure`,
1750
+ * `dtmf`) and parses `event`-keyed server frames, so you can't trip the #1
1428
1751
  * Omni integration bug. Omni is zero-state: nothing to create first; the
1429
1752
  * agent's behavior travels in the `configure` frame. Pass `token` (from
1430
1753
  * `createSession`) to connect from a browser without the secret key.
@@ -1447,7 +1770,7 @@ export class PyAI {
1447
1770
  const q = new URLSearchParams(opts.query ?? {});
1448
1771
  if ((opts.product ?? "omni") === "omni") {
1449
1772
  // 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.
1773
+ // the key's org (zero-state), sessionLabel is an optional opaque tag.
1451
1774
  // format/rate are load-bearing on the connect URL, so default to
1452
1775
  // browser-grade PCM16/24kHz.
1453
1776
  if (opts.sessionLabel) q.set("session_label", opts.sessionLabel);
@@ -1481,6 +1804,16 @@ export class PyAI {
1481
1804
  return `${wsBase}/v1/audio/transcriptions/stream${qs ? `?${qs}` : ""}`;
1482
1805
  }
1483
1806
 
1807
+ /** Build the AMD detection WebSocket URL (`/v1/amd/stream`). */
1808
+ amdStreamURL(opts: AmdStreamOptions = {}): string {
1809
+ const wsBase = this.baseURL.replace(/^http/, "ws");
1810
+ const q = new URLSearchParams(opts.query ?? {});
1811
+ if (opts.aggressiveness !== undefined) q.set("aggressiveness", String(opts.aggressiveness));
1812
+ if (opts.sessionLabel) q.set("session_label", opts.sessionLabel);
1813
+ const qs = q.toString();
1814
+ return `${wsBase}/v1/amd/stream${qs ? `?${qs}` : ""}`;
1815
+ }
1816
+
1484
1817
  /**
1485
1818
  * Open a realtime WebSocket. Uses the global WebSocket (browser, Node 22+).
1486
1819
  * The key travels as a subprotocol so it works from the browser without