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

chat.postMessage (lazy draft) failed: {err}

Error message

chat.postMessage (lazy draft) failed: {err}

What it means

Raised by SlackChannel::materialize_lazy_draft when chat.postMessage — used to lazily create the draft message on the first update_draft call — returns ok != true. The {err} is Slack's own error string from the response body ('channel_not_found', 'not_in_channel', 'msg_too_long', 'invalid_auth', 'account_inactive', 'rate_limited', ...), so the Slack error code is the diagnostic. The lazy-draft design means the first streamed update is what actually posts the message, so this error aborts streaming for that recipient.

Source

Thrown at crates/zeroclaw-channels/src/slack.rs:716

        if let Some(ts) = draft_turn.thread_ts {
            body["thread_ts"] = serde_json::json!(ts);
        }

        let resp = self
            .http_client()
            .post(self.slack_api_url("chat.postMessage"))
            .bearer_auth(&self.bot_token)
            .json(&body)
            .send()
            .await?;

        let resp_body: serde_json::Value = resp.json().await?;
        if resp_body.get("ok") != Some(&serde_json::Value::Bool(true)) {
            let err = resp_body
                .get("error")
                .and_then(|e| e.as_str())
                .unwrap_or("unknown");
            anyhow::bail!("chat.postMessage (lazy draft) failed: {err}");
        }

        let ts = resp_body
            .get("ts")
            .and_then(|v| v.as_str())
            .map(ToString::to_string);

        if let Some(ref real_ts) = ts {
            self.lazy_draft_ts
                .lock()
                .await
                .insert(lazy_id.to_string(), real_ts.clone());
        }

        Ok(ts)
    }

    fn legacy_progress_event(text: &str) -> Option<ProgressEvent> {

View on GitHub (pinned to 88bb9c8533)

Solutions

  1. Act on the Slack error string: 'not_in_channel'/'channel_not_found' → re-invite the bot or fix the target id
  2. 'msg_too_long' → chunk or truncate the streamed draft text before materializing
  3. 'rate_limited' → retry honoring Retry-After; pace update_draft calls
  4. 'invalid_auth'/'account_inactive' → reinstall the app / rotate the token
Defensive patterns

Strategy: try-catch

Try / catch

if let Err(err) = channel.update_draft(recipient, message_id, chunk).await {
    let msg = format!("{err:#}");
    let slack_err = msg.rsplit(": ").next().unwrap_or("");
    match slack_err {
        "rate_limited" => { tokio::time::sleep(retry_after_or_3s()).await; channel.update_draft(recipient, message_id, chunk).await }
        "msg_too_long" => channel.update_draft(recipient, message_id, &truncate_utf8(chunk, 39000)).await,
        "not_in_channel" | "channel_not_found" => Ok(log_invite_needed(recipient)),
        _ => Err(err),
    }
}

Prevention

When it happens

Trigger: update_draft is called for a recipient that has no draft yet; materialize_lazy_draft POSTs chat.postMessage with the token, and Slack answers ok=false. Common codes: channel the bot was removed from ('not_in_channel'), wrong/invalid channel id ('channel_not_found'), message over 40k chars ('msg_too_long'), bad token ('invalid_auth'/'account_inactive'), or rate limiting ('rate_limited').

Common situations: Bot removed from a channel (or never invited) while drafts stream; stale workspace token after app reinstallation; very long streaming drafts exceeding Slack's message size; user/channel deleted between message receipt and reply; bursts of streaming updates tripping rate limits.

Related errors


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