Product changelog
A recurring email that tells users what changed in the product, written like a person, sent safely, then reused for social posts. The email is the source of truth for each issue; every other channel is cut down from it.
How this skill runs
- Ask once, up front. On the first run (or when config is missing or a needed value is blank), ask the setup questions in one short batch, write
config.yaml, then continue without further questions. - Then do everything. In
autonomy.mode: autonomous(the default) you pick the items, draft, lint, self-review, screenshot, test send, read the test back, build the audience, dry run, send to everyone, verify, pull opt-outs, write and post the social posts, and archive. No approval step. Each outward action passes a self-review gate first. - Money is the only approval. Anything that costs money waits for the user's explicit yes in chat, in every mode: a send that would go over the provider plan's included quota (
provider.monthly_quota), upgrading the provider plan, buying a domain, paid tools or licenses (for example a paid Remotion company license). Nothing else needs a yes. - Hard stops are not approvals. They halt that one action. Finish whatever else is safe, then report. See Hard stops.
- Report at the end. One summary: the subject and full text that went out, counts (queued, sent, failed, excluded, unsubscribed), delivery sample, the test read-back result, every post with its link, anything skipped and why.
reviewmode keeps the old per-action approval gates. See Review mode.
Every script is a dry run by default. Real sends need explicit flags (--send, --yes-send-to-everyone, --yes-send-broadcast). In autonomous mode you pass them yourself, and only after the matching self-review gate passes. Never send ad hoc mail outside the scripts, because the scripts are what enforce opt-outs.
Step 0: load the config
- Look for
config.yamlin the current project root, then in this skill's folder (or$CHANGELOG_CONFIG). - If there is none, copy
config.example.yamltoconfig.yamlin the project root and ask, in one message:- product name, website, dashboard and docs URLs
- sender name and title, from address, reply-to
- test recipients (names and addresses)
- provider: the Resend audience ID, the env var holding the API key, and the plan's included monthly emails if they know it (
provider.monthly_quota, optional) - unsubscribe mode (
broadcastorown_endpoint) - image hosting: where screenshots go and their public base URL
- optional: cadence, brand link color, and how to post each social channel (
social.post_via) Do not guess any of them. Write the answers intoconfig.yaml, then carry on.
- Any value still set to an UPPERCASE placeholder or a
yourproduct.exampleaddress counts as missing. - Read
autonomy.mode(defaultautonomous),voice.notesandvoice.banned_phrases. Voice settings override the defaults in the references. - Secrets never go in config or in chat. API keys and the unsubscribe signing secret live in environment variables.
Later issues ask nothing. The one optional question is "Anything shipped outside git this time (pricing, partnerships, docs)?" If the user is not around, skip it and work from git and release notes.
Step 1: pick what goes in
Read references/voice-and-format.md first.
- Find the last issue in
paths.issues_dir(folders namedNNN-YYYY-MM-DD-slug). The newest one is the template for the next. - Gather candidates since that date:
git log --since=<date> --no-merges --oneline, merged pull requests, release notes, plus anything the user mentioned. - Keep only user-visible changes. 3 to 5 items, ranked by usefulness to the reader. Lead with the most useful change, the one most readers can use today.
- Check every claim against the live product and website. Never invent a feature, a number or a date. If a claim cannot be verified, drop the item.
- Write the list and order into the issue's
NOTES.mdand move on.
Step 2: draft the email
Create paths.issues_dir/NNN-YYYY-MM-DD-slug/ and write:
subject.txt: benefit-led and product-first ("You can now export reports to Sheets"). Never "Changelog #N".email.html: from templates/email.html (or the last issue), filling every[[...]]marker from config and content.email.txt: the same words as plain text with bare URLs.
Keep {{NAME}} and {{UNSUB_URL}} as literal placeholders; the send scripts fill them per recipient.
Core rules (detail in the reference):
Hi {{NAME}},, then the one-line opener from config, then straight into the lead item.- Each item: what changed, then why it matters, in flowing sentences. No labeled bullets.
- Short sentences, plain words, "you can now" over "we built".
- At most one bold accent per paragraph.
- One screenshot per item at most; the hero item always gets one, and it links to the feature.
- Styled-minimal HTML: 600px wrapper, system font, inline styles, no buttons or banners.
- No "excited to announce" and the rest of the banned list. No em or en dashes if
voice.no_dashesis on. - Optional casual P.S. ("BTW, ...").
- Sign-off from config. The unsubscribe line is the last line, small and muted.
Step 3: screenshots
Follow references/screenshots.md: stage real state on a demo account (no customer data), capture at 2x with Playwright, clip to the panel, name files <issue>-<slug>.png, host them at paths.image_base_url, and confirm each URL returns 200 before any blast.
Step 4: lint and self-review (gate 1: copy)
python3 scripts/lint_email.py ISSUE_DIR --check-links
Fix every failure, then work through this checklist. Fix and re-run until every box passes.
-
lint_email.py --check-linksexits 0. - Read top to bottom as a busy customer: the most useful change is clear in 5 seconds.
- Every item is something a user can see or use today.
- Every claim, number and link matches the live product and site.
- Nothing reads like a press release; it sounds like the sender talking.
- Subject under 60 characters, benefit-led, no "Changelog #N".
- Every image has alt text and its hosted URL returns 200 (or will be deployed before Step 7).
Step 5: test send and read-back (gate 2: delivered copy)
python3 scripts/send_test.py ISSUE_DIR --send # only to test_recipients, [TEST] prefix
python3 scripts/send_test.py ISSUE_DIR --readback # a minute later
Test copies go only to test_recipients and embed images inline if they are not deployed yet. --readback pulls each delivered message from the provider API and checks the last event, the subject, the rendered greeting and unsubscribe line, and that no placeholder survived. Also read the returned HTML yourself once. If anything is off, fix it, re-lint, re-test.
In own_endpoint mode, open the unsubscribe link from the test copy, confirm the opt-out is recorded, then resubscribe the test address.
Step 6: build the audience
Full detail in references/sending.md.
- Build
ISSUE_DIR/audience.jsonlfresh from the user's database ({"email", "name"}per line). Never reuse an old list. - Build
ISSUE_DIR/unsubs.txtfrom the opt-out table. In broadcast mode, first runpython3 scripts/broadcast_unsubs.py --out <issues_dir>/unsubs.txtand copy it in. - Founders, test and demo accounts are in config
exclude; they get test copies instead.
Step 7: send to everyone (gate 3: audience)
Pick the path from unsubscribe.mode. Run the dry run first and check the counts.
Broadcast mode (provider hosts unsubscribe):
python3 scripts/broadcast_sync.py ISSUE_DIR/audience.jsonl # plan
python3 scripts/broadcast_sync.py ISSUE_DIR/audience.jsonl --apply # upsert contacts
python3 scripts/broadcast_create.py ISSUE_DIR --create # draft only
python3 scripts/broadcast_create.py --send BROADCAST_ID # shows the recipient count, refuses
python3 scripts/broadcast_create.py --send BROADCAST_ID --yes-send-broadcast
Own endpoint mode (signed links to your own page):
python3 scripts/blast.py ISSUE_DIR # dry run with counts
python3 scripts/blast.py ISSUE_DIR --send --yes-send-to-everyone
Audience checklist, all must pass before the send flag:
- Gates 1 and 2 passed on this exact copy (nothing changed since the read-back).
- The opt-out list was pulled or built in this run and is non-empty if the product has ever had an unsubscribe.
- Dry-run counts make sense: queued is close to users minus opt-outs minus excluded; no surprise jump against the last issue's
NOTES.md. - Broadcast mode:
broadcast_sync.pyreported no unexpected stale contacts. - Every hosted image URL returns 200.
- Quota: if
provider.monthly_quotais set, pass--sent-this-month N(sum the sent counts in this month's issueNOTES.mdplus other mail on the same plan if known). If the script says the send would go over quota, stop and ask the user; pass--yes-over-quotaonly after their explicit yes.
The one-by-one blast is resumable: if it stops, re-run the same command and it skips addresses already sent.
Step 8: verify and sync
- One by one:
python3 scripts/verify_delivery.py ISSUE_DIR --sample 40. Broadcasts:GET /broadcasts/{id}through the provider API, or the dashboard. - Pull opt-outs back after a day (
broadcast_unsubs.py). If the user keeps opt-outs in their own database and you have write access, import them; otherwise say so in the report.
Step 9: social posts
Write from the final email, following references/social.md:
- X thread: hook tweet starting with
social.hook_prefixthat names every item, no link, strongest screenshot. Then one numbered tweet per item with its link and screenshot. Plus a one-line personal quote-post for a founder. - LinkedIn: one post, hook first line, one short paragraph per item, plain text, image gallery.
- Community (Discord or Slack): opener line, one line per item with a bare link, the thread link once live.
4 to 5 items max, no policy notices, claims match the live site. Run the same banned-phrase and dash checks on social.md (lint_email.py reads it). Then, for each channel with a tool in social.post_via, post it yourself and record the post URL. For channels with no tool, hand the user the final text and images. The founder quote-post is from a personal account, so hand it over unless a tool for that account is configured.
Step 10: video (optional, only on request)
If the user asks, follow references/video.md: a 15 to 25 second motion reel from the issue screenshots, no title slide, big headline per item, screenshots dominate, outro with the product URL. Remotion needs a paid company license above a certain team size; that is a money question for the user.
Step 11: archive and report
In the issue folder keep: subject.txt, email.html, email.txt, the screenshots, social.md, test_results.jsonl, and a NOTES.md with the item list, send date, path used, audience size, sent and failed counts, broadcast ID, post URLs, and unsubscribes after a day. If the product has a public web changelog page (product.changelog_page) and its source is in this repo, add the issue there too. When the user corrects the voice or format, add the lesson to voice.notes in config so the next issue starts from it. Then send the one summary described at the top.
Hard stops
Stop that action, finish everything else that is safe, and report what stopped and why. Never work around a stop.
- Lint failures you cannot fix.
- A broken link or image you cannot fix (still not 200 after deploying or correcting it).
- The test read-back fails and you cannot find the cause.
- Opt-out filtering fails: the opt-out list cannot be pulled or built, the audience query errors, or a script refuses on
unsubs.txt. Never send without it. - Provider errors above the threshold mid-send (
blast.pyexits 2 atprovider.max_failuresorprovider.max_failure_rate). Report the error; the blast is resumable once fixed. - A send that would go over quota, a plan upgrade or any other spend: ask the user (money rule).
- Login, 2FA or re-auth prompts, CAPTCHAs, platform warnings or rate-limit notices on a posting tool, payment or billing forms.
- Anything that would break opt-out or anti-spam law (missing unsubscribe line, resubscribing someone who opted out).
- A claim you cannot verify. Drop it instead of guessing.
Review mode
With autonomy.mode: review, the three gates also need the user's okay:
- Draft gate. Show the subject, the email and the item list in chat. Nothing is sent until the user approves the copy.
- Test gate. Send only to
test_recipients. The user reads it in a real inbox and says go or gives fixes. - Blast gate. Show the dry-run counts (queued, excluded, unsubscribed). Send to everyone only when the user says yes to that specific send in chat. A yes to an earlier step is not a yes to this one.
Social posts are shown before posting, and the item list is proposed before writing. Never pass a send flag before the matching gate.
Answering "how many subscribers?"
Subscribers = users minus opt-outs. Count opt-outs from the user's opt-out table or unsubs.txt (in broadcast mode, run broadcast_unsubs.py first). Give the latest issue's unsubscribe rate too.
Files
config.example.yaml: every company-specific value, plusautonomy,provider.monthly_quotaandsocial.post_via.templates/email.html,templates/email.txt: the starting layout.scripts/:lint_email.py,send_test.py(with--readback),blast.py,broadcast_sync.py,broadcast_create.py,broadcast_unsubs.py,verify_delivery.py. Python 3.9+, stdlib plus PyYAML.references/: voice and format, screenshots, sending, social, video.