agentleFS
rkhozinovrkhozinov/claude-skills/skills/slack-cli/SKILL.md

slack-cli

Skill0 starsChanged 4 months agoMITView on GitHub

Reference skill for Slack operations via a local Python CLI (`bin/slack`) that wraps the Slack Web API with a user OAuth token (xoxp). Replaces the Slack MCP plugin to avoid 3k+ tokens of tool schemas. Covers mentions, DMs, threads, send/reply, search, file download, and a raw API escape hatch. Use when the user wants to send/search/read Slack from the terminal or agent without loading the MCP plugin.

  • Reads credentials

7 other projects use a version of this.

Adopted by

Most recently taken up by

The file

Show its rules and text

We split this skill into the rules it gives an agent

15 rules. 0 are common, 4 are distinctive, and 11 are very specific to this project.

  • 0

    Common

    Most of the 7 edited versions we read keep these.

  • 4

    Distinctive

    This author's own choices. Worth a look.

  • 11

    Very specific

    Tied to their project's names, files or tools. Probably skip.

  • Common
  • Distinctive
  • Very specific
  1. Distinctive:

    If the keychain entry is missing or stale, recreate it:

    line 29
  2. Very specific:

    If a method returns missing_scope, add the scope under User Token Scopes at https://api.slack.com/apps/<APP_ID>/oauth, then reinstall …

    line 39
  3. Distinctive:

    When detail is genuinely needed: put the verdict in the message, the detail in a thread reply or a file, and say what you left out so they …

    line 144
  4. Distinctive:

    Sentence case: Start each sentence with a capital letter.

    line 169
  5. Very specific:

    No contractions: Write cannot, not can't. Never lemme, gonna, pls, kinda, nah, yep, kk.

    line 170
  6. Very specific:

    Keep the subject, the verb, and the articles: Write I will check the pull request.

    line 171
  7. Very specific:

    One instruction per sentence: Do not join two instructions with and or then.

    line 172
  8. Very specific:

    Active voice. Name the actor: Write The migration dropped the column.

    line 174
  9. Very specific:

    Simple tenses only: Write I fixed it. Do not write I have fixed it.

    line 175
  10. Very specific:

    Condition before command, with a comma: Write If the test fails, read the log.

    line 179
  11. Very specific:

    State uncertainty as its own plain sentence: Write The cause is not confirmed.

    line 181
  12. Very specific:

    Warnings put the command first and the risk second: Write Do not deploy this yet.

    line 182
  13. Very specific:

    Emoji: Keep the :smile: softener at roughly the measured rate.

    line 194
  14. Very specific:

    UID is readonly in bash: If you ever drop back to a while read -r CHAN UID loop, rename to PEER_ID — bash refuses the assignment with …

    line 275
  15. Distinctive:

    If a workflow needs admin-only Slack tools that this CLI doesn't cover, temporarily flip the plugin back on:

    line 299

“Common” is measured: kept by at least 6 in 10 of the 7 edited versions of this file we could read. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
SKILL.md

Slack CLI Reference

Wraps the Slack Web API behind a single Python CLI: slack, on PATH via ~/.local/bin/slack → this skill's bin/slack.

ln -sfn "$CLAUDE_PLUGIN_ROOT/skills/slack-cli/bin/slack" ~/.local/bin/slack

CLAUDE_PLUGIN_ROOT is version-pinned, so re-run that after every plugin update (or point the link at a working checkout of this repo, which does not move). A stale link is silent: slack still runs, just an older build. Check with readlink ~/.local/bin/slack.

The CLI is a PEP 723 inline-script Python file run via uv run --script (shebang). First call installs requests to a uv-managed venv (~9ms thereafter).

Auth is a user OAuth token (xoxp) issued by the user's "Claude MCP" Slack app (App ID <APP_ID>, <org> workspace <WORKSPACE_ID>). Acts as the user — full search, DM access, posts as them.

Token

Stored in macOS Keychain under service slack-xoxp. The CLI fetches at runtime; no shell handling needed. Override for one-off calls with SLACK_XOXP=xoxp-… slack ….

If the keychain entry is missing or stale, recreate it:

security add-generic-password -a "$USER" -s slack-xoxp -w 'xoxp-…' -U

Granted user scopes

search:read, channels:history, groups:history, im:history, mpim:history, channels:read, groups:read, chat:write, reactions:write, users:read, users:read.email, files:read.

If a method returns missing_scope, add the scope under User Token Scopes at https://api.slack.com/apps/<APP_ID>/oauth, then reinstall (token rotation is off, so the xoxp string stays the same — no keychain update needed). react (reactions:write) and channels (channels:read/groups:read) are already granted.

Usage

slack whoami                                  # auth.test (sanity check)
slack mentions [-n 20]                        # @mentions of self, newest first
slack dms      [-n 15] [--group] [--no-resolve]            # latest msg per 1:1 DM (--group adds mpim)
slack history  <channel> [-n 30] [--since 2d] [--cursor X] [--links] [--activity] [--no-resolve] [--warm]  # channel timeline, newest first
slack thread   <channel> <thread-ts> [-n 50] [--since 2d] [--cursor X] [--links] [--activity] [--no-resolve] [--warm]  # thread, oldest first
slack reply    <channel> <thread-ts> [txt] [--raw]   # post reply in thread
slack send     <channel> [txt] [--raw]               # post top-level message
#   send/reply take Markdown (auto-converted to Slack mrkdwn; --raw to skip),
#   --text-file <path|->  (body from file or stdin; preferred — see below),
#   --blocks-file <path|->  (Block Kit JSON; text = notification fallback),
#   --unfurl / --no-unfurl  (force/suppress link unfurling)
slack search   '<query>' [-n 20]              # slack-search-bar syntax
slack channels [--types …] [--sort name|popularity] [-n 100]   # list channels; warms the #name→id cache
slack users    '<query>' [-n 20]              # find users by name/handle/email (over the cache)
slack react    <channel> <ts> <emoji> [--remove]   # add/remove a reaction (needs reactions:write)
slack file     <file-id> [-o <path>]          # download a file by id (default: ./<name>; -o - for stdout)
slack upload   <channel> <path…> [-t txt] [--text-file P] [--title T] [--thread-ts TS] [--raw]
#   attach one or more local files to a single message (needs files:write).
#   Several paths land as one message, not one message each. The initial comment
#   goes through the same Markdown→mrkdwn conversion and @handle resolution as
#   `send`. --title is single-file only. A U… resolves to an existing DM only —
#   pass the D… (or message them first) if you have never DMed them.
slack raw      <method> [k=v …] [--post] [--force]   # escape hatch: any Web API method (--force: skip mrkdwn guardrail)
slack style    scrape|stats|exemplars                # build the personal voice corpus (see `slack-style` skill)
slack <cmd> --json                            # raw API JSON instead of formatted output

<channel> accepts a C…/G…/D… id, U… (for DMs), or a #name / @user — names resolve to IDs through a persistent cache (see below). <thread-ts> is the epoch.microseconds string from a permalink (p1778164397756779 → 1778164397.756779).

Name resolution & persistent cache

history/thread/send/reply/react accept #channel-name and @user-name as the channel arg; users and the output labeling resolve IDs back to names. Resolution is backed by an on-disk cache at ~/.cache/slack-cli/{users,channels}.json (override base via $XDG_CACHE_HOME), so it is near-free after the first populate and survives the per-run rate cap. Warm it once:

slack channels              # bulk-loads every channel name→id (and caches it)
slack history "#engineering" -n 20     # now resolvable by name, no extra calls

--no-resolve ignores the cache for output (raw U…/#C…); name→id input still resolves (you can't call the API with a #name). --warm does one users.list walk up front for very busy channels. --since 2d (30m/12h/2d/1w) bounds reads to a recent window. --links appends a ↗ permalink per message (built locally, zero API). --activity includes join/leave/topic system messages, which are hidden by default.

Output format:

  • mentions / dms / search — numbered table; first column [N], last column the permalink (mentions/search) or text (dms). Reference rows by index when iterating ("open thread #3"). <@U…>/<#C…> inside snippets are resolved to @Name/#channel.
  • history / thread — clean per-message block (MCP-parity, ~8× smaller than raw JSON):
    [1781030359.145779] <person> (@<handle>) · Jun 10 19:17 (16h)
    done @Ruslan the reviews are in, thanks!
    :+1: 2  📎 1 file(s): error.log  ↳ Thread: 4 replies, 2 people, latest 15h ago
    Head line carries the raw [ts] — copy it straight into slack thread/slack reply. Names, @-mentions, #-channels resolved; timestamps in your Slack-profile TZ; reactions :name: N; files by name (not block trees); thread parents summarized. history is newest-first, thread oldest-first. When more remains, a final next: --cursor <X> line is printed — pass it back via --cursor to page.
    • --no-resolve skips all name lookups (raw U…/#C…, no users.info/conversations.info calls) — use for big/fast pulls or after hitting a rate cap.
    • --warm bulk-prefetches users.list once before resolving — cheaper than N per-user lookups on a busy channel.
  • --json (every command) — raw Slack response, unchanged, suitable for | jq. dms --json rows carry channel_id (D…) + user_id (peer's U…, null for group) — so you can reply to a DM straight from the listing without a second lookup.

Markdown is auto-converted to Slack mrkdwn

send/reply take Markdown and convert it to Slack's mrkdwn before posting, because Slack does not use CommonMark — bold is one *star* (not **), italic is _underscore_, links are <url|label>, and there are no headings. Without conversion, **bold** renders with literal asterisks (the classic bug). Conversions:

You write (Markdown)Sent to Slack
**bold**, __bold__*bold*
*italic*_italic_
***bolditalic****_bolditalic_*
[label](url)<url|label> (any | in the URL is %7C-encoded so the link doesn't split; ) inside the URL is kept)
# Heading*Heading* (bold line)
- item, + item• item
~~strike~~~strike~
`code`, ```fenced```preserved as-is (not touched)

Code spans/fences and link URLs are protected, so **/_ inside them survive. Pass --raw to skip conversion and send verbatim Slack mrkdwn (use it when you're hand-writing *slack bold* / <url|label> yourself).

Posting = send/reply, never slack raw or --raw for agent-written Markdown. The converter only runs on send/reply without --raw. If you post CommonMark (**bold**, [label](url)) through the slack raw chat.postMessage escape hatch or with --raw, it ships verbatim and Slack renders the literal **/[]() — this is the exact bug to avoid. To make this hard to do by accident, slack raw chat.{postMessage,update,postEphemeral,scheduleMessage} refuses a text= containing CommonMark unless you pass --force, and send/reply --raw prints a warning. For rich messages, use --blocks-file on send/reply (text still converts) rather than dropping to raw.

The converter is not idempotent — running it over already-converted mrkdwn corrupts it (*bold* → _bold_). Convert once, on the way out. That's why raw never auto-converts.

Mentions that actually ping

send/reply resolve @handle in the body to a real Slack mention (<@Uid>) so the person gets pinged — runs even with --raw (it's resolution, not formatting). Rules:

AUTHORING RULE (agent MUST follow): when writing a mention, ALWAYS use the exact handle (@alice.king) or a quoted full name (@"Sam Eliwa"). NEVER write a bare first name (@Sam) or an unquoted @First Last. Reason: a bare first name silently matches a handle equal to that word — at <org> @<handle> is Sam <surname> (user@example.com), not whichever Sam you meant — and this workspace has ambiguous names. An unquoted @First Last only works if First Last is an exact real-name match; one token off and it degrades to the bare-first-name landmine. When unsure of the handle, run slack users <name> first, or paste <@ID> yourself.

  • Exact match only — @<handle> (handle) or @"Sam <surname>" (quoted full name). Matched case-insensitively against the user cache.
  • @here / @channel / @everyone → broadcast entities (<!here> etc.).
  • Ambiguous/unknown (e.g. @Taylor when 7 people match, or a bare first name) is left as literal text with a stderr warning — a non-ping beats a mis-ping. Use the exact handle or paste <@ID> yourself.
  • Not a mention: @ mid-token (user@example.com) and @x inside `code`/``` fences are never resolved.

Find a handle with slack users <name>. The cache is warmed by slack channels / slack users; re-run those if a freshly-renamed handle won't resolve.

Write in the user's voice, not yours

AUTHORING RULE (agent MUST follow): before any send/reply, read ~/.claude/slack-voice/voice.md and write the message in that voice. If the file is missing, say so once (run /slack-style to build it) and keep the message short anyway.

The register is ASD-STE100, not the one recorded in voice.md. Read the next section before drafting. It names exactly which parts of voice.md it supersedes and which parts still bind.

  • Default length is 1–3 lines. The people receiving these messages are busy and did not ask for a report. A wall of agent prose makes them do the work you skipped — read nomeatproxy.com once if this feels excessive.
  • When detail is genuinely needed: put the verdict in the message, the detail in a thread reply or a file, and say what you left out so they can ask ("full trace if you want it").
  • Never claim more than was verified. Short does not mean confident; if something is unchecked, one clause says so.
  • Build or refresh the profile with the slack-style skill (slack style scrape|stats|exemplars).

Register: ASD-STE100 Simplified Technical English

REGISTER RULE (agent MUST follow): write every message in Simplified Technical English. This supersedes the Register and Exemplars sections of voice.md, which record a lowercase, contraction-heavy register that is no longer the output target.

voice.md still binds on everything else. Those parts do not conflict with STE:

voice.md sectionstatus
Length (1–3 lines, ~200 char ceiling)binds. It is stricter than the STE sentence caps. Take the stricter one.
Escape hatch (verdict here, detail in a thread, say what you left out)binds
Anti-patterns (em dashes, bullet walls, *Bold lead-in.*, closing reassurance, restating the question, unprompted jargon)binds. STE agrees with every one of them.
Never point at a path on your own machinebinds
Register (lowercase starts, lemme, gonna, pls, run-on commas)superseded
Exemplars (the message bank)superseded. Copy their length and their structure. Do not copy their wording.

The rules, applied to a Slack message:

  • Sentence case. Start each sentence with a capital letter. End it with a period.
  • No contractions. Write cannot, not can't. Never lemme, gonna, pls, kinda, nah, yep, kk.
  • Keep the subject, the verb, and the articles. Write I will check the pull request. Do not write checking the PR.
  • One instruction per sentence. Do not join two instructions with and or then.
  • Maximum 20 words in an instruction, 25 in a description. The 1–3 line ceiling is stricter and still applies.
  • Active voice. Name the actor. Write The migration dropped the column. Do not write The column was dropped.
  • Simple tenses only. Write I fixed it. Do not write I have fixed it. Do not use -ing verb forms.
  • Approved modals are can, will, and must. Banned: should, would, may, might, could. For should, write must if the thing is required, and delete it if the thing is optional.
  • One word, one meaning. Pick one of check / verify / confirm. Reuse that one word in every message.
  • Noun clusters of three words maximum. Break a longer stack with a preposition.
  • Condition before command, with a comma. Write If the test fails, read the log.
  • Delete words that carry no fact: simply, just, seamlessly, robust, powerful, leverage, in order to.
  • State uncertainty as its own plain sentence. Write The cause is not confirmed. Never write it might have been caused by.
  • Warnings put the command first and the risk second. Write Do not deploy this yet. The migration is not applied.
  • American spelling.

Where STE pushes against the length ceiling. One instruction per sentence produces more sentences. Write those sentences as continuous prose. A Slack reply is a message, not a form with fields, so nothing in it is prefixed with a heading word. If the message needs headings to stay readable, it is too long. Cut it to the verdict and one next step. Move the detail to a thread reply. The ~200 character ceiling outranks the urge to enumerate.

STE is silent on these, so they are unchanged:

  • Emoji. Keep the :smile: softener at roughly the measured rate.
  • Raw links. Paste the URL. Do not write [label](url).
  • Mentions. The authoring rule above still applies without change.
  • Code blocks and pasted logs. They are not prose. Never rewrite them.

Worked rewrites, on the shapes that occur most:

Examples below are invented, not quoted from any corpus. See "Privacy" in the slack-style skill: real messages never enter this repo.

intentold registerSTE
ackkk. lemme grab thatUnderstood. I will start this now.
statusits up but still flakyThe service is deployed. It is still unstable.
askcan u paste the stack trace plsPlease paste the stack trace.
uncertaintyhm thats odd. lemme poke at itThe result is unexpected. The cause is not confirmed. I will investigate.
pushing backnah lets not, too much surface :smile:No. That change adds too much risk. :smile:
being wrongscratch that, wrong branchI was wrong. I read the wrong branch. The correct value is 3.

Sending text: prefer --text-file

For anything beyond a trivial one-liner, pass the body via --text-file <path> (or --text-file - for stdin) instead of the inline positional. Inline text goes through the shell, so backticks, $(...), quotes, and newlines get mangled or trigger command substitution — the same class of failure that mandates gh ... --body-file over --body. The file path is read verbatim, no shell interpretation. Write the body with a Write tool to a tmpfile, then point --text-file at it.

# robust: multi-line / backticks / quotes survive intact
slack send D0AGABWHTSA --text-file /tmp/msg.md
slack reply C0… 1778164397.756779 --text-file -   <<'EOF'
done — see `Program.cs:477`. shipped in #709.
EOF

# rich message via Block Kit — text is the notification fallback, blocks are the body.
# Use this instead of dropping to `slack raw chat.postMessage` (which won't convert text).
slack send "#engineering" "Deploy summary" --blocks-file /tmp/blocks.json --no-unfurl

Common patterns

# triage mentions, then dive into one
slack mentions -n 20
slack thread C0AQ7M5R36W 1778164397.756779

# read a channel by name, then page older (copy the printed next: --cursor)
slack channels                                # one-time: warm the #name→id cache
slack history "#engineering" -n 30
slack history "#engineering" -n 30 --cursor bmV4dF90czoxNzcy…

# what happened in the last day, with citeable links, skip the join noise
slack history "#engineering" --since 1d --links

# search a channel
slack search 'in:#engineering deploy after:2026-04-01' -n 20

# find a user's id by name, then DM them
slack users alice                             # → row with U… id
slack send '@"Alice King"' "ping — got a sec?"  # quoted full name (or handle @alice.king) — never bare @Alice

# discover file IDs in a thread, then download
slack thread C0… 1778… --json | jq -r '.messages[].files[]? | .id + "\t" + .name'
slack file F0B3R03TR4G                        # writes ./id_argus_a6000.pub
slack file F0B2UKBUC86 -o ~/.ssh/             # writes ~/.ssh/id_argus_a6000
slack file F0… -o -                           # stream to stdout for piping

# DM a user by email (UID is readonly in bash — use PEER_ID)
PEER_ID=$(slack raw users.lookupByEmail email=alice@example.com | jq -r '.user.id')
slack send "$PEER_ID" "ping — got a sec?"

# reply to a DM straight from the listing (channel_id is in the json)
CH=$(slack dms --json | jq -r '.[] | select(.peer=="<person>") | .channel_id')
TS=$(slack dms --json | jq -r '.[] | select(.peer=="<person>") | .ts')
slack reply "$CH" "$TS" --text-file /tmp/reply.md

# react to a message (needs reactions:write scope)
slack react "#engineering" 1714… thumbsup
slack react "#engineering" 1714… thumbsup --remove

Slack search syntax mirrors the search bar: from:@user, in:#channel, before:, after:, has:link, etc. is:mention is not a self-mention filter — it matches the literal word "mention". Use <@USER_ID> (or just slack mentions) instead.

Pitfalls

  • UID is readonly in bash. If you ever drop back to a while read -r CHAN UID loop, rename to PEER_ID — bash refuses the assignment with failed to change user ID: operation not permitted.
  • rtk hook wraps curl in Claude Code sessions and appends [Tee log: …] to stdout, breaking jq. The Python CLI doesn't shell out to curl, so it's immune. If you do call curl directly, use rtk proxy curl ….
  • search.messages is rate-limited tier 2 (~20/min). users.info/conversations.info are tier 3 (~50/min). Name resolution writes through to the persistent ~/.cache/slack-cli/ cache, so after a slack channels + a first warm pass it's near-zero lookups thereafter. Within a single run an in-memory cache also dedups every id. If you do hit the cap on a cold/busy channel: --no-resolve (raw ids, zero lookups), --warm (one users.list walk instead of N per-user calls), or re-run after a minute. The cache never expires automatically — re-run slack channels if a freshly-renamed channel won't resolve.
  • Timestamps render in the token owner's Slack-profile timezone (users.info tz, fetched once per run and cached; local TZ fallback). Format is Jun 11 14:32 (2h) — absolute local time plus relative age.
  • as_user is deprecated; xoxp posts as the token owner automatically.
  • DMs: post to user ID directly (slack send U… …); no need to open IM first.
  • File URLs need bearer auth, not cookies. https://files.slack.com/files-pri/… returns HTTP 200 with a Slack login page (HTML) if you fetch it without Authorization: Bearer $xoxp. The CLI handles this — bare curl does not. Easy to mistake for a successful download.
  • Sensitive content in Slack is already exposed. SSH private keys, API tokens, .env files, credentials posted as Slack attachments live in channel history, search index, mobile caches, and any workspace export. Downloading them later doesn't undo that — treat as compromised and rotate.

Escape hatch

For Web API methods the CLI doesn't wrap natively (canvases, admin scopes, etc.), use:

slack raw <method> key=val key=val            # GET-style query params
slack raw <method> key=val --post             # POST as JSON body

This is a thin shim over requests.get/post — same auth, same .ok enforcement, just no formatting. Pipe through jq.

Do not use raw to post messages. raw chat.{postMessage,update,postEphemeral,scheduleMessage} does not convert Markdown, so it ships literal **/[](). As a guard it refuses a text= containing CommonMark unless you add --force. Use slack send/slack reply (which convert, and take --blocks-file for Block Kit) instead.

Re-enabling the MCP plugin

If a workflow needs admin-only Slack tools that this CLI doesn't cover, temporarily flip the plugin back on:

# enable
jq '.enabledPlugins."slack@claude-plugins-official" = true' ~/.claude/settings.json | sponge ~/.claude/settings.json
# or use /plugin

Source

  • CLI: this skill's bin/slack — single Python file, ~1300 lines, argparse subcommands. Reads share one format_message formatter; name resolution is backed by in-memory + on-disk (~/.cache/slack-cli/) user/channel caches with #name/@user → id lookup.
  • Token retrieval: security find-generic-password -a "$USER" -s slack-xoxp -w (or SLACK_XOXP env override).
  • Adding a subcommand: edit the script, add an argparse subparser + cmd_* function. No packaging, no install step.

Download now (ZIP)

Does it work?

Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.

No reviews yet

People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.

  • It worked
  • Partly
  • It didn't
What did you use it for? e.g. Reviewing pull requests in a Rails app

Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.

Your trail

Files you open will collect here, so you can find them again.

Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.

Now do this for your own.

Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.