Shadow-test a new AI email agent on live threads with Nylas A developer from Nylas demonstrates how to shadow-test an AI email agent on live threads using Nylas Agent Accounts, ensuring the candidate agent records its proposed actions without sending or modifying customer mailboxes. The approach involves persisting shadow events and runs in a database, comparing candidate outputs against trusted outcomes, and handling webhook redeliveries safely. You have an email agent that works in a test inbox. It classifies a support request, pulls the relevant context, and drafts a plausible reply. The risky next step is turning it loose on support@yourcompany.com : a new prompt can misunderstand a frustrated customer, a new model can change its format, and a harmless-looking tool change can make the agent write twice. There is a useful stage between “it passed the fixture” and “it can send customer email”: shadow mode . The candidate agent receives the same live inbound messages as production, reads the same canonical thread context, and records what it would have done. It never sends, moves, labels, or creates a draft in the customer mailbox. A person or the existing production workflow still owns the outcome. This post builds that boundary around a Nylas Agent Account. Nylas delivers the event and exposes the message/thread data; your application persists the candidate version, proposed action, and comparison result. That division matters: shadow mode is an application rollout feature, not an email-provider switch. I work on the Nylas CLI, so I use it to inspect the live plumbing and send test events. The implementation below is deliberately database-shaped rather than tied to a queue or model provider. A shadow run consumes real traffic but produces no customer-visible side effect. For each inbound message, it can return a structured proposal such as: { "action": "reply", "reason": "The customer asked for a password-reset link.", "body": "Hi Dana, here is a fresh password-reset link…", "confidence": 0.91 } That record is useful only when you compare it with an outcome you trust: the production agent's action, a human-approved reply, a support label, or an explicit reviewer verdict. A candidate that produces polished prose but would have answered a billing question instead of escalating it has not passed. Shadow mode is not a privacy exemption. The model sees live customer content, so run it only where you already have authority to process that content, minimize retention, and exclude attachments and tools unless the experiment specifically needs them. “No send” is an important safety property; it is not the whole threat model. This is a spoke, not a second webhook tutorial. The durable ingest pattern is in Build a webhook-driven email pipeline for your AI agent https://dev.to/mqasimca/build-a-webhook-driven-email-pipeline-for-your-ai-agent-211m : verify the raw-body signature, acknowledge quickly, persist a deduplicated job, and do model work in a worker. Subscribe to message.created as that guide shows. Nylas delivers webhooks at least once, so a redelivery must not create another shadow run. The top-level notification id is the delivery key; the inner data.object.id is the message key. Keep both, because they answer different questions. Do not create a Nylas draft for the candidate reply. Drafts are visible mailbox state, which means a human can mistake an experiment for a proposed response and send it. Shadow output belongs in your own database beside the rollout decision. This small schema is enough to start: create table shadow events notification id text primary key, grant id text not null, message id text not null, thread id text, received at timestamptz not null default now ; create table shadow runs notification id text not null references shadow events, candidate version text not null, proposed action jsonb, input hash text, input snapshot jsonb, status text not null default 'pending', outcome text not null default 'pending', reviewer verdict text, actual message id text, started at timestamptz, created at timestamptz not null default now , primary key notification id, candidate version ; In the webhook transaction, insert the event and its pending run before enqueueing it. ON CONFLICT DO NOTHING makes a redelivery a no-op: with event as insert into shadow events notification id, grant id, message id, thread id values $1, $2, $3, $4 on conflict notification id do update set notification id = excluded.notification id returning notification id insert into shadow runs notification id, candidate version select notification id, $5 from event on conflict do nothing returning notification id; Enqueue only when that statement returns a row. The first table makes webhook ingest idempotent. The second lets a deliberate backfill run another candidate version without pretending that prompt version A and model version B are the same experiment. This example retains an encrypted, access-controlled input snapshot for 30 days so a reviewer can replay a candidate; delete it after that window and retain only the hash and labels. After deletion, replay is intentionally impossible. The webhook tells you what changed. Fetch the message before you ask the candidate to reason about it, and use thread id to assemble the conversation context you actually need. The Nylas Message API returns a message body; the Messages list supports filtering by thread id . curl --request GET \ --url "https://api.us.nylas.com/v3/grants/