zeroclaw-labs/zeroclaw · error · anyhow::Error
interaction followup token expired (id {interaction_id}, >15
Error message
interaction followup token expired (id {interaction_id}, >15min) What it means
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]).
Source
Thrown at crates/zeroclaw-channels/src/discord/mod.rs:1699
fn self_handle(&self) -> Option<String> {
Self::bot_user_id_from_token(&self.bot_token)
}
fn self_addressed_mention(&self) -> Option<String> {
self.self_handle().map(|id| format!("<@{id}>"))
}
async fn send(&self, message: &SendMessage) -> anyhow::Result<()> {
if let Some(interaction_id) = parse_discord_interaction_target(&message.recipient) {
let pending = {
let guard = self.pending_interactions.lock();
guard.get(interaction_id).cloned()
};
let Some(pending) = pending else {
anyhow::bail!("interaction reply target unknown or expired (id {interaction_id})");
};
if pending.created.elapsed() > INTERACTION_TOKEN_TTL {
anyhow::bail!("interaction followup token expired (id {interaction_id}, >15min)");
}
let raw = crate::util::strip_tool_call_tags(&message.content);
let (content, embeds, _embed_failures, _embeds_truncated) =
prepare_outgoing_embeds(&raw, self.workspace_dir.as_deref());
let (content, component_rows) = parse_component_markers(&content);
let component_action_rows = if component_rows.is_empty() {
Vec::new()
} else {
self.build_marker_components(&component_rows)
};
let client = self.http_client();
return deliver_interaction_answer(
&client,
&pending.app_id,
&pending.token,
DISCORD_API_BASE,
&content,
&embeds,View on GitHub (pinned to 88bb9c8533)
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
Example fix
// before: one final edit after a long turn run_long_tools().await; channel.send(&interaction_answer).await?; // >15 min → expired // after: checkpoint inside the window, finalize via a channel message edit_original_with_partial_answer().await; // <15 min run_long_tools().await; channel.send(&normal_channel_message).await?; // no token needed
Defensive patterns
Strategy: fallback
Validate before calling
// Track receipt time yourself and pre-check the 15-minute window:
let received = std::time::Instant::now();
// ... before replying:
if received.elapsed() > std::time::Duration::from_secs(15 * 60) {
// deliver via a normal channel send instead of the interaction target
} Try / catch
match channel.send(&msg).await {
Err(e) if e.to_string().contains("followup token expired") => {
let mut fallback = msg.clone();
fallback.recipient = real_channel_id.to_string(); // no token needed
channel.send(&fallback).await?;
}
r => r?,
} Prevention
- 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
When it happens
Trigger: 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.
Common situations: Long research or tool-crawl turns; overnight jobs answering old interactions; interrupted MultiMessage streaming resumed late.
Understand the failure class
- Authentication and authorization failures — expired tokens, bad credentials, and missing scopes.
Related errors
- interaction defer failed ({status}): {err}
- modal open failed ({status}): {err}
- interaction autocomplete answer failed ({status}): {err}
- interaction reject failed ({status}): {err}
- interaction followup edit failed ({status}): {err}
AI-assisted analysis of zeroclaw-labs/zeroclaw@88bb9c8533 (2026-08-23).
Data as JSON: /api/errors/56e2d0d4d8b2d7ee.
Report an issue: GitHub.