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

How: with your agent (One prompt), or with the API (About 20 minutes for a weekly job).

What you will have:

- The readers who are going quiet, strongest first, with the address to reach each one.
- The same list every Monday, without asking for it.
- A way to see, week to week, who came back.

There are 2 ways to do this. Pick one: with your agent, or with the API.

## With your agent

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 the MCP server at `https://api.usecommune.com/mcp` to your agent and approve the consent screen ([how](https://usecommune.dev/guides/mcp)). Then ask:

```text
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.
```

The tools it uses:

- `who_is_drifting_away`: Finds readers in the two lowest engagement bands (`dormant` and `reader`) 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_read`: Looks up each reader's email address and subscription status, so anyone who already unsubscribed is dropped.
- `what_are_readers_actually_doing`: Reads the recent opens, reads, likes, highlights and replies behind the scores, which is where "what they stopped doing" comes from.

What comes back, for example:

**3 readers are going quiet this week**, strongest first:

| Reader | Activity, last 2 weeks | Last seen | What stopped |
|---|---|---|---|
| dana@example.com | 0, down from 12 | 2 July | Reading issues to the end |
| jo@example.com | 3, down from 21 | 18 Aug | Opening the Thursday issue |
| priya@example.com | 2, down from 15 | 21 Aug | Replying in threads |

- **Back since last week:** sam@example.com read and liked Thursday's issue.
- **Left out:** 1 reader who unsubscribed on Monday.

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.

## With the API

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 from https://usecommune.com/settings/api-keys with: insights: read, audience: read. Export it as `COMMUNE_API_KEY`.
- Node 18 or newer, for the script at the end. Every request also works as the cURL shown, or from the collection.

Every request sends `Authorization: Bearer $COMMUNE_API_KEY` and `Commune-Version: 2026-08-26`. The requests below are also a collection you can import into Postman, Insomnia, Bruno, Yaak or Hoppscotch: https://usecommune.dev/use-cases/win-back-readers/collection.json

### 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` (or `id`, either works in a path): every other request names it.

`GET /newsletters` ([reference](https://api-reference.usecommune.dev/operation/operation-listnewsletters))

```sh
curl "https://api.usecommune.com/newsletters" \
  -H "Authorization: Bearer $COMMUNE_API_KEY" \
  -H "Commune-Version: 2026-08-26"
```

Response `200`:

```json
{
  "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 from `dormant` to `superfan`. `velocity=cooling` keeps the readers whose activity fell, and `status`, repeated, keeps the two lowest bands: the readers you are about to lose.

`expand=subscriber` puts each reader's full subscriber record, email address and subscription `status` included, in the same response. That is one request per page of up to 100 readers, not one more per reader. It needs `audience: read` on the key as well as `insights: read`: without it, the request is refused with `403 insufficient_scope` rather than answered without the addresses.

The list comes back highest `total_score` first. While `pagination.has_more` is `true`, send the same request again with `cursor` set to `pagination.next_cursor`.

`GET /newsletters/{newsletter}/insights` ([reference](https://api-reference.usecommune.dev/operation/operation-listnewsletterinsights))

```sh
curl "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`:

```json
{
  "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": "dana@example.com",
        "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:

```js
// 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](https://docs.slack.dev/messaging/sending-messages-using-incoming-webhooks/); 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.mjs:

```js
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")}`,
  }),
});
```

### 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.yml:

```yaml
name: 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.

```js
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")}`,
  }),
});
```
