Declarative thread sync for Slack, Discord, and Bluesky.
Given a desired thread state (list of message contents), diffs against existing messages and applies minimal edits/posts/deletes to converge.
A TypeScript port (Slack subset) lives on the
tsbranch, published on npm as@rdub/thrds. Both impls sharetests/fixtures/sync.jsonas the cross-language contract for the diff/edit/post/delete algorithm.
pip install thrds # Core only (zero deps)
pip install thrds[bsky] # + Bluesky (atproto)Slack and Discord clients use only stdlib (urllib) and curl subprocess respectively — no extra deps needed.
from thrds import SlackClient, Thread
slack = SlackClient(token="xoxb-...", channel="C0AQC2VKEJF")
thread = Thread(messages=["OP text", "Reply 1", "Reply 2"])
# Create new thread
result = slack.sync(thread)
# Update existing thread (edits changed messages, appends new, deletes extras)
result = slack.sync(thread, thread_ts="1775516040.743629")from thrds import DiscordClient, Thread
discord = DiscordClient(token="your-bot-token", channel_id="1489279547689140505")
thread = Thread(messages=["OP text", "Reply 1", "Reply 2"])
result = discord.sync(thread, thread_id="1490821926288097503")from thrds import BskyClient, Thread
bsky = BskyClient(handle="you.bsky.social", password="app-password")
thread = Thread(messages=["Root post", "Reply 1"])
result = bsky.sync(thread)Bluesky doesn't support editing posts — the sync algorithm automatically falls back to delete+repost when content changes.
Post summary bullets that link to detail messages in the same thread:
from thrds import LinkedThread, Section
linked = LinkedThread(
summary_prefix="# Daily Digest",
sections=[
Section(title="Topic A", summary="Brief summary", body="Full detail text..."),
Section(title="Topic B", summary="Another summary", body="More details..."),
],
)
# Discord: summary bullets use [**Title**](url) markdown links
result = discord.sync_linked(linked, thread_id="...", guild_id="...")
# Slack: summary bullets use <url|*Title*> mrkdwn links
result = slack.sync_linked(linked, thread_ts="...")Two-phase sync: posts all messages with placeholder links, then edits summaries with real links once message IDs are known.
result = slack.sync(thread, thread_ts="...", dry_run=True)
print(result.format_preview(color=True, prefix="thread: "))thread: SKIP [0] (unchanged)
thread: EDIT [1]
thread: -old message text
thread: +new message text
thread: POST [2]
thread: +entirely new message
Each Action carries prior_content (for EDIT/DELETE) alongside content, enabling colored unified-diff output via action.format().
Given desired messages M and existing thread messages N:
- Delete extras from the end (backwards — replies before OP)
- Edit overlapping messages where content changed (skip unchanged)
- Post new messages at the end
Foreign (non-editable) messages — e.g. human replies in a bot thread — are automatically skipped. The sync only operates on the bot's own messages, leaving everyone else's untouched.
- Foreign message preservation: Non-bot messages in threads are skipped during sync (no more
cant_update_messageerrors) - Rate limit handling: Slack 429 retry with
Retry-After, configurablepaceandjitterbetween API calls - Edit rate limit fallback: Discord's 30046 error (edit limit on old messages) triggers automatic delete+repost
- Linked summary threads:
sync_linked()for summary-with-links threads on Discord and Slack - Diff preview:
Action.format()andSyncResult.format_preview()for colored diff output - Orphan guard: Slack
delete()checks for thread replies before deleting (raisesOrphanedRepliesError) - Unfurl/embed suppression: Slack link previews and Discord embeds suppressed via options
- Discord system message filtering: Thread starter messages filtered from
list_messages - Bot token prefix: Discord
Botprefix auto-prepended - Metadata support: Slack message metadata passthrough
thrds ships a CLI split into platform subgroups. Today: thrds slack … (the primary workflow) and thrds capture … (gist-only trajectory, no platform target — for drafting posts you'll paste manually while still capturing iteration history). One session per .md file lives in <git-root-or-cwd>/thrds/<slug>/ with its own private git repo and (default) a secret gist mirror.
thrds slack … — draft multi-thread Slack posts locally, sync to a staging private channel, promote to a real prod channel:
thrds slack init draft.md # scaffold session dir + gist mirror
thrds slack push # sync to staging PC (terraform)
thrds slack pull --write # pull edits back → .md
thrds slack push --prod --channel #foo # sync to prod (additive)
thrds slack diff --prod --channel #foo # see what would change
thrds slack archive # archive the staging PC
thrds slack list-sessions #foo # what thrds sessions exist in #foo
thrds slack recover -i <sid> #foo # rebuild a lost session from Slack metadatathrds slack … also exposes low-level CRUD verbs for ad-hoc operations (finding a message's ts, deleting a test post, posting one-off mrkdwn) — a first-class alternative to hand-rolled chat.* heredocs. All CRUD verbs default to raw mrkdwn (send verbatim); pass -m to opt into local-md → Slack-mrkdwn conversion (the opposite of the session verbs' default — see raw-mrkdwn-passthrough).
thrds slack history #foo -n 10 # last 10 messages (ts, sender, text)
thrds slack thread #foo 1783.1 # OP + replies as a table (`-j` = JSON)
thrds slack rm #foo 1783.1 1783.2 # delete msg(s); `-f` = orphans_ok
thrds slack post #foo '*bold*' # raw mrkdwn (`-m` = convert md first)
thrds slack post #foo 'hi' -u 'Bot' -i https://cdn.example/a.png -t 1783.0
thrds slack edit #foo 1783.1 'new' # edit; raw by default
thrds slack permalink #foo 1783.1 # get workspace permalink URLthrds capture … — capture-only sessions: same on-disk shape (git repo + gist mirror), no platform posting. Useful when the destination is somewhere thrds doesn't (yet) integrate with, but you still want the doc's iteration history captured to a gist:
thrds capture init draft.md # scaffold session dir + gist mirror (no channel)
# ... edit draft.md ...
thrds capture push # commit doc changes and push to the gist
thrds capture open # browse the gistthrds discord … — capture + MD-compat lint for Discord. Discord asymmetry: prod delivery is copy-paste (self-bots are ToS-prohibited), so there's no push. render prints the doc to stdout (idiomatic: thrds discord render | pbcopy) and auto-runs lint alongside (tables and raw @name — two constructs Discord's user-message renderer drops on the floor; masked links [text](url) do render in normal user messages since Discord's 2023 markdown update):
thrds discord init draft.md # scaffold session dir + gist (no channel/bot)
# ... edit draft.md, iterate ...
thrds discord lint # just the MD-compat warnings
thrds discord render | pbcopy # MD → clipboard (warnings → stderr)
thrds discord open # browse the gistthrds bsky … — same shape for Bluesky. Different lints: bsky's chief drafting pain is the 300-char post limit (per paragraph), so bsky lint flags paragraphs that exceed it. Also warns on masked links (bsky auto-linkifies bare URLs via facets, so [text](url) renders as literal text):
thrds bsky init draft.md # scaffold session dir + gist
thrds bsky lint # length + link warnings
thrds bsky render | pbcopy # MD → clipboard
thrds bsky open # browse the gistEvery session's platform is stamped into thrds.json at init and guarded on every subsequent verb — running thrds slack push inside a capture-inited session errors immediately with a clear message rather than trying and failing halfway through.
The CLI reads the Slack token from THRDS_SLACK_TOKEN (a deprecated alias SLACK_THRDS_USER_TOKEN still works with a one-time warning). Which token type you need depends on which verbs you use:
- User token (
xoxp-…) — needed for the session verbs (slack init/push/pull/diff/archive/list-sessions/recover/open). The session workflow's whole point is "draft locally in.md, sync to a staging PC, tweak the posts in Slack (as you), pull back, push again"; because those in-Slack tweaks are your Slack user's own posts, only a token you own canchat.updatethem. - Bot token (
xoxb-…) — sufficient for theslackCRUD verbs (history/thread/rm/post/edit/permalink) as long as the bot is only editing / deleting its own posts. Also sufficient for programmaticSlackClient.sync()/sync_linked()when the bot owns the content lifecycle end-to-end (bot renders, bot posts, bot reconciles).
Add scopes under OAuth & Permissions — under User Token Scopes for a user token, Bot Token Scopes for a bot token. All scopes have the same name in both places.
| Scope | Needed for | Session verbs | CRUD / sync() |
|---|---|---|---|
chat:write |
Post / edit / delete messages | ✓ | ✓ |
groups:write |
Create + archive staging PCs | ✓ (slack init, push, archive) |
— |
groups:read |
Read + resolve #name for private channels |
✓ | ✓ |
channels:read |
Resolve #name for public channels |
✓ (if pushing/pulling public) | ✓ (public channels) |
users:read |
Resolve foreign-author names on pull |
✓ (pull) |
— |
emoji:read |
Download custom workspace emoji on pull |
✓ (pull) |
— |
chat:write.customize |
Per-message username / icon_url / icon_emoji |
If used | If used (slack post -u/-i/-e) |
reactions:read |
SenderChangePolicy pre-flight (library) |
— | If using aggressive-mode sync |
Metadata visibility is app-scoped (Slack only returns your app's metadata to your app), so slack recover needs no additional scope beyond the ones above.
- hudcostreets/nj-crashes — Slack crash-notification threads (
SlackClient.sync()) - Open-Athena/marin-discord — Discord summary threads (
DiscordClient.sync_linked())
@dataclass
class SyncResult:
thread_id: str # thread_ts (Slack), thread channel ID (Discord), AT URI (Bluesky)
message_ids: list[str] # Per-message IDs
actions: list[Action] # What was done: Edit, Post, Delete, Skip@dataclass
class Action:
type: ActionType # SKIP, EDIT, POST, DELETE
index: int
message_id: str | None
content: str | None # Desired text (POST, EDIT, SKIP)
prior_content: str | None # Previous text (EDIT, DELETE)| Option | Default | Description |
|---|---|---|
dry_run |
False |
Print actions without executing |
pace |
0.0 |
Seconds between mutating API calls |
jitter |
0.0 |
Random additional delay (0 to jitter) added to pace |
suppress_embeds |
False |
Discord: suppress link previews |
suppress_unfurls |
True |
Slack: suppress link previews |