Send daily digest emails from your AI agent
Send daily digest emails from your AI agent
Most "AI sends you email" demos fire a message on every event. The agent processes a record, sends an email. It finishes a task, sends an email. It notices something, sends an email. By Tuesday afternoon your users have eleven messages from a bot, they've muted the thread, and the one notification that actually mattered is buried under ten that didn't.
Per-event email is how you train people to ignore your agent. The fix is boring and it works: collect the day's events, summarize them once, and send a single digest. One email people actually open. And because it comes from a real mailbox the agent owns, the recipient can hit Reply - "re-run the failed sync" or "who approved this?" - and that reply lands somewhere the agent can read and act on.
That last part is what separates a digest from a glorified cron log. It's a conversation starter, not a dead end. This post builds exactly that with a Nylas Agent Account: where the digest content comes from (your app, not Nylas), how to send it, two honest ways to schedule it, and how replies come back.
I work on the Nylas CLI, so the terminal commands below are the ones I actually reach for - and I'll show both angles for every operation, the nylas command and the raw curl, because your batch job will probably shell out to one and your services hit the other.
Why a digest beats per-event email
Before any code, it's worth being clear about what you're trading. Per-event email has exactly one advantage: latency. The user hears about a thing the instant it happens. For most agent activity, that latency is a cost, not a feature - nobody needs a separate email the moment each of forty overnight records syncs. They need to know, once, in the morning, that forty synced and two failed.
A digest gets you:
- One open instead of eleven ignores. Inbox attention is finite. Spend it on a single well-structured summary and people read it. Spend it on a stream and they filter you to a folder they never check.
- Actual synthesis. A digest can say "37 succeeded, 2 failed, here are the 2." A per-event stream makes the human do that aggregation themselves, across eleven messages, which they won't.
- A natural reply surface. Because the digest lands from a replyable address, "reply with retry to re-run the failures" becomes a real workflow. The agent reads the reply and acts. You can't bolt that onto eleven separate notification emails cleanly.
- Cheap, searchable history. Every digest lands in the account's Sent folder automatically. That's a dated archive of "what did the agent report, and when" with zero extra infrastructure.
The honest tradeoff, stated plainly: a digest is the wrong tool for anything urgent. If a payment fails and someone needs to act in the next five minutes, send that one immediately - don't hold it for the 8am batch. Digests own the large middle band of important-but-not-urgent: daily summaries, overnight job reports, weekly rollups, "here's what I did while you were asleep." Most agent activity lives in that band, and it's exactly the band that per-event email ruins.
One more framing worth nailing down, because it's the most common confusion: the "what happened today" content is your data, not Nylas's. Nylas doesn't know your agent processed 40 records or that a sync failed. Your application knows that - you log those events somewhere, your batch job queries them, your code (or an LLM) writes the summary. Nylas is the delivery layer: it gives the agent a real mailbox to send the finished digest from, and an inbox for replies to come back to. Keep that line clear and the rest of this is simple.
The grant is the whole data plane
Here's the part that makes this tractable: an Agent Account is just a grant. It has a grant_id, and that ID works with every grant-scoped endpoint Nylas already exposes - Messages, Drafts, Threads, Folders, Attachments. There's nothing new to learn on the data plane.
You provision one mailbox like digest@yourcompany.com, and from then on sending the digest is the same POST /v3/grants/{grant_id}/messages/send you'd use for any message, and reading replies is the same GET /v3/grants/{grant_id}/messages. If you've built against a connected Gmail or Microsoft grant before, you already know this API. Same endpoints, same auth, same payloads.
What's different - and what makes an Agent Account the right tool here rather than a connected OAuth mailbox - is that you own this address programmatically. No human had to log into Google and grant consent. You mint it from an API call, it sends from your domain with your DKIM signature, and it has no OAuth token to expire at 2am and break your morning send.
Before you begin
You need three things:
The CLI. On macOS or Linux it's a Homebrew tap:
brew install nylas/nylas-cli/nylasA Nylas API key. If you don't have one,
nylas initcreates an account and mints a key in a single guided command. You can also pass an existing key non-interactively withnylas init --api-key <your-key>.A domain for the sender. Every Agent Account lives on a domain. For prototyping, Nylas hands out trial
*.nylas.emailsubdomains, so you can createdigest@your-app.nylas.emailimmediately. For production, register a dedicated subdomain likedigest.yourcompany.comand publish the DKIM and SPF records Nylas gives you. New domains warm over roughly four weeks, so don't register one the morning you launch - and if your digest goes to real customers, walk through the deliverability checklist (authenticate, set up DMARC, warm up) before you turn on volume.
Every API example below uses the US host https://api.us.nylas.com and a bearer token: Authorization: Bearer <NYLAS_API_KEY>. For the EU region, point at https://api.eu.nylas.com (the CLI honors the NYLAS_API_BASE_URL environment variable for the same purpose).
Provision the digest mailbox
This is the only step specific to Agent Accounts, and it's one line:
nylas agent account create digest@digest.yourcompany.com --name "Acme Daily Digest"
The --name sets the display name, so recipients see Acme Daily Digest <digest@digest.yourcompany.com> instead of a bare address. The command prints the new grant's id, status, and connector details - save that id, it's the handle for every send and read below.
The API auto-creates a default workspace and policy for the account, so there's nothing else to wire up. (If you later want a custom send/spam policy, attach it with nylas workspace update <workspace-id> --policy-id <policy-id> - there's deliberately no --workspace flag on create.)
Under the hood the CLI is a thin wrapper over POST /v3/connect/custom with provider: "nylas". The same call your provisioning code makes directly:
curl --request POST \
--url "https://api.us.nylas.com/v3/connect/custom" \
--header "Authorization: Bearer <NYLAS_API_KEY>" \
--header "Content-Type: application/json" \
--data '{
"provider": "nylas",
"name": "Acme Daily Digest",
"settings": {
"email": "digest@digest.yourcompany.com"
}
}'
The response contains a grant_id. That's your handle. Store it next to wherever you store your app's other infrastructure IDs - it doesn't change, and there's no refresh token to rotate.
Build the digest content (this part is yours)
This is the step Nylas can't do for you, and that's the point. Sometime during the day your application records events - jobs run, records processed, errors caught. Wherever you keep those (a table, a log, a queue), your batch job queries the last 24 hours and assembles a summary.
That assembly is plain application code; here's the shape of it:
# your_app/digest.py - runs once a day, before the send
events = db.query_events(since="24h") # YOUR data
summary = {
"succeeded": sum(1 for e in events if e.ok),
"failed": [e for e in events if not e.ok],
"highlights": top_n(events, 5),
}
body = render_digest(summary) # an LLM call, or a plain template - your choice
Whether render_digest is a Jinja template or a model prompt ("summarize these events into a friendly 5-line digest") is entirely up to you. Nylas doesn't see your events and doesn't write your summary. It enters the picture at exactly one moment: when body is ready and you hand it to the send endpoint. Keeping that boundary honest saves you from a whole class of "why doesn't Nylas know about my data" confusion later.
Send the digest
With the body assembled, sending it is one call. The CLI first:
nylas email send digest@digest.yourcompany.com \
--to ada@customer.com \
--subject "Your Acme daily digest - 37 done, 2 need a look" \
--body "$DIGEST_HTML"
Pass the grant by its email (or its grant_id) as the first argument; --body takes HTML or plain text. Notice there's no --from - Nylas defaults the sender to the Agent Account's own address and display name, which is exactly what you want for a digest.
The same operation against the API is POST /v3/grants/{grant_id}/messages/send:
curl --request POST \
--url "https://api.us.nylas.com/v3/grants/<GRANT_ID>/messages/send" \
--header "Authorization: Bearer <NYLAS_API_KEY>" \
--header "Content-Type: application/json" \
--data '{
"to": [{ "email": "ada@customer.com", "name": "Ada" }],
"subject": "Your Acme daily digest - 37 done, 2 need a look",
"body": "<h2>Today at Acme</h2><p>37 jobs succeeded, 2 failedβ¦</p>"
}'
That's the entire send. If you want the digest replyable to a different address than the sender (say replies should route to support@), add a reply_to array - but for most digests, letting replies come back to the Agent Account itself is the point, so omit it.
Schedule it: your cron, or Nylas send_at
Now the "daily" part. There are two honest ways to schedule, and they solve different problems - you'll likely use both.
Option A - your own scheduler decides when to run
The cleanest mental model: a cron entry (or a Cloud Scheduler job, or a Kubernetes CronJob) triggers your batch job at 8am, the job builds the digest from fresh data, and then it sends immediately. The scheduling lives in your infrastructure, where it belongs, because only your infra knows when "the day" is done and the data is ready.
# crontab: 08:00 every weekday, in your infra
0 8 * * 1-5 /usr/local/bin/build-and-send-digest.sh
That script does the query_events β render_digest β nylas email send sequence above. This is the option I reach for first, because the digest reflects data as of send time and there's no window where you've "scheduled tomorrow's email" before tomorrow's events exist.
Option B - hand the scheduled-send time to Nylas
Sometimes you've already built the body and just want Nylas to hold it until a specific moment - useful when your job finishes overnight but the digest should land at 8am sharp regardless of when the batch completed. The CLI exposes this with --schedule, which accepts a duration, a time, or a date:
nylas email send digest@digest.yourcompany.com \
--to ada@customer.com \
--subject "Your Acme daily digest" \
--body "$DIGEST_HTML" \
--schedule "tomorrow 8am"
--schedule also takes relative durations like 2h or 30m, a wall-clock time like 08:00, or an explicit 2024-01-15 08:00.
On the API, the same thing is the send_at field on the send request - a Unix timestamp, in seconds:
curl --request POST \
--url "https://api.us.nylas.com/v3/grants/<GRANT_ID>/messages/send" \
--header "Authorization: Bearer <NYLAS_API_KEY>" \
--header "Content-Type: application/json" \
--data '{
"to": [{ "email": "ada@customer.com" }],
"subject": "Your Acme daily digest",
"body": "<h2>Today at Acme</h2>β¦",
"send_at": 1739606400
}'
A couple of real constraints worth knowing before you lean on send_at: the time must be at least one minute in the future, and you can schedule at most 30 days out. So send_at is great for "hold this until 8am" but it is not your recurring scheduler - you don't queue up a year of digests. The recurrence still comes from your cron in Option A; send_at just shifts the exact delivery moment of a single, already-built message. Use Option A for when the job runs, and reach for send_at only when the build time and the desired delivery time genuinely differ.
Replies come back to the agent
This is the part that makes a digest worth sending from an Agent Account instead of a one-way relay. When Ada replies - "re-run the two failures" - her message lands in the Agent Account's inbox and fires the standard message.created webhook. Subscribe to it and your agent gets poked the instant a reply arrives.
One important detail: the message.created payload carries summary fields (sender, subject, snippet, thread_id, message_id) but not always the full body, and when a body exceeds ~1 MB the trigger becomes message.created.truncated with the body omitted entirely. So treat the webhook as a notification, then fetch the full message to read what Ada actually wrote.
The CLI lists inbound mail with:
nylas email list digest@digest.yourcompany.com --unread
And the equivalent API call is GET /v3/grants/{grant_id}/messages:
curl --request GET \
--url "https://api.us.nylas.com/v3/grants/<GRANT_ID>/messages?unread=true" \
--header "Authorization: Bearer <NYLAS_API_KEY>"
From there your agent parses the intent ("retry", "who approved this", "unsubscribe me"), does the work, and - because keeping the conversation in one thread matters - replies in-thread rather than starting a new message:
nylas email reply <MESSAGE_ID> --body "On it - re-running the 2 failed syncs now."
The CLI fetches the original to populate recipient and subject automatically and preserves threading via the reply-to relationship, so the exchange groups as one conversation in Ada's mail client.
On the API, the same in-thread reply is a normal POST /v3/grants/{grant_id}/messages/send with reply_to_message_id set to the message you're answering:
curl --request POST \
--url "https://api.us.nylas.com/v3/grants/<GRANT_ID>/messages/send" \
--header "Authorization: Bearer <NYLAS_API_KEY>" \
--header "Content-Type: application/json" \
--data '{
"to": [{ "email": "ada@customer.com", "name": "Ada" }],
"subject": "Re: Your Acme daily digest - 37 done, 2 need a look",
"body": "On it - re-running the 2 failed syncs now.",
"reply_to_message_id": "<MESSAGE_ID>"
}'
Comments
No comments yet. Start the discussion.