A digest reads several sources. It works out a few things. Then it shows a short answer. A morning brief is a digest. So is a pipeline cockpit or a renewal radar. It is the most common shape in the marketplace. The rules are 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. The names and fields below are just examples.
#Keep it read-only
Everything a digest does with your accounts is a read. Only two kinds of
writes belong inside one. The first is a notification. The second is a
button section, for when the digest finds work to do. Its buttons open
another recipe. That is a normal run, with all the usual checks, and the
values already filled in. Anything that changes a record other people can
see belongs in that second recipe. You run that one on purpose. It does not
belong in the digest.
Say so in the description. For example: "Read-only. It never creates, updates, or cancels anything." People read that before they install. So does the model, when chat picks a tool.
#The read layer
Try sources in this order:
- Enrichments. These are facts worked out ahead of time: rollups, health scores, how long someone has been quiet. They are kept fresh for you. Reading one is instant. See the warehouse.
- Warehouse reads. Mail, calendar, contacts, files, and timelines, through the built-in operations.
- Vendor operations. These come from an installed pack. List the pack
in
depends_on. A step names no connection: it uses the one the owner chose for the pack, else the pack's default.
"steps": [
{ "id": "invoices", "op": "core.acct.invoice.search" }
]A canonical family is a shared name for one kind of operation. The one above sends the call to whichever vendor the connection is for. So one recipe works for QuickBooks and Xero alike.
Normalize right after every read. That means: turn the result into one safe shape. Then later steps see the same shape, even when a source gave back nothing:
{ "id": "invoices_safe", "transform": "default",
"value": "{{step.invoices.result}}", "fallback": [] }That fallback is only for display math. Does a decision depend on the
value? A notification gate, or a proposed action? Then check is_not_empty
first, on purpose. An empty fallback would quietly switch the decision off.
#Which connection a step uses
- A search reads every connection that can answer it and returns one list. A connection chosen for the run, the dish or the setting limits it to that connection.
- A step on one record (read, update, delete) uses the connection its
id names. The
target_idcan come from the run's context or settings. It can also come from an earlier step, such as a search's row or aforeachitem. Then a step that reads records from two connections reads each in its own. If another connection was chosen, a record from elsewhere is refused and nothing is sent. So is a record of another kind, such as a contact's id on a deal step. - Anything else uses the pack's default. With no default, the owner's run takes the only connection that can serve it, or asks which. A scheduled run stops and says why.
Each record a vendor-neutral step returns says where it came from in
connection_name, and so does each record a CRM trigger hands a recipe. A
CRM record also carries target_id, an id that names its connection.
The owner can keep a dish, or one run, on one connection. The dish form
and the run dialog show a Connection line for the family (crm, acct).
It appears where two or more connections can serve it. Its "Any connection" choice
leaves the rules above.
To keep each connection's records apart, read the record's
connection_name. When a run used one connection for the family,
{{context.connections.acct}} says which.
#The derive layer
Counts, totals, overdue flags, and rankings are transforms. They are 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 the rows you show. Use a row_limit the user can change. When
you cut rows off, put the full count in the summary. A table that quietly
shows 20 of 400 reads as "everything is fine."
Use an AI step only when the digest really needs a summary in words. That is a short "so what" over the numbers. Most digests need none.
#The render layer
Send results through output.render. Put the headline numbers first
(to_summary). Then one table per question (to_table). Put actions last.
Format hints work inside rendered strings. For example,
{{step.total:currency}}, or format: "relative" on a date column. The
section types are in the
recipe schema reference.
When a digest finds work to do, hand over the next step as a button. Do not describe it in words:
{ "type": "button", "source": "step.actions" }Each action names another installed recipe, with its settings filled in. Clicking one opens that recipe's normal run. Grants and approvals still apply.
#Notify only on findings
Pulling the digest needs nothing extra. Pulling means clicking Run, asking in chat, or opening the panel. To push it to you instead:
{ "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 the channels. Make channels an optional array
setting with no default. Left empty, the note goes to every channel the
user has set up. In-app is always set up. Email and connected chat apps
such as Slack or Telegram join once they are set up. A default would
choose for the user. Gate the note on "is there anything to say", plus a
way to opt out. Keep the text to
the headline numbers. The tables live in the result panel. To send
notifications, the recipe needs the notification_send permission in its
requires list.
A daily push that says "0 items" teaches its reader to mute it. Notify when there is a signal. Never notify just because it is time.
#Running it on a cadence
Ship the digest so it runs by hand first. Let the owner pick how often it runs:
- A schedule. The owner arms it, which means switches it on, in the UI or from chat. Each schedule carries its own settings. So one installed digest can run daily for one client and weekly for another.
- A self-ticking window. Use
auto_runwith a time-window gate. Do this when the timing is part of what the recipe means:
"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}}" } }
]A recipe with a trigger installs switched off. The owner switches it on, on
purpose. Set metadata.budget_ms on anything that runs by itself, a schedule
or a self-ticking window. For a
digest that calls several vendors, 15–35 seconds is typical. See
Triggers and watchers for all about runs that
start by themselves. It also says why actions that reach outside do not
belong in them.
#Checklist
- Every outside read is normalized. Decisions check for emptiness again, on purpose.
- Rows are capped. When rows are cut, the total is shown.
- Summary first. One table per question. Actions are buttons to a second recipe.
- The note only goes out on findings, and only if the user opted in. The
user picks the channels.
notification_sendis declared. - The description says it is read-only.
budget_msis set.