Hermes Agent Email: Give Your Nous Agent an Inbox

8 min read

Step-by-step guide to wiring a real email inbox into a Hermes agent: mailbox creation, tool schemas, inbound webhooks, and threaded replies.

John Joubert

John Joubert

Founder, Robotomail

Hermes Agent Email: Give Your Nous Agent an Inbox
Table of contents

Setting up Hermes agent email takes three pieces: a mailbox with a real address, two or three tool definitions the model can call, and a webhook that hands inbound mail back to your agent loop. Hermes models from Nous Research are strong at structured tool calling, so the model side is mostly schema design. The infrastructure side is where people get stuck, and that is what this guide covers in depth.

If you just want the short version with the minimum viable code, read how to give your Hermes agent an email address first. This post assumes you already ran that and now need the operational details: threading, idempotency, tool ergonomics, and what to do when the agent is not the only one in the conversation.

One framing note before code. A Hermes agent email setup is not a mail-merge integration and not an "AI writes your emails" feature. The agent owns an address, receives real mail from real people, and decides what to do. That means you need an inbox, not a send-only API.

What Hermes actually needs

Hermes runs behind an OpenAI-compatible server in most deployments (vLLM, SGLang, or a hosted endpoint). That gives you the standard tools array and tool_calls response format, so email becomes three functions:

Tool Purpose
send_email New outbound message to one or more recipients
reply_to_email Reply inside an existing thread
list_thread Read prior messages before answering

Keep the surface small. Every extra tool costs you accuracy in the dispatch step, and a model that can send mail with four optional parameters will eventually send mail with the wrong four.

Step 1: create the mailbox

Fastest path is the CLI:

npx @robotomail/cli mailbox create hermes-agent

Or from your provisioning code:

curl -X POST https://api.robotomail.com/v1/mailboxes \
  -H "Authorization: Bearer rm_your_api_key" \
  -H "Content-Type: application/json" \
  -d '{"address": "hermes-agent"}'

You get back a mailbox ID (mbx_...) and a live address on our platform domain. Store the mailbox ID next to whatever identity record your agent already has. If you are running one agent per customer or per task, create one mailbox per agent rather than sharing a single address with a routing prefix. Separate mailboxes give you separate threads, separate suppression state, and a clean audit trail. See mailboxes for the identity model.

Want hermes@yourcompany.com instead? Add a custom domain and pass its UUID as domainId at creation time. The DNS steps are in the custom domain guide.

Step 2: implement the email functions

Plain Python, no framework. Note that we build the full request URL at the call site, which keeps the HTTP method and path visible together and makes the code easy to grep during an incident.

import os
import requests

RM_KEY = os.environ["ROBOTOMAIL_API_KEY"]
MAILBOX_ID = os.environ["ROBOTOMAIL_MAILBOX_ID"]  # e.g. mbx_shopping

def _headers():
    return {
        "Authorization": f"Bearer {RM_KEY}",
        "Content-Type": "application/json",
    }

def send_email(to, subject, body_text, in_reply_to=None):
    payload = {"to": to, "subject": subject, "bodyText": body_text}
    if in_reply_to:
        payload["inReplyTo"] = in_reply_to

    resp = requests.post(
        f"https://api.robotomail.com/v1/mailboxes/{MAILBOX_ID}/messages",
        headers=_headers(),
        json=payload,
        timeout=20,
    )
    resp.raise_for_status()
    return resp.json()

reply_to_email is the same call with inReplyTo set to the inbound message ID. Do not reimplement it as a second HTTP path. One function, one optional field, less to go wrong.

Step 3: give Hermes the tool schemas

Descriptions matter more than names here. Hermes follows explicit constraints well, so put your policy in the schema rather than hoping the system prompt holds.

TOOLS = [
    {
        "type": "function",
        "function": {
            "name": "send_email",
            "description": (
                "Send an email from the agent's own mailbox. "
                "Use reply_to_email instead when responding to a message "
                "you received. Never send to more than 3 recipients."
            ),
            "parameters": {
                "type": "object",
                "properties": {
                    "to": {
                        "type": "array",
                        "items": {"type": "string"},
                        "description": "Recipient email addresses.",
                    },
                    "subject": {"type": "string"},
                    "body_text": {
                        "type": "string",
                        "description": "Plain text body. No markdown.",
                    },
                },
                "required": ["to", "subject", "body_text"],
            },
        },
    },
    {
        "type": "function",
        "function": {
            "name": "reply_to_email",
            "description": (
                "Reply within an existing thread. Requires the message_id "
                "of the message you are replying to."
            ),
            "parameters": {
                "type": "object",
                "properties": {
                    "message_id": {"type": "string"},
                    "body_text": {"type": "string"},
                },
                "required": ["message_id", "body_text"],
            },
        },
    },
]

Then the usual loop:

from openai import OpenAI

client = OpenAI(base_url="http://localhost:8000/v1", api_key="local")

def run(messages):
    while True:
        out = client.chat.completions.create(
            model="hermes",
            messages=messages,
            tools=TOOLS,
        )
        msg = out.choices[0].message
        messages.append(msg)

        if not msg.tool_calls:
            return msg.content

        for call in msg.tool_calls:
            result = dispatch(call.function.name, call.function.arguments)
            messages.append({
                "role": "tool",
                "tool_call_id": call.id,
                "content": result,
            })

dispatch parses the JSON arguments and calls the Python functions above. Return a short JSON string on success and a short JSON string on failure. Hermes recovers from {"error": "recipient suppressed"} far better than from a stack trace.

Step 4: handle inbound Hermes agent email

Sending is the easy half. The reason to give an agent a mailbox at all is that replies come back.

Register a webhook for the mailbox, and inbound mail arrives as a POST to your endpoint:

{
  "event": "message.received",
  "data": {
    "message_id": "...", "mailbox_id": "...", "mailbox_address": "hermes-agent@robotomail.co",
    "from": "vendor@example.com", "subject": "Re: Order #A-4521", "body_text": "...",
    "thread_id": "...", "received_at": "2026-04-17T10:00:00.000Z"
  }
}

Three rules that will save you an outage:

  1. Return 200 immediately, then process. Enqueue the payload and run the Hermes loop out of band. Model inference plus tool calls can take tens of seconds, which is longer than any sane webhook timeout.
  2. Deduplicate on message_id. Retries happen. A dedupe table keyed on message_id is the difference between one reply and five.
  3. Key agent state on thread_id. Load the conversation history for that thread, append the new message, run the loop. Do not try to reconstruct context from the subject line. Details are in threading, and the end-to-end flow is in receive and reply.

Webhook registration is its own resource with its own fields, documented under webhooks. Set it up once per mailbox at provisioning time.

Guardrails worth adding on day one

Hermes will do what you ask, including the parts you did not think about.

  • Recipient allowlist in the dispatch layer. Check the to array against a list before the HTTP call, not in the prompt. Prompts are advisory, code is not.
  • Per-mailbox send cap. A loop where the agent replies to its own auto-responder is the classic failure. Count sends per thread per hour and refuse past a threshold.
  • Human loop-in address. Give the agent a handoff tool that emails a person and stops the loop. Cheaper than a clever escalation policy.
  • Plain text by default. Fewer rendering surprises and better deliverability for conversational mail. See plain text email for the tradeoff.

If your agent is going to be pulled into multi-step workflows with other agents on other addresses, read multi-agent workflows before you design the thread state, because the "one mailbox per role" pattern is much easier than retrofitting it later.

FAQ

Does this work with any Hermes deployment?

Anything exposing an OpenAI-compatible chat completions endpoint with tool calling works, whether you self-host on vLLM or use a hosted inference provider. The email side is plain HTTP and does not care what model you run.

Can the agent read attachments people send it?

Yes. Inbound attachments are stored and retrievable by ID, which is the right pattern for agents because you avoid dumping binary blobs into the model context. See attachments.

Should each Hermes agent get its own address?

Usually yes. Separate mailboxes give you clean threads, per-agent rate visibility, and the ability to revoke one agent without touching the others. The reasoning is spelled out in why agents need a real inbox.

How do I test before pointing real people at it?

Send between two mailboxes you own and assert on the webhook payloads your handler receives. That exercises threading, dedupe, and reply construction without involving an external recipient. How to test email delivery covers the checks worth automating.

Ready to wire it up? Create a mailbox, register a webhook, and give your Hermes agent an address it actually owns at robotomail.com.

Give your AI agent a real email address

One API call creates a mailbox with full send and receive. Webhooks for inbound, automatic threading, deliverability handled. 30-day money-back guarantee.

Related posts