Controls · September 27, 2026

Monitor Every Email Your Agent Sends

How to monitor what emails my agent sent: four alerts built only from events that actually fire, and why two obvious ones would stay green forever.

Your agent has been sending mail on its own for a week. Nothing has gone wrong that you know of, and that is the problem: you would only know if you went and looked. When people ask how to monitor what emails my agent sent, the usual answer is “open the sent folder”. That tells you what happened after you thought to check. It does not tell you on Tuesday at 03:00 that the agent started writing to addresses your policy forbids.

The record is not the hard part here. The console shows every thread, and the API returns every message. What this page settles is what to alert on: four alerts built only from events and responses this build actually produces, with the thresholds you should set from your own baseline. It also covers the two alerts you would reach for first, which would never fire. For which fields to keep for later reconstruction, read what to log in an agent email audit trail. This piece is about noticing, not archiving.

Where the record of every agent email already lives

Three surfaces hold it, and none of them needs to be built.

The human console shows every thread in every inbox, inbound and outbound, including mail the agent composed and the approval queue holding anything a policy stopped. That is the review surface for a person. The agent inbox is a real mailbox, not a send log, so a reply the recipient sent lands in the same thread as the message the agent wrote.

The REST API returns the same data to a program. GET /v1/projects/{project_id}/inboxes/{inbox_id}/messages lists messages with state, direction, environment and simulated on each, paged by cursor and limit, and needs messages:read. GET /v1/projects/{project_id}/events is the pollable event stream, which is what alerting runs on.

Exports cover bulk review. POST /v1/projects/{project_id}/exports starts a job, GET /v1/projects/{project_id}/exports/{export_id} reports status, and .../exports/{export_id}/download streams checksummed NDJSON. Exports are for the weekly read-through at the end of this page, not for alerting, because only a human session can create or read one.

Qasim Muhammad’s dev.to post “Auditing What Your Email Agent Actually Did” (15 June 2026, read on 27 September 2026) is built on Nylas Agent Accounts, which it describes as in beta. It takes the sent-folder route: subscribe to the send-outcome triggers (message.send_success, message.send_failed, message.bounce_detected), store the payloads keyed by grant, and “schedule a weekly skim”. That is a sound review habit and it is the one this page ends on. Nylas is also ahead here on one point: the post says a record is kept when a policy rule blocks, routes or flags a message, showing which rule fired and why. As alert 1 explains, this build keeps no such record of a refused send. What the post does not give you is an alert design, and neither do the contact-centre, CRM-log and employee-monitoring pages that ranked alongside it for this query on 27 September 2026. By alert design we mean a statement of which condition, on which signal, should wake someone up.

Two of the obvious alerts would never fire

Before designing anything, separate the events that are declared from the events that are emitted. An alert on a declared event that is never written is a permanently green dashboard. It is worse than no alert, because it looks like evidence that nothing is wrong.

The customer event vocabulary in packages/contracts/src/events.ts has eighteen types. Two of them look purpose-built for monitoring an agent: message.quarantined and project.sending_paused. Neither is emitted anywhere in packages/domain or workers in this build. The webhook endpoint will still accept a subscription to either, because subscription validation checks the name against the declared list, not against what fires. You can subscribe, receive a 201, and wait forever.

Here is what is emitted for mail, and under which condition. (inbox.created, domain.updated and webhook.test also fire, but they say nothing about what the agent sent.) These are the only names to build on.

EventEmitted whendata fields
message.queueda send is committed, direct or after approvalmessage_id, thread_id, inbox_id
message.acceptedthe provider accepted the submissionmessage_id
message.failedthe provider definitely rejected it, or the retry window closedmessage_id, sometimes reason
message.submission_unknownthe provider call ended without a provable answermessage_id
message.delivery_updateda per-recipient outcome arrivedmessage_id, recipient, status
approval.requesteda send was held for a humanapproval_id, draft_id
approval.rejecteda human declined a held sendapproval_id
message.receivedinbound mail landedmessage_id, thread_id, inbox_id, environment

message.canceled, approval.approved, approval.invalidated, inbox.updated and inbox.deleted are declared and not emitted either. The events and webhooks reference marks each one.

The word “quarantined” does appear in the source, in two places that are not customer events. Attachment scanning can mark an upload quarantined, which rejects it before it can be attached. Inbound ingestion can mark a receipt quarantined when evidence does not line up. Neither produces anything on your event stream, and neither is about what your agent sent.

How to monitor what emails my agent sent: the four alerts

Four conditions cover the ways an autonomous sender goes wrong: it tries to do something it should not, its mail stops getting out, a human becomes the bottleneck, or sending stops altogether. Each alert below names its exact signal. Where no event exists, the signal is a response code your agent’s own client has to count.

AlertSignalFires onBlind to
Off-policy pressurepolicy_denied responses, approval.requested ratea rise over the agent’s baselinedenials seen by a client you do not own
Outcome failuremessage.failed plus message.submission_unknown shareshare of terminal outcomes above thresholdsends that end with no event at all
Approval backlogpending rows on the approvals list, oldest created_atcount or age above thresholdrows past the newest 100
Sending stopped403 permission_denied, 402 quota_exceeded, silent acceptsfirst occurrence, or zero accepts in a windownothing, if you run both halves

The thresholds are yours to set. This page does not publish recommended numbers because none were measured; an agent that emails a fixed list of vendors and one that answers inbound support have nothing in common at the rate level. Take a week of the poller below as a baseline and alert on departures from it. One departure has a known shape: a burst of message.queued on a single thread_id is what an agent answering an auto-responder looks like, and the rules that stop it are in stopping an infinite agent auto-reply loop.

Alert 1: off-policy pressure, and why the earliest signal is not an event

The earliest sign that an agent has gone off-policy is a send the policy refused. That refusal is returned to the caller and written nowhere you can read later. It is not an event, it does not appear on the stream, and a webhook will never carry it.

When a send breaks an inbox, project or workspace policy, the send call returns HTTP 403 with code policy_denied and details.reason_codes set to one of send_disabled, blocked_recipient, recipient_not_allowed or unsupported_policy_rule (packages/domain/src/services.ts:1544). The daily recipient limit also answers policy_denied, with the message “The daily recipient limit has been reached.” (packages/domain/src/daily-limits.ts:25). No message is created and no event is written.

So the first half of this alert lives in your agent’s client, not in the product. Wrap the send call and increment a counter keyed by reason code on every policy_denied. An agent that is being steered by a prompt injection toward an attacker’s address shows up here first, as recipient_not_allowed or blocked_recipient, on a key that has never produced one before. That signal only exists if an allowlist or blocklist is set, which is covered in restrict which domains your agent can email.

The second half is on the stream. A send the policy did not refuse but wanted a human for becomes a held draft and emits approval.requested. The reason codes are not in the event payload; fetch the approval by approval_id and read reason_codes, which carries the rule types that matched: always, new_recipient_domain or attachments. It is empty when the send was held only because the credential can draft but not send; the send response says draft_only_principal in that case, but the stored approval does not. A rise in held sends with attachments from an agent that never attaches files is a behaviour change worth a page.

One more counterparty signal belongs here. message.delivery_updated carries a status of delivered, bounced, complained, suppressed, delayed or failed per recipient. A complained status means a person marked the agent’s mail as spam, and it also adds that recipient to the workspace suppression list. One complaint is an incident, not a rate.

Alert 2: failed and unknown outcomes, plus the sends that go quiet

Alert on the share of terminal send outcomes that are not message.accepted. Each committed send emits message.queued once, then at most one of message.accepted, message.failed or message.submission_unknown when the provider answers (services.ts:4460). Count those three over a rolling window and alert when failed plus unknown crosses your threshold.

Keep the two apart in the alert text, because they need opposite responses. message.failed means the message did not go and the recipient units were released. Two failure paths carry a reason: provider_retry_window_expired and reply_parent_headers_not_available. The generic provider rejection carries only message_id. message.submission_unknown means the provider may have accepted it, the units stay held, and the message must not be resent, because a resend of a message the provider did accept is a duplicate in the recipient’s inbox.

Now the gap. Two ways a send can end emit no terminal event at all:

So the failure-rate alert needs a companion: every message.queued should be matched by a terminal event within your window, and any that is not gets looked up. Keep the message_id and inbox_id from each message.queued, clear it when the terminal event arrives, and for anything still open after the window, read the message from the messages list and alert on its state. A send that shows canceled or submission_unknown with no event is exactly the send you would otherwise never hear about. Do not wait for a submission_unknown message to clear itself: a later provider callback cannot be matched to it, so it stays unknown and a person has to check the provider’s log (what that involves). The full state walk for a single missing message is in my agent says it sent it and nobody received it.

Alert 3: the approval backlog

A held send is a promise the agent made to someone and a human has not kept yet. Alert on the number of pending approvals and on the age of the oldest one.

approval.requested fires when a send is held and approval.rejected fires when a human declines it. approval.approved is declared and never emitted; approving commits the send, which emits message.queued without an approval_id in the payload. You cannot compute the backlog by subtracting events. Read it directly instead.

GET /v1/projects/{project_id}/approvals needs approvals:read and returns the project’s approvals newest first, each with id, inbox_id, draft_id, state, reason_codes, created_at and expires_at (packages/api/src/index.ts:205). The route is served but is not yet listed in the published OpenAPI document, so a generated client will not have it; call it by path. Count state: "pending" and take the oldest created_at. Approvals expire seven days after the request, and a pending row read after its expires_at is reported as expired. An expired approval is a send that silently never happened, so count those too.

Two limits on this list are worth knowing before you trust the number. It returns at most the 100 newest approvals, next_cursor is always null, and has_more is true when the page is full. If has_more is true, your pending count is a lower bound, and that is itself the alert. A key restricted to certain inboxes sees only those inboxes’ approvals. The mechanics of the gate are covered in human approval before an AI agent sends email.

Alert 4: sending has stopped

project.sending_paused does not fire, so a pause has to be detected where it surfaces: in the response to the next send, and in the absence of accepts.

A send that reaches a paused or inactive target returns HTTP 403 permission_denied with the message “Sending is paused or the resource is inactive.” (services.ts:4263), or “Sending is paused for this workspace.” from the entitlement check (packages/domain/src/billing.ts:687). An exhausted allowance returns HTTP 402 quota_exceeded, “This workspace has exhausted its delivery allowance.” (billing.ts:775). Alert on the first of any of these, not on a rate.

That catches the pause when the agent next tries to send. It misses mail already queued, which is cancelled at dispatch with no event, as described under alert 2. It also misses the quieter case where the agent itself stops calling. Pair the response alert with a heartbeat: if message.accepted count over a window drops to zero while message.received keeps arriving on an agent that normally answers, something between the two has stopped. For a human, the inbox policy endpoint also reports effective_send_enabled, but that endpoint requires a human session, so an agent key cannot poll it.

The poller that feeds all four alerts

One loop over the events endpoint, one wrapped send call, and one approvals read on a timer. The credential needs events:read, messages:read and approvals:read, and nothing that can send.

The events endpoint takes cursor and limit. limit is 1 to 100 and defaults to 25. Each item carries id, type, sequence, occurred_at, data, environment and api_version, and the page returns next_cursor and has_more. There is no type filter, so count types on your side. next_cursor is null on an empty page, so keep the last cursor you had. Give the poller a key that covers every inbox in the project. A key restricted to some inboxes sees only events tied to those inboxes, and approval.rejected is written without an inbox, so a restricted poller never sees a rejection.

const BASE = `https://api.emailforagents.ai/v1/projects/${process.env.EFA_PROJECT_ID}`;
const H = { Authorization: `Bearer ${process.env.EFA_API_KEY}` };
const TERMINAL = ["message.accepted", "message.failed", "message.submission_unknown"];

let cursor = await loadCursor(); // your storage; undefined on first run
const open = new Map(); // message_id -> { inbox_id, queuedAt }
const counts = {};

async function pollEvents() {
  for (;;) {
    const qs = new URLSearchParams({ limit: "100", ...(cursor ? { cursor } : {}) });
    const res = await fetch(`${BASE}/events?${qs}`, { headers: H });
    if (res.status === 410) { cursor = undefined; continue; } // cursor_expired: restart
    if (!res.ok) throw new Error(`events poll failed: ${res.status}`);
    const page = await res.json();
    for (const ev of page.data) {
      counts[ev.type] = (counts[ev.type] ?? 0) + 1;
      if (ev.type === "message.queued")
        open.set(ev.data.message_id, { inbox_id: ev.data.inbox_id, queuedAt: ev.occurred_at });
      if (TERMINAL.includes(ev.type)) open.delete(ev.data.message_id);
      if (ev.type === "message.delivery_updated" && ev.data.status === "complained")
        page_oncall("complaint", ev.data);
    }
    if (page.next_cursor) cursor = page.next_cursor;
    await saveCursor(cursor, counts, open); // one transaction, or a crash skips events
    if (!page.has_more) return;
  }
}

Run the unmatched-send check against open after your window: fetch the message list for each inbox_id, find the message_id, and alert on a state of canceled or submission_unknown. Run the approvals read on its own timer. Put the policy_denied, permission_denied and quota_exceeded counters in the agent’s send wrapper, because the poller will never see them.

A cursor_expired response is a 410 and is not retryable; restart from the beginning rather than backing off, and treat the gap as a reason to reconcile from the messages list. Webhooks can feed the same counters with lower latency, but the webhook body is only id, type, api_version and data, with no sequence. Use the cursor as the source of truth and the webhook as a hint; the trade-off is worked through in agent email polling vs webhooks.

The weekly human review, and what alerting cannot tell you

Alerts catch rates and absences. They do not tell you whether the agent’s mail was any good. That still needs a person reading threads, and the export is the efficient way to hand them a week at once.

A human with exports:write and messages:read starts a project_messages export, which carries each message’s direction, subject, from, to, state, safety_status, environment and simulated, with bodies included only where safety_status is allowed. Filter to direction: "outbound" and read a sample. An API key cannot create or read an export at all, by design, so this review cannot be delegated to the agent being reviewed.

What none of this is: a monitoring service run on your behalf. Nothing here pages anyone unless you wire it to, and no uptime SLA or response-time commitment is published. Test inboxes use a simulated transport and a non-routable .mail.invalid address, so an alert firing on test traffic tells you about your agent’s logic, never about real delivery. The enforcement these alerts watch, scopes, policies, approval gates and allowances, is described on the controls surface. Build the four alerts against the events that fire, count the refusals the stream never sees, and delete any subscription to an event that is only declared.


Keep reading

All guides · Documentation · Controls