Get a pulse on your newsletter in plain words
Dashboards answer questions you did not ask and make you work out the ones you did. Ask how your newsletter is doing and get a few sentences back: how the list moved, where new readers came from, how the last issue landed and what the community did with it. Or have the same few sentences waiting in Slack every Monday.
Connect your agent once, then ask for the pulse the way you would ask a colleague, and have it arrive every Monday on its own. Commune's tools read your numbers over the window you name; your agent puts them into sentences.
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, tell me how my Commune newsletter did last week (Monday to Monday).
In eight lines or fewer: the net change in subscribers and where the new ones came from, how my latest article did by email and in the community, and how weekly sign-ups compare with the previous seven weeks.
Leave out numbers that are empty rather than calling them zero.
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.
how_is_my_newsletter_doingOne snapshot over the window: subscribers on record and the net change, articles sent and scheduled, conversations, replies, highlights and reactions, and the open and click rates of the email Commune sent. The rates are empty for a newsletter Commune does not send. If the connection only reads, the scheduled count comes back empty and the community numbers cover public conversations only.
where_are_new_subscribers_coming_fromThe arrivals in the window, split by source, largest first: an import's provider,
csvfor a file, or no source for people who joined through Commune.how_did_my_latest_article_doYour newest sent article with its email side (recipients, delivered, opened, clicked, bounced, unsubscribed, each a count of people) and its community side (views, likes, highlights, replies).
chart_one_number_over_timeOne number, such as new subscribers, bucketed by week, oldest first. Each bucket counts what happened in it, not a running total, so it is sign-ups per week, not your list size.
What comes back
Last week, 25 August to 1 September
| Last week | |
|---|---|
| Subscribers | +38 net, 2,184 on record |
| New, by source | 43: 31 joined through Commune, 12 from a CSV import |
| Latest issue | "What newsletters get wrong about community" |
| By email | 2,126 reached, 1,206 opened, 311 clicked |
| In the community | 148 likes, 63 highlights, 27 replies |
- Sign-ups were the best of the last eight weeks, against an average of about 22.
- 4 people unsubscribed after the latest issue.
An example. Yours comes from your own newsletter.
Worth asking next
- How does this week compare with the one before?
- Where did last week's new subscribers come from?
- Which article drove the most replies this month?
Tips
- Name the window. "Last week", "since 1 July" or "the last 90 days" beats the default (the last 30 days) when you are comparing.
- On a newsletter connected to another email provider, subscribers on record are a floor, not your audience size. Ask your provider for the real total.
- Treat open rates as a direction, not a fact. Mail clients that prefetch images inflate them and ones that block images hide them.
- 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 message that lands in Slack every Monday morning: last week's numbers, where new readers came from, an eight-week trend and how the latest issue did, in a few plain sentences. Five requests, then the code that writes the message and runs it on a schedule.
Before you start
- An API key with
insights: read,content: 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.
- Some steps need more than this. Each one lists it where it starts.
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) for the requests that follow, itsnamefor the message, and itsesp: on acommunenewsletter the subscriber counts are the real ones, while on one connected to another provider they are Commune's partial record.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
Read last week's headline numbers
sinceanduntilpin the window to last Monday through this Monday, so the digest describes a whole week and not the last seven days from whenever the job ran. The response echoes the window it resolved inperiod_startandperiod_end.Four groups come back.
audienceis how the list moved:net_changeis gained minus lost inside the week, andby_statusis how everyone on record splits at the end of it.publishingcounts articles, never emails.communityis what readers did on Commune.deliveryhas the open and click rates, and isnullfor a newsletter Commune does not send.A read-only key counts what a reader can see
This page's key only reads, so Commune treats it as a reader of the published newsletter.
audienceanddeliveryare the same as for your team, butpublishing.scheduledisnull(not zero: you may well have articles queued),publishing.sentcovers published articles not restricted to a tag, andcommunitycovers public conversations and their replies only. The discussion under each article is for subscribers, so it is not in these community numbers. Giving the keywritein any family makes it count everything, because Commune then treats it as acting for your team. For a Monday digest, keep the key read-only anyway. It lives in a scheduler's secrets and runs unattended, so it should not be able to change your newsletter, and what the read-only view misses is recovered elsewhere: the latest article's discussion is counted in full in step 6, and the audience, sources and trend are unaffected. The digest labels the community line as public conversations so nobody reads it as the whole picture.cURLcurl "https://api.usecommune.com/newsletters/example-letter/stats?since=2026-08-24&until=2026-08-31" \ -H "Authorization: Bearer $COMMUNE_API_KEY" \ -H "Commune-Version: 2026-08-26"Response 200{ "object": "newsletter_stats", "newsletter": { "object": "newsletter", "id": "7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411" }, "period_start": "2026-08-24T00:00:00Z", "period_end": "2026-08-31T00:00:00Z", "audience": { "known_subscribers": 2184, "net_change": 38, "by_status": { "subscribed": 2131, "unsubscribed": 37, "bounced": 11, "complained": 1, "pending": 4 } }, "publishing": { "sent": 1, "scheduled": null }, "community": { "threads": 2, "messages": 9, "highlights": 71, "reactions": 17 }, "delivery": { "open_rate": 0.569, "click_rate": 0.147 } } - 3
See where the new subscribers came from
Same window, split by acquisition source, largest first.
sourcenames the import that brought people in: a provider's slug, orcsvfor a file.nullis the ordinary value for someone who joined through Commune rather than an import, though it also covers older rows nobody could label, so call it "not from an import" rather than "from Commune".These are arrivals, not an audience size. A source with no arrivals in the window is left out rather than returned as zero.
cURLcurl "https://api.usecommune.com/newsletters/example-letter/growth?since=2026-08-24&until=2026-08-31" \ -H "Authorization: Bearer $COMMUNE_API_KEY" \ -H "Commune-Version: 2026-08-26"Response 200{ "object": "newsletter_growth", "newsletter": { "object": "newsletter", "id": "7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411" }, "period_start": "2026-08-24T00:00:00Z", "period_end": "2026-08-31T00:00:00Z", "by_source": [ { "source": null, "known_subscribers": 31 }, { "source": "csv", "known_subscribers": 12 } ] } - 4
Get the trend behind the week
One week on its own does not say whether 43 new subscribers is good. Ask for the same number bucketed by
weekover the eight weeks before this Monday. Weeks start on Monday in UTC, every bucket is present (an empty week is0), and eachvaluecounts only that week, so the last bucket is last week.Compare the last bucket with the average of the ones before it. That one comparison is most of what a chart would tell you.
cURLcurl "https://api.usecommune.com/newsletters/example-letter/timeseries?metric=subscribers&interval=week&since=2026-07-06&until=2026-08-31" \ -H "Authorization: Bearer $COMMUNE_API_KEY" \ -H "Commune-Version: 2026-08-26"Response 200{ "object": "timeseries", "newsletter": { "object": "newsletter", "id": "7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411" }, "metric": "subscribers", "interval": "week", "period_start": "2026-07-06T00:00:00Z", "period_end": "2026-08-31T00:00:00Z", "buckets": [ { "ts": "2026-07-06T00:00:00Z", "value": 19 }, { "ts": "2026-07-13T00:00:00Z", "value": 24 }, { "ts": "2026-07-20T00:00:00Z", "value": 17 }, { "ts": "2026-07-27T00:00:00Z", "value": 22 }, { "ts": "2026-08-03T00:00:00Z", "value": 26 }, { "ts": "2026-08-10T00:00:00Z", "value": 21 }, { "ts": "2026-08-17T00:00:00Z", "value": 31 }, { "ts": "2026-08-24T00:00:00Z", "value": 43 } ] } - 5
Find the latest issue
status=sentwithlimit=1is the newest article that actually went out. Keep itsidfor the next request and itstitlefor the message. An emptydatameans nothing has been sent yet, and the digest simply leaves the section out.cURLcurl "https://api.usecommune.com/newsletters/example-letter/articles?status=sent&limit=1" \ -H "Authorization: Bearer $COMMUNE_API_KEY" \ -H "Commune-Version: 2026-08-26"Response 200{ "object": "list", "data": [ { "object": "article", "id": "4c9e2f81-0b7a-4d13-8e55-1a2b3c4d5e6f", "short_id": "k7Rm2xQp", "slug": "what-newsletters-get-wrong-about-community", "newsletter": { "object": "newsletter", "id": "7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411" }, "title": "What newsletters get wrong about community", "preview_text": "The moderation load is the product, not a tax on it.", "image_url": "https://cdn.example.com/articles/k7Rm2xQp/cover.png", "external_url": null, "status": "sent", "is_imported": false, "posted_at": "2026-08-26T09:32:11Z", "scheduled_for": null, "authors": [ { "object": "user", "id": "usr_2Nf8Kq1pWc" } ], "thread": { "object": "thread", "id": "b1c2d3e4-f506-4718-8293-a4b5c6d7e8f9" }, "stats": { "likes": 148, "comments": 27, "highlights": 63 }, "created_at": "2026-08-24T11:04:52Z", "updated_at": "2026-08-26T09:32:11Z" } ], "pagination": { "has_more": true, "next_cursor": "Y3Vyc29yOjE3NTY0MjM2MDAwMDA6MDE5MmM4" } } - 6
See how it did
The article measured on both sides.
emailcounts recipients, not events:openedis people who opened at least once, and the rates a provider quotes divide bydelivered, notrecipients. It isnullfor an article your provider sent, because the provider kept those numbers.communityis what readers did on Commune, counted when you ask, so it keeps growing after the send.participantsnext tothread_messagestells you whether 27 replies were a conversation among 19 people or an argument between two.cURLcurl "https://api.usecommune.com/articles/4c9e2f81-0b7a-4d13-8e55-1a2b3c4d5e6f/stats" \ -H "Authorization: Bearer $COMMUNE_API_KEY" \ -H "Commune-Version: 2026-08-26"Response 200{ "object": "article_stats", "article": { "object": "article", "id": "4c9e2f81-0b7a-4d13-8e55-1a2b3c4d5e6f" }, "email": { "recipients": 2126, "delivered": 2118, "opened": 1206, "clicked": 311, "bounced": 8, "unsubscribed": 4 }, "community": { "views": 1580, "likes": 148, "saves": 41, "highlights": 63, "thread_messages": 27, "participants": 19 } } - 7
Say it in plain words and post it
You'll need
- A Slack incoming webhook URL for the channel you want the digest in.
Turn the four answers into a handful of sentences, one per question you would actually ask, and leave out anything that is
nullrather than printing a zero it does not mean. Then post it to a Slack incoming webhook; swap the last call for email, Discord or wherever your team reads on Monday.pulse.mjs// stats, growth, series, latest, performance: steps 2 to 6. const pct = (x) => `${Math.round(x * 100)}%`; const signed = (n) => (n >= 0 ? `+${n}` : `${n}`); const { audience, publishing, community, delivery } = stats; const weeks = series.buckets.map((b) => b.value); const thisWeek = weeks.at(-1); const before = weeks.slice(0, -1); const average = before.length ? before.reduce((a, b) => a + b, 0) / before.length : 0; const lines = [ `*${newsletter.name}, week of ${stats.period_start.slice(0, 10)}*`, `Audience: ${signed(audience.net_change)} this week, ${audience.by_status.subscribed} mailable of ${audience.known_subscribers} on record.`, `New subscribers: ${thisWeek}, against an average of ${average.toFixed(0)} over the previous ${before.length} weeks.`, `Where they came from: ${growth.by_source.map((s) => `${s.known_subscribers} ${s.source ?? "not from an import"}`).join(", ") || "no arrivals recorded"}.`, // scheduled is null for a key that only reads: it then counts public conversations only. `${publishing.scheduled === null ? "Public conversations" : "Community"}: ${community.threads} conversations, ${community.messages} replies, ${community.highlights} highlights, ${community.reactions} reactions.`, `Published: ${publishing.sent} ${publishing.sent === 1 ? "article" : "articles"}${publishing.scheduled === null ? "" : `, ${publishing.scheduled} scheduled`}.`, ]; if (delivery?.open_rate != null) { lines.push(`Email: ${pct(delivery.open_rate)} opened, ${pct(delivery.click_rate)} clicked.`); } if (latest) { const { email, community: c } = performance; lines.push( `Latest issue, "${latest.title}": ` + (email ? `${email.opened} of ${email.delivered} opened, ${email.clicked} clicked, ${email.unsubscribed} unsubscribed. ` : "") + `${c.views} views, ${c.likes} likes, ${c.highlights} highlights, ${c.thread_messages} replies from ${c.participants} people.`, ); } await fetch(process.env.SLACK_WEBHOOK_URL, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ text: lines.join("\n") }), }); - 8
Run it every Monday
Any scheduler works. This GitHub Actions workflow runs the script below at 08:00 UTC every Monday, with the key and the Slack URL kept as repository secrets. The script works out last week from the day it runs, so a late or manual run still reports the same whole week.
.github/workflows/pulse.ymlname: Newsletter pulse 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 pulse.mjs env: COMMUNE_API_KEY: ${{ secrets.COMMUNE_API_KEY }} SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} COMMUNE_NEWSLETTER: ${{ vars.COMMUNE_NEWSLETTER }}
All together
Steps 1 to 7 as one file, with no dependencies. It works out last week's Monday-to-Monday window in UTC, 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;
}
// This Monday at 00:00 UTC, and the Mondays before it.
const DAY = 24 * 60 * 60 * 1000;
const today = new Date();
today.setUTCHours(0, 0, 0, 0);
const monday = new Date(today.getTime() - ((today.getUTCDay() + 6) % 7) * DAY);
const date = (d) => d.toISOString().slice(0, 10);
const until = date(monday);
const since = date(new Date(monday.getTime() - 7 * DAY));
const trendSince = date(new Date(monday.getTime() - 8 * 7 * DAY));
// 1. The newsletter to work on. A key can reach several newsletters. COMMUNE_NEWSLETTER picks one by
// handle; without it, a key that reaches exactly one uses that one.
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.",
);
}
const base = `/newsletters/${newsletter.handle}`;
const week = `since=${since}&until=${until}`;
// 2 to 4. Last week's numbers, where arrivals came from, and the trend.
const [stats, growth, series] = await Promise.all([
api(`${base}/stats?${week}`),
api(`${base}/growth?${week}`),
api(`${base}/timeseries?metric=subscribers&interval=week&since=${trendSince}&until=${until}`),
]);
// 5 and 6. The latest issue and how it did, if anything has been sent.
const { data: [latest] } = await api(`${base}/articles?status=sent&limit=1`);
const performance = latest ? await api(`/articles/${latest.id}/stats`) : null;
// 7. In plain words, to Slack.
const pct = (x) => `${Math.round(x * 100)}%`;
const signed = (n) => (n >= 0 ? `+${n}` : `${n}`);
const { audience, publishing, community, delivery } = stats;
const weeks = series.buckets.map((b) => b.value);
const thisWeek = weeks.at(-1) ?? 0;
const before = weeks.slice(0, -1);
const average = before.length ? before.reduce((a, b) => a + b, 0) / before.length : 0;
const lines = [
`*${newsletter.name}, week of ${since}*`,
`Audience: ${signed(audience.net_change)} this week, ${audience.by_status.subscribed} mailable of ${audience.known_subscribers} on record.`,
`New subscribers: ${thisWeek}, against an average of ${average.toFixed(0)} over the previous ${before.length} weeks.`,
`Where they came from: ${growth.by_source.map((s) => `${s.known_subscribers} ${s.source ?? "not from an import"}`).join(", ") || "no arrivals recorded"}.`,
// scheduled is null for a key that only reads: it then counts public conversations only.
`${publishing.scheduled === null ? "Public conversations" : "Community"}: ${community.threads} conversations, ${community.messages} replies, ${community.highlights} highlights, ${community.reactions} reactions.`,
`Published: ${publishing.sent} ${publishing.sent === 1 ? "article" : "articles"}${publishing.scheduled === null ? "" : `, ${publishing.scheduled} scheduled`}.`,
];
if (newsletter.esp !== "commune") {
lines.push("Subscriber counts are Commune's record of your provider's list, so read them as a floor.");
}
if (delivery?.open_rate != null) {
lines.push(`Email: ${pct(delivery.open_rate)} opened, ${pct(delivery.click_rate)} clicked.`);
}
if (latest) {
const { email, community: c } = performance;
lines.push(
`Latest issue, "${latest.title}": ` +
(email ? `${email.opened} of ${email.delivered} opened, ${email.clicked} clicked, ${email.unsubscribed} unsubscribed. ` : "") +
`${c.views} views, ${c.likes} likes, ${c.highlights} highlights, ${c.thread_messages} replies from ${c.participants} people.`,
);
}
await fetch(process.env.SLACK_WEBHOOK_URL, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ text: lines.join("\n") }),
});Next use cases
See all- API
Build an app every creator can connect
Ship an integration other newsletters install in a few clicks.
Read the use case - AgentAPI
Win back readers before they leave
See who is going quiet while there is still time to reach them.
Read the use case - AgentAPI
Plan your next issue from what readers said
Turn comments, replies and highlights into your next topic.
Read the use case