Digests¶
Twelve people comment on a post within a few minutes. Sending twelve notifications trains the author to ignore you, and modern phones increasingly silence senders that do this. A digest turns the burst into one message: "Ann, Bo and 10 others commented on your post."
A digest is a property of a rule. Add a digest policy and the rule stops messaging
for every matching event. Instead, it groups events per recipient and per grouping key
for a time window, then composes a single message.
POST /v1/rules
{
"key": "comment-digest",
"name": "Group comments per post",
"event": "comment.created",
"template": "comments",
"category": "social",
"channels": ["push", "email"],
"digest": {
"window": "PT10M",
"key": "{{ event.data.post_id }}",
"send_after": 20
}
}
| Field | Default | Meaning |
|---|---|---|
window |
— | How long to collect after the first event: ISO 8601 (PT10M) or seconds. 1 s – 7 days. |
deliver_at |
— | Instead of window: send at this time of day in the recipient's time zone, e.g. "08:00". |
days |
every day | With deliver_at: the days to send on, e.g. ["mon"] for a weekly summary (mon … sun). |
key |
one group per rule | Template rendered per event. Events with different keys form separate digests (one per post, per project…). |
send_after |
none | Send as soon as this many events are grouped, without waiting for the window to end. |
max_items |
50 | Most recent events kept for the template; older ones are still counted. |
summarize |
false |
Smart mode: let the AI layer write the summary (see below). |
summary_fields |
[] |
Event data keys the AI may see. Required with summarize. |
Set exactly one of window and deliver_at.
Daily and weekly summaries¶
A morning summary at 08:00 wherever each person lives, and a Monday report:
"digest": {"deliver_at": "08:00"}
"digest": {"deliver_at": "09:00", "days": ["mon"]}
The time comes from recipient.timezone (falling back to policy.default_timezone) and
follows daylight saving. A digest that opens at 08:30 goes out at 08:00 the next day.
Quiet hours still apply, so a deliver_at inside someone's quiet window is held until
it ends.
Writing the template¶
A digest's template receives the group as data:
| Variable | Contents |
|---|---|
data.count |
Events grouped, including any beyond max_items. |
data.events |
The kept events' data, oldest first. |
data.first, data.last |
The first and last kept event's data. |
data.omitted |
count minus the events kept. |
data.key |
The rendered grouping key. |
{
"key": "comments",
"locales": {
"en": {
"title": "{{ data.count }} new {{ 'comment' if data.count == 1 else 'comments' }}",
"body": "{% for c in data.events[-2:] %}{{ c.author }}{% if not loop.last %} and {% endif %}{% endfor %}{% if data.count > 2 %} and {{ data.count - 2 }} others{% endif %} commented on “{{ data.last.post_title }}”",
"url": "myapp://posts/{{ data.key }}",
"thread": "post-{{ data.key }}"
}
}
}
Grouping on the device too
thread is the device-side half of grouping. Push connectors map it to the APNs
thread-id and the FCM data.thread (so iOS and Android stack messages from the same
conversation), e-mail gets a stable References header, and the webhook connector
forwards it as-is. It works on any template, not only digests.
Lifecycle¶
- The first matching event opens a digest for (rule, recipient, key) and schedules
its send for the end of the window (or the next
deliver_at). The event's outcome isdigested, with thedigest_id. - Later events join it until then. Reaching
send_aftermoves the send to now. - When it is sent, the digest composes one message through the regular policy. Unsubscribes, frequency caps, cooldowns and quiet hours apply to the digest message just as to any other message, and a message suppressed by policy shows up as such in the message log.
- The next matching event opens a fresh digest.
A digest whose rule was disabled or deleted before the window closed is cancelled, not sent. Events for the same person are grouped under a row lock, so concurrent workers never open two digests for one key, and the group stays in the order the events happened, not the order they were processed.
GET /v1/digests?recipient=user-1&status=open
GET /v1/digests/{id}
POST /v1/digests/{id}/send # close the window early
Use the dry-run endpoint to check a rule. A digest rule reports
"reason": "digested" without grouping anything.
Smart digests¶
Templates are predictable. Sometimes a summary reads better: "Your PR got 3 approvals
and 2 change requests, mostly about the migration." Set summarize and list which
event fields the model may read:
"digest": {
"window": "PT30M",
"key": "{{ event.data.pr }}",
"summarize": true,
"summary_fields": ["author", "verdict", "excerpt"]
}
When the digest sends, the template still renders first. That rendered copy is the
baseline, and the AI layer rewrites it with the grouped updates as context. Only
summary_fields values are shared (plus the template's own ai_attributes). The same
guardrails apply as for AI personalisation: numbers and links must survive
verbatim, and any failure, timeout or exhausted token budget falls back to the
template's text. So a smart digest is never worse than an automatic one.
Choosing a mode¶
| Automatic | Smart | |
|---|---|---|
| Copy | Your template, deterministic | AI summary of your template's baseline |
| Needs | Nothing | AI enabled (CUE_AI__MODEL) |
| Cost | None | One model call per digest |
| Best for | Counts, lists of names, activity feeds | Heterogeneous updates where the gist matters |
Start automatic. Switch on summarize for the rules where people tell you the list is
noise.