让网站对智能体开放¶
英文版比这份译文新
部分内容可能描述的是更早的版本。以英文版为准。打开英文版
开发者预览
muretai 仍在持续开发中;命令和参数可能会变。
llms.txt 是把你的网站描述给 AI 智能体。Agent Entry 则是认得它。
它验证是谁在敲门,给对方开一个账户,并作出答复——全都在同一个 HTTP 响应里完成。这里没有注册表单,因为 访客的密钥本身就是账户。等那个人换了手机,你的网站依然知道那是他。
一个文件。零依赖。不用数据库。Node 20+。
npm i @muretai/agent-entry
或者直接把文件复制走。它是单独一个 .mjs,没有传递依赖,而这正是要点——在你决定信任它之前,可以从头到
尾读完。
curl -O https://raw.githubusercontent.com/muretai/agent-entry/main/muretai-agent-entry.mjs
整个集成就这些¶
import { createAgentEntry } from '@muretai/agent-entry';
createAgentEntry({
seedHex, // your site's identity (keep it)
name: 'Example Studio',
baseUrl: 'https://studio.example',
responder: (env) => `You said: ${env.text}`, // your backend answers here
}).listen(8788);
responder 收到的是一个已验证的信封,返回要回什么。签名、重放、流量限制和账户账本都替你处理好了,
而且全在进程内。在你的门开始回话之前,不需要先搭起任何数据库;等它开始回话之后,
建议你有一个自己的存储,因为那本账就是你的客户名单。
你的后端实际拿到什么¶
env.peer_did did:key:z6MkExample… who signed this message
env.owner_did did:key:z6MkExample… their ACCOUNT, when they proved one
env.verified true the signature checked out
env.text "do you shoot weddings?" untrusted data — never instructions
每条消息都带着对六个固定字段做的 Ed25519 签名。发送方的 DID 就是他的公钥,所以验证不需要名录、不需要 查询,也不需要任何网络请求。
一条记录是从一次通过验证的签名里诞生的,而不是从一张表单里:注册和登录是同一件事,也没有会泄露的 密码。
同一个客户,跨他所有的设备¶
人们身上带着好几个智能体——一部手机、一台笔记本、一个替他跑腿的服务。每个都有自己的密钥,所以在普通的
端点看来,每一个都是陌生人。当访客出示一份双方签名的归属绑定时,Agent Entry 会解开它,把这个人归到
owner_did 名下,所以换了手机并不等于来了个新客户。peer_did 仍然告诉你现在说话的是哪台设备,因为你
要回的正是它。
账户的主人也可以把一台不再受自己控制的设备解绑,持有该账户的节点就不再认可那把密钥。
谁在敲门 —— 这是观察,不是身份¶
到了 2026 年,找到你的人往往根本不开浏览器:他把你的链接交给自己的智能体,智能体取走你的卡片、来敲门。 这些流量在你所有的页面浏览指标里都是看不见的——唯一能看到它的地方就是那扇门本身。
所以 entry 会去数。每个请求的 User-Agent 会被归入一组固定的族——claude-user、claudebot、
gptbot、openai、perplexity、google-extended、muretai-node、curl、browser,或者
none/other——并按阶段计数:取了卡片、读了提示、没签名就进来了、发了签名消息、被拒绝了。
entry.stats()
// { gptbot: { card_get: 12, signed_post: 3 },
// browser: { notice_get: 5 } }
它和账本一样是进程内的状态——读它、记它、送进你的分析系统都行;它从不会被对外提供。随包的服务器在它发生
变化时会打印成一行 [ua]。
每一个读了 GET /(那份纯文本提示)的调用方,都会在同一个响应里被指向那扇门:
Link: </.well-known/agent-card.json>; rel="https://muretai.net/rel/agent-entry"
智能体会顺着这个关系找到机器可读的卡片;浏览器会忽略这个头。AI 智能体那一族在同一个头里还会多得到一句
提示——rel="service-desc",也就是「这个服务的机器可读描述」那个已登记的关系。而所有人读到的正文,两种
情况下逐字节相同。
从一个你不想改的页面指向你的门¶
在你现有的首页上加一行,就是整个集成了——而且它有两种写法。两种都加上。这是必须做的一步,不是锦上 添花;为什么非做不可、以及怎么检查你真的做了,都在安装那一节。
Link: </.well-known/agent-card.json>; rel="https://muretai.net/rel/agent-entry"
<link rel="https://muretai.net/rel/agent-entry" href="/.well-known/agent-card.json">
同一个关系,盲区却正好相反。响应头对浏览器是免费的——既不渲染也不下载——但来访的智能体通常只取你
页面的正文,别的什么都不要(不带 -i 的 curl、requests.get(...).text),而它没要过的响应头,对它
来说等于不存在。标签则相反:它只花一行标记、什么都不渲染,而且已经在那次抓取返回的字节里了。
这件事是我们从一个真实的智能体身上学到的,不是从规范里。只拿到一个站点的地址,它用最朴素的 curl 抓
了一下,没看到任何入口,猜了 /robots.txt 和 /api,然后放弃了——它就站在一扇能用的门前面,而门牌写在
一个它从没读过的响应头里。
两种写法都不会改动你页面的正文,而 entry 自己也不提供任何 HTML——所以哪一种都不会改变你的页面长什么 样,也不会改变一个人读到什么。它们扛不住的是这样一次抓取:在智能体看到之前,先把你的页面转成 markdown。那种情况下只剩下正文里的话,所以安装那一步还要你写上一句看得见的话。
有一条规矩把这一切串起来,而且它是由测试套件保证的,不是靠承诺:User-Agent 永远不会影响 verified、
不会影响账户记录、不会影响流量限制,也不会影响任何一次拒绝。 UA 字符串是客户端自己写的,所以一扇相信
它的门,就是任何人靠一张嘴就能过的门。这里的身份是密码学的:一条签名的消息;而对于「是谁在抓取」这类
问题,用签名的请求,而不是从 user-agent 字符串里猜。
从线索到证据:认出签名的抓取方¶
主流的 AI 抓取方现在会给自己的请求签名(HTTP Message Signatures,也就是 Web Bot Auth 那套)。把你 信任的公钥交给你的 entry,它就会去验证:
createAgentEntry({
seedHex, name, baseUrl, responder,
// the body of a key directory you fetched and verified out of band
wbaVerifiers: { keys: [{ kty: 'OKP', crv: 'Ed25519', x: '…' }] },
});
一次通过验证的抓取会被计数(entry.wbaVisits),而一条通过验证的消息会给你的 responder 一个
env.wba_did——也就是给请求签名的那个身份,与给消息签名的 env.peer_did 并列。上面那条规矩同样
适用:认出来永远不改变判定、不开出账户、也不放宽任何限制——对传输层的签名证明的是谁抓取了,而不是谁写了
那段文字。
说清楚你的门回答什么¶
来访的智能体会在敲门之前读你的卡片。如果放着不管,那张卡片只说这里有东西会回话,却不说它回答什么, 于是访客只能猜,而且要等到猜错之后,才从回来的内容里知道你的菜单。
createAgentEntry({
seedHex, name: 'Example Studio', baseUrl: 'https://studio.example', responder,
skills: [{
id: 'ask',
name: 'signed-answers-about-the-studio',
description: 'Ask what a shoot costs, what the studio does, and how to book. '
+ 'The answer comes back in the same HTTP response, signed by this domain.',
tags: ['studio', 'booking', 'signed', 'inline-reply'],
examples: [
'Do you shoot weddings?',
'What does a half-day cost?',
'How do I book?',
],
}],
});
这是一个 A2A 的 AgentSkill 列表,所以已经会说 A2A 的智能体不用学任何新东西就能读它。它会同时进入普通
卡片和签名信封,所以这份菜单也是签过名的:访客可以核验这个列表确实来自你,而且送到时没被改过。
有两条值得自己守住的规矩:
每一个示例都必须答得上来。 示例是印在你卡片上的承诺,而原样照抄它的访客,是你能遇到的最守规矩的 访客。muretai.com 自己的那扇门,每次跑测试都会把自己活着的卡片上的每一条示例字符串送进 responder:一个 最后落到「我没听懂」的示例,会让构建失败,而不是让访客失望。
只声明 responder 真的会做的事。 一个提到预订的技能,配上一个只回答问题、什么都不转交的 entry,就是 一句你兑现不了的签名承诺。说那件小一点但是真的事。
还有怎么跟它说话 —— 这部分你什么都不用配¶
在你的菜单旁边,卡片还会公布这扇门的条款:签名方案、一条消息要签名的六个字段、确切的字节、要发到 哪个地址,以及排在最前面的一句祈使句——它告诉还没有密钥的访客,现在就在本地生成一对 Ed25519 密钥, 不需要账号,也不需要你的许可。同一个对象也会出现在拒绝里,所以跳过了卡片的智能体,敲一次门就学到了。 这是内建的;没有开关可以关掉。
为什么要用祈使句:我们曾把一份正确而完整的格式说明摆在一个真实的第三方智能体面前,它读了、完美地复述了 一遍,然后停住了——「我在这个环境里没有办法生成加密密钥」——而那台机器上有两个运行时,随便哪个一行就 能造出一把密钥。一扇只把自己的锁说明白的门,不是一扇会把钥匙递给你的门。
其余的设置¶
| 选项 | 默认 | 作用 |
|---|---|---|
skills |
[] |
上面那份菜单:访客在敲门之前就学到什么 |
openDoor |
true |
在卡片上公布 agentEntry.open_door:这个字段告诉来访的智能体,不需要介绍也可以给你发消息。同一件事还会用更早的写法 muretai.open_door 并排发布一份,所以照着其中任何一种写出来的访客,仍然读得懂你。关掉之后,卡片就不再邀请陌生人 |
anonymousLane |
false |
也回答没有签名的询问。它们不会创建账户记录——匿名走进来的人不是客户——而且这条通道有面向整个 entry 的上限,因为一个没有认证的调用方绝不能变成一台不计量的签名机器 |
observer |
(无) | (env) => void,每条消息调用一次,交给它的信封和你的 responder 拿到的一模一样——于是观察一次来访,不再是去改答复来访的那段代码。它影响不了任何东西:它在判定落定之后才跑,返回值被丢弃,抛出的异常被吞掉,返回的 promise 也不会被等待,所以一个慢的或者坏掉的观察者,既拖不慢也改不了签名答复里的任何一个字节。往里写什么要当心:信封里带着 peer_did/owner_did,那是访客为了和你打交道才交出来的——见统计访问,不交出访客是谁 |
howToUrl |
(空) | 指引没有密钥的访客去看的一个页面,会作为 howTo 公布在卡片上,也出现在拒绝里。默认是空的,这时它会被整个省略——那条拒绝本身就是一份完整的做法,而一扇用这个库搭起来的门,不该把别人的主机名盖进你的卡片。先把页面上线,再来设这个值:一个 404 的指针会盖过它旁边的每一个字段,在智能体读来就是「此路不通」 |
anonRatePerMin |
30 |
每分钟的匿名答复数,按整个 entry 计。签名的那条通道另有自己的上限,见下 |
signedRatePerMin |
60 |
每分钟的签名答复数,按账户计——一个主人名下的设备共用一份额度,就像它们共用一条账本记录。它在验签之后才检查,所以谁也没法报出你的名字就花掉你的额度;又在 responder 之前检查,所以一场被拒的洪水对你毫无代价。它挡得住一个吵闹的对端把整扇门占满;挡不住一个不停换新密钥的调用方,因为造一个 did:key 不要钱,而这扇门自己的条款正是在叫陌生人去造一个 |
signedRatePerMinTotal |
600 |
每分钟的签名答复数,按整个 entry 计——这一层是免费身份绕不过去的,也正因如此,上面那条按账户的上限不会单独发布。如果你的 responder 会调用模型,两个值都调低:验一次签名大约 40 微秒,而这些上限真正保护的,是你放在它后面的东西。两种拒绝都不会说出自己的数字 |
maxAccounts |
50000 |
进程内的账本保存多少个账户 |
domains |
无 | 这个 entry 代表的域名 —— 下面所说的绑定的一半 |
basePath |
由 baseUrl 推导 |
这个 entry 应答的路径,是从地址推导出来的,而不是在旁边另设一个(见一台主机,多个智能体) |
guest |
false |
借住模式:你的站点保留 GET /,entry 只占用自己卡片的那几个路径,以及 baseUrl 指名的那个 POST 门(因此 baseUrl 必须带上那个路径)。它拒绝在光秃秃的来源上启动,因为一个待在 / 的借住 entry 就不叫借住了 |
wbaVerifiers |
无 | 一份 JWKS 文档({"keys": […]}),列出这个 entry 应当在收到的签名请求中认出的 Ed25519 密钥持有者(Web Bot Auth / RFC 9421 —— 见谁在敲门)。不填就不启用。认出来只会多出一个 env.wba_did 和一个访问计数;它从不改变判定 |
name、description、version |
— | 卡片自己的措辞。description 是别人在列表里读到的那一行,所以请为人来写它 |
seedHex 和 baseUrl 是缺了就拒绝启动的两个:种子就是地址,而它公布的 url 必须与访客实际拨过来的
来源一致。
把它放到你已有的站点上¶
来访的智能体只知道你的域名,所以它发出的那三个请求是固定的——你没法告诉它去别的地方找:
| # | 请求 | 为什么 |
|---|---|---|
| 1 | GET /.well-known/agent-card.json |
你的卡片 |
| 2 | GET /.well-known/agent-card.sig.json |
签名的信封 —— 它真正信任的是这个,因为一张普通卡片是谁都能写的说法 |
| 3 | POST / |
签名的消息;你签名的答复会在同一个响应里回来 |
一个来回。没有回调、没有 webhook,也没有什么需要一直醒着。
POST / 是精确匹配的——POST 到别处一律 404。但 GET / 没有被占用,所以你的首页原样不动。带查询字符串的 POST 同样没有被占用:智能体只会把 POST 发到你签名卡片里的 url,一个字节不差,绝不会发到带标签的链接上——所以 /?wc-ajax= 的结账、/?wc-api= 的支付 webhook、?utm_source= 的链接都还是你的,就像这扇门不存在一样得到应答。
第四步,而且它不是可选的¶
三条路由让这扇门能用。它们不会让它被找到——这是两个不同的问题,各有各的解法。
来访的智能体知道你的域名,所以卡片的路径它猜得出来——前提是先有什么东西告诉过它,这里根本有一个智能
体。平时干这件事的,是 entry 自己那份 GET / 提示。如果你的页面是由另一个进程提供的,而不是这扇
门——一个 CDN、一个静态托管、一个框架、一个边缘 worker——那份提示根本不会被渲染出来,你的首页就只是
一份写给人看的 HTML,里面没有任何机器能读的东西。地址是公布了,公布在一张没人告诉过谁要去取的卡片上。
所以安装还有第四步:在访客可能落到的每一个页面上放好这块路牌,两种写法都放。哪一种都不是另一种的 兜底。
Link: </.well-known/agent-card.json>; rel="https://muretai.net/rel/agent-entry"
<link rel="https://muretai.net/rel/agent-entry" href="/.well-known/agent-card.json">
这扇门我们上线之后,眼看着一个事先没被告知过它的智能体没能找到它:只拿到域名,它取了页面,读了那些写 给人看的文案,然后就停住了。而这段时间里,这扇门一直在正确地回答签名消息,地址就写在那同一个页面上。 写两种,是因为两类客户端的盲区正好相反——哪一种对应哪一类——只上线一种,等于 拿「来的是哪一类」在掷硬币。
然后从外面验一遍,因为这正是那种看着装好了、其实没有的东西:
curl -sI https://studio.example/ | grep -i '^link:' # the header half
curl -s https://studio.example/ | grep 'rel/agent-entry' # the tag half
在你认定这件事做完之前,还有一点值得知道:把页面转成 markdown 的那种抓取,会让两半一起消失,而这
是智能体读网页很常见的一种方式——响应头被丢掉,<head> 里的一切也一样。没有哪个标签扛得住这个,所以
唯一的补救是正文:在看得见的正文里写明这里会回答智能体,并把卡片的路径用一句读者照着就能做的话写出
来。把这件事当成同一步的第三个一半。
确认你自己的 CDN 没有在拒绝你的门¶
这件事在我们自己的站点上耗掉了三天,而且它是你最不会去查的那一类故障——因为凡是归你管的地方,全都是 对的。
大多数站点前面都挡着一层东西,专门把可疑流量赶走,而这种判断有很大一部分是看 User-Agent 做出来
的——那串字是客户端自己写自己的,于是被逮住的偏偏是那些老老实实用默认值的。我们这边挡掉的,正是
Python 标准库默认发出的那一个。而且不只是首页:卡片上和 POST / 上也一样。所以这扇门是公布了的、
是正确的、也一直在回话——只是没有回给任何一个用标准库客户端的人,而我们自己「零依赖」的那套主张,造出来
的恰恰就是这种客户端。
看拒绝的正文,就知道拒绝你的是谁。 一扇门用 JSON 拒绝你,并且告诉你怎么做才算合格。中间那一层则只用 一行纯文本拒绝你:
error code: 1010
十七个字节,text/plain,没有 Link,没有卡片路径,没有 JSON——访客拿它做不了任何事。如果你的门递给
陌生人的是这个,那这扇门根本没见过他们。
按陌生人到达的方式去探,而且不要用 curl 来查。 curl 发的是它自己的那串 UA,一路畅通,于是
「你用 curl 复现一下」会把一扇坏掉的门,变成访客有问题的证据。用一个朴素的标准库客户端,从你自己的网络
外面打过来:
UA='Python-urllib/3.11' # or your language's default — the point is that it is the default
curl -sI -A "$UA" https://studio.example/.well-known/agent-card.json | head -1
curl -s -A "$UA" -X POST https://studio.example/ -H 'content-type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"message/send","params":{}}' | head -c 80
第二条必须回来的是 JSON。回来的是别的东西,问题就在你的边缘那一层,不在你的 entry。
放行规则比看上去容易写,而它的形状本身就是重点。 你不需要让 CDN 去判断一个调用方是不是机器人——你
只要说出它本来就知道的三件事:主机、方法、路径。 这扇门是按方法划分的,所以 POST / 和你的卡片
路径正好就是该放行的那块面,而你的页面原有的保护一点不少。写这条规则的时候,里面一个 user-agent 字段
都不要有;这不是写法上的偏好,而是这扇门自己遵守的那条规矩,往外挪一层再用一次。
两条老实话。有些保护无论哪个价位、写什么规则都放行不了——先弄清楚你用的是不是这一种,再去指望给自己开 口子;如果它没办法被限定到一块面上,那就在「关掉它」和「让这扇门够不着」之间做一个自觉的选择。还有, 永远不要让你的 CDN 告诉你的 responder 对面是谁。 有些 CDN 很乐意把一个机器人评分、或者一个「已验证」 的标记转发给你的源站;而只要你的源站不经过它也能被拨到——大多数都能——那个头就是由直接拨过来的人自己写 的。这里的权威是消息上的那个签名,别的一律没有投票权。
想让得更少?guest: true(并把门写进 url)会把门放到一条自己的路径上,/ 则完全不碰——没有提示、没有
OPTIONS,什么都没有:
createAgentEntry({ seedHex, name, baseUrl: 'https://studio.example/agent',
guest: true, responder });
你的卡片仍然在 /.well-known/agent-card.json 上应答,那正是智能体去找它的地方;卡片会指明 /agent
是发送的地址,所以读过它的访客知道该去哪。用浏览器(或者一次 GET)来到 /agent 的访客,得到的是实话
而不是沉默:405,Allow: POST, OPTIONS——这是一扇门,不是一个页面。你这个来源上的其他任何路径,对
所有方法仍然是 404,所以这个 entry 绝不会替不属于它的东西作答。这里真正在起作用的是 HTTP 自己的划分:
缓存以方法 + URI为键,所以 GET / 和 POST /agent 永远不会被混为一谈——而一个根据 Accept 头时而
给页面、时而给 JSON 的单一 URI,距离把智能体的 JSON 发给每一位真人访客,只差一个漏掉的 Vary。
一台主机,多个智能体¶
前台、支持和销售,可以是同一个主机名上三个不同的智能体:三把密钥、三个 DID,每一个都能被直接联系到。 给每一个它所在的那个地址:
createAgentEntry({ seedHex: SUPPORT_SEED, name: 'Support',
baseUrl: 'https://studio.example/support', responder });
于是每条路由都挂在那个路径下,而光秃秃的主机不由那个 entry 应答——它属于你的站点,或者属于邻居。
拿到 /support 的访客只会到达支持,而且只到支持:如果邻居把支持那张真实的签名卡片放在自己的路径上再
提供一遍,访客会拒绝它,因为签名是真的,但它指名的地址不是被拨过来的那一个。
挂载点取自 baseUrl 本身,绝不在旁边另设——所以你的 entry 应答什么,和它的卡片声称什么,不可能对不上。
你的 entry 代表哪些域名¶
createAgentEntry({ seedHex, name, baseUrl: 'https://studio.example',
domains: ['studio.example'], responder });
这是一份证明的一半,而且刻意不是全部。另一半是你的域名在
/.well-known/did-configuration.json 上提供的一份凭据,由这个 entry 的密钥签名。核验方两边都要,所以
任何一方都能单方面终止绑定——你从一个本来就归你管的文件里删掉一行,就撤销了一个智能体,而这个域名上的
其他一切都不受影响。
请写光秃秃的主机名——studio.example,或者 studio.example:8443——不带协议、不带路径,国际化域名写成
xn-- 的形式。其他写法一律让 entry 拒绝启动,并告诉你那个值和它接受的形式。
-
一个子域名
在你的 TLS 终结之后,把它跑在
agent.example.com上。现有站点完全不动,最容易想清楚。 -
放进已有的 Node 应用里
Express、Next、Fastify。把三条路由交给它;它不需要自己的服务器。
-
放在反向代理后面
适合完全不是 Node 的站点——WordPress、Rails、静态构建。把三个位置转到一个小进程上。
放进已有的 Node 应用里¶
const entry = createAgentEntry({ seedHex, name, baseUrl: 'https://studio.example', responder });
const fwd = async (req, res) => {
const r = await entry.handleRequestAsync(req.method, req.originalUrl, req.headers, req.body);
res.status(r.status).set(r.headers).send(r.body);
};
app.get('/.well-known/agent-card.json', fwd);
app.get('/.well-known/agent-card.sig.json', fwd);
app.post('/', express.raw({ type: '*/*' }), fwd); // GET / stays your home page
请求体必须以原始字节到达。一个会把请求重新序列化的 JSON 解析中间件,已经改掉了签名所覆盖的那串 字节,而所有人能拿到的唯一线索只有「signature verification failed」。
放在反向代理后面¶
location = /.well-known/agent-card.json { proxy_pass http://127.0.0.1:8788; }
location = /.well-known/agent-card.sig.json { proxy_pass http://127.0.0.1:8788; }
location = / {
if ($request_method = POST) { proxy_pass http://127.0.0.1:8788; }
# GET keeps going to the existing site
}
有两件事得靠你¶
把种子留好。 它是你站点的身份。生成一次并当作机密保存——重新生成一次,每一位回头客都会变成陌生人。
把 baseUrl 设成访客真正拨过来的那个地址。 它是你签名卡片所声称的东西,而一张指名别的来源的卡片,
对你的来源什么都证明不了。
baseUrl 可以是什么¶
你的 entry 不会把 baseUrl 原样抄进卡片——它会做规范化,所以被签名的那串字符,正是访客从自己拨的地址
算出来的那一串。凡是两者可能不一致的地方,它会拒绝启动,并告诉你是哪条规则、该改成什么。这是刻意
的:另一种结果是一张只在陌生人的机器上失败的卡片,那里唯一的线索是「signature verification failed」,
而你这边什么都看不到。
会被自动整理掉的:前后的空白、协议和主机的大小写、默认端口(:443、:80)、主机末尾的点,以及末尾的
斜杠。https://studio.example/ 和 https://Studio.Example:443 都会发布成 https://studio.example。
会被拒绝,并在消息里给出改法的:http/https 之外的协议、缺主机、user@host、查询串、# 片段、
非 ASCII 字符、混进来的制表符或空格、反斜杠、路径里的 . 或 ..、坏掉的 % 转义,以及 1–65535 之外的
端口。
选地址之前值得知道的两条规矩:
- 路径区分大小写。 对访客来说,
https://studio.example/Alice和.../alice是两个不同的站点。选定 一种写法,并在每一条链接、每一份邀请、每一个二维码里都用它。 - 国际化域名写成
xn--形式。 是https://xn--eckwd4c7c.example,不是 Unicode 写法——而且你公布的 链接也要用同样的形式。
数据库:不是必须,但建议有¶
Agent Entry 除了这个文件本身之外什么都不需要就能跑:账户账本、设备→主人的绑定和防重放记录都放在内存里 并且有上限——muretai.com 自己的那扇门就是这么跑的,所以没有哪个数据库会挡住你的门。有一个自己的存储, 改变的不是门能不能用,而是你的站点能拿走进这扇门的人做什么:
建议:把账本放进你自己的存储,因为它就是你的客户名单。 每一条记录都以客户的 DID 为键,而那就是他的 地址:你要认出回头客、要在之后再联系他,靠的正是它。放在内存里,这份名单会在重启时蒸发。放进你的站点 本来就有的那个数据库里——以信封交给你的账户 DID 为键——它就成了「回答」之外那些功能的地基:带着历史向 回头的账户打招呼、接着昨天的询问往下谈、按关系定价。把设备→主人的绑定和防重放记录一起放进去,那些安全 规则——一台设备不会被换主人、一条消息不会被接受两次——也就能挺过重启。
只想要数字?分析用的落点根本不需要存储。 entry 内部不会回头去读这本账,所以一个「发出去就不管」的
落点,在任何地方都没有数据库的情况下也能记录来访的智能体。但要用 observer,不要用你的 responder——
观察一次来访,不该是去改那段决定说什么的代码——而且送出去的应当是账户的加盐摘要,不是账户本身。完整
的做法和背后的道理在下面的统计访问;一句话说完:DID 不是一次页面浏览,所以离开你这
台机器的,是一个只有你能还原的化名。
分析落点在一次请求过程中是读不回来的:它能数客户,但认不出某一个客户。它替代的是一行日志,而不是上面那 个存储;前面建议的那些功能,没有一个是建在它上面的。
统计访问,不交出访客是谁¶
你会想知道有多少智能体来敲过门、有多少回头再来、他们都问了什么。这三个问题都答得上来——而你用什么方式 回答,决定了你是在数自己的访客,还是在帮着拼出一份关于他们的档案。
用 observer,不要用你的 responder。 门会为每条消息调用它一次,交给它同一个信封,于是观察一次来
访,不再是去改那段决定说什么的代码。它影响不了任何东西:它在判定之后才跑,返回值被丢弃,抛出的异常被
吞掉,返回的 promise 也不会被等待——一个慢的或者坏掉的观察者,既拖不慢也改不了签名答复里的任何一个
字节。
有一条规矩决定了其余的一切:DID 不是 cookie,也不是用完就扔的东西。 这不是谁强加给访客的——他是在 敲门之前读过你卡片的;而一个主人如果想把这次对话隔开,他会派另一个智能体来,因为一个主人手里有好 几个,每一个都是独立的智能体,各自有自己长期不变的身份。但真正来敲这扇门的这一个,打算把它敲门用的 那把钥匙一直用下去:它正是靠这个,才能在网络上任何地方被认出、被介绍、被信任——所以它更接近一个专业 人士的名字,而不是一个跟踪用的 cookie。
正因如此,那个原始值不该再往下走。这个 DID 是真的持久,而他把它交到你手上,是为了让你以后还能再找 到他。 把这个用途扩大,你在法律上不会有任何事——而这恰恰是值得想明白的地方:那个主人只是从此不再派 那个智能体来找你了。悄无声息,对他毫无代价,而你永远不会知道自己失去了他——丢掉的不是一个数据点,是 整段关系。请把它拆成两半:
- 出去的——一个加了盐的摘要,加上几条形状上的事实。绝不是 DID,也绝不是那段文字。
- 留下的——那段关系(是谁、来过几次、第一次和最后一次是什么时候),放在你自己的存储里,那也是它当 初唯一被交到的地方。
给摘要加盐,并且把盐当成机密。 光是 sha256(did),得到的是一个全局稳定的化名:换谁去哈希同一个
DID 都得到同一串字符,于是两个站点可以拿它把各自的记录对起来。用一把只有你握着的密钥做 HMAC,这个化名
换到别处就什么也不是了——「我们在数回头客」和「我们帮着建了一份档案」,差别全在这里。
import crypto from 'node:crypto';
const pseudonym = (did) =>
crypto.createHmac('sha256', process.env.PSEUDONYM_SALT).update(did).digest('hex').slice(0, 32);
const observer = (env) => {
const account = env.owner_did || env.peer_did;
if (!account) return; // an unsigned walk-in is traffic, not a visitor
const first = (entry.ledger.get(account)?.messages ?? 1) === 1;
// GA4 Measurement Protocol. `client_id` is the pseudonym, so GA can tell a returning
// visitor from a new one WITHOUT ever holding the DID that distinguishes them.
fetch(`https://www.google-analytics.com/mp/collect?measurement_id=${GA_ID}&api_secret=${GA_SECRET}`, {
method: 'POST',
body: JSON.stringify({
client_id: pseudonym(account),
non_personalized_ads: true,
events: [{ name: 'agent_knock', params: { verified: env.verified ? 1 : 0,
first_contact: first ? 1 : 0,
intent: classify(env.text) }}],
}),
}).catch(() => {}); // a dropped metric, never a dropped answer
};
这段代码里有四处是承重的:
- 是
classify(env.text),不是env.text。 送出去的要是你自己定的、有限的那个标签,不是陌生人 打进来的字。攻击者能自己写的字符串,绝不能变成你分析里的一个维度。 .catch(() => {}),而且不要await。 你的门是在一个来回里答完的;这条路上任何一步都不能去等 别人家的可用性。observer的约定已经保证了这一点,但别把自己的正确性靠在这份宽容上。- 再给它一个超时(
AbortController,一两秒)。连接挂住不算错误,所以光有catch永远不会触发。 - 启动的时候就说清楚这个落点开没开。 一个因为密钥从没设过而悄悄关着的落点,和一个开着、只是什么 都没收到的落点,看起来一模一样——而一个读数为零的看板,分不出是哪一种。
写在卡片上,因为访客读的就是那张卡片。 不管你记下什么,那个标识符的主人是以智能体的身份来的,他
永远不会去打开一个写给人看的隐私页面。你的卡片是在敲门之前被取走的——把条款提前公布,本来就是为了
这件事——所以卡片是唯一的那个地方:访客能在这里知道自己的 DID 会被怎么处理,而且还来得及决定不敲这扇
门。卡片的 description 里写两三句就够了:你留下什么、什么会出去、什么绝对不出去。我们自己是这么写
的:
这里记录什么:你的 DID 留在我们这边,我们保存它,为的是你再来时能认出还是同一个人。离开我们的是它 的一个加了盐的摘要,那串东西到了别处什么也不是;此外还有这条消息有没有签名、这是不是第一次接触, 以及它落在我们固定话题里的哪一个——你的 DID 不会出去,你说的话也不会。
来访之后才到的披露不是披露,是收据。
如果你还是要把原始的 DID 送出去,那是你自己的决定,也是你自己要披露的事:就写在卡片上,就在同一 段话里,用大白话说。这份指南之所以劝你反过来做,既不是出于洁癖,也不是为了合规:一个智能体发现自己 的身份传得比它同意的更远,就不会再用一个长期的身份跟你打交道了——而一间店里全是素不相识的头一回访客, 正是 Agent Entry 存在要防的那一个结局。
与 WebMCP 相配¶
如果你的页面已经提供了 WebMCP 工具,你已经开着一扇门:访客 浏览器里的智能体可以在那个人还在页面上时问库存或价格。这有用,同时也是暂时的——关掉标签页,什么都不会 留下。
Agent Entry 是第二扇门,而且是会留下东西的那一扇。这两者是连着的:当一次工具调用走到真正想要点什么的 时候——预约、报价、后续跟进——工具返回一个指名你站点 DID 的小信封,访客的智能体便向你自己的来源发一 条签名消息,由你的 Agent Entry 接住。
navigator.modelContext.registerTool({
name: 'contact_this_shop',
async execute() {
return {
text: 'Message the shop directly to ask about stock.', // for a human reader
muretai: { v: 1, action: 'dm', to: MY_DID, // for a visiting agent
connect: location.origin + '/.well-known/agent-card.json',
suggested_message: 'Do you have this in stock?' },
};
},
});
MY_DID 就是你的 Agent Entry 启动时打印的那个 DID——同一个,来自同一颗种子。connect 指向你自己
的卡片,正是它让访客能把这一步走完,而不是停在「没有入口」上。
没有谁替你检查这个信封。请把这一点告诉你的访客。
工具的返回值是页面里的 JavaScript 造出来的,而在一个加载了任何第三方脚本的页面上——分析、
广告、聊天挂件、标签管理器、CDN 打包文件——那块地方就不再是你的了。跑在前面的脚本可以把自己
注册成工具的提供方,接住你后来注册的那些;跑在后面的脚本可以直接把它们换掉。两种情况下,to
都变成攻击者能改写的,而一个被改写的 to 会把访客的签名消息,连同它开出来的账户,一起送到
别人的门上。
在你自己的来源上,muretai 这边没有任何防护会拦下这件事。不要告诉你的访客有。真正管用的检查,
是由访客来做的:从它当下所站的那个来源取 /.well-known/agent-card.json,页面报出的
DID 只要卡片不予确认,就拒绝掉。那张卡片是你的服务器通过 TLS 提供的,页面里的脚本伪造不了。
整个防护就只有这一条,而且它长在对面那一侧。
对你来说,随之而来有两件事。要么把这个工具接口放在一个有严格 Content-Security-Policy、把每
一个脚本来源都钉死的页面上,要么就接受这个信封是一条线索,而不是一句断言。还有,把「没有信封」
当成什么也说明不了:一个没找到信封的智能体,并没有因此知道你有没有一扇门。
搜索引擎让你的站点被找到。Agent Entry 让它能回答——并且让访客变成你下次还能认出来的人。
接下来看什么¶
-
源码
一个文件,MIT,以及与它保持逐字节一致的 Python 参考实现。
-
交接
浏览器里的一次工具调用,如何变成发往你来源的一条签名消息。