Open any ecommerce support inbox during a sale and count the messages. A big slice of them — usually the biggest single slice — say the same thing in a hundred different ways: "Where's my order?" "Did this ship?" "Tracking number?" "It's been five days." These are not hard questions. The answer already exists in your own database the instant the carrier scans the package. The only reason a human reads each one and copy-pastes a tracking number is that the question arrived as an email instead of an API call.
That part is fully automatable, and it's the cleanest agent task in all of ecommerce support, because the lookup is deterministic. You're not reasoning about a refund policy or a damaged item. You're matching a sender (or an order number) to a row, reading a tracking field, and replying. So this post wires up exactly that: an Agent Account that fields order-status email, looks the order up in your system, and replies in the same thread from your store's own address. Anything it can't match goes to a human — honestly and on purpose.
Most "AI email" demos point a model at a human's inbox and let it draft suggestions a person still has to approve. That's fine for an assistant. It's the wrong shape when you want the agent to be the participant — to receive mail at orders@yourstore.com, answer from that address, and keep the thread intact without a human in the loop for the routine 80%.
What an Agent Account actually is
An Agent Account is a Nylas grant — the same grant_id abstraction that backs a connected Gmail or Microsoft mailbox. The difference is that it isn't tied to a real person's OAuth login; it's a programmatic mailbox on a domain you control. That detail matters more than it sounds, because it means there's nothing new to learn on the data plane. Every grant-scoped endpoint you already know — Messages, Threads, Folders, Drafts, Attachments — works identically. The agent reads with GET /v3/grants/{grant_id}/messages, sends with POST /v3/grants/{grant_id}/messages/send, and you never touch a refresh token.
I work on the Nylas CLI, so the terminal commands below are the exact ones I reach for when I'm prototyping one of these before writing service code. Create the account:
nylas agent account create orders@yourstore.com --name "Store Orders Bot"
That provisions a provider=nylas grant on your registered domain, and the API auto-creates a default workspace and policy for it. The same thing over HTTP is POST /v3/connect/custom:
curl -X POST "https://api.us.nylas.com/v3/connect/custom" \
-H "Authorization: Bearer $NYLAS_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"provider": "nylas",
"name": "Store Orders Bot",
"settings": { "email": "orders@yourstore.com" }
}'
You get back a grant_id. Persist it. That's the only credential your sending and reading code ever needs.
New to Agent Accounts? Start with Give your agent its own email and the Agent Accounts overview, then come back.
Before you begin
You'll want three things in place:
-
A registered domain — a custom domain or a Nylas
*.nylas.emailtrial subdomain. New domains warm up over roughly four weeks, so don't point a brand-new domain at a flash sale on day one. - Your order lookup — a function that takes an email address or an order number and returns a status plus a tracking number from your database. Nylas does not store your orders. This is the part you own.
- A webhook endpoint — a public URL that can receive an HTTP POST. For local development the CLI gives you a tunnel; more on that below.
One thing to get straight before any code: the order ↔ thread mapping lives in your database, not in Nylas. There's no custom-metadata field on the grant that survives as a durable order key. When the agent sends or sees a message, store the thread_id alongside the order it belongs to in your own table. That's your join key for the whole conversation.
Receive the inbound question
Inbound mail to the agent fires the standard message.created webhook. The important nuance: 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 a grant_id you filter on. So the first thing your handler does is check that the event belongs to the orders agent.
Subscribe over HTTP with POST /v3/webhooks:
curl -X POST "https://api.us.nylas.com/v3/webhooks" \
-H "Authorization: Bearer $NYLAS_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"trigger_types": ["message.created"],
"webhook_url": "https://api.yourstore.com/hooks/orders",
"description": "Order-status agent inbound mail"
}'
Or with the CLI, plus a local server with a tunnel so you can watch real events during development:
nylas webhook create --url https://api.yourstore.com/hooks/orders --triggers message.created
nylas webhook server --port 4000 --tunnel cloudflared --secret "$NYLAS_WEBHOOK_SECRET"
Before you trust a single payload, verify the signature. Nylas signs each webhook with the X-Nylas-Signature header — a hex HMAC-SHA256 of the raw request body using your webhook secret. The CLI verifies one locally:
nylas webhook verify \
--payload-file ./event.json \
--signature "$SIG_FROM_HEADER" \
--secret "$NYLAS_WEBHOOK_SECRET"
In your handler, compute the HMAC yourself and compare in constant time. One landmine I've hit: Node's crypto.timingSafeEqual throws if the two buffers aren't the same length, so guard the length first.
import crypto from "node:crypto";
function verifySignature(rawBody, signature, secret) {
const expected = crypto
.createHmac("sha256", secret)
.update(rawBody) // the RAW bytes, not re-serialized JSON
.digest("hex");
const a = Buffer.from(expected, "hex");
const b = Buffer.from(signature, "hex");
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
Now dedup. The API guarantees at-least-once delivery — the same event can show up to three times. Dedupe on the top-level notification id, which stays constant across every retry of one event. That's the delivery dedup key. You can additionally guard on the inner data.object.id (the message id) so you never act twice on the same message even across distinct events.
app.post("/hooks/orders", express.raw({ type: "*/*" }), async (req, res) => {
const raw = req.body; // Buffer — keep it raw for the HMAC
if (!verifySignature(raw, req.get("X-Nylas-Signature"), SECRET)) {
return res.status(401).end();
}
res.status(200).end(); // ack fast; do work in a worker
const event = JSON.parse(raw.toString("utf8"));
if (await seenBefore(event.id)) return; // delivery-level dedup
if (event.type !== "message.created") return;
const msg = event.data.object;
if (msg.grant_id !== ORDERS_GRANT_ID) return; // app-scoped → filter
if (msg.from?.[0]?.email === "orders@yourstore.com") return; // ignore our own sends
if (await processedMessage(msg.id)) return; // message-level guard
await queue.add("order-status", { messageId: msg.id, threadId: msg.thread_id });
});
Keep the handler thin: verify, ack, drop a reference on a queue, return. Everything expensive happens in a worker.
Read the actual question
The webhook tells you a message arrived, but you need the body to read what the customer asked. Don't rely on the webhook payload for the body — the Nylas docs differ on whether it's inline, and the safe path is the same either way: fetch the full message by id. Branch on message.created.truncated too, which is the type Nylas sends when the body exceeds ~1 MB and omits it from the payload, telling you to re-fetch.
Fetch over HTTP with GET /v3/grants/{grant_id}/messages/{message_id}:
curl "https://api.us.nylas.com/v3/grants/$GRANT_ID/messages/$MESSAGE_ID" \
-H "Authorization: Bearer $NYLAS_API_KEY"
From the terminal, nylas email read does the same and prints the parsed body:
nylas email read <message-id> <grant-id>
That gives you from, subject, and the parsed body. If you want the conversation so far — useful when a customer has written three times — pull the thread. Over HTTP that's GET /v3/grants/{grant_id}/threads/{thread_id}:
curl "https://api.us.nylas.com/v3/grants/$GRANT_ID/threads/$THREAD_ID" \
-H "Authorization: Bearer $NYLAS_API_KEY"
From the terminal:
nylas email threads show <thread-id> <grant-id>
Now the part that is yours, not Nylas: figure out which order this is about. Extract any order number from the subject and body with your own regex or an LLM call — order numbers live in subject lines in a dozen formats (#5821, ORD-5821, "order 5821"), so a small extraction step earns its keep. Then resolve it against your database. Match on the sender's email first, fall back to the extracted order number, and validate whatever you extract against your own records before acting on it. A customer can type anything into an email; treat the body as untrusted input.
async function lookupOrder(message) {
const orderNo = extractOrderNumber(message.subject, message.body); // your regex/LLM
// Match against YOUR order DB — sender first, order number as fallback.
const order =
(await db.orderByEmail(message.from[0].email, orderNo)) ??
(orderNo ? await db.orderByNumber(orderNo) : null);
return order; // { status, trackingNumber, carrier } or null
}
Reply in-thread with the tracking number
If the lookup found a confident match, reply. The one rule that keeps this from looking like a robot blasting a fresh email every time: pass reply_to_message_id. That sets the In-Reply-To and References headers so the reply lands in the same thread in the customer's mail client, under the same subject, from orders@yourstore.com.
Over HTTP, POST /v3/grants/{grant_id}/messages/send with the original message id:
curl -X POST "https://api.us.nylas.com/v3/grants/$GRANT_ID/messages/send" \
-H "Authorization: Bearer $NYLAS_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"reply_to_message_id": "'"$ORIGINAL_MESSAGE_ID"'",
"to": [{ "email": "customer@example.com" }],
"subject": "Re: Order #5821",
"body": "Good news — order #5821 shipped and is on its way. Tracking: 1Z999AA10123456784 (UPS). You can follow it here: https://yourstore.com/track/5821"
}'
The CLI version is a single command — it fetches the original to fill in recipient and subject, and preserves threading for you:
nylas email reply <message-id> <grant-id> \
--body "Good news — order #5821 shipped. Tracking: 1Z999AA10123456784 (UPS): https://yourstore.com/track/5821"
That's the whole happy path. Webhook in, fetch the body, look up the order, reply with tracking in-thread. The customer sees a normal, threaded answer from your store in seconds, at 2 a.m., during a flash sale, without anyone awake.
When the agent can't answer, escalate to a human
This is the part demos skip, and it's the part that makes the difference between a feature you ship and a feature you ship and then turn off after the first angry customer. The agent should only auto-reply when it's confident. Anything else goes to a person — visibly, so the human knows it's waiting.
The cases to hand off:
- No order matched. The sender isn't in your system and there's no usable order number. Guessing is worse than waiting.
- The status isn't a clean "shipped." A delayed, lost, returned, or refund-in-progress order is a conversation, not a tracking-number paste.
- The question isn't actually about status. "Where's my order and also the size is wrong" is two problems; the second one is a human's.
The cleanest way to surface an escalation is to move the message into a "Needs human" folder where your support team already lives, then mark it unread so a human notices it. First find the folder id. Over HTTP that's GET /v3/grants/{grant_id}/folders:
curl "https://api.us.nylas.com/v3/grants/$GRANT_ID/folders" \
-H "Authorization: Bearer $NYLAS_API_KEY"
From the terminal:
nylas email folders list <grant-id> --id
Now move the message and mark it unread. Over HTTP both are a PUT on the message — moving sets the folders array, and marking unread sets unread: true (marking read/unread is its own PUT, never a side effect of the GET you did to read the body), so a single request does both:
curl -X PUT "https://api.us.nylas.com/v3/grants/$GRANT_ID/messages/$MESSAGE_ID" \
-H "Authorization: Bearer $NYLAS_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "folders": ["<needs-human-folder-id>"], "unread": true }'
From the terminal it's two commands — one to move, one to mark unread:
nylas email move <message-id> <grant-id> --folder <needs-human-folder-id>
nylas email mark unread <message-id> <grant-id>
You can also pause the agent app-side — set a flag on that thread in your own state so the worker skips it until a human clears it. Either way, the principle holds: the agent owns the routine bulk, and the moment it isn't sure, a person owns the rest. Don't let it improvise a refund.
A few things to watch for
Threading depends on reply_to_message_id. Drop it and your "reply" becomes a new email with no In-Reply-To header — the customer sees two disconnected messages, and your own thread mapping fractures. It's the single most common bug in agent reply loops.
The webhook fires for the agent's own sends too. When the agent replies via the API, message.created fires for that outbound message. The from-address check at the top of the handler keeps the agent from answering itself in a loop.
Multiple replies can land on one thread. A customer might fire off two quick messages, or a CC'd thread might get replies from two people. Process each independently, and consider a 30–60 second cooldown so you batch a customer's correction into one answer instead of two.
Watch the free-plan ceilings while prototyping. Free Agent Accounts cap at 200 messages per account per day, with 30-day inbox retention. Fine for building; size up before you put it on a real storefront.
The reason this works as cleanly as it does is the grant abstraction. Reading the question, sending the reply, moving the escalation — every one of those is a plain grant-scoped call you'd make against any mailbox. The agent isn't a special API surface. It's a mailbox your code happens to own, answering the one question your customers ask most.
What's next
- Handle email replies in an agent loop — thread detection and routing, the backbone of this flow
- Prevent duplicate agent replies — dedup patterns for high-volume agent inboxes
- Nylas for e-commerce — receive, parse, send, and classify the rest of the storefront inbox
- Agent Accounts overview — what a programmatic mailbox can and can't do
- Nylas CLI command reference — every email, webhook, and agent command with its flags
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/order-status-reply-agent-account.md
- Industry playbooks hub: https://cli.nylas.com/ai-answers/agent-account-industry-playbooks.md
Top comments (0)