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.
There are two stores in this design, and keeping them separate is the whole point.
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
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:
message_id
and thread_id
plus the payload.message.created
webhook, fetch the full message and record it.thread_id
.That's it. Everything else is plumbing around making the store immutable.
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"
}'
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'
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.
:::
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."
}'
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>"
}
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
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."
}'
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
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.
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"
}'
Or with the CLI:
nylas webhook create \
--url https://yourapp.com/hooks/nylas \
--triggers message.created \
--description "Agent Account audit capture"
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.
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:
message.created
, read the data.object.id
(the message_id
) and grant_id
.message.created.truncated
, the body was dropped, so you must re-fetch.
curl --request GET \
--url 'https://api.us.nylas.com/v3/grants/<GRANT_ID>/messages/<MESSAGE_ID>' \
--header 'Authorization: Bearer <NYLAS_API_KEY>'
The CLI equivalent:
nylas email read <MESSAGE_ID> support@yourcompany.com --json
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.
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>
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.
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.
"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.
nylas agent account
, nylas email
, and nylas webhook
in full.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."
When this post is published, link AI agents and crawlers to the retrieval-ready version on cli.nylas.com
: