14 KiB
OpenSpec Change: third-party-elevenlabs
- Change ID: third-party-elevenlabs
- Package:
@suaveplan/third-party-elevenlabs - Category: universal
- Target path:
packages/audio/third-party-elevenlabs - Status: PROPOSED
- Date: 2026-05-02
- Reference spec: none — new capability (genesis-original)
Summary
Deliver @suaveplan/third-party-elevenlabs on the genesis branch from scratch at enterprise quality. The package is a typed thin client for the entire ElevenLabs platform API — REST and WebSocket. It owns HTTP plumbing (auth, base URL, fetch wrapper), retry + circuit breaker via @suaveplan/retry, rate-limit-header parsing, structured error mapping, OpenTelemetry instrumentation, and Zod-validated request/response DTOs for every documented endpoint.
It is not a voice abstraction. It does not implement @suaveplan/voice interfaces. It is the substrate that domain adapter packages — @suaveplan/voice-elevenlabs, future @suaveplan/conversational-elevenlabs, @suaveplan/music-elevenlabs, @suaveplan/dubbing-elevenlabs — depend on. Domain adapters consume DTOs from this package and re-shape them into provider-agnostic domain types.
This split exists because the ElevenLabs API surface spans far beyond TTS/STT — Music, Dubbing, Studio, Audio Native, Conversational AI agents, History, Pronunciation Dictionaries, Workspace administration. Fusing HTTP plumbing into a single voice adapter would force every future surface to reinvent auth, retry, rate-limiting, OTel, and error mapping.
Why a separate package
- DRY HTTP plumbing. Auth (
xi-api-keyheader), base URL handling, idempotent retry,Retry-AfterandX-RateLimit-Remainingparsing, 401/422/429/5xx →SuaveplanErrormapping, OTel spans/counters/histograms — all live in one place. - Layering matches the genesis precedent.
payment+payment-providers,currency+currency-providers,sms+sms-providersalready separate domain abstraction from provider HTTP. ElevenLabs is the inverse — the vendor surfaces multiple domains, so the vendor client is the shared layer. - Independent versioning of vendor changes. ElevenLabs ships rapid API changes (new models, new headers, new endpoints). The thin client absorbs them; downstream adapters only update when domain semantics shift.
- Reusable across workspaces. The same client backs voice, music, dubbing, conversational-AI, and any future surface. No two adapters re-implement the same
fetchwrapper.
Out of scope
- Provider-agnostic abstractions. The client returns ElevenLabs-shaped DTOs (validated via Zod). Mapping to
@suaveplan/voicetypes happens in@suaveplan/voice-elevenlabs. - Voice-domain factories or composite engines. Those live in
@suaveplan/voice-elevenlabs. - API key management or secret storage. The caller injects
apiKeyat construction time; this package never reads fromprocess.env. - Rate-limit enforcement. The client surfaces rate-limit metadata via headers + errors; quota planning is the caller's responsibility.
Available workspace deps on genesis
| Package | Role |
|---|---|
@suaveplan/error (workspace:^) |
SuaveplanError base + defineCodes for THIRD_PARTY_ELEVENLABS.* codes |
@suaveplan/types (workspace:^) |
Clock, LoggerLike, EventBusLike, CancellableOptions |
@suaveplan/streams (workspace:^) |
toBytes for buffering ReadableStream audio uploads |
@suaveplan/retry (workspace:^) |
Retry policies, circuit breaker, backoff with jitter |
@suaveplan/logger (workspace:^) |
Optional structured logging hook |
@suaveplan/serialize (workspace:^) |
Multipart form-data helpers and binary-safe encoders if needed |
zod (catalog:forms) |
DTO schemas for every endpoint |
@opentelemetry/api (catalog:observability) |
Spans, counters, histograms |
tslib (catalog:build) |
Runtime helpers |
Dev deps: @suaveplan/testing (workspace:^, dev-only), vitest, @vitest/coverage-istanbul, fast-check, mitata (all catalog:testing).
Public API surface
Errors
class ElevenLabsApiError extends SuaveplanError<ElevenLabsErrorCode> {}
Codes (under namespace THIRD_PARTY_ELEVENLABS):
AUTH.UNAUTHORIZED— 401, invalid/missing API keyAUTH.FORBIDDEN— 403, key valid but lacks scope/quotaREQUEST.INVALID— 4xx (excluding 401/403/404/422/429), client-side request shape errorREQUEST.VALIDATION_FAILED— 422, ElevenLabs validation rejectionREQUEST.NOT_FOUND— 404RATE_LIMIT.EXCEEDED— 429 (withretryAfterMscontext)RATE_LIMIT.QUOTA_EXHAUSTED— quota header indicates exhaustion (subclass for caller branch)SERVER.TRANSIENT— 5xx, retryableSERVER.UNAVAILABLE— 503 / circuit-openNETWORK.TIMEOUT— request timeoutNETWORK.ABORTED— caller-suppliedAbortSignalabortedNETWORK.UNREACHABLE— DNS / connect failureRESPONSE.MALFORMED— JSON parse failure or Zod schema mismatchRESPONSE.UNSUPPORTED_FORMAT— server returned content-type the client cannot handleSTREAM.INTERRUPTED— server closed the streaming response mid-flightWEBSOCKET.CONNECTION_FAILED— handshake failureWEBSOCKET.PROTOCOL— server sent unexpected frame / close code
Each code has a typed subclass (UnauthorizedError, RateLimitExceededError, etc.) so callers can branch with instanceof.
Client
class ElevenLabsClient {
constructor(options: ElevenLabsClientOptions)
fetchJson<TBody>(req: JsonRequest, schema: ZodSchema<TBody>): Promise<TBody>
fetchBinary(req: BinaryRequest): Promise<BinaryResponse>
fetchStream(req: StreamRequest): Promise<ReadableStream<Uint8Array>>
fetchMultipart<TBody>(req: MultipartRequest, schema: ZodSchema<TBody>): Promise<TBody>
openSocket(req: SocketRequest): Promise<ElevenLabsSocket>
}
interface ElevenLabsClientOptions {
apiKey: string
baseUrl?: string
timeoutMs?: number
retryPolicy?: RetryPolicy
circuitBreaker?: CircuitBreaker
fetchImpl?: typeof fetch
webSocketImpl?: WebSocketImpl
clock?: Clock
logger?: LoggerLike
events?: EventBusLike<ElevenLabsClientEvents>
userAgent?: string
defaultHeaders?: Record<string, string>
}
Feature modules (REST)
Each ElevenLabs API surface gets its own subpath module. Every module exports a class taking ElevenLabsClient in its constructor; Zod schemas in *.schemas.ts; TypeScript types inferred from Zod; sibling .md.
| Module | Subpath | ElevenLabs endpoints |
|---|---|---|
tts |
./tts/tts |
POST /v1/text-to-speech/{voice_id}, /stream, /with-timestamps, /stream/with-timestamps |
tts-websocket |
./tts-websocket/tts-websocket |
wss://api.elevenlabs.io/v1/text-to-speech/{voice_id}/stream-input |
stt |
./stt/stt |
POST /v1/speech-to-text (multipart) |
voices |
./voices/voices |
GET /v1/voices, /v1/voices/{id}, /v1/shared-voices, settings get/edit, default settings |
voice-cloning |
./voice-cloning/voice-cloning |
instant clone, edit, delete, noise-reduced add |
voice-design |
./voice-design/voice-design |
generate-voice, create-voice, design, create-from-design |
speech-to-speech |
./speech-to-speech/speech-to-speech |
speech-to-speech (sync + stream) |
sound-generation |
./sound-generation/sound-generation |
POST /v1/sound-generation |
audio-isolation |
./audio-isolation/audio-isolation |
POST /v1/audio-isolation, /stream |
music |
./music/music |
music, music/stream, composition-plan, music/detailed |
dubbing |
./dubbing/dubbing |
project, asset, transcript, language audio, dubbing-resource CRUD |
studio |
./studio/studio |
projects + chapters + snapshots |
audio-native |
./audio-native/audio-native |
settings, content-update |
history |
./history/history |
list, get, audio, delete, download |
user |
./user/user |
user info, subscription |
workspace |
./workspace/workspace |
invites, members, groups, secrets, webhooks |
pronunciation-dictionaries |
./pronunciation-dictionaries/pronunciation-dictionaries |
CRUD + version listing |
conversational-ai |
./conversational-ai/conversational-ai |
agents, conversations, knowledge bases, tools, phone numbers, secrets, batch calls, dashboard |
models |
./models/models |
GET /v1/models |
WebSocket transport
interface ElevenLabsSocket {
readonly url: string
readonly readyState: 'connecting' | 'open' | 'closing' | 'closed'
send(frame: SocketFrame): void
close(code?: number, reason?: string): void
onMessage(handler: (frame: SocketFrame) => void): () => void
onClose(handler: (event: SocketCloseEvent) => void): () => void
onError(handler: (err: ElevenLabsApiError) => void): () => void
}
WebSocket modules (tts-websocket, conversational-ai) expose typed wrappers that emit/parse domain frames over this transport. The transport is responsible for ping/pong, surfacing protocol errors as WEBSOCKET.PROTOCOL errors, and clean shutdown.
Design decisions
- Native
fetchonly. Noaxios,node-fetch,undicidirect dep. Bun'sfetchsupports streaming bodies, multipart uploads,AbortSignal, andReadableStreamresponses out of the box. - DTOs validated at the boundary. Every JSON response is parsed through a Zod schema before reaching the caller. Malformed responses throw
RESPONSE.MALFORMEDwith the raw body incontext(truncated for logging). - Retry only on documented retryable codes. 429, 502, 503, 504 and
NETWORK.UNREACHABLEretry by default. 5xx that aren't gateway/throttling errors do not auto-retry — domain code decides whether to escalate. Retry-Afterstrict parsing. Both seconds and HTTP-date formats. Surface inRateLimitExceededError.context.retryAfterMs.X-RateLimit-RemainingandX-Character-Costexposed via response metadata. Every successful call returns ametadataenvelope alongside the body so adapters can attribute usage.- Streams are never buffered server-side.
fetchStreamreturnsresponse.bodyasReadableStream<Uint8Array>directly. Callers can pipe into Web Audio, MediaSource, or buffer on demand via@suaveplan/streams. - Multipart uses
FormData. Audio uploads acceptUint8Array | Blob | ReadableStream | string(URL).ReadableStreamis buffered to bytes via@suaveplan/streams.toBytesbecause ElevenLabs STT requires a complete file. URLs are fetched first. - OTel on every call. Span name
elevenlabs.<feature>.<operation>, attributeselevenlabs.endpoint,elevenlabs.method,http.response.status_code,elevenlabs.model_id(where present). Counterthird_party_elevenlabs.requests_total{feature, operation, status}. Histogramthird_party_elevenlabs.request_duration_ms{feature, operation}. - Versioned base path. Each module is constructed with the
ElevenLabsClient; the path version (/v1/) is owned by the module so a v2 migration touches only the affected module. - No singleton state. No global registry. Every feature class is constructed per-
ElevenLabsClientinstance. - Structured error context preservation. Every error carries
context.requestId,context.endpoint,context.statusCode(where applicable), andcontext.retryAfterMs(for 429). The Zod-parsed body is carried undercontext.bodyforVALIDATION_FAILEDonly. - No conditional
process.envreads. This package is universal-tier and must work in browsers and edge runtimes; environment variables are the caller's concern.
Implementation phases
The full ElevenLabs surface is large. Implementation lands in waves; the package ships at version 0.1.0 between waves.
| Wave | Scope | Status target |
|---|---|---|
| Wave 1 — voice surface | client, errors, schemas, OTel, retry, tts, stt, voices, sound-generation, audio-isolation, speech-to-speech, models |
This OpenSpec change ships Wave 1; required by @suaveplan/voice-elevenlabs v1 |
| Wave 2 — voice extensions | tts-websocket, voice-cloning, voice-design, pronunciation-dictionaries |
Follow-up third-party-elevenlabs-voice-extensions |
| Wave 3 — Conversational AI | conversational-ai, agent WebSocket frames |
Follow-up third-party-elevenlabs-conversational |
| Wave 4 — content ops | music, dubbing, studio, audio-native |
Follow-up third-party-elevenlabs-content-ops |
| Wave 5 — admin + history | history, user, workspace |
Follow-up third-party-elevenlabs-admin |
Each subsequent wave gets its own openspec/changes/<id>/ folder with delta ## ADDED Requirements against openspec/specs/third-party-elevenlabs/spec.md.
Parity Checklist
Genesis-original package — no upstream @suaveplan/third-party-elevenlabs exists in suaveplan main. Reference for behaviour: ElevenLabs API documentation at https://third-party-elevenlabs.io/docs/api-reference/.
| Capability | Upstream | This proposal | Notes |
|---|---|---|---|
| Typed REST client | n/a | ✅ Wave 1 | |
| Streaming TTS | n/a | ✅ Wave 1 | ReadableStream<Uint8Array> |
| WebSocket transport | n/a | ⏳ Wave 2 + Wave 3 | Plumbing in Wave 2 (tts-websocket); agents in Wave 3 |
| Retry/backoff/circuit breaker | n/a | ✅ Wave 1 | via @suaveplan/retry |
| OTel instrumentation | n/a | ✅ Wave 1 | spans + counters + histograms |
| Rate-limit header parsing | n/a | ✅ Wave 1 | Retry-After, X-RateLimit-Remaining, X-Character-Cost |
| All ElevenLabs endpoints | n/a | ✅ planned across waves 1–5 | this proposal lists every documented endpoint |
Impact
Affected dependents
This is a new package — no existing dependents. After Wave 1 ships, the only direct consumer will be @suaveplan/voice-elevenlabs (rebuilt as a thin adapter under change voice-elevenlabs).
@suaveplan/voice-elevenlabs— [x] tests run / [x] docs reviewed / [ ] no action needed (the rebuild lands together; see changevoice-elevenlabs)
Future changes that consume this package: @suaveplan/conversational-elevenlabs, @suaveplan/music-elevenlabs, @suaveplan/dubbing-elevenlabs, @suaveplan/studio-elevenlabs — each will land via its own OpenSpec change.
Migration
This is the first version of the package; no migration notes apply. When endpoints change across ElevenLabs API revisions, follow-up OpenSpec changes will document the migration in their ## Migration sections.