DEV Community

Qasim
Qasim

Posted on

Run an enrollment agent from a school's admissions inbox

Admissions inboxes are a grind. Most of what lands in admissions@yourschool.edu is some variation of the same three questions — what's the deadline, what documents do I still need, did you get my transcript — interleaved with the documents themselves arriving as PDF attachments. A human reads each one, looks up the applicant in the SIS, checks which docs are still outstanding, and writes back. Then, a week before the deadline, someone exports a spreadsheet and manually nudges everyone who hasn't finished.

The naive "AI" version of this points an LLM at a counselor's personal mailbox and lets it draft replies. That works right up until you want the agent to be the admissions desk — to send mail under its own address, receive replies in its own thread, hold the application state for every applicant, and fire deadline reminders without a human in the loop. A drafting assistant bolted onto a person's inbox can't do that. It has no identity of its own.

This post builds the version that does, on a Nylas Agent Account. I work on the Nylas CLI, so every step below is shown twice: the raw curl against the API, and the nylas command I'd actually type. Pick whichever fits where the operation lives — your webhook worker probably speaks HTTP, but your ops runbook and your one-off debugging speak CLI.

What an Agent Account actually is

Here's the part that makes this tractable: an Agent Account is just a grant. Same grant_id you'd get from connecting a Gmail or Microsoft account, except it isn't backed by a human's mailbox — it's a first-class inbox that your application owns. That means nothing new to learn on the data plane. Every grant-scoped endpoint you already know — Messages, Threads, Attachments, Drafts, Folders — works exactly the same. The agent just happens to be the account.

You create one by pointing POST /v3/connect/custom at a registered domain:

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": "Admissions Bot",
    "settings": { "email": "admissions@youruniversity.nylas.email" }
  }'
Enter fullscreen mode Exit fullscreen mode

Or, the way I'd do it from a terminal:

nylas agent account create admissions@youruniversity.nylas.email --name "Admissions Bot"
Enter fullscreen mode Exit fullscreen mode

No OAuth dance, no refresh token, no provider quirks. The API auto-provisions a default workspace and policy for the account. If you need a custom policy later — say, to constrain what the agent can do — you attach it with nylas workspace update <workspace-id> --policy-id <policy-id>. There's no --workspace flag on create; the workspace comes for free.

For production you'd use your own custom domain. For prototyping, a *.nylas.email trial subdomain works, with the caveat that new sending domains warm up over roughly four weeks before they're trusted by the big mailbox providers. Free-plan limits are worth knowing up front too: 200 messages per account per day, 3 GB of storage per org, and a 30-day inbox retention window. For a small admissions cycle that's plenty; for a whole incoming class, plan your domain and plan.

Full details live in the Agent Accounts docs.

What lives in your code, and what lives in Nylas

Before the wiring, a clear line between the two halves, because it's the thing people get wrong.

Nylas gives you the mail plane: receiving messages, fetching bodies, downloading attachments, sending replies and reminders. The brains — classifying a question as an FAQ, deciding which checklist item a PDF satisfies, knowing that applicant #4821 is missing a recommendation letter and their deadline is March 1 — is entirely your application code plus your LLM.

In particular: there's no custom-metadata field on the grant to stash application state. So the per-applicant record — which documents have arrived, which are outstanding, the deadline, the last reminder you sent — lives in your own database. Nylas tells you an email happened; your DB remembers what it means for that applicant. Keep that boundary sharp and the rest falls into place.

Receive the inbound mail

Inbound email to the agent fires the standard message.created webhook. One thing to internalize: webhooks are application-scoped, not grant-scoped. You subscribe once at the app level, and events for every grant in your app arrive at that one endpoint, each payload carrying the grant_id you filter on.

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"],
    "description": "Admissions agent inbound mail",
    "webhook_url": "https://admissions.yourschool.edu/webhooks/nylas",
    "notification_email_addresses": ["ops@yourschool.edu"]
  }'
Enter fullscreen mode Exit fullscreen mode

Or from the terminal:

nylas webhook create \
  --url https://admissions.yourschool.edu/webhooks/nylas \
  --triggers message.created \
  --description "Admissions agent inbound mail" \
  --notify ops@yourschool.edu
Enter fullscreen mode Exit fullscreen mode

Your handler does three things, in this order: respond 200 immediately, verify the signature, then dedupe. Nylas signs each delivery with X-Nylas-Signature, a hex HMAC-SHA256 of the raw request body using your webhook secret. Verify before you trust anything in the payload. (The CLI ships nylas webhook verify if you want to check a captured payload locally.)

On dedup: the API guarantees at-least-once delivery and will retry the same event up to three times. The dedup key is the top-level notification id — it stays constant across all retries of one event. The inner data.object.id is the message id, which you may additionally guard on so two concurrent workers don't both act on the same applicant email.

import crypto from "crypto";

app.post("/webhooks/nylas", express.raw({ type: "*/*" }), (req, res) => {
  const sig = req.headers["x-nylas-signature"];
  const digest = crypto
    .createHmac("sha256", process.env.NYLAS_WEBHOOK_SECRET)
    .update(req.body) // the raw buffer, not parsed JSON
    .digest("hex");

  const a = Buffer.from(sig, "utf8");
  const b = Buffer.from(digest, "utf8");
  // timingSafeEqual throws on length mismatch — guard first.
  if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
    return res.status(401).end();
  }
  res.status(200).end();

  const event = JSON.parse(req.body.toString());
  if (event.type !== "message.created") return;
  if (alreadyProcessed(event.id)) return; // dedup on notification id
  if (event.data.object.grant_id !== AGENT_GRANT_ID) return;

  void handleInbound(event.data.object);
});
Enter fullscreen mode Exit fullscreen mode

One nuance on the payload body. The Nylas docs are not fully consistent on whether the message body arrives inline in the message.created payload — the agent-accounts cookbook treats the payload as summary fields, while the general webhook doc says the body is inline unless the message exceeds about 1 MB (in which case the type becomes message.created.truncated). Both agree on the safe move, so do that: don't rely on the payload for the body — fetch the full message by id when you need it, and branch on message.created.truncated.

Read the full message

When the inbound message is a question or carries documents, fetch it in full. Marking it read is a separate operation — a GET does not flip the unread flag, so don't expect it to.

curl --request GET \
  --url 'https://api.us.nylas.com/v3/grants/<NYLAS_GRANT_ID>/messages/<MESSAGE_ID>' \
  --header 'Authorization: Bearer <NYLAS_API_KEY>'
Enter fullscreen mode Exit fullscreen mode
nylas email read <message-id> <grant-id>
Enter fullscreen mode Exit fullscreen mode

If you want to mark it read at the same time, the CLI has a flag for that — nylas email read <message-id> --mark-read — which is convenient when you're triaging by hand. In code, marking read is PUT /v3/grants/{id}/messages/{id} with {"unread": false}. Keep fetch and mark-read distinct; conflating them is a classic source of "why is everything still bold" bugs.

For the conversation chain — useful when an applicant replies to a reminder you sent — pull the thread:

curl --request GET \
  --url 'https://api.us.nylas.com/v3/grants/<NYLAS_GRANT_ID>/threads/<THREAD_ID>' \
  --header 'Authorization: Bearer <NYLAS_API_KEY>'
Enter fullscreen mode Exit fullscreen mode
nylas email threads show <thread-id> <grant-id>
Enter fullscreen mode Exit fullscreen mode

Answer the admissions FAQ

This is where your LLM earns its keep. Classify the inbound message: is it a known admissions question ("when is the priority deadline?", "what's the minimum TOEFL?", "do you need official transcripts or are scans fine?") or something that needs a human? For the known ones, your app composes the answer from your own FAQ corpus and the applicant's record, then replies in-thread so the exchange stays a single conversation in the applicant's mail client.

The API call is messages/send with reply_to_message_id set to the message you're answering:

curl --request POST \
  --url 'https://api.us.nylas.com/v3/grants/<NYLAS_GRANT_ID>/messages/send' \
  --header 'Authorization: Bearer <NYLAS_API_KEY>' \
  --header 'Content-Type: application/json' \
  --data '{
    "reply_to_message_id": "<MESSAGE_ID>",
    "to": [{ "email": "applicant@example.com", "name": "Jordan Rivera" }],
    "subject": "Re: Question about transcript deadline",
    "body": "<p>Hi Jordan,</p><p>The priority deadline is <strong>March 1</strong>. We accept uploaded scans for review, but official transcripts must arrive before enrollment.</p>"
  }'
Enter fullscreen mode Exit fullscreen mode

The CLI version is shorter because it fetches the original to fill in recipient and subject for you:

nylas email reply <message-id> --body "<p>Hi Jordan,</p><p>The priority deadline is <strong>March 1</strong>. We accept scans for review; official transcripts are required before enrollment.</p>"
Enter fullscreen mode Exit fullscreen mode

nylas email reply threads via the message's reply_to_message_id automatically, so the reply groups with the original conversation. Use --all if the applicant looped in a parent or a counselor and you want everyone on the response.

A guardrail worth stating plainly: routing by subject or message content is not something a Nylas inbound Rule can do. Inbound rules match only on from.* fields — address, domain, TLD. So "is this an FAQ vs. a document submission vs. escalate-to-human" is a decision your app makes after the webhook, by fetching and classifying. Don't reach for Rules to do content routing; they can't see the subject.

Track submitted application documents

A big slice of admissions mail isn't questions — it's documents. Transcripts, recommendation letters, test-score reports, financial forms, all arriving as attachments. The agent's job is to pull each one down, figure out which checklist item it satisfies, and update the applicant's record.

A fetched message lists its attachments with ids. Download one with the attachment id and its message id — and note the API quirk: the download endpoint requires the message_id query parameter, it won't work without it.

curl --request GET \
  --url 'https://api.us.nylas.com/v3/grants/<NYLAS_GRANT_ID>/attachments/<ATTACHMENT_ID>/download?message_id=<MESSAGE_ID>' \
  --header 'Authorization: Bearer <NYLAS_API_KEY>' \
  --output transcript.pdf
Enter fullscreen mode Exit fullscreen mode

The CLI takes both ids positionally and writes the file with -o:

nylas email attachments download <attachment-id> <message-id> <grant-id> -o transcript.pdf
Enter fullscreen mode Exit fullscreen mode

If you only need the metadata first — filename, content type, size — before deciding whether to pull the bytes, fetch that without downloading. The metadata endpoint takes the same message_id query parameter:

curl --request GET \
  --url 'https://api.us.nylas.com/v3/grants/<NYLAS_GRANT_ID>/attachments/<ATTACHMENT_ID>?message_id=<MESSAGE_ID>' \
  --header 'Authorization: Bearer <NYLAS_API_KEY>'
Enter fullscreen mode Exit fullscreen mode
nylas email attachments show <attachment-id> <message-id> <grant-id>
Enter fullscreen mode Exit fullscreen mode

From there it's your logic: run the PDF through whatever classifier or OCR step tells you this is a transcript for applicant #4821, then mark that checklist item complete in your database. Update the per-applicant record — transcript: received, recompute which documents are still outstanding — and you've got the state you need to drive reminders. Again: Nylas hands you the file; your DB remembers what it satisfied.

Send deadline reminders on a cadence

This is the piece that separates an enrollment agent from a generic document-collection bot. Applicants with outstanding documents need nudging on a schedule that tightens as the deadline approaches — a friendly note three weeks out, a firmer one at one week, a final one the day before.

The cadence itself is your code: a cron job (or any scheduler) that wakes up, queries your DB for applicants who are (a) missing documents and (b) due for a reminder based on their deadline and when you last contacted them, then sends a personalized message to each. The deadline per applicant and the reminder schedule live in your database — there's no Nylas-side cadence engine. Nylas just sends the mail.

curl --request POST \
  --url 'https://api.us.nylas.com/v3/grants/<NYLAS_GRANT_ID>/messages/send' \
  --header 'Authorization: Bearer <NYLAS_API_KEY>' \
  --header 'Content-Type: application/json' \
  --data '{
    "to": [{ "email": "applicant@example.com", "name": "Jordan Rivera" }],
    "subject": "Reminder: 2 documents due before March 1",
    "body": "<p>Hi Jordan,</p><p>Your application is missing your <strong>official transcript</strong> and one <strong>recommendation letter</strong>. The deadline is March 1. Reply to this email with the documents attached, or upload them in your portal.</p>"
  }'
Enter fullscreen mode Exit fullscreen mode
nylas email send \
  --to applicant@example.com \
  --subject "Reminder: 2 documents due before March 1" \
  --body "<p>Hi Jordan,</p><p>Your application is missing your official transcript and one recommendation letter. The deadline is March 1.</p>"
Enter fullscreen mode Exit fullscreen mode

There's a nice shortcut for time-of-day delivery. Rather than holding a job open or scheduling your own wake-up, you can hand the send a future time and let Nylas hold it. nylas email send takes a --schedule flag that accepts durations (1d, 2h), clock times, or natural language:

nylas email send \
  --to applicant@example.com \
  --subject "Final reminder: documents due tomorrow" \
  --body "<p>Hi Jordan, this is a final reminder...</p>" \
  --schedule "tomorrow 9am"
Enter fullscreen mode Exit fullscreen mode

Over the API, the same deferred send is a send_at field on the send body — a Unix timestamp for when Nylas should release the message:

curl --request POST \
  --url 'https://api.us.nylas.com/v3/grants/<NYLAS_GRANT_ID>/messages/send' \
  --header 'Authorization: Bearer <NYLAS_API_KEY>' \
  --header 'Content-Type: application/json' \
  --data '{
    "to": [{ "email": "applicant@example.com", "name": "Jordan Rivera" }],
    "subject": "Final reminder: documents due tomorrow",
    "body": "<p>Hi Jordan, this is a final reminder...</p>",
    "send_at": 1709298000
  }'
Enter fullscreen mode Exit fullscreen mode

So your nightly cron can decide who gets a reminder and when it should land, then schedule each one for, say, 9 a.m. local-ish time instead of blasting them all at 2 a.m. when the job happens to run. I reach for --schedule "tomorrow 9am" and --schedule 1d constantly for exactly this — it moves the "send at a civilized hour" problem off your scheduler and onto the API.

When an applicant replies to a reminder with the missing PDF attached, you're back at the top of the loop: message.created fires, you fetch the message, download the attachment, mark the checklist item done, and — ideally — stop reminding them about that document. Closing the loop matters. Nothing erodes trust like a "you're missing your transcript" email sent the morning after they submitted it.

Guardrails and gotchas

A handful of things that'll bite if you skip them:

  • The webhook fires for outbound mail too. When the agent sends a reply or reminder, message.created fires for that sent message. Filter on the sender at the top of your handler, or the agent will cheerfully try to process its own outgoing mail.
  • Dedupe on the notification id, always. Retries and concurrent workers both re-trigger your handler. Without the dedup guard you'll double-answer questions and double-send reminders — and double-reminding an anxious applicant is exactly the failure mode this whole system exists to prevent.
  • State is yours. There's no custom-metadata escape hatch on the grant. Checklist progress, deadlines, and reminder history belong in your database, keyed by applicant.
  • Sending attachments (a confirmation packet, say) goes through the Messages/Drafts API as Base64 strings in the attachments array, or multipart where the file field is named attachment — not file. Note nylas email send has no attachment flag; for an attachment from the CLI you'd build a draft with nylas email drafts create --attach.
  • Deletes trash, they don't wipe. DELETE /v3/grants/{id}/messages/{id} moves a message to Trash unless you pass ?hard_delete=true. The CLI nylas email delete only trashes. If you ever need to fully purge an applicant's data, the only true wipe is deleting the grant itself — nylas agent account delete.

What's next

You've got the full loop: a dedicated admissions identity, FAQ answers in-thread, document tracking off attachments, and a deadline-reminder cadence driven by your own scheduler. The same grant abstraction carries you further:

Give the agent its own inbox, keep the application state in your database, and let Nylas be the mail plane. The admissions desk stops being a queue someone has to babysit.

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)