Win back readers before they leave
Most unsubscribes are decided weeks before the click. Commune follows how each reader's engagement is moving, so you can see who is cooling off and reach out while they are still listening.
Connect your agent once, then give it the whole job in one prompt: who to find, what to tell you about each reader, and when to do it again. It answers from the same engagement scores the API returns.
Connect once
Add Commune's MCP server to your agent, then approve the consent screen and pick your newsletter. Nothing else to install.
claude mcp add --transport http commune https://api.usecommune.com/mcpAsk
One prompt does the whole job. Paste it as it is, or change the parts that are yours.
Every Monday at 8:00, check my Commune newsletter for readers I'm about to lose.
For each one, give me their email address, what they stopped doing and when they were last active. Put the strongest first, leave out anyone who has unsubscribed, and keep it to the top twenty.
Send me the list, and tell me who from last week's list is no longer on it.
What it uses
Your agent picks these of Commune's tools to answer. You do not name them; they are here so you know where every part of the answer comes from.
who_is_drifting_awayFinds readers in the two lowest engagement bands (
dormantandreader) whose activity in the last fourteen days fell against the fourteen before, strongest first. Somebody who was always quiet is left out: they are not someone you are losing.commune_readLooks up each reader's email address and subscription status, so anyone who already unsubscribed is dropped.
what_are_readers_actually_doingReads the recent opens, reads, likes, highlights and replies behind the scores, which is where "what they stopped doing" comes from.
What comes back
3 readers are going quiet this week, strongest first:
| Reader | Activity, last 2 weeks | Last seen | What stopped |
|---|---|---|---|
| [email protected] | 0, down from 12 | 2 July | Reading issues to the end |
| [email protected] | 3, down from 21 | 18 Aug | Opening the Thursday issue |
| [email protected] | 2, down from 15 | 21 Aug | Replying in threads |
- Back since last week: [email protected] read and liked Thursday's issue.
- Left out: 1 reader who unsubscribed on Monday.
An example. Yours comes from your own newsletter.
Worth asking next
- Draft a short personal note to each of them about the issue they last read.
- Which readers are getting more engaged instead?
Tips
- Write to them one by one, not as another broadcast. A short note that asks what changed does more than an issue they have already stopped opening.
- Scheduling is your agent's: most can run a prompt on a schedule when you ask them to. If yours cannot, ask the same thing each Monday.
Build a job that runs every Monday and puts the list where you will act on it: two requests to find the readers and their addresses, then the code that ranks them, sends the list and runs it on a schedule.
Before you start
- An API key with
insights: read,audience: read, exported asCOMMUNE_API_KEY. - Node 18 or newer, for the script at the end. Every request also works as the cURL shown, or from the collection.
Run it in your client
Every request below, in order, as a collection. Import the file into Postman, Insomnia, Bruno, Yaak or Hoppscotch, set apiKey, and send them one by one.
Every request sends Authorization: Bearer $COMMUNE_API_KEY and Commune-Version: 2026-08-26.
- 1
Find your newsletter
A key reaches one newsletter or several, whichever its owner ticked when creating it. List them and pick yours by
handle(orid, either works in a path): every other request names it.cURLcurl "https://api.usecommune.com/newsletters" \ -H "Authorization: Bearer $COMMUNE_API_KEY" \ -H "Commune-Version: 2026-08-26"Response 200{ "object": "list", "data": [ { "object": "newsletter", "id": "7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411", "handle": "example-letter", "name": "The Example Letter", "description": "A weekly letter about how newsletters and communities fit together.", "esp": "commune", "image_url": "https://cdn.example.com/newsletters/example-letter/avatar.png", "website_url": "https://example.com", "social_links": { "twitter": "https://x.com/exampleletter", "bluesky": "https://bsky.app/profile/exampleletter.bsky.social" }, "language": "en", "chat_create_permission": "subscribers", "allow_non_subscriber_chat": false, "owner": { "object": "user", "id": "usr_2Nf8Kq1pWc" }, "featured_article": { "object": "article", "id": "4c9e2f81-0b7a-4d13-8e55-1a2b3c4d5e6f" }, "created_at": "2025-03-04T10:00:00Z", "updated_at": "2026-08-26T09:32:11Z" } ], "pagination": { "has_more": false, "next_cursor": null } } - 2
Ask for readers who are cooling off, with their addresses
Commune scores every reader with a Commune account on what they did in the last fourteen days (
t1_score) against the fourteen before (t2_score), and places them on a ladder fromdormanttosuperfan.velocity=coolingkeeps the readers whose activity fell, andstatus, repeated, keeps the two lowest bands: the readers you are about to lose.expand=subscriberputs each reader's full subscriber record, email address and subscriptionstatusincluded, in the same response. That is one request per page of up to 100 readers, not one more per reader. It needsaudience: readon the key as well asinsights: read: without it, the request is refused with403 insufficient_scoperather than answered without the addresses.The list comes back highest
total_scorefirst. Whilepagination.has_moreistrue, send the same request again withcursorset topagination.next_cursor.cURLcurl "https://api.usecommune.com/newsletters/example-letter/insights?velocity=cooling&status=dormant&status=reader&expand=subscriber&limit=100" \ -H "Authorization: Bearer $COMMUNE_API_KEY" \ -H "Commune-Version: 2026-08-26"Response 200{ "object": "list", "data": [ { "object": "subscriber_insight", "newsletter": { "object": "newsletter", "id": "7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411" }, "subscriber": { "object": "subscriber", "id": "44556677-8899-4aa1-b2c3-d4e5f6071829", "newsletter": { "object": "newsletter", "id": "7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411" }, "user": { "object": "user", "id": "usr_2xR8kQ4mN7pL1vB9" }, "email": "[email protected]", "status": "subscribed", "source": "commune", "tags": [], "created_at": "2026-02-14T10:31:07Z", "synced_at": null }, "total_score": 88, "community_score": 88, "esp_score": 0, "t1_score": 0, "t2_score": 12, "velocity": "cooling", "status": "dormant", "share_points": 0, "last_action_at": "2026-07-02T09:12:40Z", "synced_to_esp_at": null } ], "pagination": { "has_more": false, "next_cursor": null } } - 3
Put the sharpest falls first
Keep the readers who are still
subscribed: somebody who already unsubscribed is not drifting, they are gone, and mailing them anyway is the one thing this job must never do. Then sort by how far their activity fell,t2_score - t1_score. The top of that list is who to write to today.rank.mjs// insights: every page of step 2's data, with subscribers expanded. const rows = insights .filter((insight) => insight.subscriber.status === "subscribed") .map((insight) => ({ email: insight.subscriber.email, fell_by: insight.t2_score - insight.t1_score, last_action_at: insight.last_action_at, })) .sort((a, b) => b.fell_by - a.fell_by); - 4
Send the list where you follow up
The list is only useful where you will act on it: a CSV for your email tool, a row per reader in your CRM, or a message in the channel where your team works. This posts it to a Slack incoming webhook; swap the last call for whatever you use.
Write to them one by one, not as another broadcast. A short personal note that asks what changed does more than a newsletter they have already stopped opening.
notify.mjsconst lines = rows .slice(0, 20) .map((row) => `• ${row.email}: activity down ${row.fell_by} points, last seen ${row.last_action_at.slice(0, 10)}`); await fetch(process.env.SLACK_WEBHOOK_URL, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ text: `${rows.length} readers are going quiet this week:\n${lines.join("\n")}`, }), }); - 5
Run it every Monday
Scores move daily, and a weekly list is the rhythm most people can act on. Any scheduler works. This is a GitHub Actions workflow that runs the script below at 08:00 UTC every Monday, with the key and the Slack URL kept as repository secrets.
Next Monday's list is also how you see who came back: a reader you wrote to who is no longer on it has warmed up again.
.github/workflows/win-back.ymlname: Win back readers on: schedule: - cron: "0 8 * * 1" workflow_dispatch: jobs: run: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 20 - run: node win-back.mjs env: COMMUNE_API_KEY: ${{ secrets.COMMUNE_API_KEY }} COMMUNE_NEWSLETTER: ${{ vars.COMMUNE_NEWSLETTER }} SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
All together
Steps 1 to 4 as one file, with no dependencies. It follows the cursor through every page, waits out a 429 instead of failing on it, and reports errors with the request id Commune gave them.
const BASE = "https://api.usecommune.com";
const VERSION = "2026-08-26";
const KEY = process.env.COMMUNE_API_KEY;
async function api(path) {
const res = await fetch(BASE + path, {
headers: { authorization: `Bearer ${KEY}`, "commune-version": VERSION },
});
if (res.status === 429) {
await new Promise((r) => setTimeout(r, Number(res.headers.get("retry-after") ?? 5) * 1000));
return api(path);
}
const body = await res.json();
if (!res.ok) {
const { code, message, request_id } = body.error;
throw new Error(`${res.status} ${code}: ${message} (request ${request_id})`);
}
return body;
}
async function* all(path) {
let cursor = null;
do {
const sep = path.includes("?") ? "&" : "?";
const page = await api(cursor ? `${path}${sep}cursor=${encodeURIComponent(cursor)}` : path);
yield* page.data;
cursor = page.pagination.next_cursor;
} while (cursor);
}
// 1. The newsletter to work on. A key can reach several newsletters, so
// COMMUNE_NEWSLETTER picks one by handle; a key that reaches exactly one
// uses it without being told.
const { data: newsletters } = await api("/newsletters");
const wanted = process.env.COMMUNE_NEWSLETTER;
const newsletter = wanted
? newsletters.find((n) => n.handle === wanted || n.id === wanted)
: newsletters.length === 1 ? newsletters[0] : null;
if (!newsletter) {
throw new Error(
wanted
? "This key does not reach " + wanted + "."
: "This key reaches " + newsletters.length + " newsletters. Set COMMUNE_NEWSLETTER to one handle.",
);
}
// 2. Readers you are about to lose, with their subscriber records inlined.
const query = "velocity=cooling&status=dormant&status=reader&expand=subscriber&limit=100";
const insights = [];
for await (const insight of all(`/newsletters/${newsletter.handle}/insights?${query}`)) {
insights.push(insight);
}
// 3. Still subscribed only, sharpest fall first.
const rows = insights
.filter((insight) => insight.subscriber.status === "subscribed")
.map((insight) => ({
email: insight.subscriber.email,
fell_by: insight.t2_score - insight.t1_score,
last_action_at: insight.last_action_at,
}))
.sort((a, b) => b.fell_by - a.fell_by);
// 4. Where you follow up.
const lines = rows
.slice(0, 20)
.map((row) => `• ${row.email}: activity down ${row.fell_by} points, last seen ${row.last_action_at.slice(0, 10)}`);
await fetch(process.env.SLACK_WEBHOOK_URL, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
text: `${rows.length} readers are going quiet this week:\n${lines.join("\n")}`,
}),
});Next use cases
See all- AgentAPI
Plan your next issue from what readers said
Turn comments, replies and highlights into your next topic.
Read the use case - AgentAPI
Find and reward your superfans
Know your most engaged readers, and give them something back.
Read the use case - API
Publish from wherever you write
Write in your CMS, notes app or repository. Commune sends it.
Read the use case