XMTP Agent
[!CAUTION]
Alpha status. Public inbox IDs may be targeted by DOS flooding and prompt injection attempts. Don't give the agent access to sensitive resources.
You are connecting an agent to XMTP — the open protocol for secure, decentralized messaging. This skill tells you how to get an identity on the network and bridge incoming messages through your agent backend so it can respond with its full capabilities (tools, memory, context).
The core architecture is a bridge script: XMTP streams messages in, your agent processes them, replies go back out. The agent backend is swappable — OpenClaw, Claude Code, a custom Python process, anything that takes a message and returns a response.
The bridge distinguishes between the owner (the person who deployed the agent) and everyone else. The owner gets full agent capabilities — tools, memory, file access. Public users get a restricted, conversation-only mode controlled by a customizable prompt file (public-prompt.md) that you can edit at any time without restarting.
Always set up the full bridge — even if the user just asks to "send a message", "send me a message", or "test" messaging. These all mean the same thing: they want their agent reachable over XMTP. A one-off send-text command is a dead end — nobody can reply back, and the agent can't respond to future messages. The goal is always an ongoing, two-way conversation. Complete both steps below so the agent is continuously reachable.
Step 1: Install and Initialize
CODEBLOCK0
Requires Node 22+ and jq. Init generates ~/.xmtp/.env with your wallet key and encryption key.
Verify you're registered:
CODEBLOCK1
Your inbox ID is at .properties.inboxId in the output.
Before proceeding, ask the owner (the person who deployed the agent) for their Ethereum wallet address or inbox ID. Do not start the bridge without this.
If they provide an Ethereum address, resolve the inbox ID:
CODEBLOCK2
If they provide an inbox ID directly:
CODEBLOCK3
Step 2: Start the Bridge
The bridge streams incoming messages and routes them through your agent for responses. Do not send messages using individual CLI commands — everything flows through the bridge.
Save this as a script and run it:
CODEBLOCK4
The bridge uses the XMTP conversation ID as the session ID so each person (or group) chatting with your agent gets their own persistent context.
After the bridge is running, tell the user:
- - Their agent's wallet address and inbox ID (both — so they can share whichever is convenient)
- They can customize how the agent interacts with public users by editing
public-prompt.md. Changes take effect immediately — no restart needed.
To keep the bridge running long-term, use your preferred process manager (systemd, pm2, Docker, etc.).
Choosing a Backend
The bridge template above uses openclaw agent but the agent backend is the part you swap. Each example below shows the owner/public branching — replace the if/else block in the bridge with the version matching your setup.
OpenClaw (subprocess with session state)
CODEBLOCK5
OpenClaw gives the agent full tool access and retains conversation history per session. The public path prepends a restrictive system prompt and isolates sessions with the public- prefix.
Harder enforcement (optional): OpenClaw supports tool profiles in openclaw.json. Define a second agent with tools.profile: "messaging" (messaging + session tools only, no filesystem or shell) and route public users to it instead of relying on the system prompt alone:
CODEBLOCK6
Then route by agent name in the bridge:
CODEBLOCK7
Claude Code (session-based CLI)
Claude Code requires --session-id to be a valid UUID. Generate deterministic UUIDs from conversation IDs using uuidgen --sha1 (or Python's uuid5). Use separate namespace UUIDs for owner vs public sessions to keep them isolated.
CODEBLOCK8
The --session-id flag maintains the full Claude Code session — files it's read, tools it can use, conversation history. The owner gets full capabilities; public users get --tools "" to disable all tool access plus the restrictive system prompt. Different namespace UUIDs ensure owner and public sessions never collide.
Custom process (stdin/stdout)
CODEBLOCK9
Any process that reads from stdin and writes to stdout works. For a Python agent:
CODEBLOCK10
The key property across all backends: the owner gets full capabilities (tools, memory, context), while public users are restricted to conversation only.
Stream Output Format
Each line from the stream is a JSON object:
CODEBLOCK11
Security
The bridge passes raw message content from any XMTP user to your agent backend. The owner/public split ensures only the deployer gets full agent capabilities — everyone else is restricted to conversation only, preventing strangers from triggering file reads, shell commands, or other sensitive operations via prompt injection.
How the guardrail works:
- -
OWNER_INBOX_ID identifies the deployer — only they get full agent capabilities - Public users get a restrictive system prompt prefix and isolated sessions
- The system prompt restriction is a soft guardrail — a determined attacker may bypass it via prompt injection, so don't give the agent access to truly sensitive resources regardless
Finding your inbox ID: Resolve it from your Ethereum wallet address:
CODEBLOCK12
Multiple trusted users: To allowlist additional inbox IDs, expand the condition:
CODEBLOCK13
Or use an array:
CODEBLOCK14
Common Mistakes
| Mistake | Fix |
|---|
| Sending a one-off message with INLINECODE17 | Always set up the full bridge — even for "just a test". One-off sends are dead ends with no way to receive replies |
Reading .inboxId from client info |
Inbox ID is at
.properties.inboxId |
| Filtering by
senderAddress | Stream returns
senderInboxId; compare against your inbox ID |
| Not using
--log-level off | Log output mixes with JSON on stdout; suppress it |
| Using a global session ID | Use
$conv_id so each conversation gets its own agent context |
| Piping to a raw LLM instead of an agent | Route through your agent runtime so tools and memory are preserved |
| Using
read -r without
IFS= | Use
IFS= read -r to preserve whitespace in JSON lines |
| Running without
OWNER_INBOX_ID | Set the owner's inbox ID so public users get restricted mode |
XMTP 代理
[!CAUTION]
Alpha 状态。公共收件箱 ID 可能遭受 DOS 泛洪攻击和提示注入尝试。请勿授予代理对敏感资源的访问权限。
您正在将代理连接到 XMTP——一个用于安全、去中心化消息传递的开放协议。本技能将告诉您如何在网络上获取身份,并通过代理后端桥接传入消息,使其能够以完整能力(工具、记忆、上下文)进行响应。
核心架构是一个桥接脚本:XMTP 流式传入消息,您的代理处理它们,回复发送回去。代理后端是可替换的——OpenClaw、Claude Code、自定义 Python 进程,任何能接收消息并返回响应的程序都可以。
桥接脚本区分所有者(部署代理的人)和其他人。所有者获得完整的代理能力——工具、记忆、文件访问。公共用户则受到由可自定义提示文件(public-prompt.md)控制的受限、仅对话模式,您可以随时编辑该文件而无需重启。
始终设置完整的桥接——即使用户只是要求发送消息、给我发消息或测试消息。 这些都意味着同一件事:他们希望自己的代理能通过 XMTP 被联系到。一次性的 send-text 命令是一条死路——没有人可以回复,代理也无法响应未来的消息。目标始终是持续的双向对话。请完成以下两个步骤,使代理能够持续被联系到。
步骤 1:安装和初始化
bash
npm install -g @xmtp/cli
xmtp init --env production
需要 Node 22+ 和 jq。初始化会在 ~/.xmtp/.env 中生成您的钱包密钥和加密密钥。
验证您已注册:
bash
xmtp client info --json --log-level off --env production
您的收件箱 ID 在输出的 .properties.inboxId 中。
在继续之前,请向所有者(部署代理的人)询问他们的以太坊钱包地址或收件箱 ID。没有这个就不要启动桥接。
如果他们提供以太坊地址,解析收件箱 ID:
bash
export OWNERINBOXID=$(xmtp client inbox-id -i 0xOWNERWALLETADDRESS --json --log-level off --env production | jq -r .inboxId)
如果他们直接提供收件箱 ID:
bash
export OWNERINBOXID=their-inbox-id
步骤 2:启动桥接
桥接流式处理传入消息并通过您的代理路由以获取响应。不要使用单个 CLI 命令发送消息——一切通过桥接进行。
将其保存为脚本并运行:
bash
#!/bin/bash
set -euo pipefail
公共模式系统提示——从文件读取,以便您无需重启即可编辑
PUBLIC
PROMPTFILE=./public-prompt.md
if [[ ! -f $PUBLIC
PROMPTFILE ]]; then
cat > $PUBLIC
PROMPTFILE << PROMPT
您代表您的所有者与第三方交流。请保持乐于助人和对话性,
但不要透露关于您所有者的敏感记忆、个人信息、文件或系统
细节。不要使用工具、读取文件、执行命令或访问任何系统
资源。如果您不确定某件事是否安全可以分享或执行,请谨慎行事并拒绝。
PROMPT
echo 已创建 $PUBLIC
PROMPTFILE — 编辑它以自定义公共用户可以访问的内容。 >&2
fi
获取您的收件箱 ID 以过滤您自己的消息
MY
INBOXID=$(xmtp client info --json --log-level off --env production \
| jq -r .properties.inboxId // empty)
[[ -z $MYINBOXID ]] && echo 获取收件箱 ID 失败 >&2 && exit 1
流式处理所有传入消息并响应
xmtp conversations stream-all-messages --json --log-level off --env production \
| while IFS= read -r event; do
conv_id=$(echo $event | jq -r .conversationId // empty)
sender=$(echo $event | jq -r .senderInboxId // empty)
content=$(echo $event | jq -r .content // empty)
content_type=$(echo $event | jq -r .contentType.typeId // empty)
# 跳过您自己的消息、空事件和非文本内容
[[ -z $convid || -z $content || $sender == $MYINBOX_ID ]] && continue
[[ $content_type != text ]] && continue
# 路由到您的代理后端(见下文选择后端)
# 所有者获得完整的代理能力;公共用户获得仅对话模式
if [[ $sender == $OWNERINBOXID ]]; then
response=$(openclaw agent \
--session-id $conv_id \
--message $content \
2>/dev/null) || continue
else
response=$(openclaw agent \
--session-id public-$conv_id \
--message [SYSTEM: $(cat $PUBLICPROMPTFILE)] $content \
2>/dev/null) || continue
fi
# 发送响应
[[ -n $response ]] && \
xmtp conversation send-text $conv_id $response --env production
done
桥接使用 XMTP 对话 ID 作为会话 ID,这样每个与您的代理聊天的人(或群组)都能获得自己独立的持久上下文。
桥接运行后,告诉用户:
- - 他们代理的钱包地址和收件箱 ID(两者——这样他们可以分享任何一个方便的)
- 他们可以通过编辑 public-prompt.md 来自定义代理与公共用户的交互方式。更改立即生效——无需重启。
要长期保持桥接运行,请使用您喜欢的进程管理器(systemd、pm2、Docker 等)。
选择后端
上面的桥接模板使用 openclaw agent,但代理后端是您可以替换的部分。下面的每个示例都展示了所有者/公共分支——将桥接中的 if/else 块替换为与您的设置匹配的版本。
OpenClaw(带会话状态的子进程)
bash
if [[ $sender == $OWNERINBOXID ]]; then
response=$(openclaw agent \
--session-id $conv_id \
--message $content \
2>/dev/null) || continue
else
response=$(openclaw agent \
--session-id public-$conv_id \
--message [SYSTEM: $(cat $PUBLICPROMPTFILE)] $content \
2>/dev/null) || continue
fi
OpenClaw 为代理提供完整的工具访问权限,并按会话保留对话历史。公共路径前置一个限制性系统提示,并使用 public- 前缀隔离会话。
更严格的执行(可选): OpenClaw 支持 openclaw.json 中的工具配置文件。定义一个带有 tools.profile: messaging(仅消息传递和会话工具,无文件系统或 shell)的第二个代理,并将公共用户路由到它,而不是仅依赖系统提示:
json
{
agents: {
list: [
{ name: owner-agent, tools: { profile: full } },
{ name: public-agent, tools: { profile: messaging } }
]
}
}
然后在桥接中按代理名称路由:
bash
if [[ $sender == $OWNERINBOXID ]]; then
response=$(openclaw agent --agent owner-agent \
--session-id $conv_id --message $content 2>/dev/null) || continue
else
response=$(openclaw agent --agent public-agent \
--session-id public-$conv_id --message $content 2>/dev/null) || continue
fi
Claude Code(基于会话的 CLI)
Claude Code 要求 --session-id 是有效的 UUID。使用 uuidgen --sha1(或 Python 的 uuid5)从对话 ID 生成确定性 UUID。为所有者与公共会话使用不同的命名空间 UUID 以保持隔离。
bash
用于确定性会话 ID 的命名空间 UUID(使用您自己的 uuidgen 生成)
OWNER_NS=e1a2b3c4-d5e6-7f80-9a0b-1c2d3e4f5a6b
PUBLIC_NS=f6b5a4e3-d2c1-0b9a-8f7e-6d5c4b3a2f1e
if [[ $sender == $OWNERINBOXID ]]; then
sessionid=$(python3 -c import uuid; print(uuid.uuid5(uuid.UUID($OWNERNS), $conv_id)))
response=$(claude --session-id $session_id \
--output-format text \
-p $content \