# Give your voice agent an email address for follow-ups

> Source: <https://dev.to/mqasimca/give-your-voice-agent-an-email-address-for-follow-ups-30bm>
> Published: 2026-07-17 21:18:52+00:00

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:

```
# See what's landed in the agent's inbox
nylas email list assistant@assistant.yourcompany.com --limit 10

# Read the full message the webhook told you about
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`

:

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

# Fetch the single message the webhook referenced
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 pause 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`

:
