DEV Community

Run an enrollment agent from a school's admissions inbox

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"
    }
  }'

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

nylas agent account create admissions@youruniversity.nylas.email --name "Admissions Bot"

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"]
  }'

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

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);
});

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>'
nylas email read <message-id> <grant-id>

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>'
nylas email threads show <thread-id> <grant-id>

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>"
  }'

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>"

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

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

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>'
nylas email attachments show <attachment-id> <message-id> <grant-id>

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>"
  }'
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>"

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 "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>" \
  --schedule "tomorrow 9am"

Comments

No comments yet. Start the discussion.