Integrations · September 22, 2026

Give an OpenClaw Agent an Email Inbox

Give an OpenClaw agent an email inbox it can read and draft in, with outbound mail held at a gate the agent itself is not permitted to release.

An always-on personal agent is the worst available place to put an unbounded send capability. OpenClaw is a self-hosted gateway that bridges chat apps to an AI agent, so it is awake, it is reachable from Telegram or Signal or a group thread, and everything arriving on those channels is text that some model will read as instruction. Give an OpenClaw agent an email inbox with sending switched on and you have handed that surface an address of its own and a way to talk to strangers.

So build it in the other order. This page grants read and draft first, leaves outbound mail sitting at an approval gate, and shows exactly what a human sees before releasing anything. Widening to unattended sending is the last step, not the first, and the controls that hold a send are the reason the first arrangement is usable at all.

Start with a grant that cannot send

OpenClaw supports remote MCP servers over streamable HTTP with OAuth, which is the connection this endpoint expects:

https://mcp.emailforagents.ai/mcp

An API key is rejected there by design. A bearer token that is not a connection grant returns unauthenticated, and a principal that is not a connection returns permission_denied. Only an OAuth connection grant may invoke the tools, which means the authorising human is recorded and the grant can be revoked as a unit. Nothing here is a certified or endorsed integration with OpenClaw, and there is no one-click install: what follows is a configuration that works because both sides implement the same protocol.

Three OAuth scopes exist and each maps to a fixed scope set. The one to ask for is the middle one.

Requested scopeTools returnedWhat the agent can do
email.read4read threads and message bodies
email.draft5read, plus compose a fully formed message
email.send6read, compose, and transmit subject to inbox policy

email.draft is the posture this page builds. The agent reads its mail, writes complete replies with real recipients and real threading, and cannot put any of them on the wire.

How to give an OpenClaw agent an email inbox, in two commands

Save the server, then run the OAuth flow:

openclaw mcp set email '{"url":"https://mcp.emailforagents.ai/mcp","transport":"streamable-http","auth":"oauth","oauth":{"scope":"email.draft"}}'
openclaw mcp login email

Or the equivalent block in the config file:

{
  mcp: {
    servers: {
      email: {
        url: "https://mcp.emailforagents.ai/mcp",
        transport: "streamable-http",
        auth: "oauth",
        oauth: { scope: "email.draft" },
        enabled: true,
      },
    },
  },
}

Two fields there are load-bearing and both have a bad default for this server.

transport must be set explicitly. OpenClaw’s transport documentation states that when the field is omitted it defaults to SSE. A GET on this endpoint returns HTTP 405 with Allow: POST, because it speaks streamable HTTP only, so an omitted transport fails at connect time and presents as an unreachable server rather than a configuration error. If openclaw mcp doctor email --probe reports a connection failure while curl -X POST against the same URL returns JSON-RPC, the transport is the problem.

auth: "oauth" replaces static authorization headers. Putting a key in headers instead is the failure this endpoint is built to produce rather than tolerate.

One behaviour worth knowing before you debug it: when a server rejects a token with insufficient_scope, OpenClaw preserves the scope you asked for and prompts you to run openclaw mcp login again rather than silently refreshing, because a refresh cannot grant permissions the grant never had. That is the right behaviour, and it means the way to widen from drafting to sending is a deliberate re-consent, not a config edit.

The five-tool session, and why create_draft cannot transmit

With email.draft, tools/list returns five tools: list_inboxes, list_threads, list_messages, get_message and create_draft. send_message is not in the list.

That absence is the control. Each tool declares the scopes it needs, the server filters tools/list by the grant, and a tools/call naming a filtered tool is re-checked and refused with permission_denied and the message “This connection cannot use that tool.” So an instruction hidden inside an inbound email has no send tool to name, and naming one anyway does not work. The MCP tool surface as a permission boundary works through how that set is computed per request.

The more interesting half is what create_draft actually is. It is not a separate lightweight endpoint that stores text somewhere. In packages/mcp/src/index.ts the handler for create_draft calls the same send service as send_message, with one difference: it builds an actor by copying the principal and filtering messages:send out of its scopes before the call. The request then runs the entire send path, the same strict schema, the same canonical payload hash, the same idempotency record, the same policy chain, and arrives at the branch that holds a draft-only principal.

Three consequences follow, and they are the reason this posture is worth the trouble:

  1. The draft is a real, validated message. Recipient shapes, the 25-recipient ceiling, subject length, attachment reuse and thread parentage are all enforced at draft time, not deferred to approval. A draft that exists is a draft that can be sent.
  2. create_draft cannot transmit on any grant, including email.send. The scope is removed in the call path itself, so there is no combination of permissions under which that tool puts mail on the wire.
  3. The reply is already threaded. Pass the inbound message’s ID as reply_to_message_id and the draft reuses the parent’s thread, so approving it later does not produce an orphan message.

The tool also requires a stable idempotency_key, between 1 and 200 characters. Derive it from the message being answered rather than from the attempt, or an agent that retries a tool call after a timeout will queue a second identical approval for a human to disambiguate.

toolFilter is a preference; the grant is the boundary

OpenClaw lets you filter a server’s tools per client with toolFilter.include and toolFilter.exclude, accepting exact names or simple globs. It is tempting to leave the grant at email.send and write "exclude": ["send_message"].

Do not use that as the send control. The filter hides the tool from the agent in that OpenClaw configuration. The server still returns send_message for that grant, and anything else holding the same credential still has it: a second client, a manual JSON-RPC call, a plugin, an operator who edits the config, a process that reads the token out of the gateway’s state. Narrowing the grant is a boundary enforced on every request by the server. Narrowing toolFilter is a local preference enforced by the process an attacker has already reached.

Use both, in the right roles. Request email.draft so send_message does not exist for that connection, and use toolFilter for what it is good at, which is keeping an irrelevant tool out of the model’s context.

A connection’s scopes are also not freely composed. They are whichever of three fixed presets the OAuth scope maps to, and none of the three contains keys:manage, members:manage, billing:manage, policies:write, inboxes:delete, messages:delete, messages:raw or exports:write. No consent screen and no misread preset yields a session that can rotate your keys, read raw MIME or loosen the policy that forced the approval. The grant is then intersected with the authorising human’s current permissions on every request, so if that person loses project access the connection narrows on the next call with no revocation step.

Shared or per-requester: who is actually driving the address

This is the OpenClaw-specific question that decides how much the approval gate is doing, and it has nothing to do with email.

OpenClaw’s OAuth block takes an identity field. The default is shared: one operator-managed credential, stored in the gateway’s state database, used by every sender the gateway accepts. Set "oauth": {"identity": "per-requester"} instead and each sender authenticates separately, which requires gateway.publicOrigin to be set to a reachable HTTPS origin because the provider redirects back through <gateway.publicOrigin>/oauth/mcp/callback. When a sender calls a tool before connecting an account, OpenClaw returns a sign-in link rather than exposing somebody else’s credential.

For a single-operator gateway on your own machine, shared identity is correct and simpler. For a gateway anyone else can message, including a group chat with an allowlist you have not audited lately, shared identity means every one of those people is driving your agent’s email address through your grant, and the audit trail on our side records one connection rather than a person. In that arrangement the approval queue is not a nicety. It is the only place a human distinguishes between what you asked for and what somebody in a group thread asked for.

Either way the grant carries a clock. A connection is created with a 30-day expiry and nothing extends it. Once expires_at passes, the grant resolves as denied, and the symptom is an agent that read its mail fine for a month and now cannot. Re-consent with openclaw mcp login email.

What the operator sees when the agent asks to send

When the agent calls create_draft, the send is held and an approval is created. In the console the request lands in a list that refreshes every five seconds, showing the subject, the time, To plus a count of the other recipients, the draft version, and a state tag. Opening one shows a fact panel before any content:

Below that: the message body, rendered from a server-sanitised projection rather than the canonical HTML the hash was taken over; the parent message, expandable, when the draft is a reply; and per-file attachment state.

Four behaviours of that screen matter more for an always-on agent than for a scripted one, because an always-on agent revises things while you are reading them.

Approve is disabled until the review is fully loadable; reject never is. If the sanitised body has not arrived, or any attachment is not both scanned clean and fully stored, the approve control stays unavailable and the page says so. You can still reject. The asymmetry is deliberate: the safe action is always available, the irreversible one is gated on having actually seen the thing.

If the agent replaces the draft mid-review, the console says so by version. You get an alert naming the version you opened and the version now on screen, the confirmation dialog is withdrawn, and you have to acknowledge the new version before approving. For an agent that may be reacting to a chat message while you read, this is the difference between approving what you read and approving what it says now.

Approving is two steps and the second one counts recipients. In a live project the confirmation asks whether to send this real email to a stated number of recipients and says that it cannot be undone. In a test project the same dialog says nothing leaves the sandbox.

The agent cannot approve its own draft. The approve route requires a human principal holding approvals:decide, and a connection grant is not a human principal: it fails with “Only a human member can perform this action.” No prompt, no scope request and no tool call reaches the release button. Human approval before an agent sends email covers what approval binds to and the five properties a home-built reviewer has to reproduce.

One number to plan around: a pending approval expires seven days after it is created, and the record then reads expired rather than pending. An agent that drafts overnight and an operator who reviews the queue fortnightly will lose drafts. Either check the queue weekly or push a notification out of approval.requested.

The two approval events that fire, and the two that do not

Four approval event types are declared: approval.requested, approval.approved, approval.rejected and approval.invalidated. Two are emitted by the current build. approval.requested fires when a send is held, carrying approval_id and draft_id. approval.rejected fires on rejection. approval.approved and approval.invalidated are accepted at webhook registration and simply never arrive.

That distinction is worth checking against the event table before you build anything on it. A gateway waiting on approval.approved to hear that its message went out is waiting on an event that never fires, and waits forever with nothing in any log explaining why. Detect the release by watching the message or the event cursor for the send that follows it.

For OpenClaw specifically, approval.requested is the event to push into a channel. A self-hosted gateway is usually already the thing that can message you on Signal or Telegram, so a small webhook receiver that turns approval.requested into a chat message with the approval ID gets you notified in the place you already are. Verify the callback before trusting it: each carries webhook-id, webhook-timestamp and webhook-signature, which is v1, followed by the base64 HMAC-SHA256 of id.timestamp.rawBody. Delivery is at-least-once, up to eight attempts, so deduplicate on the event ID or you will get paged twice for one draft.

When to widen the grant, and what does not change

Move to email.send when the agent’s outbound traffic is high-volume, low-consequence and bounded by recipient, and when somebody is actually reading the approval queue rather than clicking through it. A gate nobody reads is worse than no gate, because it manufactures a record of review that did not happen.

Widening the OAuth scope does not remove the gate, which is the part people get wrong. send_message appears in the tool list, but inbox policy still applies, so a rule requiring approval for a new recipient domain or for attachments still holds those sends and still emits approval.requested. The scope decides which tools exist; the inbox policy decides which sends need a person. They are separate controls and you can run them in any combination. The presets and how to scope a grant to one inbox are on the integrations page and in the MCP email server walkthrough.

What does not change at any scope: nothing stops a determined operator from being talked into approving a hostile draft, so treat email content as untrusted data throughout, as prompt injection through email sets out. No certification is held here, no uptime commitment is published, and no MCP client is certified, OpenClaw included. The free allowance is one live inbox and 100 email units a month per verified owner identity, where a unit is one message received or one recipient sent to; Builder and Team are proposed plans with checkout disabled, and custom domains require a paid plan, so every live address today sits on agents.emailforagents.ai.

Rehearse the whole thing before it can reach anyone. Point the same config at a test project, where inboxes use a non-routable .mail.invalid address and a simulated transport, and inject inbound mail with POST /v1/projects/{project_id}/test/inbound. The agent reads a real message, drafts a real reply, and a real approval appears in the console, with nothing capable of leaving the system.


Keep reading

All guides · Documentation · Integrations