lurkline
Slack CLI and MCP over an existing browser session
Links
README
From the repo.
Lurkline
Lurkline is a command-line interface (CLI) and stdio Model Context Protocol (MCP) server for developers and local agents that need Slack access through an existing signed-in browser session. It doesn't require a Slack app, bot, or OAuth flow.
Use Lurkline to:
- Discover channels, direct messages (DMs), and group DMs.
- Search messages and read bounded unread, recent-activity, history, and thread snapshots.
- Preserve Slack's raw message blocks and legacy attachments losslessly.
- Inspect file metadata, custom emoji, and potentially partial reaction users.
- Download private Slack files to explicit, non-existing local paths.
- Upload one local regular file to a conversation root or thread.
- Add or remove confirmed emoji reactions idempotently.
- Review the personal Slack Later inbox and deliberately save, complete, or remove exact messages.
- Render bounded Markdown as Slack
rich_text, with explicit, verifiable user mentions. - Create, update, inspect, delete, and publish text or one-file Slack drafts.
- Send confirmed root messages and thread replies.
Lurkline reads by default. CLI publication, deletion, Later, reaction,
file-upload, and file-draft creation mutations require --confirm.
The MCP server rejects every write unless you start it with
--allow-write; publication, deletion, Later, reactions, file uploads, and
file-draft creation then also require confirm: true.
[!WARNING] Slack's browser-session APIs are unsupported and can change without notice. Browser tokens and cookies grant the signed-in user's authority. Handle them like a password. Message publication is irreversible through Lurkline.
Quick start
You need a signed-in Slack workspace and Chrome or another Chromium-based browser. Install Lurkline before you continue.
-
Open the workspace, open Developer Tools, and select Network.
-
Reload Slack and select a successful
POSTrequest to/api/client.countswhose URL or form body containsslack_route. -
Right-click the request and select Copy > Copy as cURL (bash).
-
Import the request into a named profile:
pbpaste | lurkline auth import-curl --profile workOn Linux, use a trusted clipboard reader such as
wl-paste:wl-paste | lurkline auth import-curl --profile work -
Validate the profile and read your inbox:
lurkline --profile work doctor lurkline --profile work inbox
The importer treats standard input as data. It doesn't run the copied command,
invoke a shell, or invoke curl. It accepts at most 256 KiB, verifies the Slack
origin and browser credential shape, makes one bounded client.counts request,
and stores only normalized session fields.
Both <workspace>.slack.com and <organization>.enterprise.slack.com origins
are supported. The importer accepts ordinary T… routes, standalone E…
routes, and Enterprise E…:T… or E…:E… routes. For a compound route, it uses
the final ID as the profile's session context. Existing profiles need no migration.
After a successful import, clear the command from your clipboard and clipboard history. Don't save it in a file, shell history, issue, or chat.
Understand write safety
The following safeguards apply to Slack writes:
drafts createanddrafts updateare explicit CLI write commands.drafts create-filerequires--confirmbefore Lurkline reads standard input or opens the local file.drafts delete,drafts send,message send,thread reply, and reaction add/remove require--confirm.files uploadrequires--confirm.later save,later complete, andlater removerequire--confirmbefore Lurkline performs even the first Slack read.- MCP draft mutations and publications require the server's
--allow-writeoption. - MCP deletion, publication, Later, reaction, file-upload, and file-draft
creation mutations also require
confirm: truein each tool call. - Every message publication uses a fresh UUID v4 client message ID.
- Draft publication posts first and deletes the draft only after Slack returns a valid acknowledgement.
- If a post succeeds but draft cleanup fails, Lurkline returns the sent message and a cleanup warning instead of reporting a failed send.
- If the post outcome is unknown, Lurkline returns
publication_uncertain, the client message ID, and instructions not to retry automatically. - Text-draft creation accepts only an exact correlated acknowledgement or an
exact bounded reread. An unresolved result returns
draft_creation_uncertain, its client message ID, and instructions not to retry automatically. - One-file draft deletion always preserves the Slack file because no client-side ownership scan can be atomic with the later deletion request.
Lurkline doesn't ask for confirmation when it reads, renders Markdown locally, or lists and inspects drafts.
Requirements
- macOS or Linux.
- A signed-in Slack browser session.
- Rust 1.88 or later if you build from source.
Install Lurkline
Install a release archive
Download an archive and its matching .sha256 file from
GitHub Releases. Releases
provide binaries for Linux x86-64, Linux ARM64, and macOS ARM64.
Verify and install the macOS ARM64 archive:
shasum -a 256 -c lurkline-v0.8.2-macos-aarch64.tar.gz.sha256
tar -xzf lurkline-v0.8.2-macos-aarch64.tar.gz
sudo install lurkline-v0.8.2-macos-aarch64/lurkline /usr/local/bin/lurkline
lurkline --version
Build from source
Build and install Lurkline from source:
git clone https://github.com/smarzola/lurkline.git
cd lurkline
cargo install --locked --path .
lurkline --version
Manage credential profiles
Select a profile
Pass --profile before or after a command:
lurkline --profile work conversations list
lurkline conversations list --profile work
You can also select a default profile for the process:
export LURKLINE_PROFILE='work'
lurkline doctor
Stored-profile selection uses the following precedence:
--profileLURKLINE_PROFILE- The registry default
The first imported profile becomes the default. Later imports preserve that
default. Profile names must contain 1 through 64 ASCII letters, digits, .,
_, or -.
List and inspect profiles
The following commands never display tokens or cookies:
lurkline auth list
lurkline auth status --profile work
lurkline auth list --json
The status command reports non-secret workspace metadata and whether the matching credential file is present and valid.
Rotate or replace a profile
Copy a fresh browser request and repeat the import to rotate a session for the same workspace:
pbpaste | lurkline auth import-curl --profile work
To change an existing profile to a different workspace or team, pass
--replace-workspace:
pbpaste | lurkline auth import-curl \
--profile work \
--replace-workspace
Lurkline validates the new session before it changes a stored value. It serializes registry and credential-file updates across processes and uses rollback where possible.
Remove a profile
Remove the profile:
lurkline auth remove --profile work
Removing the default selects the lexicographically first remaining profile. Removing the last profile clears the default.
Storage locations
Lurkline stores one versioned JSON credential file per profile. The file name
is the lowercase hexadecimal encoding of the profile's ASCII bytes. For
example, profile work uses 776f726b.json.
- macOS:
~/Library/Application Support/lurkline/credentials/ENCODED_PROFILE.json - Linux:
$XDG_CONFIG_HOME/lurkline/credentials/ENCODED_PROFILE.json, or~/.config/lurkline/credentials/ENCODED_PROFILE.json
Credential files contain the normalized workspace origin, team ID, browser
token, and cookie. They are plaintext and rely on your operating-system user
account and full-disk encryption for confidentiality. Lurkline creates
credential directories with mode 0700 and credential files with mode 0600
on Unix. It rejects credential paths that are symlinks, have the wrong owner,
have broader permissions, or aren't the expected file type.
The profile registry contains only profile names, workspace origins, team IDs, and the default selection:
- macOS:
~/Library/Application Support/lurkline/profiles.json - Linux:
$XDG_CONFIG_HOME/lurkline/profiles.json, or~/.config/lurkline/profiles.json
The configuration directory uses mode 0700. Registry, lock, and credential
files use mode 0600.
Re-import profiles after upgrading
Profiles created by v0.4.1 or earlier keep their names and default selection, but their stored credentials aren't migrated. Re-import each profile from a fresh browser request:
pbpaste | lurkline auth import-curl --profile work
Until you re-import a profile, auth status reports
credential_present: false, and Slack commands ask you to re-import it.
Use an environment override
Existing automation can provide all four Slack session variables instead of a stored profile:
export SLACK_BASE_URL='https://workspace.slack.com'
export SLACK_TEAM_ID='T_WORKSPACE_ID'
export SLACK_TOKEN='<browser-session-token>'
export SLACK_COOKIE='<complete-cookie-header>'
lurkline doctor
Use a trusted secret-injection mechanism for real values. Don't put them in committed configuration or command arguments.
The four variables are atomic: if you set any one, you must set all four. A complete environment bundle has higher priority than stored profiles. Lurkline never combines environment and stored credential fields. Authentication-management commands ignore these variables so that you can inspect or repair stored profiles independently.
Discover conversations
List one cursor-paginated page:
lurkline conversations list --limit 100
lurkline conversations list --cursor '<next-cursor>' --limit 100
Find channels, DMs, and group DMs by case-insensitive substring:
lurkline conversations find platform --limit 20
Discovery returns stable IDs and human-readable names. DM names come from the
bounded user directory when available. JSON output sets name_is_fallback
when the participant name is unavailable.
Commands that take a conversation accept either a Slack conversation ID or an exact name. Name matching is case-insensitive. Missing or ambiguous names fail instead of guessing. Supplying an ID skips discovery when the Slack method supports direct addressing.
Raw uppercase alphanumeric values beginning with C, D, or G take ID
precedence. Add # or @ to force a colliding exact name, such as
#GENERAL2.
Search messages
Search newest first:
lurkline search messages 'deployment failed' --limit 20
Restrict the search to one conversation and calendar bounds:
lurkline search messages deploy \
--in platform \
--after 2026-07-01 \
--before 2026-07-27 \
--limit 20
Continue from a returned cursor:
lurkline search messages deploy --cursor '<next-cursor>' --limit 20
The query can contain standard Slack search modifiers. --in resolves an ID
or exact name before it applies the conversation filter. Dates must be valid
YYYY-MM-DD values, and --after can't be later than --before.
The reported total is Slack's workspace match count. Only a returned cursor indicates another page. Human search rows end with the canonical Slack permalink when it can be constructed safely.
List unreads
List every conversation and thread count Slack explicitly marks unread:
lurkline unreads
Human output keeps each stable conversation ID and adds #channel,
@username, a profile display name, or a readable group-DM participant list.
JSON adds nullable name and display_name fields plus a typed
name_resolution: resolved, inaccessible, incomplete, unnamed, or
unavailable. These states distinguish a complete discovery miss, a bounded
scan, metadata without a safe label, and a failed or conflicting auxiliary
lookup observed during the scan without hiding Slack's authoritative unread
count.
Naming uses one bounded conversation scan for the snapshot, stopping as soon as every unread ID is accounted for, and, only when a matched DM needs it, one shared target-aware user scan with the same early-completion rule. It never makes one request per result and never marks a conversation read. The inbox command reuses its existing conversation and user discovery for the same fields.
Read the inbox
Read recent context from the ten highest-priority unread conversations:
lurkline inbox
Choose explicit bounds:
lurkline inbox --conversations 20 --messages 50 --json
Inbox ordering is deterministic: highest mention count first, then conversation ID. Lurkline:
- Reads Slack's explicit unread conversation and thread counts.
- Selects at most the requested number of unread conversations.
- Resolves bounded conversation metadata.
- Reads at most the requested recent messages for each selection.
- Resolves message authors through one shared bounded user directory when needed, reusing the directory already loaded for any selected DM.
Inbox doesn't infer exact unread message boundaries, fetch unread thread
roots, or mark anything read. JSON reports total_unread_conversations,
has_more_conversations, truncation_reason, and Slack's unread-thread
summary. The complete pretty-serialized inbox report cannot exceed
LURKLINE_MAX_RESPONSE_BYTES. Lurkline stops after the first history result
that doesn't fit and reports byte_limit; otherwise a requested conversation
cap reports conversation_limit.
If bounded discovery can't find a selected conversation,
metadata_is_complete is false. Treat archive, membership, privacy, and
member-count fields as unavailable rather than authoritative in that case.
Human-readable inbox messages prefer @username, then profile display name,
then an explicitly labeled raw-ID fallback. A complete directory miss, bounded
partial scan, or auxiliary request failure remains visible in
author_resolution without discarding otherwise useful inbox messages.
Read recent activity
Read the last six hours across up to 10 joined channels, DMs, and group DMs:
lurkline activity --since 6h
Use an exact inclusive-lower, exclusive-upper interval and narrow it to one conversation:
lurkline activity \
--after '2026-07-30T08:00:00+02:00' \
--before '2026-07-30T12:00:00+02:00' \
--include '@alice' \
--json
--since accepts positive s, m, h, d, and w segments such as
30m or 1d12h, up to 365 days. Absolute bounds require RFC 3339 offsets.
Output always reports effective bounds in UTC and uses [after, before).
The defaults sample the newest 20 messages from each of at most 10 selected
conversations, then return 50 globally ordered items newest-first. Adjust them
with --per-conversation, --conversations, and --limit; use
--oldest-first to reverse that same bounded recent sample. Repeat
--include or --exclude with exact IDs or names. Includes can opt in a
visible unjoined channel; ambiguous, missing, or overlapping selectors fail
with an actionable error.
Restrict the eligible scope before the cap with repeatable kinds:
lurkline activity --since 6h --kind channel
lurkline activity --since 6h \
--kind direct-message \
--kind group-direct-message
Kinds are normalized and deduplicated. They apply before includes, excludes,
and --conversations; a selector that names a disallowed kind fails with an
actionable error. With no --kind, all three current kinds are eligible.
--conversations is the maximum conversation slice read by one call, not a
global scope cap. Eligible conversations use stable ID order so unrelated
newer Slack activity cannot reshuffle a traversal. Structured output exposes
the normalized conversation_kinds, eligible_conversations, zero-based
scope_offset, selected_conversations, remaining_conversations,
scope_has_more, and conversation_scan_truncated. selection_truncated
remains true whenever this response represents only one slice or the bounded
directory scan itself stopped early.
Activity uses one reply-inclusive, time-bounded history request per selected
conversation. It never calls a write or mark-read endpoint. Structured output
keeps the enriched message schema and reports conversation-level complete,
message_limit, inaccessible, or unavailable status, along with selection
and response-byte truncation.
Continue only with the returned opaque cursor:
lurkline activity --cursor '<next-cursor>'
continuation_kind says whether next_cursor advances messages within the
current conversation slice or advances to the next conversation_scope
slice. Human output reports the same distinction as more messages or
more conversation-scope. A message continuation always finishes its current
slice before Lurkline returns the next scope continuation; an empty slice can
advance immediately.
The cursor freezes the team, effective interval, normalized kinds, resolved include/exclude IDs, ordering, limits, and checked scope offset. It protects the fully ordered eligible ID/kind scope with a digest and rejects scope drift before any history request, without embedding the whole workspace directory. Message continuations also protect the current bounded message sample and last emitted key. Messages newer than the frozen upper bound cannot shift later message pages.
After a cursor advances to another conversation-scope slice, Lurkline does not re-read or revalidate the completed slice. Slack does not provide an immutable whole-scope snapshot across calls, so later edits or deletions to already emitted messages cannot be detected.
Each scope slice is globally ordered internally. To combine a complete
multi-slice traversal, collect every items array and sort by canonical Slack
timestamp, then conversation ID: ascending for oldest_first, or reverse that
exact comparator for newest_first. Do not concatenate scope responses.
Slack directory discovery remains capped at 20 pages of 200. If
conversation_scan_truncated is true, callers can traverse every eligible
conversation in the discovered bounded scope, but there is deliberately no
cursor beyond Slack's unscanned directory remainder.
Read messages and users
Read recent conversation history by ID or exact name:
lurkline channel read platform --limit 50
lurkline channel read C0123456789 --cursor '<next-cursor>' --limit 50
Read a thread:
lurkline thread read platform 1712345678.000100 --limit 100
lurkline thread read C0123456789 1712345678.000100 \
--cursor '<next-cursor>' \
--limit 100
Fetch one exact message or find users:
lurkline message get platform 1712345678.000100
lurkline users find alice --limit 20
User JSON and MCP output use nullable name, display_name, and real_name
fields. Omitted, JSON-null, empty, and whitespace-only Slack identity values
normalize to JSON null; a genuine value equal to the string "null" remains
the string "null". Human user rows show - for an absent field. DM
conversation output remains directly addressable by falling back to its stable
user ID and reporting name_is_fallback.
Message and search JSON preserve Slack's raw blocks and legacy
attachments arrays alongside normalized fields. Unknown nested fields remain
unchanged. For either field, null means Slack omitted it and [] means Slack
returned an explicit empty array.
Message results also normalize author, thread, reaction, and file metadata.
Channel, thread, exact-message, search, and inbox commands resolve known people
through the bounded Slack user directory. Human output shows @username, or a
profile display name when no username is usable. A raw-ID fallback includes one
of these labels:
[unresolved]:author_resolutionisunresolved; a complete directory did not contain a usable identity.[resolution incomplete]:author_resolutionisincomplete; Slack advertised another page after Lurkline reached its 20-page bound, which scans up to 4,000 users.[resolution unavailable]:author_resolutionisunavailable; the auxiliary directory request failed.[resolution not attempted]:author_resolutionisnot_attempted; this result type does not perform author enrichment, such as sent-message acknowledgements.
JSON and MCP results retain author_id and add author_name,
author_display_name, and author_resolution. The resolution value is
provided for a name included on the message, directory for a user-directory
match, one of the explicitly mapped fallback values above, or unknown when
Slack supplied neither an ID nor a name. Lurkline performs at most one bounded
user-directory scan for each targeted read or inbox snapshot and reuses a scan
required to resolve a conversation name; it never looks up each message
separately.
Human message bodies also render Slack user mentions as @username, falling
back to a safe profile display name. Inline and fenced code remain literal.
JSON and MCP keep canonical Slack text, raw blocks, and attachments unchanged,
and add rendered_text, ordered unique mentions, and mention_resolution.
That status is not_needed, not_attempted, complete, partial, or
unavailable; unresolved tokens remain in their original <@USER_ID> form.
Resolution records at most 256 unique mentions and bounds derived rendering to
40,000 UTF-8 bytes. Reaching either bound reports partial and keeps canonical
Slack text as the safe fallback.
Sent-message acknowledgements are not_attempted, and send/reply operations
never use the derived rendering.
Every structured message and search match also includes permalink,
thread_root_permalink, and permalink_resolution. A root message is
complete when its exact link is available and leaves
thread_root_permalink null because it is not applicable. A reply is
complete only when both its exact link and root link are available; one
applicable link is partial, and none is unavailable. Exact-message human
output prints link and, for replies, thread-root lines. Ordinary channel,
thread, and inbox rows stay concise.
Lurkline constructs these links locally from the validated Slack workspace
origin, conversation ID, and timestamp, so lists never make one permalink
request per message. Timestamp fractions are right-padded to Slack's canonical
six digits and are never truncated. A noncanonical timestamp or missing
metadata degrades only the affected links; the message remains available.
Slack-provided search URLs are not forwarded, preventing unexpected origins,
tracking parameters, fragments, or ambiguous encodings from reaching output.
When search omits a separate thread timestamp, a strictly validated Slack
route may supply that missing root timestamp before Lurkline reconstructs both
links locally. If neither structured thread metadata nor a valid route proves
whether the match is a root or reply, link status is unavailable rather than
guessing a root URL.
The same behavior covers channels (C…), direct messages (D…), and group
DMs (G…).
Reaction user_ids can be shorter than count; check user_ids_complete
before treating that list as exhaustive. Slack guarantees that the
authenticated user remains present when that user reacted. Nullable or omitted
file metadata remains null; sparse Slack Connect placeholders expose
mode: "file_access" and file_access: "check_file_info" without inventing
names, sizes, timestamps, or uploader IDs. File shares is null when Slack
omits it and [] only when Slack returns an explicit empty object. Treat
shares as exhaustive only when shares_complete is true; Slack's
has_more_shares and skipped_shares indicators make it false. The
channel_ids, group_ids, and im_ids fields also preserve omission as
null and an explicit empty Slack array as [].
Add --json to a data-returning command for structured JSON.
Inspect files, emoji, and reactions
Fetch bounded metadata for an exact Slack file:
lurkline files info F0123456789 --json
Download a private file to an explicit path:
lurkline files download F0123456789 \
--output ./downloads/report.pdf \
--max-bytes 104857600 \
--json
The output path must not exist. Lurkline opens every parent through directory
descriptors, rejects empty, ., .., and symbolic-link components, creates a
mode-0600 temporary file, streams within the requested bound, syncs it, and
atomically commits without replacement. A failure before the commit removes
the temporary file. A parent-directory sync failure after the commit returns
success with a durability warning. Local paths are limited to 4,096 bytes, 64
components, and 255 bytes per component.
Lurkline obtains the download URL from files.info; callers can't supply one.
The file must have an exact byte size and private download URL. Explicitly
external, non-hosted, or inaccessible files fail before local output is
committed. Legacy file objects that omit mode or file_access remain
downloadable when the other trusted metadata and URL checks pass. Lurkline
uses a separate file client. The first validated
https://files.slack.com request carries the browser token and cookie because
Slack requires both for private file bytes. A successful body is accepted when
Slack marks it as an attachment, reports the file's metadata MIME type, or uses
a generic download MIME type; hosted images and documents therefore keep their
natural media types. The declared and streamed byte counts must match
files.info. Any validated redirect is followed without either credential.
Redirects away from that exact origin, non-HTTPS URLs, embedded URL
credentials, and more than three hops fail. Authentication, authorization,
unsupported file mode, HTTP status, redirect, response-shape, and size-mismatch
failures remain distinct so callers can recover without guessing.
List custom emoji and aliases:
lurkline emoji list --json
Ensure a reaction is present or absent:
lurkline reactions add platform 1712345678.000100 eyes --confirm --json
lurkline reactions remove platform 1712345678.000100 eyes --confirm --json
lurkline reactions add platform 1712345678.000100 \
'thumbsup::skin-tone-6' --confirm --json
Reaction operations read the exact target state first. Already-satisfied
requests succeed without a write. After a mutation or ambiguous transport
result, Lurkline reads the exact message again. A known non-target state returns
reaction_not_applied, for which a deliberate retry is safe. An unreadable
state returns reaction_uncertain; inspect the message before retrying.
Review Slack Later
Open the in-progress Later inbox, or select another state:
lurkline later list
lurkline later list --state completed --limit 25 --json
lurkline later list --state archived
Each row keeps the exact conversation/message identity and Slack's created, updated, due, snooze, and completion metadata. Lurkline hydrates all source messages in one grouped request and, for saved replies, their roots in at most one additional grouped request. Deleted or inaccessible messages remain useful Later rows with explicit unavailable context. A file saved in Slack appears as an attachment on its containing message; there is no separate file-level Later identity.
Human rows always show both the display name and canonical conversation ID. Conversation, saved-message, and optional root enrichment degrade independently on bounded access or transport failures; authentication failures and malformed verified response shapes still fail the list instead of being hidden.
When has_more is true, continue with the returned opaque cursor by itself:
lurkline later list --cursor '<next-cursor>'
The cursor retains the selected state and page size and rejects count drift or an identity or upstream cursor repeated from any earlier page. A terminal page must account for the selected state count, while a non-terminal page must leave items outstanding; contradictory counts or premature termination fail closed. This gives deterministic, non-overlapping pages while that Later list is unchanged. Slack doesn't expose an immutable Later snapshot, so any concurrent Later change invalidates the continuation; restart from the first page after a mutation. Same-count replacement isn't always detectable.
Save, complete, or remove one exact message deliberately:
lurkline later save platform 1712345678.000100 --confirm --json
lurkline later complete platform 1712345678.000100 --confirm --json
lurkline later remove platform 1712345678.000100 --confirm --json
Before writing, Lurkline resolves the conversation and scans all three Later
views within a fixed bound. Saving an already in-progress item, completing an
already completed item, and removing an absent item are safe no-ops. Saving a
completed or archived item and completing an absent or archived item fail with
recovery guidance because Slack's private reopen behavior isn't assumed.
After a write, an exact bounded readback must prove the requested state. A scan
cap or unprovable outcome returns a structured later_mutation_uncertain or
later_mutation_not_applied error; a changed private response contract returns
invalid_response. Inspect Later before retrying any uncertain result.
These commands use Slack's unsupported browser saved.* methods. If Slack
changes their required fields or state meanings, Lurkline fails closed instead
of silently dropping or misclassifying items.
Upload a file
Upload one regular file to a conversation root:
lurkline files upload platform \
--path ./diagrams/release.png \
--title 'Release architecture' \
--alt-text 'Components and data flow for the release' \
--confirm \
--json
Upload to an existing thread:
lurkline files upload platform \
--path ./reports/release.txt \
--thread-ts 1712345678.000100 \
--confirm
Lurkline opens the source through directory descriptors, rejects symbolic
links and non-regular files, and rejects empty or oversized sources. The
default limit is 100 MiB; --max-bytes can raise it to 1 GiB. It hashes one
preflight pass and the streamed upload pass with SHA-256. Lurkline completes
the Slack upload only when the file identity, metadata, byte count, and hashes
still match.
The source basename must be valid UTF-8, contain at least one non-whitespace
character, contain no control characters, and be at most 255 bytes. Optional
--title values use the same 1-to-255-byte text rules. Optional image
--alt-text values can contain 1 to 1,000 UTF-8 bytes and use the same
non-whitespace and non-control rules. Lurkline validates the conversation,
thread timestamp, basename, title, and alt text before it opens or hashes the
source. Slack applies alt text only to supported image files. If you supply alt
text for another file type, Slack can retain the file privately and Lurkline
returns completion_uncertain instead of claiming that it shared the file.
For a thread upload, it also reads the exact timestamp and requires an existing
root message before Slack allocates upload storage.
The Slack browser lifecycle has three mutations—allocation, transfer, and completion—followed by exact verification:
- Call
files.getUploadURLto allocate a non-secret file ID and a signed upload URL. - Stream the exact bytes to the URL with a separate credential-free client.
Require Slack's exact
OK - <byte count>acknowledgement. - Call
files.completeUploadfor the requested root or thread. - Read
files.infoto prove the requested alternative text, when present, and membership in the requested conversation. - For a direct message, read bounded conversation history or thread replies to prove that the unique file ID has the requested root or thread route.
For a channel, the exact files.info share entry proves both the conversation
and root or thread timestamp. For a direct message, Slack reports membership
through im_ids without a message timestamp. Lurkline therefore requires both
the exact DM ID in im_ids and one exact file-ID match in the requested
history or thread. Slack can expose processed file metadata after it
acknowledges completion, so Lurkline makes up to six exact verification reads
with 3.85 seconds of bounded delay. Each direct-message read scans at most 10
pages of 200 messages. Missing, ambiguous, malformed, or still-truncated
evidence returns completion_uncertain.
Lurkline never prints or returns the signed URL. The byte request contains no browser token, cookie, workspace origin, or referrer and doesn't follow redirects.
Use the returned stage to decide what to do next:
| Stage | Meaning | Recovery |
|---|---|---|
allocation_uncertain | Slack might have allocated storage, but no safe file ID was returned. | Don't retry automatically. A deliberate retry can leave an unshared orphan. |
allocated | Slack returned a file ID, but the upload URL was missing or unsafe, so Lurkline sent no bytes. | Keep the file ID for diagnosis. Start a new upload only deliberately. |
source_changed | The source changed after allocation. Lurkline didn't complete the upload. | Stabilize the source, then start a new upload deliberately. |
transfer_uncertain | Slack byte acceptance can't be proven. | Don't upload again automatically. Inspect Slack before deciding. |
completion_uncertain | The bytes were sent, but the requested share can't be proven. | Inspect the file ID and destination before deciding whether to retry. |
shared | Slack state proves the exact conversation and root or thread route. | No recovery is required. |
Uploads support one local file and one root or thread destination. Lurkline doesn't support batch uploads, snippets, remote files, public-link creation, file deletion, or scheduled uploads. Legacy attachments remain read-only.
Render Markdown
Render ordinary Markdown locally without Slack credentials:
printf '%s\n' '**Deploy** after reviewing [the runbook](https://example.com).' \
| lurkline message render --json
Lurkline accepts at most 40,000 bytes of UTF-8 Markdown. It returns a plain-text
fallback and one deterministic Slack rich_text block.
The renderer supports the following Markdown:
| Markdown | Slack output |
|---|---|
| Paragraphs and line breaks | Rich-text sections and text elements |
*emphasis* and **strong** | Italic and bold styles |
~~strikethrough~~ | Strike style |
| Inline, fenced, and indented code | Code style and preformatted blocks |
| Links | Slack link elements |
| Block quotes | Rich-text quote elements |
| Ordered and unordered lists | Nested rich-text lists |
| Headings | Bold rich-text sections |
| Raw HTML | Literal text |
Write labeled links as standard Markdown, for example
[the runbook](https://example.com). Slack-native <URL|label> syntax in
prose is rejected with that recovery guidance before any Slack request. The
same text remains available for documentation inside inline, fenced, or
indented code.
Empty input, control characters, excessive nesting, and over-limit input fail before a Slack request.
Mention a user deliberately
Ordinary @text, email addresses, Slack-looking <@U123> text, and anything in
inline or fenced code remain literal and never notify anyone. To create a real
Slack user mention, make that intent explicit in the Markdown link destination:
Hello [@Alice](slack-user:alice).
Please ask [@Operations](<slack-user:Operations Team>) to review.
The reference may be an exact Slack user ID, username, or display name. IDs are matched exactly; names are matched case-insensitively with username before display name. References containing spaces use CommonMark's angle-bracket destination form. Lurkline accepts active people and bots, rejects deleted users, and fails on missing or ambiguous names with guidance to use an exact ID. A name also fails closed if the bounded user-directory scan is incomplete; an exact active ID already verified in that scan can still succeed.
Only this explicit syntax makes message render consult the configured Slack
profile. JSON output includes an ordered outbound_mentions proof containing
the label, supplied reference, resolved user ID, and resolution method. Human
output prints the same fields on mention rows after the fallback text. The
Slack blocks contain structured user elements, so the result can notify the
intended users instead of merely displaying @ text.
The same resolution and fail-before-write behavior applies to root messages, thread replies, text drafts, one-file drafts, draft updates, and their MCP tools. Lurkline resolves every distinct reference in one bounded directory scan and rejects the complete operation before destination lookup or publication if any explicit mention cannot be proven.
Manage drafts
List or inspect active drafts:
lurkline drafts list --limit 25
lurkline drafts get DR123 --json
Create a root-message draft from standard input:
printf '%s\n' 'Review **release 0.8.2**.' \
| lurkline drafts create platform
Create a thread-reply draft:
printf '%s\n' 'The fix is ready.' \
| lurkline drafts create platform \
--thread-ts 1712345678.000100 \
--broadcast
Create a root-message draft with one private local file:
printf '%s\n' 'Review the attached **release report**.' \
| lurkline drafts create-file platform \
--path ./reports/release.txt \
--title 'Release report' \
--confirm \
--json
To create a one-file thread draft, add --thread-ts. You can also add
--broadcast for a thread reply.
Replace a supported text or one-file draft's content:
printf '%s\n' 'Updated **draft** content.' \
| lurkline drafts update DR123
Delete a draft permanently:
lurkline drafts delete DR123 --confirm
Publish a draft and delete it after Slack acknowledges the message:
lurkline drafts send DR123 --confirm --json
Draft pagination uses private Slack timestamps. Pass the returned next_ts to
--next-ts.
Text drafts are supported when they have one root or thread destination, Slack
rich_text blocks, and no files, attachments, sent state, deleted state, or
unrecognized destination fields. For DM destinations, Lurkline validates
Slack's user_ids participant metadata but routes only by channel_id.
Creation uses a fresh UUID v4 client message ID and accepts only the exact
client ID, destination, empty file set, and authored rich-text blocks. A
mismatched or ambiguous acknowledgement triggers bounded exact active-draft
rereads. If no unique match is proved, Lurkline returns
draft_creation_uncertain with the same client message ID and doesn't retry.
A one-file draft is supported only after drafts get proves all of the
following Slack state:
- A complete scan of active drafts contains the file ID exactly once.
drafts.infoexactly matches that active-draft snapshot, including its route, revision, content, client message ID, workspace/user identity, creation/client metadata, and single file ID. Authored rich-text content must match exactly; Lurkline ignores only Slack's bounded top-levelblock_id.files.infoexplicitly reports a non-external private file with no public URL, conversation membership, or shares.- Slack returned complete, explicitly empty channel, private-channel, DM, and share metadata. Omitted or truncated metadata isn't proof.
drafts list doesn't perform this workspace-wide proof for every row. A
one-file row remains unsupported with file_association: "unverified" until
you fetch it with drafts get. A proved exact read returns
file_association: "verified". The proof scans at most 10 pages of 100 active
drafts. Incomplete pagination, duplicate ownership, multiple files,
attachments, unrecognized draft fields, a changed route or revision, and
shared or public files fail closed. Lurkline leaves unsupported drafts
unchanged.
drafts create-file uploads and privately completes one file without a
destination, creates one draft with that exact file ID, and then performs the
same cross-process proof. It never retries allocation or draft creation after
an ambiguous result. Use the returned stage to recover:
| Stage | Meaning | Recovery |
|---|---|---|
allocation_uncertain | Slack might have allocated storage, but no safe file ID was returned. | Don't retry automatically. |
allocated | Slack returned a file ID, but Lurkline sent no bytes because the upload URL was missing or unsafe. | Keep the file ID for diagnosis. Retry only deliberately. |
source_changed | The local source changed after allocation. | Stabilize the source, then retry deliberately. |
transfer_uncertain | Slack byte acceptance can't be proven. | Inspect Slack before deciding whether to retry. |
file_completion_uncertain | Slack received the bytes, but private completion can't be proven. | Inspect the returned file ID. Don't retry automatically. |
draft_not_created | Slack definitively rejected draft creation after private file completion. | The returned file ID can identify an unshared orphan. Create another draft only deliberately. |
draft_creation_uncertain | Draft creation might have succeeded, but exact exclusive ownership can't be proven. | Inspect the returned file and client message IDs. Don't retry automatically. |
created | Complete Slack state proves the exclusive one-file draft association. | No recovery is required. |
Updating a proved one-file draft sends the exact same file ID and reproves the
new revision. An ambiguous update returns draft_mutation_uncertain and isn't
retried. Deleting a proved one-file draft sends one deletion request with file
preservation enabled (skip_file_deletion=true), so a successful result
reports file_deleted: false.
Lurkline reports success only after Slack acknowledges deletion or a bounded
reread proves the draft absent. Text and one-file draft deletions return
draft_mutation_uncertain when an ambiguous outcome can't be reconciled.
Publishing a one-file draft uses Slack's browser files.share contract with
the exact draft and file IDs plus a fresh UUID v4 client message ID. Lurkline
doesn't retry that request. It reads the exact message and file state before it
reports success. Slack normally removes the draft atomically; if the draft
remains after a proved publication, Lurkline issues one file-preserving cleanup
request. If publication can't be proven, Lurkline returns
publication_uncertain. A post-success cleanup failure returns the sent
message with a warning and never reposts it.
Slack's draft methods are private and more likely to change than documented Slack methods. Refresh Lurkline or the browser credentials if Slack changes their contract.
Send messages
Send a root message from standard input:
printf '%s\n' 'Release **0.8.2** is ready.' \
| lurkline message send platform --confirm --json
Reply to a thread:
printf '%s\n' 'Verified on Linux and macOS.' \
| lurkline thread reply platform 1712345678.000100 --confirm
Add --broadcast to publish the reply in the conversation as well:
printf '%s\n' 'Verified on Linux and macOS.' \
| lurkline thread reply platform 1712345678.000100 \
--broadcast \
--confirm
Root publication doesn't accept --broadcast. All direct sends use the same
bounded Markdown renderer as message render.
Handle an uncertain publication
A timeout, transport error, HTTP error, oversized acknowledgement, or malformed
acknowledgement can occur after Slack accepts a message. Lurkline reports this
state as publication_uncertain and includes the generated client_msg_id.
Don't retry automatically. Search or inspect the destination in Slack first. Retry only after you determine that the original client message ID wasn't published.
Use the MCP server
The server supports MCP 2026-07-28 discovery and existing clients that use the 2025-11-25 initialization handshake. Both use the same tools and write guards.
Start the read-only stdio server with a stored profile:
lurkline --profile work mcp
The following MCP client configuration keeps writes disabled:
{
"mcpServers": {
"lurkline": {
"command": "lurkline",
"args": ["--profile", "work", "mcp"]
}
}
}
To enable write tools, explicitly add --allow-write:
{
"mcpServers": {
"lurkline": {
"command": "lurkline",
"args": ["--profile", "work", "mcp", "--allow-write"]
}
}
}
To expose local file transfers, configure one absolute file root:
{
"mcpServers": {
"lurkline": {
"command": "lurkline",
"args": [
"--profile",
"work",
"mcp",
"--file-root",
"/Users/me/SlackDownloads"
]
}
}
}
MCP lists the download tool only when a file root is configured. Download and
upload paths must be relative to that root. Lurkline opens the root once at
server startup and rejects path escapes, replacements, and symbolic links.
Downloads don't require --allow-write because they don't mutate Slack.
Lurkline lists the upload and one-file-draft creation tools only when both capabilities are configured. To enable them, configure the file root and the write gate:
{
"mcpServers": {
"lurkline": {
"command": "lurkline",
"args": [
"--profile",
"work",
"mcp",
"--file-root",
"/Users/me/SlackTransfers",
"--allow-write"
]
}
}
}
Each slack_upload_file call must also set confirm to true. Its path
field names one regular file beneath the configured root; file bytes never
enter the MCP JSON payload.
Each slack_create_file_draft call also requires confirm: true. Its
markdown field contains the draft text, and its relative path identifies
the single local file. Updating, deleting, or publishing an existing one-file
draft doesn't require --file-root because those operations use the file
already stored in Slack.
Omit --profile work to use LURKLINE_PROFILE or the registry default. The
MCP server and CLI resolve credentials through the same path.
slack_list_later is available in read-only mode. The
slack_save_for_later, slack_complete_later, and
slack_remove_from_later tools require both --allow-write and
confirm: true; they use the same bounded state scan and exact readback as the
CLI.
The following table maps common tasks to CLI commands and MCP tools:
| Task | CLI command | MCP tool |
|---|---|---|
| Validate the browser session | lurkline doctor | slack_doctor |
| Render Markdown and resolve explicit mentions | lurkline message render | slack_render_markdown |
| List explicit unread state | lurkline unreads | slack_list_unreads |
| Read an unread inbox snapshot | lurkline inbox | slack_read_inbox |
| Read bounded recent activity | lurkline activity | slack_read_activity |
| Review Slack Later | lurkline later list | slack_list_later |
| Save, complete, or remove a Later item | lurkline later save, complete, remove | slack_save_for_later, slack_complete_later, slack_remove_from_later |
| List or find conversations | lurkline conversations | slack_list_conversations, slack_find_conversations |
| Search messages | lurkline search messages | slack_search_messages |
| Read conversation history | lurkline channel read | slack_read_channel |
| Read a thread | lurkline thread read | slack_read_thread |
| Fetch an exact message | lurkline message get | slack_get_message |
| Get or download a file | lurkline files info, download | slack_get_file, slack_download_file |
| Upload a file | lurkline files upload | slack_upload_file |
| List custom emoji | lurkline emoji list | slack_list_custom_emoji |
| Add or remove a reaction | lurkline reactions add, remove | slack_add_reaction, slack_remove_reaction |
| Find workspace users | lurkline users find | slack_find_users |
| List or inspect drafts | lurkline drafts list, get | slack_list_drafts, slack_get_draft |
| Create or update a text draft | lurkline drafts create, update | slack_create_draft, slack_update_draft |
| Create a one-file draft | lurkline drafts create-file | slack_create_file_draft |
| Delete a draft | lurkline drafts delete | slack_delete_draft |
| Publish a draft | lurkline drafts send | slack_send_draft |
| Send a root message or reply | lurkline message send, thread reply | slack_send_message |
The server writes only MCP protocol traffic to stdout and diagnostics to
stderr. Every tool has a structured input and output schema. Tool annotations
identify read-only and destructive operations. Publication-uncertain MCP errors
include client_msg_id as a structured field.
Limits
The following table lists primary and auxiliary bounds:
| Operation | Primary bound | Auxiliary discovery bound |
|---|---|---|
| Markdown input | 40,000 bytes | One user-directory scan only when explicit slack-user: mentions are present |
| Draft list | One page of 100 | No conversation discovery |
| Slack Later list | One page of 50; complete output capped by LURKLINE_MAX_RESPONSE_BYTES | 20 conversation pages, one shared user scan when needed, one grouped source-message request, and at most one grouped thread-root request |
| Slack Later mutation proof | One exact message identity | 20 pages of 50 per non-empty Later state before and after the write |
| One-file draft proof | One file and one destination | 10 active-draft pages of 100; six bounded reads per reconciliation phase, with at most 7.75 seconds of draft-state delay |
| Conversation list | One page of 200 | Up to 20 user pages of 200 for DMs |
| Conversation find | 100 | 20 conversation pages and 20 user pages of 200 |
| Message search | One page of 100 | With --in: 20 conversation pages; exact names can also scan 20 user pages |
| Unreads | Every explicit unread count in the snapshot | One scan of up to 20 conversation pages and, only for matched DMs, one shared scan of up to 20 user pages |
| Inbox | 50 conversations; one history page of 200 each; complete output capped by LURKLINE_MAX_RESPONSE_BYTES | 20 conversation pages and one shared scan of up to 20 user pages when DM naming or author resolution needs it |
| Recent activity | 100 returned messages from one newest-200-message sample for each of up to 50 conversations in the current scope slice; complete output capped by LURKLINE_MAX_RESPONSE_BYTES | Per continuation: 20 conversation pages, one shared scan of up to 20 user pages, and exactly one reply-inclusive history request per selected conversation; scope cursors traverse the full bounded eligible directory |
| Channel history | One page of 200 | Exact names can scan 20 conversation and 20 user pages; IDs skip discovery |
| Thread replies | One page of 200 | Exact names can scan 20 conversation and 20 user pages; IDs skip discovery |
| Exact message | One message | Exact names can scan 20 conversation and 20 user pages; IDs skip discovery |
| File metadata | One file | No discovery for a file ID |
| File download | 100 MiB default; 1 GiB hard limit | Path: 4,096 bytes, 64 components, 255 bytes each; three validated redirect hops |
| File upload | 100 MiB default; 1 GiB hard limit | One regular file; path: 4,096 bytes, 64 components, 255 bytes each; UTF-8 filename/title: 255 bytes; alt text: 1,000 bytes |
| Custom emoji | 10,000 | No pagination |
| Message reactions | 100 reaction names; 1,000 returned users each | User list can be partial |
| User find | 100 | 20 user pages of 200 |
Slack-provided opaque cursors are limited to 2,048 non-control characters. Locally issued activity continuation cursors are limited to 8,192 bytes. Slack Later continuation cursors are limited to 256 KiB and retain at most 1,000 prior identities so overlap checks cover every earlier page within the mutation scan bound. Repeated response cursors fail instead of creating pagination loops. Result JSON reports continuation or scan truncation when the operation supports it.
Configure request controls
The following environment variables configure request limits:
| Variable | Default | Accepted range |
|---|---|---|
LURKLINE_TIMEOUT_MS | 15000 | 500–120000 |
LURKLINE_MAX_RESPONSE_BYTES | 8388608 | 16384–67108864 |
Security model
The browser token and d= cookie carry your Slack user authority. Lurkline:
- Stores credentials in owner-only local files or reads a complete process-environment override.
- Stores only normalized credential fields, never the copied cURL request.
- Zeroizes owned secret buffers where practical and redacts diagnostics.
- Sends browser Web API method credentials only to the configured single-label
*.slack.comworkspace origin. - Uses a separate file client, sends the token and cookie only on the first
exact
https://files.slack.comdownload request after live validation, and strips both from redirects. - Sends upload bytes through a separate credential-free client only to a
validated
https://files.slack.com/upload/v1/...URL, with redirects disabled. - Never prints, returns, or persists a signed upload URL.
- Rejects API redirects, validates file redirects, and bounds request input, response output, and streamed file bytes.
- Keeps MCP writes disabled unless the operator passes
--allow-write. - Requires per-call confirmation for publication, deletion, Later, reaction, file-upload, and file-draft creation mutations.
- Escapes control characters in human-readable output.
- Keeps MCP protocol output separate from diagnostics.
Environment variables remain visible to processes with sufficient access to your operating-system account. Protect the process environment accordingly. Credential files are also readable by processes running as your user. Use FileVault or equivalent full-disk encryption and protect your user session.
Treat Slack messages, links, files, and drafts as private, untrusted content. An agent must not follow instructions found in Slack content without separate user authorization.
Never commit real HAR files, copied cURL commands, credentials, workspace
messages, drafts, or user data. The repository ignores *.har, .env, and
.env.*.
Unsupported behavior
Lurkline doesn't provide:
- Automatic browser credential extraction or refresh.
- Bot or OAuth authentication.
- Credential helper protocols or external helper execution.
- Arbitrary Block Kit, attachment authoring, multiple files in one draft, or multi-destination drafts.
- Sent-message editing or deletion.
- Batch uploads, snippets, remote files, public file links, standalone file deletion, scheduled messages, workflows, or canvases.
- Conversation creation.
- Automatic retry after an uncertain publication.
- Local caching, unread-state persistence, or background synchronization.
- A stability guarantee for Slack's private browser endpoints.
Synthetic fixtures cover protocol behavior without committing real workspace data.
Develop Lurkline
Verify a development checkout:
cargo fmt --all -- --check
cargo clippy --all-targets --locked -- -D warnings
cargo test --locked --all-targets
cargo build --release --locked
python3 scripts/check-no-secrets.py
rustup run 1.88.0 cargo check --locked --all-targets
CI runs these gates on Linux, repeats all tests on macOS ARM64, and checks the declared Rust 1.88 minimum. Tagged release builds start only after the exact tagged source passes that reusable workflow.
License
Lurkline is available under the MIT License.
Collected info
- ★ 4 stars
- Language: Rust
- Source updated: 9/11/2026
Config for your environment
Replace {MCP_ENDPOINT_URL} with this MCP’s endpoint URL (from its repo or docs above). No API key — you connect directly.
Tool
OS
Config file: ~/.cursor/mcp.json
{
"mcpServers": {
"mcp-server": {
"url": "{MCP_ENDPOINT_URL}"
}
}
}Paste into mcpServers in the config file. Restart Cursor after saving.
If this MCP is also published on mcpchannel.ai, you can subscribe from Browse and use the gateway config there instead.