DEV Community

Qasim
Qasim

Posted on

Audit-log every email your AI agent sends

When an autonomous agent gets an email address of its own, the first question your security team asks isn't "can it send mail?" It's "can you prove, six months from now, exactly what it said and to whom?"

That's a different problem from "does it work." A demo that fires off a few support replies looks great in a sprint review. But the moment a real customer says "your bot promised me a refund," or a regulator asks for the complete record of what an automated system told a data subject, you need a defensible trail — an immutable record of every outbound and inbound message the agent touched, captured outside the mailbox the agent can also delete from.

I work on the Nylas CLI, so the terminal commands below are the exact ones I reach for. But the architectural point here is provider-agnostic and it's the part most "AI email" tutorials skip: the live mailbox is not your audit log. It's mutable, it has retention limits, and the same agent that sends mail can also trash it. If your only record of what the agent did lives in the inbox, you don't have an audit trail — you have a working copy.

What "audit-log everything" actually means

There are two stores in this design, and keeping them separate is the whole point.

  1. The live mailbox — the Agent Account grant. Messages flow in and out here. It's queryable, it's real-time, and it's mutable. Flags change, messages move folders, things get trashed. On the free plan it's also retention-limited: 30 days for the inbox, 7 days for spam.
  2. The audit storeyour system. An append-only, write-once log keyed by message_id and thread_id. Nothing in it is ever updated or deleted in normal operation. This is the record you hand a reviewer.

The audit store is the thing you build. Nylas gives you the two capture points — the send response and the inbound webhook — but the immutability is your responsibility. That means a WORM (write-once-read-many) object store, an append-only table with no UPDATE/DELETE grant for the app role, or a hash-chained log where each entry commits to the hash of the previous one so tampering is detectable. Pick whichever your compliance posture demands; the capture pattern below is the same regardless.

One thing I want to be honest about up front: custom metadata is not supported on Agent Accounts. On a normal connected grant you might be tempted to stamp an audit ID into message metadata and filter on it later. That door is closed here, and it's the wrong door anyway — metadata lives in the same mutable mailbox you're trying to audit around. The separate store isn't a workaround; it's the correct design.

The grant is the only abstraction you need

If you've used any grant-scoped Nylas endpoint, there's nothing new on the data plane. An Agent Account is just a grant with a grant_id. It addresses the same /v3/grants/{grant_id}/* routes as any other grant — Messages, Threads, Folders, Attachments, the lot. Same auth header, same payloads. The audit subsystem hangs off two of those routes plus one app-level webhook.

So the spine of this post is small:

  • Capture sends — when the agent sends, record the returned message_id and thread_id plus the payload.
  • Capture inbound — on the message.created webhook, fetch the full message and record it.
  • Read back — when an incident lands, reconstruct the conversation from your store by thread_id.

That's it. Everything else is plumbing around making the store immutable.

Before you begin

You need a registered domain (a custom domain, or a Nylas *.nylas.email trial subdomain) and an Agent Account on it. New domains warm over roughly four weeks before they're at full sending reputation, so provision early.

Provision via the API with POST /v3/connect/custom:

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",
    "settings": { "email": "support@yourcompany.com" },
    "name": "Support Bot"
  }'
Enter fullscreen mode Exit fullscreen mode

The response carries the grant id as data.id (the body is {"data": {"id": "<grant_id>", ...}}) — hold onto it; every audit capture is scoped to it. There's no refresh token to manage; an Agent Account has no OAuth flow to expire.

Or, the CLI line I actually type:

nylas agent account create support@yourcompany.com --name 'Support Bot'
Enter fullscreen mode Exit fullscreen mode

The API auto-creates a default workspace and policy. If you want a custom policy governing send limits or spam rules later, attach it with nylas workspace update <workspace-id> --policy-id <policy-id> — there's no --workspace flag on create.

:::info
New to Agent Accounts? Start with the Agent Accounts overview and the supported-endpoints reference, which lists every route a grant exposes.
:::

Capture every send

This is the easy half, and the half people forget. When you send, the API hands back the created message — including its id and thread_id. That response is your audit record for the outbound side. Capture it synchronously, in the same code path that does the send, before you return success to the caller. Don't rely on reading it back from the mailbox later; the mailbox is mutable.

Send with the API:

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": "customer@example.com" }],
    "subject": "Re: your refund request",
    "body": "Hi — your refund of $40 has been approved and will post in 3-5 days."
  }'
Enter fullscreen mode Exit fullscreen mode

The 200 response includes data.id (the message_id) and data.thread_id. Write a record like this to your append-only store the instant you get it:

{
  "direction": "outbound",
  "message_id": "<from data.id>",
  "thread_id": "<from data.thread_id>",
  "grant_id": "<GRANT_ID>",
  "to": ["customer@example.com"],
  "subject": "Re: your refund request",
  "body_sha256": "<hash of the body you sent>",
  "body": "Hi — your refund of $40 has been approved...",
  "captured_at": "<RFC3339 timestamp at write time>"
}
Enter fullscreen mode Exit fullscreen mode

Storing a body_sha256 alongside the body is cheap insurance: it's the per-record commitment you chain together if you want tamper-evidence across the whole log.

The CLI equivalent, with --json so you can pipe the response straight into your capture step:

nylas email send support@yourcompany.com \
  --to customer@example.com \
  --subject "Re: your refund request" \
  --body "Hi — your refund of \$40 has been approved and will post in 3-5 days." \
  --json
Enter fullscreen mode Exit fullscreen mode

When the agent replies inside an existing conversation, send with reply_to_message_id so the new message keeps the same thread_id. That thread continuity is what lets you reconstruct a full back-and-forth from the audit store later:

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": "customer@example.com" }],
    "reply_to_message_id": "<ORIGINAL_MESSAGE_ID>",
    "body": "Following up — the refund posted this morning."
  }'
Enter fullscreen mode Exit fullscreen mode

The CLI equivalent uses --reply-to:

nylas email send support@yourcompany.com \
  --to customer@example.com \
  --reply-to <ORIGINAL_MESSAGE_ID> \
  --body "Following up — the refund posted this morning." \
  --json
Enter fullscreen mode Exit fullscreen mode

A note on attachments, since auditors love asking about them: via the API you attach either Base64 strings in the JSON attachments array, or multipart where the file form field is named attachment (not file). nylas email send has no attachment flag — if the agent needs to send files, build the message as a draft with nylas email drafts create --attach and send that. Either way, record the attachment metadata (filename, size, content hash) in your audit entry; you rarely want to copy the bytes themselves into the log.

Capture every inbound message

Inbound is where the separate-store discipline earns its keep, because inbound mail arrives in a mailbox the agent can later modify or that retention can age out from under you.

The capture point is the message.created webhook. Two facts about Nylas webhooks shape how you wire this up, and getting them right is the difference between a clean audit log and a flaky one:

Webhooks are application-scoped, not grant-scoped. You subscribe once, at the app level, and every grant's events land at that one endpoint. Each payload carries a grant_id you filter on. So you don't create a webhook per Agent Account — you create one and route by grant_id.

Subscribe with the API:

curl --request POST \
  --url 'https://api.us.nylas.com/v3/webhooks' \
  --header 'Authorization: Bearer <NYLAS_API_KEY>' \
  --header 'Content-Type: application/json' \
  --data '{
    "trigger_types": ["message.created"],
    "webhook_url": "https://yourapp.com/hooks/nylas",
    "description": "Agent Account audit capture"
  }'
Enter fullscreen mode Exit fullscreen mode

Or with the CLI:

nylas webhook create \
  --url https://yourapp.com/hooks/nylas \
  --triggers message.created \
  --description "Agent Account audit capture"
Enter fullscreen mode Exit fullscreen mode

For local development you can run a receiver with nylas webhook server --tunnel cloudflared --secret <your-webhook-secret>, which verifies signatures on the way in and prints each event — handy for confirming your capture logic before you point it at production.

Don't trust the webhook body — fetch by id

Here's the rule that keeps you out of trouble: don't rely on the webhook payload for the message body — fetch the full message when you need it. The Nylas docs are a little inconsistent on whether the body ships inline (the general webhook doc says it's inline unless the message exceeds ~1 MB, at which point the trigger becomes message.created.truncated and the body is omitted; the agent-accounts reference leans toward summary fields). Both agree on the action, so do that:

  1. On message.created, read the data.object.id (the message_id) and grant_id.
  2. Branch on the trigger: if it's message.created.truncated, the body was dropped, so you must re-fetch.
  3. Fetch the canonical full message and write that to the audit store:
curl --request GET \
  --url 'https://api.us.nylas.com/v3/grants/<GRANT_ID>/messages/<MESSAGE_ID>' \
  --header 'Authorization: Bearer <NYLAS_API_KEY>'
Enter fullscreen mode Exit fullscreen mode

The CLI equivalent:

nylas email read <MESSAGE_ID> support@yourcompany.com --json
Enter fullscreen mode Exit fullscreen mode

Fetching by id gives you one source of truth for the body — the full message — instead of trying to reconcile an inline payload against a truncated one. For an audit log you want exactly one canonical version of each message, and this is how you get it.

Dedup on the notification id

Nylas guarantees at-least-once delivery: the same event can arrive up to three times. For an append-only store, that's a real hazard — naive code would write the same inbound message three times and your "immutable" log now has duplicates.

Dedup on the top-level notification id. That id is constant across all retries of one event, so it's the correct delivery-dedup key. Keep a set of processed notification ids (a unique constraint in the DB, or a short-TTL cache) and drop any you've already seen. As a second guard, you can also key on the inner data.object.id (the message_id) so you never act twice on the same message even across distinct events. Both together give you exactly-once semantics on top of at-least-once delivery.

And verify the signature before you process anything. Nylas signs each webhook with X-Nylas-Signature — a hex HMAC-SHA256 of the raw request body using your webhook secret. Recompute it over the raw bytes (not a re-serialized JSON object) and compare in constant time. If you use Node's crypto.timingSafeEqual, guard that both buffers are the same length first, because it throws on a length mismatch. The CLI can check a captured payload for you:

nylas webhook verify \
  --payload-file ./captured-body.json \
  --signature <X-Nylas-Signature value> \
  --secret <your-webhook-secret>
Enter fullscreen mode Exit fullscreen mode

Log the verification result, not the secret, as part of each audit entry — a reviewer wants to see that every recorded inbound event was authenticated.

Reading the trail back

When an incident lands, you read from your audit store, not the mailbox. Group your records by thread_id and you have the complete conversation — every inbound message and every outbound reply the agent produced, in order, with the body you captured at the time and the hash that proves it hasn't changed since.

You can cross-check against the live mailbox while the messages are still inside the retention window — GET /v3/grants/<GRANT_ID>/messages?thread_id=<THREAD_ID> or nylas email list support@yourcompany.com --json — but treat that as a convenience, not the record. Past the retention window, or after the agent has trashed something, the mailbox can't answer the question. Your append-only store can. That's the entire reason it exists.

A few gotchas worth calling out

"Delete" in the mailbox isn't permanent — and that's fine, because your audit store is the record anyway. DELETE /v3/grants/{id}/messages/{id} moves a message to Trash unless you pass ?hard_delete=true. The CLI nylas email delete only trashes. Thread deletion only trashes and has no hard-delete option at all. The only true wipe is deleting the grant itself (DELETE /v3/grants/{id} / nylas agent account delete). None of that touches your separate audit log, which is exactly the separation you want: erasure in the live mailbox and retention in the audit store are two independent decisions.

Capture sends synchronously; capture inbound idempotently. The send response is one-shot — if you don't record it in the send path, it's gone. The inbound webhook is at-least-once — record it idempotently or you'll get duplicates. Different failure modes, different handling.

Don't audit-log secrets. Record message metadata, bodies you're entitled to retain, hashes, timestamps, and verification results. Never write the webhook secret or the API key into the store. The point of the log is to prove what the agent communicated, not to become a second place your credentials leak from.

What's next

Build the two capture points, make the store append-only, and you can answer the question that actually matters when an agent has its own inbox: not "did it send mail," but "here is exactly what it said, to whom, and when — and here's the proof it hasn't changed."

AI-answer pages for agents

When this post is published, link AI agents and crawlers to the retrieval-ready version on cli.nylas.com:

Top comments (0)