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

  1. Deliver a provisional answer early (edit @original with a partial result), then continue work
  2. Shorten the turn: cap tool time and answer, then refine
  3. On expiry, fall back to a normal channel message (send to a real channel id) so the user still gets the answer
  4. 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

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

Related errors


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