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

modal open failed ({status}): {err}

Error message

modal open failed ({status}): {err}

What it means

After the local custom_id check passes, discord_open_modal POSTs the type-9 MODAL callback to /interactions/{id}/{token}/callback. Non-2xx becomes this error with the body. A modal is an initial response: it fails when the interaction was already acknowledged (e.g. after a defer), when the token is expired/unknown, or when the modal data violates Discord validation (rows/components structure; note the library pre-truncates to 5 rows and clamps labels/values, so those are unlikely causes here).

Source

Thrown at crates/zeroclaw-channels/src/discord/interaction.rs:81

    let Some(data) = modal.to_api() else {
        anyhow::bail!("modal custom_id exceeds Discord's 100-char limit; cannot open");
    };
    let url = format!(
        "https://discord.com/api/v10/interactions/{interaction_id}/{interaction_token}/callback"
    );
    // type 9 = MODAL
    let body = json!({ "type": 9, "data": data });
    // without_url: transport errors embed the token-bearing URL.
    let resp = client
        .post(&url)
        .json(&body)
        .send()
        .await
        .map_err(reqwest::Error::without_url)?;
    if !resp.status().is_success() {
        let status = resp.status();
        let err = resp.text().await.unwrap_or_default();
        anyhow::bail!("modal open failed ({status}): {err}");
    }
    Ok(())
}

pub(crate) async fn discord_answer_autocomplete(
    client: &reqwest::Client,
    interaction_id: &str,
    interaction_token: &str,
    choices: &[(String, String)],
) -> anyhow::Result<()> {
    let url = format!(
        "https://discord.com/api/v10/interactions/{interaction_id}/{interaction_token}/callback"
    );
    let rendered: Vec<serde_json::Value> = choices
        .iter()
        .take(25)
        .map(|(name, value)| json!({ "name": name, "value": value }))
        .collect();

View on GitHub (pinned to 88bb9c8533)

Solutions

  1. Pick exactly one initial response per interaction: modal XOR defer — never both
  2. Open the modal immediately on the button/slash event, before await-heavy work
  3. Read the body: 'interaction has already been acknowledged' vs 'Unknown interaction' point to different fixes
  4. Re-verify modal shape against Discord limits (max 5 rows, one text input per row, label ≤45)

Example fix

// before: defer then try to open a modal
discord_defer_interaction(&client, &id, &token).await?;
discord_open_modal(&client, &id, &token, &modal).await?; // 400s

// after: the modal is the sole initial response
discord_open_modal(&client, &id, &token, &modal).await?;
Defensive patterns

Strategy: try-catch

Try / catch

if let Err(e) = discord_open_modal(&client, &id, &token, &modal).await {
    // fall back to an ephemeral type-4 message so the user still gets feedback
    let _ = discord_reject_interaction(&client, &id, &token, "Could not open form").await;
}

Prevention

When it happens

Trigger: Calling open_modal after defer_interaction on the same interaction (400 already acknowledged); token expired or unknown (404) from slow pre-work; malformed component JSON in the modal data; stale interactions after a gateway resume.

Common situations: Flow that defers "for safety" then decides to open a modal; policy checks running slow between receipt and open; component schema changes after a Discord API version bump.

Related errors


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