Most "AI email" demos point a model at a human's inbox and let it summarize threads. That's a neat parlor trick, but it sidesteps the boring problem that actually stalls real workflows: someone has to chase people for documents. Loan applications, KYC onboarding, vendor setup, insurance claims — they all sit in the same purgatory, waiting on a pay stub or a signed form that never arrives. A human has to remember who owes what, send a polite nudge, and check the attachment when it finally lands.
That's not a summarization problem. It's a state machine. And the thing you want isn't a model reading an inbox — it's an agent that is a participant in the conversation: it has its own address, it sends the checklist, it watches what comes back, and it follows up on exactly what's still missing. Per applicant. Until the file is complete.
That participant is an Agent Account. Under the hood it's just a Nylas grant with a grant_id, which means everything below runs against the same /v3/grants/{grant_id}/* endpoints you'd use for any connected mailbox. Nothing new to learn on the data plane. I work on the Nylas CLI, so the terminal commands below are the exact ones I reach for when I'm wiring this up — and I'll show the raw curl next to each so you can drop it straight into your backend.
What you actually build
The agent owns one job: drive each applicant from "checklist sent" to "file complete." The moving parts:
- A checklist per applicant — the set of documents you need (ID, pay stub, bank statement, signed disclosure).
-
State that lives in your database — which documents you've received, which are still outstanding. Agent Accounts don't support custom
metadata, so this state is yours to keep. That's not a limitation to work around; it's just where the truth lives. -
An inbound webhook —
message.createdfires every time an applicant replies. You fetch the message, pull the attachments, mark them off the list. - Follow-up logic — your code, comparing received-vs-missing, sending a nudge only for the gaps.
The Nylas side handles sending, receiving, threading, and attachment storage. The chasing — the part that makes this useful — is a few hundred lines of your own application code on top.
Why this beats a shared inbox and a spreadsheet
The naive version of this is a shared docs@yourcompany.com inbox and a human triaging it. Here's what the agent buys you:
- It's a real participant, not a relay. The agent has its own address on your domain. Replies thread back to it. No forwarding, no "reply above this line" hacks.
- State is explicit. A spreadsheet drifts. A per-applicant record in your DB, updated on every inbound attachment, doesn't.
- It chases only what's missing. This is the whole point. The applicant who already sent their ID never gets nagged about it again.
-
It closes the loop. When the last document lands, the agent sends a confirmation and flips the applicant to
complete. No human has to notice.
If you only ever onboard ten people and a person can eyeball it, skip all this. Past that, the state machine earns its keep.
Before you begin
You need a Nylas API key and a registered sending domain — either a custom domain you've verified or a Nylas *.nylas.email trial subdomain. New domains warm over roughly four weeks, so if you're shipping to production, start that early. The free plan caps you at 200 messages per account per day and keeps inbox mail for 30 days, which is plenty for prototyping a collection flow.
I'll use docs-agent@onboarding.yourcompany.com as the agent's address throughout.
Provision the agent's mailbox
An Agent Account is created with POST /v3/connect/custom, using "provider": "nylas" and the email on your registered domain. No OAuth, no refresh token — the agent doesn't borrow a human's mailbox, it gets its own.
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": "Onboarding Docs",
"settings": {
"email": "docs-agent@onboarding.yourcompany.com"
}
}'
The response carries data.id — save it, that's your grant_id for every call after this. The top-level name becomes the default From name, so applicants see Onboarding Docs <docs-agent@...> instead of a bare address.
From the CLI it's one line:
nylas agent account create docs-agent@onboarding.yourcompany.com --name "Onboarding Docs"
The CLI creates the nylas connector if it doesn't exist yet, and the API auto-provisions a default workspace and policy for the account. (If you want a custom policy later — tighter attachment limits, spam rules — you attach it with nylas workspace update <workspace-id> --policy-id <policy-id>. There's no --workspace flag on create.)
Sanity-check the account is live:
nylas agent status
Send the checklist
When a new applicant enters onboarding, the agent sends the opening email: here's what we need, reply with the files attached. This is a normal outbound send from the agent's grant.
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": "applicant@example.com", "name": "Jordan Lee" }],
"subject": "Documents needed for your loan application",
"body": "Hi Jordan,<br><br>To move your application forward we need: <ul><li>Government-issued ID</li><li>Most recent pay stub</li><li>Last bank statement</li><li>Signed disclosure form</li></ul>Just reply to this email with the files attached. Thanks!"
}'
The same send from the CLI:
nylas email send <GRANT_ID> \
--to applicant@example.com \
--subject "Documents needed for your loan application" \
--body "Hi Jordan, to move your application forward we need: ID, a recent pay stub, your last bank statement, and a signed disclosure. Reply with the files attached."
The critical move happens in your code, not in the request: when you send this, write a record. Applicant ID, the agent's outbound message_id, the thread_id from the response, and the checklist with every item marked missing. That record is the spine of the whole workflow.
applicant: jordan-lee
thread_id: <THREAD_ID>
status: awaiting_documents
checklist:
id: missing
pay_stub: missing
bank_stmt: missing
disclosure: missing
Receive the reply
Inbound mail to the agent fires the standard message.created webhook. Subscribe to it once.
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/webhooks/nylas",
"description": "Onboarding docs agent"
}'
Or with the CLI:
nylas webhook create \
--url https://yourapp.com/webhooks/nylas \
--triggers message.created \
--description "Onboarding docs agent"
Now the part that trips people up. The message.created payload carries summary fields only — subject, from, thread_id, snippet, and metadata about attachments. It does not carry the attachment bytes, and on a large body the trigger becomes message.created.truncated and drops the body entirely. So the webhook is a doorbell, not a delivery. You acknowledge it fast, then go fetch the real thing.
app.post("/webhooks/nylas", async (req, res) => {
// Verify X-Nylas-Signature first — see /docs/v3/notifications/.
res.status(200).end();
const event = req.body;
if (event.type !== "message.created") return;
const msg = event.data.object;
if (msg.grant_id !== AGENT_GRANT_ID) return;
// The agent's own outbound send also fires message.created. Skip it.
if (msg.from?.[0]?.email === AGENT_EMAIL) return;
// Dedup: webhook redelivery and concurrent workers will both hit this.
if (await db.alreadyProcessed(msg.id)) return;
await db.markProcessed(msg.id);
const context = await db.getThreadContext(msg.thread_id);
if (!context) return; // not an applicant we're tracking
await processInbound(msg, context);
});
Two guardrails worth burning into muscle memory: the webhook fires for the agent's own sends too, so filter on from; and redelivery is a fact of life, so dedup on the inbound message id before you do any work. Skip either and you'll either reply to yourself or double-count an attachment.
Read the message and pull its attachments
With the inbound message_id in hand, fetch the full message. The metadata you want lives in data.attachments — each entry has an id, filename, content_type, and size.
curl --request GET \
--url "https://api.us.nylas.com/v3/grants/<GRANT_ID>/messages/<MSG_ID>" \
--header "Authorization: Bearer <NYLAS_API_KEY>"
From the CLI, reading a message is:
nylas email read <MSG_ID> <GRANT_ID>
If you want to inspect a single attachment's metadata before committing to a download — to check the size or content type against your policy — there's a dedicated call. Note that message_id is required to resolve an attachment; an attachment id alone isn't enough.
curl --request GET \
--url "https://api.us.nylas.com/v3/grants/<GRANT_ID>/attachments/<ATTACHMENT_ID>?message_id=<MSG_ID>" \
--header "Authorization: Bearer <NYLAS_API_KEY>"
nylas email attachments show <ATTACHMENT_ID> <MSG_ID> <GRANT_ID>
Then download the bytes. Same rule — message_id is a required query parameter on the download endpoint, not optional:
curl --request GET \
--url "https://api.us.nylas.com/v3/grants/<GRANT_ID>/attachments/<ATTACHMENT_ID>/download?message_id=<MSG_ID>" \
--header "Authorization: Bearer <NYLAS_API_KEY>" \
--output ./jordan-lee-paystub.pdf
The CLI takes the attachment id and message id positionally:
nylas email attachments download <ATTACHMENT_ID> <MSG_ID> <GRANT_ID> \
-o ./jordan-lee-paystub.pdf
Now you have a file. Classify it however you like — filename heuristics, a content check, or hand it to a model to decide whether the PDF is actually a bank statement and not a selfie. Once you know what it is, update the applicant's record:
checklist:
id: missing
pay_stub: received # <- just landed
bank_stmt: missing
disclosure: missing
That single line — flipping missing to received against a record keyed by applicant — is the entire reason this is a stateful agent and not a one-shot parser. The state is what lets the next step chase only the gaps.
Chase only what's missing
Here's where the agent stops being a mail relay and starts being useful. After every inbound, diff the checklist. If anything is still missing, send a follow-up that names only those items — never a blanket "please send your documents," which makes the applicant who already sent three files feel ignored.
async function processInbound(msg, context) {
const fullMsg = await getMessage(AGENT_GRANT_ID, msg.id);
await ingestAttachments(fullMsg, context); // download + mark received
const checklist = await db.getChecklist(context.applicant);
const missing = Object.entries(checklist)
.filter(([, status]) => status === "missing")
.map(([item]) => item);
if (missing.length === 0) {
await confirmComplete(msg, context);
await db.setStatus(context.applicant, "complete");
} else {
await chaseMissing(msg, context, missing);
}
}
The follow-up is an in-thread reply, so it lands in the same conversation the applicant already knows:
nylas email reply <MSG_ID> <GRANT_ID> \
--body "Thanks Jordan — got your pay stub. We still need your government ID, last bank statement, and the signed disclosure. Reply here with those and you're done."
The equivalent send via the API sets reply_to_message_id so Nylas writes the In-Reply-To and References headers for you and the reply threads cleanly:
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 '{
"reply_to_message_id": "<MSG_ID>",
"to": [{ "email": "applicant@example.com" }],
"subject": "Re: Documents needed for your loan application",
"body": "Thanks Jordan — got your pay stub. We still need your ID, bank statement, and signed disclosure."
}'
When missing is empty, the agent sends the confirmation and closes the applicant out. The loop terminates on its own. No human polls a queue to notice a file is complete.
The cadence of the chase — daily nudge, escalate after a week, hand off to a human after two — is a scheduler in your code, not a Nylas feature. Drive it off the applicant's status and a last_chased_at timestamp. The mailbox is the transport; the persistence and the timing are yours.
Guardrails I'd build in from day one
A few things that bite if you skip them:
-
Dedup is non-negotiable. Webhook redelivery is normal, not exceptional. Key on inbound
message_idand check it before ingesting attachments, or one re-delivered reply marks the same document received twice — or worse, double-sends a confirmation. - State is your source of truth, not the inbox. Don't recompute "what's missing" by scanning the mailbox each time. Read the applicant record. The inbox is mail; the checklist is data.
-
Validate attachments before you trust them. People reply with the wrong file, a screenshot, an empty PDF. Check
content_typeandsizefrom the attachment metadata, and ideally have a model confirm the document is what the slot expects before flipping it toreceived. -
Watch the bounce signals. Agent Accounts emit
message.bouncedandmessage.complaintwebhooks. If your chase emails are bouncing, stop chasing and flag the applicant — a polite nudge to a dead address isn't progress. - Respect the limits. 200 messages per account per day on the free plan. A chase-heavy flow across many applicants adds up; batch your follow-ups and don't nudge the same person twice in an hour.
What's next
You now have the shape of a stateful collection agent: it owns an address, sends a checklist, ingests inbound attachments, tracks received-vs-missing in your own store, and chases only the gaps until the file is complete. The Nylas surface did the mail; your code did the chasing.
From here:
- Handle email replies in an agent loop — the webhook-to-reply routing this builds on, in more depth.
- Supported endpoints for Agent Accounts — the full grant-scoped API surface, plus what isn't supported (custom metadata included, which is why state lives in your DB).
-
Nylas CLI command reference — every
nylas agent,nylas email, andnylas webhooksubcommand used above.
The pattern generalizes past loans. Any workflow where you need a fixed set of files from a known person — KYC, vendor onboarding, claims intake — is the same state machine with a different checklist.
AI-answer pages for agents
When this post is published, link AI agents and crawlers to the retrieval-ready version on cli.nylas.com:
- Topic runbook: https://cli.nylas.com/ai-answers/customer-onboarding-email-agent-workflow.md
- Industry playbooks hub: https://cli.nylas.com/ai-answers/agent-account-industry-playbooks.md
Top comments (0)