Inboxes · September 28, 2026
Cc, Bcc and Reply-All Rules for Agents
The recipient rules an agent should follow on reply-all: who goes in To, what to keep in Cc, why Bcc never carries forward, and what each extra name costs.
A human hits Reply All by accident and five colleagues get a slightly awkward email. An agent does it on purpose, in a loop, on every thread it touches. It copies a customer’s manager, carries a Bcc’d auditor into the open, emails itself, and pays for every one of those addresses. Reply-all is the widest blast radius available to an agent with send access, because one decision is multiplied by every name on the thread.
This article settles the agent reply-all, Cc and Bcc rules: how to resolve the recipient list for a reply, what the stored thread does and does not tell you about who was copied, what a wide reply costs in email units, and where to put the human backstop. If your problem is the reply landing in the wrong conversation rather than reaching the wrong people, read how agent replies stay in the right thread instead.
There is no reply-all operation, so your code is the recipient policy
The send path composes nothing. A reply is an ordinary send to
POST /v1/projects/{project_id}/inboxes/{inbox_id}/messages with reply_to_message_id
set, and that field only decides which thread the message joins and which threading
headers go out. to is still required, with at least one entry. cc and bcc default to
empty. The MCP send_message tool describes the same field plainly: it preserves the
thread and reply headers, and you supply explicit recipients and subject.
That is the right design for an agent, and it moves the whole problem into your code. Every rule below is something the agent’s tool wrapper has to do, because nothing downstream will do it for you. Three limits apply whatever you decide:
- At most 25 unique recipients across To, Cc and Bcc combined. The service counts
the deduplicated set before anything is stored, and a larger set fails with HTTP 400
invalid_requestand the message “Too many recipients.” A 30-person thread cannot be replied-all at all. The agent has to narrow it. - Every recipient in any field is one email unit on a live send. Three people is three units, whether they sit in To, Cc or Bcc.
- Inbox policy is evaluated against all three fields. A Bcc address is not a way around a block rule.
Replying also needs messages:read on top of the sending scope, since the service reads
the parent before it lets the reply through.
Resolving the recipients of a reply, in order
This is the procedure. Apply it in this order, because later rules depend on earlier ones.
- Pick the parent deliberately. Reply to the latest inbound message in the thread that was not sent by your own inbox. Replying to your own last outbound message, which is the natural thing to grab when the agent is following up, puts your own address in the reply’s To line and exposes your earlier Bcc list to the resolver.
- To is the parent’s Reply-To if it has one, otherwise its From. Reply-To is the author telling you where answers belong. It replaces From; it does not join it.
- Cc is the parent’s To plus its Cc, only when the agent is doing a reply-all and only when the agent was a visible recipient (rule 6).
- Bcc starts empty. Nothing is ever promoted into it or out of it from the thread.
- Remove every address your project owns. At minimum the inbox’s own
addressfromlist_inboxes. If another of your agents is on the thread, dropping it is the default and keeping it is a decision you should be able to justify, because two agents on each other’s Cc lines is how reply loops start. - If the agent’s address is not in the parent’s To or Cc, do not reply-all. It received the message some other way: it was Bcc’d, or the mail reached it through a list or a forward. Replying to everyone announces a recipient the sender chose to hide. Narrow to the sender and flag it.
- Deduplicate across all three fields, case-insensitively. Keep each address once, in its strongest position: To beats Cc beats Bcc.
- Count, then decide. If the result is over 25, the send will be refused. If it is over your own width threshold, route it to a human (covered below).
Rule 7 matters more than it looks. The service deduplicates before counting, so the same
address in To and Cc costs one unit and one slot under the cap. But its normaliser only
lower-cases the domain. Dana@corp.example and dana@corp.example are two recipients,
two units and, at most receiving servers, two copies in Dana’s mailbox. The billing detail is in
what happens when the allowance runs out; the
practical rule is to lower-case the whole address in your resolver before comparing
anything.
What the stored thread tells you about Cc and Bcc
The message objects returned by list_messages, get_message and the thread messages
route carry from, to, cc, bcc and reply_to. How those fields are populated
depends on direction, and the difference is exactly what the resolver above relies on.
| Field on the message | Inbound message | Outbound message (sent by this inbox) |
|---|---|---|
to, cc | The visible To and Cc headers as received | What the agent sent |
bcc | Always an empty array | The Bcc list the agent sent |
reply_to | Addresses from the Reply-To header, if any | Empty |
| Envelope recipient | Stored for routing, never returned | Not applicable |
Four consequences follow.
An inbound message cannot leak a Bcc list, because the API never returns one. The service stores the visible To and Cc headers and the envelope recipient it actually routed on, and it filters both Bcc and envelope rows out of every message it returns. So a resolver working only from inbound parents cannot promote a hidden recipient even if it tried.
Your own outbound messages are where the hazard lives. The agent’s sent messages do
return their bcc list, because the agent is entitled to know what it sent. A resolver
that takes “everyone on the last message” from an outbound parent will read that list and,
if it is careless, fold it into To or Cc. That single step turns a quiet copy to a
compliance archive into an announcement to the customer. This is why rule 1 picks an
inbound parent and rule 4 starts Bcc empty.
Visibility is checkable. The inbox’s own address appears in the parent’s to or cc
only if the sender addressed it visibly. Its absence is the signal for rule 6. One edge to
know: when an inbound message arrives with no usable To header at all, the service
records the receiving inbox as the To recipient, so an “undisclosed recipients” message
looks as if it was addressed to the agent directly. Treat a parent whose only To entry is
your own inbox, with no Cc, as a one-to-one message and reply only to the sender.
Names are not reliable. On live inbound mail, to and cc entries carry the address
and no display name. If the agent writes “Hi Dana and Sam” it has to get the names from somewhere other
than the recipient list, which is a good reason to address the sender only in the
greeting.
A resolver you can drop into a tool wrapper
This is the procedure above as a function over the message objects returned by the thread
messages route, which is the one that carries sequence. It has no network calls, so it
is trivial to unit-test against fixtures from a test inbox. It only
handles replies: when the agent starts a new message, the recipients have to come from a
system of record instead, as covered in
what to do when an agent invents a recipient address.
type Addr = { email: string; name?: string };
type Msg = {
id: string;
direction: "inbound" | "outbound";
sequence: number;
from: Addr;
to: Addr[];
cc: Addr[];
bcc: Addr[];
reply_to: Addr[];
};
const norm = (email: string) => email.trim().toLowerCase();
export function resolveReply(
thread: Msg[],
ownAddresses: string[],
mode: "reply" | "reply_all",
) {
const own = new Set(ownAddresses.map(norm));
const parent = thread
.filter((m) => m.direction === "inbound" && !own.has(norm(m.from.email)))
.sort((a, b) => b.sequence - a.sequence)[0];
if (!parent) throw new Error("no inbound message from someone else to reply to");
const visible = [...parent.to, ...parent.cc].some((r) => own.has(norm(r.email)));
const seen = new Set(own); // rule 5: own addresses are pre-seen, so never added
const take = (list: Addr[]) =>
list
.filter((r) => {
const key = norm(r.email);
if (seen.has(key)) return false; // rule 7: first, strongest position wins
seen.add(key);
return true;
})
// `from.name` falls back to the address itself when the sender gave no name
.map((r) => ({
email: norm(r.email),
...(r.name && norm(r.name) !== norm(r.email) ? { name: r.name } : {}),
}));
const to = take(parent.reply_to.length ? parent.reply_to : [parent.from]);
const widen = mode === "reply_all" && visible;
const cc = widen ? take([...parent.to, ...parent.cc]) : [];
return {
reply_to_message_id: parent.id,
to,
cc,
bcc: [] as Addr[], // rule 4: never inherited
recipients: to.length + cc.length,
narrowed: mode === "reply_all" && !visible,
};
}
Then send it with the idempotency key derived from the parent, not from the attempt:
curl -X POST "https://api.emailforagents.ai/v1/projects/$EFA_PROJECT_ID/inboxes/$EFA_INBOX_ID/messages" \
-H "Authorization: Bearer $EFA_API_KEY" \
-H "Idempotency-Key: reply-$PARENT_ID-v1" \
-H "Content-Type: application/json" \
-d '{
"reply_to_message_id": "msg_...",
"to": [{"email":"dana@corp.example"}],
"cc": [{"email":"sam@corp.example"},{"email":"lee@partner.example"}],
"subject": "Re: Renewal terms",
"text": "Thanks Dana, the revised terms are below."
}'
subject is required on a reply exactly as on a new message. The schema is strict, so a
stray field such as reply_all: true is a 400, not an ignored hint. If the resolver
returned narrowed: true, log it: an agent that was Bcc’d on a thread is information a
human usually wants.
What one reply-all costs
A send costs one email unit per unique recipient, and a received message costs one unit. The unit count for a send is the size of the deduplicated To, Cc and Bcc set, which is the same number the 25 cap is checked against. Test inboxes are never charged, and the free allowance is 100 units a month per verified owner identity.
Run the arithmetic on an ordinary thread: a customer writes in and copies two colleagues, and the agent is in To.
| Step | Units |
|---|---|
| The customer’s message arrives | 1 |
| Agent replies to all three | 3 |
| Each of the three replies-all back | 3 |
| Agent replies-all to the latest | 3 |
| Same thread, but the agent left itself on Cc every time | +1 per send, +1 per returning copy |
Ten units for a six-message exchange. If the self-address slipped through, each of the agent’s two sends carries one more recipient, and any copy that comes back into the inbox is billed again as a received message, so the same thread costs twelve to fourteen. The agent’s own two replies account for six of those units; addressed to the sender only, they would have cost two. The inbound side is not yours to control, which is exactly why the outbound side has to be tight.
Two properties of the meter change how you should handle a wide reply:
- Reservation is all or nothing. Units are reserved for the whole recipient set when
the send is committed. If the workspace has two units left and the reply has three
recipients, the send fails with HTTP 402
quota_exceeded; it does not go to the first two. A failed send releases its reservation. - A daily recipient limit behaves the same way. If an inbox, project or workspace
policy sets
daily_recipient_limit, each live send reserves its unique recipient count against the current UTC day. A send that would exceed it fails with HTTP 403policy_deniedand the message “The daily recipient limit has been reached.” Nothing is queued.
Neither error is a signal to retry with a trimmed list automatically. Dropping recipients until the send fits is the agent deciding, under budget pressure, who no longer needs to know. That is a decision for a person.
How inbox policy treats a wide send
Policy sees the recipient set, not the fields. Allow and block rules are checked against every unique address across To, Cc and Bcc, and the verdict applies to the whole message:
| Situation on a reply-all | Result |
|---|---|
| One Cc’d address is on a block entry | Whole send refused, 403 policy_denied, reason blocked_recipient |
| Allow list set, one Cc’d address outside it | Whole send refused, 403 policy_denied, reason recipient_not_allowed |
| A blocked address placed in Bcc instead of Cc | Refused the same way. Bcc is checked like any other field |
| Sending turned off at any layer | Refused, reason send_disabled |
The allow-list case is the one reply-all agents hit most. A customer copies a partner at another company, the agent’s inbox is allowed to mail only the customer’s domain, and a naive reply-all is refused outright. The tempting fix is to strip the outside address and resend. Do not do that silently: the customer copied that person for a reason, and a reply that quietly drops them changes the conversation without anyone deciding it should. Reply to the in-policy recipients only if a human has said that is acceptable, and otherwise hold the reply. How the rules are written and layered is covered in restricting which domains an agent can email.
One limitation to design around: every rule value is lower-cased before matching, but the
recipient keeps the case of its part before the @. A block entry for dana@corp.example
therefore does not match a send to Dana@corp.example, while a domain entry for
corp.example matches both. Lower-casing in the resolver, as rule 7 already requires,
keeps your own sends consistent with the rules. For anything you need to contain
reliably, prefer a domain entry to an address entry.
Put wide sends behind a human
There is no policy rule for “hold any send with more than N recipients.” The inbox policy
offers approval_mode of direct or always, set by an owner or admin through
PUT /v1/projects/{project_id}/inboxes/{inbox_id}/policy, and always holds every send.
That is too blunt for most agents, so enforce the width threshold in the tool wrapper,
where the resolver has just counted the recipients, and use the draft path as the gate.
Over MCP this is one branch. create_draft runs through the same send pipeline with the
sending permission removed, so it can never transmit, and a draft from a connection that
holds draft access lands in the approval queue:
const plan = resolveReply(thread, [inbox.address], "reply_all");
const needsHuman = plan.narrowed || plan.recipients > 3; // over 25 is refused anyway
const tool = needsHuman ? "create_draft" : "send_message";
Over REST the same split is two credentials: a key with messages:send for narrow
replies, and a key holding drafts:submit without messages:send for wide ones. A send
from the second key always returns HTTP 202 with result: "approval_required" and an
approval_id, with reason_codes of ["draft_only_principal"] unless a policy rule
also required approval.
What the gate gives you, precisely:
- Policy still runs first. A blocked or disallowed recipient is refused before anything is queued, so the approver never sees a send that could not go.
approval.requestedis emitted when the item is queued, andapproval.rejectedwhen a reviewer declines it. Those are the two approval events this build emits. Once a reviewer approves, the send is committed andmessage.queuedfollows like any other send.- Nothing is charged while it waits. Units and the daily limit are reserved when the send is committed, which for a held message happens when a human approves it. A rejected reply-all costs nothing.
- The held draft keeps the whole payload, Bcc included. Approval is bound to a hash of that exact payload, so the recipients a reviewer approves are the recipients that go. A reviewer should check every one of them, not a count, which is the first item on the approval review checklist.
A threshold of three or four recipients sounds conservative until you run the table in the cost section. Most useful agent replies go to one person. The reply-alls that matter are rare enough that a human glance costs less than the one that goes wrong.
When the rules flip
Each default above has a case where the opposite is right. Decide these explicitly and write them into the wrapper, rather than letting the model infer them from the thread.
| Default | Flip it when |
|---|---|
| Bcc starts empty | Every send must copy an address you own, such as an archive or CRM intake. Add it fresh on each send from config, never from the thread |
| Reply to sender when Bcc’d | The Bcc was an internal routing copy and the sender expects the agent to take over. Still reply to the sender only, and let them decide who to copy |
| Reply-To replaces From | Reply-To points at a list and the question is personal. Reply to From and say why in your log |
| Drop your own other agents | The thread is a deliberate hand-off between two of your inboxes, with loop controls in place, as described in preventing agent reply loops |
| Keep everyone on reply-all | The thread has grown past 25, or past your threshold. Reply to the sender and hold the wide version for approval |
The first row is the one that tempts people to carry Bcc forward from the agent’s own earlier sends, because “the archive was on the last message.” Adding the archive from configuration gives the same result without ever reading a Bcc list back out of a thread, which means no code path exists that could move it into Cc.
Build the resolver, give it fixtures for each row of that table, and gate anything wide behind a draft. The agent keeps answering the person who asked, and the rare message that needs to reach everyone gets a human’s eyes first.
Keep reading
Keep an Agent's Replies in the Right Email Thread
How Message-ID, In-Reply-To and References decide threading, why subject matching breaks, and how to keep an agent reply in the right conversation.
AI Agent Hallucinated an Email Address? Fix the Source
An AI agent's hallucinated email address either bounces or reaches a stranger. Take recipients from records, not model output, and gate the rest.
Restrict Which Domains Your Agent Can Email
Restrict which domains your agent can email with an allowlist it cannot argue with: the policy call, how recipients match, and what a denied send returns.