skills/zoom-meeting-slack-recap/SKILL.md
Post a Zoom meeting recap (TLDR, summary link, recording link, password) to a Slack channel via a Slack bot. Designed to be invoked from a cron-scheduled routine that fires ~30 min after a recurring meeting ends. Pulls the most recent Zoom recording, then prompts a configurable Slack target (group of handles or a channel) for any missing details — the recording password if needed, the presenter's Slack handle for crediting, and the TLDR text itself when neither Zoom's AI summary nor the transcript yields a usable one — and posts the assembled recap exactly once. Use when the user says "post the meeting recap to Slack", "publish the Zoom summary", "/zoom-meeting-slack-recap", or wires up a new recurring-meeting routine.
npx skillsauth add dfitchett/agent-skills zoom-meeting-slack-recapInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Post a Slack message recapping a recent Zoom meeting: TLDR, full-summary link, recording link, and (optionally) password.
This skill expects to run inside a cron-scheduled routine. One routine per recurring meeting, scheduled ~30 minutes after the meeting ends. The routine's prompt carries the meeting-specific inputs.
Rotating or missing details — the recording password, the presenter's Slack handle for crediting in the TLDR, and the TLDR text itself when neither AI summary nor transcript is available — are not baked into the routine. Instead, the skill asks a configurable Slack target (prompt_target: either a group of handles via DM or a single channel via threaded reply) and uses the first reply. This avoids storing values in the routine config that change per session.
Single-post guarantee: the skill gathers everything it needs — password, recording link, and a TLDR — before it posts anything to the recap channel. It posts the recap exactly once. There is no "post a placeholder then edit it" behavior. The TLDR is sourced in strict priority order: Zoom AI Companion summary → transcript-derived summary → asking prompt_target for it directly. The only Slack messages sent before the final recap are the prompts to prompt_target for whatever's missing (and a follow-up note if a prompt times out).
The invoking routine prompt must supply:
| Field | Description |
|---|---|
| meeting_id_or_title | Either an exact Zoom meeting topic (e.g. "BMT Team 2 Standup") or a numeric Zoom meeting ID. Used to locate the most recent recording. |
| slack_channel | Channel name (e.g. #bmt-team-2) or channel ID (e.g. C0123ABCDEF) — where the recap is posted. The bot must already be a member. |
| slack_bot_token | A Slack bot token (xoxb-...). See Required bot scopes below. |
| password_protected (optional) | Boolean. true (default) means the Zoom recording requires a password to view — the skill will prompt prompt_target to collect it. Set to false for unprotected recordings; the skill skips the password prompt entirely and the recap omits the password line. |
| prompt_target | Where the skill should ask the human(s) for missing information. Accepts either an array of Slack handles (e.g. ["@derek-fitchett", "@yinka"]) — opens a multi-person DM and polls it — or a single channel name (e.g. "#bmt-team-2") or channel ID (e.g. "C0123ABCD") — posts the question in the channel and polls the resulting thread for the first reply. Used for up to three prompts per run: recording password (if password_protected: true), presenter's Slack handle (when the skill is generating the TLDR), and the TLDR text (when neither AI summary nor transcript is available). Handles must match the Slack workspace's name field, not display names. Channel mode requires the additional bot scopes noted below; the bot must also be in the channel (or have chat:write.public for public channels). |
| prompt_wait_minutes (optional) | How long to wait for a reply at each prompt before falling back. Defaults to 15. Applies to every prompt the skill issues. |
| stale_recording_threshold_hours (optional) | If the matched Zoom recording's start time is older than this many hours, exit silently without prompting anyone or posting. Defaults to 4. Increase for meetings whose recordings frequently take longer to surface, or decrease to be stricter about canceled-meeting noise. |
| header_phrasing (optional) | Template for the first line of the recap. Use the placeholder {title} for the Zoom meeting topic. Defaults to "{title} Recap!". Example overrides: "📝 {title} — Summary", "Recap of {title}", "{title} debrief". |
| custom_note (optional) | One-liner prepended above the message body in the recap (e.g. "Recap for folks who missed today's sync"). |
| footer_cta (optional) | Slack-mrkdwn text appended at the bottom of the recap, after the password line, as a call-to-action (e.g. a link inviting future presenters to submit topics). Use Slack link syntax <url\|display text> for any links. Separated from the password line by a blank line. Omit for no footer. |
If any required field is missing, stop and report which — do not guess.
The Slack app powering this skill needs these OAuth bot scopes:
Always required:
chat:write — post the recap and any promptsusers:read — resolve @handle strings to user IDs (used for handles-mode prompt_target and for resolving the presenter's handle when generating the TLDR)Required if prompt_target uses handles mode:
mpim:write — open a multi-person DM with the prompt recipientsmpim:history — poll the DM for repliesim:write, im:history — same, for the 1-recipient case (Slack uses a 1:1 DM)Required if prompt_target uses channel mode:
chat:write.public — post the prompt in a public channel the bot isn't a member ofchannels:history — read public-channel thread replies via conversations.repliesgroups:history — same, for private channels (the bot must also be invited)If the bot is missing any scope at runtime, the relevant Slack API call returns missing_scope with a list — surface that error verbatim so the user can fix it in the Slack app config and reinstall.
The skill makes every Slack API call inline with curl against slack.com. It does not download or execute any bundled helper scripts at runtime. This is intentional and load-bearing: a helper-script approach gets classified by Claude Code's auto-mode classifier as "code from external" with a credential-exfil pathway (RECAP_BOT_TOKEN flows through it) and gets denied. Inline curls keep every API request visible in the routine's transcript, auditable per call, and addressed to slack.com only.
Do not re-introduce helper scripts. If you find a step tedious, factor the prose, not the runtime — the SKILL.md can document patterns once and reference them, but the actual API calls must be made inline.
Common conventions used throughout the steps below:
-H "Authorization: Bearer ${RECAP_BOT_TOKEN}". The token is read from the environment — never log or echo it.chat.postMessage, chat.update, conversations.open, auth.test) use --data @file.json from a jq-built payload (jq -n --arg ... or --rawfile for message bodies). Never inline JSON with raw shell variable interpolation — Slack message text routinely contains &, *, backticks, and newlines that break naive escaping.*.list, *.history, *.replies) use curl -s -G with --data-urlencode key=value.decoded=$(echo "$raw" | sed -e 's/&/\&/g' -e 's/</</g' -e 's/>/>/g')
bot_user_id=$(curl -s -X POST https://slack.com/api/auth.test \
-H "Authorization: Bearer ${RECAP_BOT_TOKEN}" | jq -r '.user_id')
prompt_target once per runDecide once at the start; the resolved IDs are reused for any/all prompts.
Handles mode (prompt_target is an array like ["derek.fitchett", "russ.jennings"]):
users.list (limit=200 + next_cursor) until you find each handle's user ID. Match case-insensitively on the .name field. Fail loudly if any handle matches 0 users or more than 1.jq -n --arg users "U01ABCD,U02EFGH" '{users: $users}' > /tmp/open.json
POST_CHANNEL=$(curl -s -X POST https://slack.com/api/conversations.open \
-H "Authorization: Bearer ${RECAP_BOT_TOKEN}" \
-H "Content-Type: application/json; charset=utf-8" \
--data @/tmp/open.json | jq -r '.channel.id')
THREAD_PARENT="" — handles mode polls DM history with oldest=<prompt_ts>, not threads.Channel mode (prompt_target is "#channel-name" or a raw "C0123ABCD"/"G0123ABCD" ID):
#, paginate conversations.list (types=public_channel,private_channel) and match .channels[].name to get the ID. Otherwise the ID is the value itself.POST_CHANNEL = <resolved channel ID>.THREAD_PARENT is set per-prompt to that prompt's ts (so replies stay in-thread).Used by step 3 (password), step 4a/4b (presenter handle), and step 4c (TLDR). Given a QUESTION string and the resolved POST_CHANNEL:
# 1. Post the question
jq -n --arg channel "$POST_CHANNEL" --arg text "$QUESTION" \
'{channel: $channel, unfurl_links: false, text: $text}' > /tmp/q.json
PROMPT_TS=$(curl -s -X POST https://slack.com/api/chat.postMessage \
-H "Authorization: Bearer ${RECAP_BOT_TOKEN}" \
-H "Content-Type: application/json; charset=utf-8" \
--data @/tmp/q.json | jq -r '.ts')
# Channel mode only: THREAD_PARENT="$PROMPT_TS"
# 2. Poll every 30s for the first non-bot reply, up to prompt_wait_minutes
max_iter=$(( ${prompt_wait_minutes:-15} * 60 / 30 ))
reply_raw=""
for i in $(seq 1 "$max_iter"); do
if [ -n "$THREAD_PARENT" ]; then
# Channel mode: thread replies
curl -s -G https://slack.com/api/conversations.replies \
-H "Authorization: Bearer ${RECAP_BOT_TOKEN}" \
--data-urlencode "channel=$POST_CHANNEL" \
--data-urlencode "ts=$THREAD_PARENT" \
--data-urlencode "limit=20" > /tmp/h.json
reply_raw=$(jq -r --arg bot "$bot_user_id" --arg parent "$THREAD_PARENT" \
'[.messages[]? | select(.user != $bot and .ts != $parent and (.text // "") != "")] | sort_by(.ts) | .[0] // empty | .text' \
/tmp/h.json)
else
# Handles mode: DM history newer than the prompt
curl -s -G https://slack.com/api/conversations.history \
-H "Authorization: Bearer ${RECAP_BOT_TOKEN}" \
--data-urlencode "channel=$POST_CHANNEL" \
--data-urlencode "oldest=$PROMPT_TS" \
--data-urlencode "limit=20" > /tmp/h.json
reply_raw=$(jq -r --arg bot "$bot_user_id" \
'[.messages[]? | select(.user != $bot and (.text // "") != "")] | sort_by(.ts) | .[0] // empty | .text' \
/tmp/h.json)
fi
if [ -n "$reply_raw" ] && [ "$reply_raw" != "null" ]; then break; fi
reply_raw=""
sleep 30
done
# 3. Decode HTML entities; reply is empty on timeout
if [ -n "$reply_raw" ]; then
reply=$(echo "$reply_raw" | sed -e 's/&/\&/g' -e 's/</</g' -e 's/>/>/g')
else
reply=""
fi
On timeout (empty reply), optionally post a short follow-up via chat.postMessage so recipients aren't left hanging — same endpoint, same POST_CHANNEL, add thread_ts=$THREAD_PARENT in channel mode.
Use the Zoom MCP connector. Prefer these tools in order:
recordings_list — list recordings for the authenticated user, newest first. Match on topic against meeting_id_or_title (case-insensitive, exact match) OR on numeric meeting ID.search_meetings — fallback if recordings_list doesn't surface a match (e.g. for a non-host's meeting).You're looking for the most recent recording whose topic or meeting ID matches the input. Capture:
start_time (UTC timestamp of when the recording started)duration (in minutes)share_url or play_url field)If the matched recording's start_time is older than stale_recording_threshold_hours ago (default 4), exit silently — do not DM anyone and do not post. This prevents stale chatter when a meeting was canceled or rescheduled and the cron routine still fired.
Log a one-line note locally (e.g. "Skipping: most recent recording is N hours old (threshold: <T>h)") but don't error and don't message Slack.
prompt_target for the recording password (if password-protected)If password_protected: false, skip this entire step. Set zoom_password = null and move on. The recap will post without a 🔑 line.
Otherwise, run the prompt-and-poll pattern defined above with:
QUESTION = "👋 About to post the recap for *${meeting_topic}* (held ${human_readable_date}) in ${slack_channel}. What's the recording password? Reply with *just the password text* — first reply wins.""No password reply received — posting the recap without it."The decoded reply becomes zoom_password. If empty (timeout), continue with zoom_password="" — the recap will post without the 🔑 line. If the calls return missing_scope, not_in_channel, or similar, surface the error verbatim and abort.
The recap should carry a TLDR. Evaluate three branches in this exact priority order — fall through to the next branch only if the previous one's source is unavailable:
meeting_summary.has_summary is true AND the Quick recap text is non-empty)prompt_target for the TLDR (if BOTH 4a's summary AND 4b's transcript are unavailable)Only one of these branches runs per execution. Never skip to 4c if a usable summary or transcript exists. Never skip 4c if neither exists — the routine must ask the human, not give up silently.
4a. Zoom AI Companion summary (preferred). Call get_meeting_assets with the meeting UUID. If meeting_summary.has_summary is true AND the Quick recap section (inside meeting_summary.summary_markdown / summary_plain_text) is non-empty:
prompt_target for the presenter's Slack handle (see "Asking for the presenter handle" below).<@U…>) wherever it makes sense (typically the lead clause: *<@U123>* presented …).summary_doc_url = the summary document link Zoom returns (for the 📄 line).Zoom AI summary.Otherwise (summary not ready or empty), fall through to 4b.
4b. Transcript-derived TLDR (fallback). Attempt to fetch a transcript via get_meeting_assets (look at meeting_transcript.transcript_items) or get_recording_resource (look at the transcripts array). The transcript counts as available only if it contains substantive text — a missing field, an empty array, or just speaker greetings doesn't count. If a usable transcript exists:
prompt_target for the presenter's Slack handle.<@U…> where it fits.summary_doc_url in this branch — the 📄 line is omitted.transcript-derived TLDR.Otherwise (no usable transcript either), fall through to 4c.
4c. Ask prompt_target for the TLDR directly (last resort, REQUIRED when 4a and 4b both fail). When neither a Zoom AI summary NOR a usable transcript exists (e.g. recording still processing audio, or AI Companion wasn't enabled, or transcript permission missing), the routine must prompt the human via prompt_target rather than posting a TLDR-less recap. Do not skip this step.
Run the prompt-and-poll pattern with:
QUESTION = "🧠 I couldn't find a Zoom AI summary or transcript for *${meeting_topic}* (held ${human_readable_date}). What should the TLDR for the recap in ${slack_channel} say? Reply with the TLDR text — first reply wins.""No TLDR reply received — posting the recap without a TLDR block."The decoded reply is the TLDR text verbatim (treat any <@U…> mentions the replier includes as intentional). No presenter prompt in this branch — the user-supplied TLDR is final. If reply is empty (timeout), post without the TLDR block. Status note: user-supplied TLDR (or no TLDR available on timeout).
Run the prompt-and-poll pattern with:
QUESTION = "🎤 Who presented at *${meeting_topic}* on ${human_readable_date}? Reply with their Slack handle (e.g. \\@aaron.ponce\) so I can mention them in the recap. Reply \\none\ if there was no single presenter.""No presenter handle reply — generating TLDR without an @-mention."Then resolve the decoded reply to a user ID:
reply is empty, none, n/a, or - (case-insensitive): skip the mention.<@U…> (a real Slack mention pasted by the replier): extract the user ID with grep -oE '<@U[A-Z0-9]+>'.@, lowercase, look up via the same paginated users.list you already used to resolve prompt_target (cache the result — don't paginate twice in one run). Fail soft — if no unique match, log a note and skip the mention rather than aborting.Once resolved, the presenter's <@U…> is substituted into the generated TLDR before assembling the message.
Assemble the full message body and post it one time via a single inline chat.postMessage call (see "How the skill talks to Slack" above). Never post more than once and never chat.update to amend it — the gather-everything-then-post-once invariant is non-negotiable.
*<header_phrasing with {title} substituted>*
[custom_note line, if provided]
*Meeting:* <day> <month> <day>, <year> — <h:mm AM/PM> ET / <h:mm AM/PM> PT (<duration> min)
*TLDR:* <tldr from step 4> ← omit this block only if step 4 produced no TLDR
📄 <summary_doc_url|*Full summary*> ← omit if no summary_doc_url (e.g. transcript-derived TLDR)
🎥 <recording-share-url|*Recording*>
🔑 *Password:* `<zoom_password>` ← omit if password is null (`password_protected: false`, or prompt timed out)
<footer_cta text, verbatim> ← omit entire block (including blank line above) if footer_cta is empty
Slack link syntax: <url|display text> makes the display text clickable while hiding the raw URL. Wrap the password in backticks so characters like * or & don't trigger Slack formatting.
Header: Substitute {title} in header_phrasing with the Zoom meeting's topic field. Default "{title} Recap!" yields e.g. *Engineering CoP Recap!*.
Meeting line / timezone handling: Convert the recording's UTC start_time to both Eastern and Pacific local times and show them on one line — e.g. Thu May 14, 2026 — 3:56 PM ET / 12:56 PM PT (34 min). Use date -d "<start_time>" -u plus TZ=America/New_York date -d "..." and TZ=America/Los_Angeles date -d "..." (or TZ=… date -j -f on macOS) to compute each.
Write the assembled body to a file, then make a single inline chat.postMessage call. jq --rawfile reads the body as one string and escapes it correctly, so any character in the body (&, *, backticks, newlines) survives without manual escaping.
RECAP_FILE=$(mktemp)
cat > "$RECAP_FILE" <<'BODY'
<the assembled message text>
BODY
jq -n \
--arg channel "${slack_channel}" \
--rawfile text "$RECAP_FILE" \
'{channel: $channel, text: $text, unfurl_links: false, unfurl_media: false}' \
> /tmp/recap-payload.json
resp=$(curl -s -X POST https://slack.com/api/chat.postMessage \
-H "Authorization: Bearer ${RECAP_BOT_TOKEN}" \
-H "Content-Type: application/json; charset=utf-8" \
--data @/tmp/recap-payload.json)
if [ "$(jq -r '.ok' <<<"$resp")" != "true" ]; then
echo "chat.postMessage failed: $(jq -r '.error // \"unknown\"' <<<"$resp")" >&2
exit 1
fi
ts=$(jq -r '.ts' <<<"$resp")
Single post, no chat.update, no retry, no follow-up post in the recap channel. Capture ts for the status line in step 6 only.
End the routine with a one-line status:
Posted recap to <channel> (ts=<ts>); Zoom AI summary; presenter <@U…>; password from <handle> in <N>s — happy path, AI summary + presenter + passwordPosted recap to <channel> (ts=<ts>); transcript-derived TLDR; presenter <@U…>; password from <handle> — AI summary wasn't ready, TLDR generated from transcriptPosted recap to <channel> (ts=<ts>); user-supplied TLDR; password from <handle> — neither AI summary nor transcript existed; TLDR came from a prompt_target replyPosted recap to <channel> (ts=<ts>); no password required — password_protected: falsePosted recap to <channel> without password (ts=<ts>); password prompt timed out — no reply to the password question within prompt_wait_minutesPosted recap to <channel> with no presenter mention (ts=<ts>); presenter prompt timed out — TLDR generated without the <@…> mentionPosted recap to <channel> without TLDR (ts=<ts>); TLDR prompt timed out — no summary, no transcript, and no reply to the TLDR questionSkipped: most recent recording is <N>h old (threshold: <T>h) — stale guard fired (no prompts sent)Failed: <reason> — anything elseTo wire up the first routine that uses this skill:
Create a Slack app (one-time) at https://api.slack.com/apps:
prompt_target; channel-mode prompts post in a regular channel and replies go in-thread.)xoxb-… token/invite @<bot-name> in every channel the bot needs to post inprompt_target recipient (handles mode), no special setup is needed beyond the bot being installed in the workspace. For channel mode, invite the bot to the prompt channel.Identify the meeting: confirm the exact Zoom topic string or numeric ID by checking one prior recording via the Zoom MCP. Mismatches here cause the skill to silently skip.
Decide on a prompt_target: either (a) a list of Slack handles for a group DM (e.g. ["derek.fitchett"]) or (b) a channel name/ID for a public thread (e.g. "#engineering-cop"). For each handle, in Slack click the person's profile → look for @<handle> under their display name (lowercase, dotted).
Pick the cron expression: schedule for ~30 minutes after the meeting's typical end time. Example for an 11:00 AM ET Tuesday meeting that runs until ~11:30:
30 16 * * 2 (11:30 AM ET → 16:30 UTC during EDT)Create the routine using /schedule (or mcp__scheduled-tasks__create_scheduled_task). The routine prompt should look like:
Run the zoom-meeting-slack-recap skill for the Engineering CoP meeting.
Skill: https://raw.githubusercontent.com/dfitchett/agent-skills/main/skills/zoom-meeting-slack-recap/SKILL.md
Fetch via WebFetch (or curl), read it first, then follow its workflow end-to-end.
Inputs:
- meeting_id_or_title: "Engineering CoP"
- slack_channel: "#staff-test"
- slack_bot_token: pass to bash as the literal string "$RECAP_BOT_TOKEN" — never substitute or log the value
- prompt_target: ["derek.fitchett"] # OR a channel like "#engineering-cop"
- prompt_wait_minutes: 15
# - password_protected: true # set false to skip the password prompt
# - stale_recording_threshold_hours: 4 # override the default
# - header_phrasing: "{title} Recap!" # override the recap header
# - footer_cta: "..." # optional Slack-mrkdwn footer line
Tools: Zoom MCP (search_meetings, recordings_list, get_meeting_assets, get_recording_resource), Bash.
Store the bot token outside the prompt — e.g. export RECAP_BOT_TOKEN="xoxb-..." in ~/.zshenv (mode 600) so cron/launchd-spawned shells inherit it without the secret appearing in the routine config.
Test it once by triggering the routine manually after a real meeting — verify the prompts arrive (password / presenter / TLDR as applicable), the replies are captured, and the recap lands in the channel.
channel_not_found → bot isn't a member of the target recap channel. Invite it with /invite @<bot-name>.not_in_channel → same fix.invalid_auth → the bot token is wrong, expired, or revoked. Regenerate in the Slack app config.missing_scope → bot is missing one of the required scopes. The error message lists what's needed. Add it in OAuth & Permissions, reinstall the app, grab the new token.Could not uniquely resolve handle "<x>" → the handle doesn't match a single workspace user. Check the spelling (lowercase, no spaces) and that the user is actually in the workspace.prompt_wait_minutes. The recap is posted with the corresponding line omitted (or no TLDR block). Manually edit the message in Slack, or extend prompt_wait_minutes for next time.documentation
Generate a pull request description markdown file from the repository's PR template, auto-filled with context from git diff and commit history. Use when the user says "write a PR description", "fill out the PR template", "create a PR description", "draft a PR", "make a PR doc", or asks you to prepare a pull request. Also trigger when the user says "/pr-description" or references preparing changes for review. Works with any repository that has a PR template.
data-ai
Create GitHub issues using data-driven templates. Supports any issue type via configurable template configs. Use when the user asks to create a GitHub ticket, issue, or support ticket, or when they want to add a new issue template.
data-ai
Example TaskFlow authoring pattern for inbox triage. Use when messages need different treatment based on intent, with some routes notifying immediately, some waiting on outside answers, and others rolling into a later summary.
data-ai
Example TaskFlow authoring pattern for inbox triage. Use when messages need different treatment based on intent, with some routes notifying immediately, some waiting on outside answers, and others rolling into a later summary.