<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Qasim</title>
    <description>The latest articles on DEV Community by Qasim (@mqasimca).</description>
    <link>https://dev.to/mqasimca</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3995627%2Feddc44d6-3e99-45b8-ae80-71279c900b01.jpg</url>
      <title>DEV Community: Qasim</title>
      <link>https://dev.to/mqasimca</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/mqasimca"/>
    <language>en</language>
    <item>
      <title>Automated list hygiene for agent mail: bounce and complaint webhooks a suppression list</title>
      <dc:creator>Qasim</dc:creator>
      <pubDate>Sun, 12 Jul 2026 15:19:51 +0000</pubDate>
      <link>https://dev.to/mqasimca/automated-list-hygiene-for-agent-mail-bounce-and-complaint-webhooks-a-suppression-list-p3o</link>
      <guid>https://dev.to/mqasimca/automated-list-hygiene-for-agent-mail-bounce-and-complaint-webhooks-a-suppression-list-p3o</guid>
      <description>&lt;p&gt;Sending to dead or hostile addresses tanks your deliverability. A hard bounce means the mailbox doesn't exist; a complaint means a human hit "this is spam." Mailbox providers watch both rates obsessively, and once yours climb, they stop trusting your domain — your &lt;em&gt;good&lt;/em&gt; mail starts landing in spam too. The cruel part is that an autonomous email agent will happily keep hammering a bad address forever unless you stop it, because nothing in a naive send loop ever learns that an address went bad.&lt;/p&gt;

&lt;p&gt;Most "AI email" hygiene advice stops at "authenticate your domain and warm it up." That's table stakes. The interesting problem is the &lt;em&gt;feedback loop&lt;/em&gt;: the agent sends, some sends fail or get reported, and you need that failure signal to flow back into the agent's behavior automatically. No human in the loop scrubbing CSVs. This post wires deliverability webhooks straight into a &lt;strong&gt;suppression List&lt;/strong&gt; and a &lt;strong&gt;block rule&lt;/strong&gt;, so an address that bounces or complaints once is never mailed again.&lt;/p&gt;

&lt;p&gt;I work on the Nylas CLI, so the terminal commands below are the exact ones I reach for. Every step shows both the raw HTTP call and the CLI equivalent, because that's how I actually debug this stuff — curl to see the wire, CLI to do it fast.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you're building
&lt;/h2&gt;

&lt;p&gt;The whole thing is a four-piece loop:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Deliverability webhooks&lt;/strong&gt; (&lt;code&gt;message.bounced&lt;/code&gt;, &lt;code&gt;message.complaint&lt;/code&gt;) tell you which addresses went bad, in real time.&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;suppression List&lt;/strong&gt; of type &lt;code&gt;address&lt;/code&gt; holds those bad addresses.&lt;/li&gt;
&lt;li&gt;An &lt;strong&gt;outbound block rule&lt;/strong&gt; matches &lt;code&gt;recipient.address in_list &amp;lt;suppression-list&amp;gt;&lt;/code&gt; and rejects any send to a suppressed address before it leaves the building.&lt;/li&gt;
&lt;li&gt;Your webhook handler does the only custom logic: pull the offending address out of the payload, add it to the list.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The elegant part is that pieces 2 and 3 are pure Nylas config — Policies, Rules, and Lists. Your code never has to maintain a denylist in your own database, never has to remember to check it before sending, and never has to special-case which agent is sending. The block happens at the platform, on the outbound path, for &lt;em&gt;every&lt;/em&gt; Agent Account in the workspace. You just keep the list fed.&lt;/p&gt;

&lt;p&gt;And the data plane is the same one you already know. An &lt;strong&gt;Agent Account&lt;/strong&gt; is just a grant with a &lt;code&gt;grant_id&lt;/code&gt; — it works with every grant-scoped endpoint, including Webhooks. There's nothing new to learn to receive these events; if you've subscribed to &lt;code&gt;message.created&lt;/code&gt; before, this is the same mechanism with different trigger types.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this beats a denylist in your own database
&lt;/h2&gt;

&lt;p&gt;You could absolutely keep a &lt;code&gt;suppressed_emails&lt;/code&gt; table and check it before every send. People do. Here's why I don't:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The check is easy to forget.&lt;/strong&gt; Every send path in your codebase has to remember to query the table first. Add a new send path, forget the check, and you're mailing bounced addresses again. The block rule can't be forgotten — it runs on the outbound path no matter who calls send.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It's the wrong layer.&lt;/strong&gt; A block rule rejects the send with HTTP 403 &lt;em&gt;before the message reaches the provider&lt;/em&gt;. Your own check rejects it after your code has already built the message. The platform block is strictly earlier and strictly harder to bypass.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Non-engineers can manage it.&lt;/strong&gt; A List fronted by a rule means someone can add or remove an address without touching code or redeploying. The rule keeps matching the new values immediately.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It's multi-account by default.&lt;/strong&gt; One suppression list, referenced by one workspace rule, covers every agent in that workspace. No per-tenant denylist plumbing.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The honest tradeoff: you're trusting Nylas to evaluate the rule on every send, and an outbound &lt;code&gt;block&lt;/code&gt; returns a &lt;code&gt;403&lt;/code&gt; your code has to handle like any delivery failure. If you want the suppression logic to live entirely in your own stack for portability reasons, the database approach is fine. For an autonomous agent where the whole point is &lt;em&gt;not&lt;/em&gt; writing glue code, the rule wins.&lt;/p&gt;

&lt;h2&gt;
  
  
  Before you begin
&lt;/h2&gt;

&lt;p&gt;You need:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A provisioned Agent Account (a grant with &lt;code&gt;provider: "nylas"&lt;/code&gt;) on a verified domain. If you don't have one, see &lt;a href="https://developer.nylas.com/docs/v3/agent-accounts/provisioning/" rel="noopener noreferrer"&gt;Provisioning Agent Accounts&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;Your &lt;code&gt;NYLAS_API_KEY&lt;/code&gt; and the API base host. Examples here use &lt;code&gt;https://api.us.nylas.com&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;A public HTTPS endpoint to receive webhooks.&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;New to Nylas? Start at the &lt;a href="https://developer.nylas.com/docs/v3/agent-accounts/" rel="noopener noreferrer"&gt;Agent Accounts overview&lt;/a&gt; — it explains the grant model the rest of this assumes.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;One thing to get straight up front about scope. Policies, Rules, and Lists are &lt;strong&gt;application-scoped&lt;/strong&gt; and carried by &lt;strong&gt;workspaces&lt;/strong&gt;, not individual grants. You attach a &lt;code&gt;policy_id&lt;/code&gt; and &lt;code&gt;rule_ids&lt;/code&gt; to a workspace, and every Agent Account in that workspace inherits them. Each application has a default workspace that holds any account you haven't placed in a custom one, so attaching the suppression rule there covers all your unassigned agents at once. The CLI commands below resolve the default workspace from your current default grant automatically.&lt;/p&gt;

&lt;h2&gt;
  
  
  Subscribe to the deliverability webhooks
&lt;/h2&gt;

&lt;p&gt;This is the one step where the CLI can't help you, and I want to be honest about why. Agent Accounts emit four deliverability webhooks — &lt;code&gt;message.delivered&lt;/code&gt;, &lt;code&gt;message.bounced&lt;/code&gt;, &lt;code&gt;message.complaint&lt;/code&gt;, and &lt;code&gt;message.rejected&lt;/code&gt; — and these are the events Nylas itself uses to calculate your bounce and complaint rates. They're real API trigger types. But the CLI's &lt;code&gt;nylas webhook triggers&lt;/code&gt; list doesn't include them yet; it still shows the older transactional triggers like &lt;code&gt;message.bounce_detected&lt;/code&gt;. So for this subscription, you go through the API directly.&lt;/p&gt;

&lt;p&gt;Create a webhook destination subscribed to the two triggers that matter for suppression:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;--request&lt;/span&gt; POST &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--url&lt;/span&gt; &lt;span class="s2"&gt;"https://api.us.nylas.com/v3/webhooks"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &amp;lt;NYLAS_API_KEY&amp;gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--data&lt;/span&gt; &lt;span class="s1"&gt;'{
    "trigger_types": ["message.bounced", "message.complaint"],
    "webhook_url": "https://yourapp.example.com/webhooks/nylas",
    "notification_email_addresses": ["ops@yourcompany.com"],
    "description": "Agent Account suppression feed"
  }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You can add &lt;code&gt;message.delivered&lt;/code&gt; and &lt;code&gt;message.rejected&lt;/code&gt; to the same &lt;code&gt;trigger_types&lt;/code&gt; array if you also want to track positive delivery and virus-rejected sends — &lt;code&gt;message.rejected&lt;/code&gt; fires specifically when an outbound message is rejected because one or more attachments contained a virus, not as a generic rejection signal. Both are useful for dashboards, but neither drives suppression. For suppression specifically, &lt;code&gt;message.bounced&lt;/code&gt; and &lt;code&gt;message.complaint&lt;/code&gt; are the two you act on.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;CLI note (the honest version):&lt;/strong&gt; because &lt;code&gt;message.bounced&lt;/code&gt; and &lt;code&gt;message.complaint&lt;/code&gt; aren't in the CLI's trigger enum, &lt;code&gt;nylas webhook create --triggers message.bounced&lt;/code&gt; will reject the value. The CLI is great for the &lt;em&gt;other&lt;/em&gt; webhook types — &lt;code&gt;nylas webhook create --url https://yourapp.example.com/webhooks/nylas --triggers message.created&lt;/code&gt; works fine — but for the deliverability triggers, subscribe via the curl call above. If you want to confirm what the CLI does expose, run &lt;code&gt;nylas webhook triggers --category message&lt;/code&gt; and you'll see the bounced/complaint deliverability triggers are absent.&lt;/p&gt;

&lt;h2&gt;
  
  
  Build the suppression list
&lt;/h2&gt;

&lt;p&gt;Now the part that &lt;em&gt;is&lt;/em&gt; fully CLI-friendly. Create an &lt;code&gt;address&lt;/code&gt;-typed List to hold the addresses you'll suppress. The type is immutable, and it determines which rule fields the list can match — an &lt;code&gt;address&lt;/code&gt; list matches &lt;code&gt;from.address&lt;/code&gt; and &lt;code&gt;recipient.address&lt;/code&gt;, which is exactly what we want for outbound suppression.&lt;/p&gt;

&lt;p&gt;API:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;--request&lt;/span&gt; POST &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--url&lt;/span&gt; &lt;span class="s2"&gt;"https://api.us.nylas.com/v3/lists"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &amp;lt;NYLAS_API_KEY&amp;gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--data&lt;/span&gt; &lt;span class="s1"&gt;'{
    "name": "Suppressed addresses",
    "type": "address"
  }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The response hands back the list &lt;code&gt;id&lt;/code&gt; you'll need for both adding items and wiring the rule:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"request_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"5fa64c92-e840-4357-86b9-2aa364d35b88"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"data"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"d1e2f3a4-5678-4abc-9def-0123456789ab"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Suppressed addresses"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"address"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"items_count"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"created_at"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1742932766&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"updated_at"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1742932766&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;CLI:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;nylas agent list create &lt;span class="nt"&gt;--name&lt;/span&gt; &lt;span class="s2"&gt;"Suppressed addresses"&lt;/span&gt; &lt;span class="nt"&gt;--type&lt;/span&gt; address
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That prints the new list ID. If you want to seed it with known-bad addresses at creation time, pass &lt;code&gt;--item&lt;/code&gt; (repeatable):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;nylas agent list create &lt;span class="nt"&gt;--name&lt;/span&gt; &lt;span class="s2"&gt;"Suppressed addresses"&lt;/span&gt; &lt;span class="nt"&gt;--type&lt;/span&gt; address &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--item&lt;/span&gt; known-bad@example.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Add a bad address to the list
&lt;/h2&gt;

&lt;p&gt;When your handler decides an address is dead, it adds it to the list. Up to 1000 items per request; values are lowercased, trimmed, and validated against the list's type, and duplicate additions are silently ignored — which is exactly the behavior you want, because the same address can bounce more than once and you don't want to special-case that.&lt;/p&gt;

&lt;p&gt;API:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;--request&lt;/span&gt; POST &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--url&lt;/span&gt; &lt;span class="s2"&gt;"https://api.us.nylas.com/v3/lists/&amp;lt;LIST_ID&amp;gt;/items"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &amp;lt;NYLAS_API_KEY&amp;gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--data&lt;/span&gt; &lt;span class="s1"&gt;'{
    "items": ["jane@example.com"]
  }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;CLI:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;nylas agent list add &amp;lt;LIST_ID&amp;gt; jane@example.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The silent dedupe is the detail I appreciate as an SRE. You don't have to read-before-write or guard against races between two bounce events for the same address arriving at once. Just add; the API sorts it out.&lt;/p&gt;

&lt;h2&gt;
  
  
  Block sends to suppressed addresses
&lt;/h2&gt;

&lt;p&gt;The list does nothing on its own — it's just data. The &lt;strong&gt;rule&lt;/strong&gt; is what makes it bite. Create an &lt;strong&gt;outbound&lt;/strong&gt; rule that blocks any send where a recipient address is &lt;code&gt;in_list&lt;/code&gt; your suppression list. Because &lt;code&gt;block&lt;/code&gt; is terminal and evaluated before the message reaches the provider, a suppressed recipient means the send is rejected with &lt;code&gt;403&lt;/code&gt; and no sent copy is stored.&lt;/p&gt;

&lt;p&gt;One subtlety worth knowing: &lt;code&gt;recipient.address&lt;/code&gt; matches against &lt;em&gt;any&lt;/em&gt; recipient — To, CC, BCC, and the SMTP envelope. So if a single suppressed address is buried in the BCC of a 50-recipient send, the whole send is blocked. For suppression that's usually the right call; you'd rather fail loudly than quietly mail a known-bad address. Just be aware of it when you design batch sends.&lt;/p&gt;

&lt;p&gt;API:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;--request&lt;/span&gt; POST &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--url&lt;/span&gt; &lt;span class="s2"&gt;"https://api.us.nylas.com/v3/rules"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &amp;lt;NYLAS_API_KEY&amp;gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--data&lt;/span&gt; &lt;span class="s1"&gt;'{
    "name": "Block sends to suppressed addresses",
    "priority": 1,
    "trigger": "outbound",
    "match": {
      "conditions": [
        {
          "field": "recipient.address",
          "operator": "in_list",
          "value": ["&amp;lt;LIST_ID&amp;gt;"]
        }
      ]
    },
    "actions": [
      { "type": "block" }
    ]
  }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;CLI — the &lt;code&gt;--condition&lt;/code&gt; flag takes &lt;code&gt;field,operator,value&lt;/code&gt;, and for &lt;code&gt;in_list&lt;/code&gt; the value is the list ID:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;nylas agent rule create &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--name&lt;/span&gt; &lt;span class="s2"&gt;"Block sends to suppressed addresses"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--trigger&lt;/span&gt; outbound &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--priority&lt;/span&gt; 1 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--condition&lt;/span&gt; recipient.address,in_list,&amp;lt;LIST_ID&amp;gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--action&lt;/span&gt; block
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The CLI creates the rule through &lt;code&gt;/v3/rules&lt;/code&gt; and attaches it to the default workspace for you, resolved from your current default grant — so the CLI path is already activated. The API path is not: a rule created through &lt;code&gt;POST /v3/rules&lt;/code&gt; is inert until a workspace references it. That's the next step.&lt;/p&gt;

&lt;h2&gt;
  
  
  Activate the rule on a workspace
&lt;/h2&gt;

&lt;p&gt;A rule does nothing until a workspace references it. If you created the rule through the API, add its ID to a workspace's &lt;code&gt;rule_ids&lt;/code&gt; array to make it run. That array carries inbound and outbound rules together; Nylas filters by &lt;code&gt;trigger&lt;/code&gt; at evaluation time, so listing the rule here is what makes it fire for every Agent Account in the workspace.&lt;/p&gt;

&lt;p&gt;API:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;--request&lt;/span&gt; PATCH &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--url&lt;/span&gt; &lt;span class="s2"&gt;"https://api.us.nylas.com/v3/workspaces/&amp;lt;WORKSPACE_ID&amp;gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &amp;lt;NYLAS_API_KEY&amp;gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--data&lt;/span&gt; &lt;span class="s1"&gt;'{
    "rule_ids": ["&amp;lt;RULE_ID&amp;gt;"]
  }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;CLI — the flag is &lt;code&gt;--rules-ids&lt;/code&gt; (comma-separated):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;nylas workspace update &amp;lt;WORKSPACE_ID&amp;gt; &lt;span class="nt"&gt;--rules-ids&lt;/span&gt; &amp;lt;RULE_ID&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Attach to the default workspace if you don't manage workspaces explicitly — it holds every Agent Account you haven't placed in a custom one, so suppression covers all your unassigned agents at once. One caveat: &lt;code&gt;rule_ids&lt;/code&gt; replaces the array, so include any existing rule IDs you want to keep alongside the new one.&lt;/p&gt;

&lt;p&gt;I set &lt;code&gt;priority: 1&lt;/code&gt; deliberately. Rules run lowest-number-first, and the first matching &lt;code&gt;block&lt;/code&gt; is terminal. You want suppression to be the &lt;em&gt;first&lt;/em&gt; thing evaluated on the outbound path, ahead of any routing or archiving rules, so a suppressed send dies immediately and never burns evaluation on rules that no longer matter.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wire the handler: payload → list
&lt;/h2&gt;

&lt;p&gt;Here's the only code you write. The two payloads carry the offending addresses in slightly different shapes, so handle each trigger type explicitly rather than guessing.&lt;/p&gt;

&lt;p&gt;A &lt;code&gt;message.bounced&lt;/code&gt; event puts the bounced recipients under &lt;code&gt;data.object.bounce.recipients&lt;/code&gt;, each an object with an &lt;code&gt;email&lt;/code&gt; field (and sometimes a &lt;code&gt;diagnostic_code&lt;/code&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"message.bounced"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"data"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"grant_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"&amp;lt;NYLAS_GRANT_ID&amp;gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"object"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"bounce"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"MailboxFull"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"recipients"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"email"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"jane@example.com"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"diagnostic_code"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"smtp; 550 user unknown"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A &lt;code&gt;message.complaint&lt;/code&gt; event puts them under &lt;code&gt;data.object.complaint.complained_recipients&lt;/code&gt;, a flat array of address strings:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"message.complaint"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"data"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"grant_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"&amp;lt;NYLAS_GRANT_ID&amp;gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"object"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"complaint"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"abuse"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"complained_recipients"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"jordan.taylor@example.com"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So the handler logic is: branch on &lt;code&gt;type&lt;/code&gt;, pull the addresses out of the right field, and POST them to your list's &lt;code&gt;/items&lt;/code&gt; endpoint. In pseudocode:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;on webhook (event):
  if event.type == "message.bounced":
    addresses = [r.email for r in event.data.object.bounce.recipients]
  elif event.type == "message.complaint":
    addresses = event.data.object.complaint.complained_recipients
  else:
    return  # ignore everything else

  POST https://api.us.nylas.com/v3/lists/&amp;lt;LIST_ID&amp;gt;/items
       { "items": addresses }
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the entire feedback loop. From here on, any agent in the workspace that tries to mail one of those addresses gets a &lt;code&gt;403&lt;/code&gt;, and you never have to think about it again.&lt;/p&gt;

&lt;h2&gt;
  
  
  Guardrails and gotchas
&lt;/h2&gt;

&lt;p&gt;A few things I'd want a teammate to know before shipping this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Handle the &lt;code&gt;403&lt;/code&gt; on send.&lt;/strong&gt; An outbound block returns &lt;code&gt;403&lt;/code&gt; and stores no sent copy. Treat it exactly like any other permanent delivery failure — there is no retry path that will deliver it. If your agent has retry logic, make sure a &lt;code&gt;403&lt;/code&gt; short-circuits it instead of looping.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rule evaluation fails closed.&lt;/strong&gt; If the &lt;code&gt;in_list&lt;/code&gt; lookup hits a transient infrastructure error, Nylas blocks the send rather than letting it through — but it returns &lt;code&gt;503&lt;/code&gt; (not &lt;code&gt;403&lt;/code&gt;) and inbound SMTP returns a &lt;code&gt;451&lt;/code&gt; tempfail, so the distinction is retryable-vs-permanent. A &lt;code&gt;503&lt;/code&gt; means "try again later"; a &lt;code&gt;403&lt;/code&gt; means "this address is genuinely suppressed." Don't collapse them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bounces aren't all permanent.&lt;/strong&gt; A &lt;code&gt;MailboxFull&lt;/code&gt; bounce can be transient — that mailbox might come back tomorrow. If you suppress aggressively on every bounce type, you'll permanently lose recipients who had a full inbox for a day. Consider only suppressing on hard bounces (nonexistent mailbox, &lt;code&gt;550 user unknown&lt;/code&gt;) and treating soft bounces as a back-off signal instead. The &lt;code&gt;diagnostic_code&lt;/code&gt; and &lt;code&gt;type&lt;/code&gt; fields give you what you need to make that call.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Audit what got blocked.&lt;/strong&gt; When someone asks "why didn't this send go out," call &lt;code&gt;GET /v3/grants/{grant_id}/rule-evaluations&lt;/code&gt; to see exactly which rule matched and why. The records carry the normalized recipient data and the matched rule IDs, so you can prove a block came from suppression and not from something else. This one is API-only — there's no &lt;code&gt;rule-evaluations&lt;/code&gt; subcommand under &lt;code&gt;nylas agent&lt;/code&gt;, so reach for curl:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;  curl &lt;span class="nt"&gt;--request&lt;/span&gt; GET &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--url&lt;/span&gt; &lt;span class="s2"&gt;"https://api.us.nylas.com/v3/grants/&amp;lt;NYLAS_GRANT_ID&amp;gt;/rule-evaluations?limit=50"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &amp;lt;NYLAS_API_KEY&amp;gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Verify everything against the help text.&lt;/strong&gt; The CLI surface moves; before you script &lt;code&gt;nylas agent rule create&lt;/code&gt; or &lt;code&gt;nylas agent list add&lt;/code&gt; into a pipeline, run the command with &lt;code&gt;--help&lt;/code&gt; and confirm the flags. That's the same habit that caught the missing deliverability triggers above.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://developer.nylas.com/docs/v3/agent-accounts/deliverability/" rel="noopener noreferrer"&gt;Email deliverability for Agent Accounts&lt;/a&gt; — the authentication, DMARC, and warm-up work that suppression complements&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://developer.nylas.com/docs/v3/agent-accounts/policies-rules-lists/" rel="noopener noreferrer"&gt;Policies, Rules, and Lists&lt;/a&gt; — the full rule grammar, condition fields, and list types&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://developer.nylas.com/docs/reference/notifications/agent-accounts/" rel="noopener noreferrer"&gt;Agent Account webhook notifications&lt;/a&gt; — the complete schemas for the delivery, bounce, complaint, and rejection events&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://developer.nylas.com/docs/v3/agent-accounts/send-limits/" rel="noopener noreferrer"&gt;Usage limits&lt;/a&gt; — the bounce and complaint thresholds that pause sending, and what a pause looks like to your code&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://cli.nylas.com/docs/commands" rel="noopener noreferrer"&gt;Nylas CLI commands&lt;/a&gt; — the full command reference for &lt;code&gt;nylas agent&lt;/code&gt; and &lt;code&gt;nylas webhook&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Authentication gets you delivered. Suppression keeps you delivered. The two together are the difference between an agent that ages into spam folders and one that stays in the inbox because it learned to stop knocking on doors that don't open.&lt;/p&gt;

&lt;h2&gt;
  
  
  AI-answer pages for agents
&lt;/h2&gt;

&lt;p&gt;When this post is published, link AI agents and crawlers to the retrieval-ready version on &lt;code&gt;cli.nylas.com&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Topic runbook: &lt;a href="https://cli.nylas.com/ai-answers/agent-account-bounce-monitoring.md" rel="noopener noreferrer"&gt;https://cli.nylas.com/ai-answers/agent-account-bounce-monitoring.md&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Industry playbooks hub: &lt;a href="https://cli.nylas.com/ai-answers/agent-account-industry-playbooks.md" rel="noopener noreferrer"&gt;https://cli.nylas.com/ai-answers/agent-account-industry-playbooks.md&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>email</category>
      <category>api</category>
      <category>devtools</category>
      <category>productivity</category>
    </item>
    <item>
      <title>Allowlist and denylist rules for agent email, backed by Lists</title>
      <dc:creator>Qasim</dc:creator>
      <pubDate>Sun, 12 Jul 2026 15:19:34 +0000</pubDate>
      <link>https://dev.to/mqasimca/allowlist-and-denylist-rules-for-agent-email-backed-by-lists-44f2</link>
      <guid>https://dev.to/mqasimca/allowlist-and-denylist-rules-for-agent-email-backed-by-lists-44f2</guid>
      <description>&lt;p&gt;The usual way to keep an AI email agent from talking to the wrong people is a hardcoded set in your application code:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;ALLOWED_DOMAINS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;yourcompany.com&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;customer.example&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That works, and for a single internal agent it's the right call. But the moment a non-engineer needs to add a customer domain, or you want the &lt;em&gt;same&lt;/em&gt; allowlist to govern five different agents, or you want it to apply to mail coming &lt;strong&gt;in&lt;/strong&gt; and not just going &lt;strong&gt;out&lt;/strong&gt;, that set in code becomes a deploy-shaped bottleneck. Every change is a PR, a review, a release.&lt;/p&gt;

&lt;p&gt;There's a layer below your code that can carry that policy for you. An Agent Account on Nylas evaluates &lt;strong&gt;Rules&lt;/strong&gt; against every message — inbound on arrival, outbound on send — and those rules can point at &lt;strong&gt;Lists&lt;/strong&gt;, which are typed, editable collections of domains, TLDs, or addresses. Update a list, and every rule that references it picks up the change immediately. No redeploy, no code change, no model in the loop.&lt;/p&gt;

&lt;p&gt;This post goes deep on that pattern: Lists fronted by the &lt;code&gt;in_list&lt;/code&gt; rule operator, used for allow &lt;em&gt;and&lt;/em&gt; block on &lt;em&gt;both&lt;/em&gt; directions. If you've already read the basic inbound/outbound filtering post, this is the part that makes the filtering dynamic.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you actually get
&lt;/h2&gt;

&lt;p&gt;The data plane doesn't change. An Agent Account is just a &lt;strong&gt;grant&lt;/strong&gt; with a &lt;code&gt;grant_id&lt;/code&gt;, and everything you already know about Messages, Drafts, Threads, and Folders still applies. Rules and Lists sit one level up, on the &lt;em&gt;control plane&lt;/em&gt; — they're application-scoped admin resources with no grant ID in the path. Your API key identifies the application, and the rules apply to every Agent Account in a workspace.&lt;/p&gt;

&lt;p&gt;So the mental model is two planes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Data plane&lt;/strong&gt; — &lt;code&gt;GET /v3/grants/{grant_id}/messages&lt;/code&gt;, &lt;code&gt;nylas email send&lt;/code&gt;, the stuff your agent does.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Control plane&lt;/strong&gt; — &lt;code&gt;POST /v3/lists&lt;/code&gt;, &lt;code&gt;POST /v3/rules&lt;/code&gt;, the stuff that decides what the agent is &lt;em&gt;allowed&lt;/em&gt; to do.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A &lt;strong&gt;List&lt;/strong&gt; holds values. A &lt;strong&gt;Rule&lt;/strong&gt; references a list through the &lt;code&gt;in_list&lt;/code&gt; operator and says "if the sender domain is in this list, block it" (or assign it, or archive it). Change the list, and the rule's behavior changes with it. That indirection is the whole point: the &lt;em&gt;what&lt;/em&gt; (which domains) lives in a list a human can edit; the &lt;em&gt;how&lt;/em&gt; (block vs. route) lives in a rule you set once.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this beats a code-side allowlist
&lt;/h2&gt;

&lt;p&gt;A few honest tradeoffs, because a static &lt;code&gt;ALLOWED_DOMAINS&lt;/code&gt; set genuinely is simpler when you only have one agent:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Editable without a deploy.&lt;/strong&gt; Someone updates a list through the API or a CLI command, and the change is live. No PR, no release. This is the reason to reach for it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One list, many rules.&lt;/strong&gt; A single "Blocked domains" list can feed an inbound block rule &lt;em&gt;and&lt;/em&gt; an outbound block rule. Update it once, both directions follow.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Both directions.&lt;/strong&gt; A code-side check before your send call only covers outbound. Rules also reject inbound mail at SMTP, before it ever lands in the mailbox or fires a &lt;code&gt;message.created&lt;/code&gt; webhook — so your application never even sees it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fails closed on infrastructure errors.&lt;/strong&gt; If a &lt;code&gt;block&lt;/code&gt; rule can't evaluate a list lookup because of a transient error, Nylas blocks the message rather than letting it slip through. Inbound gets a &lt;code&gt;451&lt;/code&gt; tempfail so the sender retries; an API send gets a retryable &lt;code&gt;503&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The flip side: if your agent legitimately needs to reach arbitrary external recipients — an open-ended sales tool, say — a static allowlist will block valid sends and frustrate the workflow. Lists fit internal and known-customer agents, not cold outreach. For that case, lean on volume caps and human approval instead, which the &lt;a href="https://developer.nylas.com/docs/cookbook/agents/restrict-agent-recipients/" rel="noopener noreferrer"&gt;restrict agent recipients&lt;/a&gt; cookbook recipe covers.&lt;/p&gt;

&lt;h2&gt;
  
  
  Before you begin
&lt;/h2&gt;

&lt;p&gt;You need an Agent Account (a grant created via &lt;code&gt;POST /v3/connect/custom&lt;/code&gt; against a registered domain — see &lt;a href="https://developer.nylas.com/docs/v3/agent-accounts/" rel="noopener noreferrer"&gt;Agent Accounts&lt;/a&gt;), an API key for the same application, and the host in your examples set to &lt;code&gt;https://api.us.nylas.com&lt;/code&gt;. Every API call below carries &lt;code&gt;Authorization: Bearer &amp;lt;NYLAS_API_KEY&amp;gt;&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;I work on the Nylas CLI, so the terminal commands below are the exact ones I reach for. Three subcommands cover the whole flow, and each maps to a distinct piece: &lt;code&gt;nylas agent list&lt;/code&gt; manages list resources (&lt;code&gt;/v3/lists&lt;/code&gt;), &lt;code&gt;nylas agent rule&lt;/code&gt; manages rules (&lt;code&gt;/v3/rules&lt;/code&gt;), and &lt;code&gt;nylas workspace update&lt;/code&gt; attaches a rule to a workspace via its &lt;code&gt;rule_ids&lt;/code&gt;. They don't overlap — &lt;code&gt;nylas agent list&lt;/code&gt; never touches workspaces, and a created rule does nothing until a workspace references it (more on that below). Where it matters I'll show both the curl and the CLI form. CLI reference lives at &lt;a href="https://cli.nylas.com/docs/commands" rel="noopener noreferrer"&gt;cli.nylas.com/docs/commands&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;One thing to internalize up front about which fields each direction can match, because it's the most common mistake:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Inbound rules&lt;/strong&gt; can only match &lt;code&gt;from.*&lt;/code&gt; fields — &lt;code&gt;from.address&lt;/code&gt;, &lt;code&gt;from.domain&lt;/code&gt;, &lt;code&gt;from.tld&lt;/code&gt;. You only know who sent it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Outbound rules&lt;/strong&gt; can match &lt;code&gt;from.*&lt;/code&gt;, &lt;code&gt;recipient.*&lt;/code&gt; (&lt;code&gt;recipient.address&lt;/code&gt;, &lt;code&gt;recipient.domain&lt;/code&gt;, &lt;code&gt;recipient.tld&lt;/code&gt;), and &lt;code&gt;outbound.type&lt;/code&gt; (&lt;code&gt;compose&lt;/code&gt; vs. &lt;code&gt;reply&lt;/code&gt;).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That asymmetry drives everything below. You allowlist &lt;em&gt;senders&lt;/em&gt; on the way in, and &lt;em&gt;recipients&lt;/em&gt; on the way out.&lt;/p&gt;

&lt;h2&gt;
  
  
  Create a List of domains
&lt;/h2&gt;

&lt;p&gt;A list has a fixed &lt;code&gt;type&lt;/code&gt; — &lt;code&gt;domain&lt;/code&gt;, &lt;code&gt;tld&lt;/code&gt;, or &lt;code&gt;address&lt;/code&gt; — set at creation and immutable. The type decides which rule fields it can match: a &lt;code&gt;domain&lt;/code&gt; list matches &lt;code&gt;from.domain&lt;/code&gt; and &lt;code&gt;recipient.domain&lt;/code&gt;, an &lt;code&gt;address&lt;/code&gt; list matches the &lt;code&gt;.address&lt;/code&gt; fields, and so on. Start with a domain list, since most allow/block decisions are domain-level.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;--request&lt;/span&gt; POST &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--url&lt;/span&gt; &lt;span class="s2"&gt;"https://api.us.nylas.com/v3/lists"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &amp;lt;NYLAS_API_KEY&amp;gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--data&lt;/span&gt; &lt;span class="s1"&gt;'{
    "name": "Blocked domains",
    "type": "domain"
  }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The response carries the list &lt;code&gt;id&lt;/code&gt; you'll reference from rules:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"request_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"5fa64c92-e840-4357-86b9-2aa364d35b88"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"data"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"d1e2f3a4-5678-4abc-9def-0123456789ab"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Blocked domains"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"domain"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"items_count"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"created_at"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1742932766&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"updated_at"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1742932766&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same thing from the CLI, which can seed items at creation time with repeatable &lt;code&gt;--item&lt;/code&gt; flags:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;nylas agent list create &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--name&lt;/span&gt; &lt;span class="s2"&gt;"Blocked domains"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--type&lt;/span&gt; domain &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--item&lt;/span&gt; spam-domain.com &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--item&lt;/span&gt; another-bad-domain.net
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;--type&lt;/code&gt; is &lt;code&gt;domain&lt;/code&gt;, &lt;code&gt;tld&lt;/code&gt;, or &lt;code&gt;address&lt;/code&gt;, and it's immutable — pick it deliberately. The CLI hint says it plainly: the type "determines which rule fields the list can match in in_list conditions."&lt;/p&gt;

&lt;h2&gt;
  
  
  Add items to a List
&lt;/h2&gt;

&lt;p&gt;Lists are where the dynamic part lives. Add up to 1000 items per request; values are lowercased, trimmed, and validated against the list's type, so a &lt;code&gt;domain&lt;/code&gt; list rejects full email addresses. Duplicate additions are silently ignored, which means you can re-run an add idempotently.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;--request&lt;/span&gt; POST &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--url&lt;/span&gt; &lt;span class="s2"&gt;"https://api.us.nylas.com/v3/lists/&amp;lt;LIST_ID&amp;gt;/items"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &amp;lt;NYLAS_API_KEY&amp;gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--data&lt;/span&gt; &lt;span class="s1"&gt;'{
    "items": ["spam-domain.com", "another-bad-domain.net"]
  }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;From the CLI, items are positional arguments after the list ID:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;nylas agent list add &amp;lt;LIST_ID&amp;gt; spam-domain.com another-bad-domain.net
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the command you hand to a non-engineer, or wire into an internal admin panel, or call from a webhook handler when an abuse report comes in. It's a single API write that immediately changes the behavior of every rule pointing at the list — no rule edit, no deploy. To pull a domain back out, &lt;code&gt;nylas agent list remove &amp;lt;LIST_ID&amp;gt; spam-domain.com&lt;/code&gt; (or &lt;code&gt;DELETE /v3/lists/{list_id}/items&lt;/code&gt;).&lt;/p&gt;

&lt;h2&gt;
  
  
  Block inbound mail with an &lt;code&gt;in_list&lt;/code&gt; rule
&lt;/h2&gt;

&lt;p&gt;Now wire a rule to the list. For inbound, you can only match on the sender, so this is a denylist of sender domains. The key is &lt;code&gt;"operator": "in_list"&lt;/code&gt; with the list ID — note that &lt;code&gt;value&lt;/code&gt; is an &lt;strong&gt;array&lt;/strong&gt; of list IDs, not the domains themselves. A single &lt;code&gt;in_list&lt;/code&gt; condition can reference up to 10 lists.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;--request&lt;/span&gt; POST &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--url&lt;/span&gt; &lt;span class="s2"&gt;"https://api.us.nylas.com/v3/rules"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &amp;lt;NYLAS_API_KEY&amp;gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--data&lt;/span&gt; &lt;span class="s1"&gt;'{
    "name": "Block senders on our blocklist",
    "priority": 1,
    "trigger": "inbound",
    "match": {
      "conditions": [
        {
          "field": "from.domain",
          "operator": "in_list",
          "value": ["&amp;lt;LIST_ID&amp;gt;"]
        }
      ]
    },
    "actions": [
      { "type": "block" }
    ]
  }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The CLI condition format is &lt;code&gt;field,operator,value&lt;/code&gt;. For &lt;code&gt;in_list&lt;/code&gt;, the trailing comma-separated values are list IDs — &lt;code&gt;field,in_list,list-id-1,list-id-2&lt;/code&gt; — which maps directly to the array in the JSON above:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;nylas agent rule create &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--name&lt;/span&gt; &lt;span class="s2"&gt;"Block senders on our blocklist"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--trigger&lt;/span&gt; inbound &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--priority&lt;/span&gt; 1 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--condition&lt;/span&gt; from.domain,in_list,&amp;lt;LIST_ID&amp;gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--action&lt;/span&gt; block
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;block&lt;/code&gt; action is terminal — it can't be combined with other actions. For inbound, it rejects the message at SMTP, so it never reaches the mailbox and never fires a &lt;code&gt;message.created&lt;/code&gt; webhook. Your application is genuinely insulated from that sender.&lt;/p&gt;

&lt;p&gt;A note on priority and rule ordering: rules run lowest &lt;code&gt;priority&lt;/code&gt; first (range 0–1000, default 10), and the first matching &lt;code&gt;block&lt;/code&gt; wins. Put your specific &lt;code&gt;in_list&lt;/code&gt; block ahead of any broad &lt;code&gt;contains&lt;/code&gt; rules so the precise check fires first.&lt;/p&gt;

&lt;h2&gt;
  
  
  Activate the rule on a workspace
&lt;/h2&gt;

&lt;p&gt;Here's the step that trips people up: creating the List and the Rule isn't enough. &lt;strong&gt;A rule is inert until a workspace references it.&lt;/strong&gt; Workspaces carry policies and rules — you don't attach a rule to a grant directly. You add the rule's ID to a workspace's &lt;code&gt;rule_ids&lt;/code&gt; array, and from then on it runs for every Agent Account in that workspace. One array carries both inbound and outbound rules; Nylas filters by &lt;code&gt;trigger&lt;/code&gt; at evaluation time, so the same array can hold your inbound block and your outbound block side by side.&lt;/p&gt;

&lt;p&gt;Attach the rule with &lt;code&gt;PATCH /v3/workspaces/{workspace_id}&lt;/code&gt;. Pass the &lt;em&gt;full&lt;/em&gt; set of rule IDs you want active — this replaces the array, so include any existing rules you want to keep:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;--request&lt;/span&gt; PATCH &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--url&lt;/span&gt; &lt;span class="s2"&gt;"https://api.us.nylas.com/v3/workspaces/&amp;lt;WORKSPACE_ID&amp;gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &amp;lt;NYLAS_API_KEY&amp;gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--data&lt;/span&gt; &lt;span class="s1"&gt;'{
    "rule_ids": ["&amp;lt;INBOUND_RULE_ID&amp;gt;"]
  }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;From the CLI, &lt;code&gt;nylas workspace update&lt;/code&gt; takes a comma-separated list of rule IDs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;nylas workspace update &amp;lt;WORKSPACE_ID&amp;gt; &lt;span class="nt"&gt;--rules-ids&lt;/span&gt; &amp;lt;INBOUND_RULE_ID&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you don't know the workspace ID, the default workspace is the one that holds any Agent Account you haven't placed in a custom workspace, so attaching there covers your unassigned accounts. As a convenience, &lt;code&gt;nylas agent rule create&lt;/code&gt; resolves the default grant's workspace and attaches the new rule to it for you — but when you create rules over the raw API, or you want a rule to apply to a &lt;em&gt;non-default&lt;/em&gt; workspace, this &lt;code&gt;PATCH&lt;/code&gt; is the step that actually turns the rule on. Re-run it whenever you add an outbound rule below, listing every rule ID you want active.&lt;/p&gt;

&lt;h2&gt;
  
  
  Allow vs. block on outbound
&lt;/h2&gt;

&lt;p&gt;Outbound is where the allowlist pattern gets interesting, because you have two ways to express "only talk to approved recipients," and they fail in opposite directions.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The blocklist version&lt;/strong&gt; — block sends to a list of denied recipient domains — is permissive by default. Anything not on the list goes through. Good for known-bad domains, competitors, test domains that leaked into production:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;--request&lt;/span&gt; POST &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--url&lt;/span&gt; &lt;span class="s2"&gt;"https://api.us.nylas.com/v3/rules"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &amp;lt;NYLAS_API_KEY&amp;gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--data&lt;/span&gt; &lt;span class="s1"&gt;'{
    "name": "Block sends to denied recipients",
    "trigger": "outbound",
    "match": {
      "conditions": [
        {
          "field": "recipient.domain",
          "operator": "in_list",
          "value": ["&amp;lt;DENY_LIST_ID&amp;gt;"]
        }
      ]
    },
    "actions": [
      { "type": "block" }
    ]
  }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;nylas agent rule create &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--name&lt;/span&gt; &lt;span class="s2"&gt;"Block sends to denied recipients"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--trigger&lt;/span&gt; outbound &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--condition&lt;/span&gt; recipient.domain,in_list,&amp;lt;DENY_LIST_ID&amp;gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--action&lt;/span&gt; block
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This rule, like the inbound one, does nothing until it's on a workspace's &lt;code&gt;rule_ids&lt;/code&gt;. Re-run the &lt;code&gt;PATCH /v3/workspaces/{workspace_id}&lt;/code&gt; (or &lt;code&gt;nylas workspace update &amp;lt;WORKSPACE_ID&amp;gt; --rules-ids &amp;lt;INBOUND_RULE_ID&amp;gt;,&amp;lt;OUTBOUND_RULE_ID&amp;gt;&lt;/code&gt;) with &lt;em&gt;both&lt;/em&gt; IDs in the array — the same array carries both directions, and Nylas runs each rule only on its matching trigger.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A true allowlist&lt;/strong&gt; — block any recipient &lt;em&gt;not&lt;/em&gt; on the approved list — is stricter and fails closed. Worth being precise about what the rule engine supports here: &lt;code&gt;in_list&lt;/code&gt; matches when a value &lt;em&gt;is&lt;/em&gt; present in the list, so it expresses "block what's on the denylist" directly. There's no &lt;code&gt;not_in_list&lt;/code&gt; operator. For the strict "deny everything except the allowlist" semantics, the durable pattern is to keep the hard fail-closed allowlist in your send wrapper — where an empty or unknown domain is denied by default — and use the Nylas &lt;code&gt;in_list&lt;/code&gt; block rule for the dynamic denylist layer on top.&lt;/p&gt;

&lt;p&gt;In practice that split works well: the rule is the editable, no-deploy layer a non-engineer can update; the wrapper is the fail-closed gate your code owns. The &lt;a href="https://developer.nylas.com/docs/cookbook/agents/restrict-agent-recipients/" rel="noopener noreferrer"&gt;restrict agent recipients&lt;/a&gt; recipe covers the wrapper side. Don't try to make one do both jobs.&lt;/p&gt;

&lt;p&gt;One important behavioral detail for outbound: &lt;code&gt;recipient.*&lt;/code&gt; matches against &lt;strong&gt;any&lt;/strong&gt; recipient, including CC, BCC, and SMTP envelope recipients. That's exactly what you want for data-loss prevention — a hidden BCC to a denied domain still trips the block. An outbound &lt;code&gt;block&lt;/code&gt; returns &lt;code&gt;403&lt;/code&gt; to your send call and stores no sent copy; treat it like any other non-retryable delivery failure.&lt;/p&gt;

&lt;h2&gt;
  
  
  Route inbound mail by list instead of blocking
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;in_list&lt;/code&gt; isn't only for blocking. Swap the action and you've got dynamic &lt;em&gt;routing&lt;/em&gt;. Keep a list of "VIP customer domains" and auto-star or folder their mail without touching code when a new customer signs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;--request&lt;/span&gt; POST &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--url&lt;/span&gt; &lt;span class="s2"&gt;"https://api.us.nylas.com/v3/rules"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &amp;lt;NYLAS_API_KEY&amp;gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--data&lt;/span&gt; &lt;span class="s1"&gt;'{
    "name": "Star VIP senders",
    "trigger": "inbound",
    "match": {
      "conditions": [
        {
          "field": "from.domain",
          "operator": "in_list",
          "value": ["&amp;lt;VIP_LIST_ID&amp;gt;"]
        }
      ]
    },
    "actions": [
      { "type": "mark_as_starred" }
    ]
  }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;nylas agent rule create &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--name&lt;/span&gt; &lt;span class="s2"&gt;"Star VIP senders"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--trigger&lt;/span&gt; inbound &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--condition&lt;/span&gt; from.domain,in_list,&amp;lt;VIP_LIST_ID&amp;gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--action&lt;/span&gt; mark_as_starred
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The non-blocking actions — &lt;code&gt;mark_as_starred&lt;/code&gt;, &lt;code&gt;mark_as_read&lt;/code&gt;, &lt;code&gt;assign_to_folder&lt;/code&gt;, &lt;code&gt;archive&lt;/code&gt;, &lt;code&gt;mark_as_spam&lt;/code&gt;, &lt;code&gt;trash&lt;/code&gt; — all compose with &lt;code&gt;in_list&lt;/code&gt;. Sales adds a domain to the VIP list, and the agent's inbox starts starring that customer's mail. Nobody touched a rule.&lt;/p&gt;

&lt;h2&gt;
  
  
  Guardrails and gotchas
&lt;/h2&gt;

&lt;p&gt;A few things I've watched people trip over:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A rule does nothing until it's on a workspace.&lt;/strong&gt; The most common "my rule isn't firing" cause is a rule that was created but never added to a workspace's &lt;code&gt;rule_ids&lt;/code&gt;. Creating the List and Rule is two-thirds of the job; the &lt;code&gt;PATCH /v3/workspaces/{workspace_id}&lt;/code&gt; is what arms it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;rule_ids&lt;/code&gt; is a full replacement.&lt;/strong&gt; The PATCH replaces the array, it doesn't append. List every rule ID you want active each time, or you'll silently detach the ones you left out.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;List type must match the field.&lt;/strong&gt; A &lt;code&gt;domain&lt;/code&gt; list only works with &lt;code&gt;from.domain&lt;/code&gt; / &lt;code&gt;recipient.domain&lt;/code&gt;. Point a &lt;code&gt;from.address&lt;/code&gt; condition at a domain list and it won't match. Pick the type to fit the field you'll match.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;value&lt;/code&gt; is an array of list IDs for &lt;code&gt;in_list&lt;/code&gt;, not the values.&lt;/strong&gt; The domains live &lt;em&gt;in&lt;/em&gt; the list; the rule references the list by ID. This catches everyone once.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Inbound can't see recipients.&lt;/strong&gt; You cannot allowlist recipients on inbound rules — there's no &lt;code&gt;recipient.*&lt;/code&gt; on the inbound trigger, because there's only one recipient (the agent) and it's already known. Recipient allow/block is outbound-only.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Up to 10 lists per &lt;code&gt;in_list&lt;/code&gt; condition, 500 chars per value, 50 conditions and 20 actions per rule.&lt;/strong&gt; Generous, but real caps.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deleting a list cascades.&lt;/strong&gt; Items go with it, and rules that referenced it via &lt;code&gt;in_list&lt;/code&gt; simply stop matching its values. No dangling references, but also no warning — audit before you delete.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Audit what actually fired.&lt;/strong&gt; When something gets blocked or routed unexpectedly, &lt;code&gt;GET /v3/grants/{grant_id}/rule-evaluations&lt;/code&gt; lists every evaluation most-recent-first, with the matched rule IDs and applied actions. It's the fastest answer to "why did this message get blocked?"&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;p&gt;The combination to remember: a &lt;strong&gt;List&lt;/strong&gt; is the editable noun, the &lt;code&gt;in_list&lt;/code&gt; &lt;strong&gt;operator&lt;/strong&gt; is the indirection, the &lt;strong&gt;trigger&lt;/strong&gt; decides whether you're filtering senders (inbound) or recipients (outbound), and the &lt;strong&gt;workspace&lt;/strong&gt; &lt;code&gt;rule_ids&lt;/code&gt; array is what arms everything. Create the list, create the rule, attach the rule — skip that last step and nothing fires. Once those click, you can hand the &lt;em&gt;who&lt;/em&gt; to a non-engineer and keep the &lt;em&gt;how&lt;/em&gt; in version control.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://developer.nylas.com/docs/v3/agent-accounts/policies-rules-lists/" rel="noopener noreferrer"&gt;Policies, Rules, and Lists&lt;/a&gt; for the full schema — every operator, action, and field&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://developer.nylas.com/docs/cookbook/agents/restrict-agent-recipients/" rel="noopener noreferrer"&gt;Restrict agent recipients&lt;/a&gt; for the application-side fail-closed allowlist that pairs with these rules&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://developer.nylas.com/docs/v3/agent-accounts/" rel="noopener noreferrer"&gt;Agent Accounts&lt;/a&gt; to provision a grant against a registered domain&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://cli.nylas.com/docs/commands" rel="noopener noreferrer"&gt;CLI commands&lt;/a&gt; for &lt;code&gt;nylas agent list&lt;/code&gt;, &lt;code&gt;nylas agent rule&lt;/code&gt;, and &lt;code&gt;nylas workspace update&lt;/code&gt; reference&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  AI-answer pages for agents
&lt;/h2&gt;

&lt;p&gt;When this post is published, link AI agents and crawlers to the retrieval-ready version on &lt;code&gt;cli.nylas.com&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Topic runbook: &lt;a href="https://cli.nylas.com/ai-answers/agent-account-rules-lists-api-cookbook.md" rel="noopener noreferrer"&gt;https://cli.nylas.com/ai-answers/agent-account-rules-lists-api-cookbook.md&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Industry playbooks hub: &lt;a href="https://cli.nylas.com/ai-answers/agent-account-industry-playbooks.md" rel="noopener noreferrer"&gt;https://cli.nylas.com/ai-answers/agent-account-industry-playbooks.md&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>email</category>
      <category>api</category>
      <category>devtools</category>
      <category>productivity</category>
    </item>
    <item>
      <title>Distribute meeting notes from an agent's calendar</title>
      <dc:creator>Qasim</dc:creator>
      <pubDate>Sun, 12 Jul 2026 15:18:40 +0000</pubDate>
      <link>https://dev.to/mqasimca/distribute-meeting-notes-from-an-agents-calendar-4nng</link>
      <guid>https://dev.to/mqasimca/distribute-meeting-notes-from-an-agents-calendar-4nng</guid>
      <description>&lt;p&gt;Notes are useless if they sit in a doc nobody opens. You've seen it: the meeting ends, someone pastes a summary into a shared doc, drops the link in a channel, and three days later the action items are still unassigned because nobody scrolled to the bottom. The information existed. It just never reached the people who needed it, in the place they actually read.&lt;/p&gt;

&lt;p&gt;Most "AI meeting notes" demos solve the &lt;em&gt;capture&lt;/em&gt; problem and stop there. They join the call, transcribe it, generate a tidy summary — and then hand you a URL. The distribution is left as an exercise for the reader. That's the part I want to fix here, and the interesting move is to make distribution a &lt;em&gt;calendar-triggered&lt;/em&gt; job instead of a manual one.&lt;/p&gt;

&lt;p&gt;The pattern: when an event on an &lt;strong&gt;agent's calendar&lt;/strong&gt; ends, read the event's participant list, and email a recap to each attendee — sent &lt;em&gt;from the agent's own address&lt;/em&gt; so any reply threads straight back into a real mailbox the agent already owns. No shared doc, no "find the link," no human in the loop.&lt;/p&gt;

&lt;p&gt;A quick honesty note up front, because it changes the design: this post deliberately does &lt;strong&gt;not&lt;/strong&gt; use Nylas Notetaker. Notetaker isn't supported for Agent Accounts. So the agent doesn't get the notes &lt;em&gt;from Nylas&lt;/em&gt; — the notes are &lt;strong&gt;your&lt;/strong&gt; application data (whatever your own pipeline produced: an LLM summary, structured action items, a CRM export). Nylas's job here is narrow and reliable: tell you who was in the meeting, and put the email in their inbox from an address that can receive replies. That's it. We lean on two primitives — &lt;strong&gt;Events&lt;/strong&gt; and &lt;strong&gt;Send&lt;/strong&gt; — and nothing else.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you get
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The participant list, straight from the event.&lt;/strong&gt; No scraping the invite email. The event object already carries &lt;code&gt;participants[]&lt;/code&gt;, each with an &lt;code&gt;email&lt;/code&gt; and an RSVP &lt;code&gt;status&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A real sender identity.&lt;/strong&gt; The recap comes from &lt;code&gt;notes-bot@yourcompany.com&lt;/code&gt; (the agent), not a no-reply relay. Someone hits Reply, asks a question, and it lands in a mailbox the agent reads.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A trigger tied to the meeting, not to a human remembering.&lt;/strong&gt; When the event's end time passes, the recap goes out.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Nothing new on the data plane.&lt;/strong&gt; An Agent Account is just a grant with a &lt;code&gt;grant_id&lt;/code&gt;. Every endpoint below is the ordinary grant-scoped Events and Messages API you'd use for any connected account. There's nothing agent-specific to learn — the agent &lt;em&gt;is&lt;/em&gt; a grant.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why this beats pasting a link in a channel
&lt;/h2&gt;

&lt;p&gt;Channels are lossy. The recap competes with forty other messages, gets collapsed, and is unsearchable to anyone who joined the project late. Email to the exact attendee list is addressed, durable, and — because it comes from the agent's real address — &lt;em&gt;conversational&lt;/em&gt;. The recipient can reply with "wait, who owns item 2?" and that question reaches the agent.&lt;/p&gt;

&lt;p&gt;It also sidesteps a permission headache. You're not trying to inject the agent into someone's human inbox or borrow a person's credentials to send "as" them. The agent &lt;strong&gt;is&lt;/strong&gt; a participant with its own mailbox. It reads its own calendar, it sends from its own address. Clean trust boundary.&lt;/p&gt;

&lt;h2&gt;
  
  
  Before you begin
&lt;/h2&gt;

&lt;p&gt;You need an Agent Account, which is a grant created on a registered domain. Two ways to make one.&lt;/p&gt;

&lt;p&gt;The API call is &lt;code&gt;POST /v3/connect/custom&lt;/code&gt; with &lt;code&gt;"provider": "nylas"&lt;/code&gt; and a &lt;code&gt;settings.email&lt;/code&gt; on a domain you've registered (a custom domain, or a &lt;code&gt;*.nylas.email&lt;/code&gt; trial subdomain). The optional top-level &lt;code&gt;name&lt;/code&gt; sets the display name. No refresh token, because there's no external provider to authenticate against.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;--request&lt;/span&gt; POST &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--url&lt;/span&gt; &lt;span class="s2"&gt;"https://api.us.nylas.com/v3/connect/custom"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &amp;lt;NYLAS_API_KEY&amp;gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--data&lt;/span&gt; &lt;span class="s1"&gt;'{
    "provider": "nylas",
    "name": "Notes Bot",
    "settings": { "email": "notes-bot@yourcompany.com" }
  }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I work on the CLI, so the terminal commands below are the exact ones I reach for. The CLI equivalent is one line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;nylas agent account create notes-bot@yourcompany.com &lt;span class="nt"&gt;--name&lt;/span&gt; &lt;span class="s2"&gt;"Notes Bot"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The API auto-creates a default workspace and policy for the account. There's no &lt;code&gt;--workspace&lt;/code&gt; flag on &lt;code&gt;create&lt;/code&gt; — if you want to attach a custom policy later, you do it separately with &lt;code&gt;nylas workspace update &amp;lt;workspace-id&amp;gt; --policy-id &amp;lt;policy-id&amp;gt;&lt;/code&gt;. The response gives you a &lt;code&gt;grant_id&lt;/code&gt;. That's the only identifier the rest of this flow needs.&lt;/p&gt;

&lt;p&gt;Verify the CLI is wired to your project by listing the agent grants — these are just grants with &lt;code&gt;provider: nylas&lt;/code&gt;, so the API form is a filtered &lt;code&gt;GET /v3/grants&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;--request&lt;/span&gt; GET &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--url&lt;/span&gt; &lt;span class="s2"&gt;"https://api.us.nylas.com/v3/grants?provider=nylas"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &amp;lt;NYLAS_API_KEY&amp;gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;nylas agent account list
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Read the event and its participants
&lt;/h2&gt;

&lt;p&gt;When you want to send a recap, you start from the event. Two situations:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;You already know the event ID (you created it, or stored it when the agent scheduled the meeting). Fetch it directly.&lt;/li&gt;
&lt;li&gt;You're sweeping the calendar for meetings that just ended. List events and filter.&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  Fetch one event by ID
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;GET /v3/grants/{grant_id}/events/{event_id}&lt;/code&gt; returns the event, including its &lt;code&gt;when&lt;/code&gt; block and &lt;code&gt;participants[]&lt;/code&gt;. The &lt;code&gt;calendar_id&lt;/code&gt; query parameter is required — use &lt;code&gt;primary&lt;/code&gt; for the agent's primary calendar.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;--request&lt;/span&gt; GET &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--url&lt;/span&gt; &lt;span class="s2"&gt;"https://api.us.nylas.com/v3/grants/&amp;lt;GRANT_ID&amp;gt;/events/&amp;lt;EVENT_ID&amp;gt;?calendar_id=primary"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &amp;lt;NYLAS_API_KEY&amp;gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The CLI verb is &lt;code&gt;show&lt;/code&gt; (aliased to &lt;code&gt;get&lt;/code&gt;/&lt;code&gt;read&lt;/code&gt;). It takes the event ID and an optional grant ID, plus &lt;code&gt;-c/--calendar&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;nylas calendar events show &amp;lt;EVENT_ID&amp;gt; &amp;lt;GRANT_ID&amp;gt; &lt;span class="nt"&gt;--calendar&lt;/span&gt; primary &lt;span class="nt"&gt;--json&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The piece you care about in the response is the participant array and the end time:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"data"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"&amp;lt;EVENT_ID&amp;gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"title"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Q1 planning"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"when"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"object"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"timespan"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"start_time"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1744387200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"end_time"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1744390800&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"participants"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"email"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"alice@example.com"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"yes"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"email"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"bob@example.com"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"yes"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"email"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"carol@example.com"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"noreply"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's your distribution list, already deduplicated and already RSVP-aware. You can choose to skip the &lt;code&gt;noreply&lt;/code&gt; and &lt;code&gt;no&lt;/code&gt; participants if you only want to recap to people who actually showed up — &lt;code&gt;status&lt;/code&gt; is right there on each entry. (One caveat worth stating: for Microsoft Graph events a participant's &lt;code&gt;email&lt;/code&gt; can be missing, so guard for it before you build the &lt;code&gt;to&lt;/code&gt; list.)&lt;/p&gt;

&lt;h3&gt;
  
  
  List events to find the ones that ended
&lt;/h3&gt;

&lt;p&gt;If you're polling, list the calendar and look at each event's &lt;code&gt;when.end_time&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;--request&lt;/span&gt; GET &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--url&lt;/span&gt; &lt;span class="s2"&gt;"https://api.us.nylas.com/v3/grants/&amp;lt;GRANT_ID&amp;gt;/events?calendar_id=primary&amp;amp;limit=50"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &amp;lt;NYLAS_API_KEY&amp;gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;nylas calendar events list &amp;lt;GRANT_ID&amp;gt; &lt;span class="nt"&gt;--calendar&lt;/span&gt; primary &lt;span class="nt"&gt;--days&lt;/span&gt; 1 &lt;span class="nt"&gt;--limit&lt;/span&gt; 50 &lt;span class="nt"&gt;--json&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;--days 1&lt;/code&gt; keeps the window tight to recent meetings, and &lt;code&gt;--limit&lt;/code&gt; auto-paginates past 200 if you need it. Each event in the list carries the same &lt;code&gt;when&lt;/code&gt; and &lt;code&gt;participants&lt;/code&gt; fields as the single-event fetch.&lt;/p&gt;

&lt;h2&gt;
  
  
  How you actually detect "the meeting ended"
&lt;/h2&gt;

&lt;p&gt;This is the part people hand-wave, so let me be precise about it: &lt;strong&gt;there is no "event ended" webhook.&lt;/strong&gt; Agent Account calendars emit &lt;code&gt;event.created&lt;/code&gt;, &lt;code&gt;event.updated&lt;/code&gt;, and &lt;code&gt;event.deleted&lt;/code&gt; — those fire when an event is &lt;em&gt;created&lt;/em&gt;, &lt;em&gt;changed&lt;/em&gt;, or &lt;em&gt;removed&lt;/em&gt;, not when its end time passes. The clock running out is not a state change, so nothing fires for it.&lt;/p&gt;

&lt;p&gt;So the trigger is &lt;strong&gt;yours&lt;/strong&gt;, and it's a comparison, not a notification: you compare the event's &lt;code&gt;when.end_time&lt;/code&gt; (a Unix timestamp) to the current time, in your own scheduler. Two honest ways to do it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Poll.&lt;/strong&gt; Run a job every few minutes that lists recent events (the &lt;code&gt;events list&lt;/code&gt; call above) and pick the ones where &lt;code&gt;end_time &amp;lt; now&lt;/code&gt; and you haven't already recapped. Cheap, dead simple, and good enough for most calendars.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Schedule off the event lifecycle.&lt;/strong&gt; Subscribe to &lt;code&gt;event.created&lt;/code&gt;/&lt;code&gt;event.updated&lt;/code&gt; at the app level, and when one arrives, read its &lt;code&gt;end_time&lt;/code&gt; and enqueue a one-shot job for that timestamp (a delayed task, a cron entry, a &lt;code&gt;setTimeout&lt;/code&gt; in a durable queue — whatever your stack uses). The webhook isn't the "ended" signal; it's just your cue to &lt;em&gt;schedule&lt;/em&gt; the check. If the event later moves, &lt;code&gt;event.updated&lt;/code&gt; fires and you reschedule.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Either way, &lt;strong&gt;you&lt;/strong&gt; own the "it's over now" decision. Don't tell yourself Nylas pushes it to you — it doesn't, and a design that assumes a phantom webhook will silently never send.&lt;/p&gt;

&lt;p&gt;Whichever you pick, keep a dedup record (the event ID plus its &lt;code&gt;end_time&lt;/code&gt;) so a re-run or a retried job doesn't email everyone twice. If the meeting time changed and you already sent a recap for the old slot, that's a product decision: usually you don't re-send.&lt;/p&gt;

&lt;p&gt;One more accuracy note on webhooks generally, since you'll likely subscribe to &lt;code&gt;event.*&lt;/code&gt;: &lt;strong&gt;webhooks are application-scoped, not grant-scoped.&lt;/strong&gt; You subscribe once at the app level with &lt;code&gt;POST /v3/webhooks&lt;/code&gt;, and events for every grant arrive at that one endpoint, each payload carrying a &lt;code&gt;grant_id&lt;/code&gt; you filter on. So you're not registering a webhook per agent — one subscription covers the whole fleet, and you route on &lt;code&gt;grant_id&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The subscription itself is one call:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;--request&lt;/span&gt; POST &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--url&lt;/span&gt; &lt;span class="s2"&gt;"https://api.us.nylas.com/v3/webhooks"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &amp;lt;NYLAS_API_KEY&amp;gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--data&lt;/span&gt; &lt;span class="s1"&gt;'{
    "trigger_types": ["event.created", "event.updated"],
    "webhook_url": "https://your-server.com/webhooks/nylas",
    "description": "Recap scheduler triggers"
  }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;nylas webhook create &lt;span class="nt"&gt;--url&lt;/span&gt; https://your-server.com/webhooks/nylas &lt;span class="nt"&gt;--triggers&lt;/span&gt; event.created,event.updated
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Email the recap from the agent's address
&lt;/h2&gt;

&lt;p&gt;Now the payload. Remember: &lt;strong&gt;the notes are your data.&lt;/strong&gt; Nylas didn't generate them — your summarization step did. You bring the &lt;code&gt;summary&lt;/code&gt; and &lt;code&gt;actionItems&lt;/code&gt;; Nylas delivers them.&lt;/p&gt;

&lt;p&gt;Build the body however you like (HTML reads best across clients), set the recipient list from the participants you read earlier, and send with &lt;code&gt;POST /v3/grants/{grant_id}/messages/send&lt;/code&gt;. The message goes out &lt;em&gt;from the agent's own mailbox&lt;/em&gt;, which is the whole point — a reply comes back to an address the agent owns.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;--request&lt;/span&gt; POST &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--url&lt;/span&gt; &lt;span class="s2"&gt;"https://api.us.nylas.com/v3/grants/&amp;lt;GRANT_ID&amp;gt;/messages/send"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &amp;lt;NYLAS_API_KEY&amp;gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--data&lt;/span&gt; &lt;span class="s1"&gt;'{
    "subject": "Recap: Q1 planning - summary &amp;amp; action items",
    "body": "&amp;lt;html&amp;gt;&amp;lt;body&amp;gt;&amp;lt;p&amp;gt;Hi everyone,&amp;lt;/p&amp;gt;&amp;lt;h2&amp;gt;Summary&amp;lt;/h2&amp;gt;&amp;lt;p&amp;gt;We locked the Q1 roadmap and split ownership for the first sprint.&amp;lt;/p&amp;gt;&amp;lt;h2&amp;gt;Action items&amp;lt;/h2&amp;gt;&amp;lt;ul&amp;gt;&amp;lt;li&amp;gt;Alice: finalize the API spec by Friday&amp;lt;/li&amp;gt;&amp;lt;li&amp;gt;Bob: book the design review&amp;lt;/li&amp;gt;&amp;lt;/ul&amp;gt;&amp;lt;/body&amp;gt;&amp;lt;/html&amp;gt;",
    "to": [
      { "email": "alice@example.com" },
      { "email": "bob@example.com" }
    ]
  }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The CLI sends the same message. &lt;code&gt;--to&lt;/code&gt; accepts a repeatable list, so you fan it out to every participant:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;nylas email send &amp;lt;GRANT_ID&amp;gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--to&lt;/span&gt; alice@example.com &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--to&lt;/span&gt; bob@example.com &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--subject&lt;/span&gt; &lt;span class="s2"&gt;"Recap: Q1 planning - summary &amp;amp; action items"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--body&lt;/span&gt; &lt;span class="s2"&gt;"&amp;lt;html&amp;gt;&amp;lt;body&amp;gt;&amp;lt;p&amp;gt;Hi everyone,&amp;lt;/p&amp;gt;&amp;lt;h2&amp;gt;Summary&amp;lt;/h2&amp;gt;&amp;lt;p&amp;gt;We locked the Q1 roadmap...&amp;lt;/p&amp;gt;&amp;lt;/body&amp;gt;&amp;lt;/html&amp;gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A design choice worth thinking about: &lt;strong&gt;one email to everyone, or one email per person?&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;One email, everyone on the &lt;code&gt;to&lt;/code&gt; line&lt;/strong&gt; is simplest and makes the thread a group conversation — a reply goes to the whole list. Good for a team that wants to discuss the action items together.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One email per participant&lt;/strong&gt; lets you personalize ("Carol, your two items are...") and keeps replies private, but it's N sends instead of one. If you go this way, loop the &lt;code&gt;events send&lt;/code&gt;/&lt;code&gt;messages/send&lt;/code&gt; call per recipient.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Either way, because the sender is the agent's real address, replies thread back to the agent. That's the property you don't get from a no-reply relay or a "view in browser" link. If you want to read those replies, that's the ordinary thread API — &lt;code&gt;GET /v3/grants/{grant_id}/threads/{thread_id}&lt;/code&gt; returns the thread and its messages:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;--request&lt;/span&gt; GET &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--url&lt;/span&gt; &lt;span class="s2"&gt;"https://api.us.nylas.com/v3/grants/&amp;lt;GRANT_ID&amp;gt;/threads/&amp;lt;THREAD_ID&amp;gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &amp;lt;NYLAS_API_KEY&amp;gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;nylas email threads show &amp;lt;THREAD_ID&amp;gt; &amp;lt;GRANT_ID&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the agent answers by sending with &lt;code&gt;reply_to_message_id&lt;/code&gt; set to the message it's replying to, which keeps everything in the same thread:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;--request&lt;/span&gt; POST &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--url&lt;/span&gt; &lt;span class="s2"&gt;"https://api.us.nylas.com/v3/grants/&amp;lt;GRANT_ID&amp;gt;/messages/send"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &amp;lt;NYLAS_API_KEY&amp;gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--data&lt;/span&gt; &lt;span class="s1"&gt;'{
    "reply_to_message_id": "&amp;lt;MESSAGE_ID&amp;gt;",
    "body": "Good question — Bob owns item 2.",
    "to": [ { "email": "alice@example.com" } ]
  }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;nylas email reply &amp;lt;MESSAGE_ID&amp;gt; &amp;lt;GRANT_ID&amp;gt; &lt;span class="nt"&gt;--body&lt;/span&gt; &lt;span class="s2"&gt;"Good question — Bob owns item 2."&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Putting the trigger and the send together
&lt;/h2&gt;

&lt;p&gt;The loop, end to end, is small:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Your scheduler wakes up (poll) or a scheduled job fires (lifecycle-driven).&lt;/li&gt;
&lt;li&gt;Read the event: &lt;code&gt;nylas calendar events show &amp;lt;EVENT_ID&amp;gt; &amp;lt;GRANT_ID&amp;gt; --calendar primary --json&lt;/code&gt; (or the &lt;code&gt;GET&lt;/code&gt; curl).&lt;/li&gt;
&lt;li&gt;Check &lt;code&gt;when.end_time &amp;lt; now&lt;/code&gt; and that you haven't recapped this event yet.&lt;/li&gt;
&lt;li&gt;Build the &lt;code&gt;to&lt;/code&gt; list from &lt;code&gt;participants[]&lt;/code&gt;, dropping entries without an &lt;code&gt;email&lt;/code&gt; and (optionally) anyone whose &lt;code&gt;status&lt;/code&gt; is &lt;code&gt;no&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Attach &lt;strong&gt;your&lt;/strong&gt; summary and action items.&lt;/li&gt;
&lt;li&gt;Send from the agent: &lt;code&gt;nylas email send &amp;lt;GRANT_ID&amp;gt; --to ... --subject ... --body ...&lt;/code&gt; (or the &lt;code&gt;messages/send&lt;/code&gt; curl).&lt;/li&gt;
&lt;li&gt;Record the event ID as recapped.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;No Notetaker, no shared doc, no human remembering to follow up. The meeting's own end time is the trigger, and the agent's own mailbox is the sender.&lt;/p&gt;

&lt;h2&gt;
  
  
  Guardrails and gotchas
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The trigger is yours — own it.&lt;/strong&gt; There's no event-ended webhook. If your poll job dies, recaps silently stop. Alert on "events that ended more than 15 minutes ago with no recap sent" so a stuck scheduler is visible, not invisible.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dedup hard.&lt;/strong&gt; Both poll and scheduled-job designs can fire twice (a retry, an overlapping cron, an &lt;code&gt;event.updated&lt;/code&gt; that reschedules). Key your "already sent" record on event ID, and decide your policy if &lt;code&gt;end_time&lt;/code&gt; changed after you sent.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sends count against the account's daily quota.&lt;/strong&gt; On the free plan that's 200 messages per account per day. One email per participant burns through that faster than one group email — for a busy agent recapping large meetings, the per-recipient pattern adds up. Pick the fan-out strategy with the quota in mind.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Guard for missing participant emails.&lt;/strong&gt; For Microsoft Graph events the &lt;code&gt;email&lt;/code&gt; field can be absent on a participant. Filter those out before building &lt;code&gt;to&lt;/code&gt;, or the send fails.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A new domain warms over ~4 weeks.&lt;/strong&gt; If you spin up a fresh sending domain for the agent, deliverability ramps up gradually — early recaps may be more likely to land in spam until the domain is warm. A &lt;code&gt;*.nylas.email&lt;/code&gt; trial subdomain is fine for testing the flow before you commit a custom domain.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The notes are your responsibility, not Nylas's.&lt;/strong&gt; Worth repeating because it's the design's load-bearing assumption: Notetaker is unsupported for Agent Accounts, so nothing in this pipeline gives you a transcript or summary. You generate that content. Nylas tells you the attendees and delivers the mail.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://developer.nylas.com/docs/v3/agent-accounts/calendars/" rel="noopener noreferrer"&gt;How Agent Account calendars work&lt;/a&gt; — the full picture of an agent hosting, receiving, and RSVPing to events, all on the same grant-scoped Events API.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://developer.nylas.com/docs/v3/agent-accounts/" rel="noopener noreferrer"&gt;Agent Accounts overview&lt;/a&gt; — what a grant-as-agent is and what it can do across Messages, Calendars, and Webhooks.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://developer.nylas.com/docs/cookbook/use-cases/act/automate-meeting-follow-ups/" rel="noopener noreferrer"&gt;Automate meeting follow-up emails&lt;/a&gt; — the connected-account version of this flow, which &lt;em&gt;does&lt;/em&gt; use Notetaker to generate the notes. A useful contrast: same distribution idea, different capture source.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://cli.nylas.com/docs/commands" rel="noopener noreferrer"&gt;Nylas CLI commands&lt;/a&gt; — every &lt;code&gt;nylas agent&lt;/code&gt;, &lt;code&gt;nylas calendar events&lt;/code&gt;, and &lt;code&gt;nylas email&lt;/code&gt; subcommand used above.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  AI-answer pages for agents
&lt;/h2&gt;

&lt;p&gt;When this post is published, link AI agents and crawlers to the retrieval-ready version on &lt;code&gt;cli.nylas.com&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Topic runbook: &lt;a href="https://cli.nylas.com/ai-answers/meeting-summary-delivery-by-email-agent.md" rel="noopener noreferrer"&gt;https://cli.nylas.com/ai-answers/meeting-summary-delivery-by-email-agent.md&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Industry playbooks hub: &lt;a href="https://cli.nylas.com/ai-answers/agent-account-industry-playbooks.md" rel="noopener noreferrer"&gt;https://cli.nylas.com/ai-answers/agent-account-industry-playbooks.md&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>ai</category>
      <category>email</category>
      <category>api</category>
      <category>productivity</category>
    </item>
    <item>
      <title>Warm a sending domain when you provision a new agent tenant</title>
      <dc:creator>Qasim</dc:creator>
      <pubDate>Sun, 12 Jul 2026 15:18:27 +0000</pubDate>
      <link>https://dev.to/mqasimca/warm-a-sending-domain-when-you-provision-a-new-agent-tenant-36d7</link>
      <guid>https://dev.to/mqasimca/warm-a-sending-domain-when-you-provision-a-new-agent-tenant-36d7</guid>
      <description>&lt;p&gt;Most "give every tenant its own AI agent" walkthroughs end at the moment the grant exists. You register a domain, create the agent account, fire off a "welcome, I'm your assistant" email, and call the onboarding done. That's fine right up until the first batch of those emails lands in spam — because a brand-new tenant domain has &lt;em&gt;zero&lt;/em&gt; sending reputation, and a cold blast of mail from an unknown domain is exactly the shape mailbox providers are trained to filter.&lt;/p&gt;

&lt;p&gt;I work on the Nylas CLI, so the terminal commands below are the exact ones I reach for when I'm scripting tenant provisioning. The interesting part of this post isn't deliverability in the abstract — there's already &lt;a href="https://developer.nylas.com/docs/v3/agent-accounts/deliverability/" rel="noopener noreferrer"&gt;a good general post on that&lt;/a&gt;. The angle here is narrower and more operational: treating &lt;em&gt;domain warm-up as a step in your provisioning pipeline&lt;/em&gt;. When you onboard a new tenant and spin up its agent on a fresh domain, the warm-up ramp isn't an afterthought you run later — it's part of standing the tenant up. So this is a two-angle tour (curl + CLI) through the whole sequence: register and verify the domain, provision the agent, enforce a daily send cap that matches the ramp, send the first warm-up batch, and watch the bounce/complaint signals so you can back off if reputation dips.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you're actually provisioning
&lt;/h2&gt;

&lt;p&gt;An &lt;strong&gt;Agent Account&lt;/strong&gt; is just a grant. That's the whole spine of this post. Once it exists, it has a &lt;code&gt;grant_id&lt;/code&gt; and works with every grant-scoped endpoint you already know — Messages, Drafts, Threads, Folders, Contacts, Calendars, Events, Webhooks. There's nothing new to learn on the data plane. You send mail with &lt;code&gt;POST /v3/grants/{grant_id}/messages/send&lt;/code&gt;, the same call you'd use for a connected Gmail grant.&lt;/p&gt;

&lt;p&gt;The difference is on the &lt;em&gt;reputation&lt;/em&gt; plane. A connected Google or Microsoft grant inherits the provider's established sending reputation. An Agent Account sends from a domain &lt;strong&gt;you&lt;/strong&gt; own, through Nylas-managed infrastructure, so its inbox placement is yours to build from nothing. That's the catch the demos skip: provisioning the grant is instant, but earning the right to send at volume is a four-week process.&lt;/p&gt;

&lt;p&gt;So the provisioning checklist for a new tenant on a new domain looks like this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Register and verify the domain (DKIM, SPF, ownership, MX).&lt;/li&gt;
&lt;li&gt;Provision the tenant's Agent Account on that domain.&lt;/li&gt;
&lt;li&gt;Attach a policy that caps daily sends to this week's ramp number.&lt;/li&gt;
&lt;li&gt;Send a small warm-up batch to engaged recipients.&lt;/li&gt;
&lt;li&gt;Subscribe to deliverability webhooks and watch for bounces/complaints.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Steps 1–4 you do once at onboarding. Step 3 you revisit weekly as the ramp climbs. Let's walk it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: Register and verify the tenant's domain
&lt;/h2&gt;

&lt;p&gt;Authentication is the foundation. Nothing else matters if recipient servers can't confirm the mail is really from you. For Agent Accounts that comes down to records you publish during domain setup: &lt;strong&gt;DKIM&lt;/strong&gt; adds a cryptographic signature proving the message wasn't altered and came from your domain, and &lt;strong&gt;SPF&lt;/strong&gt; authorizes Nylas to send on your behalf. Both are verified before a custom domain can host accounts — so by the time an Agent Account exists, its domain is already DKIM- and SPF-authenticated. Nylas also tracks DMARC and ARC, but it doesn't enforce them, which means DMARC is the one layer you add yourself (more on that in the guardrails section).&lt;/p&gt;

&lt;p&gt;Honest note on the tooling here: there is &lt;strong&gt;no &lt;code&gt;nylas domain&lt;/code&gt; command&lt;/strong&gt;. I checked — the CLI doesn't manage domains. Domain registration runs through the Nylas Dashboard at &lt;code&gt;dashboard-v3.nylas.com/organization/domains&lt;/code&gt;, or through the Manage Domains API. And that API isn't a plain &lt;code&gt;Authorization: Bearer&lt;/code&gt; call — it uses &lt;a href="https://developer.nylas.com/docs/v3/auth/nylas-service-account/" rel="noopener noreferrer"&gt;Nylas Service Account authentication&lt;/a&gt;, which signs each request with four custom headers. So the curl below is real, but you'll be computing a signature, not pasting an API key:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Register the tenant's domain (Manage Domains API — Service Account auth)&lt;/span&gt;
curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="s2"&gt;"https://api.us.nylas.com/v3/admin/domains"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"X-Nylas-Signature: &amp;lt;BASE64_SIGNATURE&amp;gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"X-Nylas-Kid: &amp;lt;SERVICE_ACCOUNT_ID&amp;gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"X-Nylas-Nonce: &amp;lt;NONCE&amp;gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"X-Nylas-Timestamp: 1742932766"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{
    "name": "Acme tenant 4821 mail",
    "domain_address": "mail.tenant4821.example.com"
  }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then you fetch the DNS records to publish and trigger verification:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Get the records to publish for a verification type (ownership, dkim, spf, feedback, mx)&lt;/span&gt;
curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="s2"&gt;"https://api.us.nylas.com/v3/admin/domains/&amp;lt;DOMAIN_ID&amp;gt;/info"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"X-Nylas-Signature: &amp;lt;BASE64_SIGNATURE&amp;gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"X-Nylas-Kid: &amp;lt;SERVICE_ACCOUNT_ID&amp;gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"X-Nylas-Nonce: &amp;lt;NONCE&amp;gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"X-Nylas-Timestamp: 1742932766"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{ "type": "dkim" }'&lt;/span&gt;

&lt;span class="c"&gt;# After you add the records at your DNS provider, trigger the check for that type&lt;/span&gt;
curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="s2"&gt;"https://api.us.nylas.com/v3/admin/domains/&amp;lt;DOMAIN_ID&amp;gt;/verify"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"X-Nylas-Signature: &amp;lt;BASE64_SIGNATURE&amp;gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"X-Nylas-Kid: &amp;lt;SERVICE_ACCOUNT_ID&amp;gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"X-Nylas-Nonce: &amp;lt;NONCE&amp;gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"X-Nylas-Timestamp: 1742932766"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{ "type": "dkim" }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both &lt;code&gt;/info&lt;/code&gt; and &lt;code&gt;/verify&lt;/code&gt; take a required &lt;code&gt;type&lt;/code&gt; in the body — one of &lt;code&gt;ownership&lt;/code&gt;, &lt;code&gt;mx&lt;/code&gt;, &lt;code&gt;spf&lt;/code&gt;, &lt;code&gt;dkim&lt;/code&gt;, or &lt;code&gt;feedback&lt;/code&gt; — so you run the pair once per record type. For Agent Accounts you verify all five (&lt;code&gt;ownership&lt;/code&gt;, &lt;code&gt;dkim&lt;/code&gt;, &lt;code&gt;spf&lt;/code&gt;, &lt;code&gt;feedback&lt;/code&gt;, and &lt;code&gt;mx&lt;/code&gt;); that &lt;code&gt;mx&lt;/code&gt; record is the extra one Agent Accounts need beyond what Transactional Send requires. There is no CLI for this flow — domain registration and verification are Dashboard-or-API only, so the curl above (Service Account auth) is the programmatic path; I'm not inventing a &lt;code&gt;nylas domain&lt;/code&gt; command because none exists. Configure the DNS records &lt;em&gt;before&lt;/em&gt; the first &lt;code&gt;/verify&lt;/code&gt; call to dodge DNS-caching delays. If verification fails with correct records, give it up to 24 hours (usually it's minutes, depending on TTL).&lt;/p&gt;

&lt;p&gt;If you'd rather skip DNS entirely while you build the pipeline, you can provision the agent on a free &lt;code&gt;*.nylas.email&lt;/code&gt; trial subdomain — but a real per-tenant domain is the point of this exercise, so I'll assume a custom one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: Provision the tenant's Agent Account
&lt;/h2&gt;

&lt;p&gt;With the domain verified, the grant is one call. Create it with &lt;code&gt;POST /v3/connect/custom&lt;/code&gt;, &lt;code&gt;provider: "nylas"&lt;/code&gt;, and &lt;code&gt;settings.email&lt;/code&gt; on the verified domain. No refresh token, no OAuth round-trip:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Provision the tenant's agent — API&lt;/span&gt;
curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="s2"&gt;"https://api.us.nylas.com/v3/connect/custom"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &amp;lt;NYLAS_API_KEY&amp;gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{
    "provider": "nylas",
    "name": "Acme Support Agent",
    "settings": {
      "email": "support@mail.tenant4821.example.com"
    }
  }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The optional top-level &lt;code&gt;name&lt;/code&gt; sets the agent's display name. The response carries the &lt;code&gt;grant_id&lt;/code&gt; — that's your handle for everything downstream.&lt;/p&gt;

&lt;p&gt;The CLI collapses this to one command. It always uses &lt;code&gt;provider=nylas&lt;/code&gt;, auto-creates the Nylas connector the first time if it doesn't exist, and the API auto-creates a default workspace and policy for the account:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Provision the tenant's agent — CLI&lt;/span&gt;
nylas agent account create support@mail.tenant4821.example.com &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--name&lt;/span&gt; &lt;span class="s2"&gt;"Acme Support Agent"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I verified both &lt;code&gt;--name&lt;/code&gt; (display name, 1–256 chars) and &lt;code&gt;--app-password&lt;/code&gt; (an optional IMAP/SMTP app password) on &lt;code&gt;nylas agent account create&lt;/code&gt;. There is deliberately &lt;strong&gt;no &lt;code&gt;--workspace&lt;/code&gt; flag on create&lt;/strong&gt; — the workspace and its policy come into existence automatically, and you attach a custom policy afterward. Which is exactly what step 3 is for.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: Cap the daily ramp with a policy
&lt;/h2&gt;

&lt;p&gt;Here's the division of labor that matters: &lt;strong&gt;your application decides the warm-up schedule and which recipients to send to. Nylas enforces the ceiling.&lt;/strong&gt; The ramp logic — how many to send today, spread across the day, to whom — lives in your code. But you can make Nylas backstop your ramp with a hard daily send cap, so a bug in your scheduler can't accidentally blast 1,000 emails on day one.&lt;/p&gt;

&lt;p&gt;That cap is a policy field: &lt;code&gt;limit_count_daily_email_sent&lt;/code&gt;. Create a policy with this week's number, then attach it to the agent's workspace.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Create a policy that caps daily sends to week 1's number — API&lt;/span&gt;
curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="s2"&gt;"https://api.us.nylas.com/v3/policies"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &amp;lt;NYLAS_API_KEY&amp;gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{
    "name": "Warm-up week 1 — tenant 4821",
    "limits": {
      "limit_count_daily_email_sent": 15
    }
  }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The CLI mirrors it. &lt;code&gt;nylas agent policy create&lt;/code&gt; takes &lt;code&gt;--name&lt;/code&gt; for a simple policy, or &lt;code&gt;--data&lt;/code&gt; / &lt;code&gt;--data-file&lt;/code&gt; for a full request body — and the limits live under a &lt;code&gt;limits&lt;/code&gt; object, so you pass the JSON through &lt;code&gt;--data&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Create the capped policy — CLI&lt;/span&gt;
nylas agent policy create &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--data&lt;/span&gt; &lt;span class="s1"&gt;'{"name":"Warm-up week 1 — tenant 4821","limits":{"limit_count_daily_email_sent":15}}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then attach the policy to the workspace the account was created in. Grab the workspace ID from &lt;code&gt;nylas workspace list&lt;/code&gt;. On the API side this is a &lt;code&gt;PATCH /v3/workspaces/{workspace_id}&lt;/code&gt; with the &lt;code&gt;policy_id&lt;/code&gt; in the body:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Attach the policy to the agent's workspace — API&lt;/span&gt;
curl &lt;span class="nt"&gt;--request&lt;/span&gt; PATCH &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--url&lt;/span&gt; &lt;span class="s2"&gt;"https://api.us.nylas.com/v3/workspaces/&amp;lt;WORKSPACE_ID&amp;gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &amp;lt;NYLAS_API_KEY&amp;gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--data&lt;/span&gt; &lt;span class="s1"&gt;'{
    "policy_id": "&amp;lt;POLICY_ID&amp;gt;"
  }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The CLI is the same operation in one line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Attach the policy to the agent's workspace — CLI&lt;/span&gt;
nylas workspace update &amp;lt;WORKSPACE_ID&amp;gt; &lt;span class="nt"&gt;--policy-id&lt;/span&gt; &amp;lt;POLICY_ID&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I checked &lt;code&gt;nylas workspace update&lt;/code&gt;: &lt;code&gt;--policy-id&lt;/code&gt; attaches a policy, and passing &lt;code&gt;--policy-id ""&lt;/code&gt; detaches it — which maps to setting &lt;code&gt;policy_id&lt;/code&gt; to &lt;code&gt;null&lt;/code&gt; in the API body (plan maximums apply when nothing's attached). At the end of each warm-up week you bump the policy's &lt;code&gt;limit_count_daily_email_sent&lt;/code&gt; to the next tier and the ceiling rises with your ramp. The cap is a safety net, not the schedule — your app still has to pace the actual sends.&lt;/p&gt;

&lt;h3&gt;
  
  
  Raise the cap each week
&lt;/h3&gt;

&lt;p&gt;When week 2 starts and your ramp climbs, update the same policy in place rather than creating a new one. The update is a &lt;code&gt;PUT /v3/policies/{policy_id}&lt;/code&gt; — all fields are optional, so you send only the limit you're changing, nested under &lt;code&gt;limits&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Raise the daily send cap for week 2 — API&lt;/span&gt;
curl &lt;span class="nt"&gt;-X&lt;/span&gt; PUT &lt;span class="s2"&gt;"https://api.us.nylas.com/v3/policies/&amp;lt;POLICY_ID&amp;gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &amp;lt;NYLAS_API_KEY&amp;gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{
    "limits": {
      "limit_count_daily_email_sent": 75
    }
  }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;nylas agent policy update&lt;/code&gt; takes the policy ID and the same partial JSON body through &lt;code&gt;--data&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Raise the daily send cap for week 2 — CLI&lt;/span&gt;
nylas agent policy update &amp;lt;POLICY_ID&amp;gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--data&lt;/span&gt; &lt;span class="s1"&gt;'{"limits":{"limit_count_daily_email_sent":75}}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Repeat at the start of week 3 (&lt;code&gt;300&lt;/code&gt;) and week 4 (&lt;code&gt;1000&lt;/code&gt;). Because the update is partial, you never have to re-send the rest of the policy — just the new ceiling.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: The warm-up ramp itself
&lt;/h2&gt;

&lt;p&gt;This is the schedule, straight from the &lt;a href="https://developer.nylas.com/docs/v3/agent-accounts/domain-warming/" rel="noopener noreferrer"&gt;domain warm-up guide&lt;/a&gt;. It assumes a daily target of 500–1,000 emails during a typical 8-hour send window, ramped over roughly four weeks. Within each week, nudge the daily number up by 10–20% every day or so:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Week&lt;/th&gt;
&lt;th&gt;Total emails per day&lt;/th&gt;
&lt;th&gt;Emails per hour (approx.)&lt;/th&gt;
&lt;th&gt;Notes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Week 1&lt;/td&gt;
&lt;td&gt;5 – 15&lt;/td&gt;
&lt;td&gt;1 – 2&lt;/td&gt;
&lt;td&gt;Start very low, focus on highly engaged recipients&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Week 2&lt;/td&gt;
&lt;td&gt;30 – 75&lt;/td&gt;
&lt;td&gt;4 – 10&lt;/td&gt;
&lt;td&gt;Gradually increase, maintain a steady increase&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Week 3&lt;/td&gt;
&lt;td&gt;150 – 300&lt;/td&gt;
&lt;td&gt;20 – 40&lt;/td&gt;
&lt;td&gt;Moderate volume&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Week 4&lt;/td&gt;
&lt;td&gt;500 – 1,000+&lt;/td&gt;
&lt;td&gt;60 – 125+&lt;/td&gt;
&lt;td&gt;Full ramp-up to target volume&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Three rules ride alongside the numbers, and they're as load-bearing as the volumes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Spread sends across the day.&lt;/strong&gt; Don't fire the whole day's batch at once. Distribute across business hours (say 9 AM–5 PM) with randomized delays between messages so the pattern looks human.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Send only to real, engaged recipients.&lt;/strong&gt; This is the single biggest lever, especially in week 1. A reply is a &lt;em&gt;very strong&lt;/em&gt; positive signal, a click is a strong one, an open is a mild one — and a spam-mark or a hard bounce hurts. For a brand-new tenant agent, that means seeding the warm-up with people who actually want the mail: the tenant's own team, beta users, internal stakeholders. Tell them to open, click, and reply.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Send transactional-style content.&lt;/strong&gt; Booking confirmations, password resets, account notifications — the kind of mail with high trust and high open rates. No all-caps, no link-stuffing, no sales pitch during warm-up.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Because an Agent Account is a grant, the first warm-up send is the ordinary send call. Here's the API form to an engaged recipient:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Send a warm-up message from the agent — API&lt;/span&gt;
curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="s2"&gt;"https://api.us.nylas.com/v3/grants/&amp;lt;GRANT_ID&amp;gt;/messages/send"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &amp;lt;NYLAS_API_KEY&amp;gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{
    "to": [{ "name": "Dana Okafor", "email": "dana@acmecustomer.com" }],
    "subject": "Your Acme account is ready",
    "body": "Hi Dana — your workspace is set up. Reply here any time and I will help you get started."
  }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the CLI equivalent. The first positional argument is the grant (you can pass the &lt;code&gt;grant_id&lt;/code&gt; or the agent's email):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Send a warm-up message from the agent — CLI&lt;/span&gt;
nylas email send &amp;lt;GRANT_ID&amp;gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--to&lt;/span&gt; dana@acmecustomer.com &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--subject&lt;/span&gt; &lt;span class="s2"&gt;"Your Acme account is ready"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--body&lt;/span&gt; &lt;span class="s2"&gt;"Hi Dana — your workspace is set up. Reply here any time and I will help you get started."&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One thing I confirmed while writing this: &lt;code&gt;nylas email send&lt;/code&gt; has no attachment flag. If your warm-up content needs an attachment, you build it as a draft (&lt;code&gt;nylas email drafts create --attach&lt;/code&gt;) or send via the API. For plain warm-up mail you won't miss it.&lt;/p&gt;

&lt;p&gt;The scheduling — which N recipients today, the per-message jitter, the daily increase — is your app logic. The &lt;a href="https://developer.nylas.com/docs/v3/agent-accounts/domain-warming/" rel="noopener noreferrer"&gt;warm-up guide ships a Python script&lt;/a&gt; you can run daily from cron that handles the pacing for one day's target; adapt it to send through your agent's grant instead of the Transactional Send endpoint, and update the daily target each week to match the table above.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5: Watch bounces and complaints so you can back off
&lt;/h2&gt;

&lt;p&gt;You can't manage deliverability you can't see. While the ramp runs, the thing that tells you whether reputation is holding is the stream of &lt;strong&gt;deliverability webhooks&lt;/strong&gt; Agent Accounts emit: &lt;code&gt;message.delivered&lt;/code&gt;, &lt;code&gt;message.bounced&lt;/code&gt;, &lt;code&gt;message.complaint&lt;/code&gt;, and &lt;code&gt;message.rejected&lt;/code&gt;. These are the same events Nylas uses to calculate your bounce and complaint rates — the rates that, if they climb too high, pause sending. Catching a dip in your own telemetry is what keeps you under those thresholds.&lt;/p&gt;

&lt;p&gt;Webhooks are &lt;strong&gt;application-scoped&lt;/strong&gt;, not grant-scoped — and that's a feature when you're running many tenants. You subscribe once at the app level, and events for every tenant's agent arrive at the same endpoint, each payload carrying a &lt;code&gt;grant_id&lt;/code&gt; you filter on. So you don't re-subscribe per tenant; provision the subscription once and route by &lt;code&gt;grant_id&lt;/code&gt;. Subscribe with &lt;code&gt;POST /v3/webhooks&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Subscribe to deliverability webhooks — API (app-scoped, once)&lt;/span&gt;
curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="s2"&gt;"https://api.us.nylas.com/v3/webhooks"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &amp;lt;NYLAS_API_KEY&amp;gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{
    "trigger_types": [
      "message.delivered",
      "message.bounced",
      "message.complaint",
      "message.rejected"
    ],
    "webhook_url": "https://hooks.yourcompany.com/nylas/deliverability"
  }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The CLI creates the same subscription with &lt;code&gt;--url&lt;/code&gt; and &lt;code&gt;--triggers&lt;/code&gt; (comma-separated, or repeated flags):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Subscribe to deliverability webhooks — CLI&lt;/span&gt;
nylas webhook create &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--url&lt;/span&gt; https://hooks.yourcompany.com/nylas/deliverability &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--triggers&lt;/span&gt; message.delivered,message.bounced,message.complaint,message.rejected
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run &lt;code&gt;nylas webhook triggers&lt;/code&gt; to list every available trigger type if you want to confirm the names yourself. Honest CLI note: the &lt;em&gt;signal&lt;/em&gt; you act on — the bounce and complaint events themselves — arrives over the webhook stream and gets handled by your receiver, which is application code, not a CLI command. The CLI helps you set the subscription up and verify deliveries (&lt;code&gt;nylas webhook verify&lt;/code&gt; checks a signature locally; &lt;code&gt;nylas webhook server&lt;/code&gt; runs a local receiver for testing), but reacting to a complaint spike by lowering the ramp is logic you write.&lt;/p&gt;

&lt;p&gt;Wire those events into your back-off rule: when bounces or complaints climb for a tenant's &lt;code&gt;grant_id&lt;/code&gt;, slow or pause that tenant's ramp, and stop mailing any address that hard-bounces or complains. One quick implementation note since these matter in production — the API delivers at-least-once (up to three attempts per event), so dedupe on the top-level notification &lt;code&gt;id&lt;/code&gt;, which stays constant across retries of the same event. The inner message id identifies the message; the notification id identifies the delivery.&lt;/p&gt;

&lt;h2&gt;
  
  
  Guardrails: DMARC and per-tenant isolation
&lt;/h2&gt;

&lt;p&gt;Two things to get right before you scale this across tenants.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Publish DMARC, but roll it out in stages.&lt;/strong&gt; DKIM and SPF are already verified, but DMARC is the layer you add. Publish a record at &lt;code&gt;_dmarc.&amp;lt;your-sending-subdomain&amp;gt;&lt;/code&gt; starting at &lt;code&gt;p=none&lt;/code&gt; so mail is delivered normally while providers send you aggregate reports — you confirm your agent's mail authenticates and aligns before you tighten anything. Once reports are clean, move to &lt;code&gt;p=quarantine&lt;/code&gt; (you can ease in with &lt;code&gt;pct=25&lt;/code&gt;), then finally &lt;code&gt;p=reject&lt;/code&gt;. Give each stage a couple of weeks of clean reports. Because Agent Accounts sign with your domain's DKIM key and send from your verified domain, alignment works out of the box with relaxed alignment, which is the default they're set up for.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reputation doesn't transfer between domains.&lt;/strong&gt; If your model is one domain per tenant, you warm &lt;em&gt;each domain on its own schedule&lt;/em&gt;. The reputation a freshly provisioned agent builds is tied to its domain — there's no shared head start across tenants. That's the cost of per-tenant domain isolation, and it's why the warm-up belongs in your provisioning pipeline rather than as a one-time thing you did for the first tenant and forgot about. Every new tenant on a new domain starts the four-week clock over.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://developer.nylas.com/docs/v3/agent-accounts/domain-warming/" rel="noopener noreferrer"&gt;Domain warm-up guide&lt;/a&gt; — the full ramp schedule, engagement signal table, and the daily send script&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://developer.nylas.com/docs/v3/agent-accounts/deliverability/" rel="noopener noreferrer"&gt;Email deliverability for Agent Accounts&lt;/a&gt; — DMARC staging, webhook monitoring, and SPF/DKIM alignment troubleshooting&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://developer.nylas.com/docs/v3/email/domains/" rel="noopener noreferrer"&gt;Managing domains&lt;/a&gt; — register and verify a custom Agent Account domain&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://developer.nylas.com/docs/v3/agent-accounts/" rel="noopener noreferrer"&gt;Agent Accounts overview&lt;/a&gt; — provisioning, policies, and the grant model&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://cli.nylas.com/docs/commands" rel="noopener noreferrer"&gt;CLI command reference&lt;/a&gt; — every &lt;code&gt;nylas agent&lt;/code&gt;, &lt;code&gt;nylas workspace&lt;/code&gt;, and &lt;code&gt;nylas webhook&lt;/code&gt; subcommand used above&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  AI-answer pages for agents
&lt;/h2&gt;

&lt;p&gt;When this post is published, link AI agents and crawlers to the retrieval-ready version on &lt;code&gt;cli.nylas.com&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Topic runbook: &lt;a href="https://cli.nylas.com/ai-answers/agent-account-custom-domain-rollout-plan.md" rel="noopener noreferrer"&gt;https://cli.nylas.com/ai-answers/agent-account-custom-domain-rollout-plan.md&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Industry playbooks hub: &lt;a href="https://cli.nylas.com/ai-answers/agent-account-industry-playbooks.md" rel="noopener noreferrer"&gt;https://cli.nylas.com/ai-answers/agent-account-industry-playbooks.md&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>email</category>
      <category>api</category>
      <category>devtools</category>
      <category>productivity</category>
    </item>
    <item>
      <title>GDPR retention and erasure for an agent mailbox</title>
      <dc:creator>Qasim</dc:creator>
      <pubDate>Sat, 11 Jul 2026 12:27:47 +0000</pubDate>
      <link>https://dev.to/mqasimca/gdpr-retention-and-erasure-for-an-agent-mailbox-1ejh</link>
      <guid>https://dev.to/mqasimca/gdpr-retention-and-erasure-for-an-agent-mailbox-1ejh</guid>
      <description>&lt;p&gt;Most "AI email" demos never think about deletion. The agent reads, replies, files things away, and the inbox just grows. That's fine in a demo. It is a problem the first time a real person emails your agent, because the moment that mailbox holds someone else's name, address, order history, or support complaint, you've taken on a data-protection obligation — and "we kept everything forever" is not a defensible retention policy.&lt;/p&gt;

&lt;p&gt;An &lt;strong&gt;Agent Account&lt;/strong&gt; on Nylas accumulates personal data you have to be able to purge. It's a mailbox the agent &lt;em&gt;owns&lt;/em&gt; — &lt;code&gt;support@yourcompany.com&lt;/code&gt; answering to a model instead of a human — and every inbound message lands in it. Under GDPR that data needs two things you can prove: a &lt;strong&gt;retention window&lt;/strong&gt; so it doesn't live forever, and an &lt;strong&gt;erasure path&lt;/strong&gt; so you can delete a specific person's mail when they ask. This post builds both, with the curl and the CLI for each step.&lt;/p&gt;

&lt;p&gt;A quick, honest caveat before any of it: this is a docs-and-demo walkthrough, not legal advice. The Nylas primitives below cover the mail held &lt;em&gt;in the mailbox&lt;/em&gt;. Any derived copy you made — rows in your own database, lines in your application logs, a vector store you embedded the message into — is yours to purge separately. The API can delete the message; it can't reach into your Postgres. Keep that in mind throughout.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the platform gives you
&lt;/h2&gt;

&lt;p&gt;Nothing new to learn on the data plane. An Agent Account is just a &lt;strong&gt;grant&lt;/strong&gt; with a &lt;code&gt;grant_id&lt;/code&gt;, so everything you already know about Messages and Threads applies directly — listing, reading, and deleting mail run against the same grant-scoped endpoints any other Nylas integration uses. Retention and erasure split cleanly into two layers:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Retention&lt;/strong&gt; is a &lt;em&gt;control-plane&lt;/em&gt; setting. It lives on a &lt;strong&gt;policy&lt;/strong&gt; — an application-scoped resource that bundles limits and spam settings — attached to the workspace your Agent Account belongs to. Two fields cap how long mail survives: &lt;code&gt;limit_inbox_retention_period&lt;/code&gt; and &lt;code&gt;limit_spam_retention_period&lt;/code&gt;. Set them once and Nylas deletes aged-out mail for you, per account, with no cron job on your side.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Erasure&lt;/strong&gt; is a &lt;em&gt;data-plane&lt;/em&gt; operation. To honor a right-to-erasure request for one person, you find their messages with &lt;code&gt;GET /v3/grants/{grant_id}/messages&lt;/code&gt; filtered by sender, then hard-delete each one with &lt;code&gt;DELETE .../messages/{id}?hard_delete=true&lt;/code&gt; — a plain delete only trashes the message, which isn't erasure. For a full identity wipe, you delete the grant itself.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The split matters because the two answer different questions. Retention answers "how long do we keep anything?" — a blanket time bound. Erasure answers "delete &lt;em&gt;this&lt;/em&gt; person's data, now" — a targeted request. A compliant agent mailbox needs both; one doesn't substitute for the other.&lt;/p&gt;

&lt;p&gt;I work on the Nylas CLI, so the terminal commands below are the exact ones I reach for. Where it matters I'll show the curl and the &lt;code&gt;nylas ...&lt;/code&gt; form side by side, because in practice you set retention once through the API and run erasure ad hoc from a terminal when a request comes in.&lt;/p&gt;

&lt;h2&gt;
  
  
  Before you begin
&lt;/h2&gt;

&lt;p&gt;You need an Agent Account, an API key for the same application, and the host set to &lt;code&gt;https://api.us.nylas.com&lt;/code&gt;. Every API call below carries &lt;code&gt;Authorization: Bearer &amp;lt;NYLAS_API_KEY&amp;gt;&lt;/code&gt;. If you don't have an account yet, you provision one against a registered domain — either the API:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;--request&lt;/span&gt; POST &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--url&lt;/span&gt; &lt;span class="s2"&gt;"https://api.us.nylas.com/v3/connect/custom"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &amp;lt;NYLAS_API_KEY&amp;gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--data&lt;/span&gt; &lt;span class="s1"&gt;'{
    "provider": "nylas",
    "settings": { "email": "support@yourcompany.com" },
    "name": "Support Agent"
  }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;or the CLI, which creates the &lt;code&gt;nylas&lt;/code&gt; connector for you if it doesn't exist yet:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;nylas agent account create support@yourcompany.com &lt;span class="nt"&gt;--name&lt;/span&gt; &lt;span class="s2"&gt;"Support Agent"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Either path returns a &lt;code&gt;grant_id&lt;/code&gt;. The API also auto-creates a &lt;strong&gt;default workspace&lt;/strong&gt; and a default policy for the account — that default workspace is where you'll attach the retention policy in a moment. Full background lives in the &lt;a href="https://developer.nylas.com/docs/v3/agent-accounts/" rel="noopener noreferrer"&gt;Agent Accounts docs&lt;/a&gt; and the &lt;a href="https://developer.nylas.com/docs/v3/agent-accounts/policies-rules-lists/" rel="noopener noreferrer"&gt;Policies, Rules, and Lists guide&lt;/a&gt;. CLI reference is at &lt;a href="https://cli.nylas.com/docs/commands" rel="noopener noreferrer"&gt;cli.nylas.com/docs/commands&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;One plan note up front: on the free plan, retention defaults to &lt;strong&gt;30 days in the inbox and 7 days in spam&lt;/strong&gt;, and those defaults aren't configurable. The two retention fields below are configurable on paid plans, where you set your own windows. So if you're prototyping on the free tier, you already have a 30-day inbox bound whether you ask for it or not — which is a reasonable default, but probably not the number your legal team picked.&lt;/p&gt;

&lt;h2&gt;
  
  
  Set retention windows on a policy
&lt;/h2&gt;

&lt;p&gt;Retention is two fields inside a policy's &lt;code&gt;limits&lt;/code&gt; object:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;limit_inbox_retention_period&lt;/code&gt; — days a message stays in the inbox before Nylas deletes it.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;limit_spam_retention_period&lt;/code&gt; — days a message stays in spam before deletion. This &lt;strong&gt;must be shorter than&lt;/strong&gt; the inbox window, so spam clears out ahead of legitimate mail. If you set spam longer than inbox, the API rejects it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Both take a number of days. Pick windows that match your documented retention schedule — the number you'd write down for an auditor, not a number you guessed. Here's a policy that keeps inbox mail for a year and spam for 30 days:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;--request&lt;/span&gt; POST &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--url&lt;/span&gt; &lt;span class="s2"&gt;"https://api.us.nylas.com/v3/policies"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &amp;lt;NYLAS_API_KEY&amp;gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--data&lt;/span&gt; &lt;span class="s1"&gt;'{
    "name": "Support Agent Retention Policy",
    "limits": {
      "limit_inbox_retention_period": 365,
      "limit_spam_retention_period": 30
    }
  }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The CLI takes the same JSON body through &lt;code&gt;--data&lt;/code&gt;, so the policy you describe is identical:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;nylas agent policy create &lt;span class="nt"&gt;--data&lt;/span&gt; &lt;span class="s1"&gt;'{
  "name": "Support Agent Retention Policy",
  "limits": {
    "limit_inbox_retention_period": 365,
    "limit_spam_retention_period": 30
  }
}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both return a policy &lt;code&gt;id&lt;/code&gt;. Hold onto it — a policy does nothing on its own. It only takes effect once a &lt;strong&gt;workspace&lt;/strong&gt; points at it, and every Agent Account in that workspace inherits the windows. You attach it to the default workspace your account already landed in:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;--request&lt;/span&gt; PATCH &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--url&lt;/span&gt; &lt;span class="s2"&gt;"https://api.us.nylas.com/v3/workspaces/&amp;lt;WORKSPACE_ID&amp;gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &amp;lt;NYLAS_API_KEY&amp;gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--data&lt;/span&gt; &lt;span class="s1"&gt;'{
    "policy_id": "&amp;lt;POLICY_ID&amp;gt;"
  }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;nylas workspace update &amp;lt;WORKSPACE_ID&amp;gt; &lt;span class="nt"&gt;--policy-id&lt;/span&gt; &amp;lt;POLICY_ID&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the entire retention story on the platform side. From here, Nylas enforces the windows itself — there's no scheduled job for you to write, monitor, or page on when it silently stops running at 3am. The part I like as an SRE is exactly that: retention isn't a script that can rot. It's a property of the account, declared once, enforced by the platform.&lt;/p&gt;

&lt;p&gt;A workspace with no policy attached runs its accounts at your plan's maximum limits — which on a paid plan can mean no retention bound at all. So "we have an Agent Account" does not imply "we have a retention window." Attaching the policy is the step that makes the claim true. If you ever need to widen or tighten the window, &lt;code&gt;PATCH&lt;/code&gt; the policy and every account in the workspace follows; on the default workspace, &lt;code&gt;policy_id&lt;/code&gt; and &lt;code&gt;rule_ids&lt;/code&gt; are the only fields you're allowed to change, which keeps this hard to get wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  Build the erasure path
&lt;/h2&gt;

&lt;p&gt;Retention handles the passive case — mail aging out on a schedule. Erasure handles the active one: a named individual exercises their right to be forgotten and you have to remove &lt;em&gt;their&lt;/em&gt; mail specifically, today, regardless of how old it is.&lt;/p&gt;

&lt;p&gt;The shape is two steps. First, find every message from that person. Then delete each one. Both run against the grant, on the same Messages endpoints any Nylas app uses — there's nothing Agent-Account-specific here, which is the point.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1 — find the subject's messages
&lt;/h3&gt;

&lt;p&gt;Filter the message list by sender. The Messages API takes a &lt;code&gt;from&lt;/code&gt; query parameter that matches the sender address, so a single call returns the candidate set:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;--request&lt;/span&gt; GET &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--url&lt;/span&gt; &lt;span class="s2"&gt;"https://api.us.nylas.com/v3/grants/&amp;lt;NYLAS_GRANT_ID&amp;gt;/messages?from=jane.doe@example.com&amp;amp;limit=50"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &amp;lt;NYLAS_API_KEY&amp;gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The CLI exposes the same filter as &lt;code&gt;--from&lt;/code&gt;, and &lt;code&gt;--id&lt;/code&gt; prints the message IDs you'll feed into the delete step:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;nylas email list &amp;lt;NYLAS_GRANT_ID&amp;gt; &lt;span class="nt"&gt;--from&lt;/span&gt; jane.doe@example.com &lt;span class="nt"&gt;--id&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few honest things to watch for, because "find their messages" is deceptively simple. &lt;code&gt;--from&lt;/code&gt; matches the &lt;em&gt;sender&lt;/em&gt;, so this finds mail the subject sent &lt;em&gt;to&lt;/em&gt; the agent. Mail the agent sent &lt;em&gt;to&lt;/em&gt; the subject lives in the Sent folder with the agent as sender — pass &lt;code&gt;--folder SENT&lt;/code&gt; (or list all folders) and filter on the recipient side if your erasure scope includes outbound copies. And one person can email from more than one address; if you only have a name, you may need to run the lookup for each known address. Don't assume a single &lt;code&gt;from&lt;/code&gt; value captures everything tied to a person.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2 — hard-delete each message
&lt;/h3&gt;

&lt;p&gt;This is the step where the obvious call does the wrong thing, and it matters more here than anywhere else in the post. A plain &lt;code&gt;DELETE&lt;/code&gt; on a message does &lt;strong&gt;not&lt;/strong&gt; erase it — it moves the message to &lt;strong&gt;Trash&lt;/strong&gt;. For a right-to-erasure request, trashed mail is still retained mail, and "we moved it to a folder" is not deletion. To actually remove a message, you pass &lt;code&gt;?hard_delete=true&lt;/code&gt;, which deletes it immediately instead of trashing it, and that operation is irreversible:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;--request&lt;/span&gt; DELETE &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--url&lt;/span&gt; &lt;span class="s2"&gt;"https://api.us.nylas.com/v3/grants/&amp;lt;NYLAS_GRANT_ID&amp;gt;/messages/&amp;lt;MESSAGE_ID&amp;gt;?hard_delete=true"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &amp;lt;NYLAS_API_KEY&amp;gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things gate that flag. First, &lt;code&gt;hard_delete&lt;/code&gt; has to be enabled for your application — turn on &lt;strong&gt;Enable hard delete&lt;/strong&gt; in the &lt;a href="https://dashboard-v3.nylas.com/" rel="noopener noreferrer"&gt;Nylas Dashboard&lt;/a&gt; under &lt;strong&gt;Customizations &amp;gt; API&lt;/strong&gt; before the parameter does anything. Second, and this is the one to internalize: the &lt;strong&gt;CLI cannot hard-delete.&lt;/strong&gt; &lt;code&gt;nylas email delete&lt;/code&gt; only moves a message to Trash — there is no hard-delete flag on it. So for true erasure of an individual message, the API is your only path:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# CLI: trashes the message — does NOT permanently erase it&lt;/span&gt;
nylas email delete &amp;lt;MESSAGE_ID&amp;gt; &amp;lt;NYLAS_GRANT_ID&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's fine for an inbox-cleanup workflow, but don't reach for it to satisfy an erasure request and assume the data is gone — it's sitting in Trash.&lt;/p&gt;

&lt;p&gt;Threads have the same trap, with no escape hatch. Deleting a thread (&lt;code&gt;DELETE /v3/grants/{grant_id}/threads/{thread_id}&lt;/code&gt;, or &lt;code&gt;nylas email threads delete&lt;/code&gt;) moves the thread &lt;em&gt;and all its messages&lt;/em&gt; to Trash. There is &lt;strong&gt;no&lt;/strong&gt; &lt;code&gt;hard_delete&lt;/code&gt; parameter on the thread endpoint at all — so thread deletion is a convenience for clearing a conversation out of view, not an erasure primitive. If a single back-and-forth carried the personal data across several replies, list the thread's messages and hard-delete each one individually; don't rely on the thread delete to erase them.&lt;/p&gt;

&lt;p&gt;So the erasure loop is: list by sender, collect the message IDs, then &lt;code&gt;DELETE .../messages/{id}?hard_delete=true&lt;/code&gt; on each, through the API. Keep an audit record of &lt;em&gt;what&lt;/em&gt; you deleted and &lt;em&gt;when&lt;/em&gt; — the message IDs and a timestamp — without storing the message content you just erased. A proof-of-erasure log that quietly retains the bodies defeats the entire exercise; log the fact of deletion, not the data.&lt;/p&gt;

&lt;h2&gt;
  
  
  Full erasure: delete the whole mailbox
&lt;/h2&gt;

&lt;p&gt;Sometimes the subject &lt;em&gt;is&lt;/em&gt; the agent identity. If the personal data tied to one person is the entire reason an Agent Account exists — a per-customer agent mailbox, say, that you spun up for a single client who's now offboarding — hard-deleting individual messages is the long way around, and it's also the one case where the CLI gives you a clean full wipe. Deleting the &lt;strong&gt;grant&lt;/strong&gt; removes the whole mailbox: every message, thread, folder, and the connection itself. This is the &lt;em&gt;only&lt;/em&gt; full-erasure path that doesn't depend on the &lt;code&gt;hard_delete&lt;/code&gt; toggle, because it tears down the mailbox rather than emptying it message by message.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;--request&lt;/span&gt; DELETE &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--url&lt;/span&gt; &lt;span class="s2"&gt;"https://api.us.nylas.com/v3/grants/&amp;lt;NYLAS_GRANT_ID&amp;gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &amp;lt;NYLAS_API_KEY&amp;gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The CLI does this through the agent-account or auth commands — both revoke the same underlying grant:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;nylas agent account delete support@yourcompany.com &lt;span class="nt"&gt;--yes&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;nylas auth revoke &amp;lt;NYLAS_GRANT_ID&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the nuclear option, and it's the right one for full erasure of an agent identity. It's also irreversible — the grant and its mailbox are gone, with no undelete. Reach for per-message hard-delete when you're scrubbing one person out of a &lt;em&gt;shared&lt;/em&gt; agent mailbox, and grant deletion when the entire mailbox represents the person you're erasing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Guardrails and honest limits
&lt;/h2&gt;

&lt;p&gt;A few things the demo glosses over that a compliance review won't:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Custom metadata isn't available on Agent Accounts.&lt;/strong&gt; On a normal grant you might tag messages with a subject ID in &lt;code&gt;metadata&lt;/code&gt; to make erasure lookups trivial. That's not an option here — Agent Accounts don't support custom metadata — so your erasure index has to live in &lt;em&gt;your&lt;/em&gt; system, keyed off the message IDs you already store. Plan for that when you design the handler.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Derived copies are your problem, not the API's.&lt;/strong&gt; This is worth repeating because it's the most common gap. If your agent summarized a thread into your database, embedded a message into a vector store, or wrote the sender address into a log line, deleting the message from the mailbox leaves all of those untouched. Erasure is only complete when every copy is gone. Build the deletion of &lt;em&gt;your&lt;/em&gt; derived data into the same handler that calls the Nylas DELETE.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Retention defaults aren't a policy you chose.&lt;/strong&gt; The free-plan 30-day inbox / 7-day spam window is a default, not a decision. If you're on a paid plan and never attached a policy, your accounts run at plan maximums — potentially no bound at all. Don't mistake "it seems to clean up" for "we have a documented retention schedule." Attach the policy explicitly.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;&lt;code&gt;limit_spam_retention_period&lt;/code&gt; must be shorter than &lt;code&gt;limit_inbox_retention_period&lt;/code&gt;.&lt;/strong&gt; It's a hard constraint the API enforces. Set both, and set spam lower.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;A plain delete is not erasure.&lt;/strong&gt; This is the single most important thing to get right in this post. A bare &lt;code&gt;DELETE&lt;/code&gt; on a message moves it to Trash; thread deletion trashes the whole conversation; the CLI's &lt;code&gt;nylas email delete&lt;/code&gt; and &lt;code&gt;nylas email threads delete&lt;/code&gt; only trash. None of those erase. Erasure requires either &lt;code&gt;?hard_delete=true&lt;/code&gt; on the message endpoint (API only, with the Dashboard toggle enabled) or deleting the grant. Treat "I deleted it" and "I erased it" as different claims, because the platform does.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Hard-delete and grant deletion are irreversible.&lt;/strong&gt; Once you do erase — &lt;code&gt;hard_delete=true&lt;/code&gt; on a message, or &lt;code&gt;DELETE&lt;/code&gt; on the grant — there's no undelete and no Trash to recover from. Confirm the subject and the ID set before an erasure pass. That's exactly what you want for a right-to-erasure request, and exactly what you don't want to trigger by accident.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of this is legal advice, and your retention windows and erasure SLAs should come from your own data-protection program, not from the example numbers above. What the platform gives you is the mechanism: a declarative retention bound that the platform enforces, and a deterministic deletion path you can run on demand. The policy layer matters most for compliance — it's the difference between &lt;em&gt;intending&lt;/em&gt; to delete old data and &lt;em&gt;proving&lt;/em&gt; the system does.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://developer.nylas.com/docs/v3/agent-accounts/policies-rules-lists/" rel="noopener noreferrer"&gt;Policies, Rules, and Lists&lt;/a&gt; for the full limits object, including the storage cap and send quotas alongside the two retention fields&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://developer.nylas.com/docs/cookbook/use-cases/industries/email-api-compliance/" rel="noopener noreferrer"&gt;Email API security &amp;amp; compliance&lt;/a&gt; for scoped OAuth, signed webhooks, revocable grants, and the data-residency choice between the US and EU regions&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://developer.nylas.com/docs/v3/agent-accounts/" rel="noopener noreferrer"&gt;Agent Accounts&lt;/a&gt; for provisioning, workspaces, and how the grant abstraction carries through every endpoint&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://cli.nylas.com/docs/commands" rel="noopener noreferrer"&gt;Nylas CLI commands&lt;/a&gt; for the full &lt;code&gt;nylas agent&lt;/code&gt;, &lt;code&gt;nylas workspace&lt;/code&gt;, and &lt;code&gt;nylas email&lt;/code&gt; command surface used throughout this post&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  AI-answer pages for agents
&lt;/h2&gt;

&lt;p&gt;When this post is published, link AI agents and crawlers to the retrieval-ready version on &lt;code&gt;cli.nylas.com&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Topic runbook: &lt;a href="https://cli.nylas.com/ai-answers/data-retention-email-agent.md" rel="noopener noreferrer"&gt;https://cli.nylas.com/ai-answers/data-retention-email-agent.md&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Industry playbooks hub: &lt;a href="https://cli.nylas.com/ai-answers/agent-account-industry-playbooks.md" rel="noopener noreferrer"&gt;https://cli.nylas.com/ai-answers/agent-account-industry-playbooks.md&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>email</category>
      <category>api</category>
      <category>devtools</category>
      <category>productivity</category>
    </item>
    <item>
      <title>Keep your agent's mail out of spam traps</title>
      <dc:creator>Qasim</dc:creator>
      <pubDate>Sat, 11 Jul 2026 12:27:32 +0000</pubDate>
      <link>https://dev.to/mqasimca/keep-your-agents-mail-out-of-spam-traps-13d4</link>
      <guid>https://dev.to/mqasimca/keep-your-agents-mail-out-of-spam-traps-13d4</guid>
      <description>&lt;p&gt;Spam traps are the failure mode nobody puts in the demo. A bounce is loud — you get a &lt;code&gt;5.x.x&lt;/code&gt; back, your code logs it, you move on. A complaint at least gives you a webhook. A spam trap gives you &lt;em&gt;nothing&lt;/em&gt;. The message gets accepted, no error comes back, and somewhere a mailbox provider quietly writes your domain down as a spammer. By the time you notice, your inbox placement has already cratered and you have no single bounce to point at.&lt;/p&gt;

&lt;p&gt;That's the trap, literally. And it's the one that bites autonomous agents the hardest, because the whole appeal of an agent is that it acts without a human watching every send. Point a model at a list it scraped, let it loop, and it'll happily mail a recycled address that's been a trap for two years. The agent never sees a problem. You only see the aftermath in your deliverability dashboard a week later.&lt;/p&gt;

&lt;p&gt;I work on the Nylas CLI, so the terminal commands below are the exact ones I reach for when I'm wiring up an Agent Account to &lt;em&gt;not&lt;/em&gt; do this. The good news is that an Agent Account is just a &lt;strong&gt;grant&lt;/strong&gt; — a &lt;code&gt;grant_id&lt;/code&gt; that works with every grant-scoped endpoint you already know — so there's nothing new to learn on the data plane. The defense is mostly discipline: validate before you send, honor every complaint, and age out the addresses that never wanted to hear from you.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a spam trap actually is, and why it's not a bounce
&lt;/h2&gt;

&lt;p&gt;It's worth being precise here, because the three things people lump together behave completely differently.&lt;/p&gt;

&lt;p&gt;A &lt;strong&gt;bounce&lt;/strong&gt; is a rejected delivery. The receiving server tells you the address is bad, you get a &lt;code&gt;message.bounced&lt;/code&gt; event, and you stop. &lt;a href="https://developer.nylas.com/docs/v3/agent-accounts/deliverability/" rel="noopener noreferrer"&gt;Bounce handling&lt;/a&gt; is a solved problem — you listen, you suppress, you're done.&lt;/p&gt;

&lt;p&gt;A &lt;strong&gt;complaint&lt;/strong&gt; is a recipient hitting "report spam." The mailbox provider relays that back as a feedback loop, and you get a &lt;code&gt;message.complaint&lt;/code&gt; event. The address is real and reachable; the human just doesn't want your mail. If you keep mailing them, you're training the provider to filter you.&lt;/p&gt;

&lt;p&gt;A &lt;strong&gt;spam trap&lt;/strong&gt; is neither. It's an address with no human behind it, planted (or recycled) specifically to catch senders who don't manage their lists. There are two flavors:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Pristine traps&lt;/strong&gt; — addresses that were never valid and never opted in. Published in places only a scraper would find. If you hit one, it means you're mailing addresses you harvested rather than addresses that gave you consent.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Recycled traps&lt;/strong&gt; — addresses that &lt;em&gt;were&lt;/em&gt; real, went dormant, bounced for months, and then got reactivated by the provider as a trap. If you hit one, it means you kept mailing an address long after it stopped engaging.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Here's the part that makes traps dangerous: &lt;strong&gt;they don't bounce.&lt;/strong&gt; A pristine trap accepts your mail silently. A recycled trap accepts it too, now that it's live again. There is no Nylas "is this a spam trap" endpoint — there can't be, because the whole point of a trap is that it's indistinguishable from a real inbox until your reputation tanks. So the defense can't be a lookup. It has to be &lt;em&gt;behavior&lt;/em&gt;: don't mail addresses you can't trust, and stop mailing addresses that have gone quiet.&lt;/p&gt;

&lt;p&gt;That's the honest framing. Everything below is built on it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you actually get from Nylas
&lt;/h2&gt;

&lt;p&gt;You don't get a trap detector. You get the two signals that let you build trap &lt;em&gt;avoidance&lt;/em&gt;, plus the machinery to enforce a suppression list at the platform edge:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Deliverability webhooks&lt;/strong&gt; — &lt;code&gt;message.delivered&lt;/code&gt;, &lt;code&gt;message.bounced&lt;/code&gt;, &lt;code&gt;message.complaint&lt;/code&gt;, and &lt;code&gt;message.rejected&lt;/code&gt;. These tell you what happened to every outbound message. Complaints and bounces are your strongest "stop mailing this person" signals.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Lists and Rules&lt;/strong&gt; — application-scoped suppression. A &lt;strong&gt;List&lt;/strong&gt; holds addresses; a &lt;strong&gt;Rule&lt;/strong&gt; with an &lt;code&gt;in_list&lt;/code&gt; condition blocks any outbound send to them; a &lt;strong&gt;workspace&lt;/strong&gt; activates that rule across your Agent Accounts. Once an address is suppressed here, your agent &lt;em&gt;physically cannot&lt;/em&gt; email it, even if its own logic slips.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The webhooks tell you who to suppress. The Lists and Rules make the suppression unbreakable. Your application code fills the gap Nylas can't — validating recipients before the first send, and aging out the ones who never engaged.&lt;/p&gt;

&lt;p&gt;Let's wire all three.&lt;/p&gt;

&lt;h2&gt;
  
  
  Before you begin
&lt;/h2&gt;

&lt;p&gt;You need an Agent Account on a registered domain and your API key. If you're starting cold, provision one. The grant it returns is your &lt;code&gt;grant_id&lt;/code&gt; for everything after.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;--request&lt;/span&gt; POST &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--url&lt;/span&gt; &lt;span class="s2"&gt;"https://api.us.nylas.com/v3/connect/custom"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &amp;lt;NYLAS_API_KEY&amp;gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--data&lt;/span&gt; &lt;span class="s1"&gt;'{
    "provider": "nylas",
    "settings": { "email": "support@yourcompany.com" },
    "name": "Support Agent"
  }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;nylas agent account create support@yourcompany.com &lt;span class="nt"&gt;--name&lt;/span&gt; &lt;span class="s2"&gt;"Support Agent"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The API auto-creates a default workspace and policy for the account, which matters later: a suppression rule has to be activated on a workspace, and this is the one your new account already belongs to.&lt;/p&gt;

&lt;p&gt;A reminder on &lt;code&gt;message.rejected&lt;/code&gt;, because it's easy to misread. That event fires for one specific reason — an outbound message was rejected because an &lt;strong&gt;attachment contained a virus&lt;/strong&gt;. It is &lt;em&gt;not&lt;/em&gt; a generic "this send failed" signal and has nothing to do with traps. Subscribe to it for security telemetry, but don't wire it into your suppression logic.&lt;/p&gt;

&lt;h2&gt;
  
  
  Subscribe to the complaint and bounce webhooks
&lt;/h2&gt;

&lt;p&gt;This is step one of trap defense, because a complaint or a hard bounce is the clearest evidence that an address should never receive another send. Subscribe once, at the application level — Nylas webhooks are app-scoped, not grant-scoped, so a single subscription covers every Agent Account you run. Each payload carries a &lt;code&gt;grant_id&lt;/code&gt; you filter on.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;--request&lt;/span&gt; POST &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--url&lt;/span&gt; &lt;span class="s2"&gt;"https://api.us.nylas.com/v3/webhooks"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &amp;lt;NYLAS_API_KEY&amp;gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--data&lt;/span&gt; &lt;span class="s1"&gt;'{
    "trigger_types": [
      "message.complaint",
      "message.bounced",
      "message.rejected"
    ],
    "webhook_url": "https://yourapp.com/webhooks/deliverability",
    "description": "Agent Account deliverability signals"
  }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now the honest note you'd want from a colleague rather than a marketing page: &lt;strong&gt;you have to create this one through the API, not the CLI.&lt;/strong&gt; The CLI can absolutely create webhooks — &lt;code&gt;nylas webhook create --url &amp;lt;URL&amp;gt; --triggers &amp;lt;...&amp;gt;&lt;/code&gt; — and I use it constantly for &lt;code&gt;message.created&lt;/code&gt; and friends:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;nylas webhook create &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--url&lt;/span&gt; https://yourapp.com/webhooks/deliverability &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--triggers&lt;/span&gt; message.created
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But run &lt;code&gt;nylas webhook triggers&lt;/code&gt; and you'll see the published trigger catalog doesn't list the deliverability events. The CLI validates &lt;code&gt;--triggers&lt;/code&gt; against that catalog, so &lt;code&gt;message.complaint&lt;/code&gt; won't go through the flag today. The &lt;code&gt;POST /v3/webhooks&lt;/code&gt; body above accepts them directly. This is the one place in the whole flow where the CLI can't mirror the API, and I'd rather tell you that than have you debug a rejected flag.&lt;/p&gt;

&lt;p&gt;When a complaint lands, your handler should do exactly one thing first: add that address to your suppression list. Don't wait for a daily batch — a person who reported you as spam must not receive a second message. Dedupe the webhook itself on the top-level notification &lt;code&gt;id&lt;/code&gt; (constant across Nylas's up-to-three retry attempts) so a retried delivery doesn't double-process.&lt;/p&gt;

&lt;h2&gt;
  
  
  Build the suppression list and the rule that enforces it
&lt;/h2&gt;

&lt;p&gt;A logged complaint that doesn't change behavior is just a sad log line. The enforcement lives in a &lt;strong&gt;List&lt;/strong&gt; plus a &lt;strong&gt;Rule&lt;/strong&gt;, activated on a &lt;strong&gt;workspace&lt;/strong&gt;. This is the part that makes suppression structural rather than a hope that your send loop remembers to check.&lt;/p&gt;

&lt;p&gt;First, the list — an &lt;code&gt;address&lt;/code&gt;-typed collection. Seed it with the complaining or hard-bouncing addresses as they come in.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;--request&lt;/span&gt; POST &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--url&lt;/span&gt; &lt;span class="s2"&gt;"https://api.us.nylas.com/v3/lists"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &amp;lt;NYLAS_API_KEY&amp;gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--data&lt;/span&gt; &lt;span class="s1"&gt;'{ "name": "Suppressed recipients", "type": "address" }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;nylas agent list create &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--name&lt;/span&gt; &lt;span class="s2"&gt;"Suppressed recipients"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--type&lt;/span&gt; address &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--item&lt;/span&gt; complained@example.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Add to it as complaints arrive — &lt;code&gt;POST /v3/lists/&amp;lt;LIST_ID&amp;gt;/items&lt;/code&gt; with an &lt;code&gt;items&lt;/code&gt; array, or the CLI's &lt;code&gt;add&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;--request&lt;/span&gt; POST &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--url&lt;/span&gt; &lt;span class="s2"&gt;"https://api.us.nylas.com/v3/lists/&amp;lt;LIST_ID&amp;gt;/items"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &amp;lt;NYLAS_API_KEY&amp;gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--data&lt;/span&gt; &lt;span class="s1"&gt;'{ "items": ["complained@example.com"] }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;nylas agent list add &amp;lt;LIST_ID&amp;gt; complained@example.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Next, the &lt;strong&gt;Rule&lt;/strong&gt;. It's an &lt;code&gt;outbound&lt;/code&gt; rule with one &lt;code&gt;in_list&lt;/code&gt; condition against &lt;code&gt;recipient.address&lt;/code&gt;, and a single terminal &lt;code&gt;block&lt;/code&gt; action. An outbound &lt;code&gt;block&lt;/code&gt; rejects the send before it ever reaches the provider — the API returns &lt;code&gt;403&lt;/code&gt;, and no message goes out.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;--request&lt;/span&gt; POST &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--url&lt;/span&gt; &lt;span class="s2"&gt;"https://api.us.nylas.com/v3/rules"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &amp;lt;NYLAS_API_KEY&amp;gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--data&lt;/span&gt; &lt;span class="s1"&gt;'{
    "name": "Block suppressed recipients",
    "trigger": "outbound",
    "match": {
      "conditions": [
        { "field": "recipient.address", "operator": "in_list", "value": ["&amp;lt;LIST_ID&amp;gt;"] }
      ]
    },
    "actions": [ { "type": "block" } ]
  }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The CLI condenses this into condition-and-action flags:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;nylas agent rule create &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--name&lt;/span&gt; &lt;span class="s2"&gt;"Block suppressed recipients"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--trigger&lt;/span&gt; outbound &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--condition&lt;/span&gt; recipient.address,in_list,&amp;lt;LIST_ID&amp;gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--action&lt;/span&gt; block
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One detail worth knowing: &lt;code&gt;recipient.address&lt;/code&gt; matches &lt;strong&gt;any&lt;/strong&gt; recipient on the message — To, CC, BCC, and the SMTP envelope. So a suppressed address hidden in a BCC still trips the block. That's exactly the DLP-grade coverage you want; an agent can't sneak a suppressed contact through on a carbon copy.&lt;/p&gt;

&lt;h2&gt;
  
  
  Activate the rule — it's inert until you do
&lt;/h2&gt;

&lt;p&gt;This is the step people skip, and then wonder why their block rule does nothing. &lt;strong&gt;A rule does nothing until a workspace references it.&lt;/strong&gt; Creating the rule through &lt;code&gt;POST /v3/rules&lt;/code&gt; only defines it. You have to add its ID to a workspace's &lt;code&gt;rule_ids&lt;/code&gt; array, and only then does it run for the Agent Accounts in that workspace.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;--request&lt;/span&gt; PATCH &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--url&lt;/span&gt; &lt;span class="s2"&gt;"https://api.us.nylas.com/v3/workspaces/&amp;lt;WORKSPACE_ID&amp;gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &amp;lt;NYLAS_API_KEY&amp;gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--data&lt;/span&gt; &lt;span class="s1"&gt;'{ "rule_ids": ["&amp;lt;RULE_ID&amp;gt;"] }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;nylas workspace update &amp;lt;WORKSPACE_ID&amp;gt; &lt;span class="nt"&gt;--rules-ids&lt;/span&gt; &amp;lt;RULE_ID&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A nice shortcut from the CLI side: &lt;code&gt;nylas agent rule create&lt;/code&gt; &lt;em&gt;already&lt;/em&gt; attaches the rule it creates to the default workspace for you — that's what "create a rule and attach it to the default agent workspace" in its help text means. So if your accounts live in the default workspace, the CLI path collapses suppression-rule creation and activation into a single command. The explicit &lt;code&gt;workspace update&lt;/code&gt; above is what you reach for when you built the rule through the API, or when you're attaching it to a custom workspace. Either way, the principle holds: an unattached rule is a no-op.&lt;/p&gt;

&lt;p&gt;With this in place, every complaint your webhook catches flows into the list, and every send the agent attempts to a listed address dies at &lt;code&gt;403&lt;/code&gt; before it can compound the damage. The platform is now enforcing a boundary your application code can't accidentally cross.&lt;/p&gt;

&lt;h2&gt;
  
  
  Validate recipients before the first send — this is your job
&lt;/h2&gt;

&lt;p&gt;Here's where I have to be straight with you: &lt;strong&gt;everything past this point is your application logic.&lt;/strong&gt; Nylas doesn't ship a "is this a spam trap" check, and no provider does, because traps are designed to be invisible. The suppression list above handles addresses you've &lt;em&gt;learned&lt;/em&gt; are bad. Trap &lt;em&gt;avoidance&lt;/em&gt; is about not mailing questionable addresses in the first place.&lt;/p&gt;

&lt;p&gt;The single most important rule: &lt;strong&gt;never email a scraped, purchased, or old list.&lt;/strong&gt; Pristine traps live exactly where a scraper would find them. If your agent's input is a list someone harvested, you will hit traps — it's not a question of if. The only addresses safe to mail are ones that asked to hear from you.&lt;/p&gt;

&lt;p&gt;Before any first send, run cheap validation in your own code:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Syntax and domain checks.&lt;/strong&gt; Reject malformed addresses, and confirm the domain has an MX record before you bother sending. A domain with no MX can't receive mail; sending anyway is wasted reputation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Role and disposable filtering.&lt;/strong&gt; Be wary of &lt;code&gt;info@&lt;/code&gt;, &lt;code&gt;admin@&lt;/code&gt;, &lt;code&gt;postmaster@&lt;/code&gt; style role addresses on lists you didn't build, and of known disposable-domain providers. Neither belongs in an agent's outreach.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Consent provenance.&lt;/strong&gt; This is the one that actually matters. Track &lt;em&gt;how&lt;/em&gt; each address entered your system — a form submission, an account signup, a reply to a real conversation. Addresses with no provenance trail are exactly the ones that turn out to be traps.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of this is a Nylas feature, and I won't pretend it is. It's a validation layer you own, and it's the difference between an agent that builds reputation and one that quietly burns it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Age out the addresses that never engage
&lt;/h2&gt;

&lt;p&gt;Recycled traps are the slow killer. An address that was real goes dormant, you keep mailing it out of habit, and one day the provider flips it into a trap. The defense is to stop mailing addresses that have gone quiet — &lt;em&gt;before&lt;/em&gt; they get recycled.&lt;/p&gt;

&lt;p&gt;Use the deliverability webhooks as your engagement ledger. &lt;code&gt;message.delivered&lt;/code&gt; confirms a send landed; the absence of any positive signal over time is your aging cue. In your own datastore, track last-engagement per recipient and apply a policy like:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No opens, clicks, or replies in 90 days?&lt;/strong&gt; Move them to a re-engagement track — one carefully worded "are you still there?" send, not your normal cadence.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Still silent after that?&lt;/strong&gt; Stop mailing them entirely and add them to the same suppression list you built above. A never-engaged address that you keep hitting is a recycled trap waiting to happen.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You can mine engagement signals from the grant-scoped Messages and Threads endpoints the Agent Account already exposes — &lt;code&gt;GET /v3/grants/{grant_id}/messages&lt;/code&gt; to see what came back, &lt;code&gt;nylas email list&lt;/code&gt; and &lt;code&gt;nylas email read&lt;/code&gt; from the terminal when you're spot-checking a thread by hand. There's no special endpoint for this; it's the same data plane every grant has, which is rather the point of the Agent Account abstraction. You're not learning a new API to do reputation hygiene — you're using the one you already have, with discipline.&lt;/p&gt;

&lt;h2&gt;
  
  
  The shape of the whole defense
&lt;/h2&gt;

&lt;p&gt;Put together, trap defense for an Agent Account is three loops that reinforce each other:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Inbound signal&lt;/strong&gt; — subscribe to &lt;code&gt;message.complaint&lt;/code&gt; and &lt;code&gt;message.bounced&lt;/code&gt; (via the API, since the CLI trigger catalog omits them), and treat each as a hard "suppress now."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Structural enforcement&lt;/strong&gt; — push every suppressed address into an &lt;code&gt;address&lt;/code&gt; List, blocked by an outbound &lt;code&gt;in_list&lt;/code&gt; Rule that's activated on the workspace. The agent physically can't reach a suppressed address, BCC included.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Behavioral hygiene&lt;/strong&gt; — your own code validates provenance before the first send and ages out never-engaged contacts before they become recycled traps.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The first two are Nylas machinery you can stand up in an afternoon. The third is the part nobody can do for you, and it's the part that actually keeps you out of the traps — because a trap, by definition, won't tell you it's a trap. The only way to avoid one is to never have been the kind of sender that hits them.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://developer.nylas.com/docs/v3/agent-accounts/deliverability/" rel="noopener noreferrer"&gt;Email deliverability for Agent Accounts&lt;/a&gt; — domain authentication, DMARC staging, and the full deliverability-webhook list&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://developer.nylas.com/docs/v3/agent-accounts/policies-rules-lists/" rel="noopener noreferrer"&gt;Policies, Rules, and Lists&lt;/a&gt; — the complete schema for Lists, the &lt;code&gt;in_list&lt;/code&gt; operator, outbound &lt;code&gt;block&lt;/code&gt; rules, and workspace activation&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://developer.nylas.com/docs/v3/agent-accounts/" rel="noopener noreferrer"&gt;Agent Accounts overview&lt;/a&gt; — the grant model and how Agent Accounts reuse every grant-scoped endpoint&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://cli.nylas.com/docs/commands" rel="noopener noreferrer"&gt;Nylas CLI command reference&lt;/a&gt; — &lt;code&gt;nylas agent&lt;/code&gt;, &lt;code&gt;nylas webhook&lt;/code&gt;, and &lt;code&gt;nylas workspace&lt;/code&gt; in full&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  AI-answer pages for agents
&lt;/h2&gt;

&lt;p&gt;When this post is published, link AI agents and crawlers to the retrieval-ready version on &lt;code&gt;cli.nylas.com&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Topic runbook: &lt;a href="https://cli.nylas.com/ai-answers/email-deliverability-api-for-agents.md" rel="noopener noreferrer"&gt;https://cli.nylas.com/ai-answers/email-deliverability-api-for-agents.md&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Industry playbooks hub: &lt;a href="https://cli.nylas.com/ai-answers/agent-account-industry-playbooks.md" rel="noopener noreferrer"&gt;https://cli.nylas.com/ai-answers/agent-account-industry-playbooks.md&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>email</category>
      <category>api</category>
      <category>devtools</category>
      <category>productivity</category>
    </item>
    <item>
      <title>Measure reply-rate analytics for your email agent</title>
      <dc:creator>Qasim</dc:creator>
      <pubDate>Sat, 11 Jul 2026 04:59:55 +0000</pubDate>
      <link>https://dev.to/mqasimca/measure-reply-rate-analytics-for-your-email-agent-1052</link>
      <guid>https://dev.to/mqasimca/measure-reply-rate-analytics-for-your-email-agent-1052</guid>
      <description>&lt;p&gt;Most "email analytics" demos reach straight for the open pixel. Drop a 1x1 transparent GIF in the body, watch the loads roll in, call it engagement. That worked in 2015. Today Apple's Mail Privacy Protection pre-fetches every image the instant a message hits the inbox, so a chunk of your "opens" are Apple's proxy warming a cache, not a human reading anything. Gmail proxies images too. Corporate gateways strip them. The number you get back is noise wearing a confidence interval.&lt;/p&gt;

&lt;p&gt;There's a signal sitting right next to it that nobody can fake or block: &lt;em&gt;did they reply?&lt;/em&gt; A reply is a human deciding your message was worth typing back to. It's lower-volume than an open and it's slower to arrive, but it's true. And if your sender is a Nylas &lt;strong&gt;Agent Account&lt;/strong&gt; — a mailbox your agent owns, sends from, and receives into — reply rate is something you can actually compute, because every send and every inbound reply land in the same threaded data plane you already have read access to.&lt;/p&gt;

&lt;p&gt;This post is the build. I work on the Nylas CLI, so the terminal commands below are the exact ones I reach for, and I'll show the raw HTTP next to each so you can wire it into whatever language your agent runs in.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you're actually measuring
&lt;/h2&gt;

&lt;p&gt;Reply rate is one fraction:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;reply rate = replied_threads / sent_threads   (per segment, per time window)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;sent_threads&lt;/code&gt; is the count of distinct conversations your agent started in some window. &lt;code&gt;replied_threads&lt;/code&gt; is how many of those got at least one inbound message back from a real recipient. Segment by campaign, by recipient cohort, by prompt version — whatever dimension you want to compare — and watch it move over time.&lt;/p&gt;

&lt;p&gt;The mechanism that makes this computable is &lt;strong&gt;threading&lt;/strong&gt;. When the agent sends, Nylas stamps the outbound message with a &lt;code&gt;Message-ID&lt;/code&gt; and returns a &lt;code&gt;thread_id&lt;/code&gt;. When the recipient replies, their client sets &lt;code&gt;In-Reply-To&lt;/code&gt; and &lt;code&gt;References&lt;/code&gt; pointing back at that &lt;code&gt;Message-ID&lt;/code&gt;, Nylas stitches the reply into the &lt;em&gt;same&lt;/em&gt; thread, and the inbound &lt;code&gt;message.created&lt;/code&gt; webhook carries the same &lt;code&gt;thread_id&lt;/code&gt;. So the entire correlation is: &lt;em&gt;remember which &lt;code&gt;thread_id&lt;/code&gt; you sent into, and when an inbound reply shows up on that thread, count it.&lt;/em&gt; No subject-line guessing, no header parsing by hand. The &lt;a href="https://developer.nylas.com/docs/v3/agent-accounts/email-threading/" rel="noopener noreferrer"&gt;email threading doc&lt;/a&gt; goes deep on the header chain if you want it; for analytics you only need the &lt;code&gt;thread_id&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;One thing to get straight up front, because it shapes the whole design: &lt;strong&gt;Agent Accounts don't support custom message metadata.&lt;/strong&gt; On a regular grant you might stuff a &lt;code&gt;campaign&lt;/code&gt; key into the send and filter on it later. You can't here. The mapping from &lt;code&gt;thread_id&lt;/code&gt; to "which campaign / which run / which segment" lives in &lt;em&gt;your&lt;/em&gt; database. That's not a limitation so much as a clarification of where the line is — Nylas owns the email data plane, your app owns the analytics.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this beats open tracking
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Opens are noisy and increasingly blocked.&lt;/strong&gt; Apple MPP, image proxying, and gateway stripping inflate or suppress the count depending on the recipient's setup. You can't tell a real open from a proxy pre-fetch.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A reply is unforgeable intent.&lt;/strong&gt; Nobody's mail client replies on their behalf. The signal is sparse but it means exactly what you think it means.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It's the right loop for an agent.&lt;/strong&gt; An autonomous agent doesn't need a dashboard a human reads quarterly; it needs a number it can optimize against. Reply rate per prompt version tells the agent which copy actually works.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Nothing new on the data plane.&lt;/strong&gt; An Agent Account is just a grant with a &lt;code&gt;grant_id&lt;/code&gt;. Sends, threads, messages, webhooks — all the same grant-scoped endpoints you'd use for any mailbox. The agent abstraction adds zero new API surface to learn.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Honest tradeoff, stated plainly: reply rate is a &lt;em&gt;truer&lt;/em&gt; signal but a &lt;em&gt;lower-volume&lt;/em&gt; one. On cold outreach you might see single-digit reply percentages where opens read 40%. If you need a high-frequency proxy for "message got displayed," opens still have a (degraded) place. If you need to know whether your agent's email is actually working, reply rate is the metric that won't lie to you. You can run both — they answer different questions.&lt;/p&gt;

&lt;h2&gt;
  
  
  Before you begin
&lt;/h2&gt;

&lt;p&gt;You need an Agent Account to send from. It's provisioned in two equivalent ways.&lt;/p&gt;

&lt;p&gt;The API call is &lt;code&gt;POST /v3/connect/custom&lt;/code&gt; with &lt;code&gt;provider: nylas&lt;/code&gt; and a &lt;code&gt;settings.email&lt;/code&gt; on a domain you've registered (a real custom domain, or a &lt;code&gt;*.nylas.email&lt;/code&gt; trial subdomain):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;--request&lt;/span&gt; POST &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--url&lt;/span&gt; &lt;span class="s1"&gt;'https://api.us.nylas.com/v3/connect/custom'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s1"&gt;'Authorization: Bearer &amp;lt;NYLAS_API_KEY&amp;gt;'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s1"&gt;'Content-Type: application/json'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--data&lt;/span&gt; &lt;span class="s1"&gt;'{
    "provider": "nylas",
    "settings": { "email": "agent@yourcompany.com" },
    "name": "Outbound Agent"
  }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The CLI does the same in one line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;nylas agent account create agent@yourcompany.com &lt;span class="nt"&gt;--name&lt;/span&gt; &lt;span class="s2"&gt;"Outbound Agent"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Either way you get back a &lt;code&gt;grant_id&lt;/code&gt;. No OAuth dance, no refresh token — the agent owns the mailbox outright. Hold onto that &lt;code&gt;grant_id&lt;/code&gt;; it's the identifier in every endpoint below. The &lt;a href="https://developer.nylas.com/docs/v3/agent-accounts/" rel="noopener noreferrer"&gt;Agent Accounts docs&lt;/a&gt; cover domain warm-up and the free-plan limits (200 messages per account per day, worth knowing before you batch a campaign).&lt;/p&gt;

&lt;p&gt;You'll also want a small table in your own store. The schema is boring on purpose:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- one row per agent send&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;agent_sends&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;thread_id&lt;/span&gt;    &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;-- returned by the send call&lt;/span&gt;
  &lt;span class="n"&gt;message_id&lt;/span&gt;   &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;segment&lt;/span&gt;      &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;      &lt;span class="c1"&gt;-- "q2-trial-nudge", "prompt-v3", etc.&lt;/span&gt;
  &lt;span class="n"&gt;sent_at&lt;/span&gt;      &lt;span class="n"&gt;TIMESTAMPTZ&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;replied_at&lt;/span&gt;   &lt;span class="n"&gt;TIMESTAMPTZ&lt;/span&gt;         &lt;span class="c1"&gt;-- NULL until a reply lands&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;-- dedup ledger so we never double-count a webhook&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;seen_notifications&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;notification_id&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the entire persistence layer. Everything else is reads against Nylas.&lt;/p&gt;

&lt;h2&gt;
  
  
  Send, and record the thread
&lt;/h2&gt;

&lt;p&gt;When the agent sends, capture the &lt;code&gt;thread_id&lt;/code&gt; off the response and write a row. The send endpoint is &lt;code&gt;POST /v3/grants/{grant_id}/messages/send&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;--request&lt;/span&gt; POST &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--url&lt;/span&gt; &lt;span class="s1"&gt;'https://api.us.nylas.com/v3/grants/&amp;lt;GRANT_ID&amp;gt;/messages/send'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s1"&gt;'Authorization: Bearer &amp;lt;NYLAS_API_KEY&amp;gt;'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s1"&gt;'Content-Type: application/json'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--data&lt;/span&gt; &lt;span class="s1"&gt;'{
    "to": [{ "email": "prospect@example.com" }],
    "subject": "Following up on your trial",
    "body": "Hi there — checking in on how the trial is going."
  }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The response carries &lt;code&gt;id&lt;/code&gt; (the message id) and &lt;code&gt;thread_id&lt;/code&gt;. That &lt;code&gt;thread_id&lt;/code&gt; is the key you store against your segment:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;agent_sends&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;thread_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;message_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;segment&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sent_at&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;VALUES&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'&amp;lt;thread_id&amp;gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'&amp;lt;message_id&amp;gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'q2-trial-nudge'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The CLI equivalent, with &lt;code&gt;--json&lt;/code&gt; so you can pipe the response into your recorder:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;nylas email send agent@yourcompany.com &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--to&lt;/span&gt; prospect@example.com &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--subject&lt;/span&gt; &lt;span class="s2"&gt;"Following up on your trial"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--body&lt;/span&gt; &lt;span class="s2"&gt;"Hi there — checking in on how the trial is going."&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--json&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pass the account as the first positional &lt;code&gt;grant-id&lt;/code&gt; argument (the email resolves to the grant). The &lt;code&gt;--json&lt;/code&gt; output includes the same &lt;code&gt;thread_id&lt;/code&gt;; grab it with &lt;code&gt;jq&lt;/code&gt; and run your insert. If the agent is replying &lt;em&gt;into&lt;/em&gt; an existing thread rather than starting one, use &lt;code&gt;--reply-to &amp;lt;message-id&amp;gt;&lt;/code&gt; (or &lt;code&gt;reply_to_message_id&lt;/code&gt; in the API body) — Nylas sets the threading headers so the reply lands on the original thread, and your &lt;code&gt;thread_id&lt;/code&gt; mapping is already correct.&lt;/p&gt;

&lt;p&gt;The rule: &lt;strong&gt;record on every send, no exceptions.&lt;/strong&gt; A send you forgot to log is a thread that can never count as &lt;code&gt;sent&lt;/code&gt;, which silently deflates your denominator and inflates your rate. Tag every send, even ones you don't think you'll measure.&lt;/p&gt;

&lt;h2&gt;
  
  
  Subscribe to inbound replies
&lt;/h2&gt;

&lt;p&gt;Inbound mail fires the standard &lt;code&gt;message.created&lt;/code&gt; webhook. One important fact about Nylas webhooks: &lt;strong&gt;they're application-scoped, not grant-scoped.&lt;/strong&gt; You subscribe once at the app level, and events for every grant in the app arrive at that one endpoint, each payload carrying the &lt;code&gt;grant_id&lt;/code&gt; it belongs to. You don't create a webhook per Agent Account.&lt;/p&gt;

&lt;p&gt;The subscription is &lt;code&gt;POST /v3/webhooks&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;--request&lt;/span&gt; POST &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--url&lt;/span&gt; &lt;span class="s1"&gt;'https://api.us.nylas.com/v3/webhooks'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s1"&gt;'Authorization: Bearer &amp;lt;NYLAS_API_KEY&amp;gt;'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s1"&gt;'Content-Type: application/json'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--data&lt;/span&gt; &lt;span class="s1"&gt;'{
    "trigger_types": ["message.created"],
    "webhook_url": "https://your-app.example.com/nylas/inbound",
    "description": "Reply-rate inbound signal"
  }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or from the CLI:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;nylas webhook create &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--url&lt;/span&gt; https://your-app.example.com/nylas/inbound &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--triggers&lt;/span&gt; message.created &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--description&lt;/span&gt; &lt;span class="s2"&gt;"Reply-rate inbound signal"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;While building, you don't need a public URL at all — &lt;code&gt;nylas webhook server --tunnel cloudflared --secret &amp;lt;WEBHOOK_SECRET&amp;gt;&lt;/code&gt; stands up a local receiver behind a tunnel and prints each event as it arrives, which is how I test the handler before deploying it. The &lt;code&gt;--secret&lt;/code&gt; is required whenever &lt;code&gt;--tunnel&lt;/code&gt; is set, since the server verifies each event's signature; pass &lt;code&gt;--no-tunnel&lt;/code&gt; instead if you just want a loopback-only receiver for local dev. Nylas signs every webhook with an &lt;code&gt;X-Nylas-Signature&lt;/code&gt; header (hex HMAC-SHA256 of the raw body using your webhook secret); verify it in production, and &lt;code&gt;nylas webhook verify&lt;/code&gt; checks a signature locally if you want to sanity-check your comparison logic.&lt;/p&gt;

&lt;h2&gt;
  
  
  Correlate: match each inbound reply to a sent thread
&lt;/h2&gt;

&lt;p&gt;Here's the loop the whole post builds toward. For each &lt;code&gt;message.created&lt;/code&gt; event:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Dedup on the notification id.&lt;/strong&gt; Nylas guarantees at-least-once delivery — the same event can arrive up to three times. The top-level notification &lt;code&gt;id&lt;/code&gt; is constant across all retries of one event, so it's your dedup key. If you've seen it, drop the event.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Filter out the agent's own sends.&lt;/strong&gt; &lt;code&gt;message.created&lt;/code&gt; fires for &lt;em&gt;outbound&lt;/em&gt; messages too. You only care about real replies, so skip any message whose &lt;code&gt;from&lt;/code&gt; is the agent's own address.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Match &lt;code&gt;thread_id&lt;/code&gt; against your &lt;code&gt;agent_sends&lt;/code&gt; table.&lt;/strong&gt; If the inbound reply's &lt;code&gt;thread_id&lt;/code&gt; is one the agent sent into, that thread just earned a reply.
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;hmac&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;hashlib&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;handle_message_created&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;raw_body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;signature&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="c1"&gt;# Signature check: HMAC-SHA256 of the raw body. Guard equal length
&lt;/span&gt;    &lt;span class="c1"&gt;# before constant-time compare — timingSafeEqual-style compares throw
&lt;/span&gt;    &lt;span class="c1"&gt;# on a length mismatch.
&lt;/span&gt;    &lt;span class="n"&gt;expected&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;hmac&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;NYLAS_WEBHOOK_SECRET&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
        &lt;span class="n"&gt;raw_body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;hashlib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sha256&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;hexdigest&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;expected&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;signature&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;hmac&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;compare_digest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;expected&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;signature&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;401&lt;/span&gt;

    &lt;span class="n"&gt;event&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;raw_body&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="c1"&gt;# 1. Dedup on the top-level notification id (constant across retries)
&lt;/span&gt;    &lt;span class="n"&gt;notif_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;already_seen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;notif_id&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;
    &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;mark_seen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;notif_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;obj&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;data&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;object&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

    &lt;span class="c1"&gt;# 2. Real replies only — skip the agent's own outbound sends
&lt;/span&gt;    &lt;span class="n"&gt;agent_addr&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;agent@yourcompany.com&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;senders&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;email&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;obj&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;from&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[])]&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;agent_addr&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;senders&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;

    &lt;span class="c1"&gt;# 3. Match the reply's thread to a recorded send
&lt;/span&gt;    &lt;span class="n"&gt;thread_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;obj&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;thread_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;send&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find_send_by_thread&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;thread_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;send&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;send&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;replied_at&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;mark_replied&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;thread_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;when&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;obj&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;date&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;  &lt;span class="c1"&gt;# first reply wins
&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A note on the payload body. The Nylas docs aren't fully consistent on whether &lt;code&gt;message.created&lt;/code&gt; ships the full body inline, so use the safe framing: &lt;strong&gt;don't rely on the webhook payload for the body — fetch the full message by id when you need it, and branch on &lt;code&gt;message.created.truncated&lt;/code&gt;.&lt;/strong&gt; For reply-rate counting you usually don't need the body at all (the &lt;code&gt;thread_id&lt;/code&gt;, &lt;code&gt;from&lt;/code&gt;, and &lt;code&gt;date&lt;/code&gt; on the summary are enough), but if you want the reply text to feed back into the agent, fetch it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Full message by id&lt;/span&gt;
curl &lt;span class="nt"&gt;--request&lt;/span&gt; GET &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--url&lt;/span&gt; &lt;span class="s1"&gt;'https://api.us.nylas.com/v3/grants/&amp;lt;GRANT_ID&amp;gt;/messages/&amp;lt;MESSAGE_ID&amp;gt;'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s1"&gt;'Authorization: Bearer &amp;lt;NYLAS_API_KEY&amp;gt;'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;nylas email &lt;span class="nb"&gt;read&lt;/span&gt; &amp;lt;message-id&amp;gt; agent@yourcompany.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When the event arrives as &lt;code&gt;message.created.truncated&lt;/code&gt; (the payload exceeded ~1 MB), the body isn't inline and the fetch-by-id above is mandatory rather than optional. Branch on the trigger type and re-fetch.&lt;/p&gt;

&lt;p&gt;If you'd rather reconstruct the whole conversation — say, to confirm the reply really is a back-and-forth and not an auto-responder — read the thread instead of the single message:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Full thread by id&lt;/span&gt;
curl &lt;span class="nt"&gt;--request&lt;/span&gt; GET &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--url&lt;/span&gt; &lt;span class="s1"&gt;'https://api.us.nylas.com/v3/grants/&amp;lt;GRANT_ID&amp;gt;/threads/&amp;lt;THREAD_ID&amp;gt;'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s1"&gt;'Authorization: Bearer &amp;lt;NYLAS_API_KEY&amp;gt;'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;nylas email threads show &amp;lt;thread-id&amp;gt; agent@yourcompany.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The thread object gives you &lt;code&gt;message_ids&lt;/code&gt;, &lt;code&gt;participants&lt;/code&gt;, and both &lt;code&gt;latest_message_received_date&lt;/code&gt; and &lt;code&gt;latest_message_sent_date&lt;/code&gt; — handy as a cross-check that a received message genuinely arrived after your send.&lt;/p&gt;

&lt;h2&gt;
  
  
  Aggregate the rate yourself
&lt;/h2&gt;

&lt;p&gt;The counting is done in your database, not in Nylas. There's no Nylas endpoint that returns "reply rate for campaign X" — and there shouldn't be, because the segmentation is your domain logic. With the two tables above, the rate per segment is a single query:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;
  &lt;span class="n"&gt;segment&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                              &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;sent_threads&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;replied_at&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                     &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;replied_threads&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;replied_at&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="k"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;reply_rate_pct&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;agent_sends&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;sent_at&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;interval&lt;/span&gt; &lt;span class="s1"&gt;'30 days'&lt;/span&gt;
&lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;segment&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;reply_rate_pct&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Bucket by &lt;code&gt;date_trunc('week', sent_at)&lt;/code&gt; instead of one window and you've got the trend line. Because replies trickle in over days, give each cohort time to mature before you trust its rate — a campaign sent yesterday will look worse than it really is until its replies land. I anchor every cohort to its &lt;code&gt;sent_at&lt;/code&gt; rather than the wall clock for exactly that reason. The message timestamp on each event (&lt;code&gt;obj["date"]&lt;/code&gt;, also what &lt;code&gt;date -u&lt;/code&gt; formats if you're stamping your own records) keeps sent and replied on the same clock.&lt;/p&gt;

&lt;p&gt;That's the full loop: send and record the &lt;code&gt;thread_id&lt;/code&gt;, receive &lt;code&gt;message.created&lt;/code&gt; and dedup on the notification id, filter the agent's own sends, match the reply's thread, and divide replied by sent per segment. Threading is what makes a reply &lt;em&gt;attributable&lt;/em&gt; to a specific send, and attribution is what turns "we got some replies" into a per-campaign rate the agent can steer on.&lt;/p&gt;

&lt;h2&gt;
  
  
  Guardrails and gotchas
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Don't double-count multi-reply threads.&lt;/strong&gt; A prospect might reply twice. The &lt;code&gt;replied_at IS NULL&lt;/code&gt; guard means the first reply flips the thread to "replied" and later ones are ignored — exactly what you want, since the unit is &lt;em&gt;threads that got a reply&lt;/em&gt;, not &lt;em&gt;reply messages&lt;/em&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Auto-responders look like replies.&lt;/strong&gt; An out-of-office bounce arrives as a real inbound &lt;code&gt;message.created&lt;/code&gt;. If your reply rate suddenly spikes on a cold list, sample the threads — you may be counting vacation autoresponders. Filtering those is heuristic (subject patterns, known sender domains); decide whether they count for your use case.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Webhooks are at-least-once, so the dedup ledger isn't optional.&lt;/strong&gt; Without it, three delivery attempts of one reply count as one reply (because &lt;code&gt;replied_at&lt;/code&gt; only flips once) but still cost you three handler runs and three signature checks. Dedup on the notification &lt;code&gt;id&lt;/code&gt; first, before any work.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The denominator is the fragile part.&lt;/strong&gt; Reply rate breaks far more often from undercounted sends than from miscounted replies. Make the send-recording write part of the same code path as the send itself, not a follow-up job that can fail independently.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reconcile against threads periodically.&lt;/strong&gt; Webhooks can be missed during an extended grant outage. A nightly pass that lists recent threads for the grant and checks &lt;code&gt;latest_message_received_date&lt;/code&gt; against your &lt;code&gt;agent_sends&lt;/code&gt; rows catches anything the live path dropped.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://developer.nylas.com/docs/cookbook/agents/agent-track-reply-rates/" rel="noopener noreferrer"&gt;Track email reply rates for AI agents&lt;/a&gt; — the cookbook recipe this post is built on, including the polling-vs-webhook tradeoff table&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://developer.nylas.com/docs/v3/agent-accounts/email-threading/" rel="noopener noreferrer"&gt;Email threading for agents&lt;/a&gt; — the header chain behind &lt;code&gt;thread_id&lt;/code&gt; and why it beats subject matching&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://developer.nylas.com/docs/v3/agent-accounts/" rel="noopener noreferrer"&gt;Agent Accounts&lt;/a&gt; — provisioning, supported endpoints, and plan limits&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://cli.nylas.com/docs/commands" rel="noopener noreferrer"&gt;Nylas CLI command reference&lt;/a&gt; — every &lt;code&gt;nylas email&lt;/code&gt;, &lt;code&gt;nylas webhook&lt;/code&gt;, and &lt;code&gt;nylas agent&lt;/code&gt; subcommand used above&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  AI-answer pages for agents
&lt;/h2&gt;

&lt;p&gt;When this post is published, link AI agents and crawlers to the retrieval-ready version on &lt;code&gt;cli.nylas.com&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Topic runbook: &lt;a href="https://cli.nylas.com/ai-answers/email-reply-rate-tracking-agent.md" rel="noopener noreferrer"&gt;https://cli.nylas.com/ai-answers/email-reply-rate-tracking-agent.md&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Industry playbooks hub: &lt;a href="https://cli.nylas.com/ai-answers/agent-account-industry-playbooks.md" rel="noopener noreferrer"&gt;https://cli.nylas.com/ai-answers/agent-account-industry-playbooks.md&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>ai</category>
      <category>email</category>
      <category>api</category>
      <category>productivity</category>
    </item>
    <item>
      <title>A/B test email subject lines with an agent — no ESP required</title>
      <dc:creator>Qasim</dc:creator>
      <pubDate>Sat, 11 Jul 2026 04:59:21 +0000</pubDate>
      <link>https://dev.to/mqasimca/ab-test-email-subject-lines-with-an-agent-no-esp-required-1ga9</link>
      <guid>https://dev.to/mqasimca/ab-test-email-subject-lines-with-an-agent-no-esp-required-1ga9</guid>
      <description>&lt;p&gt;Subject-line A/B testing is the kind of thing you usually rent. You sign up for an ESP, drop your contacts into their list, pick "split test" from a dropdown, and let their black box decide a winner — on their definition of "winner," with their attribution, on their schedule. That's fine if email is your whole product. It's a lot of ceremony if you have an agent that already sends mail and you just want to know which of two subjects gets more humans to reply.&lt;/p&gt;

&lt;p&gt;Here's the pivot: &lt;em&gt;the experiment is just two sends and a counter.&lt;/em&gt; If your agent owns a mailbox, it can run the split itself — pick a variant per recipient, send from its own address, watch replies land back in its own threads, and tally the result. No ESP, no list import, no separate analytics product. The whole thing lives inside the same mailbox the agent already uses.&lt;/p&gt;

&lt;p&gt;I work on the Nylas CLI, so the terminal commands below are the exact ones I reach for, and I'll show the matching HTTP call for each one so you can wire it into whatever your agent is actually built on. The data plane is the same either way.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you actually get
&lt;/h2&gt;

&lt;p&gt;An &lt;strong&gt;Agent Account&lt;/strong&gt; is a real, sending-and-receiving mailbox that your code owns outright — no human, no OAuth handshake, no token to refresh. Under the hood it's just a Nylas &lt;strong&gt;grant&lt;/strong&gt; with a &lt;code&gt;grant_id&lt;/code&gt;, which is the important part: every grant-scoped endpoint you already know (Messages, Threads, Folders, Webhooks) works against it unchanged. There's nothing new to learn on the data plane. You send with &lt;code&gt;POST /v3/grants/{grant_id}/messages/send&lt;/code&gt;, you read threads with &lt;code&gt;GET /v3/grants/{grant_id}/threads/{thread_id}&lt;/code&gt;, and replies arrive as &lt;code&gt;message.created&lt;/code&gt; webhooks. That's the entire surface area this experiment needs.&lt;/p&gt;

&lt;p&gt;What Nylas does &lt;em&gt;not&lt;/em&gt; do is run the experiment for you. There's no "split test" endpoint, no built-in significance math, no variant tracking. And that's correct — an experiment is your business logic, not the mail provider's. Nylas moves the email and reports the events; the random assignment, the sample-size discipline, and the winner call all live in your app. I'll be honest about that line throughout, because the parts you own are the parts that make the result trustworthy.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this beats reaching for an ESP
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No data export.&lt;/strong&gt; Your recipients, your sends, your replies all stay in your system. You're not syncing a contact list into a third party just to test two strings.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reply rate, not open rate.&lt;/strong&gt; Most ESP split tests optimize opens or clicks. With an agent mailbox you can attribute on &lt;em&gt;replies&lt;/em&gt; — a human typed something back — which is a far stronger signal for outreach, support, or sales follow-ups.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One code path, every provider.&lt;/strong&gt; A reply from Gmail, Outlook, Yahoo, or an IMAP mailbox lands as the same &lt;code&gt;message.created&lt;/code&gt; event on the same thread shape. You write the attribution once.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The math is yours, so you can trust it.&lt;/strong&gt; You control the split, the minimum sample, and when you're allowed to declare a winner. No vendor deciding "statistical significance" behind a curtain.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The tradeoff: you're writing the experiment harness yourself. If you only ever test subject lines for newsletter blasts and never look at replies, a marketing ESP is genuinely the better tool. This is for the case where an &lt;em&gt;agent&lt;/em&gt; is the sender and replies are the outcome you care about.&lt;/p&gt;

&lt;h2&gt;
  
  
  Before you begin
&lt;/h2&gt;

&lt;p&gt;You'll need a Nylas project and API key, plus one Agent Account to send from. If you've never set one up, the &lt;a href="https://developer.nylas.com/docs/v3/getting-started/agent-accounts/" rel="noopener noreferrer"&gt;Agent Accounts quickstart&lt;/a&gt; walks the whole flow; the &lt;a href="https://developer.nylas.com/docs/v3/agent-accounts/supported-endpoints/" rel="noopener noreferrer"&gt;supported-endpoints reference&lt;/a&gt; lists exactly what a grant can do.&lt;/p&gt;

&lt;p&gt;Provision the mailbox. The API auto-creates a default workspace and policy, so a single call is enough:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;--request&lt;/span&gt; POST &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--url&lt;/span&gt; &lt;span class="s1"&gt;'https://api.us.nylas.com/v3/connect/custom'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s1"&gt;'Authorization: Bearer &amp;lt;NYLAS_API_KEY&amp;gt;'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s1"&gt;'Content-Type: application/json'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--data&lt;/span&gt; &lt;span class="s1"&gt;'{
    "provider": "nylas",
    "name": "Outreach Bot",
    "settings": { "email": "outreach@yourcompany.com" }
  }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same thing from the CLI:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;nylas agent account create outreach@yourcompany.com &lt;span class="nt"&gt;--name&lt;/span&gt; &lt;span class="s2"&gt;"Outreach Bot"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The email has to live on a domain you've registered with Nylas (a custom domain, or a &lt;code&gt;*.nylas.email&lt;/code&gt; trial subdomain). Both calls return a &lt;code&gt;grant_id&lt;/code&gt; — that's the handle every command below uses. One caveat worth front-loading: a brand-new sending domain warms over roughly four weeks, so don't read a low reply rate in week one as a verdict on your subject line. It might just be your domain reputation.&lt;/p&gt;

&lt;p&gt;A note on what's &lt;em&gt;not&lt;/em&gt; available, because it shapes the design: &lt;strong&gt;Agent Accounts don't support custom metadata or templates.&lt;/strong&gt; You can't tag a send with &lt;code&gt;key1: variant_a&lt;/code&gt; and filter on it later, and you can't render a Nylas-hosted template. Both are fine — they just mean the variant mapping lives in &lt;em&gt;your&lt;/em&gt; database, and if a subject's matching body needs HTML, you render it yourself and pass it in the &lt;code&gt;body&lt;/code&gt; field. The mailbox stays dumb; your app stays smart.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: assign a variant per recipient
&lt;/h2&gt;

&lt;p&gt;This is your code, not Nylas's. For each recipient, flip a fair coin and record the assignment &lt;em&gt;before&lt;/em&gt; you send. Keeping it deterministic and stored up front is what makes the experiment honest — you decide the split in advance, not after you've peeked at results.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;secrets&lt;/span&gt;

&lt;span class="n"&gt;SUBJECTS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;A&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Quick question about your trial&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;B&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Are you stuck on anything?&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;assign_variant&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="c1"&gt;# secrets gives a fair, unbiased coin — don't use a time-seeded PRNG here
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;A&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;secrets&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;randbelow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;B&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A 50/50 split keeps the two cohorts the same size, which keeps the later comparison simple. The point of randomizing &lt;em&gt;per recipient&lt;/em&gt; rather than splitting your list down the middle is to avoid accidentally correlating a variant with some property of the list order — newest signups, say, or a particular acquisition source.&lt;/p&gt;

&lt;p&gt;Write the assignment to your own store keyed by recipient. You'll fill in the &lt;code&gt;thread_id&lt;/code&gt; in the next step once the send returns it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- your DB, your schema&lt;/span&gt;
&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;experiment_sends&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;recipient&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;variant&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;thread_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;replied&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;VALUES&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'prospect@example.com'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'A'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 2: send each variant from the agent mailbox
&lt;/h2&gt;

&lt;p&gt;Now send. The subject comes from the variant; everything else is identical so you're testing the one thing you mean to test. If you omit the &lt;code&gt;from&lt;/code&gt; field, Nylas defaults it to the Agent Account's own address — exactly what you want here.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;--request&lt;/span&gt; POST &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--url&lt;/span&gt; &lt;span class="s1"&gt;'https://api.us.nylas.com/v3/grants/&amp;lt;NYLAS_GRANT_ID&amp;gt;/messages/send'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s1"&gt;'Authorization: Bearer &amp;lt;NYLAS_API_KEY&amp;gt;'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s1"&gt;'Content-Type: application/json'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--data&lt;/span&gt; &lt;span class="s1"&gt;'{
    "to": [{ "email": "prospect@example.com" }],
    "subject": "Quick question about your trial",
    "body": "Hi there — checking in to see how the trial is going."
  }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The CLI equivalent, which is the one I actually run when I'm poking at this by hand:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;nylas email send &amp;lt;NYLAS_GRANT_ID&amp;gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--to&lt;/span&gt; prospect@example.com &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--subject&lt;/span&gt; &lt;span class="s2"&gt;"Quick question about your trial"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--body&lt;/span&gt; &lt;span class="s2"&gt;"Hi there — checking in to see how the trial is going."&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The send response carries a &lt;code&gt;thread_id&lt;/code&gt;. &lt;strong&gt;Record it against the variant you assigned.&lt;/strong&gt; This is the load-bearing line of the whole experiment: the &lt;code&gt;thread_id&lt;/code&gt; is how a future reply gets credited back to variant A or B. There's no metadata tag doing this for you — the link lives only in your DB.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;experiment_sends&lt;/span&gt;
&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;thread_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'thread_abc123'&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;recipient&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'prospect@example.com'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If your body is HTML, render it yourself and pass the markup straight into &lt;code&gt;body&lt;/code&gt; — remember, templates aren't supported on Agent Accounts, so there's no server-side rendering step. One subject string, one matching body, one recorded thread, per recipient. Loop that across your list and the experiment is live.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: attribute replies back to the winning variant
&lt;/h2&gt;

&lt;p&gt;Replies come in as the standard &lt;code&gt;message.created&lt;/code&gt; webhook. Webhooks are &lt;strong&gt;application-scoped&lt;/strong&gt;, not grant-scoped, so you subscribe once at the app level and every grant's events arrive at the same endpoint, each payload carrying a &lt;code&gt;grant_id&lt;/code&gt; you filter on.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;--request&lt;/span&gt; POST &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--url&lt;/span&gt; &lt;span class="s1"&gt;'https://api.us.nylas.com/v3/webhooks'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s1"&gt;'Authorization: Bearer &amp;lt;NYLAS_API_KEY&amp;gt;'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s1"&gt;'Content-Type: application/json'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--data&lt;/span&gt; &lt;span class="s1"&gt;'{
    "trigger_types": ["message.created"],
    "webhook_url": "https://your-app.example.com/nylas/replies",
    "description": "Subject-line experiment reply attribution"
  }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;From the CLI:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;nylas webhook create &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--url&lt;/span&gt; https://your-app.example.com/nylas/replies &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--triggers&lt;/span&gt; message.created &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--description&lt;/span&gt; &lt;span class="s2"&gt;"Subject-line experiment reply attribution"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When the webhook fires, the attribution logic is short but has three guards you can't skip:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Dedupe on the notification &lt;code&gt;id&lt;/code&gt;.&lt;/strong&gt; Nylas guarantees at-least-once delivery — the same event can arrive up to three times. The top-level notification &lt;code&gt;id&lt;/code&gt; is constant across all retries of one event, so that's your dedup key. If you've seen it, drop it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ignore the agent's own sends.&lt;/strong&gt; &lt;code&gt;message.created&lt;/code&gt; also fires when &lt;em&gt;your agent&lt;/em&gt; sends mail. Check the &lt;code&gt;from&lt;/code&gt; address: if it's the Agent Account itself, it's not a reply, skip it. Only an inbound message from someone else counts.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Match the &lt;code&gt;thread_id&lt;/code&gt; to your DB.&lt;/strong&gt; Pull &lt;code&gt;thread_id&lt;/code&gt; from the event, look it up in &lt;code&gt;experiment_sends&lt;/code&gt;, and credit that row's variant. A reply to a thread you don't have on file isn't part of the experiment.
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;handle_webhook&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;notification&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;seen_before&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;notification&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]):&lt;/span&gt;          &lt;span class="c1"&gt;# guard 1: dedup on notification id
&lt;/span&gt;        &lt;span class="k"&gt;return&lt;/span&gt;

    &lt;span class="n"&gt;msg&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;notification&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;data&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;object&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;sender&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;from&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;email&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;sender&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;AGENT_EMAIL&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;                     &lt;span class="c1"&gt;# guard 2: skip the agent's own sends
&lt;/span&gt;        &lt;span class="k"&gt;return&lt;/span&gt;

    &lt;span class="n"&gt;thread_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;thread_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;row&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lookup_send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;thread_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;               &lt;span class="c1"&gt;# guard 3: match to a tracked send
&lt;/span&gt;    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;replied&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;mark_replied&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;thread_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                &lt;span class="c1"&gt;# credit row.variant with a reply
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One thing the brief is right to flag: &lt;strong&gt;don't trust the webhook payload for the message body.&lt;/strong&gt; The docs disagree on whether the body is inline — the agent-accounts guide says fetch it, the general webhook doc says it's inline unless the message exceeds ~1 MB. They agree on the action, so do that: when you need the actual reply text, fetch the full message with &lt;code&gt;GET /v3/grants/{grant_id}/messages/{message_id}&lt;/code&gt;, and branch on the &lt;code&gt;message.created.truncated&lt;/code&gt; trigger name for the oversized case. For pure reply &lt;em&gt;attribution&lt;/em&gt; you don't even need the body — the &lt;code&gt;thread_id&lt;/code&gt; and &lt;code&gt;from&lt;/code&gt; are enough.&lt;/p&gt;

&lt;p&gt;If you'd rather verify a thread by hand while debugging, the CLI reads it directly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;nylas email threads show &amp;lt;thread-id&amp;gt; &amp;lt;NYLAS_GRANT_ID&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A webhook is the live path, but keep a periodic reconciliation poll too — list recent threads and re-check any tracked send that hasn't been marked replied. The same poll covers you if a grant is offline long enough to miss events. (You'll want a real send timestamp for windowing that poll; &lt;code&gt;date&lt;/code&gt; at the shell is fine for the lower bound while you're testing locally.)&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: call the winner — honestly
&lt;/h2&gt;

&lt;p&gt;Here's where most home-grown A/B tests go wrong, so this is the part to slow down on. &lt;strong&gt;Reply rate is a low-volume signal.&lt;/strong&gt; If variant A is 3 replies out of 40 and variant B is 5 out of 40, that gap is almost certainly noise. Two or three replies either way will flip it. Declaring a winner there is worse than not testing at all, because you'll act on a result that isn't real.&lt;/p&gt;

&lt;p&gt;A few rules I'd hold yourself to, all of which are your app's job, not Nylas's:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Set a minimum sample per variant before you start, and don't look until you hit it.&lt;/strong&gt; Peeking at results and stopping the moment one variant pulls ahead is the classic way to manufacture a fake winner. Pick the number first, then run to it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;For reply rates in the single-digit-percent range, "enough" is bigger than you think&lt;/strong&gt; — typically hundreds of sends per variant, not dozens, before a few points of difference means anything.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Run a real significance test&lt;/strong&gt; on the two proportions (a two-proportion z-test or a chi-square is plenty) rather than eyeballing the percentages. If you're not comfortable picking a test, the safe default is: bigger sample, larger observed gap, more skepticism.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# your stats — Nylas has no opinion on significance
&lt;/span&gt;&lt;span class="n"&gt;a_rate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;a_replies&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;a_sent&lt;/span&gt;
&lt;span class="n"&gt;b_rate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;b_replies&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;b_sent&lt;/span&gt;
&lt;span class="n"&gt;winner&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;A&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;a_rate&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;b_rate&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;B&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="c1"&gt;# but only report it once both a_sent and b_sent clear your pre-set minimum,
# and only if a significance test on the two proportions clears your threshold.
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The discipline is the product here. Nylas delivers the inbound reply events carrying a &lt;code&gt;thread_id&lt;/code&gt;, at least once per event — so your app does the dedup on the notification &lt;code&gt;id&lt;/code&gt; and your app matches each &lt;code&gt;thread_id&lt;/code&gt; back to its variant; Nylas neither dedupes nor attributes for you. Whether you turn those attributed replies into a trustworthy decision is then entirely about the sample-size and significance rules &lt;em&gt;you&lt;/em&gt; enforce before peeking.&lt;/p&gt;

&lt;h2&gt;
  
  
  Guardrails worth knowing
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Free-plan send cap:&lt;/strong&gt; 200 messages per account per day. A subject test that needs hundreds of sends per variant may take several days to fill — that's fine, just account for it in your "don't peek early" window.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Domain warm-up:&lt;/strong&gt; the ~4-week reputation ramp on a new domain can depress replies independently of your subject. Don't start the experiment that matters on a cold domain.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Inbound retention:&lt;/strong&gt; the free plan keeps inbox mail for 30 days. Reconcile and persist your reply tallies before that window closes if you're running a long test.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Variant mapping is yours to protect.&lt;/strong&gt; Since there's no metadata tag, a lost or corrupted &lt;code&gt;thread_id&lt;/code&gt; mapping means an un-attributable reply. Treat the &lt;code&gt;experiment_sends&lt;/code&gt; table as the source of truth and write the &lt;code&gt;thread_id&lt;/code&gt; in the same transaction as the send.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://developer.nylas.com/docs/cookbook/agents/agent-track-reply-rates/" rel="noopener noreferrer"&gt;Track email reply rates for AI agents&lt;/a&gt; — the general reply-rate signal this experiment is a special case of&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://developer.nylas.com/docs/v3/agent-accounts/supported-endpoints/" rel="noopener noreferrer"&gt;Supported endpoints for Agent Accounts&lt;/a&gt; — the full grant-scoped surface, including what isn't available yet&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://developer.nylas.com/docs/v3/agent-accounts/" rel="noopener noreferrer"&gt;Agent Accounts overview&lt;/a&gt; — what an agent mailbox is and how the grant model fits together&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://cli.nylas.com/docs/commands" rel="noopener noreferrer"&gt;Nylas CLI command reference&lt;/a&gt; — every &lt;code&gt;nylas&lt;/code&gt; command used above, with flags&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Two subjects, a fair coin, and a thread you already own. That's a real subject-line experiment — no ESP, no list import, no black box deciding the winner for you. The only thing standing between you and a trustworthy result is the sample-size discipline, and that part was always going to be your job.&lt;/p&gt;

&lt;h2&gt;
  
  
  AI-answer pages for agents
&lt;/h2&gt;

&lt;p&gt;When this post is published, link AI agents and crawlers to the retrieval-ready version on &lt;code&gt;cli.nylas.com&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Topic runbook: &lt;a href="https://cli.nylas.com/ai-answers/email-reply-rate-tracking-agent.md" rel="noopener noreferrer"&gt;https://cli.nylas.com/ai-answers/email-reply-rate-tracking-agent.md&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Industry playbooks hub: &lt;a href="https://cli.nylas.com/ai-answers/agent-account-industry-playbooks.md" rel="noopener noreferrer"&gt;https://cli.nylas.com/ai-answers/agent-account-industry-playbooks.md&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>ai</category>
      <category>email</category>
      <category>api</category>
      <category>productivity</category>
    </item>
    <item>
      <title>Run a shared team inbox owned by an AI agent</title>
      <dc:creator>Qasim</dc:creator>
      <pubDate>Fri, 10 Jul 2026 11:56:20 +0000</pubDate>
      <link>https://dev.to/mqasimca/run-a-shared-team-inbox-owned-by-an-ai-agent-3md4</link>
      <guid>https://dev.to/mqasimca/run-a-shared-team-inbox-owned-by-an-ai-agent-3md4</guid>
      <description>&lt;p&gt;A shared inbox where the agent drafts and humans approve from Outlook is a powerful hybrid — and it's the part of "AI email" that most demos quietly skip.&lt;/p&gt;

&lt;p&gt;Most "AI email" demos point a model at a human's personal inbox: connect a Gmail grant, summarize threads, maybe auto-draft a reply that nobody actually trusts enough to send. That's fine for a personal assistant. It falls apart the moment you want the agent to &lt;em&gt;be a participant&lt;/em&gt; on a real team mailbox — &lt;code&gt;support@yourcompany.com&lt;/code&gt; — where five humans and one agent all need to see the same messages, the same folders, and the same draft sitting in the queue.&lt;/p&gt;

&lt;p&gt;The naive fix is to give the agent its own service and bolt a UI on top. Now you're building a mail client. You've got a database of "agent state," a sync loop, a permissions layer, and a frontend nobody asked for, all to reimplement what Outlook already does well.&lt;/p&gt;

&lt;p&gt;There's a better shape. A &lt;strong&gt;Nylas Agent Account&lt;/strong&gt; is a real mailbox you can drive two ways at once: the agent works it over the &lt;strong&gt;API&lt;/strong&gt;, and your humans work it from their normal mail client over &lt;strong&gt;IMAP/SMTP&lt;/strong&gt;. Both surfaces read and write the &lt;em&gt;same&lt;/em&gt; storage. A draft the agent creates via the API shows up in the Drafts folder in Apple Mail. A message a human drags into "Needs review" in Outlook shows up in the API's &lt;code&gt;folders&lt;/code&gt; field within seconds. That bidirectional sync is the whole trick, and it's what makes genuine human+agent collaboration possible instead of two systems fighting over one inbox.&lt;/p&gt;

&lt;p&gt;I work on the Nylas CLI, so the terminal commands below are the exact ones I reach for when I'm wiring this up. Every step shows the raw &lt;code&gt;curl&lt;/code&gt; call and the &lt;code&gt;nylas&lt;/code&gt; equivalent, because you'll want the API in your service and the CLI on your laptop.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you actually get
&lt;/h2&gt;

&lt;p&gt;An Agent Account is just a &lt;strong&gt;grant&lt;/strong&gt; — the same &lt;code&gt;grant_id&lt;/code&gt; abstraction you already use for every other Nylas mailbox. Nothing new to learn on the data plane:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Agent side (API):&lt;/strong&gt; Messages, Drafts, Folders, Threads, Attachments, Webhooks — every grant-scoped endpoint works exactly as it does for a Gmail or Microsoft grant.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Human side (IMAP/SMTP):&lt;/strong&gt; Outlook, Apple Mail, Thunderbird connect with the account email and an app password. People work the mailbox the way they always have.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Shared state:&lt;/strong&gt; flags, folder moves, drafts, and sends propagate across both surfaces and fire the same webhooks, no matter which side initiated them.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The model is: the agent triages and drafts, a human reviews and approves from the comfort of their mail client, and the queue between them is just a folder both sides can see.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this beats building your own review UI
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;You don't write a mail client. Outlook &lt;em&gt;is&lt;/em&gt; the review UI. Humans approve drafts where they already live.&lt;/li&gt;
&lt;li&gt;The agent doesn't poll a side-channel. It gets &lt;code&gt;message.created&lt;/code&gt; webhooks for inbound mail and reads/writes the real mailbox.&lt;/li&gt;
&lt;li&gt;There's one source of truth. No reconciliation between "agent's view" and "human's view" — it's literally one mailbox.&lt;/li&gt;
&lt;li&gt;It degrades gracefully. If your agent service is down, the humans still have a fully working mailbox. If a human is on vacation, the agent still triages.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One honest caveat up front, because it matters for how you scope this: &lt;strong&gt;this is not a multi-user ACL system.&lt;/strong&gt; There's one app password for the mailbox, and everyone who connects a client shares it. You can't grant Alice read-only and Bob send. If you need per-person permissions, that lives in &lt;em&gt;your&lt;/em&gt; application layer in front of the API, not in the shared inbox. I'll come back to the security tradeoff at the end.&lt;/p&gt;

&lt;h2&gt;
  
  
  Before you begin
&lt;/h2&gt;

&lt;p&gt;You need:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A Nylas application and API key.&lt;/li&gt;
&lt;li&gt;A registered sending domain — either your own custom domain or a Nylas &lt;code&gt;*.nylas.email&lt;/code&gt; trial subdomain. New domains warm over roughly four weeks, so don't provision one the morning of a launch.&lt;/li&gt;
&lt;li&gt;An email address on that domain for the inbox, e.g. &lt;code&gt;support@yourcompany.com&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The API base host in these examples is &lt;code&gt;https://api.us.nylas.com&lt;/code&gt; (use the EU host if your app is in the EU region), and every request authenticates with &lt;code&gt;Authorization: Bearer &amp;lt;NYLAS_API_KEY&amp;gt;&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;If this is your first Agent Account, read the &lt;a href="https://developer.nylas.com/docs/v3/getting-started/agent-accounts/" rel="noopener noreferrer"&gt;Agent Accounts quickstart&lt;/a&gt; first. The walkthrough below assumes you've at least skimmed it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Provision the shared mailbox
&lt;/h2&gt;

&lt;p&gt;An Agent Account is created with &lt;code&gt;POST /v3/connect/custom&lt;/code&gt;, &lt;code&gt;"provider": "nylas"&lt;/code&gt;, and the email on your registered domain. No OAuth, no refresh token — it's a Nylas-hosted mailbox. The optional top-level &lt;code&gt;name&lt;/code&gt; sets the display name humans see in the From field.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;--request&lt;/span&gt; POST &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--url&lt;/span&gt; &lt;span class="s2"&gt;"https://api.us.nylas.com/v3/connect/custom"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &amp;lt;NYLAS_API_KEY&amp;gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--data&lt;/span&gt; &lt;span class="s1"&gt;'{
    "provider": "nylas",
    "name": "Support Team",
    "settings": {
      "email": "support@yourcompany.com",
      "app_password": "Sh4redInb0xReview2024!"
    }
  }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notice I'm setting &lt;code&gt;app_password&lt;/code&gt; right in the create call. That's the credential your humans use to connect their mail clients (more on it next). The CLI does the same thing in one line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;nylas agent account create support@yourcompany.com &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--name&lt;/span&gt; &lt;span class="s2"&gt;"Support Team"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--app-password&lt;/span&gt; &lt;span class="s2"&gt;"Sh4redInb0xReview2024!"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The response gives you a &lt;code&gt;grant_id&lt;/code&gt;. That's the only identifier the agent side needs from here on.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Workspaces, briefly.&lt;/strong&gt; When you create the account, the API auto-creates a default workspace and policy for it. If you run several agent inboxes — say a sales-outreach group and a support-triage group with different send limits — you can drop this account into a specific workspace at creation by passing a &lt;code&gt;workspace_id&lt;/code&gt; in the &lt;code&gt;POST /v3/connect/custom&lt;/code&gt; body. The CLI &lt;code&gt;create&lt;/code&gt; command has no &lt;code&gt;--workspace&lt;/code&gt; flag; to attach a custom policy after the fact you use &lt;code&gt;nylas workspace update &amp;lt;workspace-id&amp;gt; --policy-id &amp;lt;policy-id&amp;gt;&lt;/code&gt;. For a single shared inbox the default workspace is fine — you can ignore this entirely.&lt;/p&gt;

&lt;h2&gt;
  
  
  Set the app password so humans can connect
&lt;/h2&gt;

&lt;p&gt;The app password is what turns an API-only mailbox into a human+agent one. Until you set it, Nylas rejects every IMAP &lt;code&gt;LOGIN&lt;/code&gt; and SMTP &lt;code&gt;AUTH&lt;/code&gt; attempt. I set it at creation above, but you can also set or rotate it on an existing account.&lt;/p&gt;

&lt;p&gt;Over the API, a &lt;code&gt;PATCH&lt;/code&gt; to the grant replaces the whole &lt;code&gt;settings&lt;/code&gt; object, so send the &lt;code&gt;email&lt;/code&gt; alongside the new password:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;--request&lt;/span&gt; PATCH &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--url&lt;/span&gt; &lt;span class="s2"&gt;"https://api.us.nylas.com/v3/grants/&amp;lt;NYLAS_GRANT_ID&amp;gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &amp;lt;NYLAS_API_KEY&amp;gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--data&lt;/span&gt; &lt;span class="s1"&gt;'{
    "settings": {
      "email": "support@yourcompany.com",
      "app_password": "Sh4redInb0xReview2024!"
    }
  }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The CLI has a dedicated command for it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;nylas agent account update support@yourcompany.com &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--app-password&lt;/span&gt; &lt;span class="s2"&gt;"Sh4redInb0xReview2024!"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The password must be 18–40 characters, printable ASCII, with at least one uppercase, one lowercase, and one digit. Nylas stores it as a bcrypt hash — you can't read it back, only reset it. Rotating it disconnects any live mail client, and each one reconnects after the user types the new value.&lt;/p&gt;

&lt;p&gt;Now hand your team these connection settings. They're exact, straight from the Nylas mail-client docs:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Setting&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;IMAP server&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;mail.us.nylas.email&lt;/code&gt; (US) · &lt;code&gt;mail.eu.nylas.email&lt;/code&gt; (EU)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;IMAP port&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;993&lt;/code&gt; (implicit TLS)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SMTP server&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;mail.us.nylas.email&lt;/code&gt; (US) · &lt;code&gt;mail.eu.nylas.email&lt;/code&gt; (EU)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SMTP port&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;465&lt;/code&gt; (implicit TLS) &lt;strong&gt;or&lt;/strong&gt; &lt;code&gt;587&lt;/code&gt; (STARTTLS)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Username&lt;/td&gt;
&lt;td&gt;The account email, e.g. &lt;code&gt;support@yourcompany.com&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Password&lt;/td&gt;
&lt;td&gt;The app password you just set&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Most clients auto-detect TLS. If yours asks, pick SSL/TLS for 993 and 465, STARTTLS for 587. With IMAP IDLE, clients see new mail pushed in without polling — so when the agent moves a message, the human's Outlook updates on its own.&lt;/p&gt;

&lt;h2&gt;
  
  
  The key insight: one mailbox, two surfaces
&lt;/h2&gt;

&lt;p&gt;This is the part worth internalizing, because everything else builds on it. &lt;em&gt;IMAP and the API are not two copies that sync — they're two doors into the same room.&lt;/em&gt; Every user-visible action flows through the same storage and fires the same webhook, regardless of which door it came through:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The agent marks a message read via the API → the human's client sees it as read.&lt;/li&gt;
&lt;li&gt;A human stars a message in Apple Mail → the API's &lt;code&gt;starred&lt;/code&gt; field flips to &lt;code&gt;true&lt;/code&gt; and a &lt;code&gt;message.updated&lt;/code&gt; webhook fires.&lt;/li&gt;
&lt;li&gt;A human drags a message to a folder in Outlook → the API's &lt;code&gt;folders&lt;/code&gt; array updates.&lt;/li&gt;
&lt;li&gt;The agent creates a draft via the API → it appears in the Drafts folder in every connected client.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Propagation is seconds, not minutes. That tight loop is exactly what you need for "agent drafts, human approves" to feel like one workflow instead of two disconnected ones.&lt;/p&gt;

&lt;h2&gt;
  
  
  Build the review queue out of folders
&lt;/h2&gt;

&lt;p&gt;The cleanest way to coordinate agent and humans is a shared work queue, and the simplest queue is a folder. Create a "Needs review" folder once; both sides can see it.&lt;/p&gt;

&lt;p&gt;Over the API:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;--request&lt;/span&gt; POST &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--url&lt;/span&gt; &lt;span class="s2"&gt;"https://api.us.nylas.com/v3/grants/&amp;lt;NYLAS_GRANT_ID&amp;gt;/folders"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &amp;lt;NYLAS_API_KEY&amp;gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--data&lt;/span&gt; &lt;span class="s1"&gt;'{ "name": "Needs review" }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or from the terminal:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;nylas email folders create &lt;span class="s2"&gt;"Needs review"&lt;/span&gt; &amp;lt;NYLAS_GRANT_ID&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both return the folder's ID. Stash it — the agent needs it to route messages, and it shows up as a mailbox in everyone's mail client automatically.&lt;/p&gt;

&lt;p&gt;When inbound mail arrives, the agent decides what it can't handle alone and moves those messages into the queue. A message's folder membership lives in its &lt;code&gt;folders&lt;/code&gt; array, so you update the message with &lt;code&gt;PUT /v3/grants/{grant_id}/messages/{message_id}&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;--request&lt;/span&gt; PUT &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--url&lt;/span&gt; &lt;span class="s2"&gt;"https://api.us.nylas.com/v3/grants/&amp;lt;NYLAS_GRANT_ID&amp;gt;/messages/&amp;lt;MESSAGE_ID&amp;gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &amp;lt;NYLAS_API_KEY&amp;gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--data&lt;/span&gt; &lt;span class="s1"&gt;'{ "folders": ["&amp;lt;NEEDS_REVIEW_FOLDER_ID&amp;gt;"] }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;From the CLI it's one command:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;nylas email move &amp;lt;MESSAGE_ID&amp;gt; &lt;span class="nt"&gt;--folder&lt;/span&gt; &amp;lt;NEEDS_REVIEW_FOLDER_ID&amp;gt; &amp;lt;NYLAS_GRANT_ID&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The instant that runs, the message disappears from INBOX and shows up under "Needs review" in your team's Outlook. The folder &lt;em&gt;is&lt;/em&gt; the handoff.&lt;/p&gt;

&lt;h2&gt;
  
  
  The agent drafts; a human sends
&lt;/h2&gt;

&lt;p&gt;Here's the core workflow. The agent never sends on its own — it writes a &lt;strong&gt;draft&lt;/strong&gt; and leaves it for a human. Drafting is a first-class endpoint: &lt;code&gt;POST /v3/grants/{grant_id}/drafts&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;--request&lt;/span&gt; POST &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--url&lt;/span&gt; &lt;span class="s2"&gt;"https://api.us.nylas.com/v3/grants/&amp;lt;NYLAS_GRANT_ID&amp;gt;/drafts"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &amp;lt;NYLAS_API_KEY&amp;gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--data&lt;/span&gt; &lt;span class="s1"&gt;'{
    "to": [{ "email": "customer@example.com" }],
    "subject": "Re: Trouble resetting my password",
    "body": "Hi there — I can see your account is locked after several failed attempts. I have unlocked it; you should be able to reset now. Let me know if it sticks."
  }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The CLI version, the one I use when I'm testing a prompt by hand:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;nylas email drafts create &amp;lt;NYLAS_GRANT_ID&amp;gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--to&lt;/span&gt; customer@example.com &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--subject&lt;/span&gt; &lt;span class="s2"&gt;"Re: Trouble resetting my password"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--body&lt;/span&gt; &lt;span class="s2"&gt;"Hi there — I can see your account is locked after several failed attempts. I have unlocked it; you should be able to reset now. Let me know if it sticks."&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That draft now sits in the Drafts folder. Because IMAP and the API share state, your reviewer opens Outlook, sees the agent's draft, edits a sentence if they want, and hits send — entirely inside their mail client. No bespoke approval UI, no extra service. The human's mail client is the approval step.&lt;/p&gt;

&lt;p&gt;If you'd rather the agent send &lt;em&gt;on approval&lt;/em&gt; — say a reviewer clicks "approve" in your own tool and your backend sends the agent's draft — you send the draft by ID with &lt;code&gt;POST /v3/grants/{grant_id}/drafts/{draft_id}&lt;/code&gt; (the send-draft endpoint), or from the CLI:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;nylas email drafts send &amp;lt;DRAFT_ID&amp;gt; &amp;lt;NYLAS_GRANT_ID&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both paths are valid; pick based on where you want the human's "yes" to live. For replies that need to stay threaded, the CLI has a shortcut that fetches the original to preserve the subject and threading headers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;nylas email reply &amp;lt;MESSAGE_ID&amp;gt; &lt;span class="nt"&gt;--body&lt;/span&gt; &lt;span class="s2"&gt;"Thanks for the patience — all set now."&lt;/span&gt; &amp;lt;NYLAS_GRANT_ID&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Read what came in
&lt;/h2&gt;

&lt;p&gt;The agent reads the mailbox like any grant. List recent messages with &lt;code&gt;GET /v3/grants/{grant_id}/messages&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;--request&lt;/span&gt; GET &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--url&lt;/span&gt; &lt;span class="s2"&gt;"https://api.us.nylas.com/v3/grants/&amp;lt;NYLAS_GRANT_ID&amp;gt;/messages?limit=10"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &amp;lt;NYLAS_API_KEY&amp;gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;nylas email list &amp;lt;NYLAS_GRANT_ID&amp;gt; &lt;span class="nt"&gt;--limit&lt;/span&gt; 10
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;To pull one message's full content, including its body, fetch it by ID — &lt;code&gt;GET /v3/grants/{grant_id}/messages/{message_id}&lt;/code&gt; — or &lt;code&gt;nylas email read &amp;lt;MESSAGE_ID&amp;gt; &amp;lt;NYLAS_GRANT_ID&amp;gt;&lt;/code&gt;. That by-id fetch matters for webhooks, which is the next piece.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wire up webhooks so the agent reacts in real time
&lt;/h2&gt;

&lt;p&gt;You don't want the agent polling. Inbound mail fires the standard &lt;code&gt;message.created&lt;/code&gt; event. One important detail: &lt;strong&gt;webhooks are application-scoped, not grant-scoped.&lt;/strong&gt; You subscribe once at the app level with &lt;code&gt;POST /v3/webhooks&lt;/code&gt;, and events for every grant in your app land at that one endpoint, each payload carrying the &lt;code&gt;grant_id&lt;/code&gt; so you can route it to the right inbox.&lt;/p&gt;

&lt;p&gt;A few things to get right:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Don't rely on the webhook payload for the body.&lt;/strong&gt; Fetch the full message with &lt;code&gt;GET /v3/grants/{grant_id}/messages/{message_id}&lt;/code&gt; when you need the content, and branch on &lt;code&gt;message.created.truncated&lt;/code&gt; — Nylas uses that event type for very large messages, at which point you re-fetch by ID anyway. Treating the by-id fetch as your source of truth keeps you correct in both cases.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dedupe on the top-level notification &lt;code&gt;id&lt;/code&gt;.&lt;/strong&gt; Delivery is at-least-once (up to three attempts per event), and that outer &lt;code&gt;id&lt;/code&gt; is constant across all retries of a single event — it's your dedup key. You can additionally guard on the inner message id (&lt;code&gt;data.object.id&lt;/code&gt;) so you never act twice on the same message.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Verify the signature.&lt;/strong&gt; Each webhook carries an &lt;code&gt;X-Nylas-Signature&lt;/code&gt; header: a hex HMAC-SHA256 of the &lt;em&gt;raw&lt;/em&gt; request body using your webhook secret. Compare it with a constant-time compare like Node's &lt;code&gt;crypto.timingSafeEqual&lt;/code&gt;, but check that both buffers are the same length first — it throws on a length mismatch. Locally, &lt;code&gt;nylas webhook verify&lt;/code&gt; runs the same check so you can sanity-test before deploying.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;With that in place, the loop closes: mail arrives → &lt;code&gt;message.created&lt;/code&gt; → agent fetches the body → agent triages, drafts, and moves to "Needs review" → a human approves from Outlook. Humans and agent, same mailbox, in real time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Guardrails and honest tradeoffs
&lt;/h2&gt;

&lt;p&gt;A few things I'd want a teammate to know before they ship this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;One password, one trust boundary.&lt;/strong&gt; Everyone with the app password has full read/write to the mailbox. There's no per-user ACL. Treat the app password like a shared production credential: store it in a secrets manager, rotate it on personnel changes, and remember that rotating it kicks every connected client. If you need real per-person permissions, build them in your own service in front of the API — the shared inbox can't do it for you.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No custom metadata as agent state.&lt;/strong&gt; Don't try to stash your agent's bookkeeping on the messages. Keep workflow state — "draft #4 is awaiting Alice's approval since 2pm" — in &lt;em&gt;your&lt;/em&gt; database, keyed by message and draft IDs. The mailbox holds mail; your app holds the workflow.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Free-plan limits are real.&lt;/strong&gt; 200 messages per account per day, 3 GB of storage per org, 30-day inbox retention (7 days for spam). Fine for a pilot; size your plan before a busy launch.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deletes go to Trash by default.&lt;/strong&gt; &lt;code&gt;DELETE /v3/grants/{grant_id}/messages/{message_id}&lt;/code&gt; trashes a message unless you pass &lt;code&gt;?hard_delete=true&lt;/code&gt;. The CLI &lt;code&gt;nylas email delete&lt;/code&gt; only trashes. The only true full wipe of a mailbox is deleting the grant itself.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of these are dealbreakers — they're just the edges. Knowing where they are means you won't design yourself into a corner expecting the inbox to be something it isn't.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://developer.nylas.com/docs/v3/agent-accounts/mail-clients/" rel="noopener noreferrer"&gt;Connect mail clients to an Agent Account&lt;/a&gt; — the full IMAP/SMTP reference, the folder mapping, and the sync table.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://developer.nylas.com/docs/v3/agent-accounts/workspaces/" rel="noopener noreferrer"&gt;How Agent Account workspaces work&lt;/a&gt; — group multiple agent inboxes and apply shared policies.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://developer.nylas.com/docs/v3/agent-accounts/" rel="noopener noreferrer"&gt;Agent Accounts overview&lt;/a&gt; — provisioning, policies, rules, and the rest of the data plane.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://cli.nylas.com/docs/commands" rel="noopener noreferrer"&gt;Nylas CLI command reference&lt;/a&gt; — every &lt;code&gt;nylas&lt;/code&gt; command shown above, with its flags.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Build the agent against the API, hand your team an app password, and let the queue between them be a folder both sides can see. That's the whole shared inbox.&lt;/p&gt;

&lt;h2&gt;
  
  
  AI-answer pages for agents
&lt;/h2&gt;

&lt;p&gt;When this post is published, link AI agents and crawlers to the retrieval-ready version on &lt;code&gt;cli.nylas.com&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Topic runbook: &lt;a href="https://cli.nylas.com/ai-answers/shared-inbox-api-for-ai-agents.md" rel="noopener noreferrer"&gt;https://cli.nylas.com/ai-answers/shared-inbox-api-for-ai-agents.md&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Industry playbooks hub: &lt;a href="https://cli.nylas.com/ai-answers/agent-account-industry-playbooks.md" rel="noopener noreferrer"&gt;https://cli.nylas.com/ai-answers/agent-account-industry-playbooks.md&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>ai</category>
      <category>email</category>
      <category>productivity</category>
      <category>devtools</category>
    </item>
    <item>
      <title>Qualify real estate leads with an email agent</title>
      <dc:creator>Qasim</dc:creator>
      <pubDate>Fri, 10 Jul 2026 01:27:51 +0000</pubDate>
      <link>https://dev.to/mqasimca/qualify-real-estate-leads-with-an-email-agent-gio</link>
      <guid>https://dev.to/mqasimca/qualify-real-estate-leads-with-an-email-agent-gio</guid>
      <description>&lt;p&gt;A property inquiry has a half-life measured in minutes. Someone is scrolling listings at 11pm, they fill out the "request more info" form on the three-bedroom on Maple, and they fire the same form off to four other listings on the same street. Whichever agent replies first — with answers, not a "thanks, I'll be in touch" — is the one who gets the showing. By the time a human checks email at 9am, that lead has already toured two other houses in their head.&lt;/p&gt;

&lt;p&gt;Most "AI for real estate" tools try to fix this by bolting a chatbot onto a website, or by pointing a model at the agent's personal Gmail and hoping it doesn't reply to the agent's mom. Both are awkward. The first never sees the email leads that come in through Zillow or a portal; the second makes the model a creepy ghostwriter inside a human's private inbox. What you actually want is for the listing inquiries to land somewhere that &lt;em&gt;is&lt;/em&gt; the agent — an address that can read the question, qualify the buyer, check its own calendar, and put a showing on it, autonomously, at 11pm, without a human awake.&lt;/p&gt;

&lt;p&gt;That's a &lt;strong&gt;Nylas Agent Account&lt;/strong&gt;: a grant with its own email address &lt;em&gt;and&lt;/em&gt; its own calendar. It's a real participant, not a bot reading over a human's shoulder. 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 loops — and I'll show the raw API call next to each one, because the CLI is just a friendly wrapper over the same &lt;code&gt;/v3/grants/{grant_id}/...&lt;/code&gt; endpoints.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the agent actually does
&lt;/h2&gt;

&lt;p&gt;Strip the AI out and a real estate lead loop is four moves:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;An inquiry arrives&lt;/strong&gt; — "Is the Maple St listing still available? What's the HOA?" — as email, on a thread.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The agent answers the question and starts qualifying&lt;/strong&gt; — budget, timeline, mortgage pre-approval. It replies in-thread asking what it still needs to know.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The lead replies&lt;/strong&gt; with some of those answers. The agent updates what it knows.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Once the lead is qualified, the agent books a showing&lt;/strong&gt; on its own calendar and sends the invite.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Three of those four moves map straight onto Nylas primitives — inbound mail, in-thread reply, calendar event. The fourth — deciding &lt;em&gt;whether a lead is qualified&lt;/em&gt; and &lt;em&gt;what to ask next&lt;/em&gt; — is your application code. Nylas hands you the email text; your LLM extracts "pre-approved up to $650k, wants to move by September" and decides the lead is ready to book. I'll be honest about that boundary the whole way through, because pretending the API does the reasoning is exactly the demo magic that falls apart in production.&lt;/p&gt;

&lt;p&gt;The nice thing is the data plane never changes. An Agent Account is &lt;em&gt;just a grant with a &lt;code&gt;grant_id&lt;/code&gt;&lt;/em&gt;. If you've used the Nylas email and calendar APIs, you already know every verb below. Nothing new to learn on the data plane.&lt;/p&gt;

&lt;p&gt;One thing to get out of the way first, because it's the most common wrong turn: &lt;strong&gt;Scheduler is not available for Agent Accounts.&lt;/strong&gt; No booking pages, no availability-config API, no &lt;code&gt;/v3/scheduling/*&lt;/code&gt;. That door is locked for this provider. What you have instead is the grant's own free/busy plus the Events API — and that's enough to book the showing yourself.&lt;/p&gt;

&lt;h2&gt;
  
  
  Before you begin
&lt;/h2&gt;

&lt;p&gt;You need:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;An Agent Account (a grant). The &lt;a href="https://developer.nylas.com/docs/v3/getting-started/agent-accounts/" rel="noopener noreferrer"&gt;quickstart&lt;/a&gt; walks through it; the short version is one &lt;code&gt;POST /v3/connect/custom&lt;/code&gt; with &lt;code&gt;"provider": "nylas"&lt;/code&gt; and an email on a registered domain — no refresh token, no OAuth dance:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;  curl &lt;span class="nt"&gt;--request&lt;/span&gt; POST &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--url&lt;/span&gt; &lt;span class="s2"&gt;"https://api.us.nylas.com/v3/connect/custom"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$NYLAS_API_KEY&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--data&lt;/span&gt; &lt;span class="s1"&gt;'{
      "provider": "nylas",
      "name": "Maple Realty Listings",
      "settings": { "email": "listings@yourbrokerage.com" }
    }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or, from the CLI:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;  nylas agent account create listings@yourbrokerage.com &lt;span class="nt"&gt;--name&lt;/span&gt; &lt;span class="s2"&gt;"Maple Realty Listings"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;Its &lt;code&gt;grant_id&lt;/code&gt;, and a Nylas API key exported as &lt;code&gt;NYLAS_API_KEY&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;A &lt;code&gt;message.created&lt;/code&gt; webhook so the agent reacts within seconds of an inquiry — that responsiveness is the whole selling point.&lt;/li&gt;
&lt;li&gt;A database. &lt;strong&gt;Lead state lives in your DB, not on the message.&lt;/strong&gt; Agent Accounts don't support custom metadata on messages, so the budget, timeline, and pre-approval status you extract have to live in your own store, keyed by &lt;code&gt;thread_id&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;New to all this? Read &lt;a href="https://developer.nylas.com/docs/v3/agent-accounts/" rel="noopener noreferrer"&gt;What are Agent Accounts&lt;/a&gt; first. All API examples use the US base host &lt;code&gt;https://api.us.nylas.com&lt;/code&gt; and a bearer token — swap in &lt;code&gt;api.eu.nylas.com&lt;/code&gt; for the EU region.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: Receive the inquiry
&lt;/h2&gt;

&lt;p&gt;Inbound mail to the agent fires the standard &lt;code&gt;message.created&lt;/code&gt; webhook. &lt;strong&gt;Webhooks are application-scoped, not grant-scoped&lt;/strong&gt; — you subscribe once at the app level with &lt;code&gt;POST /v3/webhooks&lt;/code&gt;, and events for every grant arrive at that one endpoint. Each payload carries a &lt;code&gt;grant_id&lt;/code&gt; you filter on, so an app running ten listing agents still has a single webhook URL.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;--request&lt;/span&gt; POST &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--url&lt;/span&gt; &lt;span class="s2"&gt;"https://api.us.nylas.com/v3/webhooks"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$NYLAS_API_KEY&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--data&lt;/span&gt; &lt;span class="s1"&gt;'{
    "trigger_types": ["message.created"],
    "webhook_url": "https://leads.yourbrokerage.com/webhooks/nylas",
    "description": "Listing inquiry handler"
  }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The CLI does the same in one line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;nylas webhook create &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--url&lt;/span&gt; &lt;span class="s2"&gt;"https://leads.yourbrokerage.com/webhooks/nylas"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--triggers&lt;/span&gt; message.created &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--description&lt;/span&gt; &lt;span class="s2"&gt;"Listing inquiry handler"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things to bake into the handler before you do anything clever:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Dedupe on the notification &lt;code&gt;id&lt;/code&gt;.&lt;/strong&gt; Nylas guarantees at-least-once delivery — the same event can arrive up to three times. The top-level notification &lt;code&gt;id&lt;/code&gt; is constant across all retries of one event, so that's your delivery dedup key. Drop it in a &lt;code&gt;seen_notifications&lt;/code&gt; set with a TTL and bail early on repeats. (You can additionally guard on the inner &lt;code&gt;data.object.id&lt;/code&gt;, the message id, so you never act twice on the same message even across distinct events.)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don't rely on the webhook payload for the body.&lt;/strong&gt; Whether the body ships inline depends on size, so don't build on it. When you need the full message, fetch it by id with &lt;code&gt;GET /v3/grants/{grant_id}/messages/{message_id}&lt;/code&gt;, and branch on &lt;code&gt;message.created.truncated&lt;/code&gt; — that variant fires for large messages and never includes the body, so re-fetch.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Express handler — verify X-Nylas-Signature first, then:&lt;/span&gt;
&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/webhooks/nylas&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;end&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// ack fast; Nylas retries on non-2xx&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;message.created&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;message.created.truncated&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;grant_id&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;AGENT_GRANT_ID&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="c1"&gt;// Delivery dedup: notification id is constant across retries.&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;seen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;markSeen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;handleInquiry&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;object&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// {id, thread_id, ...} — fetch body next&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 2: Read the message
&lt;/h2&gt;

&lt;p&gt;The webhook gave you summary fields and a &lt;code&gt;thread_id&lt;/code&gt;. To extract budget and intent, you need the body — fetch it by id:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;--request&lt;/span&gt; GET &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--url&lt;/span&gt; &lt;span class="s2"&gt;"https://api.us.nylas.com/v3/grants/&amp;lt;GRANT_ID&amp;gt;/messages/&amp;lt;MESSAGE_ID&amp;gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$NYLAS_API_KEY&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;From the CLI:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;nylas email &lt;span class="nb"&gt;read&lt;/span&gt; &amp;lt;MESSAGE_ID&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you want the whole conversation so far — useful once a lead has gone a few rounds — pull the thread instead:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;--request&lt;/span&gt; GET &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--url&lt;/span&gt; &lt;span class="s2"&gt;"https://api.us.nylas.com/v3/grants/&amp;lt;GRANT_ID&amp;gt;/threads/&amp;lt;THREAD_ID&amp;gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$NYLAS_API_KEY&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or, from the CLI:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;nylas email threads show &amp;lt;THREAD_ID&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What you do with that text is &lt;em&gt;your&lt;/em&gt; code. "Hi, saw the Maple St place, we're pre-approved around $600k and hoping to move before the school year — is it still available?" is natural language. Feed it to an LLM and have it return structured fields: &lt;code&gt;budget&lt;/code&gt;, &lt;code&gt;timeline&lt;/code&gt;, &lt;code&gt;preapproved&lt;/code&gt;, plus the actual question to answer. Persist that against the &lt;code&gt;thread_id&lt;/code&gt; in your DB. Don't pretend the API parses intent — it hands you text and you decide what it means.&lt;/p&gt;

&lt;p&gt;The qualification logic is the part you own end to end. A reasonable bar: you'll book a showing once you have a budget that overlaps the listing price, a timeline inside your window, and a pre-approval signal (or an explicit "paying cash"). Missing any of those, the agent's job in step 3 is to ask for it — politely, one or two questions at a time, not an interrogation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: Answer and qualify, in-thread
&lt;/h2&gt;

&lt;p&gt;The agent replies on the &lt;strong&gt;same thread&lt;/strong&gt; — answering the question that was asked &lt;em&gt;and&lt;/em&gt; asking for whatever qualification data is still missing. Replying in-thread keeps the whole conversation in one place in the lead's inbox, where they'll naturally reply again. Pass &lt;code&gt;reply_to_message_id&lt;/code&gt; and Nylas sets the &lt;code&gt;In-Reply-To&lt;/code&gt; and &lt;code&gt;References&lt;/code&gt; headers for you:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;--request&lt;/span&gt; POST &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--url&lt;/span&gt; &lt;span class="s2"&gt;"https://api.us.nylas.com/v3/grants/&amp;lt;GRANT_ID&amp;gt;/messages/send"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$NYLAS_API_KEY&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--data&lt;/span&gt; &lt;span class="s1"&gt;'{
    "reply_to_message_id": "&amp;lt;INBOUND_MESSAGE_ID&amp;gt;",
    "to": [{ "email": "buyer@example.com" }],
    "subject": "Re: Maple St listing",
    "body": "Yes, 142 Maple St is still available — HOA is $210/month and it includes water and trash. To line up a showing, two quick things: roughly what price range are you targeting, and are you already pre-approved with a lender?"
  }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The CLI version is shorter, because &lt;code&gt;reply&lt;/code&gt; fetches the original message and fills in recipient, subject, and threading automatically:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;nylas email reply &amp;lt;INBOUND_MESSAGE_ID&amp;gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--body&lt;/span&gt; &lt;span class="s2"&gt;"Yes, 142 Maple St is still available — HOA is &lt;/span&gt;&lt;span class="se"&gt;\$&lt;/span&gt;&lt;span class="s2"&gt;210/month and it includes water and trash. To line up a showing, two quick things: roughly what price range are you targeting, and are you already pre-approved with a lender?"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The wording — what to answer, which qualification question to lead with, how warm to sound — is generated by your LLM from the gaps in the lead record you saved in step 2. The send and the threading are Nylas; the phrasing and the &lt;em&gt;choice of what to ask&lt;/em&gt; are yours.&lt;/p&gt;

&lt;p&gt;This is the after-hours payoff. That reply goes out at 11:04pm, four minutes after the inquiry, while a human agent is asleep and the four competing listings sit unanswered. By the time anyone wakes up, this lead is mid-conversation with you.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: Loop on the reply until qualified
&lt;/h2&gt;

&lt;p&gt;The lead replies: "We're pre-approved up to $650k and want to be in before September." That fires another &lt;code&gt;message.created&lt;/code&gt; on the &lt;strong&gt;same thread&lt;/strong&gt;. Your handler dedupes on the notification id (step 1), looks up the &lt;code&gt;thread_id&lt;/code&gt; in your DB, sees a lead mid-qualification, fetches the body (step 2), and merges the new facts into the record.&lt;/p&gt;

&lt;p&gt;Now your code re-checks the bar. Still missing something? Reply again asking for it (step 3). Got everything — budget overlaps, timeline fits, pre-approved? The lead is qualified. Move to booking.&lt;/p&gt;

&lt;p&gt;Two things keep this survivable in production:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;State lives on the thread, in your DB.&lt;/strong&gt; Map &lt;code&gt;thread_id&lt;/code&gt; to the lead's qualification record — what you know, what you've asked, how many rounds you've run. Keep it durable; these conversations span hours or days. There's no custom-metadata field on the message to lean on, so this is on you.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cap the rounds.&lt;/strong&gt; Decide what happens after, say, three exchanges with no progress: hand off to a human agent, or send a "happy to answer more by phone" close and stop. Open-ended LLM-on-LLM back-and-forth burns your daily send quota for nothing — Agent Accounts cap outbound sends per day.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Step 5: Check the agent's own calendar
&lt;/h2&gt;

&lt;p&gt;The lead is qualified. Before the agent proposes a showing time, it checks &lt;em&gt;its own&lt;/em&gt; free/busy so it never offers a slot it's already booked into. The endpoint is &lt;code&gt;POST /v3/grants/{grant_id}/calendars/free-busy&lt;/code&gt; — give it a window and the agent's own address:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;--request&lt;/span&gt; POST &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--url&lt;/span&gt; &lt;span class="s2"&gt;"https://api.us.nylas.com/v3/grants/&amp;lt;GRANT_ID&amp;gt;/calendars/free-busy"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$NYLAS_API_KEY&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--data&lt;/span&gt; &lt;span class="s1"&gt;'{
    "start_time": 1750766400,
    "end_time": 1751025600,
    "emails": ["listings@yourbrokerage.com"]
  }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The response is a list of busy blocks for that address over the window. The &lt;em&gt;open&lt;/em&gt; slots are the gaps between them — computing those gaps is your code. Free/busy tells you what's taken; you decide what counts as a showing slot (daylight hours? 45-minute blocks with travel buffer?).&lt;/p&gt;

&lt;p&gt;The CLI wraps the same call:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;nylas calendar availability check &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--emails&lt;/span&gt; listings@yourbrokerage.com &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--start&lt;/span&gt; &lt;span class="s2"&gt;"this saturday 9am"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--end&lt;/span&gt; &lt;span class="s2"&gt;"this sunday 6pm"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A point worth stating plainly: free/busy here checks &lt;em&gt;the agent's&lt;/em&gt; calendar, not the buyer's. You're qualifying and booking over email precisely because you can't see the buyer's calendar. So the agent proposes a couple of slots it's confirmed open on its end and lets the buyer pick — exactly how a human agent texts "I've got Saturday at 10 or 2, which works?"&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 6: Book the showing on the agent's calendar
&lt;/h2&gt;

&lt;p&gt;The buyer picks a slot — or you offer two and they reply "Saturday at 10 works." &lt;em&gt;Now&lt;/em&gt; you create the event. Because the agent is a real calendar participant, creating with &lt;code&gt;notify_participants=true&lt;/code&gt; sends a normal ICS invitation from the agent's address. The buyer gets it in Gmail or Outlook and clicks Accept like any other invite. &lt;code&gt;calendar_id&lt;/code&gt; is a required query parameter; use &lt;code&gt;primary&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;--request&lt;/span&gt; POST &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--url&lt;/span&gt; &lt;span class="s2"&gt;"https://api.us.nylas.com/v3/grants/&amp;lt;GRANT_ID&amp;gt;/events?calendar_id=primary&amp;amp;notify_participants=true"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$NYLAS_API_KEY&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--data&lt;/span&gt; &lt;span class="s1"&gt;'{
    "title": "Showing — 142 Maple St",
    "location": "142 Maple St",
    "when": { "start_time": 1751032800, "end_time": 1751035500 },
    "participants": [
      { "email": "buyer@example.com" }
    ]
  }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The CLI creates the same event:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;nylas calendar events create &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--calendar&lt;/span&gt; primary &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--title&lt;/span&gt; &lt;span class="s2"&gt;"Showing — 142 Maple St"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--location&lt;/span&gt; &lt;span class="s2"&gt;"142 Maple St"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--start&lt;/span&gt; &lt;span class="s2"&gt;"2026-06-27 10:00"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--end&lt;/span&gt; &lt;span class="s2"&gt;"2026-06-27 10:45"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--participant&lt;/span&gt; buyer@example.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One honest gap to call out: &lt;code&gt;notify_participants&lt;/code&gt; is a query parameter on the API create call, and the CLI &lt;code&gt;events create&lt;/code&gt; doesn't expose a flag for it. So if you need the invitation email to fire — which you do, for a showing — book it through the API path, or follow the CLI create with an explicit confirmation email. Speaking of which: send one final reply on the thread — "You're confirmed for Saturday at 10am, 142 Maple St, see you there" — so the conversation closes in the buyer's inbox where it started, not as a silent calendar ping.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;--request&lt;/span&gt; POST &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--url&lt;/span&gt; &lt;span class="s2"&gt;"https://api.us.nylas.com/v3/grants/&amp;lt;GRANT_ID&amp;gt;/messages/send"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$NYLAS_API_KEY&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--data&lt;/span&gt; &lt;span class="s1"&gt;'{
    "to": [{ "email": "buyer@example.com" }],
    "subject": "Re: Maple St listing",
    "body": "You'&lt;/span&gt;&lt;span class="se"&gt;\'&lt;/span&gt;&lt;span class="s1"&gt;'re all set — Saturday at 10:00 AM, 142 Maple St. I'&lt;/span&gt;&lt;span class="se"&gt;\'&lt;/span&gt;&lt;span class="s1"&gt;'ve sent a calendar invite. See you there!"
  }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or, from the CLI:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;nylas email send &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--to&lt;/span&gt; buyer@example.com &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--subject&lt;/span&gt; &lt;span class="s2"&gt;"Re: Maple St listing"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--body&lt;/span&gt; &lt;span class="s2"&gt;"You're all set — Saturday at 10:00 AM, 142 Maple St. I've sent a calendar invite. See you there!"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After the create fires, &lt;code&gt;event.created&lt;/code&gt; lands on the agent's calendar; when the buyer clicks Accept, their RSVP flows back to the agent's mailbox and &lt;code&gt;event.updated&lt;/code&gt; fires with their status. The lead is now genuinely booked: a real showing on a real calendar, qualified and scheduled entirely over email, at 11pm, with no human in the loop.&lt;/p&gt;

&lt;h2&gt;
  
  
  Guardrails and honest tradeoffs
&lt;/h2&gt;

&lt;p&gt;A few things I'd build in before pointing this at real leads:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The qualification is yours, and it will be wrong sometimes.&lt;/strong&gt; Parsing "pre-approved around $600k" or "before the school year" out of free-text email is an LLM task, and LLMs misread numbers and dates. Don't auto-book off a shaky parse — when confidence is low, ask one clarifying question instead of guessing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Validate the slot one more time before you book.&lt;/strong&gt; Re-run free/busy right before the create call. A showing can get booked into a slot that filled up during the back-and-forth.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Every reply and invite counts against the send quota.&lt;/strong&gt; A lead that runs three qualification rounds plus a confirmation plus an invite is several sends. Cap your rounds — partly for sanity, partly for quota.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dedupe or you'll double-book.&lt;/strong&gt; Webhook redelivery and concurrent workers both re-trigger the handler. Dedupe on the notification &lt;code&gt;id&lt;/code&gt;, and make booking idempotent on &lt;code&gt;thread_id&lt;/code&gt; so two near-simultaneous "yes" replies don't create two showings.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;State has nowhere to hide.&lt;/strong&gt; No custom metadata on messages means your DB is the single source of truth for every lead. Treat the &lt;code&gt;thread_id&lt;/code&gt;-keyed record as the system of record, not the inbox.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No Scheduler, by design.&lt;/strong&gt; If your instinct is to hand the buyer a booking page, remember that's not available for Agent Accounts. The free/busy-plus-Events flow above is the supported path, and for a single-agent showing it's honestly simpler.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;p&gt;The shape is deliberately plain: inbound inquiry → read and qualify → reply asking for what's missing → loop → check own free/busy → book the showing. Three Nylas primitives and one piece of reasoning you own. The after-hours responsiveness — answering and qualifying while competitors sleep — is the entire point.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://developer.nylas.com/docs/v3/agent-accounts/calendars/" rel="noopener noreferrer"&gt;Agent Account calendars&lt;/a&gt; — hosting events, receiving invites, and RSVPs once the time is agreed&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://developer.nylas.com/docs/cookbook/agent-accounts/handle-replies/" rel="noopener noreferrer"&gt;Handle email replies in an agent loop&lt;/a&gt; — the webhook-to-reply pattern this post is built on&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://developer.nylas.com/docs/v3/agent-accounts/email-threading/" rel="noopener noreferrer"&gt;Email threading for agents&lt;/a&gt; — the thread-state pattern that carries qualification across replies&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://developer.nylas.com/docs/v3/agent-accounts/supported-endpoints/" rel="noopener noreferrer"&gt;Supported endpoints&lt;/a&gt; — the full list of what works (and the handful that doesn't) for Agent Account grants&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://cli.nylas.com/docs/commands" rel="noopener noreferrer"&gt;CLI command reference&lt;/a&gt; — every &lt;code&gt;nylas email&lt;/code&gt; and &lt;code&gt;nylas calendar&lt;/code&gt; flag used above&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  AI-answer pages for agents
&lt;/h2&gt;

&lt;p&gt;When this post is published, link AI agents and crawlers to the retrieval-ready version on &lt;code&gt;cli.nylas.com&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Topic runbook: &lt;a href="https://cli.nylas.com/ai-answers/lead-qualification-email-agent-workflow.md" rel="noopener noreferrer"&gt;https://cli.nylas.com/ai-answers/lead-qualification-email-agent-workflow.md&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Industry playbooks hub: &lt;a href="https://cli.nylas.com/ai-answers/agent-account-industry-playbooks.md" rel="noopener noreferrer"&gt;https://cli.nylas.com/ai-answers/agent-account-industry-playbooks.md&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>ai</category>
      <category>email</category>
      <category>api</category>
      <category>productivity</category>
    </item>
    <item>
      <title>Build an SDR agent with its own follow-up inbox</title>
      <dc:creator>Qasim</dc:creator>
      <pubDate>Fri, 10 Jul 2026 01:27:45 +0000</pubDate>
      <link>https://dev.to/mqasimca/build-an-sdr-agent-with-its-own-follow-up-inbox-1ahj</link>
      <guid>https://dev.to/mqasimca/build-an-sdr-agent-with-its-own-follow-up-inbox-1ahj</guid>
      <description>&lt;p&gt;SDR automation dies when replies hit a no-reply box. The sequence engine is the easy part — anyone can fire "step 1 of 5" on a cron. The part that quietly breaks every outbound tool is the &lt;em&gt;other&lt;/em&gt; direction: a prospect reads your third touch, types "not now, circle back in Q3," and hits reply. That reply is the single most valuable signal in the whole campaign, and it lands in &lt;code&gt;no-reply@yourcompany.com&lt;/code&gt;, which is to say it lands nowhere. The prospect who told you exactly when to follow up gets your "step 4" two days later anyway, because your sequence engine never heard them.&lt;/p&gt;

&lt;p&gt;The naive fix is to point an LLM at a human SDR's mailbox and let it draft. That works right up until you want the agent to &lt;em&gt;be&lt;/em&gt; a participant — to send under its own address, receive the reply under that same address, and decide what to do next without a person in the loop. A human's inbox is the wrong substrate for that. You don't want an autonomous sender borrowing a rep's OAuth grant and threading replies into the rep's personal inbox where a notification storm waits.&lt;/p&gt;

&lt;p&gt;What you want is an address the agent &lt;em&gt;owns&lt;/em&gt;: it sends the sequence, it receives "not now" and "send me pricing" and "unsubscribe," it classifies each one, routes it to the right next action, and — the piece every outbound tool forgets — &lt;em&gt;stops the sequence the moment a real reply arrives&lt;/em&gt;. That address is a &lt;strong&gt;Nylas Agent Account&lt;/strong&gt;. I work on the Nylas CLI, so the terminal commands below are the exact ones I reach for when wiring this up; the &lt;code&gt;curl&lt;/code&gt; calls beside them are what the CLI runs under the hood, so either drops straight into your stack.&lt;/p&gt;

&lt;h2&gt;
  
  
  What an Agent Account actually is
&lt;/h2&gt;

&lt;p&gt;The thing to internalize before you write a line of code: an Agent Account is just a &lt;strong&gt;grant&lt;/strong&gt;. Same &lt;code&gt;grant_id&lt;/code&gt;, same &lt;code&gt;/v3/grants/{grant_id}/*&lt;/code&gt; endpoints, same &lt;code&gt;Authorization: Bearer &amp;lt;NYLAS_API_KEY&amp;gt;&lt;/code&gt; header as any connected Gmail or Microsoft mailbox you've ever integrated. There's no separate "outbound" SDK, no new auth model, no second data plane. If you've ever sent a message or listed a thread from a Nylas grant, you already know the entire data plane for this post. The grant abstraction is the spine here — nothing new to learn on the read/write side.&lt;/p&gt;

&lt;p&gt;What's different is the &lt;em&gt;control&lt;/em&gt; plane. There's no human OAuth and no refresh token to babysit. You provision the mailbox yourself on a domain you own (or a &lt;code&gt;*.nylas.email&lt;/code&gt; trial subdomain), and Nylas hosts it. For prospecting that's exactly right: &lt;code&gt;outbound@yourcompany.com&lt;/code&gt; belongs to the &lt;em&gt;system&lt;/em&gt;, not to a rep who might leave and take their refresh token with them. And because it's a real mailbox, replies land back in it as ordinary inbound messages you can read and answer — which is the whole reason this beats a transactional send.&lt;/p&gt;

&lt;p&gt;One tradeoff to flag up front, because it shapes the architecture: Agent Accounts don't support custom &lt;code&gt;metadata&lt;/code&gt; on messages. You can't stamp &lt;code&gt;{ "prospect_id": "P-901", "step": 3 }&lt;/code&gt; onto the outbound email and read it back later to figure out where someone sits in the sequence. &lt;em&gt;That's fine, and it's the right design anyway.&lt;/em&gt; Your sequence state — who's on which step, who replied, who's paused — was always going to live in your own database. The email is the message; your DB is the state machine. Hold that thought, because it's also where "stop on reply" lives.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this beats a one-way sequencer
&lt;/h2&gt;

&lt;p&gt;Let me be fair to the blast tools first. If your outbound is a pure one-way drip — five touches, no intention of reading what comes back — a transactional ESP or a classic sequencer is a fine fit. The Agent Account earns its keep the moment a reply needs a &lt;em&gt;decision&lt;/em&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Replies go somewhere, and get classified.&lt;/strong&gt; "Not now," "send me pricing," and "unsubscribe" are three completely different next actions, and a no-reply box collapses all of them into silence. The agent reads each reply and routes it: snooze the sequence, send the one-pager, suppress the address forever.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The sequence stops on engagement.&lt;/strong&gt; A one-way drip keeps firing on schedule. A conversational agent keys off the &lt;code&gt;message.created&lt;/code&gt; webhook — the instant a reply lands on a tracked thread, you flip that prospect's state to &lt;code&gt;paused&lt;/code&gt; and the next touch never sends. Nobody gets "step 4" an hour after they replied.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Context survives across steps.&lt;/strong&gt; Every send and reply rides the same thread, so the prospect sees one coherent conversation, not five disconnected blasts with the same footer.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It's one grant.&lt;/strong&gt; The same Agent Account that sends step 1 receives the reply, sends the pricing follow-up, and logs the unsubscribe. One mailbox, one auth model.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Here's the honest line: the LLM does the &lt;em&gt;classification&lt;/em&gt;, and your code does the &lt;em&gt;routing&lt;/em&gt;. Nylas doesn't classify "not now" vs "send pricing" for you — it delivers the reply, you fetch the body, and your own model or rules decide what bucket it's in. Don't let anyone tell you the platform reads intent. It moves mail; you read intent.&lt;/p&gt;

&lt;h2&gt;
  
  
  Before you begin
&lt;/h2&gt;

&lt;p&gt;You'll need:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A Nylas API key from the dashboard, exported as &lt;code&gt;NYLAS_API_KEY&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;A domain you own for the mailbox, or a Nylas &lt;code&gt;*.nylas.email&lt;/code&gt; trial subdomain. &lt;strong&gt;This matters more for cold outbound than for any other use case.&lt;/strong&gt; New domains warm over roughly four weeks — start low-volume and ramp, because a brand-new domain blasting a cold list is how you land in spam folders on day one.&lt;/li&gt;
&lt;li&gt;The &lt;a href="https://cli.nylas.com/docs/commands" rel="noopener noreferrer"&gt;Nylas CLI&lt;/a&gt; if you want the terminal path: &lt;code&gt;nylas init&lt;/code&gt; once to store your key.&lt;/li&gt;
&lt;li&gt;A public HTTPS endpoint for the webhook. A tunnel (cloudflared) is fine in development.&lt;/li&gt;
&lt;li&gt;Your own data plane: a prospect store, a sequence-state table, and an LLM or ruleset for reply classification.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;:::info&lt;br&gt;
&lt;strong&gt;New to Agent Accounts?&lt;/strong&gt; Start with &lt;a href="https://developer.nylas.com/docs/v3/agent-accounts/" rel="noopener noreferrer"&gt;What are Agent Accounts&lt;/a&gt; for the product overview, then come back here.&lt;br&gt;
:::&lt;/p&gt;
&lt;h2&gt;
  
  
  Provision the outbound mailbox
&lt;/h2&gt;

&lt;p&gt;Create the grant first. The API call is &lt;code&gt;POST /v3/connect/custom&lt;/code&gt; with &lt;code&gt;provider: "nylas"&lt;/code&gt; and the email on your domain. The optional top-level &lt;code&gt;name&lt;/code&gt; sets the display name prospects see.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;--request&lt;/span&gt; POST &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--url&lt;/span&gt; &lt;span class="s1"&gt;'https://api.us.nylas.com/v3/connect/custom'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s1"&gt;'Authorization: Bearer '&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$NYLAS_API_KEY&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s1"&gt;'Content-Type: application/json'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--data&lt;/span&gt; &lt;span class="s1"&gt;'{
    "provider": "nylas",
    "name": "Acme Outbound",
    "settings": { "email": "outbound@yourcompany.com" }
  }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The response carries the &lt;code&gt;grant_id&lt;/code&gt; — the only identifier you carry forward. Nylas also auto-creates a default workspace and policy for the account, which matters later when we add a suppression list.&lt;/p&gt;

&lt;p&gt;The CLI collapses connector setup, grant creation, and the default workspace into one command:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;nylas agent account create outbound@yourcompany.com &lt;span class="nt"&gt;--name&lt;/span&gt; &lt;span class="s2"&gt;"Acme Outbound"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the &lt;code&gt;nylas&lt;/code&gt; connector doesn't exist in your app yet, this creates it first, then the grant. Add &lt;code&gt;--json&lt;/code&gt; to capture the &lt;code&gt;grant_id&lt;/code&gt; for a script. Export it as &lt;code&gt;GRANT_ID&lt;/code&gt; for the rest of this post.&lt;/p&gt;

&lt;h2&gt;
  
  
  Subscribe to inbound replies
&lt;/h2&gt;

&lt;p&gt;The sequence needs to know when a prospect writes back. Inbound mail fires the standard &lt;code&gt;message.created&lt;/code&gt; webhook — the same trigger every Nylas inbox uses. One thing to get right conceptually: &lt;strong&gt;webhooks are application-scoped, not grant-scoped.&lt;/strong&gt; You subscribe once at the app level against &lt;code&gt;/v3/webhooks&lt;/code&gt;, and events for &lt;em&gt;every&lt;/em&gt; grant in your app arrive at that one endpoint, each payload carrying a &lt;code&gt;grant_id&lt;/code&gt; you filter on. You don't register a webhook per Agent Account.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;--request&lt;/span&gt; POST &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--url&lt;/span&gt; &lt;span class="s1"&gt;'https://api.us.nylas.com/v3/webhooks'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s1"&gt;'Authorization: Bearer '&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$NYLAS_API_KEY&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s1"&gt;'Content-Type: application/json'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--data&lt;/span&gt; &lt;span class="s1"&gt;'{
    "trigger_types": ["message.created"],
    "webhook_url": "https://api.yourcompany.com/hooks/outbound",
    "description": "SDR sequence replies"
  }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;From the terminal, list the available triggers, register the webhook, then stand up a local listener to watch real events during development:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;nylas webhook triggers
nylas webhook create &lt;span class="nt"&gt;--url&lt;/span&gt; https://api.yourcompany.com/hooks/outbound &lt;span class="nt"&gt;--triggers&lt;/span&gt; message.created
nylas webhook server &lt;span class="nt"&gt;--port&lt;/span&gt; 4000 &lt;span class="nt"&gt;--tunnel&lt;/span&gt; cloudflared &lt;span class="nt"&gt;--secret&lt;/span&gt; &amp;lt;webhook-secret&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;nylas webhook server&lt;/code&gt; runs a local endpoint behind a cloudflared tunnel and verifies the &lt;code&gt;X-Nylas-Signature&lt;/code&gt; HMAC on each event, so you can trigger a real reply and watch the payload land before you ship the production handler. In production, verify that signature yourself: it's a hex HMAC-SHA256 of the &lt;em&gt;raw&lt;/em&gt; request body using your webhook secret. If you compare with a constant-time function like Node's &lt;code&gt;crypto.timingSafeEqual&lt;/code&gt;, guard that both buffers are equal length first — it throws on a length mismatch.&lt;/p&gt;

&lt;h2&gt;
  
  
  Send the sequence
&lt;/h2&gt;

&lt;p&gt;A prospecting sequence is a handful of touches spaced over days: an intro, a value-add follow-up, a case study, a last-call. Each one is a normal grant send — &lt;code&gt;POST /v3/grants/{grant_id}/messages/send&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Here's the first step as a &lt;code&gt;curl&lt;/code&gt; call:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;--request&lt;/span&gt; POST &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--url&lt;/span&gt; &lt;span class="s2"&gt;"https://api.us.nylas.com/v3/grants/&lt;/span&gt;&lt;span class="nv"&gt;$GRANT_ID&lt;/span&gt;&lt;span class="s2"&gt;/messages/send"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s1"&gt;'Authorization: Bearer '&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$NYLAS_API_KEY&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s1"&gt;'Content-Type: application/json'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--data&lt;/span&gt; &lt;span class="s1"&gt;'{
    "to": [{ "email": "dana@acme.com", "name": "Dana Lee" }],
    "subject": "Quick question about Acme'&lt;/span&gt;&lt;span class="se"&gt;\'&lt;/span&gt;&lt;span class="s1"&gt;'s onboarding flow",
    "body": "Hi Dana — noticed Acme onboards a lot of new accounts each week. Worth a 15-minute look at how teams like yours cut that time? Reply here and I'&lt;/span&gt;&lt;span class="se"&gt;\'&lt;/span&gt;&lt;span class="s1"&gt;'ll send specifics."
  }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same send from the CLI:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;nylas email send &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$GRANT_ID&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--to&lt;/span&gt; dana@acme.com &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--subject&lt;/span&gt; &lt;span class="s2"&gt;"Quick question about Acme's onboarding flow"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--body&lt;/span&gt; &lt;span class="s2"&gt;"Hi Dana — worth a 15-minute look? Reply here and I'll send specifics."&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The response includes the message's &lt;code&gt;id&lt;/code&gt; and &lt;code&gt;thread_id&lt;/code&gt;. &lt;strong&gt;Persist both, keyed to the prospect, the moment the send returns:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;prospect P-901 → { thread_id, last_message_id, step: 1, state: "active", next_send_at }
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That row is your sequence state. There's no metadata on the email to lean on, so this DB record is the only place that knows Dana is on step 1 of prospect P-901's sequence and that step 2 is due in two days.&lt;/p&gt;

&lt;h3&gt;
  
  
  Schedule the later steps instead of running a cron
&lt;/h3&gt;

&lt;p&gt;You don't have to hold step 2 in a job queue and fire it yourself. The send endpoint accepts a &lt;code&gt;send_at&lt;/code&gt; (Unix timestamp) and Nylas queues the message server-side:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;--request&lt;/span&gt; POST &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--url&lt;/span&gt; &lt;span class="s2"&gt;"https://api.us.nylas.com/v3/grants/&lt;/span&gt;&lt;span class="nv"&gt;$GRANT_ID&lt;/span&gt;&lt;span class="s2"&gt;/messages/send"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s1"&gt;'Authorization: Bearer '&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$NYLAS_API_KEY&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s1"&gt;'Content-Type: application/json'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--data&lt;/span&gt; &lt;span class="s1"&gt;'{
    "to": [{ "email": "dana@acme.com", "name": "Dana Lee" }],
    "subject": "Re: Quick question about Acme'&lt;/span&gt;&lt;span class="se"&gt;\'&lt;/span&gt;&lt;span class="s1"&gt;'s onboarding flow",
    "body": "Following up — here is a two-minute breakdown of the onboarding numbers I mentioned.",
    "send_at": 1751385600
  }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;To compute a real timestamp instead of hardcoding one, the CLI's &lt;code&gt;date&lt;/code&gt; is your friend:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;date&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt;+2d +%s   &lt;span class="c"&gt;# macOS: two days from now as a Unix timestamp&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The CLI takes a duration or a clock time directly and computes the timestamp for you — &lt;code&gt;30m&lt;/code&gt;, &lt;code&gt;2h&lt;/code&gt;, &lt;code&gt;1d&lt;/code&gt;, a time like &lt;code&gt;14:30&lt;/code&gt;, or a phrase like &lt;code&gt;"tomorrow 9am"&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;nylas email send &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$GRANT_ID&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--to&lt;/span&gt; dana@acme.com &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--subject&lt;/span&gt; &lt;span class="s2"&gt;"Re: Quick question about Acme's onboarding flow"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--body&lt;/span&gt; &lt;span class="s2"&gt;"Following up — here is a two-minute breakdown."&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--schedule&lt;/span&gt; 2d
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A scheduled send returns a &lt;code&gt;schedule_id&lt;/code&gt; you can store and cancel later — which matters a lot, because canceling the queued step is exactly how you stop the sequence when the prospect replies. More on that next.&lt;/p&gt;

&lt;h2&gt;
  
  
  Catch the reply and classify it
&lt;/h2&gt;

&lt;p&gt;This is the behavior that separates a conversation from a pestering. The signal is the &lt;code&gt;message.created&lt;/code&gt; webhook: a reply on a thread you're tracking means the prospect engaged, and the rest of the touches should not fire blindly.&lt;/p&gt;

&lt;p&gt;Two things to get right in the handler before any classification logic:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;message.created&lt;/code&gt; fires for outbound too.&lt;/strong&gt; When &lt;em&gt;your&lt;/em&gt; agent sends a reply, the webhook fires for that sent message as well. Filter on the sender at the top of the handler so the agent never reacts to its own mail.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dedup on the notification &lt;code&gt;id&lt;/code&gt;.&lt;/strong&gt; The API guarantees at-least-once delivery — the same event can arrive up to three times. Dedupe on the &lt;em&gt;top-level notification &lt;code&gt;id&lt;/code&gt;&lt;/em&gt;, which stays constant across all retries of one event; that's the delivery dedup key. You can additionally guard on the inner &lt;code&gt;data.object.id&lt;/code&gt; (the message id) so you never act twice on the same message.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And on the body: &lt;strong&gt;don't rely on the webhook payload for the body — fetch the full message by id when you need it.&lt;/strong&gt; The docs are inconsistent on whether the body is inline, so write the code that's always correct: pull the message from &lt;code&gt;GET /v3/grants/{grant_id}/messages/{message_id}&lt;/code&gt;, and branch on &lt;code&gt;message.created.truncated&lt;/code&gt; (the type Nylas sends when a message exceeds ~1 MB and the body is omitted). Fetch-by-id is the safe framing every time.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;--request&lt;/span&gt; GET &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--url&lt;/span&gt; &lt;span class="s2"&gt;"https://api.us.nylas.com/v3/grants/&lt;/span&gt;&lt;span class="nv"&gt;$GRANT_ID&lt;/span&gt;&lt;span class="s2"&gt;/messages/&lt;/span&gt;&lt;span class="nv"&gt;$MESSAGE_ID&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s1"&gt;'Authorization: Bearer '&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$NYLAS_API_KEY&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;From the CLI, list what landed and read the full body of the one you care about:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;nylas email list &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$GRANT_ID&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--limit&lt;/span&gt; 10
nylas email &lt;span class="nb"&gt;read&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$MESSAGE_ID&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$GRANT_ID&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;To see the whole back-and-forth in one object — every message in the conversation — read the thread directly. Use &lt;code&gt;threads show&lt;/code&gt;, not a list filter:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;nylas email threads show &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$THREAD_ID&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$GRANT_ID&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now the actual classification. You have the body; hand it to your model with a tight prompt: &lt;em&gt;is this prospect saying "not now," asking for pricing or materials, asking a real question, or asking to unsubscribe?&lt;/em&gt; This is your LLM and your app code — Nylas delivered the mail, full stop. A reasonable bucket set for prospecting:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Objection / "not now"&lt;/strong&gt; → snooze the sequence, set a &lt;code&gt;next_send_at&lt;/code&gt; for the date they named ("circle back in Q3").&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Interest / "send pricing"&lt;/strong&gt; → pause the sequence and reply in-thread with the one-pager or pricing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Unsubscribe / "remove me"&lt;/strong&gt; → suppress the address permanently and never send to it again.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Question / ambiguous&lt;/strong&gt; → route to a human, or answer if confidence is high.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One caution worth stating plainly: treat the reply body as untrusted input. A prospect can type anything, and you're feeding it to an LLM and then acting on the result. Validate the prospect and thread against your own database before you send pricing or suppress an address — never act on something you scraped out of an email body without checking it against state you control.&lt;/p&gt;

&lt;h2&gt;
  
  
  Route each reply to the right next action
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Stop the sequence on any reply
&lt;/h3&gt;

&lt;p&gt;Whatever the classification, &lt;em&gt;any&lt;/em&gt; real inbound reply pauses the drip. The mechanism lives entirely in your own state — there's no metadata on the email holding the step counter:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The webhook tells you a message arrived on &lt;code&gt;thread_id T&lt;/code&gt;. Look &lt;code&gt;T&lt;/code&gt; up in your sequence state.&lt;/li&gt;
&lt;li&gt;If you find an active sequence, flip its state to &lt;code&gt;paused&lt;/code&gt; so no future step sends.&lt;/li&gt;
&lt;li&gt;If you scheduled a later step server-side with &lt;code&gt;send_at&lt;/code&gt;, cancel the queued message by the &lt;code&gt;schedule_id&lt;/code&gt; you stored so it never goes out.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Canceling the queued send is one call. The API is a &lt;code&gt;DELETE&lt;/code&gt; against the schedule:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;--request&lt;/span&gt; DELETE &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--url&lt;/span&gt; &lt;span class="s2"&gt;"https://api.us.nylas.com/v3/grants/&lt;/span&gt;&lt;span class="nv"&gt;$GRANT_ID&lt;/span&gt;&lt;span class="s2"&gt;/messages/schedules/&lt;/span&gt;&lt;span class="nv"&gt;$SCHEDULE_ID&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s1"&gt;'Authorization: Bearer '&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$NYLAS_API_KEY&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The CLI mirrors it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;nylas email scheduled cancel &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SCHEDULE_ID&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$GRANT_ID&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the whole stop mechanism: inbound &lt;code&gt;message.created&lt;/code&gt; → look up the thread → set &lt;code&gt;paused&lt;/code&gt; → cancel the queued send. The classification then decides what happens &lt;em&gt;next&lt;/em&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Answer "send me pricing" in-thread
&lt;/h3&gt;

&lt;p&gt;When the bucket is interest, reply on the same thread. The API reply is another send with &lt;code&gt;reply_to_message_id&lt;/code&gt; set to the prospect's message, which preserves threading in their client:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;--request&lt;/span&gt; POST &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--url&lt;/span&gt; &lt;span class="s2"&gt;"https://api.us.nylas.com/v3/grants/&lt;/span&gt;&lt;span class="nv"&gt;$GRANT_ID&lt;/span&gt;&lt;span class="s2"&gt;/messages/send"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s1"&gt;'Authorization: Bearer '&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$NYLAS_API_KEY&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s1"&gt;'Content-Type: application/json'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--data&lt;/span&gt; &lt;span class="s1"&gt;'{
    "to": [{ "email": "dana@acme.com", "name": "Dana Lee" }],
    "subject": "Re: Quick question about Acme'&lt;/span&gt;&lt;span class="se"&gt;\'&lt;/span&gt;&lt;span class="s1"&gt;'s onboarding flow",
    "body": "Happy to — here is our pricing overview and a short case study from a team your size. Want me to set up 15 minutes to walk through it?",
    "reply_to_message_id": "'&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$MESSAGE_ID&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s1"&gt;'"
  }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The CLI wraps all of that — it fetches the original to populate the recipient and subject, and threads via &lt;code&gt;reply_to_message_id&lt;/code&gt; for you:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;nylas email reply &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$MESSAGE_ID&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$GRANT_ID&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--body&lt;/span&gt; &lt;span class="s2"&gt;"Happy to — here is our pricing overview and a short case study. Want 15 minutes to walk through it?"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(If you'd rather drive the reply through &lt;code&gt;email send&lt;/code&gt; directly, it exposes the same threading via the &lt;code&gt;--reply-to &amp;lt;message-id&amp;gt;&lt;/code&gt; flag.)&lt;/p&gt;

&lt;h3&gt;
  
  
  Snooze on "not now"
&lt;/h3&gt;

&lt;p&gt;When the prospect says "circle back in Q3," you don't reply — you reschedule. Set the sequence state's &lt;code&gt;next_send_at&lt;/code&gt; to the date they named and leave the drip paused until then. No API call needed beyond storing the date; when that date arrives, your scheduler resumes the sequence with a fresh send. The win is that you're following up &lt;em&gt;when they told you to&lt;/em&gt;, which is worth ten cold touches.&lt;/p&gt;

&lt;h2&gt;
  
  
  Honor unsubscribes and watch deliverability
&lt;/h2&gt;

&lt;p&gt;Cold outbound lives or dies on deliverability, and the fastest way to torch a domain is to keep mailing people who asked you to stop, or to keep hammering addresses that bounce. Two pieces close that loop.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Unsubscribes and bounces belong on a suppression list.&lt;/strong&gt; Agent Account rules support an &lt;code&gt;in_list&lt;/code&gt; operator that matches a sender address against a managed list — exactly the shape of a suppression list. The pattern is two pieces: a &lt;em&gt;list&lt;/em&gt; you populate with addresses to suppress, and a &lt;em&gt;rule&lt;/em&gt; that acts on &lt;code&gt;in_list&lt;/code&gt; matches.&lt;/p&gt;

&lt;p&gt;Start by creating the list. It's an &lt;code&gt;address&lt;/code&gt;-typed list (the type is immutable, and it's what &lt;code&gt;from.address&lt;/code&gt; matches against). Create it via &lt;code&gt;POST /v3/lists&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;--request&lt;/span&gt; POST &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--url&lt;/span&gt; &lt;span class="s1"&gt;'https://api.us.nylas.com/v3/lists'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s1"&gt;'Authorization: Bearer '&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$NYLAS_API_KEY&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s1"&gt;'Content-Type: application/json'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--data&lt;/span&gt; &lt;span class="s1"&gt;'{ "name": "Suppressed addresses", "type": "address" }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When a prospect unsubscribes or an address hard-bounces, add it to the list. The API appends items to the existing list:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;--request&lt;/span&gt; POST &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--url&lt;/span&gt; &lt;span class="s2"&gt;"https://api.us.nylas.com/v3/lists/&lt;/span&gt;&lt;span class="nv"&gt;$LIST_ID&lt;/span&gt;&lt;span class="s2"&gt;/items"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s1"&gt;'Authorization: Bearer '&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$NYLAS_API_KEY&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s1"&gt;'Content-Type: application/json'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--data&lt;/span&gt; &lt;span class="s1"&gt;'{ "items": ["dana@acme.com"] }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The CLI does both in two commands — create the list (optionally seeding it inline with &lt;code&gt;--item&lt;/code&gt;), then append more addresses as they come in:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;nylas agent list create &lt;span class="nt"&gt;--name&lt;/span&gt; &lt;span class="s2"&gt;"Suppressed addresses"&lt;/span&gt; &lt;span class="nt"&gt;--type&lt;/span&gt; address
nylas agent list add &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LIST_ID&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; dana@acme.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now the rule that enforces the list. A rule is inert until it's attached to a workspace — remember the default workspace Nylas created with the account. From the CLI, &lt;code&gt;agent rule create&lt;/code&gt; builds the rule &lt;em&gt;and&lt;/em&gt; attaches it to that default workspace in one step:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;nylas agent rule create &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--name&lt;/span&gt; &lt;span class="s2"&gt;"Suppress unsubscribed"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--condition&lt;/span&gt; from.address,in_list,&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LIST_ID&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--action&lt;/span&gt; block
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The equivalent API path is creating the rule via &lt;code&gt;POST /v3/rules&lt;/code&gt;, then activating it by attaching it through the workspace's &lt;code&gt;rule_ids&lt;/code&gt; (&lt;code&gt;PATCH /v3/workspaces/{workspace_id}&lt;/code&gt; with &lt;code&gt;{ "rule_ids": ["&amp;lt;rule-id&amp;gt;"] }&lt;/code&gt;, or &lt;code&gt;nylas workspace update &amp;lt;workspace-id&amp;gt; --rules-ids &amp;lt;rule-id&amp;gt;&lt;/code&gt;). Skip that attach step and the rule does nothing. One honest limitation to design around: &lt;strong&gt;inbound rules match only on &lt;code&gt;from.*&lt;/code&gt; fields&lt;/strong&gt; — &lt;code&gt;from.address&lt;/code&gt;, &lt;code&gt;from.domain&lt;/code&gt;, &lt;code&gt;from.tld&lt;/code&gt; — they cannot match on subject or message content. So "suppress anyone whose reply contains the word &lt;em&gt;unsubscribe&lt;/em&gt;" is &lt;em&gt;not&lt;/em&gt; a rule. You classify that intent in your app code after the webhook, then add the address to the list. The rule enforces the list; your code populates it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Watch bounces and complaints.&lt;/strong&gt; Agent Accounts emit deliverability webhooks beyond &lt;code&gt;message.created&lt;/code&gt;: &lt;code&gt;message.delivered&lt;/code&gt;, &lt;code&gt;message.bounced&lt;/code&gt;, &lt;code&gt;message.complaint&lt;/code&gt;, and &lt;code&gt;message.rejected&lt;/code&gt; (which fires specifically when an outbound message is rejected because an attachment carried a virus — not as a generic rejection). A hard bounce or a spam complaint is a strong signal to stop mailing that address; in your webhook handler, route both into the same suppression list you built above with one &lt;code&gt;agent list add&lt;/code&gt; (or &lt;code&gt;POST /v3/lists/{list_id}/items&lt;/code&gt;). One transparency note from working on the CLI: these deliverability triggers are wired through the webhooks API — subscribe to them in your &lt;code&gt;trigger_types&lt;/code&gt; array — and there isn't a dedicated CLI shortcut for each, so the API is the path here. I'd rather tell you that than have you hunt for a flag that doesn't exist.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;p&gt;You've got the full loop: provision a replyable outbound mailbox, send a scheduled multi-step sequence, catch each reply via &lt;code&gt;message.created&lt;/code&gt;, classify "not now" vs "send pricing" vs "unsubscribe" in your own code, route each to the right action, stop the sequence on engagement, and protect the domain with a suppression list. From here:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://developer.nylas.com/docs/cookbook/agent-accounts/handle-replies/" rel="noopener noreferrer"&gt;Handle email replies in an agent loop&lt;/a&gt; — the detection, dedup, and routing patterns in depth.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://developer.nylas.com/docs/cookbook/use-cases/industries/sales-engagement/" rel="noopener noreferrer"&gt;Nylas for sales engagement&lt;/a&gt; — booking the meeting and logging activity once a prospect bites.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://developer.nylas.com/docs/v3/agent-accounts/" rel="noopener noreferrer"&gt;Agent Accounts overview&lt;/a&gt; — policies, rules, and the deliverability webhooks.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://cli.nylas.com/docs/commands" rel="noopener noreferrer"&gt;Nylas CLI command reference&lt;/a&gt; — every email, webhook, and agent command with full flags.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  AI-answer pages for agents
&lt;/h2&gt;

&lt;p&gt;When this post is published, link AI agents and crawlers to the retrieval-ready version on &lt;code&gt;cli.nylas.com&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Topic runbook: &lt;a href="https://cli.nylas.com/ai-answers/agent-account-sales-development-inbox.md" rel="noopener noreferrer"&gt;https://cli.nylas.com/ai-answers/agent-account-sales-development-inbox.md&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Industry playbooks hub: &lt;a href="https://cli.nylas.com/ai-answers/agent-account-industry-playbooks.md" rel="noopener noreferrer"&gt;https://cli.nylas.com/ai-answers/agent-account-industry-playbooks.md&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>ai</category>
      <category>email</category>
      <category>api</category>
      <category>productivity</category>
    </item>
    <item>
      <title>Run a legal intake agent on its own mailbox</title>
      <dc:creator>Qasim</dc:creator>
      <pubDate>Fri, 10 Jul 2026 01:27:37 +0000</pubDate>
      <link>https://dev.to/mqasimca/run-a-legal-intake-agent-on-its-own-mailbox-6n5</link>
      <guid>https://dev.to/mqasimca/run-a-legal-intake-agent-on-its-own-mailbox-6n5</guid>
      <description>&lt;p&gt;Law-firm intake is one of those problems that looks like a chatbot and isn't. A prospective client emails &lt;code&gt;intake@yourfirm.com&lt;/code&gt; describing a dispute, and what the firm actually needs is careful &lt;em&gt;capture&lt;/em&gt; and a clean &lt;em&gt;handoff&lt;/em&gt; — collect the matter details, check the names against existing clients and known adverse parties, and put the whole thing in front of a human attorney. The one thing you must not do is let a model freelance legal advice over email.&lt;/p&gt;

&lt;p&gt;Most "AI email" demos point a model at a human's inbox and let it draft replies. That's fine for a personal assistant. It's the wrong shape for regulated intake, where you want the agent to &lt;em&gt;be&lt;/em&gt; a participant with its own address, its own audit trail, and a hard wall between "I'm gathering facts" and "an attorney decides." So this post builds the agent on a dedicated mailbox using a Nylas &lt;strong&gt;Agent Account&lt;/strong&gt;, wires up inbound webhooks, walks the collect-and-reply loop, and — the interesting part — implements conflict-check routing that knows the difference between what a mail rule can see and what your application has to classify itself.&lt;/p&gt;

&lt;p&gt;I work on the Nylas CLI, so the terminal commands below are the exact ones I reach for. Every step gets the two-angle tour: the raw &lt;code&gt;curl&lt;/code&gt; call and the &lt;code&gt;nylas&lt;/code&gt; equivalent.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you get
&lt;/h2&gt;

&lt;p&gt;An Agent Account is just a &lt;strong&gt;grant&lt;/strong&gt; — the same &lt;code&gt;grant_id&lt;/code&gt; abstraction behind every Nylas mailbox. There's nothing new to learn on the data plane. Once the account exists, you read with &lt;code&gt;GET /v3/grants/{grant_id}/messages&lt;/code&gt;, you send with &lt;code&gt;.../messages/send&lt;/code&gt;, you reply in-thread with &lt;code&gt;reply_to_message_id&lt;/code&gt;. The same calls you'd make against a connected Gmail account work here, except this mailbox belongs to your application instead of a person.&lt;/p&gt;

&lt;p&gt;For intake specifically, that buys you:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A real, addressable inbox (&lt;code&gt;intake@yourfirm.com&lt;/code&gt;) that prospects can email and reply to, with threads that hold together across days.&lt;/li&gt;
&lt;li&gt;Inbound webhooks so your code reacts the moment a new-matter inquiry lands.&lt;/li&gt;
&lt;li&gt;Account-level &lt;strong&gt;Rules&lt;/strong&gt; that can reject mail from known-adverse senders at SMTP, before your application ever sees it.&lt;/li&gt;
&lt;li&gt;A clean place to park escalations — a folder an attorney monitors — with the agent's auto-replies paused.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Before you begin
&lt;/h2&gt;

&lt;p&gt;You'll need a Nylas application with an API key, and a registered sending domain (a custom domain, or a Nylas &lt;code&gt;*.nylas.email&lt;/code&gt; trial subdomain to start). New domains warm over roughly four weeks, so if deliverability matters for client correspondence, register the real one early. Examples below use &lt;code&gt;https://api.us.nylas.com&lt;/code&gt; and an &lt;code&gt;Authorization: Bearer &amp;lt;NYLAS_API_KEY&amp;gt;&lt;/code&gt; header.&lt;/p&gt;

&lt;p&gt;One honest caveat up front, because this is a regulated space: what follows builds &lt;em&gt;intake capture and routing&lt;/em&gt;, not legal advice. The agent collects facts and hands off to a licensed attorney. Nothing here is legal advice about how to run a conflicts process — your firm's rules govern that. Treat the agent as the front desk, not the lawyer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Provision the intake mailbox
&lt;/h2&gt;

&lt;p&gt;Create the Agent Account with &lt;code&gt;POST /v3/connect/custom&lt;/code&gt;, using &lt;code&gt;provider: "nylas"&lt;/code&gt; and a &lt;code&gt;settings.email&lt;/code&gt; on your registered domain. The optional top-level &lt;code&gt;name&lt;/code&gt; sets the display name prospects see. No OAuth, no refresh token.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;--request&lt;/span&gt; POST &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--url&lt;/span&gt; &lt;span class="s2"&gt;"https://api.us.nylas.com/v3/connect/custom"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &amp;lt;NYLAS_API_KEY&amp;gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--data&lt;/span&gt; &lt;span class="s1"&gt;'{
    "provider": "nylas",
    "name": "Firm Intake",
    "settings": { "email": "intake@yourfirm.com" }
  }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The response carries a &lt;code&gt;grant_id&lt;/code&gt;. That's your handle for everything else.&lt;/p&gt;

&lt;p&gt;From the CLI it's one line. The API auto-creates a default workspace and policy for the account, so you don't pass a workspace on create:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;nylas agent account create intake@yourfirm.com &lt;span class="nt"&gt;--name&lt;/span&gt; &lt;span class="s2"&gt;"Firm Intake"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There's no &lt;code&gt;--workspace&lt;/code&gt; flag here, and that's deliberate — if you later want a custom policy (stricter send limits, tighter retention), you attach it to the account's workspace separately with &lt;code&gt;nylas workspace update &amp;lt;workspace-id&amp;gt; --policy-id &amp;lt;policy-id&amp;gt;&lt;/code&gt;. For most intake setups, the default workspace is fine to start.&lt;/p&gt;

&lt;h2&gt;
  
  
  Receive new-matter inquiries
&lt;/h2&gt;

&lt;p&gt;Inbound mail to an Agent Account fires the standard &lt;code&gt;message.created&lt;/code&gt; webhook. Webhooks in Nylas are &lt;strong&gt;application-scoped&lt;/strong&gt;, not grant-scoped: you subscribe once at the app level, and every grant's events arrive at that one endpoint, each payload carrying a &lt;code&gt;grant_id&lt;/code&gt; you filter on. So you subscribe through the top-level &lt;code&gt;/v3/webhooks&lt;/code&gt; endpoint:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;--request&lt;/span&gt; POST &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--url&lt;/span&gt; &lt;span class="s2"&gt;"https://api.us.nylas.com/v3/webhooks"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &amp;lt;NYLAS_API_KEY&amp;gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--data&lt;/span&gt; &lt;span class="s1"&gt;'{
    "trigger_types": ["message.created"],
    "webhook_url": "https://intake.yourfirm.com/webhooks/nylas"
  }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or with the CLI:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;nylas webhook create &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--url&lt;/span&gt; https://intake.yourfirm.com/webhooks/nylas &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--triggers&lt;/span&gt; message.created
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When an inquiry lands, you get a notification. Two things matter in how you handle it.&lt;/p&gt;

&lt;p&gt;First, &lt;strong&gt;dedup.&lt;/strong&gt; Nylas guarantees at-least-once delivery — the same event can arrive up to three times. The dedup key is the &lt;strong&gt;top-level notification &lt;code&gt;id&lt;/code&gt;&lt;/strong&gt;, which stays constant across all retries of one event. Record it; if you've seen it, drop the duplicate. (The inner &lt;code&gt;data.object.id&lt;/code&gt; is the message id — you can additionally guard on that to avoid acting twice on the same message, but the notification &lt;code&gt;id&lt;/code&gt; is the delivery-level key.) For an intake agent this is not optional: you do not want to send a prospect two "thanks, we got your inquiry" replies because a worker retried.&lt;/p&gt;

&lt;p&gt;Second, &lt;strong&gt;the body.&lt;/strong&gt; Don't rely on the webhook payload for the message body — fetch the full message by id when you need it, and branch on &lt;code&gt;message.created.truncated&lt;/code&gt; (which Nylas uses when the body is large). The reliable move is always the same: take the message id from the notification and go get the message.&lt;/p&gt;

&lt;p&gt;A sketch of the handler:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/webhooks/nylas&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;end&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// ack fast&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;message.created&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;message.created.truncated&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;seen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;          &lt;span class="c1"&gt;// dedup on the notification id&lt;/span&gt;
  &lt;span class="nf"&gt;markSeen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;grant_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;messageId&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;object&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;handleInquiry&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;grant_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;messageId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Read the inquiry
&lt;/h2&gt;

&lt;p&gt;Now fetch the full message by id. Keep this as a plain read — fetching does not mark anything read, that's a separate &lt;code&gt;PUT&lt;/code&gt; if you want it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;--request&lt;/span&gt; GET &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--url&lt;/span&gt; &lt;span class="s2"&gt;"https://api.us.nylas.com/v3/grants/&amp;lt;NYLAS_GRANT_ID&amp;gt;/messages/&amp;lt;MESSAGE_ID&amp;gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &amp;lt;NYLAS_API_KEY&amp;gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;From the terminal, while you're developing the agent and want to eyeball what came in:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;nylas email &lt;span class="nb"&gt;read&lt;/span&gt; &amp;lt;message-id&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;nylas email read&lt;/code&gt; shows the full message content; add &lt;code&gt;-r&lt;/code&gt; if you specifically want to mark it read after viewing. This is the input your classifier works on — the prospect's description of the matter, the parties involved, the relief they're after.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reply to collect the details you're missing
&lt;/h2&gt;

&lt;p&gt;A first-touch inquiry is almost never complete. The agent's job is to ask for the specific facts intake needs — the other party's full legal name, the jurisdiction, key dates, whether litigation has already started — and to do it in-thread so the conversation holds together.&lt;/p&gt;

&lt;p&gt;Reply with &lt;code&gt;reply_to_message_id&lt;/code&gt; set to the inbound message. That preserves the thread by setting the &lt;code&gt;In-Reply-To&lt;/code&gt; and &lt;code&gt;References&lt;/code&gt; headers, so the prospect sees a threaded reply, not a fresh email:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;--request&lt;/span&gt; POST &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--url&lt;/span&gt; &lt;span class="s2"&gt;"https://api.us.nylas.com/v3/grants/&amp;lt;NYLAS_GRANT_ID&amp;gt;/messages/send"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &amp;lt;NYLAS_API_KEY&amp;gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--data&lt;/span&gt; &lt;span class="s1"&gt;'{
    "reply_to_message_id": "&amp;lt;MESSAGE_ID&amp;gt;",
    "to": [{ "email": "prospect@example.com" }],
    "subject": "Re: New matter inquiry",
    "body": "Thanks for reaching out. To get your matter to the right attorney, could you share the full legal name of the other party, the state where this is happening, and any key dates?"
  }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The CLI fetches the original to populate recipient and subject automatically, so you only supply the body:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;nylas email reply &amp;lt;message-id&amp;gt; &lt;span class="nt"&gt;--body&lt;/span&gt; &lt;span class="s2"&gt;"Thanks for reaching out. To route your matter, could you share the full legal name of the other party, the state involved, and any key dates?"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When the prospect replies, &lt;code&gt;message.created&lt;/code&gt; fires again on the same &lt;code&gt;thread_id&lt;/code&gt;. Pull the thread for full context before deciding what to ask next, so the agent reasons over the whole exchange instead of one stray reply:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;--request&lt;/span&gt; GET &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--url&lt;/span&gt; &lt;span class="s2"&gt;"https://api.us.nylas.com/v3/grants/&amp;lt;NYLAS_GRANT_ID&amp;gt;/threads/&amp;lt;THREAD_ID&amp;gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &amp;lt;NYLAS_API_KEY&amp;gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;nylas email threads show &amp;lt;thread-id&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Loop until you have what intake needs. Two practical notes on the loop: the webhook fires for the agent's &lt;em&gt;own&lt;/em&gt; outbound sends too, so filter on the sender to avoid replying to yourself, and keep the dedup guard in place because multiple replies can land on one thread.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conflict-check routing: what a Rule can do, and what it can't
&lt;/h2&gt;

&lt;p&gt;Here's the part that trips people up, and it's worth being precise about because conflicts are exactly where "close enough" gets a firm in trouble.&lt;/p&gt;

&lt;p&gt;Agent Accounts have account-level &lt;strong&gt;Rules&lt;/strong&gt; that filter mail. An inbound Rule matches on &lt;strong&gt;sender fields only&lt;/strong&gt; — &lt;code&gt;from.address&lt;/code&gt;, &lt;code&gt;from.domain&lt;/code&gt;, and &lt;code&gt;from.tld&lt;/code&gt;, with operators &lt;code&gt;is&lt;/code&gt;, &lt;code&gt;is_not&lt;/code&gt;, &lt;code&gt;contains&lt;/code&gt;, and &lt;code&gt;in_list&lt;/code&gt;. That makes Rules a genuinely good fit for one slice of conflict checking: &lt;em&gt;known adverse parties you can identify by their email&lt;/em&gt;. If your firm maintains a list of domains or addresses you must not represent against, a Rule can reject that mail at SMTP before it ever reaches the mailbox.&lt;/p&gt;

&lt;p&gt;Build it as a &lt;strong&gt;List&lt;/strong&gt; the conflicts team can maintain, then a Rule that references it. From the CLI:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;nylas agent list create &lt;span class="nt"&gt;--name&lt;/span&gt; &lt;span class="s2"&gt;"Adverse parties"&lt;/span&gt; &lt;span class="nt"&gt;--type&lt;/span&gt; domain &lt;span class="nt"&gt;--item&lt;/span&gt; adverseco.example
nylas agent rule create &lt;span class="nt"&gt;--name&lt;/span&gt; &lt;span class="s2"&gt;"Suppress adverse-party mail"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--trigger&lt;/span&gt; inbound &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--condition&lt;/span&gt; from.domain,in_list,&amp;lt;LIST_ID&amp;gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--action&lt;/span&gt; block
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same thing over the API — create the list, seed it with the adverse-party domains, then create the rule. The create call returns the list's &lt;code&gt;id&lt;/code&gt;, which you use both to add items and to reference the list from the rule:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;--request&lt;/span&gt; POST &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--url&lt;/span&gt; &lt;span class="s2"&gt;"https://api.us.nylas.com/v3/lists"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &amp;lt;NYLAS_API_KEY&amp;gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--data&lt;/span&gt; &lt;span class="s1"&gt;'{ "name": "Adverse parties", "type": "domain" }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;--request&lt;/span&gt; POST &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--url&lt;/span&gt; &lt;span class="s2"&gt;"https://api.us.nylas.com/v3/lists/&amp;lt;LIST_ID&amp;gt;/items"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &amp;lt;NYLAS_API_KEY&amp;gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--data&lt;/span&gt; &lt;span class="s1"&gt;'{ "items": ["adverseco.example", "anothercounterparty.example"] }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The CLI does the same — the &lt;code&gt;--item&lt;/code&gt; flag above seeds at creation time, and &lt;code&gt;nylas agent list add&lt;/code&gt; appends to an existing list, which is what the conflicts team runs as the watchlist grows:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;nylas agent list add &amp;lt;list-id&amp;gt; adverseco.example anothercounterparty.example
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the seam non-engineers own: they keep the List current, and every rule referencing it picks up new values immediately. With the list seeded, create the rule that references it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;--request&lt;/span&gt; POST &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--url&lt;/span&gt; &lt;span class="s2"&gt;"https://api.us.nylas.com/v3/rules"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &amp;lt;NYLAS_API_KEY&amp;gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--data&lt;/span&gt; &lt;span class="s1"&gt;'{
    "name": "Suppress adverse-party mail",
    "trigger": "inbound",
    "match": {
      "conditions": [
        { "field": "from.domain", "operator": "in_list", "value": ["&amp;lt;LIST_ID&amp;gt;"] }
      ]
    },
    "actions": [ { "type": "block" } ]
  }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One thing to know: a Rule is &lt;strong&gt;inert until it's attached to a workspace&lt;/strong&gt; via that workspace's &lt;code&gt;rule_ids&lt;/code&gt;. The CLI &lt;code&gt;nylas agent rule create&lt;/code&gt; attaches the new rule to the account's default workspace for you; over the raw API you add the rule's ID to the workspace's &lt;code&gt;rule_ids&lt;/code&gt; array with &lt;code&gt;PATCH /v3/workspaces/{workspace_id}&lt;/code&gt;. Until you do, the rule exists but does nothing.&lt;/p&gt;

&lt;p&gt;Now the honest limit, and it's the whole reason this section exists. &lt;strong&gt;A Rule cannot read message content.&lt;/strong&gt; It matches the &lt;em&gt;sender&lt;/em&gt;, not the subject and not the body. Real conflict checking is mostly about &lt;em&gt;names inside the matter&lt;/em&gt; — the opposing party a prospect mentions in their description, a company that isn't the sender, a person who'll never appear in a &lt;code&gt;from&lt;/code&gt; header. A Rule can't see any of that, and you should never claim it does.&lt;/p&gt;

&lt;p&gt;So content-level conflict checks are &lt;strong&gt;application-side classification&lt;/strong&gt;, not a Rule. After you fetch the message, run the matter text through your own conflicts logic (your client database, a watchlist, an LLM extracting party names — your call), and act on the result by &lt;em&gt;moving the message&lt;/em&gt;, not by trusting a rule to have caught it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;--request&lt;/span&gt; PUT &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--url&lt;/span&gt; &lt;span class="s2"&gt;"https://api.us.nylas.com/v3/grants/&amp;lt;NYLAS_GRANT_ID&amp;gt;/messages/&amp;lt;MESSAGE_ID&amp;gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &amp;lt;NYLAS_API_KEY&amp;gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--data&lt;/span&gt; &lt;span class="s1"&gt;'{ "folders": ["&amp;lt;CONFLICT_REVIEW_FOLDER_ID&amp;gt;"] }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;nylas email move &amp;lt;message-id&amp;gt; &lt;span class="nt"&gt;--folder&lt;/span&gt; &amp;lt;conflict-review-folder-id&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The mental model worth keeping: &lt;strong&gt;sender-based suppression is a Rule; content-based conflict review is your code plus a folder move.&lt;/strong&gt; Conflating the two is how you'd accidentally let a real conflict through because you assumed a rule was reading something it can't.&lt;/p&gt;

&lt;h2&gt;
  
  
  Escalate to a human attorney
&lt;/h2&gt;

&lt;p&gt;When the agent has collected enough — or when conflict logic flags a possible hit — the matter goes to a person. Escalation here means two concrete things, and neither of them is "the agent decides the case."&lt;/p&gt;

&lt;p&gt;First, &lt;strong&gt;move the message into a human-review folder&lt;/strong&gt; an attorney monitors. Create that folder once — the response carries the folder &lt;code&gt;id&lt;/code&gt; you route into later:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;--request&lt;/span&gt; POST &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--url&lt;/span&gt; &lt;span class="s2"&gt;"https://api.us.nylas.com/v3/grants/&amp;lt;NYLAS_GRANT_ID&amp;gt;/folders"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &amp;lt;NYLAS_API_KEY&amp;gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--data&lt;/span&gt; &lt;span class="s1"&gt;'{ "name": "Attorney review" }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;nylas email folders create &lt;span class="s2"&gt;"Attorney review"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then route into it with the same &lt;code&gt;PUT ... folders[]&lt;/code&gt; / &lt;code&gt;nylas email move&lt;/code&gt; you used for conflict review — just a different destination folder.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;nylas email move &amp;lt;message-id&amp;gt; &lt;span class="nt"&gt;--folder&lt;/span&gt; &amp;lt;attorney-review-folder-id&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Second, &lt;strong&gt;pause the agent's auto-replies for that thread.&lt;/strong&gt; This is application-side state — you flip a flag in your own store keyed by &lt;code&gt;thread_id&lt;/code&gt; and your webhook handler checks it before replying. Nylas Agent Accounts don't support custom metadata on messages, so don't try to stash "escalated" on the message itself; keep that state in your database. Once an attorney is in the loop, your handler sees the flag and stops generating replies, so the agent never talks over a lawyer.&lt;/p&gt;

&lt;p&gt;That's the clean handoff: facts captured, routed, parked in front of a human, with the bot silenced. The agent did intake. The attorney does law.&lt;/p&gt;

&lt;h2&gt;
  
  
  Guardrails worth keeping
&lt;/h2&gt;

&lt;p&gt;A few things I'd nail down before pointing real prospects at this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Dedup is load-bearing.&lt;/strong&gt; Webhook redelivery plus concurrent workers will re-trigger your handler. Guard on the notification &lt;code&gt;id&lt;/code&gt;, and additionally on the message id, before you ever send a reply.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sender suppression and content conflicts are different mechanisms.&lt;/strong&gt; Rules catch known-adverse &lt;em&gt;senders&lt;/em&gt;; your code catches adverse &lt;em&gt;parties named in the matter&lt;/em&gt;. Run both.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep fetch and mark-read separate.&lt;/strong&gt; Reading a message via &lt;code&gt;GET&lt;/code&gt; doesn't change its unread state — mark it read explicitly with &lt;code&gt;PUT ... {"unread": false}&lt;/code&gt; if your attorney's view depends on it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Watch the limits while prototyping.&lt;/strong&gt; Free-plan Agent Accounts cap at 200 messages per account per day, with 30-day inbox and 7-day spam retention. That's plenty for intake testing; size it up for production volume.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;This is intake, not advice.&lt;/strong&gt; The agent gathers and routes. Every substantive decision lands with a licensed attorney. Build the wall in code, and don't let "helpful auto-reply" drift into anything that reads like counsel.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;p&gt;The grant abstraction is the whole trick here: once &lt;code&gt;intake@yourfirm.com&lt;/code&gt; is an Agent Account, every endpoint you already know — messages, threads, folders, webhooks — works exactly as it does on a personal mailbox. Read the &lt;a href="https://developer.nylas.com/docs/v3/agent-accounts/" rel="noopener noreferrer"&gt;Agent Accounts overview&lt;/a&gt; for provisioning and domain warming, the &lt;a href="https://developer.nylas.com/docs/v3/agent-accounts/policies-rules-lists/" rel="noopener noreferrer"&gt;Policies, Rules, and Lists guide&lt;/a&gt; for the full Rule condition reference (including exactly which fields each trigger accepts), and the &lt;a href="https://developer.nylas.com/docs/cookbook/agent-accounts/handle-replies/" rel="noopener noreferrer"&gt;handle email replies recipe&lt;/a&gt; for the reply-loop and dedup patterns in depth. The CLI commands are all documented at &lt;a href="https://cli.nylas.com/docs/commands" rel="noopener noreferrer"&gt;cli.nylas.com/docs/commands&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  AI-answer pages for agents
&lt;/h2&gt;

&lt;p&gt;When this post is published, link AI agents and crawlers to the retrieval-ready version on &lt;code&gt;cli.nylas.com&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Topic runbook: &lt;a href="https://cli.nylas.com/ai-answers/agent-account-industry-playbooks.md" rel="noopener noreferrer"&gt;https://cli.nylas.com/ai-answers/agent-account-industry-playbooks.md&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Industry playbooks hub: &lt;a href="https://cli.nylas.com/ai-answers/agent-account-industry-playbooks.md" rel="noopener noreferrer"&gt;https://cli.nylas.com/ai-answers/agent-account-industry-playbooks.md&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>ai</category>
      <category>email</category>
      <category>api</category>
      <category>productivity</category>
    </item>
  </channel>
</rss>
