Skip to content
Runtime

Scheduled reports

Print a dashboard to a PDF and mail it — on demand from the admin, or on a schedule from a cron flow. Reuses the Insights panels, the PDF renderer and the email transport; reachable over REST, the SDK, GraphQL, MCP, and the CLI.

Scheduled reports

A dashboard already answers “what happened this month?” — but only for someone who opens the admin and looks. A report is that same dashboard as a static PDF: rendered on demand from the Insights page, or mailed on a schedule by a cron flow.

Nothing here is a new subsystem. The panels come from Embedded BI dashboards, the PDF from the renderer in Document generation, and the mail from the transport in API keys & email. This feature is the join.

The one-op version

The whole scheduled-report use case is a flow with a cron trigger and one report.deliver op:

{
"name": "Monthly revenue to the accountant",
"trigger": "cron:0 8 1 * *",
"operations": [
{
"type": "report.deliver",
"dashboardId": "b1c2…",
"to": "accounting@example.com",
"subject": "Revenue — {{ $now.month }}"
}
]
}

In the admin, that is the Deliver report step in the flow builder: pick a dashboard from the list, type the recipients, done.

What the PDF contains

Every panel in the dashboard, in its saved order, drawn the way the Insights page draws it:

Panel vizIn the PDF
counterthe number, large
bars, stacked-barsgrouped / stacked bars with a value axis
line, area, sparklinea line, with each series labelled at its end
pie, donuta ring with labels, values and percentages
radialhorizontal bars — the same comparison, easier to read on paper
radar, table, anything elsethe rows as a table

The charts are hand-written inline SVG, not recharts. The renderer is a real browser, so it could run a chart library — but then the print snapshot would race the script that draws the chart. A chart that is already drawn when the HTML arrives cannot lose that race. The consequence worth knowing: the document is entirely self-contained. No script, no external stylesheet, no font, no image, nothing to fetch.

Two things are printed rather than hidden:

  • A panel that failed prints its error in a red box. Dropping it would make a month with a broken revenue query look like a month with no revenue.
  • A truncated table says so — at most 20 rows and 8 columns, with a footnote naming what was left out.

{{ … }} inside row data survives verbatim. This matters more here than it looks: document templates are themselves stored in collections, so a cell containing {{ total }} is ordinary content, and interpolating the report page would silently replace it with nothing.

Where the file goes

Under documents/<tenant>/<uuid>/<name>.pdf — the same prefix a document.render op writes to. That is deliberate: an email op refuses to attach any key outside the running workspace’s document prefix, and that check is what stops a flow mailing out another tenant’s contract. Sharing the prefix means a report is attachable by the same guard rather than needing a second one.

The key is random, never derived from the filename. A filename comes from the dashboard’s name, and deriving the object path from it would let that name decide where the object lands.

So a flow that wants to write its own covering message just omits to:

[
{ "type": "report.deliver", "dashboardId": "b1c2…" },
{
"type": "email",
"to": "team@example.com",
"subject": "This week",
"html": "<p>Numbers attached. Ask me anything.</p>",
"attach": ["{{ $last.key }}"]
}
]

Identity, and what a report may read

The dashboard runs with the caller’s identity — the admin who pressed the button, or the flow’s user. A report is not an embed: there is no anonymous path into it, and no token that opens one. A panel reading a collection that identity cannot read comes back as an error printed on the page, not as data.

Recipients are capped at 25 per report, and each gets their own message rather than all appearing in one To: header. A flow that resolved recipients from a row would otherwise be a bulk sender with the workspace’s own numbers as the payload.

Requirements

A PDF renderer must be configuredPDF_CF_ACCOUNT_ID + PDF_CF_API_TOKEN, or PDF_GOTENBERG_URL. Without one, every surface refuses with 422 and says so. There is deliberately no bundled fallback renderer; see Document generation for why.

On managed cloud the email gateway cannot carry attachments. The covering mail still goes, and the response carries attachmentsDropped: true so the caller can say the report did not travel with it.

Surfaces

SurfaceCall
RESTPOST /api/admin/dashboards/{id}/report
SDKclient.dashboards.report(id, input) / .reportPdf(id, input)
GraphQLmutation { deliverDashboardReport(id, input) { … } }
MCPdashboards.report
CLIbacklex dashboards report <id> [--to …] [--out file.pdf]
Flowsthe report.deliver op

Request body (all optional):

{
"filename": "revenue-august", // default: <dashboard>-<date>.pdf
"pageOptions": { "format": "A4", "landscape": false },
"email": { "to": "a@x.com, b@x.com", "subject": "", "templateKey": "" },
"download": true // answer PDF bytes instead of metadata
}

download and email are mutually exclusive — a request that asked for both has one of the two intents wrong, so it is refused rather than guessed at.

The response:

{
"key": "documents/<tenant>/<uuid>/revenue-august.pdf",
"filename": "revenue-august.pdf",
"size": 148213,
"renderer": "cf-browser-rendering",
"dashboard": { "id": "b1c2…", "name": "Revenue" },
"panels": 7,
"failedPanels": 0,
"sentTo": ["a@x.com", "b@x.com"]
}

Where the code lives

  • packages/core/src/report.tsbuildReportHtml, pure. No DB, no fetch, no clock: the caller passes the rows and the timestamp, so one input is one fixed string and the layout is unit-testable without a browser.
  • packages/core/src/panels.ts — the row → series detection, shared with the admin’s chart renderer so the PDF and the screen agree on which column is the label.
  • apps/web/src/server/services/reports.tsdeliverReport: run, build, render, store, mail. Every surface calls this one function.
  • apps/web/tests/report-delivery.test.ts, apps/web/tests/reports-surfaces.test.ts — the behaviour and the parity gate.