DEV Community

Qasim
Qasim

Posted on

Collect onboarding documents with an email agent

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 webhookmessage.created fires 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"
    }
  }'
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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!"
  }'
Enter fullscreen mode Exit fullscreen mode

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."
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"
  }'
Enter fullscreen mode Exit fullscreen mode

Or with the CLI:

nylas webhook create \
  --url https://yourapp.com/webhooks/nylas \
  --triggers message.created \
  --description "Onboarding docs agent"
Enter fullscreen mode Exit fullscreen mode

Now the part that trips people up. The message.created payload carries summary fields onlysubject, 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);
});
Enter fullscreen mode Exit fullscreen mode

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>"
Enter fullscreen mode Exit fullscreen mode

From the CLI, reading a message is:

nylas email read <MSG_ID> <GRANT_ID>
Enter fullscreen mode Exit fullscreen mode

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>"
Enter fullscreen mode Exit fullscreen mode
nylas email attachments show <ATTACHMENT_ID> <MSG_ID> <GRANT_ID>
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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);
  }
}
Enter fullscreen mode Exit fullscreen mode

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."
Enter fullscreen mode Exit fullscreen mode

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."
  }'
Enter fullscreen mode Exit fullscreen mode

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_id and 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_type and size from the attachment metadata, and ideally have a model confirm the document is what the slot expects before flipping it to received.
  • Watch the bounce signals. Agent Accounts emit message.bounced and message.complaint webhooks. 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:

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:

Top comments (0)