DEV Community

Atomic Mail
Atomic Mail

Posted on

I Handed My Support Inbox to an AI Agent, and the Hardest Part Was Teaching It to Stay Quiet

Let me start with the honest part
I am not going to open this with a story about scaling or automation strategy. The real reason I built this is that I was doing support alone, on a small team, and it was eating me. Thirty to forty minutes a day, spread across the day in five-minute chunks, which is the worst possible shape for a workday. Every time I sat down to do something that required a brain, the inbox pulled me back out.

The obvious answer was to hire someone. I could not afford to yet. So I did the other thing: I gave the job to an agent.

Total build time was two to three hours, including the part where I broke it. Everything in the stack is free except the model key. I am not going to pretend this is a sophisticated system. It answers first-line support email, and it does that reliably, and that was the whole goal.

What I actually built
Four pieces, and that is it:

Agno as the agent framework. It holds the agent definition, the instructions, and the knowledge.
Atomic Mail as the email tool. It is email built for AI agents, so the agent gets an inbox it can actually read from and reply to, instead of me duct-taping an IMAP client to it.
A cron job that wakes the thing up every five minutes.
GPT-4o through OpenRouter as the model behind it.
The flow is boring on purpose:

cron (every 5 min)

  • agent wakes up
  • reads unread mail via Atomic Mail
  • checks it against product docs + example replies
  • drafts a reply in our voice
  • sends
  • marks the thread as handled

That last line, "marks the thread as handled," looks like a footnote. It is the whole article. More on that below.

The setup, roughly
Naming the agent and writing its instructions took me longer than wiring anything up, and I think that is correct. The instructions are the product here. The code is the delivery mechanism.

from agno.agent import Agent
from agno.models.openrouter import OpenRouter

support = Agent(
    name="Support",
    model=OpenRouter(id="openai/gpt-4o"),
    instructions=[
        "You answer first-line support email for <product>.",
        "Match the tone of the example replies. Short, plain, no corporate filler.",
        "If the answer is not in the docs, say you are checking with the team and flag it.",
        "Never invent pricing, timelines, or features.",
    ],
    tools=[atomic_mail_tool],
    knowledge=product_docs,
)
Enter fullscreen mode Exit fullscreen mode

Two things mattered more than the rest.

Feeding it real replies, not just docs. Docs tell the agent what is true. Example replies tell it how we sound. If you only give it docs, you get an answer that is technically correct and reads like a compliance notice. I dumped in a pile of my own past replies and the difference was immediate.

Telling it what to do when it does not know. This is the single instruction I would keep if I had to delete every other one. An agent with no escape hatch will confidently make something up about your refund policy. An agent with an escape hatch says "let me check with the team on that" and flags the thread, which is exactly what a new support hire would do.

Problem one: I could not get Agno running locally
First time touching the framework, and I lost real time to setup. Not to anything conceptually hard, just the usual environment mud: versions, keys, imports that moved between releases, the sequence of "why does this work in the example and not here."

Hermes did most of the heavy lifting on that part. I am mentioning it because I think people underreport this phase. The interesting engineering in agent projects is instructions, tooling, and guardrails. The part that actually burns your evening is getting a local environment to a state where you can run the loop once. If you are budgeting time for a build like this, budget for that, not for the agent logic.

Problem two: I built a loop that would not shut up
Here is the failure I actually want to talk about.

I set the cron to every five minutes and shipped it. What I had not thought about was state. The agent had no idea what it had already answered. So every five minutes it woke up, looked at the inbox, saw the same threads, and decided they needed a response.

Same customers. Same questions. Every five minutes. Cheerfully.

The screenshot is not pretty and I am attaching it anyway, because everyone who builds one of these hits some version of this and mostly people quietly fix it and never mention it.

The bug was not the model. The model did its job perfectly, which is the problem. It was asked "does this email need a reply" with no memory, and with no memory the honest answer is always yes.

What fixed it:

  1. Only fetch unread. Not "fetch inbox." The query itself has to be the filter, because anything you filter after the fetch is something the model can still see and act on.
  2. Mark handled immediately after sending. Not at the end of the run. If the run dies halfway, everything it already sent has to stay marked.
  3. Keep a record of message IDs it has replied to, and check against it before drafting. Belt and suspenders, but this class of bug is cheap to prevent and expensive to explain to a customer.
  4. Add a lock file so runs cannot overlap. A run that takes longer than five minutes will otherwise get a second copy of itself standing next to it, both looking at the same unanswered thread. The general lesson I took from it: an agent on a timer is a loop, and every loop needs a termination condition. I was thinking about the agent as an employee checking mail. It is not. It is a while True that costs money and talks to your customers.

Why GPT-4o and not something heavier
Price to speed. Support triage is not a reasoning-heavy task. It is retrieval, tone matching, and knowing when to escalate. Paying for a frontier model to do that is paying for capability that never gets used.

I might revisit it if I start seeing missed edge cases. So far the misses I have seen would not have been fixed by a smarter model, they would have been fixed by better docs, which is its own useful signal.

Where it landed
Support is fully handed off. Nothing sits unanswered overnight. I am not losing thirty to forty minutes a day in five-minute slices anymore, and more importantly I am not context-switching into it.

The unexpected benefit: the flagged threads are a much cleaner signal than my own memory ever was. When the agent says "I could not answer this," that is a documentation gap with a timestamp on it. I used to absorb those gaps by just knowing the answer. Now they show up in a list.

What I am actually curious about
I have a suspicion this setup is basic compared to what people are running, and I would like to know how basic.

Specifically:

  • Is anyone here handling more than first-line with this? Refunds, account changes, anything that writes to a system rather than just replying?
  • How are you handling escalation? Mine flags and stops. I assume there is a better pattern.
  • What is the most over-engineered "reply to support email" you have seen someone build? I want to see the version with three agents, a vector database, and a human-in-the-loop approval queue, for a company with eleven customers.

And if you have built something similar and it went sideways in a way mine has not yet, I would genuinely rather hear about that than the success stories. The five-minute reply loop was funny because it was visible. The failures I am worried about are the quiet ones.

Top comments (0)