{"record":{"id":"56e2d0d4d8b2d7ee","repo":"zeroclaw-labs/zeroclaw","slug":"interaction-followup-token-expired-id-interactio","errorCode":null,"errorMessage":"interaction followup token expired (id {interaction_id}, >15min)","messagePattern":"interaction followup token expired \\(id (.+?), >15min\\)","errorType":"validation","errorClass":"anyhow::Error","httpStatus":null,"severity":"error","filePath":"crates/zeroclaw-channels/src/discord/mod.rs","lineNumber":1699,"sourceCode":"    fn self_handle(&self) -> Option<String> {\n        Self::bot_user_id_from_token(&self.bot_token)\n    }\n\n    fn self_addressed_mention(&self) -> Option<String> {\n        self.self_handle().map(|id| format!(\"<@{id}>\"))\n    }\n\n    async fn send(&self, message: &SendMessage) -> anyhow::Result<()> {\n        if let Some(interaction_id) = parse_discord_interaction_target(&message.recipient) {\n            let pending = {\n                let guard = self.pending_interactions.lock();\n                guard.get(interaction_id).cloned()\n            };\n            let Some(pending) = pending else {\n                anyhow::bail!(\"interaction reply target unknown or expired (id {interaction_id})\");\n            };\n            if pending.created.elapsed() > INTERACTION_TOKEN_TTL {\n                anyhow::bail!(\"interaction followup token expired (id {interaction_id}, >15min)\");\n            }\n            let raw = crate::util::strip_tool_call_tags(&message.content);\n            let (content, embeds, _embed_failures, _embeds_truncated) =\n                prepare_outgoing_embeds(&raw, self.workspace_dir.as_deref());\n            let (content, component_rows) = parse_component_markers(&content);\n            let component_action_rows = if component_rows.is_empty() {\n                Vec::new()\n            } else {\n                self.build_marker_components(&component_rows)\n            };\n            let client = self.http_client();\n            return deliver_interaction_answer(\n                &client,\n                &pending.app_id,\n                &pending.token,\n                DISCORD_API_BASE,\n                &content,\n                &embeds,","sourceCodeStart":1681,"sourceCodeEnd":1717,"githubUrl":"https://github.com/zeroclaw-labs/zeroclaw/blob/88bb9c8533fc57ed7a03e36ca7c9ed2bf8336dcc/crates/zeroclaw-channels/src/discord/mod.rs#L1681-L1717","documentation":"Before answering a deferred interaction, send() compares pending.created.elapsed() against INTERACTION_TOKEN_TTL (15 minutes). Discord interaction tokens — including the followup-webhook credential — are valid for exactly 15 minutes after creation; afterwards every callback and webhook call 404s. This local check fails fast with a precise reason instead of firing a doomed REST round-trip that would surface as the generic followup failure ([52]).","triggerScenarios":"Agent turn (LLM + tools + approvals) running longer than 15 minutes before the final edit; interactions queued behind a busy worker; human approval gates with long timeouts; retries after earlier failures consuming the window.","commonSituations":"Long research or tool-crawl turns; overnight jobs answering old interactions; interrupted MultiMessage streaming resumed late.","solutions":["Deliver a provisional answer early (edit @original with a partial result), then continue work","Shorten the turn: cap tool time and answer, then refine","On expiry, fall back to a normal channel message (send to a real channel id) so the user still gets the answer","Tell the user the invocation expired and to re-invoke if a live interaction is required"],"exampleFix":"// before: one final edit after a long turn\nrun_long_tools().await;\nchannel.send(&interaction_answer).await?; // >15 min → expired\n\n// after: checkpoint inside the window, finalize via a channel message\nedit_original_with_partial_answer().await;        // <15 min\nrun_long_tools().await;\nchannel.send(&normal_channel_message).await?;      // no token needed","handlingStrategy":"fallback","validationCode":"// Track receipt time yourself and pre-check the 15-minute window:\nlet received = std::time::Instant::now();\n// ... before replying:\nif received.elapsed() > std::time::Duration::from_secs(15 * 60) {\n    // deliver via a normal channel send instead of the interaction target\n}","typeGuard":null,"tryCatchPattern":"match channel.send(&msg).await {\n    Err(e) if e.to_string().contains(\"followup token expired\") => {\n        let mut fallback = msg.clone();\n        fallback.recipient = real_channel_id.to_string(); // no token needed\n        channel.send(&fallback).await?;\n    }\n    r => r?,\n}","preventionTips":["Deliver partial answers early; finish long work outside the interaction","Cap tool/approval time on interaction-triggered turns","Design every interaction reply path with a channel-message fallback"],"tags":["discord","interactions","ttl","timeout","expiry"],"backgroundTag":"discord-interaction-token-expired","analyzedSha":"88bb9c8533fc57ed7a03e36ca7c9ed2bf8336dcc","analyzedAt":"2026-08-23T01:07:41.857Z","schemaVersion":2},"datasetVersion":"2026-08-23T08:06:27.607Z"}