Agent-to-Agent Communication Over Email

Published: (June 13, 2026 at 11:38 PM EDT)
6 min read
Source: Dev.to

Source: Dev.to

Your procurement agent needs three quotes for a hardware order. The vendor on the other side runs a sales agent that answers pricing questions automatically. Neither team has talked to the other. There’s no shared API contract, no agreed-upon protocol, no integration project. The procurement agent just… sends an email. The sales agent replies. A negotiation happens.

That works because both agents have something most AI agents don’t: a real email address.

The interop problem nobody’s protocol has solved

The industry is busy designing agent-to-agent protocols — schemas for capability discovery, message envelopes, trust handshakes. All of them share a bootstrapping problem: both sides have to adopt the same spec, and specs only help once everyone you want to talk to has implemented them.

Email skipped that problem decades ago. It’s federated (anyone can run a mailbox on any domain), it has identity built in (the address), it has conversation state built in (threading), and every organization on earth already accepts inbound delivery. An agent that speaks SMTP can communicate with any counterpart — human or machine — without anyone agreeing on anything in advance.

What each agent needs: a first-class identity

Agent Accounts — a beta feature from Nylas — give an agent exactly that. Each one is a hosted mailbox like procurement-agent@yourcompany.com that sends, receives, maintains folders, and is indistinguishable from a human-operated account to anyone interacting with it over SMTP. Under the hood it’s just another grant: you get a grant_id that works with the existing Messages, Drafts, Threads, Folders, Attachments, and Webhooks endpoints.

The “indistinguishable from a human account” part matters more than it sounds. It means agent-to-agent and agent-to-human are the same code path. Your procurement agent doesn’t care whether sales@vendor.example is a person, a bot, or a person who hands hard questions to a bot. The conversation degrades gracefully to human handling at either end, which no bespoke agent protocol can claim.

The mechanics of a negotiation

Agent A opens the conversation with a plain send:

curl --request POST \
  --url "https://api.us.nylas.com/v3/grants/$AGENT_A_GRANT_ID/messages/send" \
  --header "Authorization: Bearer $NYLAS_API_KEY" \
  --header "Content-Type: application/json" \
  --data '{
    "to": [{ "email": "sales-agent@vendor.example" }],
    "subject": "Quote request: 40 units, SKU TR-200",
    "body": "Requesting a quote for 40 units of TR-200, delivery by end of quarter."
  }'
Enter fullscreen mode


Exit fullscreen mode

On the vendor’s side, the inbound message fires a standard message.created webhook — identical in shape to the same event for any other grant. Their agent reads it, reasons, and replies in-thread by passing reply_to_message_id on its own send. The platform populates the In-Reply-To and References headers automatically, so both mailboxes index the exchange as one conversation.

That threading is the quiet superpower here. Each agent reconstructs the full negotiation state by fetching the thread — every offer, counter-offer, and constraint is durable, ordered, and inspectable. No session store, no shared database, no conversation-state service. The thread is the state, and it’s replicated on both sides by the protocol itself.

Telling agents apart from humans in your handler

If your application also handles webhooks for connected human accounts, you’ll want to know which deliveries belong to agent mailboxes. The payload shape gives you nothing to branch on — by design, message.created for an Agent Account is identical to message.created for a Gmail or Outlook grant. The distinguishing signal is the grant itself: Agent Account grants carry provider: "nylas".

async function handleMessageCreated(payload) {
  const grantId = payload.data.grant_id;
  const grant = await getGrant(grantId); // cache this lookup

  if (grant.provider === "nylas") {
    return agentLoop.enqueue(payload); // agent mailbox — route to the negotiation loop
  }
  return humanInboxHandlers.dispatch(payload); // connected human account
}
Enter fullscreen mode


Exit fullscreen mode

One handler, one branch, and the rest of your webhook infrastructure — verification, retries, queueing — stays shared between humans and agents.

Guard the loop before the model sees anything

An agent that replies to whatever lands in its inbox will reply to spam, phishing, and cold outreach from other people’s runaway agents. Inbound rules let you reject that traffic at the SMTP stage, before the message is stored and before message.created ever fires — your negotiation loop never sees it.

Rules match on sender fields (from.address, from.domain, from.tld) and run actions like block, mark_as_spam, or assign_to_folder. For values that change over time, point a rule at a list through the in_list operator: a typed collection of domains or addresses that anyone can update without touching the rule. A practical setup for a procurement agent is an address list of known vendor counterparts, a rule that routes their mail to a negotiations folder, and a block rule in front of everything from domains you’ve flagged. The agent then reasons only over mail that survived the filter.

Structured data rides along fine

Email bodies are free text, which suits LLM agents — the model parses the counterpart’s prose directly. But nothing stops you from embedding structure: a JSON block in the body, an attachment with the formal quote, a machine-readable footer. The pattern that works well is human-readable prose with a structured payload appended, so the same message serves a human reviewer and a parsing agent equally. If the counterpart turns out to be a person, they ignore the JSON. If it’s an agent, it skips your prose.

The honest caveats

Email’s latency is seconds, not milliseconds — webhook delivery typically lands shortly after the SMTP handoff, but this isn’t a channel for tight request/response loops. It’s a channel for negotiations, confirmations, and workflows that span hours or days, where durability beats speed.

There are quotas to respect too. The cap is 200 messages per account per day on the free plan (paid plans drop the daily cap by default), and outbound messages are capped at 40 MB total. A runaway agent loop — two bots politely thanking each other forever — will burn through a daily quota fast, so build a turn limit or a “no new information” detector into your reply logic.

And identity cuts both ways: because your agent has a real address on your real domain, its behavior affects your domain’s sender reputation. One application can manage accounts across unlimited registered domains, which is why putting agents on a dedicated subdomain is the standard recommendation.

Try the two-mailbox experiment

The fastest way to feel this pattern is to provision two accounts in the same application — the quickstart takes under 5 minutes per mailbox — wire each to a different LLM with a different objective (“buy below $50/unit” vs. “sell above $45/unit”), and let them email each other. Watch the thread in the mailbox as they converge.

Then ask yourself: what cross-organization workflow are you currently exposing a REST API for that could just be… an email address?

0 views
Back to Blog

Related posts

Read more »