cd /news/ai-agents/allowlist-and-denylist-rules-for-age… Β· home β€Ί topics β€Ί ai-agents β€Ί article
[ARTICLE Β· art-56316] src=dev.to β†— pub= topic=ai-agents verified=true sentiment=Β· neutral

Allowlist and denylist rules for agent email, backed by Lists

Nylas introduced a dynamic allowlist and denylist system for AI email agents using Lists and Rules, decoupling policy from application code. Lists are editable collections of domains or addresses that Rules reference via the in_list operator, enabling non-engineers to update policies without redeployment. The system supports both inbound and outbound filtering, with a fail-closed approach on transient errors.

read12 min views1 publishedJul 12, 2026

The usual way to keep an AI email agent from talking to the wrong people is a hardcoded set in your application code:

ALLOWED_DOMAINS = {"yourcompany.com", "customer.example"}

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 same allowlist to govern five different agents, or you want it to apply to mail coming in and not just going out, that set in code becomes a deploy-shaped bottleneck. Every change is a PR, a review, a release.

There's a layer below your code that can carry that policy for you. An Agent Account on Nylas evaluates Rules against every message β€” inbound on arrival, outbound on send β€” and those rules can point at Lists, 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.

This post goes deep on that pattern: Lists fronted by the in_list

rule operator, used for allow and block on both directions. If you've already read the basic inbound/outbound filtering post, this is the part that makes the filtering dynamic.

The data plane doesn't change. An Agent Account is just a grant with a grant_id

, and everything you already know about Messages, Drafts, Threads, and Folders still applies. Rules and Lists sit one level up, on the control plane β€” 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.

So the mental model is two planes:

GET /v3/grants/{grant_id}/messages

, nylas email send

, the stuff your agent does.POST /v3/lists

, POST /v3/rules

, the stuff that decides what the agent is A List holds values. A Rule references a list through the in_list

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 what (which domains) lives in a list a human can edit; the how (block vs. route) lives in a rule you set once.

A few honest tradeoffs, because a static ALLOWED_DOMAINS

set genuinely is simpler when you only have one agent:

message.created

webhook β€” so your application never even sees it.block

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 451

tempfail so the sender retries; an API send gets a retryable 503

.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 restrict agent recipients cookbook recipe covers.

You need an Agent Account (a grant created via POST /v3/connect/custom

against a registered domain β€” see Agent Accounts), an API key for the same application, and the host in your examples set to https://api.us.nylas.com

. Every API call below 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 each maps to a distinct piece: nylas agent list

manages list resources (/v3/lists

), nylas agent rule

manages rules (/v3/rules

), and nylas workspace update

attaches a rule to a workspace via its rule_ids

. They don't overlap β€” nylas agent list

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 cli.nylas.com/docs/commands.

One thing to internalize up front about which fields each direction can match, because it's the most common mistake:

from.*

fields β€” from.address

, from.domain

, from.tld

. You only know who sent it.from.*

, recipient.*

(recipient.address

, recipient.domain

, recipient.tld

), and outbound.type

(compose

vs. reply

).That asymmetry drives everything below. You allowlist senders on the way in, and recipients on the way out.

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 from.domain

and recipient.domain

, an address

list matches the .address

fields, and so on. Start with a domain list, since most allow/block decisions are domain-level.

curl --request POST \
  --url "https://api.us.nylas.com/v3/lists" \
  --header "Authorization: Bearer <NYLAS_API_KEY>" \
  --header "Content-Type: application/json" \
  --data '{
    "name": "Blocked domains",
    "type": "domain"
  }'

The response carries the list id

you'll reference from rules:

{
  "request_id": "5fa64c92-e840-4357-86b9-2aa364d35b88",
  "data": {
    "id": "d1e2f3a4-5678-4abc-9def-0123456789ab",
    "name": "Blocked domains",
    "type": "domain",
    "items_count": 0,
    "created_at": 1742932766,
    "updated_at": 1742932766
  }
}

Same thing from the CLI, which can seed items at creation time with repeatable --item

flags:

nylas agent list create \
  --name "Blocked domains" \
  --type domain \
  --item spam-domain.com \
  --item another-bad-domain.net

The --type

is domain

, tld

, or address

, 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."

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 domain

list rejects full email addresses. Duplicate additions are silently ignored, which means 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": ["spam-domain.com", "another-bad-domain.net"]
  }'

From the CLI, items are positional arguments after the list ID:

nylas agent list add <LIST_ID> spam-domain.com another-bad-domain.net

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, nylas agent list remove <LIST_ID> spam-domain.com

(or DELETE /v3/lists/{list_id}/items

).

in_list

rule 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 "operator": "in_list"

with the list ID β€” note that value

is an array of list IDs, not the domains themselves. 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 senders on our blocklist",
    "priority": 1,
    "trigger": "inbound",
    "match": {
      "conditions": [
        {
          "field": "from.domain",
          "operator": "in_list",
          "value": ["<LIST_ID>"]
        }
      ]
    },
    "actions": [
      { "type": "block" }
    ]
  }'

The CLI condition format is field,operator,value

. 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 senders on our blocklist" \
  --trigger inbound \
  --priority 1 \
  --condition from.domain,in_list,<LIST_ID> \
  --action block

The block

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 message.created

webhook. Your application is genuinely insulated from that sender.

A note on priority and rule ordering: rules run lowest priority

first (range 0–1000, default 10), and the first matching block

wins. Put your specific in_list

block ahead of any broad contains

rules so the precise check fires first.

Here's the step that trips people up: creating the List and the Rule isn't enough. A rule is inert until a workspace references it. 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 rule_ids

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 trigger

at evaluation time, so the same array can hold your inbound block and your outbound block side by side.

Attach the rule with PATCH /v3/workspaces/{workspace_id}

. Pass the full set of rule IDs you want active β€” this replaces the array, so include any existing rules you want to keep:

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": ["<INBOUND_RULE_ID>"]
  }'

From the CLI, nylas workspace update

takes a comma-separated list of rule IDs:

nylas workspace update <WORKSPACE_ID> --rules-ids <INBOUND_RULE_ID>

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, 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 a rule to apply to a non-default workspace, this PATCH

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.

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.

The blocklist version β€” 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:

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",
    "trigger": "outbound",
    "match": {
      "conditions": [
        {
          "field": "recipient.domain",
          "operator": "in_list",
          "value": ["<DENY_LIST_ID>"]
        }
      ]
    },
    "actions": [
      { "type": "block" }
    ]
  }'
nylas agent rule create \
  --name "Block sends to denied recipients" \
  --trigger outbound \
  --condition recipient.domain,in_list,<DENY_LIST_ID> \
  --action block

This rule, like the inbound one, does nothing until it's on a workspace's rule_ids

. Re-run the PATCH /v3/workspaces/{workspace_id}

(or nylas workspace update <WORKSPACE_ID> --rules-ids <INBOUND_RULE_ID>,<OUTBOUND_RULE_ID>

) with both IDs in the array β€” the same array carries both directions, and Nylas runs each rule only on its matching trigger.

A true allowlist β€” block any recipient not on the approved list β€” is stricter and fails closed. Worth being precise about what the rule engine supports here: in_list

matches when a value is present in the list, so it expresses "block what's on the denylist" directly. There's no not_in_list

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 in_list

block rule for the dynamic denylist layer on top.

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 restrict agent recipients recipe covers the wrapper side. Don't try to make one do both jobs.

One important behavioral detail for outbound: recipient.*

matches against any 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 block

returns 403

to your send call and stores no sent copy; treat it like any other non-retryable delivery failure.

in_list

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

curl --request POST \
  --url "https://api.us.nylas.com/v3/rules" \
  --header "Authorization: Bearer <NYLAS_API_KEY>" \
  --header "Content-Type: application/json" \
  --data '{
    "name": "Star VIP senders",
    "trigger": "inbound",
    "match": {
      "conditions": [
        {
          "field": "from.domain",
          "operator": "in_list",
          "value": ["<VIP_LIST_ID>"]
        }
      ]
    },
    "actions": [
      { "type": "mark_as_starred" }
    ]
  }'
nylas agent rule create \
  --name "Star VIP senders" \
  --trigger inbound \
  --condition from.domain,in_list,<VIP_LIST_ID> \
  --action mark_as_starred

The non-blocking actions β€” mark_as_starred

, mark_as_read

, assign_to_folder

, archive

, mark_as_spam

, trash

β€” all compose with in_list

. Sales adds a domain to the VIP list, and the agent's inbox starts starring that customer's mail. Nobody touched a rule.

A few things I've watched people trip over:

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.domain

list only works with from.domain

/ recipient.domain

. Point a from.address

condition at a domain list and it won't match. Pick the type to fit the field you'll match.value

is an array of list IDs for in_list

, not the values.recipient.*

on the inbound trigger, because there's only one recipient (the agent) and it's already known. Recipient allow/block is outbound-only.in_list

condition, 500 chars per value, 50 conditions and 20 actions per rule.in_list

simply stop matching its values. No dangling references, but also no warning β€” audit before you delete.GET /v3/grants/{grant_id}/rule-evaluations

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?"The combination to remember: a List is the editable noun, the in_list

operator is the indirection, the trigger decides whether you're filtering senders (inbound) or recipients (outbound), and the workspace rule_ids

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 who to a non-engineer and keep the how in version control.

nylas agent list

, nylas agent rule

, and nylas workspace update

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

:

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

Run your AI side-project on zahid.host

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

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