SlackCLI is built to be driven by scripts, cron jobs, and AI agents as much as by hand.
--json
Section titled “--json”Every read command supports --json: conversations list, conversations read,
conversations get, conversations unread, conversations members list,
search messages, search channels,
search people, saved list, canvas list, canvas read, canvas sections, team info,
usergroups list, usergroups read, emoji list, emoji get, files info,
files read, users info, users list, messages list-drafts,
messages list-scheduled, auth whoami, auth list.
The writing commands support it too — messages send, messages edit, messages delete,
messages react, messages unreact, messages draft, messages send-draft, messages delete-draft, messages schedule,
messages delete-scheduled, emoji add, emoji remove, canvas create, canvas edit, the saved write verbs
(add, update, complete, reopen, remove), the usergroups write verbs (create, update, add,
remove, enable, disable), the conversations membership write/self
verbs (members add, members remove, join, leave), and
conversations mark-read — where it returns the
identity of what was just written instead of the human success line.
JSON goes to stdout. Progress spinners, warnings, error messages, and the update-available notice go to stderr, so a pipe normally carries only data:
slackcli conversations read C1234567890 --json | jq '.messages[].text'Shapes
Section titled “Shapes”# Conversations, with a resolved users array so DM ids are not opaqueslackcli conversations list --json | jq '.conversations[] | {id, name}'slackcli conversations list --json | jq '.next_cursor'
# Messages, with a resolved users array so IDs are not opaqueslackcli conversations read C123 --json | jq '.messages[] | {ts, user, text}'slackcli conversations read C123 --json | jq '.users[] | {id, real_name}'
# Just the thread replies to one messageslackcli conversations read --permalink="$LINK" --json | jq -r '.messages[].text'
# Search hits with their permalinksslackcli search messages "deploy failed" --json | jq -r '.matches[] | "\(.channel.name)\t\(.permalink)"'
# Unread channels that have mentionsslackcli conversations unread --json | jq '.unread_channels[] | select(.mention_count > 0)'
# Unread replies or mentions in followed threads (browser auth; the key is absent with an app token)slackcli conversations unread --json | jq '.threads // empty | select(.has_unreads or .mention_count > 0)'
# Every unread message and unread thread reply in one call (browser auth)slackcli conversations unread --messages --json \ | jq -r '.unread_channels[] | select(.messages) | .name as $n | .messages[] | "\($n)\t\(.user)\t\(.text)"'
# A canvas as Markdownslackcli canvas read F123 --json | jq -r '.markdown' > canvas.md--json output for messages includes ts, thread_ts, user, text, type,
reply_count, reactions, bot_id, blocks, attachments, and file metadata
when a message has attachments.
Keeping output small: --fields and --limit
Section titled “Keeping output small: --fields and --limit”Read commands print full Slack objects: block structures, edit metadata, team
IDs, flags. For an AI agent every byte is tokens and context, so ask only for
what you use. With --json, --fields keeps just the named fields:
slackcli conversations read C0123456789 --limit 50 --json --fields ts,user,textslackcli users info U0123456789 --json --fields id,real_name,profile.email- Comma-separated field names; a dot path selects a nested value and keeps its
nesting (
profile.emailgives{ "profile": { "email": "…" } }). Through an array, a dot path selects from each element (reactions.name). - A name is matched exactly and may contain spaces, as the labels printed by
--resolve-fieldsdo:--fields "id,fields.Start Date". A key that itself contains a comma or a dot cannot be named; ask for its parent (fields). - It applies to each item of the command’s main list, or to the record itself
for a command that prints one record. Every other top-level key — counts,
next_cursor,next_oldest,has_more, the resolvedusersarray — is kept as it is, so paging still works. - Fields are printed in the order you list them. A field an item does not have
is left out for that item, without an error; items are never dropped, so
check the spelling if you get back
{}. - It needs
--json.--fieldswithout--json, an empty value, or a malformed one (ts,,user,profile.) exits 1 withinvalid_inputbefore any Slack call. Without--fields, output is exactly what it was. - Pair it with
--limit: fewer items shrink output as much as fewer fields.
Measured on a real workspace (bytes of stdout):
| Command | Full --json |
With --fields |
Saved |
|---|---|---|---|
conversations read <channel> --limit 50, --fields ts,user,text |
95,601 | 16,346 | 83% |
conversations list --limit 100, --fields id,name,is_member |
8,625 | 3,204 | 63% |
search messages <query> --limit 20, --fields ts,user,text,channel.name,permalink |
110,198 | 10,526 | 90% |
users list --limit 100, --fields id,name,email |
2,108 | 1,092 | 48% |
Useful field sets, and what each command projects:
| Command | Projects | Useful --fields |
|---|---|---|
conversations list |
each of conversations |
id,name,is_member |
conversations read |
each of messages |
ts,user,text (add thread_ts,reply_count for threads) |
conversations get |
message |
ts,user,text |
conversations unread |
each of unread_channels |
id,name,mention_count (with --messages: id,name,messages.ts,messages.user,messages.text) |
search messages |
each of matches |
ts,user,text,channel.name,permalink |
search channels |
each of channels |
id,name |
search people |
each of people |
id,name,real_name,profile.email |
users list |
each of users |
id,name,email |
users info |
the user record | id,name,real_name,profile.email,tz |
usergroups list |
each of usergroups |
id,handle,name |
usergroups read |
the user group record | id,handle,member_ids |
saved list |
each of items |
type,channel_id,message.ts,message.text |
canvas list |
each of canvases |
id,title,permalink |
canvas read |
the canvas record | id,title,markdown |
canvas sections |
each of sections |
id |
files info |
the file record | id,name,mimetype,size,permalink |
files read |
the file record | id,name,content |
emoji list |
each of emoji |
name,url |
emoji get |
the emoji record | name,url |
team info |
the workspace record | id,name,domain |
messages list-drafts |
each of drafts |
draft_id,channel_id,text |
messages list-scheduled |
each of scheduled_messages |
scheduled_message_id,channel_id,post_at |
Each command’s --help lists its full --json shape. conversations members list returns plain IDs, so it has no --fields.
--dry-run
Section titled “--dry-run”Every command that changes something in Slack takes --dry-run:
messages send, edit, delete, react, unreact, draft, send-draft, delete-draft,
schedule, delete-scheduled;
conversations members add, members remove, join, leave, mark-read;
emoji add, remove;
saved add, update, complete, reopen, remove;
canvas create, edit; and
usergroups create, update, add, remove, enable, disable.
A dry run does everything except the write: it picks the workspace, resolves
the target, reads --message-file and parses --blocks, runs every check the
real command runs, and prints what would be done. It never prompts and needs no
--yes, so it is safe to run unattended, and it exits 0 on a valid preview.
$ slackcli messages send --recipient-id C0123456789 --message "Deploy done" --dry-runDry run: nothing was sent. Workspace: Acme Corp (acme) Action: send message Target: C0123456789 (#deploys) Text: Deploy doneWith --json the preview is one object on stdout:
{ "dry_run": true, "action": "send message", "workspace": { "name": "Acme Corp", "id": "T0123456789", "profile": "acme" }, "target": { "kind": "channel", "id": "C0123456789", "name": "#deploys" }, "payload": { "text": "Deploy done" }}actionnames the write:send message,edit message,add reaction,remove reaction,create draft,send draft,delete draft,schedule message,delete scheduled message,add channel members,remove channel members,join channel,leave channel,mark conversation read,add emoji,add emoji alias,remove emoji,add saved item,update saved item,complete saved item,reopen saved item,remove saved item,create user group,update user group,add user group members,remove user group members,enable user group,disable user group.target.kindischannel,user(a DM, which a dry run does not open),message(withts),draft,scheduled_message,emojiorusergroup.thread_tsis set for a thread reply.name(#channel,@user, the group’s name) is looked up for display and left out when the lookup fails.payloadis exactly what would be sent: the messagetextandblocks;post_at(Unix seconds) for a scheduled message, and for cancelling one thechannel_idandpost_atof the message found;file,file_size, thefileslist,total_sizeandcommentfor an upload (file/file_sizedescribe the first file,files/total_sizecover the whole set — one or many); theemoji;fileandbytes, oralias_for, for an added custom emoji; thetodo_state,date_due(Unix seconds,0for none) and, to complete or reopen,markof a saved item; theaddorremoveuser IDs for a channel; for a user group, the fields to set, or{ added, removed, next, noop }wherenextis the full member list the write would send.teamappears when--teamis passed.
A dry run may make read calls (resolving a user group, reading its members,
loading a draft, finding a scheduled message, naming the target) but no write call. A failure is the real
command’s failure: invalid input, a missing file, bad Block Kit JSON, an
unknown group, a draft or saved write command on an app token or a scheduled-message command
on a browser session exits 1 with the same error. Slack checks permissions only on the real write, so a dry run can
pass where the write is then refused (missing_scope, not_in_channel, …).
Exit codes
Section titled “Exit codes”0 on success, 1 on failure. Without --json, a failure prints a message to
stderr; check the exit code rather than parsing that text.
if ! slackcli messages send --recipient-id="$CHANNEL" --message="$TEXT"; then echo "post failed" >&2 exit 1fiErrors under --json
Section titled “Errors under --json”With --json, a failing command writes one single-line JSON object as the
last line of stderr, writes nothing to stdout, and exits 1. The spinner’s
failure line is left out, so that last line parses on its own:
{"error":{"code":"auth_failed","message":"Authentication failed for profile \"acme\" (Acme Corp, browser auth): invalid_auth","hint":"slackcli auth login-auto --workspace-url https://acme.slack.com","retryable":false,"slack_error":"invalid_auth"}}| Field | Meaning |
|---|---|
code |
One of the codes below. React to this, never to message. |
message |
What went wrong, in words. Wording may change between releases. |
hint |
What to do next, when there is something specific: often a command to run. Optional. |
retryable |
true only for rate_limited and network: trying again unchanged can succeed. |
slack_error |
Slack’s own error code, verbatim, when Slack returned one (channel_not_found, missing_scope, …). Optional. |
code |
When | What to do |
|---|---|---|
auth_failed |
Slack refused the stored credentials (invalid_auth, token_expired, token_revoked, not_authed, account_inactive), a download returned Slack’s sign-in page, or no workspace is configured |
Log in again; hint has the command |
not_found |
The channel, user, message, file, canvas, draft, pending scheduled message, user group, profile or log run does not exist (channel_not_found, user_not_found, invalid_scheduled_message_id, canvas_not_found, …), or a channel name, @handle or email matched nothing |
Fix the ID or name |
permission_denied |
The identity may not do this (missing_scope, not_in_channel, restricted_action, enterprise_is_restricted, …) |
Join the channel, add the scope, or use another profile |
rate_limited |
Slack throttled the call (HTTP 429, ratelimited), or too many messages were scheduled for one channel in a short window (restricted_too_many) |
Wait, then retry |
invalid_input |
A flag, argument, link or file the command cannot use (--limit 0, an ambiguous --workspace, a channel name or handle that matches more than one ID, bad --blocks JSON, a missing --file, a draft send-draft cannot send, a --at or --in that is unreadable, in the past or more than 120 days ahead (also Slack’s time_in_past, time_too_far), a non-text file for files read, invalid_ts, …) |
Fix the input |
network |
Slack could not be reached, or answered with a 5xx | Retry |
confirmation_required |
A write needs --yes when stdin is not a terminal, or the prompt was declined |
Pass --yes once the write is confirmed |
unsupported_auth_type |
The command needs the other auth type: drafts need browser auth, scheduled messages and the canvas sections, create and edit commands need an app token; Slack’s not_allowed_token_type |
Use a profile of the other type |
unknown |
Anything else | Read message |
out=$(slackcli conversations read "$CHANNEL" --json 2>err.txt) || { err=$(tail -n 1 err.txt) case "$(jq -r '.error.code' <<<"$err")" in rate_limited|network) sleep 30 ;; # retry later auth_failed) slackcli auth login-auto --headless ;; # browser profiles *) jq -r '.error.message' <<<"$err" >&2; exit 1 ;; esac}Only the last line is the error object: a spinner or a warning can come before
it. An error object never carries a token, a message’s text, a file’s content or
a search query. Usage errors that Commander raises before the command starts
(an unknown option, a missing argument) are still plain text, with exit code
1. Commands without --json (auth login, files download, logs clear, …)
are unchanged. Two commands report a partial result on stdout with exit code
1 instead of an error object, because something did happen:
conversations members remove (failed lists the users that were not removed)
and messages send-draft (cleanup_error when the message posted but the draft
could not be deleted). auth whoami --json and auth list --check --json
likewise report a refused or unreachable profile as their own result on stdout.
An empty result is not a failure: a search with no hits, or an unread list with
nothing in it, exits 0. conversations unread --json prints
{ "unread_channels": [] } then (plus threads with browser auth), never empty
stdout. Test the data, not the exit code:
count=$(slackcli search messages "$Q" --json | jq '.total')[ "$count" -gt 0 ] || echo "nothing found"Patterns
Section titled “Patterns”Post and keep the timestamp, so you can edit or react later. --json gives
you the channel, the timestamp, and the permalink in one object:
sent=$(slackcli messages send --recipient-id=C123 --message="Working…" --json)ts=$(jq -r '.ts' <<<"$sent")
slackcli messages react --channel-id=C123 --timestamp="$ts" --emoji=eyesslackcli messages edit --channel-id=C123 --timestamp="$ts" --message="Done ✅"The permalink is handy for handing the message to a human, or for feeding it
straight back into any command that takes --permalink:
link=$(jq -r '.permalink // empty' <<<"$sent")slackcli messages send --permalink="$link" --message="…and here is the log"permalink is looked up after delivery and is omitted if that lookup fails, so
use // empty (or test the key) rather than assuming it is always there.
Send a reviewed draft and capture the posted message identity:
sent=$(slackcli messages send-draft "$DRAFT_ID" --workspace=rafael --yes --json)ts=$(jq -r '.ts' <<<"$sent")send-draft removes the draft after posting. If that cleanup fails, the command
exits nonzero but still emits channel_id, ts, and cleanup_error in JSON.
Check the posted message before retrying, to avoid a duplicate. Use
messages delete-draft "$DRAFT_ID" --yes --json to discard an unneeded draft.
Send a long or multi-line body from a file, instead of fighting shell quoting — useful when the text is generated by an earlier step:
generate-release-notes > /tmp/notes.mdslackcli messages send --recipient-id=C123 --message-file=/tmp/notes.md --json--message and --message-file are mutually exclusive, and an empty or
unreadable file is rejected before anything is posted, so a failed generation
step cannot silently post an empty message.
Pipe the text in with --message-file - when it is already in a variable
or comes from another command. Nothing is quoted for the shell and nothing is
written to disk:
printf '%s' "$REPORT" | slackcli messages send --recipient-id=C123 --message-file - --json
slackcli messages send --recipient-id=C123 --message-file - --json <<'MSG'Build `main` failed: "tests" step, see $LOGMSGQuote the heredoc delimiter (<<'MSG') so the shell leaves $ and backticks
alone. One trailing newline is dropped; the rest is sent as is. Empty input, a
terminal on standard input, more than 1 MB, or a pipe that stays open for more
than 5 minutes exits 1 with invalid_input, and nothing is posted. The same
works on messages edit and messages draft.
Reply into a thread from a link — no ID juggling:
slackcli messages send --permalink="$SLACK_LINK" --message="On it"Pick an identity explicitly in unattended jobs, rather than depending on whichever workspace happens to be the default:
slackcli messages send --workspace=automation-bot --recipient-id=C123 --message="Nightly build green"Pin the identity for a whole script or job with SLACKCLI_WORKSPACE instead
of repeating the flag. It applies only to that process environment, so it does
not change the default other terminals use, and --workspace still overrides
it:
export SLACKCLI_WORKSPACE=automation-botslackcli messages send --recipient-id=C123 --message="Nightly build green"slackcli conversations read C123 --limit=5 --jsonA value that matches no stored profile fails the command with
Workspace not found: <value> (from SLACKCLI_WORKSPACE) rather than falling back
to the default. See
workspaces and profiles.
Check the identity before a write, so a job stops on an expired session or
the wrong workspace instead of failing halfway. auth whoami exits 1 unless
Slack accepted the credentials, and its --json object says which profile and
user were used:
me=$(slackcli auth whoami --json) || { echo "slack identity check failed: $(jq -r '.status' <<<"$me")" >&2 # auth_failed | unreachable exit 1}[ "$(jq -r '.workspace_id' <<<"$me")" = "T1234567" ] || { echo "wrong workspace" >&2; exit 1; }status is auth_failed when logging in again is needed (.error.fix holds the
command) and unreachable when Slack did not answer, which is worth a retry.
See authentication.
Check every stored profile at the start of a session with
auth list --check --json. It exits 1 unless every profile is ok, and each
entry’s check.status says which ones to use and which need a new login:
slackcli auth list --check --json > profiles.json || truejq -r '.workspaces[] | "\(.profile)\t\(.check.status)"' profiles.jsonSee authentication.
Paginate a search:
page=1while :; do out=$(slackcli search messages "$Q" --page="$page" --limit=100 --json) echo "$out" | jq -r '.matches[].permalink' [ "$page" -lt "$(echo "$out" | jq '.pages')" ] || break page=$((page + 1))donePoll a channel or thread safely
Section titled “Poll a channel or thread safely”Keep the next_oldest cursor between calls, and drop your own messages so a
reply you post does not wake you up again:
cursor=$(date +%s) # start from now; or a saved cursorwhile :; do out=$(slackcli conversations read C123 --thread-ts="$THREAD" \ --oldest="$cursor" --exclude-self --limit=200 --json) jq -c '.messages[]' <<<"$out" | while read -r m; do handle "$m"; done cursor=$(jq -r '.next_oldest' <<<"$out") sleep 30done- Use the returned cursor, not a message count: counting never changes on a
thread longer than
--limit. - Messages at or before
--oldestare never returned, so the thread parent is not re-emitted on every poll. next_oldeststill advances when every new message was your own.- For channel history Slack returns the newest
--limitmessages in the range. Ifhas_moreistrue, more arrived than you asked for: advancing the cursor would skip the older ones, so re-read with a larger--limit(or page back with--latest) before moving on.
Notes for unattended use
Section titled “Notes for unattended use”- Token freshness. Browser tokens die with the browser session. Refresh them
non-interactively with
slackcli auth login-auto --headless, which works once the profile has been signed in once. - Names or IDs. Channel and user arguments accept
#channel,@handleor an email address as well as IDs (details). A name costs a pagedconversations.list/users.listlookup on every run; an ID costs nothing, and--jsonoutput reports the resolved ID, so a job that runs often can resolve once and keep the ID. Quote a#in scripts. - Throttling. slackcli keeps at most 2 Slack API calls in flight and leaves
at least 200ms between them, so it does not trip Slack’s
unexpected_api_call_volumeanomaly detection. Commands that resolve many users or channels (conversations unread,saved liston a long list) make one API call per entity, so budget wall-clock time for them and set generous timeouts in a scheduled job. - Logs. Every command except
slackcli logs …appends to a log file (see Troubleshooting); nothing is written to stdout, and stderr gets log lines only with-v. SetSLACKCLI_LOG_LEVEL=offto skip the file, orSLACKCLI_LOG_DIRto move it, e.g. in a read-only container. - The update notice. SlackCLI may append a one-line “update available” notice
after a command that succeeded. It goes to stderr and never contaminates
--jsonon stdout, and it is never printed after a failure, so a--jsonerror object stays the last line of stderr. SetSLACKCLI_NO_UPDATE_NOTIFIER=1to turn it and its daily request to GitHub off; it is off automatically whenCIis set (CI=true,CI=1). See Updating. usergroupswrites need--yes.create,update,add,remove,enable, anddisablerefuse to run with a non-zero exit when stdin is not a terminal and--yesis absent, so an unattended job must pass--yesexplicitly. With--jsonthe refusal is aconfirmation_requirederror object. See User groups.conversations mark-readneeds--yes. Same rule: it refuses to run with a non-zero exit when stdin is not a terminal and--yesis absent.mark-read --jsonreturnsprevious_last_read; keep it if the run may need to be undone. See Conversations.messages delete-scheduledneeds--yes. Same rule: it refuses to cancel with a non-zero exit when stdin is not a terminal and--yesis absent. It reads the pending list before asking, so a refusal still makes that one read. See Messages.canvas edit --operation replace|deleteneeds--yes. Same rule: it refuses to change the canvas with a non-zero exit when stdin is not a terminal and--yesis absent. The inserts andrenamedo not ask. See Canvas.--dry-runnever needs--yes. It changes nothing, so it never prompts and is not refused when stdin is not a terminal. See--dry-run.files downloadneeds--yesto write outside the working directory. An--outputpath that resolves inside the current directory is unaffected. One that escapes it (../…, an absolute path, or a symlinked directory) is refused with a non-zero exit when stdin is not a terminal and--yesis absent.cdinto the target directory and use a relative path, or pass--yes. See Files.auth migrate-secretsneeds--yes. Same rule: refuses to run with a non-zero exit when stdin is not a terminal and--yesis absent. See Authentication.logs clearneeds--yes. Same rule: refuses to delete the log files with a non-zero exit when stdin is not a terminal and--yesis absent. See Troubleshooting.- Credentials.
~/.config/slackcli/workspaces.jsonholds live tokens at mode0600, unless a profile was moved to the macOS Keychain backend — give a CI job its own bot-token profile rather than copying a personal browser session around.