# Tag a reader the moment they act

> A segment you maintain by hand is out of date the day after you build it. Commune can watch for the thing that puts somebody in a group, move the tag the moment it happens, and then say something to them a day or two later, so the segment and the follow-up are the same piece of work.

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

What you will have:

- A tag that maintains itself: readers join it the moment their engagement drops a band.
- One short email, two days later, to the readers who have gone quiet rather than to the whole list.
- A clear answer to who is in the group right now, and a way to stop the whole thing without losing anybody's place in it.

**Warning:** An automation that moves a tag causes an event, because moving a tag publishes one. Commune refuses to start a run whose chain already contains that automation, so a sequence triggered by the tag it applies does not loop. It also means the second automation you were about to build on that tag may never fire, and the last step of this page is how you tell.

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

## With your agent

Your agent can make the tag, write the automation and leave it off until you have read the email. **This route writes.** It creates a tag and a draft automation on your newsletter, and the tag it creates is visible to you in Commune straight away.

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
In my Commune newsletter, make a tag called "Going quiet", then write me
a draft automation: when a reader's engagement drops a band, add that tag,
wait two days, and send them a short note asking whether they still want
the letter. Only when their engagement drops, not when it rises.

Allow somebody to go through it more than once. Do not turn it on, and send
me a test copy of the email first.
```

The tools it uses:

- `commune_read`: Reads the trigger catalog, which is what says that this topic's events are about a subscriber and which properties a filter may name under it. A filter on a property the payload does not carry would match every event.
- `commune_write`: Creates the tag, writes the graph as a draft, sends the test copy, and turns the automation on when you ask. A draft sends nothing.
- `who_is_drifting_away`: Answers how many readers are cooling off right now, which is the size of the group this automation will start reaching.

What comes back, for example:

**Made the tag and wrote the automation. It is off.**

| Piece | What it is |
|---|---|
| Tag | "Going quiet", 0 readers so far |
| Trigger | A reader crossing the engagement ladder |
| Filter | Downwards only, so a reader getting *more* engaged hears nothing |
| Steps | Add the tag, wait 2 days, send one note |

- **Re-entry is on**, so somebody who cools off, comes back and cools off again a year later gets the note again.
- **31 readers are cooling off right now.** They will not be tagged retrospectively: the automation reacts to the next crossing, not to the backlog.
- **One thing to decide before you turn it on:** adding a tag publishes an event of its own, so if you later build something that triggers on this tag, Commune will refuse to run it from this automation's own tagging rather than loop.
- The test copy is in your inbox.

Worth asking next:

- "Turn it on, and tell me next week how many readers it reached."
- "Who is holding the Going quiet tag right now?"
- "Pause it for December, and resume it in January without losing anybody's place."

Tips:

- Say which direction you mean. The event fires on every crossing of the ladder, both ways, and an automation with no filter on it emails people for reading more.
- It reacts to the next crossing rather than to the readers who are already quiet. If you want to reach the existing backlog once, that is a one-off list and a one-off email, not a standing instruction.
- Ask for the tag's name back. A tag is matched in a condition by id, so a second automation that wants to read this one's tag needs the id rather than the name.

## With the API

Four requests and an event. Make the tag, write the graph with a filter so it reacts to the readers you actually mean, turn it on, and ask who is in the group. The event is the one that explains why the next automation you build on this tag may never fire.

Before you start:

- An API key from https://usecommune.com/settings/api-keys with: sending: write, audience: write. Export it as `COMMUNE_API_KEY`.
- A newsletter Commune sends the email for, with a verified sending address. The tagging step would work without one; the email step is what the activation checks.
- An address you can read, for the test copy.

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/tag-readers-on-an-event/collection.json

### 1. Make the tag the automation will apply

A tag step names a tag by `id`, so the tag has to exist first. A step naming a tag that has been deleted is refused: it would do nothing, every run, forever.

The name has to be unique among the newsletter's live tags, so creating one that already exists answers `409` rather than quietly handing you the existing one. `known_subscriber_count` starts at zero and is what you will watch.

It is a tag like any other: you can see it in Commune, apply it by hand, and send an article to it. The automation is just another thing that moves it.

`POST /newsletters/{newsletter}/tags` ([reference](https://api-reference.usecommune.dev/operation/operation-createtag))

```sh
curl -X POST "https://api.usecommune.com/newsletters/example-letter/tags" \
  -H "Authorization: Bearer $COMMUNE_API_KEY" \
  -H "Commune-Version: 2026-08-26" \
  -H "Idempotency-Key: going-quiet-tag-2026-10-09" \
  -H "Content-Type: application/json" \
  -d '{
  "name": "Going quiet"
}'
```

Response `201`:

```json
{
  "object": "tag",
  "id": "cc33dd44-ee55-4f66-8a77-b8c9d0e1f203",
  "newsletter": {
    "object": "newsletter",
    "id": "7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411"
  },
  "name": "Going quiet",
  "known_subscriber_count": 0,
  "is_room": false,
  "retired": false,
  "retired_at": null,
  "created_at": "2026-10-09T15:18:00Z"
}
```

### 2. Write it, filtered to the readers you actually mean

The trigger is a reader crossing a boundary in the newsletter's engagement ladder. That event fires in **both** directions, so an automation on it with no filter would send this email to somebody who had just started reading everything.

`trigger_filter` is what stops that. It is a JSON Schema document in the same closed subset a `condition` step uses, and it decides whether an event starts a run at all. The paths in it are the event payload's own, not the three-branch context a `condition` is evaluated against, because a filter runs before there is a run to have a context. `direction` is `promoted` or `demoted`, and this one keeps the demotions.

`required` is doing real work there. An absent property says nothing in JSON Schema, so `properties` on its own would also match a payload carrying no `direction` at all.

The graph is three steps wired one to the next, and the last one wires nothing, which is what ends the run. `mark-quiet` moves the tag on the subscriber the run is about. `settle` parks the run for two days, written as an ISO 8601 duration: there is no time-of-day field, because Commune does not know a subscriber's time zone and a time of day would be interpreted in UTC. `reach-out` sends the email, with the unsubscribe link and `{{ address }}` that Commune refuses to send without.

`reentry: true` because this is not a welcome. A reader can cool off, come back, and cool off again two years later, and the note is worth sending both times. The default is `false`, which means once ever.

`POST /newsletters/{newsletter}/automations` ([reference](https://api-reference.usecommune.dev/operation/operation-createautomation))

```sh
curl -X POST "https://api.usecommune.com/newsletters/example-letter/automations" \
  -H "Authorization: Bearer $COMMUNE_API_KEY" \
  -H "Commune-Version: 2026-08-26" \
  -H "Idempotency-Key: going-quiet-automation-2026-10-09" \
  -H "Content-Type: application/json" \
  -d '{
  "name": "Reach out when a reader cools off",
  "trigger": "subscriber.status_changed",
  "trigger_filter": {
    "properties": {
      "direction": {
        "const": "demoted"
      }
    },
    "required": [
      "direction"
    ]
  },
  "reentry": true,
  "steps": [
    {
      "id": "mark-quiet",
      "type": "add_tag",
      "tag": "cc33dd44-ee55-4f66-8a77-b8c9d0e1f203",
      "next": "settle"
    },
    {
      "id": "settle",
      "type": "wait",
      "duration": "P2D",
      "next": "reach-out"
    },
    {
      "id": "reach-out",
      "type": "send_email",
      "email": {
        "subject": "Still want this, {{ subscriber.first_name }}?",
        "preview_text": "One line back is enough.",
        "body_markdown": "# Still want this?\n\nYou used to read most of these and have not opened one in a while, which\nis fine. If the letter has drifted away from what you signed up for, tell\nme. A one-line reply is worth more to me than any survey.\n\n[Unsubscribe]({{ unsubscribe_url }}) · {{ address }}\n"
      }
    }
  ]
}'
```

Response `201`:

```json
{
  "object": "automation",
  "id": "2d4f6a81-3b5c-4d7e-8f90-a1b2c3d4e5f6",
  "newsletter": {
    "object": "newsletter",
    "id": "7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411"
  },
  "name": "Reach out when a reader cools off",
  "status": "draft",
  "trigger": "subscriber.status_changed",
  "trigger_filter": {
    "properties": {
      "direction": {
        "const": "demoted"
      }
    },
    "required": [
      "direction"
    ]
  },
  "reentry": true,
  "steps": [
    {
      "id": "mark-quiet",
      "type": "add_tag",
      "next": "settle",
      "then": null,
      "else": null,
      "duration": null,
      "tag": "cc33dd44-ee55-4f66-8a77-b8c9d0e1f203",
      "schema": null,
      "email": null
    },
    {
      "id": "settle",
      "type": "wait",
      "next": "reach-out",
      "then": null,
      "else": null,
      "duration": "P2D",
      "tag": null,
      "schema": null,
      "email": null
    },
    {
      "id": "reach-out",
      "type": "send_email",
      "next": null,
      "then": null,
      "else": null,
      "duration": null,
      "tag": null,
      "schema": null,
      "email": {
        "subject": "Still want this, {{ subscriber.first_name }}?",
        "preview_text": "One line back is enough.",
        "body_markdown": "# Still want this?\n\nYou used to read most of these and have not opened one in a while, which\nis fine. If the letter has drifted away from what you signed up for, tell\nme. A one-line reply is worth more to me than any survey.\n\n[Unsubscribe]({{ unsubscribe_url }}) · {{ address }}\n"
      }
    }
  ],
  "created_at": "2026-10-09T15:20:00Z",
  "updated_at": "2026-10-09T15:20:00Z",
  "activated_at": null
}
```

### 3. Turn it on

Read a test copy of `reach-out` first. The step is the same one Welcome every new reader walks through, and the reason is the same: there is no moment at which anybody watches an automation email go out, so without a test copy the first person to see it rendered is a reader you are already at risk of losing.

Every gate runs again here on the stored automation: the graph, the filter, the email's footer, every image in it, and the newsletter's own ability to send. Each is a `422` naming what is wrong. The newsletter checks apply only because this graph sends; an automation that only moved the tag would be turned on without them.

**It reacts to the next crossing, not to the backlog.** Readers who were already cooling off before you turned this on are not tagged retrospectively. If you want to reach them once, that is a list and a one-off email rather than a standing instruction, and Win back readers is that page.

**This installs a standing instruction.** From the moment it returns, Commune emails whoever the trigger names, as often as it fires, with no further request from anybody. It is charged to a daily cap counted per newsletter, reported as a `429` whose `Retry-After` is measured in hours rather than seconds.

`POST /automations/{automation}/activate` ([reference](https://api-reference.usecommune.dev/operation/operation-activateautomation))

```sh
curl -X POST "https://api.usecommune.com/automations/2d4f6a81-3b5c-4d7e-8f90-a1b2c3d4e5f6/activate" \
  -H "Authorization: Bearer $COMMUNE_API_KEY" \
  -H "Commune-Version: 2026-08-26" \
  -H "Idempotency-Key: going-quiet-activate-2026-10-09"
```

Response `200`:

```json
{
  "object": "automation",
  "id": "2d4f6a81-3b5c-4d7e-8f90-a1b2c3d4e5f6",
  "newsletter": {
    "object": "newsletter",
    "id": "7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411"
  },
  "name": "Reach out when a reader cools off",
  "status": "active",
  "trigger": "subscriber.status_changed",
  "trigger_filter": {
    "properties": {
      "direction": {
        "const": "demoted"
      }
    },
    "required": [
      "direction"
    ]
  },
  "reentry": true,
  "steps": [
    {
      "id": "mark-quiet",
      "type": "add_tag",
      "next": "settle",
      "then": null,
      "else": null,
      "duration": null,
      "tag": "cc33dd44-ee55-4f66-8a77-b8c9d0e1f203",
      "schema": null,
      "email": null
    },
    {
      "id": "settle",
      "type": "wait",
      "next": "reach-out",
      "then": null,
      "else": null,
      "duration": "P2D",
      "tag": null,
      "schema": null,
      "email": null
    },
    {
      "id": "reach-out",
      "type": "send_email",
      "next": null,
      "then": null,
      "else": null,
      "duration": null,
      "tag": null,
      "schema": null,
      "email": {
        "subject": "Still want this, {{ subscriber.first_name }}?",
        "preview_text": "One line back is enough.",
        "body_markdown": "# Still want this?\n\nYou used to read most of these and have not opened one in a while, which\nis fine. If the letter has drifted away from what you signed up for, tell\nme. A one-line reply is worth more to me than any survey.\n\n[Unsubscribe]({{ unsubscribe_url }}) · {{ address }}\n"
      }
    }
  ],
  "created_at": "2026-10-09T15:20:00Z",
  "updated_at": "2026-10-09T15:20:00Z",
  "activated_at": "2026-10-09T15:41:00Z"
}
```

### 4. Watch the tag move, and watch for the loop

Every tag the automation applies publishes `subscriber.tagged`, exactly as a tag applied by hand does, with one difference worth all of this paragraph: `actor` names the **run** that caused it.

That attribution is what the loop guard reads. A run started by this event inherits this run's chain plus this run's own automation, and two things are then refused before a run exists: a chain that already contains that automation, which is a cycle of any length; and a chain already five automations deep. So an automation triggered by `subscriber.tagged` whose steps add a tag loops immediately on paper and is refused in practice.

**Nothing is published for a run the guard refused**, because no run was created and there would be no run id to name. A refusal therefore looks exactly like an automation nobody switched on. If you build a second automation on this tag and it never fires, this is the first thing to check, before the trigger, the filter and the key.

`data.chain_depth` on every `automation.run.*` message says how many automations' actions led to that run: zero for one a person caused, one for a run started by what another automation did.

Event `subscriber.tagged`, as Commune posts it to your endpoint:

```json
{
  "id": "018f2a94-1010-7000-8000-000000000101",
  "type": "subscriber.tagged",
  "api_version": "2026-08-26",
  "occurred_at": "2026-10-10T06:12:00Z",
  "newsletter_id": "7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411",
  "actor": {
    "type": "system",
    "id": "4e5f6a7b-8c9d-4e0f-8a1b-2c3d4e5f6a7b",
    "label": "automation: Reach out when a reader cools off"
  },
  "idempotency_key": null,
  "data": {
    "subscriber_id": "33445566-7788-4990-a1b2-c3d4e5f60718",
    "email": "reader@example.com",
    "tag_id": "cc33dd44-ee55-4f66-8a77-b8c9d0e1f203",
    "tag_name": "Going quiet",
    "direction": "assigned"
  }
}
```

### 5. Ask who is in the group

The tag is an ordinary tag, so this is the ordinary question. `?tag=` takes the tag's `id` and `?status=subscribed` keeps the people the newsletter can still reach, which is the filter you want on a group defined by having gone quiet: an unsubscribe does not remove somebody's tags.

This needs `audience: read` on top of the sending permission, because it returns email addresses. The automation's own runs do not: a run is read under `sending` and carries no address at all, which is why there are two permissions on this page rather than one.

To see where each of those readers is in the sequence instead, list the automation's runs. `?status=waiting` is the readers sitting on the two-day wait, which is the group that has been tagged and not yet written to.

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

```sh
curl "https://api.usecommune.com/newsletters/example-letter/subscribers?tag=cc33dd44-ee55-4f66-8a77-b8c9d0e1f203&status=subscribed&limit=100" \
  -H "Authorization: Bearer $COMMUNE_API_KEY" \
  -H "Commune-Version: 2026-08-26"
```

Response `200`:

```json
{
  "object": "list",
  "data": [
    {
      "object": "subscriber",
      "id": "33445566-7788-4990-a1b2-c3d4e5f60718",
      "newsletter": {
        "object": "newsletter",
        "id": "7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411"
      },
      "user": {
        "object": "user",
        "id": "usr_2xR8kQ4mN7pL1vB9"
      },
      "email": "reader@example.com",
      "status": "subscribed",
      "source": "commune",
      "tags": [
        {
          "object": "tag",
          "id": "cc33dd44-ee55-4f66-8a77-b8c9d0e1f203"
        }
      ],
      "created_at": "2026-02-14T10:31:07Z",
      "synced_at": null
    }
  ],
  "pagination": {
    "has_more": false,
    "next_cursor": null
  }
}
```

### 6. Stop it without losing anybody's place

A pause holds every waiting run exactly where it is. A reader one day into the two-day wait stays on that step and resumes from it when you turn the automation back on, so there is no window in which a paused automation still sends and no drain to wait out.

**That is the difference from archiving**, which is what deleting an automation does. An archive ends the runs it was in the middle of, with an `exit_reason` saying so, because an archive is not coming back and a run holding its place on a graph nobody will activate again is a subscriber stuck part way through forever. There is no way back from archived: write a new automation instead.

Pausing a paused automation succeeds and changes nothing. A draft or an archived one is refused, because neither is sending and there is nothing to pause.

The tag stays where it is either way. Pausing stops the automation, not the segment, so the readers it has already tagged are still in the group and still reachable by hand.

`POST /automations/{automation}/pause` ([reference](https://api-reference.usecommune.dev/operation/operation-pauseautomation))

```sh
curl -X POST "https://api.usecommune.com/automations/2d4f6a81-3b5c-4d7e-8f90-a1b2c3d4e5f6/pause" \
  -H "Authorization: Bearer $COMMUNE_API_KEY" \
  -H "Commune-Version: 2026-08-26" \
  -H "Idempotency-Key: going-quiet-pause-2026-12-01"
```

Response `200`:

```json
{
  "object": "automation",
  "id": "2d4f6a81-3b5c-4d7e-8f90-a1b2c3d4e5f6",
  "newsletter": {
    "object": "newsletter",
    "id": "7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411"
  },
  "name": "Reach out when a reader cools off",
  "status": "paused",
  "trigger": "subscriber.status_changed",
  "trigger_filter": {
    "properties": {
      "direction": {
        "const": "demoted"
      }
    },
    "required": [
      "direction"
    ]
  },
  "reentry": true,
  "steps": [
    {
      "id": "mark-quiet",
      "type": "add_tag",
      "next": "settle",
      "then": null,
      "else": null,
      "duration": null,
      "tag": "cc33dd44-ee55-4f66-8a77-b8c9d0e1f203",
      "schema": null,
      "email": null
    },
    {
      "id": "settle",
      "type": "wait",
      "next": "reach-out",
      "then": null,
      "else": null,
      "duration": "P2D",
      "tag": null,
      "schema": null,
      "email": null
    },
    {
      "id": "reach-out",
      "type": "send_email",
      "next": null,
      "then": null,
      "else": null,
      "duration": null,
      "tag": null,
      "schema": null,
      "email": {
        "subject": "Still want this, {{ subscriber.first_name }}?",
        "preview_text": "One line back is enough.",
        "body_markdown": "# Still want this?\n\nYou used to read most of these and have not opened one in a while, which\nis fine. If the letter has drifted away from what you signed up for, tell\nme. A one-line reply is worth more to me than any survey.\n\n[Unsubscribe]({{ unsubscribe_url }}) · {{ address }}\n"
      }
    }
  ],
  "created_at": "2026-10-09T15:20:00Z",
  "updated_at": "2026-10-09T15:20:00Z",
  "activated_at": "2026-10-09T15:41:00Z"
}
```

### All together

The tag and the automation as one file, stopping before activation. It is written so a second run changes nothing: the tag is reused if it already exists, and the idempotency keys are fixed rather than generated, so a retry after a timeout cannot leave two automations behind.

```js
const BASE = "https://api.usecommune.com";
const VERSION = "2026-08-26";
const KEY = process.env.COMMUNE_API_KEY;
const NEWSLETTER = process.env.COMMUNE_NEWSLETTER;

async function api(path, { method = "GET", body, idempotencyKey } = {}) {
  const res = await fetch(BASE + path, {
    method,
    headers: {
      authorization: `Bearer ${KEY}`,
      "commune-version": VERSION,
      ...(body ? { "content-type": "application/json" } : {}),
      ...(idempotencyKey ? { "idempotency-key": idempotencyKey } : {}),
    },
    body: body ? JSON.stringify(body) : undefined,
  });
  const answer = await res.json();
  if (!res.ok) {
    const { code, message, request_id } = answer.error;
    throw new Error(`${res.status} ${code}: ${message} (request ${request_id})`);
  }
  return answer;
}

const TAG_NAME = "Going quiet";

// 1. The tag. A name has to be unique among the live tags, so a second run
//    answers 409 rather than making a duplicate; reuse what is there.
const { data: tags } = await api(`/newsletters/${NEWSLETTER}/tags`);
const existing = tags.find((tag) => tag.name === TAG_NAME);
const tag =
  existing ??
  (await api(`/newsletters/${NEWSLETTER}/tags`, {
    method: "POST",
    idempotencyKey: "going-quiet-tag-v1",
    body: { name: TAG_NAME },
  }));
console.log(`Tag ${tag.id} (${tag.known_subscriber_count} readers).`);

// 2. The automation. The filter runs against the event's own payload before
//    a run exists, which is why it names `direction` rather than anything
//    under `subscriber`. Without `required`, a payload with no `direction`
//    would match: an absent property says nothing in JSON Schema.
const draft = await api(`/newsletters/${NEWSLETTER}/automations`, {
  method: "POST",
  idempotencyKey: "going-quiet-automation-v1",
  body: {
    name: "Reach out when a reader cools off",
    trigger: "subscriber.status_changed",
    trigger_filter: {
      properties: { direction: { const: "demoted" } },
      required: ["direction"],
    },
    reentry: true,
    steps: [
      { id: "mark-quiet", type: "add_tag", tag: tag.id, next: "settle" },
      { id: "settle", type: "wait", duration: "P2D", next: "reach-out" },
      {
        id: "reach-out",
        type: "send_email",
        email: {
          subject: "Still want this, {{ subscriber.first_name }}?",
          preview_text: "One line back is enough.",
          body_markdown: process.env.REACH_OUT_BODY,
        },
      },
    ],
  },
});

// 3. Read it before a reader does.
const test = await api(`/automations/${draft.id}/steps/reach-out/send-test`, {
  method: "POST",
  idempotencyKey: "going-quiet-test-v1",
  body: { to: [process.env.REVIEW_ADDRESS] },
});
console.log(`Test copy: sent ${test.sent}, failed ${test.failed}.`);

console.log(
  `Draft ${draft.id} is written and NOT sending. When you are happy:\n` +
    `  POST ${BASE}/automations/${draft.id}/activate`,
);
```
