| name | slack-thread-routing-investigation |
| description | Diagnose why a Slack reply did not go to the right thread — failure modes 1-6 (5a-5h) plus direct-HTTP fallback. Includes wrong thread_ts from session header, deliver:local cron leak, cross-workspace bot-token block, home-workspace bot-not-in-channel, MCP wrapper `text must be a string`, Slack attachment unreadable, and the silent-empty-MsgID failure mode where `mcp__slack__conversations_add_message` returns no error and no message ID without delivering the post. Use when reply lands top-level, narration leaks, add_message returns bare error OR empty MsgID with no error, recovery stalls, user sends attachment, or user says I thought we fixed this. |
Slack Thread Routing — Investigation & Durable Post Path
⚠️ STATUS UPDATE — 2026-06-14: the send_message thread_ts-drop bug is FIXED
As of PR #29 (hermes-agent, merged 2026-06-14 21:57:32Z), the gateway send_message Slack path honors the :thread_ts segment of a 3-part target="slack:CHAN:thread_ts". Failure 1 (gateway self-roots / strips :thread_ts and falls back to the home channel — tracked as $USER-kr3b / agent-orchestrator #684) is resolved at the code level: tools/send_message_tool.py now widens _SLACK_TARGET_RE to capture :thread_ts, forwards it in _parse_target_ref, builds the payload with it in _send_slack, and fails loud if the echoed thread_ts does not match.
What this means for you, right now:
- Use the 3-part
send_message target="slack:CHAN:thread_ts" form as the PRIMARY path. It threads correctly post-#29.
- Path A (MCP HTTP-direct) and Path B (curl
chat.postMessage) are now FALLBACKS, not the default — use them only if the gateway tool is unavailable or you observe a fresh misroute.
- Every "
send_message is BROKEN / never trust it / both forms land top-level / 11 instances" statement below is HISTORICAL (all dated 2026-06-09 → 2026-06-13, pre-#29). It is retained as the investigation record, not as current guidance. Do not act on it as if send_message is still broken.
- Do NOT normalize root/sibling posts as "irreducible." Post-#29 a clean single threaded reply is achievable. If your reply still lands at channel root, it is one of: (a) Failure 5 below (a wrong
thread_ts in your session context — run step 0), or (b) a fresh regression of the now-fixed path — in which case file it (do not accept it).
Still open (NOT fixed by #29): Failure 5 — wrong thread_ts injected by the session context header. This is a separate session-routing bug, not a send_message bug. Step 0 (verify thread_ts via conversations_history, never the header) remains mandatory regardless of which post path you use.
When to use this skill
Use when ANY of these are true:
- A Slack reply landed as a top-level channel message instead of a thread reply (the user says "you posted in the wrong thread" or
conversations_replies shows the new message has thread_ts == ts).
mcp__slack__conversations_add_message is not surfaced in the runtime tool list, but the MCP server at 127.0.0.1:8006 does register it.
- Bot debug/thinking lines are leaking into a sibling thread while you are trying to post a single user-facing reply.
- The gateway
send_message Slack path silently ignores thread_ts (or rewrites it to the outgoing post own ts).
- The user says "we thought we fixed this" / "is this fixed already?" / "I thought we patched this". As of 2026-06-14 the
send_message thread_ts-drop bug was patched (PR #29 — see banner), so this signal no longer means "the send_message bug is still alive." It now most likely means either (a) Failure 5 (the session context header is feeding you a wrong thread_ts — run step 0), or (b) an agent is still following the historical "never trust send_message" guidance below and self-rooting unnecessarily. Diagnose which before reaching for a workaround; only file a fresh gateway bead+GH issue if you reproduce a genuine post-#29 send_message misroute.
Framing (updated 2026-06-14): the gateway routing bug WAS patched; one related mode remains
The send_message thread_ts-drop bug (Failure 1) was a genuine gateway-side bug in jleechanorg/agent-orchestrator-ts Slack handler — it silently stripped the :thread_ts segment from target=slack:CHAN:thread_ts and fell back to the home channel. That bug is now fixed at the code level by hermes-agent PR #29 (merged 2026-06-14). The slack-reply-inherit-thread-ts SOUL rule (2026-06-09) and the Path A/B curl workarounds in this skill were the interim agent-side mitigation; post-#29 they are fallbacks, not the default.
One related mode is NOT a send_message bug and remains open: Failure 5 — the session-routing layer can inject a wrong thread_ts into your prompt Source: Slack (...) header. No send_message fix addresses this, because the agent itself supplies the wrong target. Step 0 (verify thread_ts from conversations_history, never the header) is the durable mitigation and is mandatory on every Slack reply.
When a user signals they have seen this before ("we thought we fixed this?"): do not silently reach for a curl workaround or self-root. First determine which mode you are in — a fresh post-#29 send_message misroute (file a new gateway bead+issue, it would be a regression) vs Failure 5 (run step 0) vs an agent blindly following the historical "never trust send_message" guidance below (it is stale — use the now-fixed 3-part form).
Action plan (the only durable cure)
If you are about to send a Slack reply that must land in a specific thread, follow these FIVE steps in this exact order — do not improvise:
- Verify the
thread_ts from conversations_history, NOT from the session context header. The header line Source: Slack (group: <chan>, thread: <ts>) is a hint, not authoritative. It can be stale, point to a HERMES-bot prior self-message in the same channel (which has no replies and is not a thread), or point to a different user thread. Before composing anything: call mcp__slack__conversations_history(channel_id=<chan>, limit=5), find the user most recent message in the channel, and use ITS thread_ts (or, if top-level, ITS ts) as the reply target. This is Failure 5 mitigation (see below). One conversations_history call is cheap; the cost of getting thread_ts wrong is 2 orphan posts in channel root + a self-correction reply. Verified universal across the 2026-06-14 instance 15.
- Compose the entire final reply in your head (or in a scratch buffer) before the first
send_message call. No interim "wait" / "let me re-check" / "actually" narration. The gateway serializes every <think> block and every post-send_message tool call as a separate chat.postMessage.
- Post the reply, in priority order:
- PRIMARY (post-#29):
send_message target="slack:CHAN:thread_ts" (the 3-part form). Since PR #29 this honors :thread_ts and threads correctly — it is the simplest one-call path. (The historical "both forms land top-level / 11 instances" warning below is pre-#29 and no longer applies to the 3-part form.)
- FALLBACK: raw HTTP
chat.postMessage (Path B): one curl call, JSON body via heredoc, explicit channel + thread_ts + text, bot token from HERMES_SLACK_BOT_TOKEN. Use this if the gateway tool is unavailable or you observe a fresh misroute. Either way: one post, no probing, no retrying with different target formats — each retry adds a leaked post.
- Compose the entire final reply before the first post call. No interim "wait" / "let me re-check" / "actually" narration between post-related tool calls — the gateway serializes every
<think> block and post-call tool result as its own chat.postMessage (Failure 4). This is the real lever against sibling-post leaks, independent of which post path you choose.
A clean single threaded reply is the expected outcome post-#29 — do not treat root/sibling posts as "irreducible cost." If your reply still leaks siblings, that is Failure 4 (collapse the investigation to ≤ 3 tool calls and compose before posting); if it lands at channel root, that is Failure 5 (wrong thread_ts — run step 0) or a fresh regression to file. The 10-post instance (2026-06-12, pre-#29) was caused by 15 tool calls between the first post and the recovery — tool-call discipline is the lever.
Six known failure modes + two recurring silent-post failures
Two additional failure modes (7, 8) sit at the END of this skill — Failure 7 (silent-empty-MsgID from mcp__slack__conversations_add_message) and Failure 8 (auto-leak of agent thinking + tool stdout). Both recur in production; both were verified on 2026-07-28 thread C0ALSKLU9KM/p1785222486.120339.
Six known failure modes
Failure 1 — Gateway self-roots the post (canonical bug, 2026-06-09 → FIXED 2026-06-14 by PR #29)
Symptom: the reply becomes a new top-level channel message; subsequent user follow-ups appear threaded to the bot broken message, not the original.
Root cause: the gateway Slack post path set thread_ts = ts of the outgoing post rather than the incoming message thread_ts — it dropped the :thread_ts segment of the 3-part target and fell back to the home channel.
Diagnostic:
gh curl /api/...
On the broken message: ThreadTs == MsgID (self-rooted). On a correct message in the same channel: ThreadTs points to the original parent.
Status — FIXED: hermes-agent PR #29 (merged 2026-06-14 21:57:32Z) patched this at the code level in tools/send_message_tool.py (_SLACK_TARGET_RE widened, _parse_target_ref/_send_slack forward thread_ts, fail-loud on echo mismatch). The 3-part send_message target="slack:CHAN:thread_ts" form is now the PRIMARY working path. If you still see ThreadTs == MsgID on a post made via the 3-part form after #29, that is a regression — file a fresh gateway bead+GH issue (do not just work around it). Path A/B remain as fallbacks. (Failure 5 below — a wrong thread_ts from the session header — is a separate, still-open mode that #29 does not address.)
Failure 2 — Runtime tool-surface gap (canonical bug, 2026-06-09)
Symptom: mcp__slack__conversations_add_message is missing from your tool list, even though the server registers 13 tools including it.
Root cause: the gateway MCP client registers the tools server-side but does not surface conversations_add_message to the agent runtime. The other tools (conversations_history, conversations_replies, channels_list, users_search, etc.) ARE surfaced.
Diagnostic:
SID=$(curl -sS -i -X POST http://127.0.0.1:8006/mcp \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"probe","version":"0"}}}' \
| grep -i "^mcp-session-id:" | awk '{print $2}' | tr -d '\r')
curl -sS -X POST http://127.0.0.1:8006/mcp \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-H "Mcp-Session-Id: $SID" \
-d '{"jsonrpc":"2.0","id":2,"method":"tools/list"}'
If conversations_add_message is in the response but missing from your tool list, you have Failure 2. Fall through to the Durable Post Path.
Failure 3 — Gateway scratch-leak during probing (canonical bug, 2026-06-10)
Symptom: while figuring out the post endpoint (probing /tools/list, /v1/tools, /mcp without a session, etc.), 3-6 bot debug lines leak into the most recent visible thread in the channel — typically the broken sibling thread, which makes the leak loudest.
Root cause: the gateway times out the Slack post call after ~3 min and starts streaming intermediate status into whatever thread the user is looking at. This is the same gateway-scratch-leak from the 2026-06-09 loop, but the trigger changed: now any non-trivial post path investigation triggers it.
Mitigation: once you have a working SID from Failure 2 diagnostic, do all subsequent calls inside a single curl pipeline. Do NOT issue multiple curl calls separated by echo "---". Each one is a fresh gateway invocation that risks another scratch-leak cycle.
Failure 4 — Tool-call narration leaks as separate posts even after the final reply is correctly routed (canonical bug, 2026-06-11)
Symptom: the final user-facing reply lands correctly in-thread (verified via conversations_replies, ThreadTs == original thread_ts). But 3-7 sibling posts in the same thread are raw tool-call narration — phrases like "Let me check…", "Now I have the full picture…", "Two more scratch-leak posts in the thread — the runtime is leaking every tool-call narration", "Let me post ONE final cleanup note…". The narration comes from the runtime <think> / tool-result summary that gets streamed as its own Slack message after the final reply send_message returns.
Root cause: the gateway serializes each "text" block the runtime emits between tool calls as a separate chat.postMessage. The final send_message call succeeds, but the runtime keeps generating thinking-block text for subsequent tool calls (e.g. mcp__slack__conversations_replies to verify, read_file to check a status, etc.) and each one of those becomes a new Slack post. This is a different leak vector than Failure 1 (self-threaded) or Failure 3 (probing): the post is correctly threaded, it is just that there is a flood of them, one per think block.
Diagnostic: after sending a Slack reply, if you call mcp__slack__conversations_replies for verification and see N>1 new MsgID rows where all but one are short narration text, you have Failure 4. The ThreadTs of every leaked post matches the original thread thread_ts (correctly routed), so it is NOT Failure 1.
Mitigation:
- Compose the entire final reply before calling
send_message the first time. Do not call send_message, then verify, then write more text — each verification step generates more narration that leaks. Verify AFTER the final reply only, and accept that 1-2 leaks of meta-reasoning may slip in.
- If 5+ narration posts already leaked, post ONE explicit cleanup message in the same thread naming the noise (e.g. "the previous N posts were tool-call narration; the actionable reply is at ts X.Y") and stop. Do not delete them — there is no
chat.delete token in the runtime in the typical case (verified 2026-06-11: HERMES_SLACK_BOT_TOKEN is not in the runtime env, so the curl chat.delete path requires sourcing the token from the launchd plist, which is brittle).
- The actual post landed correctly; the user can see the answer. Failure 4 is noise, not data loss. Do not let it derail into a "fix the leak" rabbit hole when the original request is still actionable.
This is the failure mode that triggers the "2-part target=slack:CHAN works but pollutes the thread" observation from 2026-06-10 vNU3 PCD-spread (instance 5). The 2-part form is the working pattern; the leak is a separate runtime-streaming bug, not a routing bug.
Failure 5 — Wrong thread_ts from session context header (canonical bug, 2026-06-14, instance 15)
Symptom: the agent posts via Path B curl with what it believes is the correct thread_ts (taken from the runtime Source: Slack (group: <chan>, thread: <ts>) context header). The post lands as 1-2 top-level orphans in the channel root with no ThreadTs, NOT in the intended thread. A conversations_replies(thread_ts=<wrong_ts>) call returns thread_not_found because the supposed "thread" is actually a HERMES-bot prior top-level status post (not a thread) or a different user question.
Root cause: the session-routing layer that injects Source: Slack (group: <chan>, thread: <ts>) into the prompt is not always in sync with the user actual current thread. Three observed failure shapes:
- The header points to a HERMES-bot own self-message in the same channel (the bot prior status post, which is a top-level message with no replies, not a thread).
conversations_replies(ts=<bot_self_msg>) returns thread_not_found because there is no parent.
- The header points to a different user question from a different thread in the same channel. The agent posts to the wrong thread, polluting an unrelated conversation.
- The header is from a previous turn in the same session and points to a thread the user has already left. The post goes to a stale thread that the user is no longer reading.
Diagnostic:
mcp__slack__conversations_history(channel_id=<chan>, limit=5)
Mitigation: the pre-flight conversations_history step is now required before composing any Path B JSON payload. Treat the session context header as a hint about the channel (the group field is usually right), but never trust the thread field. The 1 extra tool call is cheap insurance against a 2-orphan + self-correction cycle. The corrected reply should include a Self-correction transparency section in its body (not a separate apology message) so the user reads one message with both the analysis AND the explanation of why prior siblings in the same turn were orphans. Worked example at references/2026-06-14-wrong-thread-ts-context-instance-15.md.
Distinct from Failure 1: Failure 1 is the gateway stripping :thread_ts from target=slack:CHAN:thread_ts. Failure 5 is the agent itself supplying the wrong thread_ts from a stale prompt header. The post is correctly threaded under the wrong thread (or top-level if the wrong thread_ts does not exist as a thread) — it is not the gateway that mis-routes, it is the agent input. Path A/B curl with the wrong thread_ts will reproduce the bug every time, deterministically. The fix is the agent pre-flight, not the gateway.
Implication for the gateway patch (#684): even after send_message is fixed to honor :thread_ts, Failure 5 will still occur if the agent session context header is stale. The session-routing layer that injects the thread context is a separate system from the gateway Slack post path. Future agents should be aware that the "fix the gateway" PR does not eliminate Failure 5.
Failure 5e — gateway-cron-LLM with deliver: local posts conversational narration at channel root (canonical bug, 2026-06-18, ts 1781793603)
Symptom: a cron job whose deliver field is local (no Slack chat.postMessage target is wired into the job itself) runs the LLM, and the LLM posts its conversational narration — clarifications ("just want to confirm: ..."), status updates ("phase complete on ..."), spawn announcements ("worker spawned for ...") — at channel root instead of the cron job origin thread. Observed instance: 3 channel-root orphans at ts 1781793603.149289, 1781793611.471479, 1781793618.797789 in #worldai (C0AH3RY3DK6) from the babysit-wa-2366-rev-5deak cron job. All 3 posts reference both PR #7570 and wa-2366 / rev-5deak; all 3 should have been threaded under C0AH3RY3DK6 / 1781477039.080969 (the job thread_ts).
Root cause: deliver: local means "do not call Slack from this job — the LLM is expected to surface its own output through whatever path the operator wired." When the LLM posts via Path C (gateway send_message), the 3-part target="slack:CHAN:thread_ts" form is required to thread correctly. Many cron prompts do include the channel + thread in the prompt body (e.g., "the deliver target is C0AH3RY3DK6 / thread 1781477039.080969"), but the LLM often paraphrases or skips the thread_ts segment when composing conversational posts, producing a root post. The gateway Path C is technically correct (no 5a/5b path bug here); the bug is that the LLM narration thread inheritance is non-deterministic.
Detection signature (5e-specific):
- Channel-root post (
thread_ts absent OR equal to ts)
- Author == hermes bot (
U0AEZC7RX1Q)
- Text contains the cron job name (full or any hyphen-delimited part of length ≥ 4)
- Text contains at least one PR/thread identifier extracted from the job prompt (regex:
PR #NNN, rev-XXXX, wa-NNNN, generic aa-NNNN bead keys, or NNNNNNNNNN.NNNNNN Slack ts)
- Parent job in
~/.hermes_prod/cron/jobs.json has deliver: local
Fix layers (defense-in-depth):
- Prompt guard — cron prompts SHOULD repeat the channel + thread in plain text near the top of the prompt (already done for
babysit-wa-2366-rev-5deak) AND instruct the LLM explicitly: "every Slack post MUST use target=slack:C0AH3RY3DK6:1781477039.080969 form, not the bare channel form." The CONTEXT-DRIFT GUARD header pattern is the template.
- Detector —
scripts/slack_5b_leak_detector.sh detect_5e_local_deliver_leaks scans each deliver: local job channel history, applies the signature above, and emits 5E-ALERT ts=<ts> channel=<C> job=<name> matched_id=<id> preview=<text> lines. Hooked into the combined detect_all_leaks runner so a single cron invocation catches both 5b and 5e.
- Gateway enforcement —
send_message_tool.py (already fixed by PR #29 for 5a) should also reject 3-part targets whose thread_ts segment equals 0 or is empty when the calling session has a stored origin_thread_ts (deferred; tracked as $USER-5e-enforce).
Escape hatch: cron jobs that want to opt out (e.g., LLM is supposed to start a fresh daily-thread anchor post) can set "disable_5e_detect": true in jobs.json. This mirrors the per-job override pattern used by other safety nets.
Test coverage: tests/test_slack_5e_local_deliver_leak.sh — 4 cases:
- (A) function defined
- (B) 3 leak ts detected, threaded reply + human + non-matching cron-name ignored
- (C) dedup — second run on same state file exits 0
- (D)
disable_5e_detect: true escape hatch honored
Sibling to: Failure 5 (wrong thread_ts from session context header — agent pulls stale thread from the prompt), 5b (MCP-direct Claude Code posts bypass gateway), 5c (intentional first-of-day daily-anchor is by design channel-root), 5d (cron LLM content drift — right channel/thread, wrong PR). 5e is distinct: right content, wrong routing layer (cron job path), post lands at channel root because deliver: local lets the LLM pick the post shape.
Defense-in-depth gaps remaining: (i) the gateway Path C tool does not auto-inject the cron job thread_ts when deliver: local is detected — would require a new origin_thread_ts runtime context, tracked separately; (ii) no automated re-thread on alert — operator must manually chat.update to set thread_ts on the orphan post; (iii) detector reads jobs.json from disk per run, so a job that runs >once/2h can miss leaks if the operator rotates jobs.json mid-window (acceptable for the cron cadence in use today).
Failure 5f — cross-workspace bot-token hard-block, recovery stalls with no posted reply (canonical bug, 2026-06-25, ts 1782317177)
Symptom: a session correctly diagnoses Failure 5 (or any of 5a-5e) and prepares a recovery Slack reply, but Path A / Path B / Path C / mcp__slack__conversations_add_message ALL fail with error: missing_scope or error: not_in_channel because the bot token is scoped to the bot HOME workspace (e.g. T09FXQ4LCQP Your Project workspace) and the channel lives in a DIFFERENT workspace. The agent then runs out of iteration budget and reports "iteration budget exhausted (60/60) — asking model to summarise" with NO posted reply, leaving the user with zero recovery output. Observed instance: 2026-06-25 in C0AH3RY3DK6, recovery attempts at 1782317095.215819 ("Now I will post this analysis...") and 1782317177.654589 ("Cross-workspace bot-token hard-block — sub-class 5f per the slack-misroute-detector skill"). Both stalled.
Root cause: Slack bot tokens (xoxb-...) are scoped to the bot installation workspace. A bot installed in workspace A cannot post to a channel in workspace B even with the correct channel_id + thread_ts. The gateway Path A/B/C paths all use the bot token, so they all fail identically. User tokens (xoxp-...) cross workspace boundaries (when the user is a member of both workspaces) — but the typical agent runtime has only the bot token in env, not the user token.
Mitigation — the XOX-P user-token fallback:
- Detect sub-class 5f — symptom:
chat.postMessage returns {"ok":false,"error":"missing_scope"} or {"ok":false,"error":"not_in_channel"} AND team in the error response differs from the bot home team. The agent should report "cross-workspace bot-token hard-block (sub-class 5f)" and proceed to step 2 immediately — NOT stall on the error.
- Source the XOX-P user token from
~/.bashrc — see the Token-name pitfall below for the canonical extraction command. Do NOT trust a sibling ~/.bashrc export that has been overwritten by .profile — see memory bashrc-profile-xapp-drift-blocks-launchd (2026-06-18) for the failure mode where rotating OPENCLAW_SLACK_APP_TOKEN requires updating BOTH files.
- Post via curl with the user token:
TOKEN="$(grep -h '^export SLACK_MCP_XOXP_TOKEN=' ~/.bashrc 2>/dev/null | head -1 | sed 's/^export SLACK_MCP_XOXP_TOKEN=//; s/^"//; s/"$//')"
[[ -z "$TOKEN" ]] && { echo "ERROR: SLACK_MCP_XOXP_TOKEN empty"; exit 1; }
curl -fsS -X POST "https://slack.com/api/chat.postMessage" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"channel":"<chan>","thread_ts":"<correct_ts>","text":"<reply>"}'
- Identity disclosure in post body — the reply will appear as posted by
$USER (or whichever user), not the hermes bot. Include a brief disclaimer in the post body if the identity switch might confuse the reader: "(posted via $USER identity due to cross-workspace bot-token hard-block; the analysis below is from the hermes agent)".
- Verify via
conversations_replies — the new MsgID must have ThreadTs == <correct_ts> AND UserID == U09GH5BR3QU ($USER), not a bot ID.
- Do NOT stall — the SOUL.md
push-pr-donot-stop-halfway COMMIT (shipped 2026-06-23) and the slack-cross-workspace-fallback-xoxp COMMIT (shipped 2026-06-25 in the same PR as this skill update) are the operational guardrails. An incomplete reply posted at the correct thread beats a complete analysis that the user never sees because the agent ran out of iteration budget.
Distinct from 5e: 5e is deliver: local cron narration landing at channel root (right content, wrong post shape). 5f is the recovery path being blocked entirely (no post at all because all bot-token paths fail). 5f is the "iteration budget exhausted with no posted reply" failure mode; 5e is the "posted but at wrong location" failure mode.
Failure 6 — Slack attachment referenced in message but no tool to fetch the file content (canonical bug, 2026-07-26, ts 1785057454)
Symptom: a user Slack message contains a file attachment — FileCount=1, AttachmentIDs=[F0xxxxxxxxx], HasMedia=true in the conversations_replies / conversations_history row. The user asks the agent to "continue" / "review" / "implement" / "address" the attachment contents. The agent has no files_download, files.info, or files_get tool in its runtime, and the local Slack attachments cache (~/Library/Containers/com.tinyspeck.slackmacgap/...) is either empty, inaccessible, or sits inside a TCC-protected subdirectory that the agent's terminal cannot reach. The agent is supposed to act on content it cannot read.
Root cause: the Slack MCP server registers a small read-only tool surface (channels_list, conversations_history, conversations_replies, conversations_mark, users_search, usergroups_*, list_resources, read_resource) — none of those expose files.info / files.get / files_download. The agent has read access to the metadata (filename, size, url_private) via the message row, but not to the file bytes. The local macOS Slack app caches attachments in a sandboxed container that the agent cannot traverse (filesystem timeouts on ls against the container are the canonical signature). The fallback bot token + curl https://slack.com/api/files.info requires files:read scope on the bot token AND an HTTP-reachable network — neither is guaranteed in scope.
Three observed failure shapes (all hit 2026-07-26 in C0AH3RY3DK6/1785057454.672319):
- 6a — local cache absent.
~/Library/Application Support/Slack/attachments/ does not exist; the Slack app has either not downloaded the attachment, downloaded it to a sandboxed container the agent cannot see, or auto-deleted it after the upload. Confirmed via find ~/Library -name "F0xxx" returning 0 results.
- 6b — local cache present but unreadable.
~/Library/Containers/com.tinyspeck.slackmacgap/Data/... exists but ls against it hangs or returns Operation not permitted (TCC sandbox). Even reading the attachment directory is gated.
- 6c — no token in runtime.
HERMES_SLACK_BOT_TOKEN is not in the runtime env, so even the files.info API path via curl is blocked. Some sessions have the xoxp token, others don't — never assume.
Diagnostic (cheap, always run in this order):
find ~/Library ~/Downloads ~/Desktop /tmp -name "<attachment_id>" -o -name "<base_filename>" 2>/dev/null | head -5
ls -la "$HOME/Library/Application Support/Slack/attachments/" 2>&1 | head -5
echo "HERMES_SLACK_BOT_TOKEN: ${HERMES_SLACK_BOT_TOKEN:+PRESENT (${#HERMES_SLACK_BOT_TOKEN} chars)}"
echo "SLACK_USER_TOKEN: ${SLACK_USER_TOKEN:+PRESENT (${#SLACK_USER_TOKEN} chars)}"
echo "SLACK_MCP_XOXP_TOKEN: ${SLACK_MCP_XOXP_TOKEN:+PRESENT (${#SLACK_MCP_XOXP_TOKEN} chars)}"
Mitigation (the right thing to do):
- STOP. Do NOT guess the attachment contents. A user message that says "consider these changes" / "review this file" / "implement what's in this attachment" requires the actual content. Inventing content from the subject line and applying it is the canonical "fabricated completion" anti-pattern — see
references/2026-07-15-fabrication-class.md in this skill's library for the class lesson (incident 2026-07-15: agent output a 200-line plan based on a Slack-share title without reading the source).
- Post to the thread ONCE with the diagnostic findings and three unblock options. The reply body should be a clear "I cannot read the attachment, here's what I checked, here are three ways to unblock" — NOT a multi-step investigation that leaks 5+ narration posts (per Failure 4 rules above). The structure that worked in
C0AH3RY3DK6/1785057454.672319:
- Healthy — list of mvp_site prompt files that the eventual change will touch (proves the agent has the diff surface mapped when content arrives).
- Blocked — the specific three checks that failed (cache absent, container unreadable, no token in env).
- Next actions — three concrete options for the user: paste content inline, re-attach as a different file, or point at a path where a prior session saved the draft.
- Do NOT serially probe all three failure shapes — that is the Failure 4 leak pattern. ONE
find + ONE ls + ONE echo triple-check, then reply. The user has the throughput to debug the cache; the agent does not have the throughput to thrash on the file system.
- When the unblock arrives, the existing skill content (Failure 5 pre-flight, Path A/B/C post paths) applies unchanged. The attachment is metadata-gated only — once the bytes are in scope, the rest of the Slack post path is the same as any other reply.
Distinct from Failure 1-5: Failures 1-5 are about post routing (wrong thread, wrong channel, narration leak). Failure 6 is about input — the agent has no tool to read the very artifact the user is asking it to act on. The agent's reply shape is different: instead of "post to the right thread," it is "tell the user I cannot read the file and ask for the content." The investigation budget is also tighter (3 checks, not 25) because the failure mode does not depend on the Slack post path at all.
Future-work note (not in scope 2026-07-26): adding a files_download MCP tool to the Slack MCP server would close Failure 6 at the harness level. The cost is one new tool exposing the existing files.info + files_download Slack Web API methods, gated by the same files:read scope that the bot token already has in most installs. Filed as a followup: agent-orchestrator slack-mcp-server add files_download tool. Until then, the agent's only durable mitigation is the STOP + ask path.
Bug-ref: 2026-07-26, C0AH3RY3DK6/1785057454.672319 ("Consider these changes to add challenge to the game but adapt to the $PROJECT_ROOT/ code and prompts, the suggesting agent coudlnt read existing code"). Message row showed FileCount=1, AttachmentIDs=F0BKZ4BJYTW, HasMedia=true. Local cache: directory not present (6a). Token check: not in runtime env (6c). User follow-up "Continue" at 1785114900.765049 arrived before the agent could resolve — agent reposted with the three unblock options and stopped. No fabrication; no blind edits to $PROJECT_ROOT/prompts/*.md; no investigation leak beyond the three diagnostic calls. The user's underlying intent (add challenge mechanics to the game prompts) is preserved — the agent has the diff surface mapped (narrative_system_instruction.md, combat_system_instruction.md, game_state_mechanics_appendix.md, rewards_system_instruction.md, leveling_pace_contract.md, prompts/divine/, prompts/shared/) and is ready to act when the content arrives.
Failure 5g — not_in_channel for a channel in the bot HOME workspace (canonical bug, 2026-07-16, ts 1784215712)
Symptom: mcp__slack__conversations_add_message(channel_id=<C0xxx>, thread_ts=<ts>, text=...) returns {"error":"not_in_channel"} even though the channel IS in the bot home workspace (T09FXQ4LCQP in the 2026-07-16 instance). Same not_in_channel shape as 5f cross-workspace block, but with the same team in both error and home workspace — so 5f "different workspace" diagnostic does not fire. Distinct root cause: the bot user (B0BGY53L8N8 hermes, or B0A450AF9NF MCP Agent Mail depending on which MCP server the runtime uses) has not been invited to the channel, even though the workspace is the right one. Slack bot tokens can read any public channel via conversations_history/conversations_replies without explicit invitation, but conversations_add_message (and chat.postMessage) requires the bot to be a member of the channel.
Diagnostic (5g-specific): the error response is {"ok":false,"error":"not_in_channel"} with the same team value as the home workspace — NOT missing_scope and NOT team_access_not_granted (which would indicate 5f). If you have read access (verified via conversations_history returning the message list) but cannot post, you have 5g.
Mitigation: the XOX-P user-token fallback from 5f applies verbatim — a user token (xoxp-...) is scoped to the user own channel memberships, not the bot, and posts as the human user. The bot not_in_channel error does NOT block a user-token post. Path:
- Detect 5g —
conversations_add_message returns not_in_channel with same team as home workspace. conversations_replies/conversations_history STILL WORK (read tools do not require membership). Distinct from 5f (cross-workspace) and from a generic channel_not_found (wrong channel ID entirely).
- Source
SLACK_MCP_XOXP_TOKEN from ~/.bashrc per the Token-name pitfall below.
- Post via curl with the user token (same curl recipe as 5f step 3) — the post will appear as
$USER, not the hermes bot. Include a brief identity-disclosure line in the post body if the identity switch might confuse readers.
- Verify via
conversations_replies — new MsgID must have ThreadTs == <correct_ts> AND UserID == U09GH5BR3QU.
- Do NOT attempt to invite the bot to the channel — that requires
channels.invite API scope which the bot token likely lacks. The XOX-P path is the durable mitigation.
Distinct from 5f: 5f is the bot token being scoped to a different workspace (different team). 5g is the bot token being in the right workspace but not invited to the specific channel (same team, missing channel membership). Both block conversations_add_message/chat.postMessage, and both have the same XOX-P recovery, but the diagnostic is different — verify the team field in the error response first.
Token-name pitfall (verified 2026-07-16; refreshed 2026-07-17 with ~/.profile source for SLACK_USER_TOKEN): the xoxp token has TWO canonical env vars in this environment, depending on which dotfile you source:
| Variable name | Location | Length | Prefix | Notes |
|---|
SLACK_MCP_XOXP_TOKEN | ~/.bashrc line 290 | 80 chars | [REDACTED_SLACK_TOKEN] | Originally documented in this skill (2026-07-16) |
SLACK_USER_TOKEN | ~/.profile line 4 | 80 chars | [REDACTED_SLACK_TOKEN] | The SOUL.md slack-cross-workspace-fallback-xoxp COMMIT canonical name |
Both vars carry the SAME token value (the MCP Agent Mail OAuth user-token). The var named in SOUL.md (SLACK_USER_TOKEN) is NOT in ~/.bashrc — it's in ~/.profile, which is not sourced by ~/.bash_profile on stock macOS bash. That means the SOUL.md-canonical name only resolves when you explicitly source ~/.profile.
Reliable extraction (in priority order):
TOKEN=$(grep -h '^export SLACK_USER_TOKEN=' ~/.profile 2>/dev/null | head -1 | sed 's/^export SLACK_USER_TOKEN=//; s/^"//; s/"$//')
if [[ -z "$TOKEN" ]]; then
TOKEN=$(grep -h '^export SLACK_MCP_XOXP_TOKEN=' ~/.bashrc 2>/dev/null | head -1 | sed 's/^export SLACK_MCP_XOXP_TOKEN=//; s/^"//; s/"$//')
fi
if [[ -z "$TOKEN" ]]; then
TOKEN=$(grep -hi 'xoxp-' ~/.bashrc ~/.profile ~/.bash_profile ~/.zshrc 2>/dev/null | head -1 | sed 's/^[^=]*SLACK_[A-Z_]*TOKEN=//; s/^"//; s/"$//')
fi
[[ -z "$TOKEN" ]] && { echo "ERROR: no xoxp token found in any dotfile"; exit 1; }
The bash -lc vs bash -c sourcing divergence (verified 2026-07-17, jleechanorg/jleechanclaw PR #786 babysit cron 59e6e2f5dda0 tick #1):
bash -lc 'echo $SLACK_USER_TOKEN' from a direct terminal: WORKS — login shell sources .bash_profile → .bashrc → .profile chain.
bash -lc 'echo $SLACK_USER_TOKEN' from subprocess.run(['bash','-lc',...]) in Python: RETURNS EMPTY. Python's subprocess does NOT inherit the login-shell rc-file chain from the parent terminal session — it spawns a fresh non-interactive shell that, even with -l, does not pick up the user custom exports from .profile.
bash -c 'source ~/.profile; echo $SLACK_USER_TOKEN' (non-login, explicit .profile source): WORKS.
The durable Python-subprocess recipe is:
import subprocess
proc = subprocess.run(['bash', '-c', 'source ~/.profile 2>/dev/null; printf "%s" "$SLACK_USER_TOKEN"'], capture_output=True, text=True)
user_token = proc.stdout
This is the documented bashrc-profile-xapp-drift-blocks-launchd memory pattern manifesting in the Python subprocess runtime — the cron babysit needs the explicit .profile source, not a login-shell assumption.
For the bot token (Path B), the canonical var is HERMES_SLACK_BOT_TOKEN (line 958 of ~/.bashrc). SLACK_BOT_TOKEN (line 953) and SLACK_MCP_XOXB_TOKEN (line 956) are aliases. Always verify the var is non-empty before issuing curl — the cheap pre-flight is the [[ -z "$TOKEN" ]] guard above. Bug-ref: 2026-07-16, C0AJQ5M0A0Y/1784215507.433759 (cron backup ? placeholders diagnostic), where the documented SLACK_USER_TOKEN extraction returned length 0 on first attempt and a second grep -h -i 'SLACK.*TOKEN' ~/.bashrc was needed to find the real var name. Refreshed 2026-07-17 when a bash -lc invocation inside subprocess.run returned empty for SLACK_USER_TOKEN (the subprocess parent is non-interactive, so the login-shell rc-file chain does NOT propagate); explicit source ~/.profile was the working fix.
Bug-ref: 2026-07-16, channel C0AJQ5M0A0Y thread 1784215507.433759 (cron backup ? placeholders diagnostic). MCP conversations_add_message returned {"error":"not_in_channel"} on first attempt; Path B curl with SLACK_MCP_XOXP_TOKEN from ~/.bashrc line 290 succeeded with ts=1784215712.918349, thread_ts=1784215507.433759, posted as U09GH5BR3QU ($USER) via "MCP Agent Mail" bot identity. The diagnosis surfaced that the MCP Agent Mail bot (B0A450AF9NF) had been invited to the channel at some earlier point but the hermes bot (B0BGY53L8N8) had not — hence conversations_add_message (hermes bot token) failed but Path B curl with xoxp ($USER identity, scoped to the user own channel memberships) succeeded.
Implication for future bots: when wiring a new Slack bot to a workspace, the channel-invitation step for each channel the bot should post in is a separate operational task — the OAuth install flow grants workspace-wide scopes, not per-channel membership. A durable check: after every bot install/reinstall, run conversations_list_members(channel_id=<each_target_channel>) to verify the bot is in the channel; if not, channels.invite (requires the right OAuth scope) or fall back to XOX-P user-token path for that channel until the invite lands.
Failure 5h — text must be a string MCP wrapper shape (canonical bug, 2026-07-26, ts 1785119827)
Symptom: mcp__slack__conversations_add_message(channel_id=<C0xxx>, thread_ts=<ts>, text=<markdown_with_newlines>) returns {"error":"text must be a string"} even though text IS a string in the calling agent's runtime (verified — the call payload contains a single string field with \n newlines and Slack mrkdwn formatting). Same MCP server, same channel, same thread_ts that worked on prior calls in the same session. No missing_scope, no not_in_channel, no team_access_not_granted — just text must be a string with no ok field at all.
Diagnostic (5h-specific):
- The error has no
ok: false wrapper and no team field. It looks like a wrapper-layer validation error, NOT a Slack API response.
- The same payload (same
channel_id, same thread_ts, same text content) succeeds via Path B curl with the xoxp token. Verified: 2026-07-26 thread C0AJQ5M0A0Y/1784906714.446409, the MCP call returned {"error":"text must be a string"} at the wrapper layer, and the immediately-following Path B curl with SLACK_USER_TOKEN from ~/.profile succeeded with ts=1785119872.441179, ThreadTs=1784906714.446409.
- The error is deterministic on the MCP path but does not reproduce via direct HTTP. That asymmetry is the diagnostic — the wrapper is rejecting something the Slack Web API accepts.
Root cause (best guess, verified 2026-07-26): the mcp__slack__conversations_add_message tool in this runtime wraps the text argument in a JSON schema validation step before forwarding to the Slack Web API. The validation appears to either (a) reject multi-line strings with embedded \n characters (the calling agent's text field was 3 paragraphs with newlines, which Path B accepted verbatim), (b) reject strings containing certain mrkdwn tokens (<@USERID> mentions, code fences with backticks, the <url|text> link shape), or (c) reject strings exceeding some implicit length threshold. The exact trigger was not isolated because switching to Path B was the durable fix — the MCP path is non-load-bearing for this thread.
Mitigation: Path B (curl chat.postMessage with xoxp or bot token) is the durable mitigation. The MCP wrapper shape is not user-controllable from the agent runtime; the failure cannot be worked around within the MCP tool surface. Path:
- Detect 5h —
mcp__slack__conversations_add_message returns {"error":"text must be a string"} with no ok/team fields. Distinct from 5f (cross-workspace, missing_scope/not_in_channel), 5g (same-workspace, not_in_channel), and 5d (token missing, invalid_auth). The bare error string is the fingerprint.
- Do NOT retry the MCP call with shorter/different
text content. The wrapper validation is non-deterministic across runs and retrying burns iteration budget without changing the outcome. Switch paths immediately.
- Source the token per the Token-name pitfall below —
SLACK_USER_TOKEN (canonical SOUL.md name, lives in ~/.profile line 4) or SLACK_MCP_XOXP_TOKEN (lives in ~/.bashrc line 290). Both carry the same xoxp value.
- Materialize the JSON via
write_file (per the durable pattern in references/2026-06-14-dropped-thread-followup-instance-13.md) — the terminal wrapper in some runtimes rejects heredoc-style command bodies with Foreground command uses '&' backgrounding errors, so writing to /tmp/slack-reply-<ts>.json first and using --data-binary @<file> is the canonical shape.
- Post via curl with
xoxp- token:
curl -fsS -X POST "https://slack.com/api/chat.postMessage" \
-H "Authorization: Bearer $SLACK_USER_TOKEN" \
-H "Content-Type: application/json; charset=utf-8" \
--data-binary @/tmp/slack-reply-<ts>.json
- Verify via
conversations_replies — new MsgID must have ThreadTs == <correct_ts> AND UserID == U09GH5BR3QU ($USER identity, since xoxp posts as the user).
- Identity disclosure in post body — the reply appears as
$USER, not the hermes bot. Include a brief disclaimer in the post body if the identity switch might confuse readers: "(posted via $USER identity due to MCP wrapper text must be a string error)".
Distinct from 5f and 5g: both 5f and 5g are channel/workspace membership issues that block all bot-token paths identically. 5h is a runtime wrapper-layer validation error that ONLY blocks the MCP path — the same payload works via direct HTTP curl. The diagnostic is the bare error string and the absence of ok/team fields; the mitigation is identical (xoxp Path B curl), but the framing of the failure differs (runtime wrapper vs Slack API).
Why this matters as a class: the MCP wrapper layer is not under the agent's control and the validation behavior is opaque. Any future agent that hits {"error":"text must be a string"} (or similar bare-error shapes without ok/team) should treat the MCP path as broken for that payload and switch to Path B without retrying. The retry loop is the trap — the wrapper is deterministic on the same payload, and the agent will burn 5+ tool calls trying different text shapes before concluding the wrapper is broken. Path B is reachable in 1 curl call.
Bug-ref: 2026-07-26, C0AJQ5M0A0Y/1784906714.446409 (Cron Backup Slack message catch-up). First reply attempt via mcp__slack__conversations_add_message(channel_id="C0AJQ5M0A0Y", thread_ts="1784906714.446409", text=<3-paragraph mrkdwn with newlines and <@U09GH5BR3QU>mentions>) returned{"error":"text must be a string"}. Second reply attempt with the same payload via Path B curl using SLACK_USER_TOKENfrom~/.profileline 4 succeeded atts=1785119827.077019, ThreadTs=1784906714.446409. Identity-disclosure line added in subsequent reply (ts=1785119872.441179). The <@U0AEZC7RX1Q>Hermes-bot mention was stripped on display in the user's quote because the xoxp user identity cannot resolve the bot user mention the same way a bot-token post does — future alert-tagged posts that need the Hermes bot to wake up should use the bot-token Path B (withHERMES_SLACK_BOT_TOKEN`) instead of xoxp when the MCP path is broken.
Durable Post Path (3 paths in priority order)
Post-#29 priority (2026-06-14): the primary path is now Path C (gateway send_message 3-part target="slack:CHAN:thread_ts") — see the Path C section below. Path A and Path B are fallbacks for when the gateway tool is unavailable or a fresh misroute is observed. The "Path A preferred since 2026-06-09" note below reflects the pre-#29 era when send_message was broken.
Path A — Slack MCP HTTP-direct (fallback; was preferred 2026-06-09 → pre-#29)
SID=$(curl -sS -i -X POST http://127.0.0.1:8006/mcp \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"curl","version":"0"}}}' \
| grep -i "^mcp-session-id:" | awk '{print $2}' | tr -d '\r')
curl -sS -X POST http://127.0.0.1:8006/mcp \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-H "Mcp-Session-Id: $SID" \
-d '{"jsonrpc":"2.0","method":"notifications/initialized"}' \
-o /dev/null
python3 -c "
import json
print(json.dumps({
'jsonrpc':'2.0','id':3,
'method':'tools/call',
'params':{
'name':'conversations_add_message',
'arguments':{
'channel_id':'C0XXXXXXXX',
'thread_ts':'1234567890.123456', # ← INHERIT from incoming, NOT the outgoing post ts
'content_type':'text/plain', # avoids Block Kit fragmentation
'text': '...'
}
}
}))
" > /tmp/post.json
curl -sS -X POST http://127.0.0.1:8006/mcp \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-H "Mcp-Session-Id: $SID" \
--data-binary @/tmp/post.json
The MCP response returns a CSV header line, NOT the message ID. To confirm the post landed, use mcp__slack__conversations_replies and look for the new MsgID at the bottom.
CRITICAL: thread_ts must be the INCOMING message thread_ts. If the incoming message has no thread_ts (top-level), use the incoming message own ts. This is enforced by SOUL rule COMMIT: slack-reply-inherit-thread-ts.
Path B — chat.postMessage with bot token (escape hatch)
When the MCP server is down or refusing connections. Use mrkdwn=False to get plain text without Block Kit fragmentation:
curl -sS -X POST https://slack.com/api/chat.postMessage \
-H "Authorization: Bearer $HERMES_SLACK_BOT_TOKEN" \
-H "Content-Type: application/json; charset=utf-8" \
-d '{
"channel":"C0XXXXXXXX",
"thread_ts":"1234567890.123456",
"mrkdwn":false,
"text":"..."
}'
content_type for the MCP layer accepts both text/markdown and text/plain (verified 2026-06-10, despite the schema documented enum of text/markdown only). Use text/plain for user-facing posts that contain emoji shortcodes or formatting that fragments in Block Kit — the 2026-06-09 "formatting broken" complaint was caused by text/markdown going through Block Kit rich_text parsing. The chat.postMessage fallback with mrkdwn=False (Path B) is only needed if you specifically want a real ts in the API response (the MCP path returns a CSV header instead).
Path C — gateway send_message 3-part form (PRIMARY since PR #29, 2026-06-14)
This is now the recommended primary path for threaded replies. The historical breakage below predates the fix.
- 3-part form
target=slack:CHAN:thread_ts — WORKS as of PR #29 (2026-06-14). The gateway now honors :thread_ts and fails loud on mismatch. Use this as the default one-call path. (Pre-#29 history: silently stripped :thread_ts and landed in the home channel — Failure 1, the bug #29 fixed.)
- 2-part form
target=slack:CHAN (no :thread_ts) — still non-deterministic for thread inheritance (sometimes threads, sometimes lands as a top-level orphan). Do not rely on it; always pass the explicit 3-part :thread_ts.
Recommended order: (1) send_message 3-part target=slack:CHAN:thread_ts (primary, post-#29), (2) Path A (MCP HTTP-direct), (3) Path B (curl chat.postMessage with bot token) — fall to A/B only if the gateway tool is unavailable or you observe a fresh misroute. Always verify with conversations_replies; if a 3-part post self-roots after #29, treat it as a regression and file it.
Pre-#29 verification record: "broken across 11 instances (2026-06-09 → 2026-06-13)" — historical, applied before the fix. Retained as the investigation record, not current guidance.
Verifying a post landed correctly
Pass criteria: new MsgID row has ThreadTs matching the original thread thread_ts.
Fail criteria: new MsgID row has ThreadTs equal to its own MsgID (self-rooted) — post-#29 this means either (a) Failure 5 (you posted with a wrong thread_ts from the session header — run step 0), or (b) a regression of the now-fixed send_message path (file a fresh gateway bead+GH issue). Pre-#29 it meant Failure 1 / the gateway thread_ts-drop bug, which PR #29 has since fixed.
Anti-patterns to avoid
- Do NOT spawn an agent session just to read/post Slack. Use Slack MCP tools directly or Path A.
- Do NOT call
mcp__slack__conversations_add_message from runtime that does not surface it (Failure 2). The tool call will fail with a "tool not found" error; the gateway may then leak scratch while retrying.
- Do NOT post plain text and then follow up with formatting. Pick
text/plain (Path A or B) for the entire thread, or text/markdown (Path A only). Mixing causes Block Kit fragmentation.
- Do NOT issue multiple curl calls separated by
echo "---" or interactive commentary while probing. Each call risks Failure 3.
- Do NOT trust the gateway
deliver=slack:chat_id cron format for threaded delivery. Use deliver=slack:chat_id:thread_ts instead. The thread_id segment is what carries thread_ts into the cron job outgoing post.
- Do NOT blindly trust
SLACK_USER_TOKEN as the xoxp variable name OR its location — verified 2026-07-16 the canonical ~/.bashrc name is SLACK_MCP_XOXP_TOKEN; refreshed 2026-07-17 when a cron babysit found SLACK_USER_TOKEN IS valid but lives in ~/.profile (line 4), not ~/.bashrc. Always grep -hi 'xoxp-' ~/.bashrc ~/.profile ~/.bash_profile ~/.zshrc to confirm before extracting.
Support files
scripts/slack_mcp_post.sh — copy-pasteable bash script that bakes in the three lessons from this skill: (1) capture SID in one curl call, do not probe, (2) inherit thread_ts from the incoming message, (3) the MCP response is a CSV header, not a JSON-RPC result. Use this instead of hand-typing the curl pipeline each time. Args: <channel_id> <thread_ts> <text-file>. Exit 0 = post accepted by MCP, but you must still verify with conversations_replies.
references/2026-06-10-repro-vNU3-pcd-spread-instance-5.md — 5th confirmed instance of the 3-part form mis-route. Worked example of a 2-part target=slack:CHAN recovery (no thread_ts) that happened to work, but with 3-4 leaked meta-reasoning messages as collateral. The 2nd-derivative lesson: do not retry send_message with different target formats once you see the home-channel fallback — switch to curl on the second attempt, not the third.
references/2026-06-10-wa-2289-godmode-l6-instance-4.md — 4th confirmed instance of the 3-part form mis-route (this session). Worked example of a clean Path B recovery with no scratch-leak. Read this if you want the "smallest possible clean recovery" recipe; read references/2026-06-10-dropped-thread-followup.md for the more chaotic instances with scratch-leaks.
references/2026-06-13-stale-fix-callback-instance-11.md — 11th confirmed instance and first time the user explicitly invoked a prior fix as an expectation of resolution ("We should not be replying here I thought we fixed the issue?"). Documents the recovery recipe (HERMES_SLACK_BOT_TOKEN sourced from ~/.bashrc, not runtime env) and the two micro-lessons embedded: (1) gh issue create --label does NOT validate label existence — always gh label list first; (2) br comments add <id> "text" uses positional args, not --body. Read this when the user signals "I thought this was fixed" — the next action is file a bead + GH issue for the gateway patch, not another workaround.
references/2026-06-13-another-example-instance-12.md — 12th instance. User sends a Slack link to the bot own broken orphan as proof of the bug (Failure 1 + Failure 4 combo). The right reply target is the orphan itself (make it the parent of the answer), NOT a new top-level. Documents the "two distinct bugs" disambiguation (PR #27 vs issue #684), the live-verify snippet for which hermes → venv → gateway/run.py:14681, and the rule against adding a 12th SOUL rule when the user signals "I thought we fixed this."
Related skills
recurring-job-notifications — for scheduled launchd/cron jobs that need to
send Gmail + Slack on success AND error. Includes the channel-membership
reality table (bot in #life / #all-$USER-ai but NOT in #ai-general),
the gog gmail send pattern, the ERR trap-based failure alerts, and three
recurring-shell-script bugs (slack_post_message | tee swallow,
find -newermt @<epoch> BSD silent-fail, [[ -z "$X" ]] && X=0 ERR
trap false-positive). Read this skill before wiring notifications onto any
new scheduled job.
slack-mcp-mail-bot-reinstall — re-invite the bot to a channel, fix scope
gaps, repair the Slack side of MCP Agent Mail.
When the user says "out of thread" or "did you fix the Slack bug?", there are two separate failure modes that look like the same bug. Do not conflate them. The merged PR and the open issue are in different repos and address different code paths.
| Bug A: channel-root leak on context compression | Bug B: send_message strips :thread_ts from 3-part target |
|---|
| PR/issue | jleechanorg/hermes-agent#27 MERGED 2026-06-12 (f4841cc3) | jleechanorg/agent-orchestrator-ts#684 — fixed by jleechanorg/hermes-agent#29 MERGED 2026-06-14 (04f82afa3) |
| Repo | jleechanorg/hermes-agent (the upstream gateway fork) | fix landed in jleechanorg/hermes-agent (tools/send_message_tool.py); issue tracked under agent-orchestrator |
| Symptom | During a long run, after context compression, the bot chat.postMessage lands at channel root (thread_ts=None) instead of threading under the user original message | The send_message tool target=slack:CHAN:thread_ts form silently stripped the :thread_ts segment and fell back to either the home channel C0AJQ5M0A0Y or top-level orphan in the target channel |
| Fix mechanism | _status_thread_metadata now carries the Slack reply-anchor thread_id through the queued-follow-up / stream-consumer path (gateway/run.py line 14681 region) | PR #29: _SLACK_TARGET_RE widened to capture :thread_ts, _parse_target_ref/_send_slack forward it into the payload, fail-loud on echo mismatch |
| Live status | IS running — verify with grep -n "_status_thread_metadata" $HOME/projects_other/hermes-agent/gateway/run.py (5+ hits expected) | FIXED & deployed — verify with grep -n "_SLACK_TARGET_RE|thread_ts" $HOME/projects_other/hermes-agent/tools/send_message_tool.py. The 3-part send_message form is now the primary path; Path A/B remain as fallbacks |
When the user asks "is the Slack out-of-thread bug fixed?":
- If they mean "during long runs, sometimes the bot reply goes to channel root" → YES, PR #27 fixed it. Verify with the grep above before claiming.
- If they mean "the bot reply to my message just went to the wrong place" → As of 2026-06-14, PR #29 fixed the
send_message thread_ts-drop (issue #684). The 3-part target=slack:CHAN:thread_ts form now threads correctly. If you still see a misroute via that form post-#29, it is a regression — file a fresh gateway bead+GH issue. (Older notes about $USER-k5z / a stale "can be closed" comment on #684 are obsolete now that #29 has landed.)
- If they mean "the bot posted to a totally unrelated thread / a HERMES-bot self-message" → that is Failure 5, not issue #684. The fix is the agent pre-flight
conversations_history step in the Action plan above, not a gateway patch.
- If unclear: ask which symptom they saw, do not conflate.
Reply target rule when the user sends a broken post as evidence
When the user question includes a Slack link to a broken bot post (an orphan with thread_ts=None or thread_ts == ts), the right reply target is the broken post itself, not a new top-level message. Reply with thread_ts=orphan.ts via Path B curl so the orphan becomes the parent of the actual diagnosis — the thread is then self-documenting for future Slack searches.
| User question shape | Right reply target | Wrong reply target |
|---|
| "Why are replies still going out of thread?" + Slack link to broken bot orphan | That orphan (thread_ts=orphan.ts) | A new top-level channel message (becomes a 3rd orphan) |
| "You posted in the wrong thread" | The intended parent thread (use the thread_ts the user is pointing at) | The broken post (adds noise to the wrong conversation) |
| "Why is the bot narration leaking?" | The same thread the user is in | A new thread |
| "Status on..." (any direct question) | The thread the user is currently in — verify with conversations_history(limit=5) first, do NOT trust the session context header thread field | The thread_ts from the session context header (Failure 5: may be a HERMES-bot prior self-message, a different user question, or a stale thread) |
When in doubt about the right thread_ts: the mcp__slack__conversations_history(channel_id=<chan>, limit=5) call returns the most recent 5-10 messages in the channel. The user question is the row with UserName=$USER (or whoever the human is in this context). Use that row ThreadTs (if it is a reply) or its own Ts (if top-level) as the reply target. This pre-flight is Failure 5 mitigation and is now required, not optional.
Patches / known followups
Marker — 2026-06-14 21:57Z: hermes-agent PR #29 fixed the send_message thread_ts-drop bug (Failure 1). Every changelog entry below this line dated before 2026-06-14 21:57Z predates that fix and describes the pre-#29 broken behavior. Read them as the investigation record, not as current guidance — the 3-part send_message target="slack:CHAN:thread_ts" form now works. The still-open mode is Failure 5 (wrong thread_ts from the session header), which #29 does not address.
-
2026-07-17 (jleechanorg/jleechanclaw PR #786 babysit cron 59e6e2f5dda0 tick #1, C0AKYEY48GM/1784256036.015239): Failure 5g + SLACK_USER_TOKEN in ~/.profile + Python-subprocess sourcing divergence verified together. The v2.5.7 babysit cron recipe from drive-pr-to-green ran successfully end-to-end on tick #1 — except for the Slack post, which hit 5g on the originating thread (mcp__slack__conversations_add_message returned not_in_channel for C0AKYEY48GM). Path B recovery worked once SLACK_USER_TOKEN was sourced from ~/.profile via explicit source ~/.profile 2>/dev/null inside a subprocess.run(['bash','-c',...]) call. First attempt bash -lc 'echo $SLACK_USER_TOKEN' returned length 0 because Python subprocess.run does NOT inherit the login-shell rc-file chain from the parent terminal — the parent (hermes-agent gateway) is non-interactive, so the .bash_profile → .bashrc → .profile chain does not propagate. Verified the bot-id-vs-user-id distinction on the resulting post: ts=1784335981.616639 posted as U09GH5BR3QU ($USER), NOT as B0BGY53L8N8 (hermes bot). Cron left enabled; next tick at 17:50:39 PT. Combined lesson: any babysit cron that needs to post its poll-status update to the originating Slack thread from inside hermes cron run must follow the xoxp extraction recipe above (source ~/.profile inside the Python subprocess.run call). The pattern is now: GraphQL gh pr view → REST gh api (v2.5.5 fallback) → MCP conversations_add_message → xoxp curl chat.postMessage (this skill's Path B with xoxp). Future babysit cron prompts that include a "post poll status to thread" step should bake this 4-tier fallback into the prompt body, not leave the LLM to re-derive it each tick.
-
2026-07-16 (cron backup ? placeholders diagnostic, C0AJQ5M0A0Y/1784215507.433759): Failure 5g (bot not invited to channel in home workspace) + token-name pitfall. mcp__slack__conversations_add_message returned not_in_channel even though channel IS in home workspace — same error code as 5f but different root cause (bot not invited to channel vs token scoped to different workspace). Initial token extraction returned length 0 (the var does not exist) — second revealed the real name is (line 290, 80 chars, prefix ). Curl Path B with extracted token succeeded at , posted as via "MCP Agent Mail" bot identity, threaded correctly under . Confirms that (the var named in this skill prior section + SOUL.md ) is documentation drift — actual variable is . Future agents: always grep for the real var name before extracting; never trust the docs alone.