--- name: "teams-export" description: "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_id` is present in the entry, use it directly — DO NOT call `m365_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_email` with `email` when `chat_id` is missing. If resolution returns `user_not_found`, try `m365_search_people` with the alias / name to find the correct UPN, then retry. Report the resolved chatId in the run summary so the user can paste it into `sources.yaml`. - **group**: use `chat_id` directly with `m365_list_chat_messages`. - **channel**: try `m365_list_channel_messages` first with `team_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 orderBy: lastModifiedDateTime desc ``` Notes: - `createdDateTime` only supports the `lt` operator on this endpoint. - `lastModifiedDateTime` requires `orderBy: lastModifiedDateTime desc` — otherwise Graph returns "filter must target createdDateTime when orderBy is createdDateTime desc". - Page with `skipToken` until 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):** 1. Run the script with `--headed`. An Edge window opens controlled by Playwright, using a persistent profile at `.pw-profile/`. 2. Sign in to teams.microsoft.com if prompted. 3. 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):** ```pwsh node "C:\Users\dkucinski\.scout\m-skills\teams-export\scrape-channel.mjs" ` --team-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=` 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 ` 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: ```pwsh 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 "` 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 messages - `since` — ISO timestamp; include messages with `createdDateTime >= since` - `date_range` — explicit start/end ISO timestamps - `today` — 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:** `.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:** ```markdown --- 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, name2` line. 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:** 1. Build the set of known identifiers from `sources.yaml`: every `chat_id` and `channel_id` from all three sections — **including entries with `enabled: false`**. A deliberately-retired source must never come back as a nomination. Also collect `email` values from `oneOnOne` entries that have no cached `chat_id`, and treat a candidate as known if it resolves to one of those people. 2. Read the optional top-level `ignore:` list (entries with `chat_id` or `channel_id`). Treat everything in it as known. A missing or empty `ignore:` is normal — don't error. 3. Call `workiq_list_chats` ordered by most recent activity, paging with `skipToken` until you reach chats whose last activity predates the run window. Don't page further back than the window. 4. 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. 5. Load the state file (below) and apply the repeat rules. 6. 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: ```json { "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_reported` reaches **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.yaml` or `ignore:`, 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. ### 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_id` values** (so the user can paste them into `sources.yaml` to 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 is `00 - Chats\.nominations.json` (nominate-pass state). - **Never edit `sources.yaml`** — not to add a nominated source, not to prune a dead one, not to update `ignore:`. 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_id` or `channel_id` can't be resolved (403/404), report it and move on — don't crash the whole run.