15 KiB
| name | description |
|---|---|
| teams-export | Export Microsoft Teams messages (1:1 chats, group chats, and channel posts) into the Obsidian vault at D:\Repos\Obsidian\00 - Chats\ as markdown. Reads the user-maintained source list at D:\Repos\Obsidian\00 - Chats\sources.yaml to know what to copy. The invoking prompt or automation decides the time range (e.g. last N messages, today, since last export, explicit date range). Triggers: 'teams export', 'export chat', 'export channel', 'copy teams messages', '/teams-export'. |
Teams Export Skill
Copies Microsoft Teams messages into the Obsidian vault as plain markdown.
Read-only against Teams; only writes inside D:\Repos\Obsidian\00 - Chats\.
Source list (authoritative)
File: D:\Repos\Obsidian\00 - Chats\sources.yaml
The user maintains this file by hand. The skill MUST read it on every run and treat it as the single source of truth for what to export. Never invent sources, never write back to this file, never silently skip malformed entries — if an entry is broken or its identifiers can't be resolved, report it to the user and continue with the rest.
Entry shapes (see file for inline examples):
| type | required fields | optional fields |
|---|---|---|
| oneOnOne | alias, email |
chat_id, notes, enabled |
| group | alias, chat_id |
topic, notes, enabled |
| channel | alias, team_id, channel_id |
team, channel, notes, enabled |
Skip any entry where enabled: false.
Resolving identifiers
- oneOnOne:
- If
chat_idis present in the entry, use it directly — DO NOT callm365_create_chat_by_email. 1:1 chatIds are stable per user pair, so cached values stay valid indefinitely and skipping the lookup saves a round-trip per entry. - Only fall back to
m365_create_chat_by_emailwithemailwhenchat_idis missing. If resolution returnsuser_not_found, trym365_search_peoplewith the alias / name to find the correct UPN, then retry. Report the resolved chatId in the run summary so the user can paste it intosources.yaml.
- If
- group: use
chat_iddirectly withm365_list_chat_messages. - channel: try
m365_list_channel_messagesfirst withteam_id+channel_id(expand replies). If it fails with a permission error (403 / "missing ChannelMessage.Read.All"), fall back to the bundled scraper described below.
Listing messages (Graph quirks)
m365_list_chat_messages rejects most $filter combinations. The shape that
works for date-windowed exports is:
filter: lastModifiedDateTime gt <ISO>
orderBy: lastModifiedDateTime desc
Notes:
createdDateTimeonly supports theltoperator on this endpoint.lastModifiedDateTimerequiresorderBy: lastModifiedDateTime desc— otherwise Graph returns "filter must target createdDateTime when orderBy is createdDateTime desc".- Page with
skipTokenuntil the window is fully covered (50-message cap per call). Stop early once messages fall outside the requested range.
Channel fallback: scrape-channel.mjs
Clawpilot's Graph app registration typically does not have
ChannelMessage.Read.All. The fallback uses the user's signed-in Teams web
session via Playwright to capture a bearer token and replay paginated requests
against Teams' internal chat service. Output shape is Graph-compatible so the
same markdown renderer handles it.
Location: C:\Users\dkucinski\.scout\m-skills\teams-export\scrape-channel.mjs
One-time setup (first run only):
- Run the script with
--headed. An Edge window opens controlled by Playwright, using a persistent profile at.pw-profile/. - Sign in to teams.microsoft.com if prompted.
- Click into the target channel and let messages load. The script auto-captures
the bearer token from the first matching network request and caches it to
.token-cache.json.
After that, subsequent runs reuse the profile and cached token (~1 hr lifetime).
When the token expires the script re-captures automatically — --headed is only
needed if AAD requires interactive sign-in again.
Invocation pattern (from this skill, when Graph 403s):
node "C:\Users\dkucinski\.scout\m-skills\teams-export\scrape-channel.mjs" `
--team-id "<team_id>" `
--channel-id "<channel_id>" `
--since "2026-05-21T00:00:00Z" `
--limit 200 `
[--headed] [--refresh-token] [--no-replies]
stdout is JSON { value: [root,...], count, replyCount, capturedAt, endpoint }.
Each root has Graph fields: id, createdDateTime, lastModifiedDateTime,
from.user.{displayName,id}, body.contentType, body.content, attachments,
messageType, subject, plus replies: [reply,...] (same shape, oldest-first).
Replies also include replyToId pointing to their root for cross-referencing.
Threading: replies are on by default. Use --no-replies to suppress them
(roots only). Replies are detected via the messageid=<rootId> suffix in the
chat-service conversationLink, so threaded conversations in the OMR channels
come through correctly.
Name resolution: real users resolve to display names via Teams' profile
endpoint. Service identities (meeting-recording bot, channel-email, webhooks)
may show as User <guid-prefix> or Channel — that's expected and means the
post wasn't authored by a person.
Diagnostics: set $env:SCRAPE_DEBUG="1" to log every Teams API request seen,
which helps when Teams web routing changes and the URL matcher needs updating.
Profile lock recovery: when the scraper aborts with
Failed to create a ProcessSingleton, an orphaned Edge from a previous run is
still holding .pw-profile\lockfile. Find and kill it:
Get-CimInstance Win32_Process -Filter "Name='msedge.exe'" |
Where-Object { $_.CommandLine -like "*pw-profile*" } |
ForEach-Object { Stop-Process -Id $_.ProcessId -Force }
Remove-Item "C:\Users\dkucinski\.scout\m-skills\teams-export\.pw-profile\lockfile" -Force
If Stop-Process reports "Cannot find a process" but tasklist /fi "PID eq <pid>"
still shows it (zombie session), the process is in a different session and
can't be killed from this one — the user needs to reboot or close it from the
session that spawned it. Surface this clearly and skip the channel.
Guardrails for the fallback:
- Never commit
.pw-profile/or.token-cache.json(covered by local.gitignore). These contain auth state. - Treat the captured token as a credential — never echo it to the user, never
write it outside
.token-cache.json. - If capture fails after 5 min, surface the failure and skip that channel; do not block the rest of the run.
Range selection (caller-driven)
The skill does NOT pick a default range. The invoking prompt or automation must specify one of:
last_n— e.g. last 50 messagessince— ISO timestamp; include messages withcreatedDateTime >= sincedate_range— explicit start/end ISO timestampstoday— local-day window in America/Los_Angeles
If no range is supplied, ask the user before exporting anything.
Page through Graph results (50/call cap) using skipToken until the window is
covered. Stop early once messages fall outside the requested range.
Output
Folder: D:\Repos\Obsidian\00 - Chats\YYYY-MM\ (create if missing,
month derived from the message timestamp in America/Los_Angeles).
Filename: <alias>.md — one file per source per month. Append-only:
if the file exists, add new messages under a new date divider; never rewrite
existing content. Deduplicate by message id to avoid double-writing on reruns.
File structure:
---
alias: omr-c3
type: channel
team: OMR
channel: XP - C3 - OMR Reliability Tooling (OMRHealth)
team_id: db3900a4-...
channel_id: 19:134dc34e...@thread.tacv2
---
# omr-c3 — 2026-05
## 2026-05-18
### 09:14 — Daniel Kucinski
Message body in markdown. HTML is converted to markdown; emoji and basic
formatting (bold, italic, lists, links, code) are preserved.
> Reply by Alex Smith — 09:18
> Threaded reply body.
**Attachments:** `dashboard.png`, `notes.docx` *(metadata only — files not downloaded)*
---
### 09:32 — Alex Smith
...
Conventions:
- Headings:
##for date (YYYY-MM-DD),###for each message (HH:mm — Author). - Replies are rendered as blockquotes nested under their parent root message.
- Media simplification: attachments and inline images become a single
**Attachments:** name1, name2line. Do NOT download binaries. - Metadata simplification: keep author, timestamp, and reply structure. Drop reactions, read receipts, mention metadata (just render @Name inline), and raw HTML wrappers.
- Convert message HTML → markdown (preserve links, bold/italic, lists, inline code, code blocks, blockquotes).
- Timestamps in America/Los_Angeles, 24-hour.
- Separate root messages with a horizontal rule (
---).
Auto-nominate new sources (opt-in, read-only)
Off by default. The caller enables it (nominate: true); ad-hoc /teams-export
runs should leave it off so they stay fast. The nightly archive automation
turns it on.
Purpose: sources.yaml is an allowlist, so its failure mode is silent
omission — a new important thread archives nothing and the user never finds
out. This pass closes that gap by surfacing candidates for a one-line opt-in.
This pass NEVER edits sources.yaml. It only reports.
Run it after the export pass, so a failed export never costs the user their nominations (and vice versa).
Steps:
- Build the set of known identifiers from
sources.yaml: everychat_idandchannel_idfrom all three sections — including entries withenabled: false. A deliberately-retired source must never come back as a nomination. - Resolve
oneOnOneentries that have no cachedchat_id. Several entries are identified only byemail, so they have no ID to match against and will be falsely nominated as "new" unless handled. For every 1:1 candidate, get the other participant's email — callworkiq_list_chatswithexpand: "members", orworkiq_get_chaton the candidate — and treat the chat as known if that email matches anemailinsources.yaml(case-insensitive). When you resolve one this way, report itschat_idin the run summary under the existing "newly resolved 1:1 chat_id" item, so the user can cache it and remove the ambiguity permanently. - Read the optional top-level
ignore:list (entries withchat_idorchannel_id). Treat everything in it as known. A missing or emptyignore:is normal — don't error. - Call
workiq_list_chatsordered by most recent activity, paging withskipTokenuntil you reach chats whose last activity predates the run window. Don't page further back than the window. Filter onlastUpdatedDateTimefirst — this is what removes the long tail of dormant chats before you spend any calls counting messages. - For each chat not in the known set, count messages inside the run window. Nominate only if ≥5 messages land in the window — this is what keeps the single-reply meeting chats out. Skip any chat whose in-window messages are all system/bot events (joins, leaves, call records, recording notices) with no human authorship.
- Load the state file (below) and apply the repeat rules.
- Report survivors in the run summary.
State file: D:\Repos\Obsidian\00 - Chats\.nominations.json
The one file this skill may write outside the monthly export folders. Shape:
{
"19:meeting_abc...@thread.v2": {
"topic": "Patch Plan V-Team Sync",
"first_seen": "2026-09-14",
"last_reported": "2026-09-14",
"times_reported": 1
}
}
Repeat rules — these exist so the pass never becomes nagging:
- Report a candidate the first time it's seen.
- Re-report only if it's still unlisted, still active, and ≥7 days since
last_reported. - After
times_reportedreaches 3, stop reporting it permanently. Keep the record so it isn't re-nominated from scratch later. - Prune records for chats that have since been added to
sources.yamlorignore:, so re-adding a source later starts the count fresh.
If the state file is missing or unparseable, treat it as empty and rewrite it — never let a bad state file abort the run.
Report shape — give the user something they can paste, and say why it was nominated:
Nominated (not in sources.yaml):
• "Patch Plan V-Team Sync" — 23 msgs since 09-07, 4 participants (1st time seen)
- alias: patch-plan-vteam
type: group
chat_id: "19:meeting_abc...@thread.v2"
topic: Patch Plan V-Team Sync
• "Lee/Daniel Chat" — 8 msgs since 09-07 (reported 2x, will stop after 1 more)
- alias: lee-daniel
type: group
chat_id: "19:...@thread.v2"
To silence one permanently, add its chat_id under `ignore:` in sources.yaml.
Suggest a kebab-case alias derived from the topic or participant names, and
check it doesn't collide with an existing alias — aliases are filenames and must
stay unique.
Topics are not unique. Distinct chats routinely share a topic (Daniel's
tenant currently has two separate Patch Plan V-Team Sync meeting chats with
different chat_ids). Never assume topic identifies a chat. Always key state,
dedup, and known-set matching on chat_id / channel_id. When two candidates
in the same report share a topic, disambiguate the suggested aliases with a date
or sequence suffix (e.g. patch-plan-vteam-2026-09) and say plainly in the
report that these are two different chats, so the user doesn't assume it's a
duplicate and opt in only one.
Run summary
After every run, print a short summary to the user:
- Sources processed / skipped (with reasons)
- Messages written per source
- Output file paths
- Any errors
- Any newly resolved 1:1
chat_idvalues (so the user can paste them intosources.yamlto skip the lookup next time) - Nominations, if the nominate pass ran and produced any (see above)
Guardrails
- Never write outside
D:\Repos\Obsidian\00 - Chats\. The only non-export file this skill may write is00 - Chats\.nominations.json(nominate-pass state). - Never edit
sources.yaml— not to add a nominated source, not to prune a dead one, not to updateignore:. Report and let the user decide. This is the boundary that keeps the allowlist trustworthy. - Respect MIP sensitivity labels — if a source surfaces classified content, note it in the run summary so the user knows the output file inherits that sensitivity.
- If a
chat_idorchannel_idcan't be resolved (403/404), report it and move on — don't crash the whole run.