{"record":{"id":"c55459209134ea23","repo":"zeroclaw-labs/zeroclaw","slug":"whatsapp-interactive-buttons-require-1-3-options","errorCode":null,"errorMessage":"WhatsApp interactive buttons require 1..=3 options (got {}); use send_interactive_list for more","messagePattern":"WhatsApp interactive buttons require 1\\.\\.=3 options \\(got (.+?)\\); use send_interactive_list for more","errorType":"validation","errorClass":null,"httpStatus":null,"severity":"error","filePath":"crates/zeroclaw-channels/src/whatsapp.rs","lineNumber":559,"sourceCode":"        messages\n    }\n\n    /// Send an interactive button message (≤ 3 buttons per Meta's Cloud\n    /// API limit). Each tuple is `(id, label)`; `id` round-trips back\n    /// through the inbound `interactive.button_reply.id` field as a\n    /// `[choice]<id>` synthetic `ChannelMessage` content (see\n    /// `parse_webhook_payload`).\n    ///\n    /// Meta's `interactive` body schema:\n    /// https://developers.facebook.com/docs/whatsapp/cloud-api/reference/messages\n    pub async fn send_interactive_buttons(\n        &self,\n        recipient: &str,\n        body_text: &str,\n        buttons: &[(String, String)],\n    ) -> anyhow::Result<()> {\n        if buttons.is_empty() || buttons.len() > 3 {\n            anyhow::bail!(\n                \"WhatsApp interactive buttons require 1..=3 options (got {}); \\\n                 use send_interactive_list for more\",\n                buttons.len()\n            );\n        }\n        let url = format!(\n            \"https://graph.facebook.com/v18.0/{}/messages\",\n            self.endpoint_id\n        );\n        ensure_https(&url)?;\n        let to = recipient.strip_prefix('+').unwrap_or(recipient);\n        let action_buttons: Vec<serde_json::Value> = buttons\n            .iter()\n            .map(|(id, label)| {\n                // Meta caps button title at 20 chars and id at 256.\n                let title = label.chars().take(20).collect::<String>();\n                let id_trim = id.chars().take(256).collect::<String>();\n                serde_json::json!({","sourceCodeStart":541,"sourceCodeEnd":577,"githubUrl":"https://github.com/zeroclaw-labs/zeroclaw/blob/88bb9c8533fc57ed7a03e36ca7c9ed2bf8336dcc/crates/zeroclaw-channels/src/whatsapp.rs#L541-L577","documentation":"ZeroClaw's WhatsApp Cloud channel rejects `send_interactive_buttons` before any HTTP call when `buttons` is empty or contains more than 3 entries. The 1..=3 cap mirrors Meta's interactive button-message contract (quick-reply buttons), so the library fails fast with a local validation error instead of letting the Graph API return a 400. The message itself points you to `send_interactive_list`, which handles up to 100 options via sections.","triggerScenarios":"Calling `WhatsAppChannel::send_interactive_buttons(recipient, body_text, &buttons)` with `buttons.len() == 0` or `> 3`. Note that `send_choice` routes only 2-3 options here, so the direct-call path with 4+ options (e.g. a menu ported from Telegram inline keyboards) is the usual trigger.","commonSituations":"Dynamic option lists (menus, LLM-generated choices, approval actions) whose length varies at runtime and occasionally exceeds 3; migrating from channels with higher button limits; an empty button vec produced by a filter chain that removed every option.","solutions":["Branch on option count: use `send_interactive_buttons` for 1-3 options and `send_interactive_list` (or simply `send_choice`, which routes automatically) for 4-100.","Trim or merge the options down to at most 3 before calling `send_interactive_buttons`.","For >100 options, split into multiple prompts or paginate with a 'show more' option."],"exampleFix":"// before\nchannel\n    .send_interactive_buttons(to, \"Pick one:\", &all_options)\n    .await?; // bails when all_options.len() > 3\n\n// after\nif (1..=3).contains(&all_options.len()) {\n    channel.send_interactive_buttons(to, \"Pick one:\", &all_options).await?;\n} else {\n    // interactive list: 10 sections x 10 rows, up to 100 options\n    channel.send_choice(to, \"Pick one:\", &all_options).await?;\n}","handlingStrategy":"validation","validationCode":"let n = options.len();\nif n == 0 {\n    // nothing to choose from: send prompt as text (or no-op)\n    return channel.send(&SendMessage::new(prompt, recipient)).await;\n}\nif n <= 3 {\n    channel.send_interactive_buttons(recipient, prompt, &options).await\n} else {\n    channel.send_choice(recipient, prompt, &options).await // interactive list\n}","typeGuard":"fn fits_whatsapp_buttons(buttons: &[(String, String)]) -> bool {\n    (1..=3).contains(&buttons.len())\n}","tryCatchPattern":null,"preventionTips":["Route variable-length choices through `send_choice`; it picks buttons vs list automatically and handles 0 and 1 options gracefully.","Bound menu size at the source (config or LLM prompt constraint): 3 buttons or 100 list rows.","Unit-test option-count boundaries (0, 1, 3, 4, 100, 101) against the channel."],"tags":["whatsapp","cloud-api","interactive-buttons","validation","rust"],"backgroundTag":"api-limit-exceeded","analyzedSha":"88bb9c8533fc57ed7a03e36ca7c9ed2bf8336dcc","analyzedAt":"2026-08-23T01:07:41.857Z","schemaVersion":2},"datasetVersion":"2026-08-23T08:06:27.607Z"}