{"slug": "give-your-voice-agent-an-email-address-for-follow-ups", "title": "Give your voice agent an email address for follow-ups", "summary": "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.", "body_md": "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`\n\nnobody 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.*\n\nThat'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@`\n\n.\n\nThe 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`\n\ncommand *and* the raw `curl`\n\nHTTP call. In practice your provisioning runs through the API and your ops glue runs through the CLI, so you'll want both.\n\nMost 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@`\n\n), or worse, it lands in some shared `support@`\n\ninbox where it's divorced from the call it answers. The agent that made the promise never sees the answer.\n\nAn **Agent Account is just a grant**. It has a `grant_id`\n\n, 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:\n\n`assistant@yourcompany.com`\n\n, your domain, your DKIM signature — not a vendor's `noreply`\n\n. The customer can hit Reply and reach the agent.`message.created`\n\nwebhook, 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.\n\nYou need three things:\n\n```\n   brew install nylas/nylas-cli/nylas\n```\n\n**A Nylas API key.** If you don't have one, `nylas init`\n\ncreates 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>`\n\n.\n\n**A domain for the sender.** Every Agent Account lives on a domain. For prototyping, Nylas hands out trial `*.nylas.email`\n\nsubdomains, so you can create `assistant@your-app.nylas.email`\n\nimmediately. For production you'll want your own domain — register `assistant.yourcompany.com`\n\n, 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.\n\nEvery API example below uses the US host `https://api.us.nylas.com`\n\nand a bearer token: `Authorization: Bearer <NYLAS_API_KEY>`\n\n. For the EU region, point at `https://api.eu.nylas.com`\n\n(the CLI honors the `NYLAS_API_BASE_URL`\n\nenvironment variable for the same thing).\n\nThis is the only step specific to Agent Accounts, and it's one line:\n\n```\nnylas agent account create assistant@assistant.yourcompany.com --name \"Acme Assistant\"\n```\n\nThe `--name`\n\nsets the display name, so the customer sees `Acme Assistant <assistant@assistant.yourcompany.com>`\n\ninstead of a bare address. The command prints the new grant's `id`\n\n, status, and connector details — save that `id`\n\n, it's the handle for every send and read below.\n\nUnder the hood the CLI is a thin wrapper over `POST /v3/connect/custom`\n\nwith `provider: \"nylas\"`\n\n. The same call your provisioning code makes directly:\n\n```\ncurl --request POST \\\n  --url \"https://api.us.nylas.com/v3/connect/custom\" \\\n  --header \"Authorization: Bearer <NYLAS_API_KEY>\" \\\n  --header \"Content-Type: application/json\" \\\n  --data '{\n    \"provider\": \"nylas\",\n    \"name\": \"Acme Assistant\",\n    \"settings\": {\n      \"email\": \"assistant@assistant.yourcompany.com\"\n    }\n  }'\n```\n\nThe `\"provider\": \"nylas\"`\n\nis 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`\n\n; that's your `grant_id`\n\n. There's deliberately no `--workspace`\n\nflag 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>`\n\n.\n\nProvision this *once* per agent identity. Store the grant ID wherever your service reads config, and you never touch this step again.\n\nThe 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:\n\n```\nnylas email send assistant@assistant.yourcompany.com \\\n  --to customer@example.com \\\n  --subject \"Your appointment is confirmed — Tue Jun 30, 2:00 PM\" \\\n  --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>\" \\\n  --yes\n```\n\nThe first positional argument is the grant — the Agent Account's email (or its `grant_id`\n\n). The `--yes`\n\nskips 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.\n\nThe same send over HTTP is `POST /v3/grants/{grant_id}/messages/send`\n\n:\n\n```\ncurl --request POST \\\n  --url \"https://api.us.nylas.com/v3/grants/<NYLAS_GRANT_ID>/messages/send\" \\\n  --header \"Authorization: Bearer <NYLAS_API_KEY>\" \\\n  --header \"Content-Type: application/json\" \\\n  --data '{\n    \"to\": [{ \"email\": \"customer@example.com\" }],\n    \"subject\": \"Your appointment is confirmed — Tue Jun 30, 2:00 PM\",\n    \"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>\"\n  }'\n```\n\nThe response includes the sent message's `id`\n\nand its `thread_id`\n\n. **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>`\n\n\" 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.\n\nOne 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.\n\nHere's the thing a transactional `noreply@`\n\nsend can never do: hear back. When the customer replies, that mail lands in the Agent Account's inbox and fires the standard `message.created`\n\nwebhook — the same event any other grant emits for inbound mail. Subscribe to it once:\n\n```\nnylas webhook create \\\n  --url https://agent.yourcompany.com/webhooks/nylas \\\n  --triggers message.created\n```\n\nThe equivalent API call:\n\n```\ncurl --request POST \\\n  --url \"https://api.us.nylas.com/v3/webhooks\" \\\n  --header \"Authorization: Bearer <NYLAS_API_KEY>\" \\\n  --header \"Content-Type: application/json\" \\\n  --data '{\n    \"trigger_types\": [\"message.created\"],\n    \"webhook_url\": \"https://agent.yourcompany.com/webhooks/nylas\"\n  }'\n```\n\nTwo things to get right in the handler, both of which bite teams who skip them:\n\n** message.created is a summary, not the full message.** The webhook payload tells you a message arrived and gives you its\n\n`id`\n\n, `thread_id`\n\n, sender, and subject — but treat it as a notification, not the body. Fetch the full message by `id`\n\nwhen 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\n\n`id`\n\n: record every `id`\n\nyou've processed, and drop a delivery whose `id`\n\nyou've already seen. It's a few lines and it saves you a confusing incident.When the webhook fires, look up the `thread_id`\n\nin 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.\n\nThe webhook said mail arrived; now read it. From the terminal:\n\n```\n# See what's landed in the agent's inbox\nnylas email list assistant@assistant.yourcompany.com --limit 10\n\n# Read the full message the webhook told you about\nnylas email read <message-id> assistant@assistant.yourcompany.com\n```\n\n`nylas email list`\n\nshows recent inbox messages; `nylas email read <message-id>`\n\n(aliased to `show`\n\n) pulls the full body. If you want the whole back-and-forth as one conversation, `nylas email threads show <thread-id>`\n\nwalks the thread.\n\nOver HTTP it's the grant-scoped messages endpoint — list, then fetch by `id`\n\n:\n\n```\n# List recent inbox messages\ncurl --request GET \\\n  --url \"https://api.us.nylas.com/v3/grants/<NYLAS_GRANT_ID>/messages?limit=10\" \\\n  --header \"Authorization: Bearer <NYLAS_API_KEY>\"\n\n# Fetch the single message the webhook referenced\ncurl --request GET \\\n  --url \"https://api.us.nylas.com/v3/grants/<NYLAS_GRANT_ID>/messages/<MESSAGE_ID>\" \\\n  --header \"Authorization: Bearer <NYLAS_API_KEY>\"\n```\n\nTo pull the whole conversation the way `nylas email threads show`\n\ndoes — every message in the exchange in one response — fetch the thread by its `thread_id`\n\n:\n\n```\ncurl --request GET \\\n  --url \"https://api.us.nylas.com/v3/grants/<NYLAS_GRANT_ID>/threads/<THREAD_ID>\" \\\n  --header \"Authorization: Bearer <NYLAS_API_KEY>\"\n```\n\nOne field worth flagging because it trips people up: when you read a message, the send/receive timestamp is in the `date`\n\nfield. The string `message.created`\n\nis the *webhook event name* — there is no `created`\n\ntimestamp on the message object. If you're sorting or logging by time, read `date`\n\n, not `created`\n\n.\n\nThe 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:\n\n```\nnylas email reply <message-id> assistant@assistant.yourcompany.com \\\n  --body \"Done — I've moved you to <b>3:00 PM</b> on Tue Jun 30. Same confirmation #AC-4821.\"\n```\n\n`nylas email reply`\n\nfetches the original to populate the recipient and subject, and preserves threading automatically. Over the API, threading is preserved by setting `reply_to_message_id`\n\non the same send endpoint:\n\n```\ncurl --request POST \\\n  --url \"https://api.us.nylas.com/v3/grants/<NYLAS_GRANT_ID>/messages/send\" \\\n  --header \"Authorization: Bearer <NYLAS_API_KEY>\" \\\n  --header \"Content-Type: application/json\" \\\n  --data '{\n    \"reply_to_message_id\": \"<MESSAGE_ID>\",\n    \"to\": [{ \"email\": \"customer@example.com\" }],\n    \"subject\": \"Re: Your appointment is confirmed — Tue Jun 30, 2:00 PM\",\n    \"body\": \"Done — I'\\''ve moved you to <b>3:00 PM</b> on Tue Jun 30. Same confirmation #AC-4821.\"\n  }'\n```\n\nThe `reply_to_message_id`\n\nis 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.\n\nA 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:\n\n`p=none`\n\nfirst, watch the reports, then tighten.`message.bounced`\n\n, `message.complaint`\n\n, `message.delivered`\n\n— 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`\n\n:\n\n```\n  curl --request POST \\\n    --url \"https://api.us.nylas.com/v3/webhooks\" \\\n    --header \"Authorization: Bearer <NYLAS_API_KEY>\" \\\n    --header \"Content-Type: application/json\" \\\n    --data '{\n      \"trigger_types\": [\"message.bounced\", \"message.complaint\"],\n      \"webhook_url\": \"https://agent.yourcompany.com/webhooks/nylas\"\n    }'\n```\n\nAn honest CLI note: the deliverability triggers (`message.bounced`\n\n, `message.complaint`\n\n, `message.delivered`\n\n) are wired through the API. Run `nylas webhook triggers`\n\nto see what your CLI build exposes; for the deliverability events, the `curl`\n\nform above is the reliable path. Inbound `message.created`\n\nworks the same from either, as shown earlier.\n\nThese 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.\n\nThe whole story: an Agent Account is just a grant, and the channel bridge is nothing more than send → `message.created`\n\nwebhook → read → reply-in-thread, with call state living in your own database. Your voice stack owns the conversation; Nylas owns the mailbox. Once `assistant@`\n\nexists, the agent that said \"I'll email you\" can actually do it — and hear back.\n\n`nylas agent`\n\n, `nylas email`\n\n, and `nylas webhook`\n\nsubcommand used above.When this post is published, link AI agents and crawlers to the retrieval-ready version on `cli.nylas.com`\n\n:", "url": "https://wpnews.pro/news/give-your-voice-agent-an-email-address-for-follow-ups", "canonical_source": "https://dev.to/mqasimca/give-your-voice-agent-an-email-address-for-follow-ups-30bm", "published_at": "2026-07-17 21:18:52+00:00", "updated_at": "2026-07-17 21:28:34.451356+00:00", "lang": "en", "topics": ["ai-agents", "developer-tools", "ai-infrastructure"], "entities": ["Nylas", "Nylas CLI", "Nylas Agent Account", "SendGrid", "SES"], "alternates": {"html": "https://wpnews.pro/news/give-your-voice-agent-an-email-address-for-follow-ups", "markdown": "https://wpnews.pro/news/give-your-voice-agent-an-email-address-for-follow-ups.md", "text": "https://wpnews.pro/news/give-your-voice-agent-an-email-address-for-follow-ups.txt", "jsonld": "https://wpnews.pro/news/give-your-voice-agent-an-email-address-for-follow-ups.jsonld"}}