Hermes
The config.yaml entry, tool filtering, its own webhook adapter, cron sweeps, and running the gateway as a service.
Hermes is the other client here built to run unattended, and it is unusually well suited to email: it has a webhook adapter of its own, so mail can start an agent run with nothing of yours in between, and a cron scheduler for the work that should happen on a clock rather than on an event.
The short version
Five steps, and the rest of this page is why each one is what it is. The first three get an agent that reads and answers mail when you ask it to. The last two are what makes it happen on its own.
- One. Add the server
- Put the
mcp_serversentry below in ~/.hermes/config.yaml, or run hermes mcp add mcpmailer --url https://connect.mcpmailer.com/mcp. - Two. Sign in
- hermes mcp login mcpmailer runs the PKCE flow and keeps the token, so nothing sensitive lands in the file.
- Three. Check it for real
- hermes mcp test mcpmailer connects and lists the tools. If it lists them, you are connected and can stop here.
- Four. Put the gateway on the internet
- The adapter listens on 8644 and we can only POST to public https, so run mcpmailer tunnel --handle scout --target http://localhost:8644.
- Five. Point mail at it
- Add a route with a secret, then mcpmailer webhooks:add against https://scout.mcpmailerwire.com/webhooks/mcpmailer. Put the signing secret it prints on the route.
Connecting
Servers go under mcp_servers in ~/.hermes/config.yaml. With auth: oauth, Hermes runs the PKCE flow itself and keeps the token, refreshing it as needed, so nothing sensitive is in the file. hermes mcp add mcpmailer --url writes the same entry from the command line.
mcp_servers:
mcpmailer:
url: https://connect.mcpmailer.com/mcp
auth: oauth
# wait_for_reply blocks for up to 300 seconds, so this has to clear it.
timeout: 320
connect_timeout: 30
tools:
exclude:
- "delete_*"
- "merge_contacts"
- "update_identity"timeout is the tool call timeout in seconds and connect_timeout covers only the initial handshake. wait_for_reply blocks for up to 300 seconds, so timeout has to clear that or long waits die before the reply lands. This is the setting people get wrong on this client.
To hold a key instead, headers takes one, and it substitutes ${ENV_VAR} at runtime, so the config stays free of the secret.
mcp_servers:
mcpmailer:
url: https://connect.mcpmailer.com/mcp
headers:
# Substituted at runtime, so the key is not in the file.
Authorization: "Bearer ${MCPMAILER_KEY}"
timeout: 320
connect_timeout: 30Which tools it gets
tools takes include and exclude, both accepting names or globs, and include wins if you set both. As with any agent left running, the mail tools are the point and the destructive ones are worth leaving out: an unattended run should not be able to delete a contact because an email asked it to. enabled: false switches the whole server off without deleting the entry, which is the quickest way to take a mailbox out of service while you look at something.
Two more keys trim the surface further than exclude reaches. prompts: false and resources: false drop the utility wrappers Hermes otherwise registers alongside the tools, and on a mailbox that is only ever called for its tools they are pure context the model has to read past.
Waking it when mail arrives
The webhook adapter listens on port 8644 and routes are reached at /webhooks/<route-name>. Every route must resolve to a secret or the adapter refuses to start: either its own, or the fallback secret set once beside port under extra. A per-route secret is the one to prefer, because rotating ours then touches one route rather than every route at once. hermes webhook subscribe writes a route from the command line and hands back its URL and secret; the config file form is below.
platforms:
webhook:
enabled: true
extra:
port: 8644
routes:
mcpmailer:
# The endpoint's signing secret, shown once when you create it.
# Hermes verifies our signature against this on every delivery.
secret: "whsec_..."
prompt: "New mail from {data.from}: {data.subject}. {data.snippet}"
filters:
- field: "event"
equals: "message.received"The prompt is a template over the payload with dot paths, and this is where Hermes is easier than anything else here: our envelope is already the shape it wants, so {data.from}, {data.subject}, {data.snippet}, and {data.message_id} resolve straight out of it with no body template on our side. {__raw__} drops the whole payload in, up to 4000 characters. A missing key renders as itself rather than erroring, which is what you are looking at when a prompt arrives with braces still in it.
filters run before the agent is dispatched, so a route that should only act on new mail says so declaratively instead of spending a turn deciding. deliver_only skips the model altogether, which is right for a route whose whole job is to forward a notification. Its response codes are worth knowing when you read our delivery log: 200 handled, 400 malformed JSON, 401 secret mismatch, 404 no such route, 413 body over a megabyte, 429 rate limited at 30 a minute per route by default, 502 delivery failed downstream. The 413 is the one to know about, because it is the only one that depends on the mail rather than the setup: a message with a long snippet is what gets near a megabyte, so a route that works all week can fail on one email. max_body_bytes raises the ceiling.
# Hermes listens on 8644. Ours has to reach it over public https.
mcpmailer tunnel --handle scout --target http://localhost:8644
# The route name is the last path segment. Keep the signing secret this prints:
# it is what the route's secret: line wants.
mcpmailer webhooks:add https://scout.mcpmailerwire.com/webhooks/mcpmailer \
--events message.receivedMail is untrusted input
Their own documentation puts this well: authenticated does not mean trusted. A signature proves a delivery came from us and says nothing at all about the person who sent the email inside it. An unattended agent is the case that matters, because nobody is watching when a message arrives telling it to ignore its instructions and mail the vault somewhere.
- Scope the toolset
- tools.exclude on the MCP entry is the control with the most leverage, and it is the same one that keeps the tool list short. Their guidance goes further for a gateway on the internet: keep terminal, file, and outbound-action tools away from a session that only needs to read and summarise, and run the gateway with the Docker or SSH backend so a hijacked turn cannot touch the host.
- Give a route only the skills it needs
- skills is per route, so the run that answers mail loads what answering mail takes and nothing else. A route is a much smaller thing to reason about than an agent, which is the argument for having one per job.
- Filter before the model, not after
- filters run before dispatch, so mail that should never start a run does not start one.
deliver_onlygoes further and skips the model altogether, which is exactly right for a route whose job is to forward a notification: nothing reads the message, so nothing can be talked into anything by it. - Grant vault secrets narrowly
- Grants are per agent on our side. The identity reading mail from the public does not need to be the identity that can open a credential, and every read is recorded in the activity log with the agent and the time.
- Never let the message choose the recipient
- Answer with
reply_all, or withsend_emailcarryingreply_to_message_id, so the reply goes to whoever wrote. An agent that sends to an address it read out of a message body is one instruction away from forwarding whatever it can reach to whoever asks.
Cron, for the work that is not an event
Not everything wants a webhook. A morning digest, a nightly sweep of what went unanswered, a weekly tidy of the address book: those are schedules, and a scheduled run is not the polling loop this documentation keeps warning you off. One run a day that lists unread mail is fine. A loop that calls list_messages every thirty seconds is not, and it will spend the rate limit that the rest of the agent needs.
hermes mcp test mcpmailer # connect and list the tools
hermes mcp login mcpmailer # re-authorise when a token is refused
hermes mcp list # what is configured
hermes gateway run # foreground, for a terminal or tmux
hermes gateway start # install as systemd or launchd
hermes gateway status # is it up, and as which profile
# A morning sweep is not polling: one run a day, not a loop.
hermes cron create "0 9 * * *" "Summarise any unread mail and archive what is handled."Running it as a service
hermes gateway run keeps it in the foreground, which is what you want under tmux, in WSL, or in a container. hermes gateway start installs it as systemd or launchd so it comes back after a reboot, and hermes gateway status says whether it is up and under which profile. Profiles matter here if you run more than one: a route bound to a profile is reached under its own path, so two mailboxes can have two gateways without their webhooks crossing.
The whole file
Everything above, in one piece. Every other sample on this page is a section lifted out of this one.
# ~/.hermes/config.yaml
mcp_servers:
mcpmailer:
url: https://connect.mcpmailer.com/mcp
auth: oauth
timeout: 320 # seconds on this client, unlike OpenClaw
connect_timeout: 30
tools:
exclude:
- "delete_*"
- "merge_contacts"
- "update_identity"
prompts: false
resources: false
platforms:
webhook:
enabled: true
extra:
port: 8644
routes:
mcpmailer:
secret: "whsec_..."
prompt: "New mail from {data.from}: {data.subject}. {data.snippet}"
skills: ["answer-mail"]
filters:
- field: "event"
equals: "message.received"Line the identities up across both sides: one MCPmailer agent per Hermes profile or route, each with its own key, and each endpoint scoped with mailbox_id. /docs/several-agents covers what is shared between agents on our side and what is not.