Skip to content

Commit 37046b8

Browse files
feat: Add speech-to-text (STT) commands to agent CLI (#265)
* feat(cli): add STT commands (speech-to-text) * fix(cli): route stt through ai:audio transcribe; bump CLI pin for stt-providers The bundled telnyx CLI v0.11.0 has no top-level speech-to-text command (it was introduced in v0.17.0), and even at v0.21.0 the speech-to-text retrieve-transcription subcommand is a WebSocket streaming API with required --input-format/--transcription-engine and no audio-URL support. URL-based transcription is served by ai:audio transcribe --file-url at both v0.11.0 and v0.21.0, so: - stt now shells out to ai:audio transcribe --file-url, always passing --model (required by the Go CLI; defaults to distil-whisper/distil-large-v2, matching the CLI default) and only forwarding --language when explicitly given (the default model rejects it). Dropped the WebSocket-only flags (--transcription-engine, --input-format, --interim-results, --endpointing, --redact, --keywords); added --response-format. - Bumped the pinned Go CLI to 0.21.0 so stt-providers' speech-to-text list-providers subcommand exists in the bundled binary (same line change as PR #261, so either merge order is clean). - Updated help text, capabilities entries, and tests accordingly; wired tests/stt.test.ts into the npm test script. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(cli): preserve verbose STT responses and version-check the CLI skip - stt --response-format verbose_json now includes the full API response (timestamps/segments) in --json output instead of discarding it - postinstall only skips the vendored download when the telnyx on PATH is at least the pinned version; an older global CLI previously masked the download and broke commands that need the newer surface Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Oliver Zimmerman <9112652+Oliver-Zimmerman@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 51831bf commit 37046b8

7 files changed

Lines changed: 399 additions & 1 deletion

File tree

cli/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
},
99
"scripts": {
1010
"start": "tsx bin/telnyx-agent.ts",
11-
"test": "tsx --test tests/edge-handoff.test.ts tests/integration.test.ts tests/sms.test.ts tests/telnyx-cli-flags.test.ts tests/tts.test.ts tests/verify.test.ts tests/voice.test.ts tests/whatsapp.test.ts",
11+
"test": "tsx --test tests/edge-handoff.test.ts tests/integration.test.ts tests/sms.test.ts tests/stt.test.ts tests/telnyx-cli-flags.test.ts tests/tts.test.ts tests/verify.test.ts tests/voice.test.ts tests/whatsapp.test.ts",
1212
"typecheck": "tsc --noEmit",
1313
"postinstall": "tsx scripts/postinstall.ts",
1414
"build": "npm run typecheck"

cli/scripts/postinstall.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,14 @@ const PLATFORM_MAP: Record<string, string> = {
1717
"win32-x64": `telnyx_${VERSION}_windows_amd64.zip`,
1818
};
1919

20+
/** True when `found` (e.g. [0,21,0]) is >= `want`, comparing major.minor.patch. */
21+
function isAtLeast(found: number[], want: number[]): boolean {
22+
for (let i = 0; i < 3; i++) {
23+
if ((found[i] ?? 0) !== (want[i] ?? 0)) return (found[i] ?? 0) > (want[i] ?? 0);
24+
}
25+
return true;
26+
}
27+
2028
async function main() {
2129
// Skip if telnyx is already on PATH AND at the required version.
2230
// An older CLI on PATH would leave WhatsApp and other commands broken,

cli/src/commands/capabilities.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,10 @@ const CAPABILITIES: Record<string, Capability[]> = {
2929
{ name: "Speech Synthesis", description: "Generate audio from text via Telnyx and third-party TTS providers (telnyx, aws, azure, elevenlabs, minimax, resemble, rime, xai)", actions: ["generate_speech"] },
3030
{ name: "Voice Catalog", description: "List available TTS voices, optionally filtered by provider (telnyx, aws, azure, elevenlabs, minimax, resemble, rime, xai)", actions: ["list_voices"] },
3131
],
32+
"🎤 Speech-to-Text": [
33+
{ name: "Transcription", description: "Transcribe hosted audio files to text via the OpenAI-compatible transcription endpoint, with model and language options", actions: ["ai_audio_transcribe"] },
34+
{ name: "Providers", description: "List available speech-to-text providers and service types", actions: ["list_stt_providers"] },
35+
],
3236
"📠 Fax": [
3337
{ name: "Fax", description: "Send faxes programmatically", actions: ["send_fax"] },
3438
],
@@ -92,6 +96,9 @@ const COMPOSITE_COMMANDS = [
9296
{ name: "telnyx-agent tts", description: "Generate speech from text (text-to-speech) across multiple providers, returning base64-encoded audio data" },
9397
{ name: "telnyx-agent tts-voices", description: "List available TTS voices, optionally filtered by provider (telnyx, aws, azure, elevenlabs, minimax, resemble, rime, xai)" },
9498
{ name: "telnyx-agent setup-whatsapp", description: "Zero to WhatsApp: lists WABA, buys number, initializes & verifies, sets profile" },
99+
{ name: "telnyx-agent stt", description: "Transcribe audio to text (speech-to-text) — accepts an audio URL and returns the transcript with optional language, model, and keyword biasing" },
100+
{ name: "telnyx-agent stt", description: "Transcribe audio to text (speech-to-text) — accepts a hosted audio file URL and returns the transcript with optional model and language selection" },
101+
{ name: "telnyx-agent stt-providers", description: "List available speech-to-text providers, optionally filtered by provider name or service type" },
95102
{ name: "telnyx-agent status", description: "Account health overview — balance, numbers, profiles, connections" },
96103
{ name: "telnyx-agent capabilities", description: "This command — lists all available API capabilities" },
97104
{ name: "telnyx-agent call-dial", description: "Make an outbound call via Call Control (AMD, deepfake detection, recording optional)" },

cli/src/commands/stt-providers.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
/**
2+
* telnyx-agent stt-providers — List available speech-to-text providers.
3+
*
4+
* Shells out to the telnyx CLI's `speech-to-text list-providers` subcommand
5+
* and surfaces the available STT providers (optionally filtered by provider
6+
* name or service type) to the caller in either human-readable or JSON form.
7+
*/
8+
9+
import { telnyxCli, TelnyxCLIError } from "../telnyx-cli.ts";
10+
import { printSuccess, printError, outputJson } from "../utils/output.ts";
11+
12+
interface SttProvider {
13+
provider: string;
14+
service_type?: string;
15+
[key: string]: unknown;
16+
}
17+
18+
export async function sttProvidersCommand(flags: Record<string, string | boolean>): Promise<void> {
19+
const jsonOutput = flags.json === true;
20+
const provider = flags.provider as string | undefined;
21+
const serviceType = flags["service-type"] as string | undefined;
22+
23+
try {
24+
if (!jsonOutput) {
25+
console.log("\n🎤 Listing speech-to-text providers...\n");
26+
}
27+
28+
const args = ["speech-to-text", "list-providers"];
29+
if (provider) args.push("--provider", provider);
30+
if (serviceType) args.push("--service-type", serviceType);
31+
32+
const response = await telnyxCli(args);
33+
const data = (response as Record<string, unknown> | undefined)?.data ?? response;
34+
const providers: SttProvider[] = Array.isArray(data)
35+
? (data as SttProvider[])
36+
: Array.isArray(response)
37+
? (response as SttProvider[])
38+
: [];
39+
40+
if (jsonOutput) {
41+
outputJson({ providers, count: providers.length });
42+
} else {
43+
const details: Record<string, string | number | boolean> = {
44+
Count: providers.length,
45+
Providers: providers.map((p) => p.provider).join(", ") || "(none)",
46+
};
47+
if (provider) details["Provider Filter"] = provider;
48+
if (serviceType) details["Service Type Filter"] = serviceType;
49+
printSuccess("Available speech-to-text providers", details);
50+
}
51+
} catch (err) {
52+
const msg = errorMsg(err);
53+
if (jsonOutput) {
54+
outputJson({ error: msg });
55+
} else {
56+
printError(msg);
57+
}
58+
process.exit(1);
59+
}
60+
}
61+
62+
function errorMsg(err: unknown): string {
63+
if (err instanceof TelnyxCLIError) return err.stderr || err.message;
64+
if (err instanceof Error) return err.message;
65+
return String(err);
66+
}

cli/src/commands/stt.ts

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
/**
2+
* telnyx-agent stt — Speech-to-text transcription.
3+
*
4+
* Shells out to the telnyx CLI's `ai:audio transcribe` subcommand (the
5+
* OpenAI-compatible transcription endpoint) and surfaces the resulting
6+
* transcription text to the caller in either human-readable or JSON form.
7+
*
8+
* The transcribe endpoint accepts a hosted audio file URL (`--file-url`)
9+
* and returns the transcribed text. Note: the Go CLI's separate
10+
* `speech-to-text retrieve-transcription` command is a WebSocket streaming
11+
* API (no audio-URL support), so URL-based transcription must go through
12+
* `ai:audio transcribe`.
13+
*/
14+
15+
import { telnyxCli, TelnyxCLIError } from "../telnyx-cli.ts";
16+
import { printSuccess, printError, outputJson } from "../utils/output.ts";
17+
18+
/**
19+
* Default transcription model. `ai:audio transcribe` requires --model;
20+
* this mirrors the Go CLI's own default (lower latency, English-only).
21+
*/
22+
const DEFAULT_MODEL = "distil-whisper/distil-large-v2";
23+
24+
interface SttResult {
25+
audio_url: string;
26+
model: string;
27+
transcription: string;
28+
language?: string;
29+
response_format?: string;
30+
/** Full API response when a non-default --response-format is requested —
31+
* verbose_json carries timestamps/segments the flat fields drop. */
32+
response?: unknown;
33+
}
34+
35+
/**
36+
* Extract the transcription text from a telnyx CLI transcribe response.
37+
* The endpoint is OpenAI-compatible and returns the transcript under
38+
* `text`, but we defensively check a `data` envelope and a few common
39+
* field names.
40+
*/
41+
function extractTranscription(response: unknown): string {
42+
const data = (response as Record<string, unknown> | undefined)?.data ?? response;
43+
const obj = (data ?? {}) as Record<string, unknown>;
44+
45+
for (const key of ["text", "transcription", "transcript"]) {
46+
const v = obj[key];
47+
if (typeof v === "string" && v) return v;
48+
}
49+
50+
return "";
51+
}
52+
53+
export async function sttCommand(flags: Record<string, string | boolean>): Promise<void> {
54+
const jsonOutput = flags.json === true;
55+
const audioUrl = flags["audio-url"] as string;
56+
const model = (flags.model as string) || DEFAULT_MODEL;
57+
// Only forward --language when explicitly provided: the default model
58+
// (distil-whisper/distil-large-v2) rejects the language parameter.
59+
const language = flags.language as string | undefined;
60+
const responseFormat = flags["response-format"] as string | undefined;
61+
62+
if (!audioUrl) {
63+
printError("--audio-url is required (e.g., --audio-url https://example.com/audio.mp3)");
64+
process.exit(1);
65+
}
66+
67+
try {
68+
if (!jsonOutput) {
69+
console.log("\n🎙️ Transcribing audio...\n");
70+
}
71+
72+
const args = ["ai:audio", "transcribe", "--file-url", audioUrl, "--model", model];
73+
if (language) args.push("--language", language);
74+
if (responseFormat) args.push("--response-format", responseFormat);
75+
76+
const response = await telnyxCli(args);
77+
const transcription = extractTranscription(response);
78+
79+
const verbose = responseFormat !== undefined && responseFormat !== "json";
80+
const result: SttResult = {
81+
audio_url: audioUrl,
82+
model,
83+
transcription,
84+
...(language ? { language } : {}),
85+
...(responseFormat ? { response_format: responseFormat } : {}),
86+
...(verbose
87+
? { response: (response as Record<string, unknown> | undefined)?.data ?? response }
88+
: {}),
89+
};
90+
91+
if (jsonOutput) {
92+
outputJson(result);
93+
} else {
94+
const details: Record<string, string | number | boolean> = {
95+
"Audio URL": audioUrl,
96+
Model: model,
97+
};
98+
if (language) details["Language"] = language;
99+
details["Transcription"] = transcription || "(no text returned)";
100+
printSuccess("Transcription complete!", details);
101+
}
102+
} catch (err) {
103+
const msg = errorMsg(err);
104+
if (jsonOutput) {
105+
outputJson({ error: msg });
106+
} else {
107+
printError(msg);
108+
}
109+
process.exit(1);
110+
}
111+
}
112+
113+
function errorMsg(err: unknown): string {
114+
if (err instanceof TelnyxCLIError) return err.stderr || err.message;
115+
if (err instanceof Error) return err.message;
116+
return String(err);
117+
}

cli/src/index.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ import { smsStatusCommand } from "./commands/sms-status.ts";
3030
import { callDialCommand } from "./commands/call-dial.ts";
3131
import { callControlCommand } from "./commands/call-control.ts";
3232
import { callStatusCommand } from "./commands/call-status.ts";
33+
import { sttCommand } from "./commands/stt.ts";
34+
import { sttProvidersCommand } from "./commands/stt-providers.ts";
3335
import { parseFlags } from "./utils/output.ts";
3436

3537
const HELP = `
@@ -67,6 +69,8 @@ Commands:
6769
call-dial Make an outbound call via Call Control
6870
call-control Call Control actions (answer, hangup, transfer, dtmf, record, speak, ...)
6971
call-status Get the status of a call by call-control-id
72+
stt Transcribe audio to text (speech-to-text)
73+
stt-providers List available speech-to-text providers
7074
7175
Global Flags:
7276
--json Output structured JSON instead of human-readable text
@@ -201,6 +205,15 @@ Voice Call Flags:
201205
--queue-name Queue to place the call into (call-control enqueue, required)
202206
--body SIP INFO body content (call-control send-sip-info, required)
203207
--content-type SIP INFO Content-Type header (call-control send-sip-info, required, e.g. application/dtmf-relay)
208+
STT Flags:
209+
--audio-url <url> URL of the audio file to transcribe (required)
210+
--model Transcription model (default: distil-whisper/distil-large-v2; also openai/whisper-large-v3-turbo, deepgram/nova-3)
211+
--language Language code (optional; not supported by the default model)
212+
--response-format Transcript output format (optional, json or verbose_json)
213+
214+
STT-providers Flags:
215+
--provider Filter providers by name (optional)
216+
--service-type Filter providers by service type (optional)
204217
205218
Environment:
206219
TELNYX_API_KEY API key (or configure ~/.config/telnyx/config.json)
@@ -267,6 +280,10 @@ Examples:
267280
telnyx-agent call-control --action update-client-state --call-control-id <id> --client-state state-2
268281
telnyx-agent call-control --action reject --call-control-id <id> --cause USER_BUSY
269282
telnyx-agent call-status --call-control-id <id> --json
283+
telnyx-agent stt --audio-url https://example.com/audio.mp3
284+
telnyx-agent stt --audio-url https://example.com/audio.mp3 --model openai/whisper-large-v3-turbo --language es --json
285+
telnyx-agent stt-providers --json
286+
telnyx-agent stt-providers --provider telnyx --service-type transcription --json
270287
`;
271288

272289
const COMMANDS: Record<string, (flags: Record<string, string | boolean>) => Promise<void>> = {
@@ -298,6 +315,8 @@ const COMMANDS: Record<string, (flags: Record<string, string | boolean>) => Prom
298315
"call-dial": callDialCommand,
299316
"call-control": callControlCommand,
300317
"call-status": callStatusCommand,
318+
stt: sttCommand,
319+
"stt-providers": sttProvidersCommand,
301320
};
302321

303322
export async function run(argv: string[]): Promise<void> {

0 commit comments

Comments
 (0)