@pyai/sdk 0.5.0 → 0.6.0

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/AGENT_GUIDE.md CHANGED
@@ -1,13 +1,17 @@
1
1
  # PyAI CLI integration guide for coding agents
2
2
 
3
+ Speech and calling decision tree: https://pyai.com/agents/speech-calling.md
4
+ Omni frame contract: https://api.pyai.com/omni-frames.json
5
+
6
+
3
7
  Use this guide when building an application against PyAI, automating a file
4
8
  workflow, or adding PyAI to an existing repository. It is readable as plain
5
9
  Markdown and does not require the marketing website renderer.
6
10
 
7
11
  ## Availability and sources of truth
8
12
 
9
- The CLI is included in `@pyai/sdk` 0.5.0. Install it with
10
- `npm install -g @pyai/sdk@0.5.0`. Browser login supports existing PyAI accounts;
13
+ The CLI is included in `@pyai/sdk` 0.6.0. Install it with
14
+ `npm install -g @pyai/sdk@0.6.0`. Browser login supports existing PyAI accounts;
11
15
  unattended agents should use an authorized environment key. Inspect the
12
16
  installed command schema before acting.
13
17
 
@@ -31,7 +35,7 @@ do not invent an endpoint or present the feature as deployed.
31
35
  Install the published CLI with Node.js 22 or newer recommended:
32
36
 
33
37
  ```bash
34
- npm install -g @pyai/sdk@0.5.0
38
+ npm install -g @pyai/sdk@0.6.0
35
39
  pyai --version
36
40
  pyai schema --json
37
41
  ```
@@ -47,7 +51,7 @@ npm install -g .
47
51
  ```
48
52
 
49
53
  Alternatively install a supplied build with
50
- `npm install -g /path/to/pyai-sdk-0.5.0.tgz`. The compiled executable supports
54
+ `npm install -g /path/to/pyai-sdk-0.6.0.tgz`. The compiled executable supports
51
55
  Node.js 18 or newer. Use `node dist/cli.js` explicitly when another package's
52
56
  `pyai` shadows this executable.
53
57
 
package/CLI.md CHANGED
@@ -6,8 +6,8 @@ scripts and in coding agents with shell access, including Cursor, Claude Code,
6
6
  and Codex. `--json` provides structured results; `--help` and `schema` describe
7
7
  the installed command surface.
8
8
 
9
- **Version:** the expanded CLI is included in `@pyai/sdk` 0.5.0. Install it from
10
- npm with `npm install -g @pyai/sdk@0.5.0`, then run `pyai login` for browser
9
+ **Version:** the expanded CLI is included in `@pyai/sdk` 0.6.0. Install it from
10
+ npm with `npm install -g @pyai/sdk@0.6.0`, then run `pyai login` for browser
11
11
  sign-in or provide `PYAI_API_KEY` for unattended automation.
12
12
 
13
13
  ## Contents
@@ -31,7 +31,7 @@ sign-in or provide `PYAI_API_KEY` for unattended automation.
31
31
  Install the published package (Node.js 22 or newer recommended):
32
32
 
33
33
  ```bash
34
- npm install -g @pyai/sdk@0.5.0
34
+ npm install -g @pyai/sdk@0.6.0
35
35
  pyai --version
36
36
  pyai login
37
37
  pyai speak "Hello from PyAI." -o hello.wav
@@ -56,7 +56,7 @@ also be packaged and installed on another machine:
56
56
  # In sdk/typescript after building
57
57
  npm pack --ignore-scripts
58
58
  # On the destination machine, using the actual tarball path
59
- npm install -g /path/to/pyai-sdk-0.5.0.tgz
59
+ npm install -g /path/to/pyai-sdk-0.6.0.tgz
60
60
  pyai schema --json
61
61
  ```
62
62
 
@@ -607,3 +607,28 @@ exit/error code, request ID, and a redacted reproduction. Never include the key.
607
607
  Realtime microphone sessions, live Hear streams, Omni conversations, and live
608
608
  AMD streams require a realtime client. Use the SDK and canonical protocol docs
609
609
  for those workflows; the CLI covers files, REST operations, and job lifecycles.
610
+
611
+ ## Speech and calling
612
+
613
+ Read the [speech and calling decision tree](https://pyai.com/agents/speech-calling.md),
614
+ or run `pyai recipes calling --json` / `pyai recipes omni --json`.
615
+
616
+ - `numbers search`, `numbers list`, `numbers buy`, `numbers bind ID`: discover,
617
+ purchase and route managed numbers. `numbers bind` takes `--data` with
618
+ `agent_id`; null unassigns.
619
+ - `calls create`, `calls list`, `calls get ID`, `calls wait ID`: real outbound
620
+ calling. Creation requires a request body from the live contract.
621
+ - `omni calls`, `omni get ID`, `omni summary ID`, `omni transcript ID`,
622
+ `omni recording ID --out call.wav`: read session artifacts, including inbound
623
+ calls. Use `--session-label` on `omni calls` to find an Agent's sessions.
624
+
625
+ Purchases and outbound calls require `--confirm` and `--idempotency-key`. Use
626
+ `--dry-run` for inspection before authorized execution. `needs_human` (exit 2)
627
+ means confirmation is missing and no submission was made. Outbound test calls
628
+ are real calls; sandbox keys do not grant dialing or purchase permissions.
629
+ The API enforces deployment availability, scopes, billing and policy gates.
630
+
631
+ `calls wait` accepts `--wait-timeout` and `--poll-interval`; preserve the original
632
+ ID after a deadline. `dispatch_unknown` stops waiting with a failure result for
633
+ inspection, never redial. Follow `calls get`'s `artifacts.omni_call_id` to session
634
+ artifacts. A completed call does not guarantee a recording or summary.
package/CLI.schema.json CHANGED
@@ -232,6 +232,131 @@
232
232
  "command": "doctor",
233
233
  "description": "Diagnose key, catalogs, and a Speak to Hear round-trip"
234
234
  },
235
+ {
236
+ "command": "omni calls",
237
+ "description": "List Omni sessions including inbound calls. Scope omni:read.",
238
+ "args": [],
239
+ "flags": {
240
+ "session-label": "string",
241
+ "limit": "string",
242
+ "cursor": "string"
243
+ }
244
+ },
245
+ {
246
+ "command": "omni get",
247
+ "description": "Read an Omni call's transcript, summary and recording availability.",
248
+ "args": [
249
+ "id"
250
+ ],
251
+ "flags": {}
252
+ },
253
+ {
254
+ "command": "omni summary",
255
+ "description": "Read the structured summary of an Omni session when generated.",
256
+ "args": [
257
+ "id"
258
+ ],
259
+ "flags": {}
260
+ },
261
+ {
262
+ "command": "omni transcript",
263
+ "description": "Read the complete Omni session transcript.",
264
+ "args": [
265
+ "id"
266
+ ],
267
+ "flags": {}
268
+ },
269
+ {
270
+ "command": "omni recording",
271
+ "description": "Download the recording with --out; recording must have been enabled for this call.",
272
+ "args": [
273
+ "id"
274
+ ],
275
+ "flags": {
276
+ "out": "string",
277
+ "force": "boolean"
278
+ }
279
+ },
280
+ {
281
+ "command": "numbers search",
282
+ "description": "Search managed numbers; inspect provisioning availability before buying. Scope telephony:manage.",
283
+ "args": [],
284
+ "flags": {
285
+ "country": "string",
286
+ "area-code": "string",
287
+ "contains": "string",
288
+ "limit": "string"
289
+ }
290
+ },
291
+ {
292
+ "command": "numbers list",
293
+ "description": "List the organization's managed numbers and current Agent bindings.",
294
+ "args": [],
295
+ "flags": {
296
+ "include-released": "boolean",
297
+ "limit": "string",
298
+ "cursor": "string"
299
+ }
300
+ },
301
+ {
302
+ "command": "numbers buy",
303
+ "description": "Purchase a number from JSON. Requires --confirm and --idempotency-key; may incur recurring charges.",
304
+ "args": [],
305
+ "flags": {
306
+ "data": "string",
307
+ "idempotency-key": "string",
308
+ "confirm": "boolean"
309
+ }
310
+ },
311
+ {
312
+ "command": "numbers bind",
313
+ "description": "Bind an existing number using JSON {agent_id}; null unassigns. Read numbers list first.",
314
+ "args": [
315
+ "id"
316
+ ],
317
+ "flags": {
318
+ "data": "string",
319
+ "idempotency-key": "string"
320
+ }
321
+ },
322
+ {
323
+ "command": "calls list",
324
+ "description": "List outbound call requests and their normalized status.",
325
+ "args": [],
326
+ "flags": {
327
+ "limit": "string",
328
+ "cursor": "string"
329
+ }
330
+ },
331
+ {
332
+ "command": "calls create",
333
+ "description": "Dial a real destination from JSON. Requires --confirm and --idempotency-key; this is not a simulated test call.",
334
+ "args": [],
335
+ "flags": {
336
+ "data": "string",
337
+ "idempotency-key": "string",
338
+ "confirm": "boolean"
339
+ }
340
+ },
341
+ {
342
+ "command": "calls get",
343
+ "description": "Get an outbound call by its returned ID; retain artifact identifiers separately.",
344
+ "args": [
345
+ "id"
346
+ ],
347
+ "flags": {}
348
+ },
349
+ {
350
+ "command": "calls wait",
351
+ "description": "Wait with a deadline. dispatch_unknown stops polling for manual inspection; never redial automatically.",
352
+ "args": [
353
+ "id"
354
+ ],
355
+ "flags": {
356
+ "wait-timeout": "string",
357
+ "poll-interval": "string"
358
+ }
359
+ },
235
360
  {
236
361
  "command": "agents list",
237
362
  "description": "List managed Agent profiles.",
@@ -660,6 +785,123 @@
660
785
  }
661
786
  ],
662
787
  "routes": [
788
+ {
789
+ "command": "omni calls",
790
+ "method": "GET",
791
+ "path": "/v1/omni/calls",
792
+ "query": {
793
+ "session-label": "string",
794
+ "limit": "string",
795
+ "cursor": "string"
796
+ },
797
+ "description": "List Omni sessions including inbound calls. Scope omni:read."
798
+ },
799
+ {
800
+ "command": "omni get",
801
+ "method": "GET",
802
+ "path": "/v1/omni/calls/{id}",
803
+ "id": true,
804
+ "description": "Read an Omni call's transcript, summary and recording availability."
805
+ },
806
+ {
807
+ "command": "omni summary",
808
+ "method": "GET",
809
+ "path": "/v1/omni/calls/{id}/summary",
810
+ "id": true,
811
+ "description": "Read the structured summary of an Omni session when generated."
812
+ },
813
+ {
814
+ "command": "omni transcript",
815
+ "method": "GET",
816
+ "path": "/v1/omni/calls/{id}/transcript",
817
+ "id": true,
818
+ "description": "Read the complete Omni session transcript."
819
+ },
820
+ {
821
+ "command": "omni recording",
822
+ "method": "GET",
823
+ "path": "/v1/omni/calls/{id}/recording",
824
+ "id": true,
825
+ "binary": true,
826
+ "description": "Download the recording with --out; recording must have been enabled for this call."
827
+ },
828
+ {
829
+ "command": "numbers search",
830
+ "method": "GET",
831
+ "path": "/v1/telephony/available",
832
+ "query": {
833
+ "country": "string",
834
+ "area-code": "string",
835
+ "contains": "string",
836
+ "limit": "string"
837
+ },
838
+ "description": "Search managed numbers; inspect provisioning availability before buying. Scope telephony:manage."
839
+ },
840
+ {
841
+ "command": "numbers list",
842
+ "method": "GET",
843
+ "path": "/v1/telephony/numbers",
844
+ "query": {
845
+ "include-released": "boolean"
846
+ },
847
+ "description": "List the organization's managed numbers and current Agent bindings."
848
+ },
849
+ {
850
+ "command": "numbers buy",
851
+ "method": "POST",
852
+ "path": "/v1/telephony/numbers",
853
+ "body": true,
854
+ "confirm": true,
855
+ "idempotencyRequired": true,
856
+ "description": "Purchase a number from JSON. Requires --confirm and --idempotency-key; may incur recurring charges."
857
+ },
858
+ {
859
+ "command": "numbers bind",
860
+ "method": "POST",
861
+ "path": "/v1/telephony/numbers/{id}/assign",
862
+ "id": true,
863
+ "body": true,
864
+ "description": "Bind an existing number using JSON {agent_id}; null unassigns. Read numbers list first."
865
+ },
866
+ {
867
+ "command": "calls list",
868
+ "method": "GET",
869
+ "path": "/v1/telephony/calls",
870
+ "description": "List outbound call requests and their normalized status."
871
+ },
872
+ {
873
+ "command": "calls create",
874
+ "method": "POST",
875
+ "path": "/v1/telephony/calls",
876
+ "body": true,
877
+ "confirm": true,
878
+ "idempotencyRequired": true,
879
+ "description": "Dial a real destination from JSON. Requires --confirm and --idempotency-key; this is not a simulated test call."
880
+ },
881
+ {
882
+ "command": "calls get",
883
+ "method": "GET",
884
+ "path": "/v1/telephony/calls/{id}",
885
+ "id": true,
886
+ "description": "Get an outbound call by its returned ID; retain artifact identifiers separately."
887
+ },
888
+ {
889
+ "command": "calls wait",
890
+ "method": "GET",
891
+ "path": "/v1/telephony/calls/{id}",
892
+ "id": true,
893
+ "wait": {
894
+ "success": [
895
+ "completed"
896
+ ],
897
+ "failure": [
898
+ "failed",
899
+ "canceled",
900
+ "dispatch_unknown"
901
+ ]
902
+ },
903
+ "description": "Wait with a deadline. dispatch_unknown stops polling for manual inspection; never redial automatically."
904
+ },
663
905
  {
664
906
  "command": "agents list",
665
907
  "method": "GET",
package/README.md CHANGED
@@ -99,6 +99,12 @@ Live `0x02` transcript bodies are plain UTF-8 caller-text deltas, not JSON.
99
99
  Coalesce successive deltas for the current caller turn. Bounded direct JSON
100
100
  bodies remain accepted for older bridges.
101
101
 
102
+ Since version 0.5.1, the engine's four-field assistant synthesis advisory on
103
+ `0x03` also reaches `onTranscript` with `role: "assistant"`, `final: true`,
104
+ and `mode: "replace"`.
105
+ This is text submitted for synthesis; it does not prove that playback completed.
106
+ Caller transcripts continue to require the `0x02` carrier.
107
+
102
108
  `rate` configures caller input. A `rate: 16000` session still receives 24 kHz
103
109
  agent audio; `rate: 8000` receives 8 kHz. Read `hello.audio_out` before playback.
104
110
  Omni has no commit frame—keep streaming silence during caller pauses.
package/dist/cli-dx.d.ts CHANGED
@@ -7,5 +7,6 @@ export interface Recipe {
7
7
  description: string;
8
8
  commands: string[];
9
9
  notes: string[];
10
+ docs_url: string;
10
11
  }
11
12
  export declare const recipes: Recipe[];
package/dist/cli-dx.js CHANGED
@@ -8,6 +8,12 @@ export const shortFlags = Object.assign(Object.create(null), {
8
8
  "-t": "--text", "-p": "--profile", "-j": "--json",
9
9
  });
10
10
  export const recipes = [
11
+ { name: "calling", title: "Set up a managed phone Agent", description: "Inspect numbers, bind a profile, and retrieve call artifacts through the public API.",
12
+ commands: ["pyai whoami --json", "pyai numbers list --json", "pyai numbers search --country US --limit 5 --json", "pyai agents list --json", "pyai numbers bind NUMBER_ID --data @binding.json --dry-run --json", "pyai calls create --data @call.json --idempotency-key CALL_REQUEST_KEY --dry-run --json", "pyai calls get RETURNED_CALL_ID --json", "pyai omni get OMNI_CALL_ID --json"],
13
+ notes: ["Replace all placeholder IDs with returned values. binding.json is {agent_id: RETURNED_AGENT_ID}; call.json requires from_number_id, to, agent_id and use_case from live OpenAPI.", "Remove --dry-run for authorized changes. numbers buy and calls create require --confirm and --idempotency-key. Calls reach real destinations; sandbox keys do not grant telephony permissions.", "Requires telephony:manage; session artifacts require omni:read. Preserve artifacts.omni_call_id. Read the decision tree for purchase, polling, recording, and inbound-call steps.", "Use calls wait ID --wait-timeout 120 --poll-interval 3 --json for bounded waiting. dispatch_unknown requires inspection, never automatic redial."] },
14
+ { name: "omni", title: "Build a realtime conversation with the SDK", description: "Use the official SDK for binary framing, subprotocol auth, and typed events.",
15
+ commands: ["pyai schema --openapi --json", "pyai init voice-demo --template typescript", "pyai voices --json"],
16
+ notes: ["The starter supplies Hear/Speak examples. Add realtime through pyai.omni.connect({ rate, configure }); see the linked decision tree's SDK pattern.", "Wait for configured before non-silent input. Stream paced digital silence during pauses. Read hello.audio_out and clear queued playback on barge_in/flush.", "Browser code uses an ephemeral token minted by omni.createSession on your server. Never put live keys in browser code or URLs.", "Machine framing contract: https://api.pyai.com/omni-frames.json. For an existing Twilio number use @pyai/twilio and the official twilio-omni-voice-agent example."] },
11
17
  { name: "auth", title: "Sign in and check access", description: "Save a named profile and inspect the current credential.",
12
18
  commands: ["pyai login --profile work", "pyai whoami --profile work --json", "pyai use work"],
13
19
  notes: ["Browser login requires the CLI login API and console deployment. Use --no-browser on remote terminals.", "For unattended runs, supply PYAI_API_KEY through the environment. Environment values override saved profiles."] },
@@ -28,5 +34,5 @@ export const recipes = [
28
34
  notes: ["Local schema and recipes work without credentials. Live OpenAPI needs a network connection but no API key.", "The CLI manages Agent profiles; use the SDK or WebSocket API for a live Omni audio session."] },
29
35
  { name: "ci", title: "Use PyAI in a build", description: "Check access and produce an audio artifact with machine-readable output.",
30
36
  commands: ["pyai whoami --json", 'pyai speak "Your build is ready" -o build.wav --json', "pyai doctor --json"],
31
- notes: ["Set PYAI_API_KEY through the CI secret store; do not put it in source or shell arguments.", "doctor synthesizes and transcribes a sample, which consumes usage. Use whoami for an authentication-only check.", "Install the CLI with npm install -g @pyai/sdk@0.5.0 and pin that version in repeatable builds."] },
32
- ];
37
+ notes: ["Set PYAI_API_KEY through the CI secret store; do not put it in source or shell arguments.", "doctor synthesizes and transcribes a sample, which consumes usage. Use whoami for an authentication-only check.", "Install the CLI with npm install -g @pyai/sdk@0.6.0 and pin that version in repeatable builds."] },
38
+ ].map(recipe => ({ ...recipe, docs_url: "https://pyai.com/agents/speech-calling.md" }));
package/dist/cli-http.js CHANGED
@@ -234,12 +234,15 @@ export class CliHttp {
234
234
  const duration = timeout(options.timeoutMs ?? this.timeoutMs);
235
235
  const deadline = Date.now() + duration;
236
236
  const abort = new AbortController();
237
- const timer = setTimeout(() => abort.abort(new CliError("timeout", `Request timed out after ${duration} ms.`, 4)), duration);
237
+ const timedOut = () => new CliError("timeout", `Request timed out after ${duration} ms.`, 4);
238
+ const timer = setTimeout(() => abort.abort(timedOut()), duration);
238
239
  const cleanup = () => clearTimeout(timer);
239
240
  const retries = method === "GET" || method === "HEAD" ? this.maxRetries : 0;
240
241
  let streaming = false;
241
242
  try {
242
243
  for (let attempt = 0;; attempt++) {
244
+ if (Date.now() >= deadline)
245
+ throw timedOut();
243
246
  let delay = retryDelay(null, attempt);
244
247
  try {
245
248
  const response = await fetch(url, { method, headers, body, signal: abort.signal, redirect: "manual" });
@@ -275,7 +278,13 @@ export class CliHttp {
275
278
  if (abort.signal.aborted || failure.code !== "network_error" || failure.exitCode !== 4 || attempt >= retries)
276
279
  throw failure;
277
280
  }
278
- await pause(Math.min(delay, Math.max(0, deadline - Date.now())), abort.signal);
281
+ const remaining = Math.max(0, deadline - Date.now());
282
+ const retryExceedsDeadline = delay >= remaining;
283
+ await pause(Math.min(delay, remaining), abort.signal);
284
+ // A clipped wait is terminal even if its timer runs before abort.
285
+ // Otherwise the next request can ignore Retry-After at the deadline.
286
+ if (retryExceedsDeadline)
287
+ throw timedOut();
279
288
  }
280
289
  }
281
290
  finally {
package/dist/cli-init.js CHANGED
@@ -22,6 +22,8 @@ Read it before generating PyAI calls. Do not place credentials in this file.
22
22
  - Canonical live OpenAPI: https://api.pyai.com/openapi.json
23
23
  - API docs: https://docs.pyai.com
24
24
  - Agent index: https://api.pyai.com/llms.txt
25
+ - Speech and calling recipes: https://pyai.com/agents/speech-calling.md
26
+ - Omni frame contract: https://api.pyai.com/omni-frames.json
25
27
  - API origin: https://api.pyai.com; REST routes below include /v1.
26
28
 
27
29
  CLI schema describes the installed executable; live OpenAPI describes the
@@ -102,7 +104,8 @@ async jobs' language field is a Recap summarization hint.
102
104
  The CLI handles REST and job workflows; it does not run a realtime microphone
103
105
  or WebSocket session. Use the SDK and protocol docs for Hear streaming or Omni.
104
106
  Omni connects to \`wss://api.pyai.com/v1/omni?format=pcm16&rate=24000\`.
105
- Configure the agent after connection; a managed Agent is optional. Never place
107
+ Use \`pyai.omni.connect({ rate, configure })\`; a managed Agent is optional.
108
+ Wait for configured before non-silent audio and clear playback on barge-in/flush. Never place
106
109
  long-lived API keys in a public frontend. Use the documented ephemeral session
107
110
  flow for browser apps. Do not add unsupported grounding/Cue fields.
108
111
 
@@ -124,7 +127,7 @@ const install = `## Install the CLI
124
127
  Install the published CLI with Node.js 22 or newer recommended:
125
128
 
126
129
  \`\`\`bash
127
- npm install -g @pyai/sdk@0.5.0
130
+ npm install -g @pyai/sdk@0.6.0
128
131
  pyai --version
129
132
  \`\`\`
130
133
 
@@ -217,7 +220,7 @@ and product calls below are separate explicit steps. Read \`PYAI.md\` first.
217
220
  Install the official SDK:
218
221
 
219
222
  \`\`\`bash
220
- npm install @pyai/sdk@0.5.0
223
+ npm install @pyai/sdk@0.6.0
221
224
  cp .env.example .env
222
225
  \`\`\`
223
226
 
@@ -11,5 +11,8 @@ export interface CliRoute {
11
11
  success: string[];
12
12
  failure: string[];
13
13
  };
14
+ query?: Record<string, "string" | "boolean">;
15
+ confirm?: boolean;
16
+ idempotencyRequired?: boolean;
14
17
  }
15
18
  export declare const routes: CliRoute[];
@@ -1,4 +1,17 @@
1
1
  export const routes = [
2
+ { command: "omni calls", method: "GET", path: "/v1/omni/calls", query: { "session-label": "string", limit: "string", cursor: "string" }, description: "List Omni sessions including inbound calls. Scope omni:read." },
3
+ { command: "omni get", method: "GET", path: "/v1/omni/calls/{id}", id: true, description: "Read an Omni call's transcript, summary and recording availability." },
4
+ { command: "omni summary", method: "GET", path: "/v1/omni/calls/{id}/summary", id: true, description: "Read the structured summary of an Omni session when generated." },
5
+ { command: "omni transcript", method: "GET", path: "/v1/omni/calls/{id}/transcript", id: true, description: "Read the complete Omni session transcript." },
6
+ { command: "omni recording", method: "GET", path: "/v1/omni/calls/{id}/recording", id: true, binary: true, description: "Download the recording with --out; recording must have been enabled for this call." },
7
+ { command: "numbers search", method: "GET", path: "/v1/telephony/available", query: { country: "string", "area-code": "string", contains: "string", limit: "string" }, description: "Search managed numbers; inspect provisioning availability before buying. Scope telephony:manage." },
8
+ { command: "numbers list", method: "GET", path: "/v1/telephony/numbers", query: { "include-released": "boolean" }, description: "List the organization's managed numbers and current Agent bindings." },
9
+ { command: "numbers buy", method: "POST", path: "/v1/telephony/numbers", body: true, confirm: true, idempotencyRequired: true, description: "Purchase a number from JSON. Requires --confirm and --idempotency-key; may incur recurring charges." },
10
+ { command: "numbers bind", method: "POST", path: "/v1/telephony/numbers/{id}/assign", id: true, body: true, description: "Bind an existing number using JSON {agent_id}; null unassigns. Read numbers list first." },
11
+ { command: "calls list", method: "GET", path: "/v1/telephony/calls", description: "List outbound call requests and their normalized status." },
12
+ { command: "calls create", method: "POST", path: "/v1/telephony/calls", body: true, confirm: true, idempotencyRequired: true, description: "Dial a real destination from JSON. Requires --confirm and --idempotency-key; this is not a simulated test call." },
13
+ { command: "calls get", method: "GET", path: "/v1/telephony/calls/{id}", id: true, description: "Get an outbound call by its returned ID; retain artifact identifiers separately." },
14
+ { command: "calls wait", method: "GET", path: "/v1/telephony/calls/{id}", id: true, wait: { success: ["completed"], failure: ["failed", "canceled", "dispatch_unknown"] }, description: "Wait with a deadline. dispatch_unknown stops polling for manual inspection; never redial automatically." },
2
15
  { command: "agents list", method: "GET", path: "/v1/agents", description: "List managed Agent profiles." },
3
16
  { command: "agents get", method: "GET", path: "/v1/agents/{id}", id: true, description: "Get an Agent profile." },
4
17
  { command: "agents create", method: "POST", path: "/v1/agents", body: true, description: "Create an Agent profile from JSON; name is required." },
package/dist/cli.js CHANGED
@@ -44,6 +44,7 @@ const commands = [
44
44
  { command: "doctor", description: "Diagnose key, catalogs, and a Speak to Hear round-trip" },
45
45
  ...routes.map((r) => ({ command: r.command, description: r.description, args: r.id ? ["id"] : [], flags: {
46
46
  ...(r.body ? bodyFlags : {}), ...(r.binary ? outputFlags : {}), ...(r.wait ? waitFlags : {}),
47
+ ...(r.query ?? {}), ...(r.confirm ? { confirm: "boolean" } : {}),
47
48
  ...(r.command.endsWith(" list") || r.command === "amd calls" ? { limit: "string", cursor: "string" } : {}),
48
49
  } })),
49
50
  ];
@@ -404,7 +405,7 @@ async function main() {
404
405
  if (flags.json)
405
406
  output({ name: "pyai", globals, aliases, short_flags: shortFlags, commands: visible }, flags, undefined, false);
406
407
  else if (!spec && !group)
407
- process.stdout.write(`PyAI CLI — speech, agents, and automation\n\nStart here\n pyai login Sign in through your browser\n pyai speak "Hello from PyAI" -o hello.wav Turn text into audio\n pyai hear call.wav --text-only Print a transcript\n pyai dub call.wav --from en --to hi -o hi.wav\n Submit, wait, and save dubbed audio\n pyai init voice-demo --template typescript Create a starter project offline\n\nDiscover\n pyai recipes [name] Copyable workflows\n pyai help <command> Command options and examples\n pyai help all Every command\n pyai schema [command] -j Machine-readable command definitions\n\nGroups\n auth profiles models voices agents jobs clones design\n cast dub recap trace tools vocabulary amd\n\nShortcuts: login, logout, whoami, use, say (speak), hear (transcribe)\nOptions: -o FILE -f FILE -t TEXT -p PROFILE -j (JSON)\nGlobal: --base-url URL --api-key KEY --timeout SECONDS --retries N --dry-run\nCredentials: pyai login or PYAI_API_KEY. Run pyai doctor for a usage-consuming diagnostic.\n`);
408
+ process.stdout.write(`PyAI CLI — speech, agents, and automation\n\nStart here\n pyai login Sign in through your browser\n pyai speak "Hello from PyAI" -o hello.wav Turn text into audio\n pyai hear call.wav --text-only Print a transcript\n pyai dub call.wav --from en --to hi -o hi.wav\n Submit, wait, and save dubbed audio\n pyai init voice-demo --template typescript Create a starter project offline\n\nDiscover\n pyai recipes [name] Copyable workflows\n pyai help <command> Command options and examples\n pyai help all Every command\n pyai schema [command] -j Machine-readable command definitions\n\nGroups\n auth profiles models voices agents jobs clones design\n cast dub recap trace tools vocabulary amd numbers calls omni\n\nShortcuts: login, logout, whoami, use, say (speak), hear (transcribe)\nOptions: -o FILE -f FILE -t TEXT -p PROFILE -j (JSON)\nGlobal: --base-url URL --api-key KEY --timeout SECONDS --retries N --dry-run\nCredentials: pyai login or PYAI_API_KEY. Run pyai doctor for a usage-consuming diagnostic.\n`);
408
409
  else
409
410
  process.stdout.write(`PyAI CLI — speech, agents, and automation\n\n${visible.map(c => ` pyai ${c.command}${(c.args ?? []).map(a => c.optionalArgs ? ` [${a}${c.variadic ? "..." : ""}]` : ` <${a}>`).join("")}\n ${c.description}${c.flags ? `\n Options: ${Object.keys(c.flags).map(k => `--${k}${c.flags[k] === "boolean" ? "" : " VALUE"}`).join(" ")}` : ""}${c.example ? `\n ${c.example}` : ""}`).join("\n")}\n\nGlobal: --profile NAME --json --timeout SECONDS --retries N --dry-run\n --base-url URL --api-key KEY --help --version\nShort flags: -o FILE -f FILE -t TEXT -p PROFILE -j (JSON)\nAuth: pyai login or PYAI_API_KEY. Discover: pyai schema --json\n`);
410
411
  return;
@@ -666,13 +667,21 @@ async function main() {
666
667
  return;
667
668
  }
668
669
  const route = routes.find(r => r.command === cmd);
670
+ if (route.confirm && !flags.confirm && !flags["dry-run"]) {
671
+ throw new CliError("needs_human", "Review the request and supply --confirm only after purchase or dialing is authorized.", 2, { docs_url: "https://pyai.com/agents/speech-calling.md", console_url: "https://console.pyai.com/telephony" });
672
+ }
673
+ if (route.idempotencyRequired) {
674
+ const key = required(flags, "idempotency-key");
675
+ if (!key.trim() || key.length > 255 || /[\r\n]/.test(key))
676
+ usage("--idempotency-key must be a non-empty header-safe string of at most 255 characters");
677
+ }
669
678
  let path = route.path.replace("{id}", route.id ? safeId(args[0]) : "");
670
679
  if (value(flags, "limit") !== undefined) {
671
680
  const limit = number(flags, "limit", 20, 1, 100);
672
681
  if (!Number.isInteger(limit))
673
682
  usage("--limit must be an integer");
674
683
  }
675
- path = withQuery(path, { limit: value(flags, "limit"), cursor: value(flags, "cursor") });
684
+ path = withQuery(path, { limit: value(flags, "limit"), cursor: value(flags, "cursor"), ...Object.fromEntries(Object.keys(route.query ?? {}).filter(k => flags[k] !== undefined).map(k => [k.replaceAll("-", "_"), String(flags[k])])) });
676
685
  const data = route.body ? await jsonInput(flags, true) : undefined;
677
686
  const out = route.binary ? required(flags, "out") : undefined;
678
687
  if (out)
package/dist/index.d.ts CHANGED
@@ -134,8 +134,19 @@ export interface SpeechParams {
134
134
  */
135
135
  temperature?: number;
136
136
  }
137
- export interface CreateJobParams {
137
+ export type CreateJobParams = CreateJobOptions & ({
138
138
  audio_url: string;
139
+ gpt_live?: never;
140
+ } | {
141
+ gpt_live: {
142
+ session_id: string;
143
+ };
144
+ audio_url?: never;
145
+ });
146
+ export interface CreateJobOptions {
147
+ /** Requires Trace entitlement; incompatible with channel/diarize. */
148
+ trace?: boolean;
149
+ rule_pack?: Record<string, unknown>;
139
150
  model?: string;
140
151
  diarize?: boolean;
141
152
  channel?: boolean;
@@ -535,6 +546,8 @@ export declare const OmniEvent: {
535
546
  readonly Hello: "hello";
536
547
  /** Ack for your `configure` frame (echoes the resolved `voice_id`). */
537
548
  readonly Configured: "configured";
549
+ /** Served voice capabilities changed, for example after synthesis fallback. */
550
+ readonly VoiceCapabilities: "voice_capabilities";
538
551
  /** Session is live; includes the resolved agent + audio caps. */
539
552
  readonly SessionStarted: "session_started";
540
553
  /** Turn boundary (user/assistant speaking). */
@@ -1085,16 +1098,21 @@ export interface AmdCall extends AmdCallSummary {
1085
1098
  }
1086
1099
  /**
1087
1100
  * A mid-call AMD decision event pushed on the stream (and to the per-call
1088
- * TwiML `webhook`). Carries the coarse routing class; the machine subtype
1089
- * (`voicemail`/`ivr`/`screening`/`music`) is on the stored call record
1090
- * (`AmdCall`) and the `amd.call.completed` webhook instead.
1101
+ * TwiML `webhook`). Carries the routing class and an optional machine subtype.
1102
+ * Stored `AmdCall` records fold the subtype into `answered_by`.
1091
1103
  */
1092
1104
  export interface AmdDecisionEvent {
1093
1105
  event?: "amd";
1094
1106
  call_id?: string;
1095
1107
  answered_by?: AmdWireAnsweredBy;
1096
1108
  answered_by_twilio?: string | null;
1109
+ subtype?: string;
1110
+ /** A human or automated answering party was identified. */
1111
+ party_detected?: boolean;
1112
+ /** Classification events return false; detection does not establish recording readiness. */
1113
+ voicemail_ready?: boolean;
1097
1114
  confidence?: number | null;
1115
+ /** Processed inbound audio through the decision, not time since carrier answer. */
1098
1116
  decision_ms?: number | null;
1099
1117
  reason?: string | null;
1100
1118
  [k: string]: unknown;
package/dist/index.js CHANGED
@@ -286,6 +286,8 @@ export const OmniEvent = {
286
286
  Hello: "hello",
287
287
  /** Ack for your `configure` frame (echoes the resolved `voice_id`). */
288
288
  Configured: "configured",
289
+ /** Served voice capabilities changed, for example after synthesis fallback. */
290
+ VoiceCapabilities: "voice_capabilities",
289
291
  /** Session is live; includes the resolved agent + audio caps. */
290
292
  SessionStarted: "session_started",
291
293
  /** Turn boundary (user/assistant speaking). */
@@ -495,6 +497,17 @@ export class OmniConnection {
495
497
  try {
496
498
  const parsed = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes.subarray(1)));
497
499
  if (parsed?.event === OmniEvent.Transcript) {
500
+ // The deployed engine also sends this bounded synthesis-input
501
+ // advisory. It is not caller ASR or proof that speech was played.
502
+ const keys = Object.keys(parsed);
503
+ const text = omniTranscriptText(parsed.text);
504
+ if (parsed.role === "assistant" && parsed.final === true && text?.trim()
505
+ && keys.length === 4
506
+ && keys.every((key) => ["event", "role", "text", "final"].includes(key))) {
507
+ this.opts.onEvent?.(parsed);
508
+ this.opts.onTranscript?.({ event: "transcript", role: "assistant", text, final: true, mode: "replace" });
509
+ return;
510
+ }
498
511
  this.opts.onError?.(new Error("Omni transcript events must use a binary 0x02 frame"));
499
512
  return;
500
513
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pyai/sdk",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "Official TypeScript/JavaScript SDK for PyAI, speech-to-text (Hear), text-to-speech (Speak), realtime voice agents (Omni), and call compliance (Trace).",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
package/src/cli-dx.ts CHANGED
@@ -7,8 +7,14 @@ export const shortFlags: Record<string, string> = Object.assign(Object.create(nu
7
7
  "-h": "--help", "-v": "--version", "-o": "--out", "-f": "--file",
8
8
  "-t": "--text", "-p": "--profile", "-j": "--json",
9
9
  });
10
- export interface Recipe { name: string; title: string; description: string; commands: string[]; notes: string[] }
10
+ export interface Recipe { name: string; title: string; description: string; commands: string[]; notes: string[]; docs_url: string }
11
11
  export const recipes: Recipe[] = [
12
+ { name: "calling", title: "Set up a managed phone Agent", description: "Inspect numbers, bind a profile, and retrieve call artifacts through the public API.",
13
+ commands: ["pyai whoami --json", "pyai numbers list --json", "pyai numbers search --country US --limit 5 --json", "pyai agents list --json", "pyai numbers bind NUMBER_ID --data @binding.json --dry-run --json", "pyai calls create --data @call.json --idempotency-key CALL_REQUEST_KEY --dry-run --json", "pyai calls get RETURNED_CALL_ID --json", "pyai omni get OMNI_CALL_ID --json"],
14
+ notes: ["Replace all placeholder IDs with returned values. binding.json is {agent_id: RETURNED_AGENT_ID}; call.json requires from_number_id, to, agent_id and use_case from live OpenAPI.", "Remove --dry-run for authorized changes. numbers buy and calls create require --confirm and --idempotency-key. Calls reach real destinations; sandbox keys do not grant telephony permissions.", "Requires telephony:manage; session artifacts require omni:read. Preserve artifacts.omni_call_id. Read the decision tree for purchase, polling, recording, and inbound-call steps.", "Use calls wait ID --wait-timeout 120 --poll-interval 3 --json for bounded waiting. dispatch_unknown requires inspection, never automatic redial."] },
15
+ { name: "omni", title: "Build a realtime conversation with the SDK", description: "Use the official SDK for binary framing, subprotocol auth, and typed events.",
16
+ commands: ["pyai schema --openapi --json", "pyai init voice-demo --template typescript", "pyai voices --json"],
17
+ notes: ["The starter supplies Hear/Speak examples. Add realtime through pyai.omni.connect({ rate, configure }); see the linked decision tree's SDK pattern.", "Wait for configured before non-silent input. Stream paced digital silence during pauses. Read hello.audio_out and clear queued playback on barge_in/flush.", "Browser code uses an ephemeral token minted by omni.createSession on your server. Never put live keys in browser code or URLs.", "Machine framing contract: https://api.pyai.com/omni-frames.json. For an existing Twilio number use @pyai/twilio and the official twilio-omni-voice-agent example."] },
12
18
  { name: "auth", title: "Sign in and check access", description: "Save a named profile and inspect the current credential.",
13
19
  commands: ["pyai login --profile work", "pyai whoami --profile work --json", "pyai use work"],
14
20
  notes: ["Browser login requires the CLI login API and console deployment. Use --no-browser on remote terminals.", "For unattended runs, supply PYAI_API_KEY through the environment. Environment values override saved profiles."] },
@@ -29,5 +35,5 @@ export const recipes: Recipe[] = [
29
35
  notes: ["Local schema and recipes work without credentials. Live OpenAPI needs a network connection but no API key.", "The CLI manages Agent profiles; use the SDK or WebSocket API for a live Omni audio session."] },
30
36
  { name: "ci", title: "Use PyAI in a build", description: "Check access and produce an audio artifact with machine-readable output.",
31
37
  commands: ["pyai whoami --json", 'pyai speak "Your build is ready" -o build.wav --json', "pyai doctor --json"],
32
- notes: ["Set PYAI_API_KEY through the CI secret store; do not put it in source or shell arguments.", "doctor synthesizes and transcribes a sample, which consumes usage. Use whoami for an authentication-only check.", "Install the CLI with npm install -g @pyai/sdk@0.5.0 and pin that version in repeatable builds."] },
33
- ];
38
+ notes: ["Set PYAI_API_KEY through the CI secret store; do not put it in source or shell arguments.", "doctor synthesizes and transcribes a sample, which consumes usage. Use whoami for an authentication-only check.", "Install the CLI with npm install -g @pyai/sdk@0.6.0 and pin that version in repeatable builds."] },
39
+ ].map(recipe => ({ ...recipe, docs_url: "https://pyai.com/agents/speech-calling.md" }));
package/src/cli-http.ts CHANGED
@@ -232,12 +232,14 @@ export class CliHttp {
232
232
  const duration = timeout(options.timeoutMs ?? this.timeoutMs);
233
233
  const deadline = Date.now() + duration;
234
234
  const abort = new AbortController();
235
- const timer = setTimeout(() => abort.abort(new CliError("timeout", `Request timed out after ${duration} ms.`, 4)), duration);
235
+ const timedOut = () => new CliError("timeout", `Request timed out after ${duration} ms.`, 4);
236
+ const timer = setTimeout(() => abort.abort(timedOut()), duration);
236
237
  const cleanup = () => clearTimeout(timer);
237
238
  const retries = method === "GET" || method === "HEAD" ? this.maxRetries : 0;
238
239
  let streaming = false;
239
240
  try {
240
241
  for (let attempt = 0; ; attempt++) {
242
+ if (Date.now() >= deadline) throw timedOut();
241
243
  let delay = retryDelay(null, attempt);
242
244
  try {
243
245
  const response = await fetch(url, { method, headers, body, signal: abort.signal, redirect: "manual" });
@@ -264,7 +266,12 @@ export class CliHttp {
264
266
  const failure = this.transportError(error, abort.signal);
265
267
  if (abort.signal.aborted || failure.code !== "network_error" || failure.exitCode !== 4 || attempt >= retries) throw failure;
266
268
  }
267
- await pause(Math.min(delay, Math.max(0, deadline - Date.now())), abort.signal);
269
+ const remaining = Math.max(0, deadline - Date.now());
270
+ const retryExceedsDeadline = delay >= remaining;
271
+ await pause(Math.min(delay, remaining), abort.signal);
272
+ // A clipped wait is terminal even if its timer runs before abort.
273
+ // Otherwise the next request can ignore Retry-After at the deadline.
274
+ if (retryExceedsDeadline) throw timedOut();
268
275
  }
269
276
  } finally {
270
277
  if (!streaming) cleanup();
package/src/cli-init.ts CHANGED
@@ -43,6 +43,8 @@ Read it before generating PyAI calls. Do not place credentials in this file.
43
43
  - Canonical live OpenAPI: https://api.pyai.com/openapi.json
44
44
  - API docs: https://docs.pyai.com
45
45
  - Agent index: https://api.pyai.com/llms.txt
46
+ - Speech and calling recipes: https://pyai.com/agents/speech-calling.md
47
+ - Omni frame contract: https://api.pyai.com/omni-frames.json
46
48
  - API origin: https://api.pyai.com; REST routes below include /v1.
47
49
 
48
50
  CLI schema describes the installed executable; live OpenAPI describes the
@@ -123,7 +125,8 @@ async jobs' language field is a Recap summarization hint.
123
125
  The CLI handles REST and job workflows; it does not run a realtime microphone
124
126
  or WebSocket session. Use the SDK and protocol docs for Hear streaming or Omni.
125
127
  Omni connects to \`wss://api.pyai.com/v1/omni?format=pcm16&rate=24000\`.
126
- Configure the agent after connection; a managed Agent is optional. Never place
128
+ Use \`pyai.omni.connect({ rate, configure })\`; a managed Agent is optional.
129
+ Wait for configured before non-silent audio and clear playback on barge-in/flush. Never place
127
130
  long-lived API keys in a public frontend. Use the documented ephemeral session
128
131
  flow for browser apps. Do not add unsupported grounding/Cue fields.
129
132
 
@@ -146,7 +149,7 @@ const install = `## Install the CLI
146
149
  Install the published CLI with Node.js 22 or newer recommended:
147
150
 
148
151
  \`\`\`bash
149
- npm install -g @pyai/sdk@0.5.0
152
+ npm install -g @pyai/sdk@0.6.0
150
153
  pyai --version
151
154
  \`\`\`
152
155
 
@@ -239,7 +242,7 @@ and product calls below are separate explicit steps. Read \`PYAI.md\` first.
239
242
  Install the official SDK:
240
243
 
241
244
  \`\`\`bash
242
- npm install @pyai/sdk@0.5.0
245
+ npm install @pyai/sdk@0.6.0
243
246
  cp .env.example .env
244
247
  \`\`\`
245
248
 
package/src/cli-routes.ts CHANGED
@@ -8,9 +8,25 @@ export interface CliRoute {
8
8
  id?: boolean;
9
9
  binary?: boolean;
10
10
  wait?: { success: string[]; failure: string[] };
11
+ query?: Record<string, "string" | "boolean">;
12
+ confirm?: boolean;
13
+ idempotencyRequired?: boolean;
11
14
  }
12
15
 
13
16
  export const routes: CliRoute[] = [
17
+ { command: "omni calls", method: "GET", path: "/v1/omni/calls", query: { "session-label": "string", limit: "string", cursor: "string" }, description: "List Omni sessions including inbound calls. Scope omni:read." },
18
+ { command: "omni get", method: "GET", path: "/v1/omni/calls/{id}", id: true, description: "Read an Omni call's transcript, summary and recording availability." },
19
+ { command: "omni summary", method: "GET", path: "/v1/omni/calls/{id}/summary", id: true, description: "Read the structured summary of an Omni session when generated." },
20
+ { command: "omni transcript", method: "GET", path: "/v1/omni/calls/{id}/transcript", id: true, description: "Read the complete Omni session transcript." },
21
+ { command: "omni recording", method: "GET", path: "/v1/omni/calls/{id}/recording", id: true, binary: true, description: "Download the recording with --out; recording must have been enabled for this call." },
22
+ { command: "numbers search", method: "GET", path: "/v1/telephony/available", query: { country: "string", "area-code": "string", contains: "string", limit: "string" }, description: "Search managed numbers; inspect provisioning availability before buying. Scope telephony:manage." },
23
+ { command: "numbers list", method: "GET", path: "/v1/telephony/numbers", query: { "include-released": "boolean" }, description: "List the organization's managed numbers and current Agent bindings." },
24
+ { command: "numbers buy", method: "POST", path: "/v1/telephony/numbers", body: true, confirm: true, idempotencyRequired: true, description: "Purchase a number from JSON. Requires --confirm and --idempotency-key; may incur recurring charges." },
25
+ { command: "numbers bind", method: "POST", path: "/v1/telephony/numbers/{id}/assign", id: true, body: true, description: "Bind an existing number using JSON {agent_id}; null unassigns. Read numbers list first." },
26
+ { command: "calls list", method: "GET", path: "/v1/telephony/calls", description: "List outbound call requests and their normalized status." },
27
+ { command: "calls create", method: "POST", path: "/v1/telephony/calls", body: true, confirm: true, idempotencyRequired: true, description: "Dial a real destination from JSON. Requires --confirm and --idempotency-key; this is not a simulated test call." },
28
+ { command: "calls get", method: "GET", path: "/v1/telephony/calls/{id}", id: true, description: "Get an outbound call by its returned ID; retain artifact identifiers separately." },
29
+ { command: "calls wait", method: "GET", path: "/v1/telephony/calls/{id}", id: true, wait: { success: ["completed"], failure: ["failed", "canceled", "dispatch_unknown"] }, description: "Wait with a deadline. dispatch_unknown stops polling for manual inspection; never redial automatically." },
14
30
  { command: "agents list", method: "GET", path: "/v1/agents", description: "List managed Agent profiles." },
15
31
  { command: "agents get", method: "GET", path: "/v1/agents/{id}", id: true, description: "Get an Agent profile." },
16
32
  { command: "agents create", method: "POST", path: "/v1/agents", body: true, description: "Create an Agent profile from JSON; name is required." },
package/src/cli.ts CHANGED
@@ -48,6 +48,7 @@ const commands: Command[] = [
48
48
  { command: "doctor", description: "Diagnose key, catalogs, and a Speak to Hear round-trip" },
49
49
  ...routes.map((r): Command => ({ command: r.command, description: r.description, args: r.id ? ["id"] : [], flags: {
50
50
  ...(r.body ? bodyFlags : {}), ...(r.binary ? outputFlags : {}), ...(r.wait ? waitFlags : {}),
51
+ ...(r.query ?? {}), ...(r.confirm ? { confirm: "boolean" as const } : {}),
51
52
  ...(r.command.endsWith(" list") || r.command === "amd calls" ? { limit: "string" as const, cursor: "string" as const } : {}),
52
53
  } })),
53
54
  ];
@@ -269,7 +270,7 @@ async function main(): Promise<void> {
269
270
  const grouped = group ? commands.filter(c => c.command.startsWith(`${group} `)) : commands;
270
271
  const visible = spec ? [spec, ...commands.filter(c => c.command.startsWith(`${spec.command} `))] : grouped.length ? grouped : commands;
271
272
  if (flags.json) output({ name: "pyai", globals, aliases, short_flags: shortFlags, commands: visible }, flags, undefined, false);
272
- else if (!spec && !group) process.stdout.write(`PyAI CLI — speech, agents, and automation\n\nStart here\n pyai login Sign in through your browser\n pyai speak "Hello from PyAI" -o hello.wav Turn text into audio\n pyai hear call.wav --text-only Print a transcript\n pyai dub call.wav --from en --to hi -o hi.wav\n Submit, wait, and save dubbed audio\n pyai init voice-demo --template typescript Create a starter project offline\n\nDiscover\n pyai recipes [name] Copyable workflows\n pyai help <command> Command options and examples\n pyai help all Every command\n pyai schema [command] -j Machine-readable command definitions\n\nGroups\n auth profiles models voices agents jobs clones design\n cast dub recap trace tools vocabulary amd\n\nShortcuts: login, logout, whoami, use, say (speak), hear (transcribe)\nOptions: -o FILE -f FILE -t TEXT -p PROFILE -j (JSON)\nGlobal: --base-url URL --api-key KEY --timeout SECONDS --retries N --dry-run\nCredentials: pyai login or PYAI_API_KEY. Run pyai doctor for a usage-consuming diagnostic.\n`);
273
+ else if (!spec && !group) process.stdout.write(`PyAI CLI — speech, agents, and automation\n\nStart here\n pyai login Sign in through your browser\n pyai speak "Hello from PyAI" -o hello.wav Turn text into audio\n pyai hear call.wav --text-only Print a transcript\n pyai dub call.wav --from en --to hi -o hi.wav\n Submit, wait, and save dubbed audio\n pyai init voice-demo --template typescript Create a starter project offline\n\nDiscover\n pyai recipes [name] Copyable workflows\n pyai help <command> Command options and examples\n pyai help all Every command\n pyai schema [command] -j Machine-readable command definitions\n\nGroups\n auth profiles models voices agents jobs clones design\n cast dub recap trace tools vocabulary amd numbers calls omni\n\nShortcuts: login, logout, whoami, use, say (speak), hear (transcribe)\nOptions: -o FILE -f FILE -t TEXT -p PROFILE -j (JSON)\nGlobal: --base-url URL --api-key KEY --timeout SECONDS --retries N --dry-run\nCredentials: pyai login or PYAI_API_KEY. Run pyai doctor for a usage-consuming diagnostic.\n`);
273
274
  else process.stdout.write(`PyAI CLI — speech, agents, and automation\n\n${visible.map(c => ` pyai ${c.command}${(c.args ?? []).map(a => c.optionalArgs ? ` [${a}${c.variadic ? "..." : ""}]` : ` <${a}>`).join("")}\n ${c.description}${c.flags ? `\n Options: ${Object.keys(c.flags).map(k => `--${k}${c.flags![k] === "boolean" ? "" : " VALUE"}`).join(" ")}` : ""}${c.example ? `\n ${c.example}` : ""}`).join("\n")}\n\nGlobal: --profile NAME --json --timeout SECONDS --retries N --dry-run\n --base-url URL --api-key KEY --help --version\nShort flags: -o FILE -f FILE -t TEXT -p PROFILE -j (JSON)\nAuth: pyai login or PYAI_API_KEY. Discover: pyai schema --json\n`);
274
275
  return;
275
276
  }
@@ -436,9 +437,16 @@ async function main(): Promise<void> {
436
437
  output(await http.json("GET", path, { auth: cmd !== "schema" }), flags, undefined, cmd !== "schema"); return;
437
438
  }
438
439
  const route = routes.find(r => r.command === cmd)!;
440
+ if (route.confirm && !flags.confirm && !flags["dry-run"]) {
441
+ throw new CliError("needs_human", "Review the request and supply --confirm only after purchase or dialing is authorized.", 2, { docs_url: "https://pyai.com/agents/speech-calling.md", console_url: "https://console.pyai.com/telephony" });
442
+ }
443
+ if (route.idempotencyRequired) {
444
+ const key = required(flags, "idempotency-key");
445
+ if (!key.trim() || key.length > 255 || /[\r\n]/.test(key)) usage("--idempotency-key must be a non-empty header-safe string of at most 255 characters");
446
+ }
439
447
  let path = route.path.replace("{id}", route.id ? safeId(args[0]!) : "");
440
448
  if (value(flags, "limit") !== undefined) { const limit = number(flags, "limit", 20, 1, 100); if (!Number.isInteger(limit)) usage("--limit must be an integer"); }
441
- path = withQuery(path, { limit: value(flags, "limit"), cursor: value(flags, "cursor") });
449
+ path = withQuery(path, { limit: value(flags, "limit"), cursor: value(flags, "cursor"), ...Object.fromEntries(Object.keys(route.query ?? {}).filter(k => flags[k] !== undefined).map(k => [k.replaceAll("-", "_"), String(flags[k])])) });
442
450
  const data = route.body ? await jsonInput(flags, true) : undefined;
443
451
  const out = route.binary ? required(flags, "out") : undefined;
444
452
  if (out) await checkOutput(out, flags);
package/src/index.ts CHANGED
@@ -165,8 +165,15 @@ function assertActiveSpeechParams(params: SpeechParams): void {
165
165
  }
166
166
  }
167
167
 
168
- export interface CreateJobParams {
169
- audio_url: string;
168
+ export type CreateJobParams = CreateJobOptions & (
169
+ | { audio_url: string; gpt_live?: never }
170
+ | { gpt_live: { session_id: string }; audio_url?: never }
171
+ );
172
+
173
+ export interface CreateJobOptions {
174
+ /** Requires Trace entitlement; incompatible with channel/diarize. */
175
+ trace?: boolean;
176
+ rule_pack?: Record<string, unknown>;
170
177
  model?: string;
171
178
  diarize?: boolean;
172
179
  channel?: boolean;
@@ -731,6 +738,8 @@ export const OmniEvent = {
731
738
  Hello: "hello",
732
739
  /** Ack for your `configure` frame (echoes the resolved `voice_id`). */
733
740
  Configured: "configured",
741
+ /** Served voice capabilities changed, for example after synthesis fallback. */
742
+ VoiceCapabilities: "voice_capabilities",
734
743
  /** Session is live; includes the resolved agent + audio caps. */
735
744
  SessionStarted: "session_started",
736
745
  /** Turn boundary (user/assistant speaking). */
@@ -1066,6 +1075,17 @@ export class OmniConnection {
1066
1075
  try {
1067
1076
  const parsed = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes.subarray(1))) as OmniServerFrame;
1068
1077
  if (parsed?.event === OmniEvent.Transcript) {
1078
+ // The deployed engine also sends this bounded synthesis-input
1079
+ // advisory. It is not caller ASR or proof that speech was played.
1080
+ const keys = Object.keys(parsed);
1081
+ const text = omniTranscriptText(parsed.text);
1082
+ if (parsed.role === "assistant" && parsed.final === true && text?.trim()
1083
+ && keys.length === 4
1084
+ && keys.every((key) => ["event", "role", "text", "final"].includes(key))) {
1085
+ this.opts.onEvent?.(parsed);
1086
+ this.opts.onTranscript?.({ event: "transcript", role: "assistant", text, final: true, mode: "replace" });
1087
+ return;
1088
+ }
1069
1089
  this.opts.onError?.(new Error("Omni transcript events must use a binary 0x02 frame"));
1070
1090
  return;
1071
1091
  }
@@ -1593,16 +1613,21 @@ export interface AmdCall extends AmdCallSummary {
1593
1613
 
1594
1614
  /**
1595
1615
  * A mid-call AMD decision event pushed on the stream (and to the per-call
1596
- * TwiML `webhook`). Carries the coarse routing class; the machine subtype
1597
- * (`voicemail`/`ivr`/`screening`/`music`) is on the stored call record
1598
- * (`AmdCall`) and the `amd.call.completed` webhook instead.
1616
+ * TwiML `webhook`). Carries the routing class and an optional machine subtype.
1617
+ * Stored `AmdCall` records fold the subtype into `answered_by`.
1599
1618
  */
1600
1619
  export interface AmdDecisionEvent {
1601
1620
  event?: "amd";
1602
1621
  call_id?: string;
1603
1622
  answered_by?: AmdWireAnsweredBy;
1604
1623
  answered_by_twilio?: string | null;
1624
+ subtype?: string;
1625
+ /** A human or automated answering party was identified. */
1626
+ party_detected?: boolean;
1627
+ /** Classification events return false; detection does not establish recording readiness. */
1628
+ voicemail_ready?: boolean;
1605
1629
  confidence?: number | null;
1630
+ /** Processed inbound audio through the decision, not time since carrier answer. */
1606
1631
  decision_ms?: number | null;
1607
1632
  reason?: string | null;
1608
1633
  [k: string]: unknown;