name: slack-user-cli
description:
"Read and write Slack channels, DMs, threads, and search from the terminal
using slack_user_cli. Use when the user asks to interact with Slack
workspaces, read messages, send messages, or search Slack. Every command
supports --json; every read defaults to raw IDs (users, channels) and
accepts --names to resolve to display names. For programmatic consumers
(scripts, smithers workflows, pipelines) prefer --json to get structured
output rather than parsing rendered text."
allowed-tools:
- Bash
- Read
Slack User CLI
Terminal access to Slack using browser session credentials (xoxc- token + d
cookie). Located at ~/.claude/skills/slack-user-cli/slack_user_cli.py.
Running
All commands use uv run:
uv run ~/.claude/skills/slack-user-cli/slack_user_cli.py <command> [options]
Authentication
Must be logged in before using any command. Credentials are stored in
~/.config/slack-user-cli/config.json.
# Auto-extract from Slack desktop app (close Slack first)
slack_user_cli login --auto
# Import all workspaces from browser — copies to clipboard, reads via pbpaste
slack_user_cli login --browser
# Add a single workspace manually
slack_user_cli login --manual
Global Options
| Option | Description |
|---|---|
-w <name>, --workspace <name> |
Use a specific workspace instead of default |
--debug |
Enable debug logging |
Output Conventions
- IDs by default. Every read command emits raw Slack IDs (
U…users,C…/D…/G…channels) so output is stable for scripts. Do not add--namesunless the user explicitly asks for human-readable names — raw IDs are the right answer for chaining further commands. --namesis opt-in. Only when the user specifically wants display names (e.g. "show me who said what", "summarize this thread"), pass--namesto resolve user/channel IDs and rewrite<@UXXX>mentions.- CRITICAL — never guess a name. A name attributed to a message must come
from
--namesresolution (or an explicitusers/searchlookup), never from inference. Do not guess an author from the message content, from a username stem, from a DM/MPIM conversation title, or from surrounding context — that is silent misattribution and a correctness failure. If a name will not resolve, keep the rawU…ID and say so; do not approximate or invent one. --jsoneverywhere. Every command supports--jsonfor structured output. Use it whenever a programmatic consumer would otherwise parse rendered text.--jsonand--namesare independent.
Commands Reference
Workspace Management
# List all saved workspaces
slack_user_cli workspaces
# Set default workspace
slack_user_cli default "Workspace Name"
# Force-refresh the channel and user cache
slack_user_cli refresh
Reading
# List joined channels (output: channel IDs)
slack_user_cli channels
slack_user_cli channels --all
slack_user_cli channels --type "public_channel,private_channel,mpim,im"
slack_user_cli channels --json # {channels: [{id, name, type, num_members, topic, is_member}, ...]}
# Read recent messages from a channel (by name or ID; output: user IDs)
slack_user_cli read <channel_name_or_id> --limit 20
# Emit structured JSON instead of human-readable text (for programmatic
# consumers — smithers workflows, scripts, pipelines). Shape:
# {"channel": "...", "messages": [{"ts", "raw_ts", "user", "text", "thread_ts"?, "threadCount"?, "files"?, "replies"?}, ...]}
# Every message carries `raw_ts` (full microsecond ts) — `ts` is minute-precision
# for humans, but `raw_ts` is what you pass to the `permalink`/`click`/`thread`
# commands. Use --json whenever you'd otherwise parse the pretty output back into
# fields — the parse step is the #1 source of bugs and timeouts.
# Messages with attachments carry a `files` array: each entry has
# {id, name, filetype, mimetype, size, url_private, url_private_download,
# permalink}. In text output, attachments show as a 📎 line under the message.
# To actually read a file's contents, fetch it with the `download` command
# (the message text alone never includes attachment contents).
# A message that QUOTES/SHARES another message carries the original under a
# `shared` array: [{url, author, channel, ts, text, files:[...]}]. Its `files`
# are the quoted message's attachments — so a forwarded message never hides its
# attachments (text output shows them under a "↪ quoted <author>" line). Plain
# pasted message permalinks appear in a `links` array ([{url, channel, ts}]).
slack_user_cli read <channel_name_or_id> --limit 20 --json
# Add --expand-thread to inline every thread's replies under `replies: [...]`
# on the parent. Only meaningful with --json. Most decisions live in replies
# rather than parent posts, so expansion is almost always what you want for
# analysis; skip it only when you need cheap channel-level metadata.
slack_user_cli read <channel_name_or_id> --limit 20 --json --expand-thread
# Time-bounded fetch: only messages at/after an ISO date or datetime (UTC if
# no tz). Sets the history `oldest` bound; pair with a larger --limit to pull a
# whole window. To catch threads whose parent predates the window but that got
# fresh replies inside it, set --since a couple of days earlier, --expand-thread,
# and filter on each message's raw_ts >= your real cutoff.
slack_user_cli read <channel_name_or_id> --since 2026-05-29 --limit 200 --json --expand-thread
slack_user_cli read <channel_name_or_id> --since 2026-05-29T10:07:00 --limit 200 --json
# Read thread replies (use --dm when CHANNEL is a user name, not a channel)
slack_user_cli thread <channel_name_or_id> <message_ts>
slack_user_cli thread <channel_name_or_id> <message_ts> --json
slack_user_cli thread --dm <user_name_or_id> <message_ts>
# Read a thread directly from a Slack permalink URL
slack_user_cli url "https://workspace.slack.com/archives/C.../p..."
slack_user_cli url "https://workspace.slack.com/archives/C.../p..." --json
# Canonical permalink(s) via chat.getPermalink — pass the channel + one or more
# raw_ts. Unlike a hand-built /p<ts> URL, these are thread-aware (carry
# thread_ts/cid) so they navigate correctly to threaded replies, not just roots.
# ALWAYS use this to cite a message rather than constructing /p<ts> by hand.
slack_user_cli permalink <channel_name_or_id> <raw_ts> [<raw_ts> ...]
slack_user_cli permalink <channel_name_or_id> <raw_ts> --json # {channel, permalinks: {ts: url}}
# List workspace members (output: user IDs)
slack_user_cli users
slack_user_cli users --json # {users: [{id, name, display_name, real_name, status_emoji, status_text}, ...]}
# List channels a user is a member of (by username or user ID; output: channel IDs)
slack_user_cli user-channels <user_name_or_id>
slack_user_cli user-channels <user_name_or_id> --json
slack_user_cli user-channels <user_name_or_id> --type "public_channel,private_channel,mpim"
Writing
CRITICAL: When drafting messages for the user, read and follow the tone guide
in ~/.claude/skills/slack-user-cli/TONE.md. Match the user's natural voice:
casual, humble, concrete, no marketing-speak. If file does not exist, create it.
CRITICAL: Before sending any message to a main channel (i.e. a send
command), you MUST use AskUserQuestion to get explicit approval. Posting to
a main channel is visible to everyone and cannot be undone. Always confirm with
the user first.
Permalink timestamp parsing: When replying to a thread from a Slack
permalink URL (e.g. https://...slack.com/archives/C.../p1012345678907689),
extract the thread_ts by inserting a dot before the last 6 digits of the p
value: p1012345678907689 → 1012345678.907689. Double-check this conversion
before sending.
# Send a message to a channel (use --thread to reply in a thread)
slack_user_cli send <channel_name_or_id> "message text"
slack_user_cli send <channel_name_or_id> "reply text" --thread <message_ts>
# DM a user (by display name or user ID; use --thread for thread replies)
slack_user_cli dm <user_name_or_id> "message text"
slack_user_cli dm <user_name_or_id> "reply text" --thread <message_ts>
slack_user_cli dm <user_name_or_id> "message" --json
# Read DM history (omit message; output: user IDs). Same --json shape as `read`.
slack_user_cli dm <user_name_or_id>
slack_user_cli dm <user_name_or_id> --json
Clicking Block-Kit Buttons
read --json surfaces interactive elements alongside the text. When a bot
message has a block-kit actions block, the JSON entry gains an actions
field. The raw_ts field (the unformatted ts you pass to click) is present
on every message, not just block-kit ones:
{
"ts": "2026-05-04 09:30",
"raw_ts": "1777887012.881539",
"user": "bot",
"text": "Which one is legit?",
"bot_id": "B095...",
"app_id": "A01G...",
"actions": [
{"type": "button", "block_id": "...", "action_id": "...", "text": "The first one!", "value": "..."},
{"type": "button", "block_id": "...", "action_id": "...", "text": "The second one!", "value": "..."}
]
}
click dispatches a button or radio choice via Slack's internal
blocks.actions endpoint (the same call the web client makes when you click
a button). It's how you complete training bots like Riot/Albert from the CLI.
Same approval rules as Writing above — clicking a button on a message in a shared channel produces side effects visible to everyone. DMs to bots are fine without confirmation.
# Click by visible button label (most ergonomic)
slack_user_cli click <channel> <raw_ts> --option "The first one!"
# Pick a radio option by its label
slack_user_cli click <channel> <raw_ts> --option "A few seconds"
# Pick by 1-based index across all action elements on the message
slack_user_cli click <channel> <raw_ts> --index 2
# Pick by exact action_id (precise; needed if multiple buttons share text)
slack_user_cli click <channel> <raw_ts> --action-id "WyIw..."
# Pick by exact value (for buttons or radio options)
slack_user_cli click <channel> <raw_ts> --value "WyJiY..."
# JSON output with the raw blocks.actions response
slack_user_cli click <channel> <raw_ts> --option "OK" --json
Supported element types: button, radio_buttons, static_select. Other
types (datepickers, multi-selects, modals) raise an error explaining how to
extend _build_action_payload.
File Uploads
Same approval rules as Writing above — uploading to a main channel (without
--thread) requires AskUserQuestion confirmation first.
# Upload a file to a channel
slack_user_cli upload <channel_name_or_id> /path/to/file.png
# Upload with a message and title
slack_user_cli upload <channel_name_or_id> /path/to/file.png --message "Here's the report" --title "Q1 Report"
# Upload in a thread
slack_user_cli upload <channel_name_or_id> /path/to/file.png --thread <message_ts>
# Upload a file via DM
slack_user_cli dm-upload <user_name_or_id> /path/to/file.png
# DM upload with message and in a thread
slack_user_cli dm-upload <user_name_or_id> /path/to/file.png --message "See attached" --thread <message_ts>
Downloading File Attachments
Messages often carry uploaded files (PDFs, images, docs). The message text
never contains the file contents, so to read an attachment you must download it.
read/url/thread --json expose a files array (and text output shows a 📎
line) so you can spot attachments; download fetches the bytes locally.
# Download every attachment on a message — pass a permalink…
slack_user_cli download "https://workspace.slack.com/archives/C.../p..." -o ./out
# …or a channel + message raw_ts
slack_user_cli download <channel_name_or_id> <raw_ts> -o ./out
# …or a single file by its ID (starts with F)
slack_user_cli download F0B883G50V6 -o ./out
# Just list the attachments (name, type, size, id) without downloading
slack_user_cli download <channel_name_or_id> <raw_ts> --list
slack_user_cli download <channel_name_or_id> <raw_ts> --list --json
# JSON: {"files": [...], "downloaded": ["./out/quote.pdf", ...]}
slack_user_cli download <channel_name_or_id> <raw_ts> -o ./out --json
Default output directory is ./slack-downloads. Downloaded files can then be
read with normal file tools (e.g. a PDF reader). Reading another workspace's
files is gated by your own access — download uses the same session auth.
download also fetches attachments from a quoted/shared message: if you
point it at a message that forwards another one (the original shows up under
shared in JSON / a "↪ quoted" line in text), it pulls the original's files
too — so you don't have to chase the source message manually.
Important: DM User Name Resolution
When using dm, the USER argument must match the Slack username (e.g.
first.last), not the display name with spaces (e.g. First Last). Use
search "from:<username>" to discover the correct username format.
Search
# Search messages (output: raw user/channel IDs in user/channel fields)
slack_user_cli search "query" --count 20 --page 1
slack_user_cli search "query" --json
Canvases
# Read a canvas by URL (outputs plain text by default)
slack_user_cli canvas "https://workspace.slack.com/docs/TEAM_ID/FILE_ID"
# Read a canvas by file ID
slack_user_cli canvas DEADBEEFUUV
# Get raw HTML output
slack_user_cli canvas "https://workspace.slack.com/docs/TEAM_ID/FILE_ID" --html
# JSON: {file_id, title, text} (or html with --html)
slack_user_cli canvas DEADBEEFUUV --json
# Append markdown to a canvas (default: insert_at_end)
slack_user_cli canvas-edit DEADBEEFUUV "## New Section\nSome text"
# Replace entire canvas content
slack_user_cli canvas-edit DEADBEEFUUV "## Fresh Start" --operation replace
# Prepend content
slack_user_cli canvas-edit DEADBEEFUUV "## Header" --operation insert_at_start
# Pipe content from a file or heredoc
cat summary.md | slack_user_cli canvas-edit DEADBEEFUUV --operation replace
Cross-workspace Usage
# Read from a specific workspace
slack_user_cli -w "Other Workspace" channels
slack_user_cli -w "Other Workspace" read general --limit 5
Cache
Channel and user data is cached to disk for fast resolution:
- Location:
~/.config/slack-user-cli/cache/<workspace>/ - Files:
channels.json(name→id map),users.json(id→display, name→id, display→id maps) - TTL: 1 hour — cache auto-expires and is rebuilt on next use
- Refresh: run
slack_user_cli refreshto force-rebuild both caches - Behavior:
resolve_user()passively reads disk cache, falling back to a singleusers_infoAPI call — never triggers a fullusers_listbuild.resolve_channel()and_resolve_user_by_name()will auto-build the cache on first use if it doesn't exist.
Run refresh after joining new channels or when user lookups return IDs instead
of names.
Key Details
- Auth model:
xoxc-token (per-workspace) +dcookie (shared across workspaces), extracted from browser or Slack desktop app - Config location:
~/.config/slack-user-cli/config.json - Cache location:
~/.config/slack-user-cli/cache/<workspace>/ - Multi-workspace: stores all workspaces; use
-wto switch ordefaultto set the default - Channel resolution: accepts channel names (without
#) or IDs (starting with C/D/G); uses disk cache for fast lookup - User resolution: accepts display names, usernames, or user IDs (starting with U); uses disk cache + single API fallback
- Pagination: handled automatically for channels, users, messages, and threads
- Search: uses page-based pagination (
--page,--count) - Output: formatted with Rich tables and colored text
- Timestamps: message timestamps (
ts) are displayed asYYYY-MM-DD HH:MMUTC
Channel Summary Workflow
When the user asks to summarize a Slack channel (e.g., "summarize #general since Feb 27"), follow this procedure:
1. Resolve channel ID and start date
- If the user gives a channel name, resolve the ID:
slack_user_cli channels --all 2>&1 | grep -i "<channel_name>" - If the channel name isn't found (e.g., cross-workspace channel), ask the user
for a sample message link from that channel to extract the channel ID (
C...from the URL). - If no start date is provided, ask the user for one before proceeding.
2. Read channel messages from the start date
# --since bounds the fetch server-side; --expand-thread inlines replies, and
# --json gives you each message's raw_ts for citation.
slack_user_cli read <channel_id> --since <start_date> --limit 200 --json --expand-thread
- Each message carries
raw_ts; keep it for permalinks and for filtering on the exact cutoff. - Threads arrive inline under
replies(with--expand-thread) — no need to re-fetch each one. To catch threads whose parent predates the window but got fresh replies inside it, set--sincea day or two earlier and filter replies onraw_ts >= cutoff.
3. Cite each message with a real permalink
Use the permalink command with the message's raw_ts — do not hand-build
/p<ts> URLs. Hand-built URLs only resolve for root messages; threaded
replies need the thread_ts/cid query params that chat.getPermalink
(behind permalink) returns:
slack_user_cli permalink <channel_id> <raw_ts> [<raw_ts> ...] --json
If you only have a permalink (not a raw_ts) and want the thread, the url
command reads it directly:
slack_user_cli url "https://<workspace>.slack.com/archives/<channel_id>/p<ts>"
If url fails with thread_not_found, fall back to search with targeted
keywords to retrieve thread replies:
slack_user_cli search "<topic keywords> in:<channel_name>" --count 15
4. Summarize each thread
For each thread, produce a structured summary:
- Topic: One-line description
- Decisions taken: What was agreed upon
- Pending / Open questions: Unresolved items
- Next steps / Action items: Who does what
- Codebase relevance: How it relates to the current project (backend, frontend, SDK, infrastructure, etc.) — only include if applicable
5. Cross-cutting summary
After all threads, add a cross-cutting table of technical issues that span multiple threads, with columns: Issue | Impact | Status.
Tips
- The
readcommand returns messages newest-first by default. Increase--limitif the start date is far back. - Search is more reliable than
readfor finding specific messages and their thread replies. Usein:<channel_name>to scope searches. - Watch for Slack API rate limits. If you hit
ratelimited, wait a few seconds and retry. - For large channels, process threads in batches of 3-5 to avoid rate limits.
Troubleshooting
- "Not logged in": run
slack_user_cli login --browseror--manual - "Workspace not found": check available names with
slack_user_cli workspaces - Token expired: tokens expire on Slack logout; re-run
login - Too many channels:
channelsshows only joined by default; this is correct - macOS Keychain prompt: expected when using
--auto(cookie decryption) - User shows as ID instead of name: run
slack_user_cli refreshto rebuild the user cache - Channel not found after joining: run
slack_user_cli refreshto rebuild the channel cache