AI skills in use in my daily
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 

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_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 <ISO>
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):

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 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: <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, 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.
  2. Resolve oneOnOne entries that have no cached chat_id. Several entries are identified only by email, 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 — call workiq_list_chats with expand: "members", or workiq_get_chat on the candidate — and treat the chat as known if that email matches an email in sources.yaml (case-insensitive). When you resolve one this way, report its chat_id in the run summary under the existing "newly resolved 1:1 chat_id" item, so the user can cache it and remove the ambiguity permanently.
  3. 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.
  4. 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. Filter on lastUpdatedDateTime first — this is what removes the long tail of dormant chats before you spend any calls counting messages.
  5. 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.
  6. Load the state file (below) and apply the repeat rules.
  7. 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_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.

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_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.