A Dart package and CLI for interacting with the Slack Web API.
Use dart_slack as a library in any Dart or Flutter app:
import 'package:dart_slack/dart_slack.dart';High-level facade with typed return values. This is the recommended entry point.
final slack = Slack(token: 'xoxp-...');
// Post a message
final msg = await slack.postMessage(channel: 'C0123ABCDEF', text: 'Hello!');
print(msg.ts); // message timestamp
// Reply to a thread
await slack.postMessage(
channel: 'C0123ABCDEF',
text: 'Thread reply',
threadTs: '1234567890.123456',
);
// Send a DM (pass a user ID as the channel)
await slack.postMessage(channel: 'U0123ABCDEF', text: 'Hey!');
// List channels
final channels = await slack.listChannels();
for (final channel in channels) {
print('${channel.name} (${channel.id})');
}
// Join a public channel
await slack.joinChannel('C0123ABCDEF');
// Search messages (user token with `search:read` only)
final results = await slack.searchMessages(query: 'deploy failed', limit: 10);
for (final match in results.items) {
print('${match.channelLabel} ${match.ts} ${match.authorLabel}');
}
// Read a canvas's markdown body (returns null if the id is not a canvas)
final body = await slack.readCanvas(canvasId: 'F0123ABCDEF');
print(body);
// Set your custom status (user token only; expiration is a Unix timestamp)
await slack.setStatus(text: 'Working on a task', emoji: ':gear:');
await slack.clearStatus();
// Release resources when done
slack.close();postMessage returns a SlackMessage, listChannels returns a List<SlackChannel>. All methods throw SlackApiException when the API returns an error. postMessage automatically joins the channel on not_in_channel and retries.
You can also create an instance from stored credentials:
final slack = Slack.fromStore(); // returns null if not logged inLower-level HTTP client that returns raw Map<String, dynamic> responses. Use this when you need direct access to the full Slack API response.
final client = SlackApiClient(token: 'xoxp-...');
final json = await client.postMessage(channel: 'C0123ABCDEF', text: 'Hello!');
print(json['ts']);
client.close();Real-time event streaming over WebSocket using Slack's Socket Mode. Requires an app-level token (xapp-*).
import 'package:dart_slack/dart_slack.dart';
final client = SocketModeClient(appToken: 'xapp-...');
await client.connect();
client.events.listen((event) {
print('${event.channel}: <${event.user}> ${event.text}');
});
// Later...
await client.close();Events are automatically acknowledged. The client reconnects with exponential backoff on disconnect.
Runs the Slack OAuth 2.0 V2 authorization flow over a local HTTPS server.
final flow = OAuthFlow(
clientId: 'your-client-id',
clientSecret: 'your-client-secret',
logger: Logger(),
);
final credentials = await flow.execute();
print('Token: ${credentials.accessToken}');
print('Team: ${credentials.teamName}');Opens the user's browser, listens on https://localhost:8585/callback, and exchanges the authorization code for a user token. A self-signed TLS certificate is generated automatically in ~/.dart_slack/. CSRF protection is handled via the OAuth state parameter.
Persists OAuth credentials to ~/.dart_slack/credentials.json with 0600 file permissions.
final store = CredentialsStore();
// Save after login
store.save(credentials);
// Load later
final loaded = store.load(); // returns null if not logged in
// Check existence
if (store.hasCredentials) { ... }
// Remove on logout
store.delete();Data class holding the access token and workspace metadata.
| Field | Type | Description |
|---|---|---|
accessToken |
String |
OAuth user token (xoxp-...) |
teamId |
String |
Workspace ID |
teamName |
String |
Workspace name |
userId |
String? |
Authenticated user ID |
Supports fromJson / toJson for serialization.
Returned by Slack.postMessage.
| Field | Type | Description |
|---|---|---|
channel |
String |
Channel the message was posted to |
ts |
String |
Message timestamp (used for threading) |
text |
String |
Message text content |
Returned by Slack.listChannels.
| Field | Type | Description |
|---|---|---|
id |
String |
Channel identifier (e.g. C0123ABCDEF) |
name |
String |
Channel name without the # prefix |
isPrivate |
bool |
Whether this is a private channel |
One message match, returned by Slack.searchMessages inside a CursorPage.
| Field | Type | Description |
|---|---|---|
ts |
String |
Message timestamp |
text |
String |
Message text content |
channelId |
String? |
Channel identifier (e.g. C0123ABCDEF) |
channelName |
String? |
Channel name without the # prefix |
user |
String? |
Author's user ID |
username |
String? |
Author's display name, as resolved by Slack |
channelLabel gives #name, then the channel ID, then unknown.
authorLabel gives the display name, then the user ID, then unknown.
- Dart SDK
^3.10.0(included with Flutter 3.41+) - A Slack app with the required OAuth scopes (see Slack App Setup)
-
Go to https://api.slack.com/apps and create a new app From scratch.
-
Under OAuth & Permissions, add these User Token Scopes:
channels:historychannels:readchannels:writechat:writecanvases:read(read canvas content withcanvas read)canvases:write(create/edit/delete canvases)files:read(look up a canvas's downloadable file)files:write(attach a file withsend/reply/dm --file)groups:readim:writesearch:read(full-text message search withsearch)users:read
Already logged in? A stored token does not gain a new scope on its own. After
search:readis added to the Slack app, every existing user must rundart_slack loginagain, orsearchfails withmissing_scope. Rundart_slack auth testto see the active token and its scopes.Reading huddle transcripts: Slack provides no API for huddle audio recordings or spoken transcripts — once a huddle ends, the spoken content is permanently inaccessible. What is retrievable is the huddle notes canvas (notes and links captured during a huddle are saved to a canvas), which
canvas readfetches, plus the huddle thread messages viathread. -
Under OAuth & Permissions > Redirect URLs, add:
https://localhost:8585/callback -
Copy your Client ID and Client Secret from Basic Information.
-
Create a
.envfile in the project root (see.env.example):SLACK_CLIENT_ID=<your-client-id> SLACK_CLIENT_SECRET=<your-client-secret>Alternatively, set these as environment variables.
- Under Socket Mode, toggle it on and generate an App-Level Token with the
connections:writescope. - Under Event Subscriptions, enable events and subscribe to
message.channels. - Add the token to your
.envfile:SLACK_APP_TOKEN=xapp-...
dart pub getAuthenticated commands resolve a token in this order:
~/.dart_slack/credentials.json, written bydart_slack login.- The
SLACK_TOKENenvironment variable — read from the process environment or a.envfile in the current directory, the same way the OAuth secrets (SLACK_CLIENT_ID, etc.) are resolved.
The environment fallback lets the CLI run non-interactively — in CI, containers, or agent workspaces — where a token is injected rather than obtained through an interactive login. A token from the credentials file always wins over the environment.
export SLACK_TOKEN=xoxp-… # or add SLACK_TOKEN=xoxp-… to .env
dart run bin/dart_slack.dart channels # no `login` step required# Authenticate
dart run bin/dart_slack.dart login
# Validate the current token (exits non-zero and prints a JSON error on failure)
dart run bin/dart_slack.dart auth test
# List channels
dart run bin/dart_slack.dart channels
# Show channel details
dart run bin/dart_slack.dart info -c <channel-id>
# List channel members
dart run bin/dart_slack.dart members -c <channel-id>
# Show recent messages
dart run bin/dart_slack.dart history -c <channel-id> -l 20
# Show thread replies
dart run bin/dart_slack.dart thread -c <channel-id> --ts <thread_ts>
# Search messages across every channel you can see
dart run bin/dart_slack.dart search -q "deploy failed"
# Limit the number of matches
dart run bin/dart_slack.dart search -q "deploy failed" -l 20
# Use Slack's own search modifiers in the query
dart run bin/dart_slack.dart search -q "in:#incidents from:@lee after:2026-08-01"
# Restrict the search to one channel, by ID or by name
dart run bin/dart_slack.dart search -q "deploy failed" -c C0123ABCDEF
dart run bin/dart_slack.dart search -q "deploy failed" -c incidents
# Send a message. Write the text to a file first, then pass the path.
# A shell runs backticks and $( ) inside a double-quoted argument, so
# inline -t can change the message before the CLI starts. See "Message text".
dart run bin/dart_slack.dart send -c <channel-id> --text-file ./message.md
# The same text from standard input
printf 'Hello from the CLI\n' |
dart run bin/dart_slack.dart send -c <channel-id> --text-stdin
# Short text with no backtick, no $( and no newline is safe inline
dart run bin/dart_slack.dart send -c <channel-id> -t "Hello from the CLI"
# Send a message with a file attached. --file attaches, --text-file is the text.
dart run bin/dart_slack.dart send -c <channel-id> --text-file ./comment.md -f ./report.pdf
# Reply to a thread
dart run bin/dart_slack.dart reply -c <channel-id> -r <thread_ts> --text-file ./reply.md
# Edit a message
dart run bin/dart_slack.dart edit -c <channel-id> --ts <message_ts> --text-file ./new.md
# Delete a message
dart run bin/dart_slack.dart delete -c <channel-id> --ts <message_ts>
# Create a canvas from markdown (standalone)
dart run bin/dart_slack.dart canvas create --title "Plan" -m "# Heading\n- item"
# Create a canvas tabbed into a channel, reading content from a file
dart run bin/dart_slack.dart canvas create -c <channel-id> -f ./plan.md
# Read a canvas's markdown body to stdout (e.g. a huddle's notes canvas)
dart run bin/dart_slack.dart canvas read --canvas <canvas-id>
# Read a canvas and save it to a file
dart run bin/dart_slack.dart canvas read --canvas <canvas-id> -o ./notes.md
# Edit a canvas (replace | append | prepend)
dart run bin/dart_slack.dart canvas edit --canvas <canvas-id> --mode append -m "More notes"
# Delete a canvas
dart run bin/dart_slack.dart canvas delete --canvas <canvas-id>
# Send a DM
dart run bin/dart_slack.dart dm -u <user-id> --text-file ./message.md
# Send a DM with a file attached
dart run bin/dart_slack.dart dm -u <user-id> --text-file ./comment.md -f ./notes.txt
# List workspace users
dart run bin/dart_slack.dart users
# Look up a user profile
dart run bin/dart_slack.dart whois -u <user-id>
# Set your custom status (expires automatically after 6 hours)
dart run bin/dart_slack.dart status set -t "Working on a task" -e ":gear:" -x 6h
# Clear your custom status
dart run bin/dart_slack.dart status clear
# Watch a channel (poll-based, uses user token)
dart run bin/dart_slack.dart watch -c <channel-id> -i 5 -n 10
# Stream a channel in real time (Socket Mode, uses app-level token)
dart run bin/dart_slack.dart stream -c <channel-id>
# Log out
dart run bin/dart_slack.dart logoutsearch reads Slack's search.messages method. It prints one line for each
match:
[#incidents] [1756719000.123456] <lee> deploy failed on staging
The line holds the channel, the message timestamp, the author, and a snippet
of the text. A multi-line message is collapsed onto one line, and a snippet
longer than 200 characters is cut and marked with …. Use history or
thread to read a match in full.
-c restricts the search to one channel. It adds the matching in: modifier
to the query and keeps the query itself: -q "deploy failed" -c C0123ABCDEF
searches for deploy failed in:<#C0123ABCDEF>. A value that is not a
conversation ID is treated as a channel name, so -c incidents and
-c '#incidents' both add in:#incidents.
search needs a user token with the search:read scope. That scope is new.
An existing user must run dart_slack login again to receive it — a
stored token does not gain a scope on its own. Without it, Slack answers
missing_scope and the CLI prints the dart_slack login hint. Run
dart_slack auth test to check the active token.
send, reply, dm, and edit take the message text from exactly one of
three sources. Giving none of them, or more than one, is a usage error.
| Source | Passes through a shell | Bytes sent |
|---|---|---|
--text-file <path> |
no | exactly the file contents |
--text-stdin (or --text-file -) |
no | exactly the standard input |
-t / --text |
YES | normalized, see below |
--file / -f is a different option. It attaches a file to the message.
--text-file supplies the text of the message.
A shell expands a backtick span and a $( ) span inside a double-quoted
argument. It runs them BEFORE this CLI starts, so the CLI never sees the text
the author wrote:
python3 -c 'import sys; print(repr(sys.argv[1]))' "field is `printf changed`"
# 'field is changed'The marked-up span is gone and the command output is spliced in. The send then succeeds, so nothing reports the change. The CLI cannot detect this, because the shell destroys the evidence before the process starts. The only fix is to keep the text out of the argument list.
--text therefore prints a warning when its value contains `, $( or
${. Read that warning as "this command line is a risky habit", not as "this
message was damaged": a marker that arrives intact proves the shell did NOT
run it that time.
# Safe. The bytes never meet a shell.
cat > message.md <<'EOF'
Run `dart test` and check $(pwd).
EOF
dart run bin/dart_slack.dart send -c C0123ABCDEF --text-file message.md
# Also safe, and no temporary file.
dart run bin/dart_slack.dart send -c C0123ABCDEF --text-stdin <<'EOF'
Run `dart test` and check $(pwd).
EOFFile and standard-input bytes are sent unchanged. They are NOT normalized: no
unescaping, no line-ending rewrite, no control-character stripping. There is no
shell between the author and those bytes, so there is nothing to repair, and
repairing would corrupt a file that deliberately holds the two characters \
and n.
The rest of this section applies to -t / --text only.
send, reply, dm, and edit normalize the --text argument before they
call the Slack API. A plain double-quoted shell string does not interpret
backslash escapes, so -t "line1\nline2" sends the two literal characters \
and n, and Slack shows them as text instead of a line break.
The normalization is a safety net for that case:
- The literal
\n,\r\n,\r, and\tsequences become real control characters. \r\nand lone\rline endings become\n.- Other non-printable control bytes (C0 and DEL) are removed. Newline and tab are kept.
- Every other backslash sequence is left alone.
The escape hatch is a doubled backslash, but it must survive your shell first.
What the CLI acts on is the BYTES the argument arrives as, not how you spelled
it. In bash double quotes a backslash is only special before $, `, ",
\ and a newline, so "\n" and "\\n" pass the SAME two bytes:
Spelling of --text |
Bytes the CLI receives | What Slack shows |
|---|---|---|
"a\nb" |
a \ n b |
two lines |
"a\\nb" |
a \ n b |
two lines — the same bytes, so this is NOT an escape hatch |
"a\\\\nb" |
a \ \ n b |
the literal \n |
'a\\nb' |
a \ \ n b |
the literal \n |
So use single quotes — -t 'a\\nb' — or four backslashes inside double quotes.
The same rule applies to any literal text where a backslash meets n, r or
t. A Windows path is the common one: -t 'C:\new' sends C:, a newline and
ew, because the shell passes C:\new and \n is then unescaped. Write
-t 'C:\\new' to keep the path.
test/src/cli/message_text_test.dart pins this table. It runs the spellings
above through bash and asserts the argument bytes, so the table cannot drift
from the behaviour.
Text that already holds real newlines, real tabs, and printable characters is sent unchanged. To make a real newline in the shell, use ANSI-C quoting or a heredoc:
dart run bin/dart_slack.dart send -c <channel-id> -t $'line1\nline2'dart testdart pub global activate coverage 1.15.0
dart test --coverage=coverage
dart pub global run coverage:format_coverage --lcov --in=coverage --out=coverage/lcov.info
genhtml coverage/lcov.info -o coverage/
open coverage/index.html