DEV Community

Qasim Muhammad
Qasim Muhammad

Posted on • Originally published at developer.nylas.com

Migrating From Transactional Email to Agent Accounts

This is what most agent email code looks like today:

// SendGrid / Resend / Postmark — outbound only
await sendgrid.send({
  to: "prospect@example.com",
  from: "outreach@yourcompany.com",
  subject: "Following up on your demo request",
  html: "<p>Hi Alice — wanted to follow up on...</p>",
});
// That's it. If Alice replies, the agent never sees it.
Enter fullscreen mode Exit fullscreen mode

The send works fine. The problem is everything after: when Alice replies, that reply bounces, lands at a no-reply nobody reads, or hits a human inbox the agent can't reach programmatically. The agent is talking into a void. Transactional providers were built for receipts and password resets — one-way mail — and an agent that's supposed to hold a conversation needs a receive path those APIs simply don't have.

Agent Accounts (a beta feature from Nylas) close that gap with a full hosted mailbox: send and receive, with threading, webhooks, and folders built in. Here's what the migration actually involves.

The delta, honestly

Outbound barely changes — it's still an API call. The new parts are everything transactional providers never gave you:

Concern Transactional provider Agent Account
Outbound API call Same — POST /messages/send
Inbound None (or polling a shared inbox) Replies land automatically, fire message.created
Threading You track Message-ID yourself Headers preserved, threads grouped automatically
Reply detection Parse forwards, poll Webhook within seconds of arrival
DNS SPF/DKIM/DMARC for the provider MX, SPF, DKIM, DMARC for the mailbox host

Provision the mailbox

One call creates the account; the response includes a grant_id that identifies it on every later request:

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",
    "settings": { "email": "outreach@agents.yourcompany.com" }
  }'
Enter fullscreen mode Exit fullscreen mode

(Or nylas agent account create outreach@agents.yourcompany.com from the CLI.) Custom domains need MX and TXT records pointed at the mail host before inbound works — provisioning docs here — but a *.nylas.email trial subdomain works instantly for prototyping.

Swap the send, keep the thread

The replacement send is nearly identical to what you had, with one addition that matters: store the thread_id from the response.

const sent = await nylas.messages.send({
  identifier: AGENT_GRANT_ID,
  requestBody: {
    to: [{ email: "prospect@example.com", name: "Alice" }],
    subject: "Following up on your demo request",
    body: "<p>Hi Alice — wanted to follow up on...</p>",
  },
});

await db.conversations.create({
  threadId: sent.data.threadId,
  contactEmail: "prospect@example.com",
  step: "awaiting_reply",
});
Enter fullscreen mode Exit fullscreen mode

That stored ID is how you'll recognize Alice's reply when it comes back.

Add the receive path

Subscribe a webhook to message.created — this is the entire piece of infrastructure your transactional setup never had:

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://youragent.example.com/webhooks/nylas"
  }'
Enter fullscreen mode Exit fullscreen mode

The notification fires within seconds of a reply arriving. The handler then closes the loop that didn't exist before: look up the thread, skip the agent's own outbound, fetch the full body, restore context.

app.post("/webhooks/nylas", async (req, res) => {
  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;

  // Skip the agent's own outbound messages.
  if (msg.from?.[0]?.email === "outreach@agents.yourcompany.com") return;

  // Is this a reply to something we sent?
  const conversation = await db.conversations.findByThreadId(msg.thread_id);
  if (!conversation) return; // new inbound, not a tracked reply

  // The webhook payload is a summary — fetch the full body.
  const full = await nylas.messages.find({
    identifier: AGENT_GRANT_ID,
    messageId: msg.id,
  });

  // Hand it to the LLM with conversation context restored.
  await processReply(full.data, conversation);
});
Enter fullscreen mode Exit fullscreen mode

When the agent answers, pass reply_to_message_id and the reply threads correctly in the recipient's client — Nylas sets the In-Reply-To and References headers, so there's no Message-ID bookkeeping on your side. And the recipient sees a normal threaded reply: no "sent via" branding, no relay footer. The reply-handling recipe has the full routing logic.

DNS: don't move your main domain

If your company's mail lives on Google Workspace or Microsoft 365, leave those MX records alone. The pattern that works:

  • Keep yourcompany.com pointed where it is.
  • Register agents.yourcompany.com and point its MX records at the mailbox host.
  • The agent sends and receives on the subdomain.

This also isolates the agent's sender reputation from your primary domain — which you'll care about the first time an experiment goes sideways.

Three numbers before you flip the switch

  1. 200 messages per account per day on the free plan is the send cap. At higher volume, move to a paid plan or shard across multiple accounts and domains.
  2. A week or two is the realistic warm-up window for a new domain. Hundreds of sends on day one gets you flagged; ramp gradually.
  3. At-least-once webhook delivery means the same notification can arrive twice. Build dedup before going live, not after the first double-reply.

Two questions that come up every time

Does Nylas manage my conversation state too? No — and the comparison table above is honest about this. The Threads API gives you conversation history for free, but the mapping from threads to your agent's workflow state (awaiting reply, confirmed, closed) is still yours to build. That's the db.conversations table in the code above.

Do I still need to generate Message-ID values? No. With a transactional provider, threading is your problem: you track Message-ID yourself and set In-Reply-To on every send. With an Agent Account, headers are preserved and messages group into threads automatically — reply_to_message_id is the only threading concept your code touches.

You don't have to migrate everything

Keep the transactional provider for receipts and password resets if it's working — those flows don't need replies. Move only the conversations where the agent must see what comes back: outreach, support, scheduling, anything multi-turn. Different addresses, different jobs.

The complete walkthrough is in the migration recipe. Next step if you're tempted: provision a trial-subdomain account, re-point one send call at it, and reply to the email it sends you. Watching your own reply arrive as a webhook is the moment the architecture clicks.

Top comments (0)