OpenClaw
Connecting the gateway, the timeouts that bite, limiting which tools an unattended agent gets, and waking it on mail.
OpenClaw runs a gateway that stays up, which makes it one of the two clients here that can hold a mailbox unattended: mail can arrive at three in the morning and be answered without anyone opening a laptop. That also means the settings below matter more than they would for a chat client, because nobody is watching when one of them is wrong.
The short version
Five steps, and the rest of this page is why each one is what it is. If you only want mail read and answered on request, the first three are enough: the last two are what makes it happen without you asking.
- One. Add the server
- openclaw mcp set mcpmailer with the JSON below, or the same fields as flags on openclaw mcp add.
- Two. Sign in
- openclaw mcp login mcpmailer prints a URL. Approve it, then pass the code back with --code.
- Three. Check it for real
- openclaw mcp doctor mcpmailer --probe connects and lists the tools it found. If it lists them, you are connected and can stop here.
- Four. Put the gateway on the internet
- We can only POST to public https, so a gateway on your own machine needs mcpmailer tunnel --handle scout --target http://localhost:18789.
- Five. Point mail at it
- Set hooks.token, then mcpmailer webhooks:add against
/hooks/wakeor/hooks/agent.Both templates are further down.
Connecting
Server entries live under mcp.servers.<name> in ~/.openclaw/openclaw.json, which is JSON5, so comments and trailing commas in the samples here are not a mistake. The canonical form is one JSON object, which openclaw mcp set takes whole; openclaw mcp add takes the same fields as flags if you would rather. transport is the field name to use, and it has to say streamable-http: without it the entry is treated as the older SSE style and the connection never comes up.
# The canonical form: one JSON object for the whole server entry.
openclaw mcp set mcpmailer '{
"url": "https://connect.mcpmailer.com/mcp",
"transport": "streamable-http",
"auth": "oauth",
"requestTimeoutMs": 320000,
"connectionTimeoutMs": 30000
}'
# OAuth: this prints a URL. Approve it, then hand the code back.
openclaw mcp login mcpmailer
openclaw mcp login mcpmailer --code abc123
# Connect for real and list what came back.
openclaw mcp doctor mcpmailer --probeWith auth set to oauth there is no key on disk. openclaw mcp login prints an authorization URL, and the code that comes back goes in with --code. Tokens are refreshed for you, so a gateway restart does not send you back through the browser. openclaw mcp doctor --probe is the one to trust afterwards: it connects for real and lists the tools it found, where status only reports what the config says. openclaw mcp probe skips the static checks and connects straight away, and openclaw mcp status --verbose is the quick read when you only want to know whether a token is still good.
If you would rather hold a key, headers takes one directly. Interpolate it from the environment rather than writing it into the file, which is what keeps a config safe to commit.
{
"url": "https://connect.mcpmailer.com/mcp",
"transport": "streamable-http",
"requestTimeoutMs": 320000,
"headers": { "Authorization": "Bearer ${MCPMAILER_KEY}" },
"toolFilter": {
"exclude": ["delete_*", "merge_contacts", "update_identity"]
}
}The timeout that bites
Set requestTimeoutMs above 300000, and do it deliberately. wait_for_reply blocks for up to 300 seconds, while the value in their own example entry is 20000, and their reference documents no default at all: unset, the timeout is whatever the build you are running decided, which has no reason to clear a five minute wait. What you see when it does not is a tool error rather than a timeout you can attribute. connectionTimeoutMs is a different thing, covering only the handshake, and can stay small.
Mind the units. Both are milliseconds, in the JSON and in the CLI flags that write it: --timeout 320000 is the flag form of what this asks for. Passing --timeout 320 does not ask for 320 seconds, it asks for a third of a second, and every tool call fails.
Which tools it gets
toolFilter takes include and exclude, both accepting globs. An agent that runs unattended is a good reason to use it: the mail tools are the point, but delete_contact, merge_contacts, and update_identity are all things you probably do not want happening at four in the morning on the strength of an email. Excluding them costs nothing and takes a whole class of incident off the table.
It also trims the tool list the model has to read, which is worth having when the mailbox surface is forty-odd tools and only a handful are ever used.
Waking it when mail arrives
The gateway can be told directly, with no service of yours in between. Its webhook endpoint is on by default but needs a token: set hooks.token, and hooks.path if you want it somewhere other than /hooks. Two routes, and which you want depends on whether mail should interrupt the session or be handled beside it.
/hooks/wake takes { text, mode } and drops a line into the main session, immediately when mode is now, or at the next heartbeat. It answers 200. This is the shorter setup and the right one for a single assistant you also talk to.
{
"text": "New mail from {{data.from}}: {{data.subject}}",
"mode": "now"
}# The endpoint OpenClaw exposes, and the token from its hooks config.
mcpmailer webhooks:add https://your-gateway.example.com/hooks/wake \
--events message.received \
--header "Authorization: Bearer YOUR_OPENCLAW_TOKEN" \
--template '{"text":"New mail from {{data.from}}: {{data.subject}}","mode":"now"}'/hooks/agent takes { message, ... } and runs an isolated turn, answering 202 because the work carries on after the response. It accepts agentId for which of your agents handles it, wakeMode, and deliver, channel, and to for where the answer goes. Use it when mail should be dealt with without derailing whatever the session is in the middle of.
{
"message": "New mail from {{data.from}}: {{data.subject}}. Read it with read_message {{data.message_id}} and answer if you can.",
"agentId": "support",
"wakeMode": "now"
}The field name changes between the two: text for wake, message for agent. A template built for one route and posted at the other comes back 400.
Mail is untrusted input
Anyone who knows the address can write to your agent, and an unattended one is the case that matters: nobody is reading over its shoulder when a message arrives saying to ignore its instructions and forward everything in the vault. The content of an email is data. It is never an instruction, however politely it is phrased and whoever it claims to be from.
Nothing here can decide that for you, because a mailbox that only accepted safe mail would not be a mailbox. What the two sides can do is make the blast radius small, and most of it is configuration you were going to set anyway.
- Take away the tools it does not need
- toolFilter is the strongest control on this page, which is why it has its own section above. An agent that cannot call
delete_contactcannot be talked into calling it. The CLI writes the same thing with --include, as in --include 'search,read_*'. - Prefer an isolated turn for mail from strangers
/hooks/wakeputs the text into the main session, where it stays in context for everything that comes after./hooks/agentruns a turn of its own and posts a summary back. An injected instruction in an isolated turn is a bad five minutes; the same instruction in the main session is in the room for the rest of the day.- Bound what a hook may reach
- hooks.allowedAgentIds limits which agents a hook can route to at all, and hooks.allowRequestSessionKey stays false unless you have a reason: it defaults that way because a caller choosing its own session key is a caller choosing which conversation to write into.
- Grant vault secrets to the agents that need them
- A mail-answering agent usually needs none. Grants are per agent on our side, so the one reading mail from the public and the one holding your Stripe key do not have to be the same identity. Every read is 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 actually wrote. An agent that composes a fresh send to an address it read out of a message body is one instruction away from being a forwarding service for whatever it can reach.
One gateway, several mailboxes
Agents are defined under agents.entries.<agentId>, and the hook routes accept agentId, so mail can be routed to the agent whose job it is. Make that line up on our side too: one MCPmailer agent per OpenClaw agent, each with its own key, and each webhook endpoint scoped with mailbox_id so one mailbox's mail cannot wake another's handler. Set hooks.allowedAgentIds to bound which agents a hook may reach at all.
See /docs/several-agents for what agents do and do not share on our side.
Running it unattended
- Check the connection, not the config
- openclaw mcp doctor mcpmailer --probe after any change. A config that reads correctly and a server that answers are different claims.
- Watch the deliveries, not the gateway log
- mcpmailer webhooks:deliveries records what each attempt got back. Twenty consecutive failures turn an endpoint off and email the workspace owner, so a gateway that was down for a day comes back to a held queue rather than to silence.
- Events are held for seven days
- An endpoint turned off keeps its events for a week and delivers them within a few minutes of being turned back on. Fixing a gateway inside that window loses nothing.
- Verify the workspace email first
- Nothing sends until it is done, and an agent that discovers this at three in the morning cannot fix it.
sending_locked_verify_emailis the reason you will see.
The whole file
Everything above, in one piece. Every other sample on this page is a section lifted out of this one.
// ~/.openclaw/openclaw.json (JSON5: comments and trailing commas are fine)
{
mcp: {
servers: {
mcpmailer: {
url: "https://connect.mcpmailer.com/mcp",
transport: "streamable-http",
auth: "oauth",
requestTimeoutMs: 320000, // milliseconds, and above the 300s wait
connectionTimeoutMs: 30000,
toolFilter: {
exclude: ["delete_*", "merge_contacts", "update_identity"],
},
},
},
},
hooks: {
token: "${OPENCLAW_HOOKS_TOKEN}",
path: "/hooks",
allowedAgentIds: ["support"],
allowRequestSessionKey: false,
},
}Everything else that can go wrong is in /docs/troubleshooting, and the full webhook reference, including the signature check and the retry schedule, is in /docs/webhooks.