zeroclaw-labs/zeroclaw · error · anyhow::Error
Failed to speak: {}
Error message
Failed to speak: {} What it means
speak() POSTs the text to Telnyx Call Control /v2/calls/{call_control_id}/actions/speak (payload_type text, service_level premium, voice female, language en-US). Non-2xx embeds Telnyx's error body in this bail. The dominant cause in this channel is timing: Channel::send sleeps a fixed 2 seconds after initiate_call and then speaks, so an unanswered, declined, or already-ended leg makes the call_control_id stale.
Source
Thrown at crates/zeroclaw-channels/src/clawdtalk.rs:129
language: "en-US".to_string(),
};
let response = self
.client
.post(format!(
"{}/calls/{}/actions/speak",
Self::TELNYX_API_URL,
call_control_id
))
.header("Authorization", format!("Bearer {}", self.api_key))
.header("Content-Type", "application/json")
.json(&request)
.send()
.await?;
if !response.status().is_success() {
let error = response.text().await?;
anyhow::bail!("Failed to speak: {}", error);
}
Ok(())
}
/// Hang up an active call
pub async fn hangup(&self, call_control_id: &str) -> anyhow::Result<()> {
let response = self
.client
.post(format!(
"{}/calls/{}/actions/hangup",
Self::TELNYX_API_URL,
call_control_id
))
.header("Authorization", format!("Bearer {}", self.api_key))
.send()
.await?;
View on GitHub (pinned to 88bb9c8533)
Solutions
- Only speak on a confirmed-answered leg — drive speak from the call.answered webhook/state instead of the fixed 2-second sleep
- Confirm the call_control_id comes from the initiate_call response of the same account
- Inspect the embedded Telnyx body for which parameter was rejected (service_level, voice, language)
- If premium TTS is unavailable, provision it or lower service_level in SpeakRequest
Example fix
// before: fixed sleep, speak may hit an unanswered/stale leg tokio::time::sleep(Duration::from_secs(2)).await; self.speak(&session.call_control_id, &message.content).await?; // after: wait for the answered state before speaking wait_for_call_answered(&session.call_control_id).await?; self.speak(&session.call_control_id, &message.content).await?;
Defensive patterns
Strategy: try-catch
Validate before calling
// Only speak to legs confirmed answered:
// GET https://api.telnyx.com/v2/calls/{call_control_id} and check state == "answered"
let state = telnyx_call_state(&session.call_control_id).await?;
if state != "answered" {
// skip speak: the leg cannot receive TTS
} Try / catch
if let Err(e) = channel.speak(&session.call_control_id, text).await {
// stale or non-answered leg — teardown, don't retry
let _ = channel.hangup(&session.call_control_id).await;
} Prevention
- Drive speak from call.answered webhooks, not a fixed sleep
- Treat speak failure as terminal for that call leg
- Match TelnyxWebhookEvent lifecycle events with the session ids you speak to
When it happens
Trigger: Call not answered (or premium answering-machine detection ended the leg) before the fixed 2s sleep in send() elapses; speaking to a call_control_id after hangup; call_control_id from a different Telnyx project than the api_key; account/region rejecting the premium TTS service level or voice/language pair.
Common situations: Calls that roll to voicemail; recipients declining quickly; reusing a CallSession captured in a previous run after a restart; accounts without premium TTS provisioned.
Related errors
- Destination {} is not in allowed list
- Failed to initiate call: {}
- Failed to start AI conversation: {}
- TTS returned empty audio
- OpenAI TTS API error ({}): {}
AI-assisted analysis of zeroclaw-labs/zeroclaw@88bb9c8533 (2026-08-23).
Data as JSON: /api/errors/81d82189b3a926dc.
Report an issue: GitHub.