A digest reads several sources, computes over them, and renders a compact answer — a morning brief, a pipeline cockpit, a renewal radar. It is the most common shape in the marketplace, and the discipline is the same at every size:
read → normalize → derive → render → notify (only on findings)A digest is not a special kind of recipe. The same JSON runs when you click Run, when chat calls it, and when a schedule fires it — the pattern is about what the steps do. Slugs and fields below are illustrative.
#Keep it read-only
Everything a digest does against your accounts is a read. The only writes
that belong inside one are a notification and, when the digest surfaces work
to act on, a button section whose actions open another recipe — a normal,
still-governed run with the arguments prefilled. Anything that changes a
record other people see belongs in that second, deliberately-run recipe, not
in the digest.
Say so in the description ("Read-only. It never creates, updates, or cancels anything") — installers read it, and so does the model when chat picks tools.
#The read layer
Prefer sources in this order:
- Enrichments. Precomputed facts — rollups, health scores, silence durations — are already maintained for you; reading one is instant. See the warehouse.
- Warehouse reads. Mail, calendar, contacts, files, timelines via built-in operations.
- Vendor operations. From an installed pack (listed in
depends_on), with the account chosen by the user through a connection variable:
"variables": {
"acct": { "label": "Accounting connection", "type": "connection",
"connection_kind": "api", "default": "" }
},
"steps": [
{ "id": "invoices", "op": "core.acct.invoice.search",
"connection": "{{config.acct}}" }
]Canonical families like the one above dispatch to whichever vendor the chosen connection binds — one recipe serves QuickBooks and Xero alike.
Normalize immediately after every read, so downstream steps see one safe shape even when a source returned nothing:
{ "id": "invoices_safe", "transform": "default",
"value": "{{step.invoices.result}}", "fallback": [] }That fallback is for display math only. If a decision hangs on the value —
a notification gate, a proposed action — check is_not_empty explicitly
first; an empty fallback would silently disable the decision.
#The derive layer
Counts, totals, overdue flags, and rankings are transforms, not AI:
{ "id": "open", "transform": "filter", "array": "{{step.invoices_safe}}",
"field": "balance", "operator": "greater", "value": 0 },
{ "id": "open_dated", "transform": "map", "array": "{{step.open}}",
"apply": "is_past", "field": "due_date", "output_field": "overdue" },
{ "id": "open_total", "transform": "reduce", "array": "{{step.open}}",
"field": "balance", "operator": "sum", "initial": 0 },
{ "id": "ranked", "transform": "sort", "array": "{{step.open_dated}}",
"fields": [{ "field": "balance", "direction": "desc" }] },
{ "id": "rows", "transform": "slice", "array": "{{step.ranked}}",
"start": 0, "end": "{{config.row_limit}}" }Always cap rendered rows with a user-configurable row_limit, and when you
truncate, put the full count in the summary — a table that quietly shows
20 of 400 reads as "everything is fine."
Reach for an AI step only when the digest genuinely needs synthesis — a prose "so what" over the assembled numbers. Most digests need none.
#The render layer
Route results through output.render: the headline numbers first
(to_summary), one table per question (to_table), actions last. Format
hints ({{step.total:currency}}, format: "relative" on date columns)
apply inside rendered strings. The section types are in the
recipe schema reference.
A digest that surfaces work should hand you the next step as a button, not as prose:
{ "type": "button", "source": "step.actions" }Each action names another installed recipe with prefilled configuration. Clicking one opens its normal run — grants and approvals still apply.
#Notify only on findings
Pulling the digest (Run, chat, the panel) needs nothing extra. To push it:
{ "id": "should_notify", "transform": "all", "conditions": [
"{{step.has_findings}} equal true",
"{{config.notify_enabled}} equal true" ] },
{ "id": "notify", "skip_when": "{{step.should_notify}} not_equal true",
"op": "core.notification.send",
"args": { "channels": "{{config.channels}}",
"title": "3 invoices overdue",
"text": "Overdue total $4,210 across 3 invoices." } }Let the user pick channels (in-app by default; email and connected chat
platforms such as Slack or Telegram once set up), gate on "is there anything
to say" plus an opt-out, and keep the text to headline numbers — the tables
live in the result panel. Sending notifications requires the
notification_send permission in the recipe's requires.
A daily "0 items" push trains its reader to mute it. Notify on signal, never on schedule.
#Running it on a cadence
Ship the digest manual-first and let the owner choose the cadence:
- A schedule — armed in the UI or from chat. Each schedule carries its own configuration, so one installed digest can run daily for one client and weekly for another.
- A self-ticking window —
auto_runwith a time-window gate, when the cadence is part of the recipe's meaning:
"auto_run": { "interval_ms": 3600000 },
"trigger_steps": [
{ "id": "window", "op": "core.watch.time",
"args": { "weekdays": "{{config.weekdays}}",
"start_hour": "{{config.start_hour}}",
"end_hour": "{{config.end_hour}}" } }
]Recipes with triggers install disarmed — the owner arms them deliberately.
Set metadata.budget_ms on anything scheduled (15–35 seconds is typical
for a digest that fans out across vendor calls), and see
Triggers and watchers for everything about
automated fires — including why outward-facing actions don't belong in
them.
#Checklist
- Every outside read normalized; decisions re-check emptiness explicitly.
- Rows capped; truncation surfaced as a total.
- Summary first, tables per question, actions as buttons to a second recipe.
- Notification gated on findings and opt-in; channels user-chosen;
notification_senddeclared. - Description states the read-only scope;
budget_msset.