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

  1. Only speak on a confirmed-answered leg — drive speak from the call.answered webhook/state instead of the fixed 2-second sleep
  2. Confirm the call_control_id comes from the initiate_call response of the same account
  3. Inspect the embedded Telnyx body for which parameter was rejected (service_level, voice, language)
  4. 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

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


AI-assisted analysis of zeroclaw-labs/zeroclaw@88bb9c8533 (2026-08-23). Data as JSON: /api/errors/81d82189b3a926dc. Report an issue: GitHub.