zeroclaw-labs/zeroclaw · error · anyhow::Error

Telnyx call failed: {body}

Error message

Telnyx call failed: {body}

What it means

Raised by VoiceCallChannel::execute_outbound_call when the Telnyx Call Control API returns a non-2xx status for POST {api_base_url}/calls. The request authenticates with a bearer token (config.auth_token), sends connection_id (from config.account_id), to, from, webhook_url, and timeout_secs, and the response body is embedded verbatim in the error so the Telnyx error envelope (errors[0].code/detail) tells you the exact cause. It means Telnyx refused to create the call; the raw body is the authoritative diagnosis.

Source

Thrown at crates/zeroclaw-channels/src/voice_call.rs:191

            VoiceProvider::Telnyx => {
                let url = format!("{}/calls", self.api_base_url());
                let resp = self
                    .client
                    .post(&url)
                    .bearer_auth(&self.config.auth_token)
                    .json(&serde_json::json!({
                        "connection_id": self.config.account_id,
                        "to": to_number,
                        "from": self.config.from_number,
                        "webhook_url": webhook_url,
                        "timeout_secs": self.config.max_call_duration_secs,
                    }))
                    .send()
                    .await?;

                if !resp.status().is_success() {
                    let body = resp.text().await.unwrap_or_default();
                    bail!("Telnyx call failed: {body}");
                }

                let json: serde_json::Value = serde_json::from_str(&resp.text().await?)?;
                let call_id = json["data"]["call_control_id"]
                    .as_str()
                    .unwrap_or("unknown")
                    .to_string();
                ::zeroclaw_log::record!(
                    INFO,
                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
                        .with_attrs(::serde_json::json!({"call_id": call_id, "to": to_number})),
                    "outbound call placed via Telnyx"
                );
                Ok(call_id)
            }
            VoiceProvider::Plivo => {
                let url = format!(
                    "{}/Account/{}/Call/",

View on GitHub (pinned to 88bb9c8533)

Solutions

  1. Read the embedded Telnyx error body first — it names the failing field or token (e.g. "10111\" no object found for connection_id").
  2. Verify auth_token is a current Telnyx V2 API token and account_id is the Call Control application/connection id, not the account SID.
  3. Ensure from and to are E.164 (+15551234567, no spaces/dashes) and the from number is owned by the Telnyx project.
  4. Set channels.voice.webhook_base_url to a public https base so the generated webhook_url is reachable by Telnyx.
  5. Reproduce outside the agent with curl -X POST https://api.telnyx.com/v2/calls -H "Authorization: Bearer $TOKEN" to isolate config vs. code.

Example fix

# before — config.toml
[channels.voice]
model_provider = "telnyx"
account_id = "SIM-xxxx"            # wrong: account SID, not a Call Control connection id
from_number = "+1 (555) 123-4567" # wrong: not E.164

# after
[channels.voice]
model_provider = "telnyx"
account_id = "12345678-1234-1234-1234-123456789012"  # Call Control Application/Connection id
from_number = "+15551234567"                        # E.164
webhook_base_url = "https://bot.example.com"         # public, not localhost
Defensive patterns

Strategy: try-catch

Validate before calling

fn is_e164(n: &str) -> bool {
    let digits: String = n.chars().filter(|c| c.is_ascii_digit()).collect();
    n.starts_with('+') && (8..=15).contains(&digits.len())
}

// before placing a Telnyx call
if !is_e164(to_number) || !is_e164(&voice_config.from_number) {
    anyhow::bail!("refusing to dial non-E.164 number: {to_number}");
}
if voice_config.webhook_base_url.is_none() {
    anyhow::bail!("channels.voice.webhook_base_url unset; Telnyx cannot reach status callbacks");
}

Try / catch

match voice.place_call(to_number).await {
    Ok(call_id) => { /* track id */ }
    Err(e) if e.to_string().starts_with("Telnyx call failed:") => {
        // e carries the raw Telnyx error body: log it, surface to operator,
        // and treat as non-retryable (4xx auth/validation) until config changes.
    }
    Err(e) => { /* transport errors (reqwest) may be transient: safe to retry */ }
}

Prevention

When it happens

Trigger: POST https://api.telnyx.com/v2/calls with (1) an expired/rotated V2 API token -> 401; (2) a connection_id that is not a valid Call Control Application ID (e.g. an account SID pasted instead) -> 422 'no object found for connection_id'; (3) from/to numbers not in E.164 (spaces, dashes, missing +) -> 422; (4) a from number not purchased/rented in the Telnyx project; (5) require_outbound_approval=false and any of the above fields wrong. Note webhook_url defaults to http://localhost:{webhook_port} when channels.voice.webhook_base_url is unset, which Telnyx will reject as unreachable.

Common situations: Rotating the Telnyx API key but not updating zeroclaw config; pasting the Telnyx Account ID into account_id (which must hold the Call Control connection/application id); copying a phone number from a contact card with formatting characters; running the daemon locally without webhook_base_url so Telnyx cannot reach status callbacks.

Related errors


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