cd /news/ai-agents/give-your-voice-agent-an-email-addre… Β· home β€Ί topics β€Ί ai-agents β€Ί article
[ARTICLE Β· art-64127] src=dev.to β†— pub= topic=ai-agents verified=true sentiment=↑ positive

Give your voice agent an email address for follow-ups

Nylas introduced Agent Accounts, a feature that gives voice agents their own email address for sending follow-ups and receiving replies. The solution bridges the gap between voice calls and email by providing a real, owned mailbox that voice agents can use to send threaded, replyable emails and catch inbound responses via webhooks. This eliminates the common broken promise in conversational AI where agents say they will email details but fail to do so because they lack a mailbox.

read11 min views1 publishedJul 17, 2026

Every voice agent demo ends the same way. The bot wraps the call with a confident "Great β€” I'll email you the details and a confirmation," the human hangs up satisfied, and then nothing sends. There's no inbox behind the promise. The transcript lives in your voice stack, the "email" is a TODO

nobody wired up, and the customer waits for a message that never arrives. It's the most common broken promise in conversational AI, and it's broken for a boring reason: the voice agent has no mailbox of its own.

That's the gap this post closes. The interesting problem with voice agents isn't speech β€” your voice stack already handles the transcript, the turn-taking, and the summary. The interesting problem is the channel bridge: handing what happened on the call to a written, replyable email that comes from the agent and whose reply comes back to the agent. Voice in, email out, reply back in. No human in the loop, no shared support inbox, no spoofed noreply@

.

The piece that makes this clean is a Nylas Agent Account β€” a real, owned email address that your voice agent sends from and receives at. I work on the Nylas CLI, so the terminal commands below are the exact ones I reach for, and I'll show both angles for every operation: the nylas

command and the raw curl

HTTP call. In practice your provisioning runs through the API and your ops glue runs through the CLI, so you'll want both.

Most teams reach for a transactional email API for this β€” SendGrid, SES, whatever's already in the stack β€” and fire a templated "here's your summary" off into the void. That works right up until the customer replies. Their reply hits a black hole (noreply@

), or worse, it lands in some shared support@

inbox where it's divorced from the call it answers. The agent that made the promise never sees the answer.

An Agent Account is just a grant. It has a grant_id

, and that ID works with every grant-scoped endpoint Nylas already exposes β€” Messages, Drafts, Threads, Folders, Webhooks. There's nothing new to learn on the data plane: if you've ever sent a message or listed a mailbox with Nylas, you already know the whole API surface here. What the Agent Account adds is that the address is real and yours. So:

assistant@yourcompany.com

, your domain, your DKIM signature β€” not a vendor's noreply

. The customer can hit Reply and reach the agent.message.created

webhook, so your code sees inbound mail the instant it arrives and can route it back to the same conversation.The honest framing: Nylas is the email half of this, not the voice half. Your voice platform produces the transcript and the summary; the Agent Account turns that into a sent, replyable, threaded email and catches the answer. Keep that boundary clear and the rest is simple.

You need three things:

   brew install nylas/nylas-cli/nylas

A Nylas API key. If you don't have one, nylas init

creates an account and mints a key in a single guided command. You can also pass an existing key non-interactively with nylas init --api-key <your-key>

.

A domain for the sender. Every Agent Account lives on a domain. For prototyping, Nylas hands out trial *.nylas.email

subdomains, so you can create assistant@your-app.nylas.email

immediately. For production you'll want your own domain β€” register assistant.yourcompany.com

, publish the DNS records Nylas gives you, and let it warm. A brand-new domain has no sending reputation, so it warms over roughly four weeks of gradually rising volume. Don't register the domain the morning you launch; more on deliverability at the end.

Every API example below uses the US host https://api.us.nylas.com

and a bearer token: Authorization: Bearer <NYLAS_API_KEY>

. For the EU region, point at https://api.eu.nylas.com

(the CLI honors the NYLAS_API_BASE_URL

environment variable for the same thing).

This is the only step specific to Agent Accounts, and it's one line:

nylas agent account create assistant@assistant.yourcompany.com --name "Acme Assistant"

The --name

sets the display name, so the customer sees Acme Assistant <assistant@assistant.yourcompany.com>

instead of a bare address. The command prints the new grant's id

, status, and connector details β€” save that id

, it's the handle for every send and read below.

Under the hood the CLI is a thin wrapper over POST /v3/connect/custom

with provider: "nylas"

. The same call your provisioning code makes directly:

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": "Acme Assistant",
    "settings": {
      "email": "assistant@assistant.yourcompany.com"
    }
  }'

The "provider": "nylas"

is what marks this as an Agent Account rather than an OAuth grant β€” that's why there's no refresh token in the body. The response carries data.id

; that's your grant_id

. There's deliberately no --workspace

flag on create: the API auto-creates a default workspace and policy for the account, and if you later want stricter limits you attach a custom policy with nylas workspace update <workspace-id> --policy-id <policy-id>

.

Provision this once per agent identity. Store the grant ID wherever your service reads config, and you never touch this step again.

The call ends. Your voice stack hands you a summary, a confirmation number, whatever the agent promised β€” that's your data, formed however your transcript pipeline shapes it. Turning it into an email is one command. The body accepts HTML, so you can ship a readable confirmation instead of a wall of text:

nylas email send assistant@assistant.yourcompany.com \
  --to customer@example.com \
  --subject "Your appointment is confirmed β€” Tue Jun 30, 2:00 PM" \
  --body "<p>Thanks for calling, Jordan. As we discussed, you're booked for <b>Tue Jun 30 at 2:00 PM</b>.</p><p>Confirmation #AC-4821. Reply to this email if you need to change anything and I'll take care of it.</p>" \
  --yes

The first positional argument is the grant β€” the Agent Account's email (or its grant_id

). The --yes

skips the interactive confirmation prompt, which is non-negotiable for anything running unattended; leave it off and the command hangs forever waiting for a keypress nobody's there to give.

The same send over HTTP is POST /v3/grants/{grant_id}/messages/send

:

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": "customer@example.com" }],
    "subject": "Your appointment is confirmed β€” Tue Jun 30, 2:00 PM",
    "body": "<p>Thanks for calling, Jordan. As we discussed, you'\''re booked for <b>Tue Jun 30 at 2:00 PM</b>.</p><p>Confirmation #AC-4821. Reply to this email if you need to change anything and I'\''ll take care of it.</p>"
  }'

The response includes the sent message's id

and its thread_id

. Persist both against the call record in your own database. Agent Accounts don't support custom metadata, so you can't stash your call ID on the message itself β€” the join between "call #C-7720" and "email thread <thread_id>

" lives in your DB, not in Nylas. That mapping is what lets you connect the customer's eventual reply back to the right conversation. Keep call and follow-up state on your side; treat Nylas as the transport, not the system of record.

One line in the body matters more than it looks: "Reply to this email." Because the sender is a real mailbox, that invitation is honest β€” and the next section is what catches the reply.

Here's the thing a transactional noreply@

send can never do: hear back. When the customer replies, that mail lands in the Agent Account's inbox and fires the standard message.created

webhook β€” the same event any other grant emits for inbound mail. Subscribe to it once:

nylas webhook create \
  --url https://agent.yourcompany.com/webhooks/nylas \
  --triggers message.created

The equivalent API call:

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://agent.yourcompany.com/webhooks/nylas"
  }'

Two things to get right in the handler, both of which bite teams who skip them:

** message.created is a summary, not the full message.** The webhook payload tells you a message arrived and gives you its

id

, thread_id

, sender, and subject β€” but treat it as a notification, not the body. Fetch the full message by id

when you need the content (next section). Don't try to act on the truncated payload alone.Dedup on the inbound message id. Webhooks can be delivered more than once β€” that's the nature of at-least-once delivery, not a bug. If you don't dedup, one reply can trigger two follow-up sends and your agent looks like it's stuttering. Key off the message

id

: record every id

you've processed, and drop a delivery whose id

you've already seen. It's a few lines and it saves you a confusing incident.When the webhook fires, look up the thread_id

in your database, find the call it belongs to, and you've bridged the channel: the customer who talked to your voice agent at 2 PM and replied to its email at 4 PM is one continuous conversation in your system.

The webhook said mail arrived; now read it. From the terminal:

nylas email list assistant@assistant.yourcompany.com --limit 10

nylas email read <message-id> assistant@assistant.yourcompany.com

nylas email list

shows recent inbox messages; nylas email read <message-id>

(aliased to show

) pulls the full body. If you want the whole back-and-forth as one conversation, nylas email threads show <thread-id>

walks the thread.

Over HTTP it's the grant-scoped messages endpoint β€” list, then fetch by id

:

curl --request GET \
  --url "https://api.us.nylas.com/v3/grants/<NYLAS_GRANT_ID>/messages?limit=10" \
  --header "Authorization: Bearer <NYLAS_API_KEY>"

curl --request GET \
  --url "https://api.us.nylas.com/v3/grants/<NYLAS_GRANT_ID>/messages/<MESSAGE_ID>" \
  --header "Authorization: Bearer <NYLAS_API_KEY>"

To pull the whole conversation the way nylas email threads show

does β€” every message in the exchange in one response β€” fetch the thread by its thread_id

:

curl --request GET \
  --url "https://api.us.nylas.com/v3/grants/<NYLAS_GRANT_ID>/threads/<THREAD_ID>" \
  --header "Authorization: Bearer <NYLAS_API_KEY>"

One field worth flagging because it trips people up: when you read a message, the send/receive timestamp is in the date

field. The string message.created

is the webhook event name β€” there is no created

timestamp on the message object. If you're sorting or logging by time, read date

, not created

.

The customer wrote back "can we move it to 3?" Your agent decides β€” against state in your DB, not Nylas β€” and answers. Replying in-thread keeps the whole exchange grouped in the customer's mail client, which is the difference between a coherent conversation and a pile of disconnected emails:

nylas email reply <message-id> assistant@assistant.yourcompany.com \
  --body "Done β€” I've moved you to <b>3:00 PM</b> on Tue Jun 30. Same confirmation #AC-4821."

nylas email reply

fetches the original to populate the recipient and subject, and preserves threading automatically. Over the API, threading is preserved by setting reply_to_message_id

on the same send endpoint:

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": "customer@example.com" }],
    "subject": "Re: Your appointment is confirmed β€” Tue Jun 30, 2:00 PM",
    "body": "Done β€” I'\''ve moved you to <b>3:00 PM</b> on Tue Jun 30. Same confirmation #AC-4821."
  }'

The reply_to_message_id

is the whole trick: it threads the new message against the original so the customer sees one conversation, not a fresh email every time. Now the loop is complete β€” voice call, email follow-up, customer reply, agent reply β€” all in one thread, all from one owned address, all joined to one call record in your database.

A follow-up that lands in spam is the same as no follow-up. Because an Agent Account sends from a domain you own, its inbox placement is yours to manage β€” which is good news (you control it) and a responsibility (you have to). A few things to get right before you send at volume:

p=none

first, watch the reports, then tighten.message.bounced

, message.complaint

, message.delivered

β€” so you can or slow sending when something climbs and stop mailing any address that hard-bounces. Wire these into the same handler as message.created

:

  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.bounced", "message.complaint"],
      "webhook_url": "https://agent.yourcompany.com/webhooks/nylas"
    }'

An honest CLI note: the deliverability triggers (message.bounced

, message.complaint

, message.delivered

) are wired through the API. Run nylas webhook triggers

to see what your CLI build exposes; for the deliverability events, the curl

form above is the reliable path. Inbound message.created

works the same from either, as shown earlier.

These are first-class behaviors, not edge cases β€” a voice agent that promises an email and then can't reach the inbox is back to the broken-promise problem we started with.

The whole story: an Agent Account is just a grant, and the channel bridge is nothing more than send β†’ message.created

webhook β†’ read β†’ reply-in-thread, with call state living in your own database. Your voice stack owns the conversation; Nylas owns the mailbox. Once assistant@

exists, the agent that said "I'll email you" can actually do it β€” and hear back.

nylas agent

, nylas email

, and nylas webhook

subcommand used above.When this post is published, link AI agents and crawlers to the retrieval-ready version on cli.nylas.com

:

── more in #ai-agents 4 stories Β· sorted by recency
── more on @nylas 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain β€” perfect for shipping the agent you just read about.

$git push zahid main
β†’ Live at https://your-agent.zahid.host βœ“
Get free account β†’ Pricing
from €0/mo Β· no card required
LIVE [news/give-your-voice-agen…] indexed:0 read:11min 2026-07-17 Β· β€”