DEV Community

Collect onboarding documents with an email agent

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_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.

Comments

No comments yet. Start the discussion.