Skip to main content

Export a Dive to PDF and Deliver It

Build this flight plan with your AI assistant

I want a scheduled PDF or PNG of one of my MotherDuck Dives rendered and delivered to my team over Slack, Microsoft Teams, or email, without anyone clicking anything. Help me adapt the "Export a Dive to PDF and Deliver It" recipe to my own data and use case, using it as a guide: https://motherduck.com/docs/cookbook/flight-dive-export

Dives have no native PDF or PNG export yet. This single-file Flight builds one out of the pieces that already exist: a Dive embed session, headless Chromium running on MotherDuck compute, and a MotherDuck table to keep the results in. Each run renders the Dive as it looks to a service account, captures a full-height PNG and a single-page PDF, appends both to STORE_TABLE as BLOBs, and hands them to whatever DELIVERY names. Put it on a cron and the report arrives on its own.

A first run takes roughly 95 seconds, most of it the one-off Chromium download; later runs on a warm image are much quicker.

How it works​

flight.py runs a fixed sequence, and every input is a config value:

  1. POST /v1/dives/{DIVE_ID}/embed-session with {"username": SERVICE_ACCOUNT} mints a 24-hour embed session for the Dive. Each run mints its own, so there is nothing to refresh.
  2. playwright install chromium pulls the browser into the Flight container. The image ships without one, which is what makes the first run slow.
  3. Chromium opens the session URL at VIEWPORT and waits for the Dive to finish loading: at least MIN_WAIT_MS, then until the DOM stops changing, giving up at WAIT_MS. Set WAIT_FOR_TEXT to key on real content instead. See Waiting for the Dive.
  4. It stretches the viewport to the Dive's own scroll height, then captures a PNG at SCALE and a PDF on a single page of the same size, so no chart is cut in half. See Sizing.
  5. Each rendition named in ATTACH is appended to STORE_TABLE (captured_at, label, source_url, kind, filename, mime, byte_count, content). The database and schema are created on first run.
  6. Every target named in DELIVERY gets the same renditions. Delivery runs after the store, so a broken webhook still leaves the file somewhere you can reach it. DELIVERY="" stores and stops.

DELIVERY, ATTACH, STORE_TABLE, and the delivery credentials are all validated before Chromium starts, so a typo or a missing secret fails the run in seconds instead of after a 90-second render.

The Dive is rendered as the service account, so the export contains exactly what that account is allowed to read. Grant it read access on the databases the Dive queries and nothing else.

Questions to answer​

  • Which Dive is being exported, and what is its id (the UUID in the Dive URL)?
  • Which service account renders it, and does it have read access to everything the Dive queries?
  • Is the PDF, the PNG, or both wanted?
  • What layout width should the Dive be rendered at (see Sizing)?
  • Which database holds the exports, and who is allowed to read that table?
  • Where should the report land: a Slack channel, a Teams channel, a mailbox, the exports table, or several of those?
  • For Teams, is there an Entra app registration to use, and who can grant it admin consent?
  • What schedule (cron, UTC) matches how often the underlying data changes?

Caveats​

  • A service account is required. embed-session rejects a regular user account with a generic 404 entity not found. Create one with the REST API (see Run it) before deploying.
  • The Flight's token must be able to mint embed sessions. That means a read/write token on an account with the admin permission; a read scaling or read-only token fails the API call.
  • A Dive scrolls inside its own frame, so full_page on its own stops at the fold. The Flight measures the Dive's scroll container and grows the viewport to match before capturing, up to 30000px. Past that the capture is short, and the log says so on a WARNING: line. See Sizing.
  • The exports table grows. Each run appends a few MB of BLOBs. Prune it on a schedule (DELETE FROM ... WHERE captured_at < now() - INTERVAL 90 DAY) or set STORE_TABLE = "" once delivery is enough.
  • A failed render does not raise on its own. An expired or wrong session loads fine as far as Chromium is concerned; the sandbox just paints "Unable to load Dive", and that page would store and mail like any other. The Flight counts elements before going further (9 on that page against 539 on a loaded one) and fails the run instead, quoting what the page said. Lower MIN_ELEMENTS, or set it to 0, if a genuinely sparse Dive trips it.
  • Queries run on MotherDuck, not in the container. The session URL carries queryMode=server, so the Dive goes through the Postgres endpoint instead of spinning up DuckDB-wasm inside Chromium. That halved load time on the test Dive (roughly 8s to 4s) and keeps the Flight's memory use down. A session without a pgEndpoint falls back to wasm on its own.
  • Browser diagnostics are limited. Console text is not logged because it may contain embed credentials or query data. Script errors produce a generic log entry. Inspect a local browser session when deeper diagnosis is needed.
  • Slack needs a bot token, not a webhook. Incoming webhooks cannot carry a file. Delivery uses a bot token with files:write, and the bot has to be a member of the target channel or the upload is rejected with not_in_channel.
  • Teams delivery takes two hops. No Teams webhook can carry a file, and posting a channel message with an app-only token is restricted to migration scenarios. So the file goes into the SharePoint folder behind the channel through Microsoft Graph (it appears in the channel's Files tab) and the optional TEAMS_WEBHOOK_URL posts a card that links to it. Without the webhook the file still lands, it is just not announced.
  • A failing target does not block the others. Every target in DELIVERY is attempted; each failure is logged on its own line and the run ends FAILED naming the targets that broke, so an expired token is visible without hiding the deliveries that worked.
  • Mail servers cap attachment size. Most sit around 25 MB, and because the capture grows to the full height of the Dive, a long one at SCALE=2 can approach that. Send ATTACH=pdf only, or drop SCALE to 1.5, if mail bounces on size. The log reports both byte counts on every run.
  • Waiting for the Dive is a heuristic unless you make it one. There is no "queries finished" signal to key on, so the Flight waits out MIN_WAIT_MS and then watches for the DOM to go quiet. That is a guess, and a Dive slower than its skeleton is deceptive (see Waiting for the Dive). WAIT_FOR_TEXT removes the guess.

Waiting for the dive​

Nothing in the embed tells you the Dive has finished loading. networkidle never fires, because the client holds connections open (measured: the in-flight request count never reaches zero). The hosted sandbox exposes no "connected" attribute either.

Watching the DOM go quiet is the obvious fallback, and on its own it is a trap. A Dive paints a skeleton whose shape then sits perfectly still while the queries run. On the Dive used to build this recipe:

TimeElementsCharactersContent height
1.3s to 7.3s653211000px (nothing yet)
8.8s53931182586px (loaded)

Six seconds of a motionless skeleton. A plain "unchanged for three polls" check captures that and reports success, which is why MIN_WAIT_MS (15s) exists: DOM quiet is only believed once it has passed. Raise it for a Dive whose first query is slow.

For a report you actually depend on, set WAIT_FOR_TEXT instead. Give it a string that appears only once the Dive has real data (a table footer, a total, a column header that renders after the query), and the wait keys on that. It is a Dive-specific readiness signal, and it fails the run rather than delivering a half-rendered export:

WAIT_FOR_TEXT = "SHOWING 32 OF 32 TICKETS"

This is the same contract MotherDuck's own scheduled Dive-screenshot workflows use. The trade-off is that the string is per-Dive: if the Dive is redesigned, update it, or the run starts failing.

Sizing​

VIEWPORT is the layout width and the minimum height, not a ceiling. The Flight lays the Dive out at that size, measures how tall it actually came out, and grows the viewport to fit before capturing, so the whole Dive lands in the shot without you having to guess its height.

Width still matters, because it decides the layout the Dive responds into: 1440 is a good desktop default. Height only matters as a floor, for a short Dive you want rendered on a taller canvas. SCALE is the PNG pixel ratio: 2 is crisp, 1.5 halves the file size.

What you'll adjust​

Everything is a Flight config value (or an env var for a local run). Nothing has to be edited in the code.

KnobDefaultPurpose
DIVE_ID(unset)The Dive to render, the UUID from its URL. Required unless SHOT_URL is set.
SERVICE_ACCOUNT(unset)Service account username the Dive is rendered as. Must be a service account.
REPORT_NAMEDive exportHuman name for the report. Used in the filenames (slugified) and in the message text.
ATTACHpdf,pngWhich renditions to produce: pdf, png, or both.
VIEWPORT1440x1000Layout width and minimum height as WxH. The capture grows past the height to fit the Dive.
SCALE2PNG device pixel ratio. 1.5 for smaller files.
MIN_ELEMENTS30Refuse to store or deliver a page with fewer elements than this. 0 disables the check.
MIN_WAIT_MS15000Never capture before this many ms have passed, however quiet the page looks.
WAIT_MS120000Ceiling in ms. Past it the Flight captures anyway, or fails if WAIT_FOR_TEXT was set.
WAIT_FOR_TEXT(unset)A string only the loaded Dive contains. Set it and the wait keys on that instead of on DOM quiet.
STORE_TABLEflights_demo.main.dive_exportsWhere the BLOBs land, as database.schema.table. "" skips the copy.
DELIVERY(unset)Comma-separated delivery targets: slack, teams, email. Empty stores the export and stops.
DRY_RUNfalsetrue renders and stores, then logs what each target would send instead of sending it. Only true and false are accepted.
MESSAGE(generated)Message text stored with the export. Defaults to <REPORT_NAME> captured <timestamp> UTC.
SHOT_URL(unset)Debugging only: capture this URL instead of minting a Dive session.
LABELadhocLabel stored with a SHOT_URL capture.
MOTHERDUCK_TOKEN(Flight-injected)Auth for the REST API and for the write. Select a token on the Flight; never hard-code it.

Slack delivery adds two credentials, which belong in a Flight secret rather than in config:

KnobPurpose
SLACK_BOT_TOKENBot User OAuth token (xoxb-...) with the files:write scope.
SLACK_CHANNEL_IDDestination channel id (C...), from the channel's details in Slack.

Teams delivery needs an Entra app registration, the channel's ids, and optionally a webhook to announce the upload:

KnobPurpose
TEAMS_TENANT_IDDirectory (tenant) id of the Entra app registration.
TEAMS_CLIENT_IDApplication (client) id of that registration.
TEAMS_CLIENT_SECRETClient secret for it.
TEAMS_TEAM_IDThe team's group id (groupId in the channel link).
TEAMS_CHANNEL_IDThe channel id (19:...@thread.tacv2).
TEAMS_WEBHOOK_URLOptional. Workflows webhook that posts the Adaptive Card linking to the uploaded files.

Email delivery is plain SMTP, so any provider works:

KnobDefaultPurpose
SMTP_HOST(unset)SMTP server hostname.
SMTP_PORT587SMTP port.
SMTP_TLSstarttls (ssl on port 465)Transport security: starttls, ssl (implicit TLS), or none for a local relay.
SMTP_USER / SMTP_PASSWORD(unset)Credentials. Leave unset for a relay that does not authenticate.
EMAIL_FROM(unset)Envelope sender.
EMAIL_TO(unset)Comma-separated recipients.
EMAIL_SUBJECTREPORT_NAMESubject line.

Config is per-run overridable, so one Flight can export several Dives.

Run it​

You need a MotherDuck account, an admin read/write token, and a Dive.

First create the service account that renders the Dive (skip if you already have one), then grant it read access on the databases the Dive queries:

export MOTHERDUCK_TOKEN=your_admin_token_here
curl -X POST "https://api.motherduck.com/v1/users" \
-H "Authorization: Bearer $MOTHERDUCK_TOKEN" \
-H "Content-Type: application/json" \
-d '{"username":"dive_export_bot"}'

Then run the export locally:

export DIVE_ID=00000000-0000-0000-0000-000000000000
export SERVICE_ACCOUNT=dive_export_bot
uv run --with-requirements requirements.txt flight.py

The first local run downloads Chromium too. To check the render path without a Dive or a service account, point it at any URL:

SHOT_URL=https://motherduck.com STORE_TABLE="" \
uv run --with-requirements requirements.txt flight.py

Set up slack delivery​

Slack's incoming webhooks cannot attach a file, so delivery uses a bot token and the external upload flow:

  1. Open api.slack.com/apps?new_app=1, choose From a manifest, select your workspace, and paste this manifest:
{
"display_information": { "name": "Dive Export" },
"features": { "bot_user": { "display_name": "Dive Export" } },
"oauth_config": { "scopes": { "bot": ["files:write"] } },
"settings": {
"org_deploy_enabled": false,
"socket_mode_enabled": false,
"token_rotation_enabled": false
}
}
  1. Review, create the app, then Install to Workspace and copy the Bot User OAuth Token (xoxb-...) from OAuth & Permissions.
  2. Invite the bot to the destination channel (/invite @Dive Export). Without this the upload fails with not_in_channel.
  3. Copy the channel id (C...) from the channel's About details.

Then send a real report to Slack:

export SLACK_BOT_TOKEN=xoxb-...
export SLACK_CHANNEL_ID=C0123456789
DELIVERY=slack uv run --with-requirements requirements.txt flight.py

Use DRY_RUN=true first to confirm the render and the filenames without posting anything.

Set up teams delivery​

Teams needs Microsoft Graph for the file and (optionally) a webhook for the announcement:

  1. In the Microsoft Entra admin center, register an application (App registrations > New registration). Copy the Application (client) ID and Directory (tenant) ID, then add a client secret under Certificates & secrets.
  2. Under API permissions, add the Microsoft Graph application permission Files.ReadWrite.All, then grant admin consent. That one covers both calls: it resolves the channel's Files folder and writes into it. If a call is refused, the Graph error body names the permission it wanted; this Flight surfaces that body verbatim.
  3. Get the ids from Teams: on the channel, Get link to channel produces a URL containing groupId=<TEAMS_TEAM_ID> and threadId=<TEAMS_CHANNEL_ID> (the 19:...@thread.tacv2 value).
  4. Optional, for the card: in Teams open the channel's Workflows and create one from the Post to a channel when a webhook request is received template, then copy its URL as TEAMS_WEBHOOK_URL. (This replaces the retired Office 365 connectors.)
export TEAMS_TENANT_ID=... TEAMS_CLIENT_ID=... TEAMS_CLIENT_SECRET=...
export TEAMS_TEAM_ID=... TEAMS_CHANNEL_ID='19:...@thread.tacv2'
export TEAMS_WEBHOOK_URL=https://prod-00.westeurope.logic.azure.com/...
DELIVERY=teams uv run --with-requirements requirements.txt flight.py

Set up email delivery​

There is no provider SDK here, just SMTP, so SES, Resend, Postmark, Google Workspace, or an internal relay all work the same way. Point it at the provider's submission host and attach the credentials as a secret:

export SMTP_HOST=email-smtp.eu-central-1.amazonaws.com SMTP_PORT=587
export SMTP_USER=... SMTP_PASSWORD=...
export EMAIL_FROM=reports@example.com
export EMAIL_TO='team@example.com, exec@example.com'
DELIVERY=email uv run --with-requirements requirements.txt flight.py

Set SMTP_TLS=none only for a relay on a trusted network (or a local SMTP sink while you are testing the render).

Getting the files out​

The renditions are BLOBs, so pull the newest one through the client:

duckdb "md:?motherduck_token=$MOTHERDUCK_TOKEN" -noheader -list -c "
SELECT to_base64(content)
FROM flights_demo.main.dive_exports
WHERE kind = 'pdf'
ORDER BY captured_at DESC
LIMIT 1
" | tail -1 | base64 --decode > dive.pdf

Deploy as a Flight​

Create the Flight with the MD_CREATE_FLIGHT SQL function (no deploy SQL is checked in; adapt the arguments to your situation), passing:

  • name: a Flight name, for example dive_export
  • source_code: the contents of flight.py
  • requirements_txt: the contents of requirements.txt
  • config: at least DIVE_ID, SERVICE_ACCOUNT, and DELIVERY, plus any knob from the table above
  • max_runtime_sec: optional cap on a run's duration in seconds (0 = no cap). Leave room for the Chromium download on the first run.
  • flight_secret_names: the secret holding the delivery credentials

Store the delivery credentials as a MotherDuck Flights secret, never in config. The simplest way is the MotherDuck UI: open Settings > Secrets, add a secret of type Flights, and give it one param per credential. The same secret can be created from any write-enabled SQL connection (read-only connections reject CREATE SECRET):

CREATE SECRET dive_export_delivery IN motherduck (
TYPE flights,
PARAMS MAP {
'SLACK_BOT_TOKEN': 'xoxb-...',
'SLACK_CHANNEL_ID': 'C0123456789',
'TEAMS_CLIENT_SECRET': '...',
'SMTP_PASSWORD': '...'
}
);

A TYPE flights secret injects each param under its bare name, so the params above arrive as SLACK_BOT_TOKEN and SLACK_CHANNEL_ID whatever you name the secret. (Each param is also injected namespaced as <secret_name>_<PARAM>, which disambiguates when several secrets define the same param name.)

A MotherDuck token is attached to the Flight automatically and injected at run time as MOTHERDUCK_TOKEN; no token argument is needed. It must be a read/write token on an admin account, or minting the embed session fails.

Create the Flight without a schedule, trigger one run with MD_RUN_FLIGHT(flight_id := ...) (the id is returned by MD_CREATE_FLIGHT and listed by MD_FLIGHTS(); inspect a run with MD_GET_FLIGHT_RUN(flight_id := ..., run_number := ...)), and look at the exported PDF before you trust it. Then add a schedule by updating the Flight's schedule_cron with MD_UPDATE_FLIGHT (for example 0 6 * * 1, Mondays at 06:00 UTC). Schedule updates are metadata-only and do not create a new Flight version.

Security​

  • The export inherits the service account's access. Whatever that account can read can end up in a PDF that leaves MotherDuck, so scope its grants to the Dive's own data.
  • The session token stays out of the table. source_url is stored without the URL fragment, which is where the embed session lives.
  • Identifier validation. STORE_TABLE is split and each part checked against ^[A-Za-z_][A-Za-z0-9_]*$ before it is interpolated into the CREATE and INSERT statements, which cannot be parameterized. Row values are bound parameters.
  • Delivery credentials live in a secret. SLACK_BOT_TOKEN, TEAMS_CLIENT_SECRET, and SMTP_PASSWORD are read from the environment at run time and never logged; put them in a Flights secret, not in Flight config, which is visible to anyone who can read the Flight.
  • Files.ReadWrite.All is tenant-wide, and it is the narrowest permission that covers both hops as written. Sites.Selected is the usual way to scope an app to one site, but it is not accepted on GET /teams/{id}/channels/{id}/filesFolder, so the folder cannot be resolved with it. Using it means resolving the drive and folder ids another way and hardcoding them, which this recipe does not do. Grant the tenant-wide permission to a dedicated app registration used for nothing else, and audit it accordingly.
  • Delivery is a data egress path. A Dive that renders sensitive numbers sends them to whoever can read the destination channel or mailbox, and an emailed attachment leaves your control entirely. Pick the destination with that in mind, and keep EMAIL_TO in config where it is reviewable rather than buried in a secret.
  • Keep mail on TLS. Both TLS modes verify the server certificate and hostname. SMTP_TLS=none sends the report, and any SMTP credentials, in the clear. Use it only on a trusted network.
  • Treat the exports table as sensitive. It holds rendered business data; the same read grants you would put on the Dive's tables belong on it.

Learn more​