← Discover MCPs and Agents
l
MCPDeveloper toolsGitHub

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.

  1. Open the workspace, open Developer Tools, and select Network.

  2. Reload Slack and select a successful POST request to /api/client.counts whose URL or form body contains slack_route.

  3. Right-click the request and select Copy > Copy as cURL (bash).

  4. Import the request into a named profile:

    pbpaste | lurkline auth import-curl --profile work
    

    On Linux, use a trusted clipboard reader such as wl-paste:

    wl-paste | lurkline auth import-curl --profile work
    
  5. 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 create and drafts update are explicit CLI write commands.
  • drafts create-file requires --confirm before Lurkline reads standard input or opens the local file.
  • drafts delete, drafts send, message send, thread reply, and reaction add/remove require --confirm.
  • files upload requires --confirm.
  • later save, later complete, and later remove require --confirm before Lurkline performs even the first Slack read.
  • MCP draft mutations and publications require the server's --allow-write option.
  • MCP deletion, publication, Later, reaction, file-upload, and file-draft creation mutations also require confirm: true in 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:

  1. --profile
  2. LURKLINE_PROFILE
  3. 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:

  1. Reads Slack's explicit unread conversation and thread counts.
  2. Selects at most the requested number of unread conversations.
  3. Resolves bounded conversation metadata.
  4. Reads at most the requested recent messages for each selection.
  5. 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_resolution is unresolved; a complete directory did not contain a usable identity.
  • [resolution incomplete]: author_resolution is incomplete; Slack advertised another page after Lurkline reached its 20-page bound, which scans up to 4,000 users.
  • [resolution unavailable]: author_resolution is unavailable; the auxiliary directory request failed.
  • [resolution not attempted]: author_resolution is not_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:

  1. Call files.getUploadURL to allocate a non-secret file ID and a signed upload URL.
  2. Stream the exact bytes to the URL with a separate credential-free client. Require Slack's exact OK - <byte count> acknowledgement.
  3. Call files.completeUpload for the requested root or thread.
  4. Read files.info to prove the requested alternative text, when present, and membership in the requested conversation.
  5. 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:

StageMeaningRecovery
allocation_uncertainSlack might have allocated storage, but no safe file ID was returned.Don't retry automatically. A deliberate retry can leave an unshared orphan.
allocatedSlack 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_changedThe source changed after allocation. Lurkline didn't complete the upload.Stabilize the source, then start a new upload deliberately.
transfer_uncertainSlack byte acceptance can't be proven.Don't upload again automatically. Inspect Slack before deciding.
completion_uncertainThe bytes were sent, but the requested share can't be proven.Inspect the file ID and destination before deciding whether to retry.
sharedSlack 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:

MarkdownSlack output
Paragraphs and line breaksRich-text sections and text elements
*emphasis* and **strong**Italic and bold styles
~~strikethrough~~Strike style
Inline, fenced, and indented codeCode style and preformatted blocks
LinksSlack link elements
Block quotesRich-text quote elements
Ordered and unordered listsNested rich-text lists
HeadingsBold rich-text sections
Raw HTMLLiteral 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:

  1. A complete scan of active drafts contains the file ID exactly once.
  2. drafts.info exactly 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-level block_id.
  3. files.info explicitly reports a non-external private file with no public URL, conversation membership, or shares.
  4. 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:

StageMeaningRecovery
allocation_uncertainSlack might have allocated storage, but no safe file ID was returned.Don't retry automatically.
allocatedSlack 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_changedThe local source changed after allocation.Stabilize the source, then retry deliberately.
transfer_uncertainSlack byte acceptance can't be proven.Inspect Slack before deciding whether to retry.
file_completion_uncertainSlack received the bytes, but private completion can't be proven.Inspect the returned file ID. Don't retry automatically.
draft_not_createdSlack definitively rejected draft creation after private file completion.The returned file ID can identify an unshared orphan. Create another draft only deliberately.
draft_creation_uncertainDraft creation might have succeeded, but exact exclusive ownership can't be proven.Inspect the returned file and client message IDs. Don't retry automatically.
createdComplete 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:

TaskCLI commandMCP tool
Validate the browser sessionlurkline doctorslack_doctor
Render Markdown and resolve explicit mentionslurkline message renderslack_render_markdown
List explicit unread statelurkline unreadsslack_list_unreads
Read an unread inbox snapshotlurkline inboxslack_read_inbox
Read bounded recent activitylurkline activityslack_read_activity
Review Slack Laterlurkline later listslack_list_later
Save, complete, or remove a Later itemlurkline later save, complete, removeslack_save_for_later, slack_complete_later, slack_remove_from_later
List or find conversationslurkline conversationsslack_list_conversations, slack_find_conversations
Search messageslurkline search messagesslack_search_messages
Read conversation historylurkline channel readslack_read_channel
Read a threadlurkline thread readslack_read_thread
Fetch an exact messagelurkline message getslack_get_message
Get or download a filelurkline files info, downloadslack_get_file, slack_download_file
Upload a filelurkline files uploadslack_upload_file
List custom emojilurkline emoji listslack_list_custom_emoji
Add or remove a reactionlurkline reactions add, removeslack_add_reaction, slack_remove_reaction
Find workspace userslurkline users findslack_find_users
List or inspect draftslurkline drafts list, getslack_list_drafts, slack_get_draft
Create or update a text draftlurkline drafts create, updateslack_create_draft, slack_update_draft
Create a one-file draftlurkline drafts create-fileslack_create_file_draft
Delete a draftlurkline drafts deleteslack_delete_draft
Publish a draftlurkline drafts sendslack_send_draft
Send a root message or replylurkline message send, thread replyslack_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:

OperationPrimary boundAuxiliary discovery bound
Markdown input40,000 bytesOne user-directory scan only when explicit slack-user: mentions are present
Draft listOne page of 100No conversation discovery
Slack Later listOne page of 50; complete output capped by LURKLINE_MAX_RESPONSE_BYTES20 conversation pages, one shared user scan when needed, one grouped source-message request, and at most one grouped thread-root request
Slack Later mutation proofOne exact message identity20 pages of 50 per non-empty Later state before and after the write
One-file draft proofOne file and one destination10 active-draft pages of 100; six bounded reads per reconciliation phase, with at most 7.75 seconds of draft-state delay
Conversation listOne page of 200Up to 20 user pages of 200 for DMs
Conversation find10020 conversation pages and 20 user pages of 200
Message searchOne page of 100With --in: 20 conversation pages; exact names can also scan 20 user pages
UnreadsEvery explicit unread count in the snapshotOne scan of up to 20 conversation pages and, only for matched DMs, one shared scan of up to 20 user pages
Inbox50 conversations; one history page of 200 each; complete output capped by LURKLINE_MAX_RESPONSE_BYTES20 conversation pages and one shared scan of up to 20 user pages when DM naming or author resolution needs it
Recent activity100 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_BYTESPer 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 historyOne page of 200Exact names can scan 20 conversation and 20 user pages; IDs skip discovery
Thread repliesOne page of 200Exact names can scan 20 conversation and 20 user pages; IDs skip discovery
Exact messageOne messageExact names can scan 20 conversation and 20 user pages; IDs skip discovery
File metadataOne fileNo discovery for a file ID
File download100 MiB default; 1 GiB hard limitPath: 4,096 bytes, 64 components, 255 bytes each; three validated redirect hops
File upload100 MiB default; 1 GiB hard limitOne regular file; path: 4,096 bytes, 64 components, 255 bytes each; UTF-8 filename/title: 255 bytes; alt text: 1,000 bytes
Custom emoji10,000No pagination
Message reactions100 reaction names; 1,000 returned users eachUser list can be partial
User find10020 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:

VariableDefaultAccepted range
LURKLINE_TIMEOUT_MS15000500–120000
LURKLINE_MAX_RESPONSE_BYTES838860816384–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.com workspace origin.
  • Uses a separate file client, sends the token and cookie only on the first exact https://files.slack.com download 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.