# Read Slack channels, threads and unreads

`slackcli conversations` covers channels, DMs, and group DMs: listing them,
reading history and threads, fetching one message, seeing what is unread, and
marking a conversation as read.

Every subcommand accepts `--workspace <id|name>`. `list`, `read`, `get` and
`unread` take [`--fields`](/docs/user-guide/scripting/#keeping-output-small---fields-and---limit) with `--json`
to print only the fields you name, e.g. `--json --fields ts,user,text`.

## `conversations list`

```bash
slackcli conversations list                          # every conversation you can see
slackcli conversations list --types=public_channel
slackcli conversations list --types=im               # DMs only
slackcli conversations list --limit=200 --exclude-archived

# Machine-readable
slackcli conversations list --json
```

| Option | Default | Purpose |
|---|---|---|
| `--types <types>` | `public_channel,private_channel,mpim,im` | Comma-separated conversation types |
| `--limit <number>` | `100` | How many to return |
| `--exclude-archived` | off | Skip archived conversations |
| `--cursor <cursor>` | — | Fetch the next page |
| `--json` | off | JSON output with a resolved `users` array |
| `--fields <list>` | all | With `--json`, only these comma-separated fields (dot paths allowed) |

DM entries are resolved to the other person's name. When more results exist, the
exact `--cursor` command for the next page is printed (human output) or set on
`next_cursor` (`--json`; `null` on the last page).

## `conversations read`

Read channel history, or one thread.

```bash
# Recent messages in a channel (oldest first)
slackcli conversations read C1234567890

# One thread
slackcli conversations read C1234567890 --thread-ts=1234567890.123456

# The thread a message link points at
slackcli conversations read --permalink="https://myteam.slack.com/archives/C1234567890/p1234567890123456"

# Top-level messages only
slackcli conversations read C1234567890 --exclude-replies

# A time window
slackcli conversations read C1234567890 --oldest=1735689600 --latest=1738368000

# Machine-readable
slackcli conversations read C1234567890 --json

# Only what is new since the last poll, minus your own messages
slackcli conversations read C1234567890 --oldest="$CURSOR" --exclude-self --json
```

| Option | Default | Purpose |
|---|---|---|
| `--thread-ts <ts>` | — | Read a specific thread instead of the channel |
| `--permalink <url>` | — | Replaces the channel argument and `--thread-ts` |
| `--exclude-replies` | off | Drop threaded replies from channel history |
| `--exclude-self` | off | Drop messages sent by the authenticated user (or, for a bot token, its bot) |
| `--limit <number>` | `100` | How many messages |
| `--oldest` / `--latest` | — | Time range bounds (`1234567890.123456`, `p1234567890123456`, or epoch seconds); only messages strictly newer than `--oldest` are shown |
| `--json` | off | JSON output, including `ts` and `thread_ts` |
| `--fields <list>` | all | With `--json`, only these comma-separated fields (dot paths allowed) |

Channel history comes back newest-first from Slack and is reversed so you read
top to bottom. Thread replies are already chronological. `--json` also includes
reactions, blocks, attachments, file metadata, and a resolved `users` array.

`--json` also carries a polling cursor:

- `next_oldest` — the newest `ts` of the page Slack returned, taken before
  `--exclude-self` / `--exclude-replies` filter it. Pass it back as `--oldest`
  on the next call. On an empty page it is the `--oldest` you passed in (or
  `null` without one), and it never moves backwards.
- `has_more` — Slack has more messages in the range than `--limit` returned.

Slack's thread API always returns the thread's parent message, whatever
`--oldest` says, so `read` drops every message with `ts <= --oldest` itself: a
thread poll does not see the parent again. `--exclude-self` uses the user ID
saved at login; a workspace saved before that, or a bot token (whose `bot_id`
is not stored), costs one `auth.test` call. See
[Poll a channel or thread safely](/docs/user-guide/scripting/#poll-a-channel-or-thread-safely).

## `conversations get`

Fetch one message by channel and timestamp.

```bash
slackcli conversations get C1234567890 1234567890.123456
slackcli conversations get --permalink="https://myteam.slack.com/archives/C1234567890/p1234567890123456"
slackcli conversations get C1234567890 p1234567890123456 --json
```

**Auth-type caveat.** With browser auth this resolves both top-level messages and
thread replies. With a standard token it can only resolve **top-level** messages
— looking up an arbitrary reply needs its parent's `thread_ts`, and no public
Slack API returns that from a reply timestamp alone. Read the thread instead:
`conversations read <channel> --thread-ts=<parent>`.

## `conversations unread`

```bash
slackcli conversations unread
slackcli conversations unread --types=dms          # channels, dms, groups
slackcli conversations unread --json
```

Conversations with mentions sort first, then alphabetically. `--json` gives
`{ unread_channels: [...] }`. When no conversation and no thread is unread the
command prints "All caught up!" on stderr and exits `0`. The text output then
writes nothing to stdout; `--json` still prints one object with an empty list,
`{ "unread_channels": [] }` (plus `threads` with browser auth), so
`jq '.unread_channels | length'` works in every case.

**Unread threads.** With browser auth the command also says whether the threads
you follow have unread replies or mentions, which Slack counts separately from
their channel:

```text
🧵 Threads: unread replies, 1 mention
```

```json
{ "unread_channels": [], "threads": { "has_unreads": true, "mention_count": 1 } }
```

`threads` is one summary for the whole workspace, not a list: it does not say
which threads. It comes from the same `client.counts` response, so it costs no
extra call, and `--types` does not filter it. Unread threads alone are enough to
print a result, so a script should test both keys before reporting "nothing
unread". A thread mention counts as unread even when `has_unreads` is `false`.
With a standard token the `threads` key is left out, because
`conversations.list` has no equivalent.

### `--messages`: the unread messages themselves

```bash
slackcli conversations unread --messages
slackcli conversations unread --messages --max-conversations 5 --limit 10 --json
```

With `--messages` the same command also returns what is unread, so catching up
is one call instead of one `conversations read` per channel:

- For each unread conversation, the messages newer than that conversation's
  read cursor (`last_read`, which Slack reports with the unread counts), oldest
  first. These are top-level messages; replies belong to the thread list below.
- The threads you follow that have unread replies: the root message and its
  unread replies.

It needs **browser auth**. With a standard token `--messages` fails with
`unsupported_auth_type` before any Slack call. Nothing is marked as read.

| Option | Default | Meaning |
| --- | --- | --- |
| `--max-conversations <n>` | `10` | How many conversations are read. Mentions first, then by name, after `--types`. The rest are listed without messages. |
| `--limit <n>` | `20` | Most messages per conversation, and most replies per thread (1 to 999). When more are unread, the earliest are kept. |

Both options only apply with `--messages`. Without it, or with a value that is
not a positive integer, the command fails with `invalid_input`.

```json
{
  "unread_channels": [
    {
      "id": "C1234567890", "name": "general", "mention_count": 2, "has_unreads": true,
      "last_read": "1700000001.000000", "latest": "1700000003.000000",
      "messages": [{ "ts": "1700000002.000000", "user": "U1234567890", "text": "…", "type": "message" }],
      "has_more": true
    },
    { "id": "C2345678901", "name": "random", "mention_count": 0, "has_unreads": true, "last_read": "1700000000.000000" }
  ],
  "threads": {
    "has_unreads": true,
    "mention_count": 1,
    "items": [
      {
        "channel_id": "C1234567890",
        "thread_ts": "1700000100.000000",
        "root": { "ts": "1700000100.000000", "user": "U1234567890", "text": "…", "type": "message" },
        "unread_replies": [{ "ts": "1700000101.000000", "user": "U2345678901", "text": "…", "type": "message" }]
      }
    ]
  },
  "users": [{ "id": "U1234567890", "name": "alice", "real_name": "Alice", "email": "alice@acme.com" }]
}
```

- Each item of `unread_channels` gains `last_read` and `latest` when Slack sent
  them. A conversation that was read has `messages` (possibly empty, when only
  thread replies are new), with the same fields as `conversations read --json`.
  A conversation past `--max-conversations` has no `messages` key: read it with
  `conversations read <id> --oldest <last_read>`.
- `has_more: true` on a conversation or a thread means `--limit` left unread
  messages out. Raise `--limit`, or continue with
  `conversations read <id> --oldest <ts of the last message shown>`.
- `threads.items` lists the unread threads, and `threads.has_more: true` says the
  list is incomplete. `items` is `[]` when no thread is unread. `--types` does
  not filter it, and `--fields` still projects `unread_channels` only (for
  example `--fields id,name,messages.ts,messages.text`).
- `users` lists the authors of every message printed, as in `conversations read`.

The text output prints each conversation's row followed by its messages, then
an `Unread Threads` section.

**Cost.** Every call is paced one at a time by the rate limiter: one
`conversations.history` per conversation read, one `users.info` per author, and
up to five calls for the threads (none when no thread is unread). That is why `--max-conversations` exists; a
run over many conversations is slower than plain `unread`.

**Unread threads come from an undocumented method.** The thread list is read
from `subscriptions.thread.getView`, the method behind Slack's own Threads view,
up to 5 pages of 10 threads. If Slack changes or refuses it, the command still
succeeds: it warns on stderr and `threads` keeps only `has_unreads` and
`mention_count`, with no `items` key. A script should treat a missing `items`
as "not known", not as "none".

**A conversation never read** has an all-zero `last_read`, which gives no
starting point. The newest `--limit` messages are returned for it instead.

**Auth-type caveat.** With browser auth this reads Slack's own unread state
(`client.counts`), then makes one or two API calls per unread conversation to
resolve its name (a DM also looks up the user), so a workspace with many unread
channels may hit Slack rate limits. With a standard token it reads the first 1000 conversations from `conversations.list`
and keeps those you are a member of that carry an unread count; Slack often
omits those counts for app tokens, so the result can be incomplete.

## `conversations mark-read`

```bash
slackcli conversations mark-read C1234567890 --ts 1712345678.123456
slackcli conversations mark-read --permalink https://acme.slack.com/archives/C1234567890/p1712345678123456 --yes --json
slackcli conversations mark-read "#general" --ts 1712345678.123456 --dry-run
```

Marks a channel, DM or group DM as read up to one message: it moves the
conversation's read cursor to that message's timestamp. It is the second half
of catching up, after `conversations unread` and `conversations read`.

The target message is always explicit. Give `<channel>` and `--ts`, or
`--permalink` alone; combining them, or giving neither `--ts` nor
`--permalink`, fails with `invalid_input` before anything is sent. There is no
"mark everything read" form. `<channel>` accepts an ID, a Slack link or a
[channel name](/docs/user-guide/links-and-timestamps/#channel-names-and-user-handles), and `--ts` accepts
`1712345678.123456` and `p1712345678123456`. A link to a thread reply marks the
conversation up to that reply's own timestamp, not its parent's.

Like the other mutating commands it confirms first (or takes `--yes` when
non-interactive), and `--dry-run` prints the conversation, the timestamp and
the current cursor without changing anything. `--json` gives:

```json
{ "channel_id": "C1234567890", "ts": "1712345678.123456", "previous_last_read": "1712345600.000200" }
```

**Undoing it.** `previous_last_read` is the read cursor before the change,
read with one `conversations.info` call just before the write. Run the command
again with `--ts` set to that value to put the cursor back. It is `null` when
Slack does not report a cursor or that read fails (the write still happens),
and then there is nothing to mark back to. Without `--json` the previous cursor
is part of the success line on stderr.

**What it does not do.**

- It does not check that the timestamp names an existing message. A timestamp
  older than the current cursor moves the cursor back, so later messages count
  as unread again.
- It does not change thread read state. Slack tracks the threads you follow
  with a separate cursor, so the `threads` summary of `conversations unread`
  is unaffected.

**Auth-type caveat.** The cursor belongs to the authenticated user, so the
command is meant for a browser session or a user token (`xoxp`), where it is a
person's inbox. A standard token needs the write scope for the conversation's
kind (`channels:write`, `groups:write`, `im:write` or `mpim:write`); without it
Slack answers `missing_scope`, reported as `permission_denied`. With a bot
token (`xoxb`) the cursor is the bot's own, not a person's inbox; what marking
it changes has not been verified beyond the scope requirement.

## `conversations members list`

```bash
slackcli conversations members list C1234567890
slackcli conversations members list C1234567890 --limit=50
slackcli conversations members list C1234567890 --cursor="$NEXT_CURSOR"   # next page
slackcli conversations members list C1234567890 --json
```

Lists the member IDs of a channel or conversation. `--limit` reflects members
**returned** (it pages until it has that many or the conversation is exhausted),
and when more remain the output prints the `--cursor` value to fetch the next
page. `--json` gives `{ channel_id, member_count, members: [...], next_cursor? }`.

**Enterprise-grid caveat.** On an Enterprise Grid org this endpoint can be
blocked by org policy — Slack returns `enterprise_is_restricted`, the command
reports it clearly and exits non-zero (scoping to a team does **not** lift it).
Member *management* (`add`/`remove`) and self ops (`join`/`leave`) are
documented below; this command is read-only.

## Previewing a change (`--dry-run`)

`members add`, `members remove`, `join`, `leave` and `mark-read` take
`--dry-run`: the channel and user IDs are resolved and checked, and the command
prints what it would do without doing it and without prompting. The preview
lists the users that would be added or removed; for `mark-read` it shows the
timestamp and the current read cursor.

```bash
slackcli conversations members add C1234567890 U1 U2 --dry-run
slackcli conversations leave C1234567890 --dry-run --json
```

See [`--dry-run`](/docs/user-guide/scripting/#--dry-run) for the preview format.

## `conversations members add`

```bash
slackcli conversations members add C1234567890 U1 U2 U3
slackcli conversations members add C1234567890 U1,U2 --yes    # non-interactive
slackcli conversations members add C1234567890 U1 --json
```

Adds one or more users (or agents/apps) to a channel. IDs are comma- or
space-separated. This is a batch, all-or-nothing operation: Slack's
`conversations.invite` adds the whole set or, if any ID cannot be invited,
adds none and reports the error — a success message never over-reports.

Mutating commands confirm before acting: in a terminal you get a `[y/N]`
prompt; with no terminal the command refuses unless you pass `--yes`, so a
script cannot change membership by accident. `--json` gives
`{ channel_id, added: [...] }`.

## `conversations members remove`

```bash
slackcli conversations members remove C1234567890 U1 U2
slackcli conversations members remove C1234567890 U1 --yes --json
```

Removes one or more users from a channel. Slack's `conversations.kick` removes
one user per call, so this is best-effort per ID: it attempts every ID and
reports which came out and which failed, rather than stopping at the first
error. It exits non-zero if **any** removal failed, and `--json` gives
`{ channel_id, removed: [...], failed: [{ user, error }] }`. Confirmation
works exactly as for `add` (prompt, or `--yes` when non-interactive).

## `conversations join`

```bash
slackcli conversations join C1234567890
slackcli conversations join C1234567890 --json
```

Joins a public channel as yourself. This is a self-op with no confirmation
prompt — it is idempotent (joining a channel you are already in is a no-op) and
only changes your own membership. `--json` gives `{ channel_id, channel }`.

## `conversations leave`

```bash
slackcli conversations leave C1234567890
slackcli conversations leave C1234567890 --yes --json
```

Leaves a channel or conversation as yourself. Like the other mutating commands
it confirms first (or takes `--yes` when non-interactive). Leaving a channel you
are already out of is reported as a no-op, not an error. `--json` gives
`{ channel_id, left, not_in_channel }`.