slack-vault-sync
activeSyncs a Slack workspace into an Obsidian-style vault as one conversation.json per channel, with attachments and incremental watermarks. Use when syncing Slack into a vault — "sync Slack", "ingest the new Slack messages", "pull the Slack channels", "update the watermarks", "back up the channels", "what came in on Slack", or before an ingest step when client feedback lives in Slack. Covers the first full pull of a workspace (all history + attachments) and every incremental update afterwards, producing one conversation.json per channel in extracted/slack/. Do NOT use for reading an already-synced conversation.json (see slack-conversation-read) or for posting/sending Slack messages.
Quality Score Breakdown
Show checks (11)
- ✓ SKILL.md exists with exact casing 3/3
- ✓ Valid YAML frontmatter 3/3
- ✓ No unexpected frontmatter keys 1/1
- ✓ Name field valid (kebab-case) 2/2
- ✓ Name matches folder name 1/1
- ✓ Description field present 2/2
- ✓ No angle brackets in frontmatter 1/1
- ✓ Folder name is kebab-case 1/1
- ✓ No README.md inside skill folder 1/1
- ✓ Test directory with test-cases.yml exists 2/2
- ✓ Status 'active' is valid 1/1
Show checks (7)
- ✓ Contains action verbs: syncs 2/4
- ✓ Contains trigger indicators: use when, use for 5/5
- ✓ Description is specific and actionable 4/4
- ✓ File types mentioned in description 3/3
- ✓ Description length: 687/1024 chars 2/2
- ✓ Has negative triggers (scope boundaries) 2/2
- ✓ Owner/author specified in metadata 2/2
Show checks (8)
- ✓ Skill body has content 3/3
- ✓ Has step/section structure 4/4
- ✓ Includes examples 5/5
- ✗ Add error handling — what should happen when things fail? 0/4
- ✓ Uses progressive disclosure (references/scripts) 4/4
- ✓ Actionable language: 3/10 verb patterns found 3/3
- ✓ Word count: 1117/5000 2/2
- ✓ All referenced paths exist 3/3
Show checks (10)
- ✓ test-cases.yml exists and parses 3/3
- ✓ 6 should-trigger tests ✓ 4/4
- ✓ 4 should-not-trigger tests ✓ 3/3
- ✓ 3 functional tests ✓ 5/5
- ✓ 2 negative tests ✓ 3/3
- ✓ 1 edge case tests ✓ 3/3
- ✓ Performance baseline documented 2/2
- ✓ All functional tests have ≥2 assertions 2/2
- ✓ All trigger phrases are diverse 2/2
- ✓ All assertions are specific 2/2
Show checks (5)
- ✗ Potential secrets found: Secret/token/password 0/5
- ✓ No injection vectors in frontmatter 3/3
- ✓ Name is not reserved 3/3
- ✓ No suspicious code patterns 2/2
- ✓ External URLs: 2 2/2
Suggestions
- → Add more functional tests — aim for 4-5 covering common scenarios and edge cases
Test Coverage
Slack → vault sync
One JSON file per channel, incremental. extracted/slack/<channel>/conversation.json is
the deliverable: messages in chronological order, replies nested under their parent,
attachments downloaded alongside and referenced by local path.
extracted/slack/
channels.json # roster + per-channel watermark + workspace fingerprint
users.json # id → name/email cache
<channel>/
conversation.json # ⬅ the deliverable
files/ # attachments + MANIFEST.json (+ MISSING.md if something is missing)
INDEX.md # human-readable index, regenerated
conversation.json is the only archive of the conversation: don’t create files for
individual messages, threads, or pages. Binary attachments stay in files/ because they
are distinct documents and are referenced from both the JSON and MANIFEST.json.
Two paths. With a Slack token (preferred) the script does everything: enumerate,
paginate, follow threads, download files. Without a token you go through the MCP
connector, which is slower and does not enumerate. Two modes: backfill (first pass,
everything) and update (only what’s new).
extracted/slack/ is source material, not the source of truth for content: facts
belong in your project’s own topic/record store, routed there by whatever extract/ingest
step your project uses. Keep the narrative source of truth for watermarks in one place in
your project (e.g. a slack-channels.md status doc); channels.json is its machine
mirror.
Path A — Slack API (default)
User token (xoxp-…) in $SLACK_TOKEN or ~/.config/slack-credentials/credentials.json
({"token": "xoxp-…"}, chmod 600). Scopes: channels:history, channels:read,
groups:history, groups:read, im:history, mpim:history, users:read,
files:read. The token is never printed or written into the archive.
S=".claude/skills/slack-vault-sync/scripts/slack_sync.py"
uv run $S fingerprint # auth.test → registers team/team_id in the archive
uv run $S discover # conversations.list → TRUE enumeration of the workspace
uv run $S backfill --all # FIRST PASS: full history + threads + attachments
uv run $S update --all # SUBSEQUENT PASSES: only what's new, from the watermark
uv run $S status --rows # rows ready to paste into your channel-status doc
backfill and update handle everything on their own: paginated history,
conversations.replies for every thread, attachment downloads with the auth header (no
base64 in context), merge by ts, watermark, INDEX.md. They write directly into a
single conversation.json per channel and produce no raw payloads. Re-running them is
safe: the merge neither duplicates nor deletes.
Before launching backfill --all, check the output of discover: it flags channels
you are not a member of (Slack will deny history — you need to join them first) and
estimates the work. A large workspace takes minutes and many calls; --no-files defers
attachments to a second pass, --channel <id> does one channel at a time.
The trade-off to know: replies on old threads
conversations.history does not return replies, and a new reply on an old thread does
not make the parent reappear in history. So update re-checks replies on already-archived
threads up to --thread-lookback-days (default 30). A reply on a thread older than
that is missed until you rerun with a wider window:
uv run $S update --all --thread-lookback-days 120
If the client resurrects old threads (it happens), raise the default or periodically run
an update --thread-lookback-days 0 --channel <id> after a backfill --force.
Path B — MCP (when there’s no token)
Same output, but the agent acts as the transport. It costs context and does not enumerate.
- Workspace:
slack_read_user_profile→fingerprint --team … --user-id …. If the fingerprint doesn’t match, stop: same-named channels can exist in different workspaces. - Channels:
slack_search_channelsis fuzzy search, not a listing — measured on 2026-08-12:query="a"→ 3 channels,query="e"→ 0,query="*"→ 0. Use multiple queries in union (single vowels, known names, domain terms, company name), cross-check withslack_search_public_and_private(after:<date>,sort=timestamp), and confirm against the Slack sidebar. Thenroster-merge --stdinwith a JSON[{id,name,type,purpose}]. - Messages:
slack_read_channel(oldest=<watermark>,limit=100, paginate withcursoruntilpagination_infosays there’s no more) and, for every thread,slack_read_thread. Pass every response verbatim tocapture, which normalizes it and merges it straight intoconversation.json:
uv run $S capture --channel <id> --page <n> <<'SLACK_RAW'
<MCP response pasted verbatim>
SLACK_RAW
uv run $S capture --channel <id> --kind thread --thread-ts <parent_ts> --page 1 <<'SLACK_RAW'
<slack_read_thread response>
SLACK_RAW
- If it prints
⚠️ unrecognized metadata, those are fields the connector emits that the parser doesn’t know: they stay in the message body (nothing is lost), add them toKNOWN_METAand re-run the same response throughcapture— it’s idempotent.parse --allexists only as a compatibility migration for old_raw/payloads that may already be present, and removes them after ingestion. - Attachments:
slack_read_filereturns base64 for binaries, which flows through context. Download only the small ones (file-add --b64-file), declare the rest (file-missing --reason … --permalink …→ ends up infiles/MISSING.md, recovered from the UI). An attachment that is undownloaded but declared is fine; one that is forgotten is not. - Manual watermark:
watermark --channel <id> --ts last --note "how far".
Wrap-up (always, either path)
status --rows→ update your channel-status doc’s table, including channels that came back empty (that’s the point of the placeholder row);- route the content: answers to open records → your ingest step; new requirements → your extract step;
- run your project’s checks GREEN before committing.
Commands
| Command | What it does |
|---|---|
fingerprint | registers/verifies the archive’s workspace — first step, always |
discover | [API] enumerates channels via conversations.list |
backfill | [API] first pass: full history + threads + attachments |
update | [API] incremental from the watermark |
roster-merge · capture · parse | [MCP] manual roster · normalize+merge · legacy migration |
file-add / file-missing | manually recovered attachment / declared missing |
index · watermark · status | regenerate INDEX.md · move the watermark · state and rows |
Verified and unverified tool behavior: references/mcp-notes.md.
Mistakes not to make
| Mistake | Why it burns you |
|---|---|
Hand-writing conversation.json or summarizing messages | the archive diverges from the real messages with nobody noticing; it must only be produced by backfill/update/capture |
backfill instead of update for routine runs | re-reads all of history and wastes rate limit (though the merge won’t duplicate) |
update with the default lookback on old threads | late replies stay out: raise --thread-lookback-days |
Trusting slack_search_channels for the channel list | it’s fuzzy search: new channels stay invisible for days |
Syncing without fingerprint | you pour a different workspace into the client’s archive |
| Moving the watermark by hand “by eye” | you skip messages never read; backfill/update move it themselves to the last archived ts |
| Not updating empty channels in your channel-status doc | the next pass re-reads them from scratch |