Guides

Authoring digests and briefs

How to write a digest that reads many sources, adds things up, and only sends a note when there is news.

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:

text
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:

  1. 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.
  2. Warehouse reads. Mail, calendar, contacts, files, and timelines, through the built-in operations.
  3. 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.
json
"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:

json
{ "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_id can come from the run's context or settings. It can also come from an earlier step, such as a search's row or a foreach item. 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:

json
{ "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:

json
{ "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:

json
{ "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_run with a time-window gate. Do this when the timing is part of what the recipe means:
json
"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_send is declared.
  • The description says it is read-only. budget_ms is set.

Recued Docs

Search documentation

Start typing to search the documentation.