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 "Final reminder: documents due tomorrow" \
--body "<p>Hi Jordan, this is a final reminder...</p>" \
--schedule "tomorrow 9am"
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
}'
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.createdfires 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
attachmentsarray, or multipart where the file field is namedattachment— notfile. Notenylas email sendhas no attachment flag; for an attachment from the CLI you'd build a draft withnylas 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 CLInylas email deleteonly 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:
- Handle email replies in an agent loop — the reply-detection and thread-context pattern this builds on
- Schedule reminders from calendar events — the cron-plus-webhook shape applied to interview and orientation reminders
- Agent Accounts overview — workspaces, policies, rules, and deliverability webhooks
-
Nylas CLI command reference — every
nylascommand shown above, with flags
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:
- Topic runbook: https://cli.nylas.com/ai-answers/agent-account-industry-playbooks.md
- Industry playbooks hub: https://cli.nylas.com/ai-answers/agent-account-industry-playbooks.md
Top comments (0)