Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
6e45f56
feat(errors): give voice_not_enabled and gate_limit_reached their own…
purpshell Sep 19, 2026
ca2dea5
feat(voice): audio library and provider credential commands (beta)
purpshell Sep 19, 2026
e030824
docs(voice): document the Voice Automation beta commands and exit codes
purpshell Sep 19, 2026
8771fde
fix(voice): keep transfer timeouts active through body reads and boun…
purpshell Sep 22, 2026
776e502
Merge remote-tracking branch 'origin/dev' into t3code/voice-audio-lib…
purpshell Sep 22, 2026
73283c0
Merge remote-tracking branch 'origin/dev' into t3code/voice-audio-lib…
purpshell Sep 22, 2026
9e3742b
Merge remote-tracking branch 'origin/t3code/voice-audio-library' into…
purpshell Sep 22, 2026
48c8561
Merge remote-tracking branch 'origin/dev' into t3code/voice-audio-lib…
purpshell Sep 22, 2026
fa67e71
fix(voice): bound status polls by the wait deadline
purpshell Sep 22, 2026
30a126a
Use typed SDK voice resources in CLI commands
purpshell Sep 23, 2026
e13f69d
Merge remote-tracking branch 'origin/dev' into t3code/voice-audio-lib…
purpshell Sep 23, 2026
828870c
Merge remote-tracking branch 'origin/dev' into t3code/voice-audio-lib…
purpshell Sep 23, 2026
0319c07
docs(cli): separate Voice and usage guidance after dev merge
purpshell Sep 23, 2026
bae3f9d
fix(voice): redact signed preview link from transfer errors
purpshell Sep 23, 2026
3754cd4
docs(voice): explain signed preview error redaction
purpshell Sep 23, 2026
1750c11
chore(voice): pin published SDK with upload redaction
purpshell Sep 23, 2026
3ff9e04
fix(voice): omit signed preview link from download JSON
purpshell Sep 23, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 95 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -862,6 +862,101 @@ optional address lookup stops after five seconds without retrying and respects
a shorter `--timeout`. If it fails
or times out, the command keeps the password on screen and exits successfully.

## Voice Automation (beta)

Voice Automation is a beta (`calls.voice-automation`) for enrolled teams only.
For other teams, every change except delete fails with `voice_not_enabled`.
Reads, previews and deletes keep working after a team leaves the beta. The
commands require an organization server API key with `voice:read` (lists,
views, previews) or `voice:manage` (changes).

### Audio library

The audio library holds recordings and text-to-speech renders for calls. The
API transcodes each asset to 16 kHz mono; an asset is `transcoding` until it is
`ready` or `failed`.

```shell
polymorfa voice audio upload greeting.mp3 --name Greeting \
--project 018f0000-0000-7000-8000-000000000002 --wait
polymorfa voice audio tts --name Welcome --text "Thanks for calling." \
--provider openai --voice alloy \
--project 018f0000-0000-7000-8000-000000000002
polymorfa voice audio list --status ready \
--project 018f0000-0000-7000-8000-000000000002
polymorfa voice audio view 6f1c2f0e-1d5b-4d8e-9a55-0d4b8a6c1f10
polymorfa voice audio update 6f1c2f0e-1d5b-4d8e-9a55-0d4b8a6c1f10 \
--name "Greeting v2" --retention-days none --expected-revision 2
polymorfa voice audio preview 6f1c2f0e-1d5b-4d8e-9a55-0d4b8a6c1f10 \
--output greeting.ogg
polymorfa voice audio delete 6f1c2f0e-1d5b-4d8e-9a55-0d4b8a6c1f10 --yes
```

- `upload` takes `.mp3`, `.wav`, `.ogg`, `.opus`, `.m4a` and `.mp4` files up to
16 MB and sets the content type from the extension. Other types, empty files
and larger files fail with exit code 2 before any request is sent. The file
goes to a short-lived upload URL that carries no credential.
- `tts` reads the text from `--text` or `--text-file` (`-` for stdin): 1 to
5000 characters, or 4096 for `openai`. `--voice` is an OpenAI voice name or
an ElevenLabs voice ID. `--model` defaults to the provider's default model.
Unsupported OpenAI voices and provider-specific models fail with exit code 2
before any request is sent.
Without `--credential`, the render uses Polymorfa's managed key for the
provider.
- `upload --wait` and `tts --wait` wait until the asset is `ready` (exit 0) or
`failed` (exit 13, with the failure reason). `--wait-timeout` defaults to 120
seconds and exits with code 5 when reached, including when a status request
stalls. This budget starts after upload or TTS submission and covers status
requests, response bodies, and retries.
- `--retention-days` deletes the asset that many days after creation (1 to
3650). `update --retention-days none` keeps it until you delete it.
- `preview` prints a link to the transcoded audio (Opus in Ogg). The link works
for 5 minutes; treat it as a secret. `--output` downloads it instead and
refuses an existing file unless `--force` is given. With `--output`, JSON
results omit the signed link. Storage refusals retain the HTTP status but
omit the signed link and storage response text.
- `delete` asks for confirmation, or takes `--yes`, and exits with code 4 while
a campaign or flow uses the asset.

### Provider credentials

Store your own ElevenLabs or OpenAI key to render text to speech with it. The
CLI never takes the key from an argument. It reads the environment variable
named by `--key-env` (default `POLYMORFA_VOICE_PROVIDER_KEY`) or, when that is
unset, piped standard input, and it refuses to read the key from a terminal:

```shell
printf '%s' "$ELEVENLABS_API_KEY" | \
polymorfa voice credential add --provider elevenlabs --label "Studio key"
polymorfa voice credential add --provider openai --label "Support line" \
--key-env OPENAI_API_KEY --project 018f0000-0000-7000-8000-000000000002
polymorfa voice credential list --project 018f0000-0000-7000-8000-000000000002
polymorfa voice credential view 7a2d3e4f-5b6c-4d7e-8f90-1a2b3c4d5e6f
polymorfa voice credential verify 7a2d3e4f-5b6c-4d7e-8f90-1a2b3c4d5e6f
polymorfa voice credential delete 7a2d3e4f-5b6c-4d7e-8f90-1a2b3c4d5e6f --yes
```

Without `--project`, the credential is available to every project of the team.
Output shows only the key's fingerprint (the first 8 hex characters of its
SHA-256). The provider checks the key when it is added and on `verify`; a
rejected key fails with `provider_credential_invalid` (exit code 2).

### Voice exit codes

| Exit code | Error code | Meaning |
| --------- | ------------------------------------------------------------------------ | ------------------------------------------------------- |
| 6 | `voice_not_enabled` | The team is not enrolled in the Voice Automation beta. |
| 7 | `gate_limit_reached` | A voice limit is reached. |
| 13 | `voice_asset_failed` | Audio awaited with `--wait` failed processing. |
| 4 | `voice_asset_in_use`, `voice_asset_revision_conflict`, `asset_not_ready` | The asset is in use, changed, or not ready. |
| 2 | `provider_credential_invalid`, `invalid_parameter` | The provider rejected the key, or the input is invalid. |
| 5 | `provider_unavailable`, `voice_unavailable` | The provider or voice service did not answer. |

Other errors keep the standard exit codes: 2 for invalid input, 3 for
authentication and authorization, 4 for conflicts, missing resources and other
payment errors, and 5 for network, rate-limit, server and timeout failures.
With `--json`, stderr carries the error code in `error.code`.

### Call usage and limits

When Calls access is enabled for a team, Polymorfa measures the calls its numbers host. Usage is measured, not charged.
Expand Down
146 changes: 146 additions & 0 deletions docs/command-research/voice.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
# Voice Automation command research

- **Status:** Implemented against contract revision `voice-audio-v1`,
unpublished, beta (`calls.voice-automation`)
- **Reviewed:** 2026-09-19
- **API contract:** polymorfa/polymorfa draft PR #227, branch
`t3code/voice-audio-library`, revision `voice-audio-v1`
- **SDK reference:** `client.voice` in polymorfa/sdks draft PR #279 at
`8c61d642da51e14290ae916cd342f71cc0bf94e6`
- **SDK pin:** `@polymorfa/sdk@0.1.0-dev.20260922102711`, inherited from
CLI `dev` (see
[SDK dependency](#sdk-dependency))

## Sources reviewed

- The voice audio library and provider credential contract, revision
`voice-audio-v1`: routes, schemas, closed enums, error codes and webhook
events.
- Design section 18.2 (CLI) of the Voice Automation specification: command
tree, key input rule, and distinct exit codes for `gate_limit_reached` and
`voice_not_enabled`.
- `packages/typescript/src/platform/voice.ts` and the upload URL transport in
polymorfa/sdks `8c61d64`.

## Command tree

```text
voice
audio
upload <file> --name --project [--retention-days] [--wait] [--wait-timeout]
tts --name (--text | --text-file) --provider elevenlabs|openai --voice
[--model] [--credential] [--retention-days] --project [--wait]
list --project [--status] [--limit] [--cursor]
view <asset>
update <asset> [--name] [--retention-days <n|none>] [--expected-revision]
delete <asset> [--yes]
preview <asset> [--output <file>] [--force]
credential
add --provider --label [--project] [--key-env]
list [--project]
view <credential>
verify <credential>
delete <credential> [--yes]
```

Campaigns, flows, agents, usage and gates (design section 18.2) belong to later
Voice Automation phases and are not part of this revision.

## Operation mapping

| Command | Operation | SDK `client.voice` (8c61d64) |
| ------------------------- | ---------------------------------------------------------- | ------------------------------ |
| `voice audio upload` | `POST /platform/voice/audio`, upload URL, `.../complete` | `audio.upload` |
| `voice audio tts` | `POST /platform/voice/audio/tts` | `audio.synthesize` |
| `voice audio list` | `GET /platform/voice/audio` | `audio.list` |
| `voice audio view` | `GET /platform/voice/audio/{id}` | `audio.retrieve` |
| `voice audio update` | `PATCH /platform/voice/audio/{id}` | `audio.update` |
| `voice audio delete` | `DELETE /platform/voice/audio/{id}` | `audio.delete` |
| `voice audio preview` | `GET /platform/voice/audio/{id}/preview` | `audio.previewUrl` |
| `--wait` | `GET /platform/voice/audio/{id}` until `ready` or `failed` | `audio.retrieve` |
| `voice credential add` | `POST /platform/voice/provider-credentials` | `providerCredentials.create` |
| `voice credential list` | `GET /platform/voice/provider-credentials` | `providerCredentials.list` |
| `voice credential view` | `GET /platform/voice/provider-credentials/{id}` | `providerCredentials.retrieve` |
| `voice credential verify` | `POST /platform/voice/provider-credentials/{id}/verify` | `providerCredentials.verify` |
| `voice credential delete` | `DELETE /platform/voice/provider-credentials/{id}` | `providerCredentials.delete` |

## SDK dependency

The committed npm pin, `0.1.0-dev.20260922102711`, predates `client.voice`.
This branch uses the typed resource from SDK PR #279 and was verified with a
temporary local package. API PR #227 and SDK PR #279 must merge before this CLI
PR can pin the exact published SDK prerelease. No local package path belongs in
the committed manifest or lockfile.

`src/sdk/voice.ts` calls the typed audio and provider-credential resources.
The SDK handles upload URL transfer without the API credential. The SDK does
not expose a preview download method, so the CLI downloads the signed preview
URL itself with no credential, no redirects, a bounded response, and a timeout.

## Credential rules

- The commands resolve Platform credentials: an organization server API key
(`POLYMORFA_API_KEY` or an API-key profile). Client tokens fail before any
request. The API also accepts project tokens; the CLI does not use them for
these commands, matching `sip-trunk`.
- `audio list`, `upload` and `tts` require `--project`. `credential add` and
`credential list` take an optional `--project`; without it, `add` creates a
team-wide credential and `list` returns only team-wide credentials.
- Provider keys are never read from an argument. `credential add` reads the
variable named by `--key-env` (default `POLYMORFA_VOICE_PROVIDER_KEY`); when
the default variable is unset it reads piped standard input (64 KiB limit)
and refuses a terminal, because the repository has no hidden-input prompt.
An explicitly named but unset variable is an error; it never falls back to
stdin. The key is registered for redaction, so it is replaced with
`[REDACTED]` if an error message ever contains it.

## Command decisions

- **Local refusals.** Upload type (from the extension, case-insensitive),
empty files, files over 16,777,216 bytes, names over 100 characters,
retention outside 1 to 3650, TTS text length (5000, or 4096 for `openai`),
ElevenLabs voice IDs (`^[A-Za-z0-9]{1,64}$`), provider-specific model and
OpenAI voice names outside the SDK's accepted sets, non-UUID IDs and oversized
keys (over 512 characters) fail with exit code 2 before any request.
- **Content types.** `.mp3` sends `audio/mpeg`, `.wav` `audio/wav`, `.ogg` and
`.opus` `audio/ogg`, `.m4a` `audio/x-m4a`, `.mp4` `audio/mp4`.
- **Waiting.** `--wait` reads the asset first and then every 2 seconds, like the
SDK's `waitUntilReady`. `failed` exits with `voice_asset_failed` (13);
reaching `--wait-timeout` exits with `request_timeout` (5). The budget begins
after upload or TTS submission. Each status request receives an abort signal
for the remaining budget, including body reads and SDK retries; no poll
starts after the deadline and a result received after it is not returned.
The upload request itself is not retried, matching the SDK.
- **Preview.** Without `--output` the command prints the 5-minute URL. With it,
the file is downloaded without a credential, limited to 32 MiB, and an
existing file is refused unless `--force` is given. JSON download results,
error messages, and nested causes must not retain the signed URL or storage
response text.
- **Confirmation.** Both deletes use the shared confirmation prompt, or
`--yes` in noninteractive use.
- **No idempotency flags.** The contract defines no idempotency behavior for
these routes.

## Exit codes

| Exit code | Code |
| --------- | ------------------------------------------------------------------------ |
| 6 | `voice_not_enabled` (any status) |
| 7 | `gate_limit_reached` (any status) |
| 13 | `voice_asset_failed` (CLI condition for `--wait`) |
| 4 | `voice_asset_in_use`, `voice_asset_revision_conflict`, `asset_not_ready` |
| 2 | `provider_credential_invalid`, `invalid_parameter`, local refusals |
| 5 | `provider_unavailable`, `voice_unavailable`, `request_timeout` |

`src/errors/present.ts` maps the two voice codes before the status-based
classification, so they keep their exit code even if the API changes the HTTP
status.

## Explicit gaps

- This revision adds no command for the `voice.asset_ready` and
`voice.asset_failed` webhook events; they arrive through the existing
webhook, event and listener commands.
- No project-token support in these commands (see Credential rules).
- The CLI cannot release until `@polymorfa/sdk` with `client.voice` is
published and pinned, and the API revision is deployed.
9 changes: 5 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

11 changes: 10 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@
"@modelcontextprotocol/server": "2.0.0",
"@napi-rs/keyring": "1.3.0",
"@oclif/core": "^4.13.5",
"@polymorfa/sdk": "0.1.0-dev.20260923132258",
"@polymorfa/sdk": "0.1.0-dev.20260923144429",
"jq-wasm": "3.0.0-jq-1.8.2",
"zod": "4.2.0"
},
Expand Down Expand Up @@ -228,6 +228,15 @@
"session-ban": {
"description": "Inspect recorded organization session bans."
},
"voice": {
"description": "Voice Automation (beta): audio library and text-to-speech provider credentials."
},
"voice:audio": {
"description": "Upload, synthesize, inspect, preview, and delete library audio (beta)."
},
"voice:credential": {
"description": "Store, check, and delete your text-to-speech provider keys (beta)."
},
"session:configuration": {
"description": "Inspect and update inherited session configuration."
},
Expand Down
2 changes: 2 additions & 0 deletions skills/polymorfa/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ Use `polymorfa --help` and `polymorfa <topic> --help` as the installed command a

Organization API keys and exact-project tokens are durable principals. Browser client tokens and listener credentials are not general CLI credentials. Do not print, log, persist in project files, or pass credentials in command arguments.

Voice Automation commands (`polymorfa voice audio`, `polymorfa voice credential`) are a beta for enrolled teams; `voice_not_enabled` (exit 6) means the team is not enrolled and `gate_limit_reached` (exit 7) means a voice limit is reached, so do not retry either. Pass a provider key to `polymorfa voice credential add` only through standard input or the environment variable named by `--key-env`, never as an argument.

Use `polymorfa quicklink create --project PROJECT --input request.json` with an organization API key for standard number pairing. Treat the returned hosted URL as credential material; do not request direct session QR payloads or pairing codes.

## Choose an interface
Expand Down
39 changes: 39 additions & 0 deletions src/commands/voice/audio/delete.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import {Args, Flags} from '@oclif/core'

import {BaseCommand} from '../../../base-command.js'
import {confirmDangerous} from '../../../input/confirmation.js'
import {createVoiceClient} from '../../../sdk/voice.js'
import {
deleteAudio,
renderDeleted,
validateVoiceId,
} from '../../../services/voice.js'

export default class VoiceAudioDelete extends BaseCommand {
static override args = {
asset: Args.string({description: 'Audio asset ID.', required: true}),
}

static override description =
'Delete an audio asset permanently. Fails with voice_asset_in_use (exit code 4) while a campaign or flow uses it. Works after the Voice Automation beta is withdrawn. Requires a server API key with voice:manage.'

static override flags = {
yes: Flags.boolean({
description: 'Confirm permanent deletion without prompting.',
}),
}

static override summary = 'Delete audio (beta).'

async run(): Promise<void> {
const {args, flags} = await this.parse(VoiceAudioDelete)
const assetId = validateVoiceId(args.asset, 'asset')
await confirmDangerous({
prompt: `Delete audio ${assetId} permanently?`,
yes: flags.yes,
})
const client = createVoiceClient(await this.platformEnvironment(flags))
const result = await deleteAudio(client, assetId)
this.writeValue(result, renderDeleted('audio', result.data), flags)
}
}
Loading