Here's the failure mode that keeps me up at night about autonomous email agents: not the agent that goes silent, but the one that sends. A model fat-fingers a recipient. It picks up qa@internal-staging.example
from a config file it shouldn't have read. It replies-all to a forwarded thread that quietly carried a BCC to a competitor. Your send wrapper looks clean, your tests pass, and one afternoon the agent emails a customer's pricing to the wrong domain. By the time you see it in the logs, the message is on someone else's mail server and you can't recall it.
Most teams handle this with an allowlist in application code β validate the recipients, reject the bad ones, then call send. That's the right first move and you should absolutely do it. But it has a gap: it only protects the send path you wrote. The moment a second code path calls the API directly, or a retry skips the wrapper, or a teammate ships a new endpoint that forgets the check, the guardrail isn't there. The address lives in your process, and processes have bugs.
There's a lower layer that doesn't. An Agent Account on Nylas can evaluate an outbound Rule at send time β after your code hands the message off, but before Nylas hands it to the email provider. If the rule matches a denied recipient, the send is rejected with a 403
and no message leaves the building. This post is about that send-side block, used as data-loss prevention: a server-side backstop that catches the wrong recipient even when your application code doesn't.
The cookbook recipe on restricting agent recipients makes the case for the application-layer allowlist, and it's a good case. Validate recipients in a guarded_send
wrapper, fail closed on unknown domains, run a dry-run gate. Do that. This post is not an argument against it.
It's an argument for a second layer, because the two fail in different places:
recipient.*
rule fields match against to
still trips the block. That's exactly the DLP case you want: the recipient you didn't think to look at.in_list
β Nylas blocks the send anyway rather than letting it through. More on that contract below, because it changes the status code you get back.The honest framing: this is a guardrail Nylas enforces, and it complements your app-side checks β it doesn't replace them. Defense in depth. Your wrapper is the fail-closed allowlist you own; the outbound Rule is the server-side denylist that holds when the wrapper doesn't.
Worth saying up front, because it's the reassuring part: an Agent Account is just a grant with a grant_id
. Everything you already know about Messages, Drafts, Threads, and POST /v3/grants/{grant_id}/messages/send
is unchanged. The agent still composes and sends exactly the same way.
Rules and Lists sit one level up, on the control plane. They're application-scoped admin resources β no grant ID in the path, your API key identifies the application β and they apply to every Agent Account in a workspace. So there's nothing new to learn on the data plane. You're adding a checkpoint the send passes through, not rewriting how the send works.
The pieces form a short chain. A List holds the denied recipient values. A Rule with trigger: outbound
references that list through the in_list
operator and says "if a recipient is on this list, block
." A workspace carries the rule in its rule_ids
array, and every Agent Account in the workspace inherits it. Miss the last step and nothing fires β I'll come back to that, because it's the most common mistake.
You need an Agent Account β a grant created via POST /v3/connect/custom
against a registered domain, covered in Agent Accounts β plus an API key for the same application. The host in every example is https://api.us.nylas.com
, and every call carries Authorization: Bearer <NYLAS_API_KEY>
.
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 they don't overlap: nylas agent list
manages the denylist (/v3/lists
), nylas agent rule
manages the rule (/v3/rules
), and nylas workspace update
arms the rule by adding it to a workspace's rule_ids
. CLI reference lives at cli.nylas.com/docs/commands.
One asymmetry to internalize, because it's the thing people get wrong: only outbound rules can match recipients. Inbound rules see from.*
and nothing else β they only know who sent the mail. Outbound rules add recipient.address
, recipient.domain
, recipient.tld
, and outbound.type
. Send-side recipient blocking is an outbound-trigger job, full stop. If you try to express it as an inbound rule, the field isn't even available.
Start with the List that holds the denied recipient domains. A list has a fixed type
β domain
, tld
, or address
β set at creation and immutable. The type decides which rule fields it can match: a domain
list matches recipient.domain
(and from.domain
), an address
list matches the .address
fields. For DLP, domain-level is the right granularity most of the time β you want to block an entire competitor or test domain, not enumerate addresses.
curl --request POST \
--url "https://api.us.nylas.com/v3/lists" \
--header "Authorization: Bearer <NYLAS_API_KEY>" \
--header "Content-Type: application/json" \
--data '{
"name": "Denied recipient domains",
"type": "domain"
}'
The response carries the id
you'll reference from the rule:
{
"request_id": "5fa64c92-e840-4357-86b9-2aa364d35b88",
"data": {
"id": "d1e2f3a4-5678-4abc-9def-0123456789ab",
"name": "Denied recipient domains",
"type": "domain",
"items_count": 0,
"created_at": 1742932766,
"updated_at": 1742932766
}
}
The CLI does the same and can seed items at creation with repeatable --item
flags:
nylas agent list create \
--name "Denied recipient domains" \
--type domain \
--item competitor.example \
--item internal-staging.example
The values you'd put here are the ones an agent should never reach in production: competitor domains, test and staging domains that exist in your environment and could leak into a recipient field, and any address class you've decided is off-limits. Values are lowercased, trimmed, and validated against the list's type, so a domain
list rejects a full email address β pick the type to fit what you'll deny.
This is where the dynamic part lives. Add up to 1000 items per request; duplicates are silently ignored, so you can re-run an add idempotently.
curl --request POST \
--url "https://api.us.nylas.com/v3/lists/<LIST_ID>/items" \
--header "Authorization: Bearer <NYLAS_API_KEY>" \
--header "Content-Type: application/json" \
--data '{
"items": ["competitor.example", "internal-staging.example"]
}'
From the CLI, items are positional arguments after the list ID:
nylas agent list add <LIST_ID> competitor.example internal-staging.example
This is the command you hand to a non-engineer, wire into an internal admin panel, or call from an incident runbook the moment you realize a domain needs blocking. It's a single API write that immediately changes the behavior of the rule pointing at the list β no rule edit, no deploy. To pull a domain back out, DELETE /v3/lists/{list_id}/items
(or nylas agent list remove <LIST_ID> competitor.example
).
Now the rule that turns the list into a send-side block. The trigger is outbound
, the condition matches recipient.domain
against the denylist with in_list
, and the single action is block
. Note that value
for an in_list
condition is an array of list IDs, not the domains themselves β the domains live in the list; the rule references the list by ID. A single in_list
condition can reference up to 10 lists.
curl --request POST \
--url "https://api.us.nylas.com/v3/rules" \
--header "Authorization: Bearer <NYLAS_API_KEY>" \
--header "Content-Type: application/json" \
--data '{
"name": "Block sends to denied recipients",
"priority": 1,
"trigger": "outbound",
"match": {
"conditions": [
{
"field": "recipient.domain",
"operator": "in_list",
"value": ["<LIST_ID>"]
}
]
},
"actions": [
{ "type": "block" }
]
}'
The CLI condition format is field,operator,value
, and for in_list
the trailing comma-separated values are list IDs β field,in_list,list-id-1,list-id-2
β which maps directly to the array in the JSON above:
nylas agent rule create \
--name "Block sends to denied recipients" \
--trigger outbound \
--priority 1 \
--condition recipient.domain,in_list,<LIST_ID> \
--action block
A few things that matter about this rule. The block
action is terminal β it can't be combined with other actions, because there's nothing to do to a message you're refusing to send. For an outbound rule it rejects the send before the message reaches the provider, so no sent copy is stored. And recipient.domain
matches against any recipient, including CC, BCC, and the SMTP envelope β which is the whole DLP point. A denied domain hidden in a BCC the agent shouldn't have added still trips the block. is_not
would only be true when no recipient matches, but for a denylist you want in_list
, which fires when any recipient is on the list.
If you'd rather block a single specific address than a whole domain, make the List type: address
and match recipient.address
instead β same shape, finer granularity.
Here's the step that trips everyone up: creating the List and the Rule isn't enough. A rule is inert until a workspace references it. Workspaces carry rules β you don't attach a rule to a grant directly. You add the rule's ID to a workspace's rule_ids
array, and from then on it runs for every Agent Account in that workspace.
Attach it with PATCH /v3/workspaces/{workspace_id}
. The array is a full replacement, not an append, so pass every rule ID you want active β include any existing rules you don't want to silently detach:
curl --request PATCH \
--url "https://api.us.nylas.com/v3/workspaces/<WORKSPACE_ID>" \
--header "Authorization: Bearer <NYLAS_API_KEY>" \
--header "Content-Type: application/json" \
--data '{
"rule_ids": ["<OUTBOUND_RULE_ID>"]
}'
From the CLI, nylas workspace update
takes a comma-separated list of rule IDs:
nylas workspace update <WORKSPACE_ID> --rules-ids <OUTBOUND_RULE_ID>
If you don't know the workspace ID, the default workspace holds any Agent Account you haven't placed in a custom one, so arming there covers your unassigned accounts. As a convenience, nylas agent rule create
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 the rule on a non-default workspace, this PATCH
is the step that actually turns the block on. The same rule_ids
array carries inbound and outbound rules together; Nylas filters by trigger
at evaluation time, so an outbound rule never runs on received mail.
This is the part worth seeing, because the rejection has a specific contract your code needs to handle. With the rule armed, ask the Agent Account to send to a denied domain. The data-plane call is the ordinary send β nothing special:
curl --request POST \
--url "https://api.us.nylas.com/v3/grants/<NYLAS_GRANT_ID>/messages/send" \
--header "Authorization: Bearer <NYLAS_API_KEY>" \
--header "Content-Type: application/json" \
--data '{
"to": [{ "email": "deals@competitor.example" }],
"subject": "Q3 pricing",
"body": "Here is the proposal you asked about."
}'
The equivalent CLI send, which is what I reach for to reproduce a block quickly:
nylas email send <NYLAS_GRANT_ID> \
--to deals@competitor.example \
--subject "Q3 pricing" \
--body "Here is the proposal you asked about."
Because competitor.example
is on the denylist, the outbound rule matches and the send is rejected with HTTP 403 before it reaches the provider. No message leaves Nylas, and no sent copy is stored:
{
"request_id": "f0a1b2c3-d4e5-46f7-89ab-cdef01234567",
"error": {
"type": "provider_error",
"message": "Message blocked by an outbound rule."
}
}
Your application should treat a 403
here exactly like any other non-retryable delivery failure: the send never reached the provider, there's no retry path that will deliver it, and re-sending the same payload will just be blocked again. Don't loop on it β surface it, log it, and move on.
There's one important wrinkle in the status code, and it's the fail-closed behavior I flagged earlier. A genuine rule match returns 403
. But if the rule engine can't evaluate the block because of a transient infrastructure error β for example, the in_list
lookup fails mid-evaluation β Nylas blocks the send anyway and returns ** 503** instead. The distinction matters for your retry logic: a
403
is a permanent policy block (don't retry), while a 503
is a retryable tempfail (the send might succeed on a later attempt once the transient error clears). Branch on the status code, not just on "the send failed."When a send is blocked and you want to know exactly why β which is most useful the first time you wire this up and the one time it blocks something it shouldn't have β pull the rule-evaluation audit log for the grant:
curl --request GET \
--url "https://api.us.nylas.com/v3/grants/<NYLAS_GRANT_ID>/rule-evaluations?limit=50" \
--header "Authorization: Bearer <NYLAS_API_KEY>"
Each record carries the evaluation stage β outbound_send
for a send-time check β the normalized recipient data that was considered (recipient_addresses
, recipient_domains
, recipient_tlds
, outbound_type
), the IDs of any matched rules, and the actions applied. For a blocked send, message_id
is null
because no sent copy was stored. And if the block came from a fail-closed evaluation error rather than a genuine match, the record sets blocked_by_evaluation_error: true
β that's how you tell a real denylist hit (403
) from an infrastructure tempfail (503
) after the fact. Cross-reference matched_rule_ids
with the Rules API to see exactly which condition tripped.
A few things I've watched people trip over with send-side blocks specifically:
rule_ids
. Creating the List and Rule is two-thirds of the job; the PATCH /v3/workspaces/{workspace_id}
is what arms it.rule_ids
is a full replacement.value
is an array of list IDs for in_list
, not the domains.domain
list works with recipient.domain
. Point a recipient.address
condition at a domain list and it won't match β and you'll think the block is broken when it's a type mismatch.recipient.*
covers hidden recipients on purpose.403
is permanent, 503
is retryable.403
policy block will never deliver; a 503
fail-closed tempfail might on retry. Treat them differently or you'll either hammer a permanent block or give up on a transient one.The shape to remember: a List holds the denied recipients, an outbound Rule with recipient.domain in_list
plus a block
action rejects the send, and the workspace rule_ids
array arms it. Create the list, create the rule, attach the rule β skip that last step and the agent sends to the competitor anyway. Once it clicks, you've got a server-side DLP block that holds even when the model, and your own code, don't.
403
/503
fail-closed contractnylas agent list
, nylas agent rule
, and nylas workspace update
reference