Browse documentation

MCP server

ReplyLayer speaks the Model Context Protocol, so an MCP client (Claude, ChatGPT, Cursor, or your own agent runtime) can send, reply, read, monitor, and triage email as tool calls — every message security-scanned on the way in and out. This is a transactional and operational email surface, not a bulk or marketing tool.

There are two ways to connect, and they expose the same 44 tools (a sign-in connection has 40 of them):

Hosted (/v1/mcp and /v1/mcp/oauth)Local (stdio)
TransportStreamable HTTP over POST https://api.replylayer.ai/v1/mcp (or /v1/mcp/oauth)Subprocess over stdin/stdout
Client requirementNative remote-MCP-over-HTTP supportAny MCP client that can spawn a command
Auth/v1/mcp: Authorization: Bearer <api-key> on each request. /v1/mcp/oauth: sign-in (OAuth), for Claude, ChatGPT and Meta MuseAPI key from the environment or a stored credentials file
InstallNothing to install — point the client at the URLnpx -y replylayer-mcp (or a global install)

Both share one tool set, so the choice is purely about how your client connects. Use the hosted endpoint when your client supports remote HTTP MCP servers; use the stdio server for local agents and clients that launch a command. Claude (claude.ai and Desktop), ChatGPT and Meta Muse connect to the hosted endpoint by signing in, with no key to copy: see Sign-in endpoint.

Credentials

Both transports authenticate with a single ReplyLayer API key — the same key you use for the REST API. The one exception is the sign-in endpoint, where Claude, ChatGPT and Meta Muse sign in instead and no key is copied anywhere. A newly created key is inert on protected routes until a human completes both signup checks (a 6-digit email code and a 6-digit SMS code); the MCP server runs with a key a human has already provisioned and verified. See Authentication for the key format and admin-vs-agent scoping, and Quickstart for provisioning a key.

Hosted endpoint (/v1/mcp)

The hosted endpoint is stateless: each request is a self-contained MCP JSON-RPC call over Streamable HTTP with no server-side session. Point a remote-capable MCP client at the URL and pass your API key as a Bearer header:

{
  "mcpServers": {
    "replylayer": {
      "url": "https://api.replylayer.ai/v1/mcp",
      "headers": { "Authorization": "Bearer rly_live_<public_id>.<secret>" }
    }
  }
}

Contract details for anyone driving the endpoint directly:

  • Method: POST only. Requests carry Content-Type: application/json and Accept: application/json, text/event-stream (native clients set these for you).
  • Auth: a missing or non-Bearer Authorization header returns 401 before any tool runs.
  • /v1/mcp takes a static Bearer key only. This URL has no sign-in and no OAuth discovery metadata, so a client configured with the Authorization header shown above is never switched into a sign-in flow. An Authenticate or Sign in button your client shows for this URL cannot succeed; add the header instead. Sign-in lives on a separate URL, /v1/mcp/oauth (below).
  • GET and DELETE: without a key they return 401 with WWW-Authenticate: Bearer, like any other request. With a valid key they return 405 Method Not Allowed (with an Allow: POST header). Because the endpoint is stateless there is no SSE-over-GET stream and no session to delete — this is expected, not an outage.
  • Idempotency keys must be literal. Some hosts (OpenClaw) mask tool arguments whose names end in _key in session history, so a retry inside the same session sends *** instead of your idempotency_key. ReplyLayer refuses such a key with 400 IDEMPOTENCY_KEY_INVALID rather than replaying or sending. On those hosts, retry from a new session with the full literal request.

Sign-in endpoint (/v1/mcp/oauth)

Claude, ChatGPT and Meta Muse connect by signing in (OAuth) rather than with a key. Give the app this URL, exactly as shown, with no trailing slash:

https://api.replylayer.ai/v1/mcp/oauth

The app sends you to a ReplyLayer page that names it, for example Connect Claude (claude.ai) to ReplyLayer. Sign in if it asks, choose the mailboxes the app may use (include the mailbox you chose on the Connect page, which the check prompt sends from), and choose Approve. ReplyLayer may ask you to confirm your sign-in first. The app then has 40 of the 44 tools. It is limited exactly like an agent key bound to the mailboxes you chose. It can add entries to your outgoing do-not-contact list, your incoming blocked-senders list and a chosen mailbox's incoming allowed-senders list, and add people to your outgoing recipients list, but it can never remove a do-not-contact entry or change your account, keys, billing, or a mailbox's outgoing recipient allowlist, scanner policy, attachment access or approval rules, and it can't attach files from your computer. The four tools an agent key can never use are left out: approving and denying messages held for review (approve_review, deny_review), the account usage report (get_account_usage), and remove_suppression, which no MCP connection can use. The page also names the company behind the app (Anthropic, OpenAI or Meta), which receives the email the app asks for under your own agreement with it; see Terms §7 and the Privacy Policy §6. ReplyLayer emails you each time you approve a new connection.

Which URL to use:

Your agentURLHow it authenticates
Claude: claude.ai and Claude Desktop connectors (the mobile apps use the same connector)https://api.replylayer.ai/v1/mcp/oauthSign-in (Claude)
ChatGPT on the web, in developer modehttps://api.replylayer.ai/v1/mcp/oauthSign-in (ChatGPT)
Meta Musehttps://api.replylayer.ai/v1/mcp/oauthSign-in (Meta Muse guide)
Claude Code, Cursor, OpenClaw, Hermes, Codex and other clients that send a headerhttps://api.replylayer.ai/v1/mcpStatic agent key in Authorization: Bearer (Hosted endpoint)

Only Claude, ChatGPT and Meta Muse can sign in; any other app's sign-in attempt is refused. Claude Code uses an agent key, not sign-in: see Claude Code. The two URLs don't mix: an API key sent to /v1/mcp/oauth is refused, and so is a sign-in token sent to /v1/mcp or to the REST API.

Disconnecting. Each connection appears under API keys → Connected apps in the dashboard, with the mailboxes it may use and when it was last used. Revoke it there to disconnect the app; its next call fails. You can also change its mailboxes there.

How long a connection lasts. A connection ends after 30 days without use, and at the latest 90 days after you approve it, however often it is used. It then shows as Expired under Connected apps until you revoke it. To keep using ReplyLayer, connect the app again the same way.

Local stdio server

For clients that launch a command, install nothing globally and let the client run the server via npx (Node.js 22 or newer). Claude Desktop needs none of this: it installs the server as an extension (Claude Desktop below). Add this to your client's MCP config (for example a Cursor mcpServers block; Cursor below also has a one-click install):

{
  "mcpServers": {
    "replylayer": {
      "command": "npx",
      "args": ["-y", "replylayer-mcp"]
    }
  }
}

The config carries no key. The server resolves the API key from the REPLYLAYER_API_KEY environment variable first, then from the stored credentials file at ~/.replylayer/credentials. Store the key in that file once, with either command (each prompts for the key and writes the file with mode 0600):

rly auth login
npx -y replylayer-mcp init

rly auth login (the CLI) always replaces a stored key. init writes the file only when none exists; otherwise it prints the stored key's prefix and leaves the file alone. Because the environment variable wins, an exported REPLYLAYER_API_KEY hides the stored key. For CI or automation, skip the file and pass REPLYLAYER_API_KEY in the environment. With a global install (npm i -g replylayer-mcp) you can set "command": "replylayer-mcp" and drop args.

Claude

Claude connects with a custom connector: one connector serves claude.ai and Claude Desktop, and the Claude mobile apps use the same connector. You don't need a terminal or a key. The Free plan allows one custom connector. On a Team or Enterprise plan, an Owner adds the connector, and each member then finds it under Customize → Connectors and chooses Connect.

1. Add the connector. In claude.ai, open Customize (or Settings) → Connectors and choose Add custom connector. Paste this URL exactly; if Claude asks for a name, use ReplyLayer:

https://api.replylayer.ai/v1/mcp/oauth

Leave Advanced settings empty: don't enter a client ID or secret. If the dialog offers a choice of how Claude signs in, keep its default, and don't choose an option without sign-in or with automatic registration; ReplyLayer supports neither.

2. Connect and approve. Choose Connect. A ReplyLayer page opens: Connect Claude (claude.ai) to ReplyLayer. Sign in if it asks, choose the mailboxes Claude may use, including the one you chose on the Connect page, and choose Approve. ReplyLayer may ask you to confirm your sign-in first.

3. Check it. Start a new chat and paste the connection check from the Connect page (choose Claude in Step 3): it sends one test email to the delivery simulator with its own claude-check-<token> idempotency key, so pasting it again replays the first send instead of sending twice. If Claude asks before using a ReplyLayer tool, allow it.

A connector added on claude.ai also appears in Claude Desktop, so you don't add it twice. To disconnect, revoke Claude under API keys → Connected apps. The connection can't attach files from your computer; use the CLI or the API for attachments.

The Claude mobile apps use the same connector. This guide covers claude.ai and Claude Desktop.

ChatGPT

ChatGPT on the web adds ReplyLayer as an app in developer mode, which you turn on in ChatGPT's settings. If you don't see developer mode there, your plan or workspace doesn't offer it yet. ChatGPT needs developer mode for this until ReplyLayer is listed in its app directory. You don't need a key.

1. Create the app. On chatgpt.com, turn on developer mode in ChatGPT's settings if it isn't on already. Open Plugins, choose Add, then Create MCP App. Enter a name, such as ReplyLayer, and paste this URL as the Connection URL:

https://api.replylayer.ai/v1/mcp/oauth

Tick I understand and want to continue, then choose Create.

2. Connect and approve. Choose Continue to ReplyLayer (ChatGPT uses the name you entered). A ReplyLayer page opens: Connect ChatGPT (chatgpt.com) to ReplyLayer. Sign in if it asks, choose the mailboxes ChatGPT may use, including the one you chose on the Connect page, and choose Approve. If ReplyLayer asks you to confirm your sign-in, for example with a text message code, do that and choose Approve again.

3. Check it. Start a new chat and paste the connection check from the Connect page (choose ChatGPT in Step 3): it sends one test email to the delivery simulator with its own chatgpt-check-<token> idempotency key.

To disconnect, revoke ChatGPT under API keys → Connected apps. The app can't attach files from your computer.

Claude Code

Claude Code runs the local stdio server above, registered for every project, and the key stays in ~/.replylayer/credentials — never in Claude Code's own settings.

1. Store the key. Install the CLI (npm install -g rly, or see CLI for other channels), then run this and paste your agent key when it asks:

rly auth login

2. Remove an older entry. If you added ReplyLayer to Claude Code before, remove that entry first, from the project folder where you added it. A project's own (local-scope) entry overrides the one added in step 3:

claude mcp remove replylayer

3. Add the server at user scope, so every project on this computer gets it:

claude mcp add replylayer --scope user -- npx -y replylayer-mcp

4. Check it. Run claude mcp list from any folder; replylayer should show as connected. Then paste the connection check from the Connect page into Claude Code: it sends one test email to the delivery simulator with its own claude-code-check-<token> idempotency key, so pasting it again replays the first send instead of sending twice.

An exported REPLYLAYER_API_KEY in the shell that starts Claude Code overrides the stored key, and an older project entry overrides this whole registration (step 2 removes it). Because the server runs on your computer, it can attach local files.

To use the hosted endpoint from Claude Code instead, register it with the key read from your shell:

claude mcp add --transport http replylayer --scope user https://api.replylayer.ai/v1/mcp --header "Authorization: Bearer ${REPLYLAYER_API_KEY}"

Your shell fills in the key when the command runs, so Claude Code stores it in its settings, and the hosted endpoint cannot attach local files.

Claude Desktop

Claude Desktop's usual path is the Claude connector: you sign in instead of entering a key, and one connector works in claude.ai and Desktop. Use the desktop extension below only if your Claude Desktop can't add connectors.

The extension runs ReplyLayer from one file you download and install. You don't need a terminal, a config file or Node.js, and the key goes into the extension's own settings in Claude Desktop, never into a chat.

1. Download the extension. On the Connect page, choose Claude Desktop in Step 3 and download replylayer.mcpb, or download it here: replylayer.mcpb.

2. Install it. On macOS or Windows, double-click the file and click Install. On Linux, or if double-clicking opens the file as an archive, open Claude Desktop → Settings → Extensions → Advanced settings → Install Extension… and choose the file. Claude Desktop may warn you to use only extensions from developers you trust.

3. Enter your key. Type your agent key into the extension's API key field in the install form. Claude can't enable the extension until it's filled. Then check the extension is switched on under Settings → Extensions → ReplyLayer, and turn it on if it isn't. Don't paste the key into a chat. To change it later, open Settings → Extensions → ReplyLayer. Anyone who can open Claude's extension settings on this computer may be able to see the key. If you revoke the key in ReplyLayer, the extension's calls fail until you enter a new one.

4. Check it. Start a new chat and paste the connection check from the Connect page: it sends one test email to the delivery simulator with its own claude-desktop-check-<token> idempotency key, so pasting it again replays the first send instead of sending twice. If Claude asks before using a ReplyLayer tool, allow it.

Local files. The server runs on your computer, so it can attach any local file the model names, within your own permissions. An instruction hidden in an incoming email could try to get it to attach one. Keep the mailbox's recipient allowlist on, so agent sends go only to addresses you approved; outbound scanning still applies.

Updates. The extension doesn't update itself. When we announce a new version, download it again and install it the same way. The Claude connector needs no download and no updates.

Where it works. The extension is for Claude Desktop's chat. This guide doesn't cover its Code and Cowork tabs.

Cursor

Cursor runs the local stdio server above, installed from the Connect page with one click, and the key stays in ~/.replylayer/credentials — never in Cursor's own settings.

1. Store the key. Install the CLI (npm install -g rly, or see CLI for other channels), then run this and paste your agent key when it asks:

rly auth login

2. Add the server. On the Connect page, choose Cursor in Step 3 and select Add to Cursor. Cursor opens and asks to install replylayer, which runs npx -y replylayer-mcp. Check the command, then choose Install. Cursor writes the entry to ~/.cursor/mcp.json, so every project on this computer gets it.

If you added ReplyLayer to Cursor before, delete the replylayer entry in ~/.cursor/mcp.json first. A replylayer entry in a project's .cursor/mcp.json takes priority over this one, so remove it too.

If nothing opens, add this to ~/.cursor/mcp.json and restart Cursor. If the file already has an mcpServers object, add only the replylayer entry inside it:

{
  "mcpServers": {
    "replylayer": {
      "command": "npx",
      "args": ["-y", "replylayer-mcp"]
    }
  }
}

For reference, the button opens this Cursor install link. Its config is the base64 of the server entry above, without the name, and it carries no key, no variable and no URL:

cursor://anysphere.cursor-deeplink/mcp/install?name=replylayer&config=eyJjb21tYW5kIjoibnB4IiwiYXJncyI6WyIteSIsInJlcGx5bGF5ZXItbWNwIl19

3. Check it. Under Customize → MCPs, replylayer should be on. Then paste the connection check from the Connect page into an Agent chat: it sends one test email to the delivery simulator with its own cursor-check-<token> idempotency key, so pasting it again replays the first send instead of sending twice. By default Cursor asks before a ReplyLayer tool call, unless Auto-review (Cursor Settings → Agents → Approvals & Execution) runs it; approve any prompt. To troubleshoot, open Output → MCP Logs.

REPLYLAYER_API_KEY in Cursor's environment overrides the stored key, and REPLYLAYER_API_URL in Cursor's environment points the server at a different API; unset both. Because the server runs on your computer, it can attach local files.

What this path can't do. It works in the Cursor app on this computer; Cursor's Cloud Agents don't use it.

OpenClaw

OpenClaw connects to the hosted endpoint. The gateway reads the key from ~/.openclaw/.env and substitutes it into the server's header, so the key never appears in a command or in OpenClaw's config.

1. Put the key on the gateway host. On the machine that runs OpenClaw, open ~/.openclaw/.env in an editor and add one line. For a Docker install, the file is /home/node/.openclaw/.env inside the container.

REPLYLAYER_API_KEY=rly_live_<public_id>.<secret>

2. Restart the gateway with openclaw gateway restart. For a Docker install, restart the container instead, for example docker restart openclaw-gateway.

3. Register the server. Keep the single quotes: they stop your shell expanding ${REPLYLAYER_API_KEY}, so OpenClaw receives the reference, not the key. OpenClaw defaults a remote server to SSE, and this endpoint is Streamable HTTP, so transport must be set.

openclaw mcp set replylayer '{"url":"https://api.replylayer.ai/v1/mcp","transport":"streamable-http","headers":{"Authorization":"Bearer ${REPLYLAYER_API_KEY}"}}'

4. Reload and probe.

openclaw mcp reload
openclaw mcp probe replylayer --json

The probe should list 44 tools and no diagnostics. openclaw mcp doctor may warn that headers.Authorization contains a literal sensitive value; that warning is about the ${REPLYLAYER_API_KEY} template and can be ignored.

Retrying. OpenClaw masks idempotency_key in session history, so a retry inside the same session is sent with a masked key (see Idempotency keys must be literal above). If a send fails or you need to repeat it, start a new OpenClaw session and repeat the full request. The Connect page's check prompt says the same, and uses its own openclaw-check-<token> key.

What this path can't do. The hosted endpoint cannot read files on the gateway host, so it cannot attach them; use the CLI or the REST API for attachments.

Checking it worked. The probe lists 44 tools, list_mailboxes and a simulator send succeed, and re-pasting the check prompt in a new session replays the first send instead of sending again. For receiving mail in OpenClaw, see OpenClaw and webhooks below.

Hermes

Hermes connects to the hosted endpoint. It reads MCP servers from ~/.hermes/config.yaml and resolves a ${VAR} reference inside headers when it connects to the server, from the gateway's own environment — which includes ~/.hermes/.env. So the key lives in .env and the config only names it.

1. Put the key on the Hermes host. Add one line to ~/.hermes/.env on the machine that runs the gateway:

REPLYLAYER_API_KEY=rly_live_<public_id>.<secret>

2. Add the server. Merge this into ~/.hermes/config.yaml under the top-level mcp_servers key, keeping any other servers already listed there:

mcp_servers:
  replylayer:
    url: "https://api.replylayer.ai/v1/mcp"
    headers:
      Authorization: "Bearer ${REPLYLAYER_API_KEY}"

3. Reload. Run /reload-mcp in Hermes, or restart the gateway. The header is resolved when Hermes loads its MCP config (it re-reads .env first), so an edit to either file needs a reload before it takes effect.

Hermes also accepts timeout and connect_timeout in an mcp_servers entry, both in seconds. The defaults (300 s per tool call, 60 s to connect) already cover the 30 s wait_for_message long poll, so leave both unset. If you do set timeout, keep it at 60 or above: a value at or below 30 races the long poll and reports a healthy wait as a timeout.

Naming the toolset in routes and cron jobs

Hermes exposes each MCP server as its own toolset, named mcp-<server> — here mcp-replylayer — and registers the bare server name as an alias for it. The individual tools arrive as mcp__replylayer__send_email, mcp__replylayer__list_messages, and so on (two underscores on each side of the server name).

Use the bare alias replylayer wherever you scope a run's tools. On the Hermes release these recipes were written against (v2026.9.11) the bare alias is the selector that scopes a run to ReplyLayer and nothing else; the mcp--prefixed form does not exclude the other MCP servers you have configured from a scheduled run. A webhook route:

platforms:
  webhook:
    enabled: true
    extra:
      routes:
        replylayer:
          toolsets: [replylayer]

and a scheduled job's enabled_toolsets takes the same value. Three things to know. The toolsets list replaces the default set rather than adding to it, so list every toolset the workflow needs. An unknown name is dropped with a warning the gateway does not show, so a typo looks exactly like a healthy route that never calls a tool. And toolset selection is version-sensitive — confirm from the run transcript which tools a run actually had, rather than trusting the config to tell you.

Troubleshooting

  • hermes mcp test replylayer says Connection failed and Server returned an error response. That is how Hermes shows a 401: either the key is wrong, or .env is on the wrong machine. ${REPLYLAYER_API_KEY} is resolved in the gateway's own process, so the variable has to be set on the host that runs Hermes — not on the machine you type prompts from, when those differ. A key that has never passed both signup checks is also inert on protected routes; see Authentication.
  • No ReplyLayer tools show up. Hermes reads mcp_servers when it connects, so a config edit is not live until you run /reload-mcp or restart the gateway. If the tools are present but a route never uses them, check the route's toolsets for a dropped name.
  • wait_for_message returned nothing. That is the normal result of a quiet window, not a failure — the tool long-polls for a bounded interval and comes back empty when nothing arrived. Call it again, and pass since so a repeated call cannot miss a message that landed between two calls.
  • A local attachment path is refused. The hosted endpoint runs on ReplyLayer's servers and cannot read files from the Hermes host, so it rejects a local path with HOSTED_LOCAL_ATTACHMENTS_UNSUPPORTED instead of reading one. Use the local stdio server above, which does read local files, or send that message through the REST API, which takes the attachment bytes directly.

Give the workflow an identity

Both recipes below key their replies on a workflow slug — one short ASCII name you pick per workflow, matching ^[a-z0-9][a-z0-9-]{0,63}$ (support-inbox, orders-triage). Set it once, on the Hermes host, in ~/.hermes/.env:

REPLYLAYER_WORKFLOW_ID=support-inbox

The route script reads it from there, and the scheduled prompt carries the same slug written out literally. Every reply the workflow sends then uses an idempotency key built from it:

rlh1:<slug>:<mailbox_id>:<message_id>:reply

That key is what turns a redelivered event, an overlapping run, or a retry after a crash into one committed reply: ReplyLayer keeps the record of an immediate send permanently, so a later call with the same key replays the first result instead of sending a second message.

Which makes the slug durable state, not a label. Use the same slug in both recipes for the same mailbox, and keep it across upgrades and reinstalls. Changing it changes every key, and a message the old slug already replied to is a message the new slug has never seen.

Check it before you rely on it

These recipes are written for Hermes v2026.9.11. Before you rely on them, run each of these checks on your own installation:

  • a message received, read, and replied to once;
  • a redelivery of the same event yielding one committed reply;
  • recovery when the reply call fails before the send;
  • recovery when the run dies after the send but before the read marker;
  • more than one page of eligible messages;
  • a scheduled run overlapping a webhook run for the same message;
  • a misconfiguration — a wrong secret, a platform left disabled, an unknown toolset name — being caught by the checks below rather than passing silently.

Every one of those is a check you can run yourself with the steps on this page. Watch the first runs.

Start a run from an incoming message

Three parts: a route script that reduces the webhook payload to identifiers, a Hermes route that names the script and the toolset, and a ReplyLayer webhook pointed at it.

1. The route script. Hermes pipes the webhook body to a script and replaces the payload with whatever JSON object the script prints, so the script is where message content stops. Save this as ~/.hermes/scripts/replylayer_route.py — Hermes resolves a route's script under <HERMES_HOME>/scripts/ (~/.hermes/scripts by default), refuses a path that resolves outside it, and gives the script 30 seconds:

#!/usr/bin/env python3
"""Hermes webhook route script for ReplyLayer `message.received` events.

Hermes pipes the raw webhook body to this script on stdin and replaces the
payload with whatever JSON object the script prints. This one prints only
identifiers plus a deterministic reply key, so no subject, body, sender, or raw
payload can reach the prompt. Anything it cannot handle becomes
`{"__hermes_ignore__": true}`, which Hermes answers with HTTP 200 `ignored`.

Needs `REPLYLAYER_WORKFLOW_ID` (the workflow slug) in the gateway's environment;
`~/.hermes/.env` reaches this subprocess. Standard library only.
"""

import json
import os
import re
import sys

IGNORE = {"__hermes_ignore__": True}
SLUG_RE = re.compile(r"^[a-z0-9][a-z0-9-]{0,63}$")
# ReplyLayer ids are UUIDs. Reject whitespace, control characters, and absurd
# lengths so a value that lands in a prompt slot cannot carry lines of its own.
MAX_ID_LEN = 200


def emit(obj):
    """Write one compact JSON line to stdout."""
    sys.stdout.write(json.dumps(obj, separators=(",", ":")) + "\n")


def ignore(reason=None):
    """Drop the event. `reason` is operator diagnostics only, never message content."""
    if reason:
        sys.stderr.write("replylayer_route: %s\n" % reason)
    emit(IGNORE)
    return 0


def clean_id(value):
    """Return a usable identifier string, or None."""
    if not isinstance(value, str):
        return None
    candidate = value.strip()
    if not candidate or len(candidate) > MAX_ID_LEN:
        return None
    for char in candidate:
        if char.isspace() or ord(char) < 0x20 or ord(char) == 0x7F:
            return None
    return candidate


def main():
    slug = os.environ.get("REPLYLAYER_WORKFLOW_ID", "")
    if not SLUG_RE.match(slug):
        return ignore(
            "REPLYLAYER_WORKFLOW_ID is unset or not a valid slug "
            "(^[a-z0-9][a-z0-9-]{0,63}$); set it in ~/.hermes/.env and restart the gateway"
        )

    try:
        envelope = json.loads(sys.stdin.read())
    except ValueError:
        return ignore("webhook body was not JSON")
    if not isinstance(envelope, dict):
        return ignore("webhook body was not a JSON object")

    if envelope.get("event") != "message.received":
        return ignore()

    data = envelope.get("data")
    if not isinstance(data, dict):
        return ignore()

    message_id = clean_id(data.get("message_id"))
    mailbox_id = clean_id(data.get("mailbox_id"))
    if message_id is None or mailbox_id is None:
        return ignore("message.received carried no usable message_id / mailbox_id")

    emit(
        {
            "event_id": clean_id(envelope.get("id")) or "",
            "mailbox_id": mailbox_id,
            "message_id": message_id,
            "idempotency_key": "rlh1:%s:%s:%s:reply" % (slug, mailbox_id, message_id),
        }
    )
    return 0


if __name__ == "__main__":
    sys.exit(main())

It prints four fields — event_id, mailbox_id, message_id, idempotency_key — and nothing else, so no subject, body, sender, or raw payload reaches the prompt. The agent still reads the message, through read_message, where the content arrives with its untrusted-content contract attached; what the script removes is the path where a subject line would have been pasted into the instructions before the agent even started. Every other case — a different event (the webhook.test envelope included), a missing id, an unset or malformed slug — prints {"__hermes_ignore__": true}, and Hermes answers 200 ignored without starting a run.

2. The route. Merge this into ~/.hermes/config.yaml, keeping any routes already under platforms.webhook.extra.routes, and put your webhook signing secret in secret:

platforms:
  webhook:
    # Off by default. Without this the platform never starts, so the route below is
    # not served at all and the health check above answers 404 rather than 401.
    enabled: true
    extra:
      # The platform listens on 8644 unless you set `port:` here, and it serves a
      # mapped route at /webhooks/<route name> — so this route is
      # http://<hermes-host>:8644/webhooks/replylayer. If your gateway already sets
      # a port, keep that one and use it everywhere below.
      routes:
        replylayer:
          script: replylayer_route.py
          secret: <your ReplyLayer webhook signing secret>
          # This list REPLACES the default webhook toolset rather than adding to it.
          # Add any other toolset the workflow needs. An unknown name is dropped after
          # a warning the gateway hides, which looks exactly like a route that never
          # calls a tool. The bare server name is the alias verified on Hermes
          # v2026.9.11 to select only the ReplyLayer toolset; confirm the effective
          # toolset from the run transcript rather than from this file.
          toolsets: [replylayer]
          prompt: "ReplyLayer message {message_id} arrived in mailbox {mailbox_id}. Read it with read_message and follow the configured workflow. If you reply, call reply_to_message with idempotency_key {idempotency_key} exactly as given. Completion rules: call mark_message_read only after one of these: email_effect.effect_status is sent, held_for_review, or blocked, or you decided no reply is needed. If effect_status is held_for_review or blocked, report it and never rewrite the content. If the reply call fails with a transport error, or returns IDEMPOTENT_REQUEST_IN_FLIGHT or IDEMPOTENT_REQUEST_NOT_PROVEN_SENT, or effect_status is held_infrastructure, do not mark the message read and do not report success; the next run retries with the same idempotency_key and ReplyLayer replays a completed send. Report NOT_PROVEN_SENT for a human to investigate."

Three things decide whether this route ever runs. platforms.webhook.enabled has to be true: the webhook platform is off by default, and a platform that is not enabled serves none of its routes — the URL simply answers 404. The toolsets list replaces the default webhook toolset rather than adding to it, so name every toolset the workflow needs. And the ReplyLayer toolset only becomes real once the server has connected and registered its tools, so a gateway that cannot reach ReplyLayer hands the run an empty toolset. Restart the gateway after editing either .env or the config.

The prompt carries the completion rules in full rather than pointing at them. A webhook run is a fresh session, and Hermes does not pass an MCP server's own instructions to the model, so a prompt that says "follow the rules in the docs" loads nothing.

3. Health-check the route — before you create the webhook. The platform serves a mapped route at /webhooks/<route name>, on port 8644 unless you set extra.port, so this one is http://<hermes-host>:8644/webhooks/replylayer. An unsigned POST to it has to be rejected:

curl -sS -o /dev/null -w '%{http_code}\n' \
  -X POST http://localhost:8644/webhooks/replylayer \
  -H 'Content-Type: application/json' -d '{}'

401 is the pass: the platform is enabled, the route is mapped, and it is verifying signatures. 404 means the route is not mapped — either the name in the URL differs from the route key, or platforms.webhook.enabled is not true. The platform's own listener answers curl http://localhost:8644/health with {"status": "ok", "platform": "webhook"} once it is up. Do this first: a webhook pointed at a route that does not exist collects failed deliveries, and 20 consecutive failures disable it.

4. The ReplyLayer webhook. Subscribe a webhook to message.received, point it at the public URL Hermes serves this route on, and store the secret it prints once into the route's secret:

rly webhook create --url https://<your-hermes-host>/webhooks/replylayer \
  --event message.received --confirm

ReplyLayer signs every delivery with X-ReplyLayer-Signature and sends the same proof as the Generic V2 pair Hermes verifies — X-Webhook-Timestamp and X-Webhook-Signature-V2, the identical secret and the identical HMAC-SHA256 over <timestamp>.<raw body>. There is nothing to configure beyond matching the secret. A secret that does not match gets 401 from Hermes, and the delivery shows up in rly webhook deliveries as failed.

5. Prove it is actually running. A misconfigured route still answers 2xx, so the status code is not the evidence. Work through these four, in order:

StepDo thisWhat passes
1. Signature and scriptrly webhook test <id> — the default webhook.test envelopeHermes answers 200 ignored. The signature was accepted and the script ran and dropped a non-matching event. A 401 means the secrets differ.
2. A real messagePOST /v1/simulator/inbound, or send real mail to the mailboxHermes answers 202 accepted. The script produced a payload and a run started.
3. The ReplyLayer sideRead the run transcript, then probe the expected key (below)The transcript shows mcp__replylayer__read_message for that message id, and — if the workflow decided to reply — the probe returns that reply. This is the step that proves the run actually had the tools.
4. A redeliveryRun replay_delivery.py (below) twice, with the same identifiers as the original eventHermes runs twice. The transcript shows two reply_to_message invocations with identical keys, and ReplyLayer still shows one message: the probe returns the same message id both times.

The probe is a read-only lookup of a reply by its key, with no side effects:

curl -sS https://api.replylayer.ai/v1/messages/idempotency \
  -H "Authorization: Bearer $REPLYLAYER_API_KEY" \
  -H "Idempotency-Key: rlh1:<slug>:<mailbox_id>:<message_id>:reply"

200 returns the reply that key committed; 404 means nothing was ever sent under it.

The replay tool for step 4. ReplyLayer cannot redeliver an event a receiver has already accepted. rly webhook retry re-queues a failed delivery and answers 409 DELIVERY_NOT_FAILED for anything else — and a delivery Hermes answered 2xx to is delivered, not failed. The deliveries list returns delivery metadata, never the payload, so the original body cannot be fetched and re-sent either. This tool rebuilds the envelope from the identifiers and signs it the way ReplyLayer signs every delivery. It is an operator tool for this check, not part of the running integration, so save it anywhere:

#!/usr/bin/env python3
"""Replay one ReplyLayer `message.received` delivery at a webhook receiver.

An activation-check tool, not part of the running integration. It exists because
neither ReplyLayer path can redeliver an event a receiver already accepted:
`POST /v1/webhooks/:id/deliveries/:delivery_id/retry` answers
`409 DELIVERY_NOT_FAILED` for anything but a `failed` delivery, and a delivery a
receiver answered 2xx to is `delivered`; the deliveries API returns delivery
metadata, never the payload, so the original body cannot be fetched and re-sent
either.

So this rebuilds the envelope from identifiers you pass on the command line and
signs it the way ReplyLayer signs every delivery: HMAC-SHA256, hex, over
`<unix seconds>.<raw body>`, sent as `X-ReplyLayer-Signature: t=<ts>,v1=<hex>`
and as the Generic V2 pair `X-Webhook-Timestamp` + `X-Webhook-Signature-V2`.

The signing secret is read from the `REPLYLAYER_WEBHOOK_SECRET` environment
variable and is never a command-line argument, so it stays out of the process
list, the shell history, and any transcript of this run.

Run it twice with the same `--message-id` and `--mailbox-id` and the receiver
runs twice; the reply idempotency key both runs compute is identical, so
ReplyLayer still commits one reply. Standard library only.

    export REPLYLAYER_WEBHOOK_SECRET=...   # the webhook's signing secret
    python3 replay_delivery.py \
      --url https://<hermes-host>/webhooks/replylayer \
      --event-id <event id from the original delivery> \
      --message-id <message id> \
      --mailbox-id <mailbox id>
"""

import argparse
import hashlib
import hmac
import json
import os
import time
import urllib.error
import urllib.request

SECRET_ENV = "REPLYLAYER_WEBHOOK_SECRET"
EVENT = "message.received"


def iso_now():
    """`occurred_at` in the shape ReplyLayer emits: UTC, milliseconds, `Z`."""
    return time.strftime("%Y-%m-%dT%H:%M:%S.000Z", time.gmtime())


def build_envelope(event_id, message_id, mailbox_id, account_id="replay", occurred_at=None):
    """The ReplyLayer wire envelope, carrying identifiers and nothing else.

    A real `message.received` payload also carries sender, recipient, subject and
    received_at. They are deliberately absent: the route script drops them before
    the prompt is built, so a replay that omits them exercises the same path.
    """
    return {
        "id": event_id,
        "event": EVENT,
        "occurred_at": occurred_at or iso_now(),
        "account_id": account_id,
        "data": {
            "message_id": message_id,
            "mailbox_id": mailbox_id,
        },
    }


def sign(secret, body, timestamp):
    """HMAC-SHA256 hex over `<timestamp>.<body>` — what ReplyLayer signs."""
    signed = "%d.%s" % (timestamp, body)
    return hmac.new(
        secret.encode("utf-8"), signed.encode("utf-8"), hashlib.sha256
    ).hexdigest()


def build_request(url, body, secret, timestamp):
    """A signed POST carrying both header packagings of one HMAC computation.

    urllib title-cases header names on the way out. HTTP header names are
    case-insensitive, so a receiver reads them the same either way.
    """
    digest = sign(secret, body, timestamp)
    return urllib.request.Request(
        url,
        data=body.encode("utf-8"),
        method="POST",
        headers={
            "Content-Type": "application/json",
            "X-ReplyLayer-Signature": "t=%d,v1=%s" % (timestamp, digest),
            "X-Webhook-Timestamp": str(timestamp),
            "X-Webhook-Signature-V2": digest,
        },
    )


def build_parser():
    """The CLI. No option carries the secret — that is read from the environment."""
    parser = argparse.ArgumentParser(
        prog="replay_delivery.py",
        description=(
            "Replay a ReplyLayer message.received delivery at a webhook receiver, "
            "signed the way ReplyLayer signs it."
        ),
        epilog=(
            "The signing secret is read from the %s environment variable. It is "
            "never a command-line argument, so it cannot reach the process list "
            "or the shell history." % SECRET_ENV
        ),
    )
    parser.add_argument(
        "--url",
        required=True,
        help="Receiver URL, e.g. https://<hermes-host>/webhooks/replylayer",
    )
    parser.add_argument(
        "--event-id",
        required=True,
        help="The `id` of the event being replayed (from the original delivery)",
    )
    parser.add_argument("--message-id", required=True, help="ReplyLayer message id")
    parser.add_argument("--mailbox-id", required=True, help="ReplyLayer mailbox id")
    parser.add_argument(
        "--account-id",
        default="replay",
        help="Envelope account_id; defaults to 'replay' (identifiers are what the route reads)",
    )
    parser.add_argument(
        "--timeout", type=float, default=30.0, help="Seconds to wait for a response"
    )
    return parser


def main(argv=None):
    parser = build_parser()
    args = parser.parse_args(argv)

    secret = os.environ.get(SECRET_ENV, "")
    if not secret:
        parser.error(
            "%s is not set. Export the webhook's signing secret into the "
            "environment; it is never passed as an argument." % SECRET_ENV
        )

    envelope = build_envelope(
        args.event_id, args.message_id, args.mailbox_id, args.account_id
    )
    body = json.dumps(envelope, separators=(",", ":"))
    timestamp = int(time.time())
    request = build_request(args.url, body, secret, timestamp)

    try:
        with urllib.request.urlopen(request, timeout=args.timeout) as response:
            status = response.status
            payload = response.read().decode("utf-8", "replace")
    except urllib.error.HTTPError as err:
        # A 401 or 404 is a result worth printing, not a crash: both are the
        # answers the activation check is looking for.
        status = err.code
        payload = err.read().decode("utf-8", "replace")
    except urllib.error.URLError as err:
        raise SystemExit(
            "replay_delivery: could not reach %s (%s)" % (args.url, err.reason)
        )

    print("HTTP %d" % status)
    print(payload)
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

Run it twice with the same identifiers the original event carried:

export REPLYLAYER_WEBHOOK_SECRET=<the webhook signing secret>

for run in 1 2; do
  python3 replay_delivery.py \
    --url https://<your-hermes-host>/webhooks/replylayer \
    --event-id <event id from the original delivery> \
    --message-id <message id> \
    --mailbox-id <mailbox id>
done

Both runs answer HTTP 202, Hermes runs twice, and the two runs compute the same reply key from the same identifiers — so the transcript shows two reply_to_message invocations with identical keys and the probe above returns one message id, the same one each time. The signing secret is read from the environment, never from an argument, so it stays out of the process list and the shell history.

The alternative, if you would rather exercise the real retry path. Make the first delivery genuinely fail: stop the Hermes gateway, then inject the inbound message. Nothing answers, ReplyLayer records the delivery failed, and rly webhook deliveries shows it as such. Restart the gateway, then run rly webhook retry <id> <delivery-id> on that failed delivery — ReplyLayer redelivers the same event id, and the receiver sees exactly what it would have seen the first time. Slower to set up, and it proves ReplyLayer's own retry path rather than reproducing it.

A 200 ignored at step 2, or a 202 with no read at step 3, is a failed activation, not a slow one. The step 3 health check has already ruled out a disabled platform, so check three things in this order: the script path (it has to resolve under the gateway's script root), REPLYLAYER_WORKFLOW_ID on the machine that runs the gateway, and the route's toolsets — an unknown toolset name is dropped after a warning the gateway hides, which looks exactly like a healthy route that never calls a tool.

Two properties of this transport are worth stating plainly. Hermes answers 202 when it starts a run and 200 when it ignores or dedupes an event, all before the agent has done anything — so a 2xx marks the delivery delivered on the ReplyLayer side and means acknowledged, never completed. And ReplyLayer deliberately sends no delivery-id header: Hermes records a delivery id before dispatch, so a network failure after that record would turn a ReplyLayer retry into a silently dropped run. Without the header every redelivery runs, and the reply key — not the transport — is what keeps the second run from sending a second message.

Check on a schedule

A scheduled pass over unread mail is the only recipe for a Hermes host with no public HTTPS ingress, and it is the reconciliation path for webhook users: mail that arrived while the webhook was failing or disabled is still unread, so the next scheduled run picks it up.

One prompt, two ways to run it. Substitute your mailbox id and slug:

Call list_messages on mailbox <id> with unread=true, direction='inbound',
status='available'; page with before=<oldest id> until no more results. For each
message: call read_message, decide per the workflow, and if you reply call
reply_to_message with idempotency_key 'rlh1:<slug>:<mailbox_id>:<message_id>:reply'.
Completion rules: call mark_message_read only after one of these:
email_effect.effect_status is sent, held_for_review, or blocked, or you decided no
reply is needed. If effect_status is held_for_review or blocked, report it and never
rewrite the content. If the reply call fails with a transport error, or returns
IDEMPOTENT_REQUEST_IN_FLIGHT or IDEMPOTENT_REQUEST_NOT_PROVEN_SENT, or effect_status
is held_infrastructure, do not mark the message read and do not report success; the
next run retries with the same idempotency_key and ReplyLayer replays a completed
send. Report NOT_PROVEN_SENT for a human to investigate.

The rules are written out rather than referred to. A scheduled run is a fresh session with no memory of this page, and Hermes does not pass an MCP server's own instructions to the model, so a prompt that says "apply the completion rules" applies nothing.

Hermes. A cron job runs that prompt in a fresh session. Three fields carry the recipe — the schedule, the prompt, and the toolset override — wherever your Hermes version takes a job definition:

schedule: "*/15 * * * *"
prompt: "Call list_messages on mailbox <id> with unread=true, direction='inbound', status='available'; page with before=<oldest id> until no more results. For each message: call read_message, decide per the workflow, and if you reply call reply_to_message with idempotency_key 'rlh1:<slug>:<mailbox_id>:<message_id>:reply'. Completion rules: call mark_message_read only after one of these: email_effect.effect_status is sent, held_for_review, or blocked, or you decided no reply is needed. If effect_status is held_for_review or blocked, report it and never rewrite the content. If the reply call fails with a transport error, or returns IDEMPOTENT_REQUEST_IN_FLIGHT or IDEMPOTENT_REQUEST_NOT_PROVEN_SENT, or effect_status is held_infrastructure, do not mark the message read and do not report success; the next run retries with the same idempotency_key and ReplyLayer replays a completed send. Report NOT_PROVEN_SENT for a human to investigate."
enabled_toolsets: [replylayer]

Without enabled_toolsets the job is not starved of ReplyLayer tools: it runs the cron platform toolset, which by default includes the MCP servers you have enabled — so it gets ReplyLayer and everything else you have configured. The override exists to scope the run to ReplyLayer only, and it replaces the default set the same way a route's toolsets does. Use the bare alias: on v2026.9.11 replylayer selects this server and nothing else, while the mcp--prefixed form does not exclude the others. Confirm which tools the run actually had from its transcript. The gateway has to be running for a job to fire, and a job whose previous run has not finished is refused rather than started a second time.

OpenClaw. Create an automation whose payload is an agent turn carrying the same prompt, with openclaw automations create (the automations command also answers to cron); run openclaw automations create --help for the flag names on your build. Give the automation access to the ReplyLayer MCP tools under OpenClaw's own MCP configuration.

OpenClaw does not document what happens when one run is still going as the next is due, so this recipe does not depend on it: pick an interval comfortably longer than a run, and note that an overlap is survivable anyway — a reply from the second run carries the same key and replays the first, and a message is only marked read after a terminal outcome.

In Hermes the model sees these tools as mcp__replylayer__read_message, mcp__replylayer__reply_to_message, and so on; OpenClaw exposes them under its own MCP naming. The prompt above uses the bare tool names on purpose — the model maps them to whichever prefixed names it has been given.

Completion rules

Both prompts above carry these rules in full, because a fresh run has no other way to learn them: Hermes does not hand the model an MCP server's own instructions, so a pointer to this page reaches nothing. This section is the same contract written out for you rather than for the model — it is what makes unread state a usable recovery set, and it is the same either way. If you rewrite a prompt, carry the rules with it.

  • Mark a message read only after a terminal recorded outcome. That means email_effect.effect_status came back sent, held_for_review, or blocked, or the workflow decided no reply was needed. sent means ReplyLayer accepted the message for sending; it is not a statement about what happened after that.
  • Report held_for_review and blocked to whatever the job delivers to. Both are terminal for this run: the message is marked read and the workflow stops. Never rewrite the content and try again after a block.
  • Leave the message unread, and do not report success, on a transport error, on IDEMPOTENT_REQUEST_IN_FLIGHT, on IDEMPOTENT_REQUEST_NOT_PROVEN_SENT, or on held_infrastructure. The next run retries with the same key, and the server replays a send that already completed rather than making a second one. IDEMPOTENT_REQUEST_NOT_PROVEN_SENT is reported for a person to look at — it is the one case where the outcome of a send is genuinely unknown, and minting a fresh key to "try again" is how it becomes two messages.
  • The recipe owns the read markers for that mailbox. Anything else that marks mail read — a person in the dashboard, another agent, a second workflow — removes it from the recovery set. Give each workflow its own mailbox.

What this buys you, stated plainly: at most one committed reply per inbound message; at-least-once processing, so a message may be handled more than once; repeated model work after a crash is possible and accepted. What it does not buy you: any guarantee that a sent message arrives, and any exactly-once execution of the agent itself.

What ReplyLayer does with the delivery

The webhook side of the contract, since the recipes depend on it:

  • 2xx → the delivery is delivered. From Hermes that is a 202 for a started run or a 200 for an ignored or duplicate event.
  • 3xx, and 4xx other than 429 → failed immediately, no retry. A 4xx records last_error: http_<status>; a redirect records http_redirect_forbidden: <status>, because a redirect is treated as an SSRF control violation and a 4xx as a configuration error on the receiving side.
  • 429, 5xx, timeout, and network errors → retried on the published schedule (7 attempts over roughly 32 hours), and failed when that is exhausted.
  • 20 consecutive failed deliveries disable the webhook and email the account owner. While it is disabled, both a retry and a test return 409 WEBHOOK_DISABLED and create no delivery row — re-enable it first, then retry.

Mail that arrived while the webhook was failing or disabled was never dropped on the ReplyLayer side: those messages are still unread in the mailbox, which is exactly what the scheduled recipe reads. Run it after re-enabling a webhook.

OpenClaw and webhooks

OpenClaw's inbound hooks authenticate with a bearer token header only — Authorization: Bearer <hooks.token>, or x-openclaw-token — and reject a token passed in the query string; there is no HMAC option. A ReplyLayer webhook can carry that header: set request_headers on the webhook and point it at a mapped hook.

1. Create the mapped hook in OpenClaw and give its transform one job — reduce the delivery to identifiers. Pass event, the envelope's id, and data.message_id / data.mailbox_id through, and drop everything else. The agent then fetches the message with its own ReplyLayer key, the same way the route script does for Hermes. This is the same boundary as everywhere else on this page: message content that reaches a prompt directly is untrusted input, and identifiers are not.

2. Create the ReplyLayer webhook with the token as a custom header:

curl -X POST https://api.replylayer.ai/v1/webhooks \
  -H "Authorization: Bearer $REPLYLAYER_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://your-openclaw-host.example.com/hooks/<your-hook>",
    "enabled_events": ["message.received"],
    "request_headers": { "Authorization": "Bearer <hooks.token>" }
  }'

x-openclaw-token works in place of Authorization if you prefer it. Header values are write-only — no read path returns one, and responses carry request_header_names only — so to rotate the token you send the whole map again. The ReplyLayer signature headers are still sent and are still the thing that proves the body was not altered; verify them in the workflow even though the hook is already gated on the token. Limits, the reserved names, and the error codes are on the webhooks page.

HTTP acceptance of a delivery does not show that a run happened, read the message, and replied once. Run the same checks as for Hermes above before relying on this recipe; the scheduled-checks recipe is the simpler path.

What the tools cover

The 44 tools break down by category:

  • Reads (no side effects): list and read messages, threads, and drafts; wait for the next inbound message; list mailboxes, recipients, suppressions, the recipient allowlist, the inbound firewall lists; check account usage and the send quota; fetch a derived attachment text preview; and check malicious-link scanning status.
  • Sends: send a new email, reply to a message, or send a prepared draft.
  • Drafts: create, update, delete, and send drafts.
  • Inbound triage: release or block a quarantined message, report-and-block a sender, and release a firewall-blocked message. Human-in-the-loop review approval and denial are admin-only (agent keys receive a 403).
  • Policy and contacts: add recipients, add do-not-contact suppressions, manage the inbound blocklist and allowlist, and mark messages or threads read — with bulk variants (up to 1,000 addresses per call) for suppressions and the inbound lists.
  • Star / unstar and delete: flag messages or threads, and soft-delete a message.

Tools return a typed structuredContent object, described by the tool's outputSchema, alongside the human-readable text block, so an agent can consume the result without re-parsing prose. From replylayer-mcp 0.14.0 that is every tool except remove_suppression (which always returns an error); earlier versions cover the high-traffic reads and sends. The full, annotated tool table — every input field, output schema, and per-tool note — is the machine contract at /agents/mcp.

How results signal errors

A tool that fails returns a tool result with isError: true (a readable message the model can recover from), rather than a JSON-RPC protocol error — so a recoverable condition never crashes the tool call. The full error-handling contract, including which conditions surface this way, lives at /agents/mcp; the machine-readable error catalog is at /agents/errors.

Sends return an outcome you branch on

The send tools do not report success or failure the way a plain HTTP call would — they return a delivery outcome (for example, delivered, held for review, or scanner-held) that you branch on. The state and verdict vocabulary those outcomes draw from is documented once at /agents/messages; the send tool response shapes are on /agents/mcp.

Safety model

Every inbound read carries a standing untrusted-content contract in its agent_safety_context: message bodies, attachments, and links are untrusted input, and the accompanying guidance tells the agent not to follow instructions embedded in a message. Scanning is directional — outbound text that resembles prompt-injection is your agent's own authored content and is delivered, while inbound prompt-injection is held. See Content scanning for the customer-readable model and /agents/security-model for the per-field trust taxonomy.

Relaxing that untrusted-content contract for a specific verified sender is possible, but it is configured entirely from the dashboard (with a fresh human re-auth) — an agent, and this MCP server, cannot enable or grant it. There is no client-side opt-in and no MCP tool to grant, list, or revoke trusted instruction sources.

See also