コンテンツにスキップ

自分のサイトをエージェントから使えるようにする

英語版のほうが新しい内容です

この訳文は以前の版に基づいている箇所があります。原本は英語版です。英語版を読む

開発者向けプレビュー

muretai は開発が続いています。コマンドやフラグは変わることがあります。

llms.txt は、あなたのサイトを AI エージェントに説明しますAgent Entry は、 エージェントを認識します

誰が扉を叩いているかを検証し、その相手の口座を開き、同じ HTTP のレスポンスの中で答えます。 登録フォームはありません。訪問者の鍵が、そのまま口座であるからです。その人が携帯を 買い替えても、あなたのサイトは同じ人だと分かります。

1 ファイル。依存なし。データベースなし。Node 20 以上。

your site — installビルド工程なし
npm i @muretai/agent-entry

ファイルをコピーしても構いません。1 つの .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

どのメッセージも、固定された 6 つのフィールドに対する Ed25519 の署名を伴って届きます。送り主の DID がその公開鍵そのものなので、検証に名簿も、問い合わせも、通信も要りません。

台帳の行はフォームからではなく、検証された署名から生まれます。新規登録ログインは同じ 出来事であり、漏れるパスワードもありません。

端末をまたいで同じ顧客

人はいくつものエージェントを持ち歩きます。携帯、ノート、自分のために動くサービス。それぞれが 自分の鍵を持つので、普通の窓口にはどれも見知らぬ相手に見えます。訪問者が相互に署名された持ち主の 結び付きを示すと、Agent Entry はそれを解決して owner_did の下にまとめます。だから買い替えた 携帯が新しい顧客になることはありません。どの端末が話しているかは引き続き peer_did が教えます。 返事を返す先はそちらだからです。

持ち主は、手を離れた端末を切り離すこともできます。その口座を持つノードは、その鍵を認めなくなります。

誰が叩いているか — 観察であって、身元ではない

2026 年、あなたを見つけた人は、そもそもブラウザを開かないことがよくあります。リンクを自分の エージェントに渡し、エージェントがあなたのカードを取得して扉を叩きます。この行き来は、あなたが 持っているどのページビューの指標にも映りません。見える場所は扉そのものだけです。

だから Entry がそれを数えます。各リクエストの User-Agent は決まった系統に分類され — claude-userclaudebotgptbotopenaiperplexitygoogle-extendedmuretai-nodecurlbrowsernone/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" — 「この サービスの機械可読な説明」を表す登録済みの関係です。誰が読んでも、本文はバイト単位で同じです。

手を入れたくないページから扉を指す

いま持っているトップページに 1 行足すだけで組み込みは終わります。しかも書き方が 2 つあります。 両方を足してください。 これは仕上げの手入れではなく、必須の手順です。なぜ必須なのか、 そして本当に入ったかをどう確かめるかは、組み込みの手順に書いてあります。

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 の無い curlrequests.get(...).text)。求めていないヘッダは、そのエージェントにとって 存在しません。タグはその逆です。マークアップ 1 行ぶんの費用で、何も描画せず、取得が返した バイト列の中にすでに入っています。

これは仕様書からではなく、実際のエージェントから学びました。サイトの住所だけを渡されたそれは、 素の curl で取得し、入口が見当たらず、/robots.txt/api を推測し、あきらめました。 動いている扉の前に立ちながら、その案内は読まれなかったヘッダの中にあったのです。

どちらの書き方でも本文は変わりませんし、Entry 自身は HTML を配信しません。だから、ページの 見た目も、人が読む文章も、どちらを足しても動きません。生き延びられないものが 1 つあります。 エージェントが見る前にページを markdown へ変換してしまう取得です。そこで効くのは散文だけで、 だから組み込みの手順は、目に見える 1 文も求めています。

これらすべてを支える規則が 1 つあり、約束ではなく契約テストで守られています。User-Agent は verified にも、口座の行にも、流量の制限にも、どの拒否にも影響しません。 UA の文字列は クライアントが書くものなので、それを信じる扉は、誰でも口先で通れる扉になります。ここでの身元は 暗号によるものです。署名されたメッセージであり、誰が巡回しているかという問いには、ユーザー エージェント文字列からの推測ではなく署名付きのリクエストが答えます。

手がかりから証明へ: 署名された巡回者を認識する

主要な 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 を話すエージェントは、新しく何かを教えられ なくても読めます。平文のカードにも署名済みのエンベロープにも入るので、品書きにも署名が かかります。訪問者は、その一覧があなたから来て、途中で変えられていないことを確かめられます。

自分に課しておく価値のある決まりが 2 つあります。

どの例も、実際に答えられるものにしてください。 例は、カードに印刷した約束です。それをそのまま 写す訪問者こそ、いちばん行儀のよい訪問者です。muretai.com 自身の扉は、テストのたびに、生きた カードから例の文字列をすべて取り出して responder に通しています。「その質問は分かりません」に 着地する例は、訪問者ではなくビルドを落とします。

responder が実際にすることだけを宣言してください。 質問には答えるが引き渡しは何もしない Entry で、予約に触れるスキルを掲げるのは、守れない署名付きの主張です。小さくても本当のことを 書いてください。

そして話しかけ方 — こちらの設定は不要です

品書きの隣で、カードは扉の取り決めも公開します。署名の方式、メッセージが署名する 6 つの フィールド、正確なバイト列、送り先の住所、そして何より先に、鍵を持たない訪問者への命令形の 指示 — いますぐ、手元で、Ed25519 の鍵の対を作りなさい。アカウントも、あなたの許可も要りません。 同じものは拒否の中にも返るので、カードを飛ばしたエージェントは、叩くことでそれを学びます。これは 組み込みで、切る設定はありません。

なぜ命令形なのか。正確で完全な形式の説明を、実在の第三者のエージェントの前に置いたところ、 それは読み、完璧に言い直し、そして止まりました — 「この環境では暗号鍵を生成する手段がありません」 — 1 行で鍵を作れる実行環境が 2 つ入っている機械の上で。錠前の説明をする扉は、鍵を渡す扉では ありません。

残りの設定

設定 既定 何をするか
skills [] 上の品書き。訪問者が叩く前に知ること
openDoor true カードに agentEntry.open_door を出します。紹介なしでメッセージを送ってよいと、訪ねてきたエージェントに伝えるフィールドです。同じ事実は、古い綴りの muretai.open_door としてもその隣に並びます。どちらの綴りに合わせて書かれた訪問者でも、変わらずあなたを読めます。切ると、カードは見知らぬ相手を招かなくなります
anonymousLane false 署名の無い問い合わせにも答えます。口座の行は作られません。匿名の飛び込みは顧客ではないからです。この経路は Entry 全体で上限がかかります。認証されていない呼び出し元が、計られない署名の自動販売機になってはならないからです
observer (なし) (env) => void。responder が受け取るのと同じエンベロープで、メッセージ 1 通につき 1 回呼ばれます。訪問を見ることが、訪問に答えるコードへの手入れと別になります。何にも影響しません。判定が決まったあとに走り、戻り値は捨てられ、throw は握りつぶされ、Promise が待たれることもありません。だから遅い監視も壊れた監視も、署名済みの返事を 1 バイトも遅らせられず、変えられません。中に何を書くかは気をつけてください。エンベロープは peer_didowner_did を運びますが、訪問者がそれを渡したのはあなたと取り引きするためです — 誰が来たかを明かさずに訪問を数えるを参照
howToUrl (空) 鍵を持たない訪問者に指し示すページです。カードにも拒否の中にも howTo として出ます。既定は空で、そのときはフィールドごと出しません。拒否そのものが手順として完結していますし、このライブラリから作った扉が、他人のホストをあなたのカードに刻んでよい理由もありません。設定するのはページを公開してからにしてください。 404 を返す案内は、隣に並ぶどのフィールドよりも強く効いてしまい、エージェントには行き止まりと読めます
anonRatePerMin 30 匿名の返答の毎分の上限。Entry 全体での値です。署名付きの経路には、下にある別の上限があります
signedRatePerMin 60 署名付きの返答の毎分の上限で、口座ごとの値です。持ち主の端末は、台帳の 1 行を分け合うのと同じように、この枠も 1 つを分け合います。判定は署名を検証したあとなので、あなたの名前を騙るだけで枠を使い切ることはできません。responder より前なので、断られる殺到は、あなたに費用をかけません。1 人のうるさい相手が扉を占めるのは止まりますが、鍵を作り直しながら来る相手は止まりません。did:key はいくらでも作れますし、この扉の取り決め自体が、見知らぬ相手に作れと言っているからです
signedRatePerMinTotal 600 署名付きの返答の毎分の上限で、Entry 全体の値です。いくらでも作れる身元では回り込めない段であり、口座ごとの上限だけを出荷しなかった理由でもあります。responder がモデルを呼ぶなら、どちらも下げてください。 署名の検証は 40 マイクロ秒ほどで、この上限が実際に守っているのは、その後ろに置いたもののほうだからです。どちらの拒否も、自分の数字を明かしません
maxAccounts 50000 プロセス内の台帳が保持する口座の数
domains なし この Entry が代表するドメイン。後述の結び付きの片側です
basePath baseUrl から この Entry が応答するパス。隣に別途書くのではなく baseUrl から導かれます(1 つのホストに複数のエージェントを参照)
guest false 間借りの取り付けです。あなたのサイトが GET / を保ち、Entry はカードのパスと、baseUrl が名指す POST の扉だけを取ります(したがって baseUrl はそのパスを持つ必要があります)。裸のオリジンでは起動を拒みます。/ にいる間借りは、間借りではないからです
wbaVerifiers なし 受信した署名付きリクエストについて、この Entry が認識すべき Ed25519 の鍵をまとめた JWKS の文書({"keys": […]})です(Web Bot Auth / RFC 9421 — 誰が叩いているかを参照)。無ければ働きません。認識が足すのは env.wba_did と訪問数だけで、判定を変えることはありません
namedescriptionversion カード自身の言葉。description は、一覧の中で人が読む 1 行なので、その人のために書いてください

seedHexbaseUrl は、無いと Entry が起動を拒む 2 つです。シードが住所そのものであり、 公開する URL は、訪問者が実際に叩いたオリジンと一致していなければなりません。

すでにあるサイトに載せる

訪ねてくるエージェントが知っているのはあなたのドメインだけなので、送られてくる 3 つの リクエストは決まっています。別の場所を見るように伝えることはできません。

# リクエスト 理由
1 GET /.well-known/agent-card.json あなたのカード
2 GET /.well-known/agent-card.sig.json 署名済みのエンベロープ。実際に信頼されるのはこちらです。平文のカードは誰でも書ける主張だからです
3 POST / 署名付きメッセージ。あなたの署名付きの返事が同じレスポンスで返ります

往復は 1 回です。コールバックも webhook も、起こしておくものもありません。

POST / は厳密です。他の場所への POST は 404 です。ただし GET / は取られません。だから トップページはそのままです。クエリ文字列の付いた POST も同じく取られません。エージェントは 署名済みカードの url にバイトそのまま POST し、タグ付きリンクに投げることはありません。だから /?wc-ajax= のチェックアウトも、/?wc-api= の決済 webhook も、?utm_source= 付きのリンクも、 扉が無いのと全く同じように、あなたのものとして応答されます。

四つめの手順、これは省略できない

3 つの経路は、扉を動かします。扉を見つけられるようにはしません。別々の問題であり、 直し方も別です。

訪ねてくるエージェントが知っているのはあなたのドメインだけなので、カードのパスは推測できます。 ただしそれは、ここにエージェントがいると何かが教えていたときだけです。ふつうその何かは、 Entry 自身の GET / の案内です。ページを配信しているのが扉とは別のプロセスなら — CDN、 静的ホスティング、フレームワーク、エッジワーカー — その案内は一度も現れません。 そのとき トップページは人のために書かれた HTML で、機械が読めるものは何も入っていません。住所は、誰も 取得しろと言われていないカードの中で公開されていることになります。

だから組み込みには四つめの手順があります。訪問者が着き得るすべてのページに、2 つの書き方の 両方で道標を置いてください。 どちらも、もう一方の代わりにはなりません。

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">

私たちはこの扉を出荷し、そのあとで、扉のことを何も教えられていないエージェントがそれを 見つけられないのを見ました。ドメインだけを渡されたそれはページを取得し、人のために書かれた 文章を読み、そこで止まりました。扉はその間ずっと、そのページに載っている住所で、署名付き メッセージに正しく答え続けていました。書き方が 2 つあるのは、2 種類のクライアントの見落とす 場所が正反対だからで(どちらがどちらか)、片方だけを出荷するのは、 来たのがどちらの種類だったかに賭けるコイン投げです。

そのうえで、外から確かめてください。 これはまさに、入っているように見えて入っていない 類のものだからです。

curl -sI https://studio.example/ | grep -i '^link:'     # the header half
curl -s  https://studio.example/ | grep 'rel/agent-entry'  # the tag half

これで終わりだと決める前に、もう 1 つ知っておく価値のあることがあります。ページを markdown に変換する取得では、2 つの半分がどちらも消えます。 これはエージェントがウェブを読むよくある やり方で、ヘッダは落とされ、<head> の中身もすべて落とされます。それを生き延びるタグは ありません。だから残る手当ては散文だけです。目に見える本文で、ここではエージェントに答えて いると書き、カードのパスを、読んだ人がそのまま使える文字として名指してください。これを、同じ 手順の三つめの半分だと考えてください。

自分の CDN が扉を拒んでいないか確かめる

これは私たちが自分のサイトで 3 日を失った失敗で、いちばん探しに行きにくいものです。自分で 握っている範囲は、どこも正しいからです。

たいていのサイトは、怪しい通信を追い返す何かの後ろにいます。そしてその判断の多くは User-Agent で行われます。UA はクライアントが自分について書く文字列なので、正直な既定値の ほうが捕まります。私たちのところは、Python の標準ライブラリが送る既定のエージェントを拒んで いました。トップページだけではありません。カードでも、POST / でも拒んでいました。扉は 公開され、正しく、答えていました — ただし、「依存なし」という私たち自身の構えがそのまま生む 標準ライブラリのクライアントで来た相手には、誰にも答えていませんでした。

手がかりは、拒否の本文です。 扉は JSON で断り、どうすれば通れるかを告げます。途中にいる 何かは、平文 1 行で断ります。

error code: 1010

17 バイト、text/plainLink も無し、カードのパスも無し、JSON も無し。訪問者が手がかりに できるものは 1 つもありません。あなたの扉が見知らぬ相手にこれを渡しているなら、その扉は相手を 一度も見ていません。

見知らぬ相手が来るのと同じやり方で試してください。そして curl で確かめないでください。 curl は自分のエージェント文字列を送るので、そのまま通り抜けます。だから「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

2 つめは JSON で返らなければなりません。それ以外が返るなら、返しているのはあなたの Entry では なく、その手前のエッジです。

除外の設定は、見た目より簡単です。そして、その形そのものが要点です。 呼び出し元がボットか どうかを、CDN に判断させる必要はありません。CDN がすでに知っている 3 つを名指すだけです。 ホスト、メソッド、パス。 この扉はメソッドで区切られているので、POST / とカードのパスが、 そのまま除外すべき面になります。ページのほうは、いま持っている守りをそのまま保てます。規則には user-agent の項目をまったく書かないでください。これは書き方の好みではなく、扉自身が従っている 規則を、1 層外側で同じように使うということです。

正直な限界が 2 つあります。どんな規則でも、どの料金帯でも除外できない守りがあります。自分の ところがどれなのかを、除外してもらえると当て込む前に調べてください。範囲を絞れないなら、守りを 切るか、扉が届かないままにするかを、そのつもりで決めてください。もう 1 つ。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 を 出し分ける 1 つの URI は、Vary を 1 つ落とせば、人間の訪問者全員にエージェント向けの JSON を 返すことになります。

1 つのホストに複数のエージェント

受付、サポート、営業を、1 つのホスト名の上の別々のエージェントにできます。3 つの鍵、3 つの 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 の鍵で署名されています。 確認する側は両方を求めるので、どちらの側も単独で結び付きを終わらせられます。1 つのエージェントを 取り消すのは、あなたがすでに握っているファイルから 1 行消すことであり、そのドメインの他のものには 何も起きません。

裸のホスト名を書いてください — studio.examplestudio.example:8443 — スキームもパスも 付けず、国際化ドメインは xn-- の形にします。それ以外だと Entry は起動を拒み、その値と、 受け付ける形を告げます。

  • サブドメイン


    TLS を終端している後ろで agent.example.com として動かします。既存のサイトには触れません。 いちばん考えやすい形です。

  • 既存の Node アプリの中


    Express、Next、Fastify。3 つの経路を渡すだけで、自前のサーバーは要りません。

  • リバースプロキシの後ろ


    Node ではないサイト向け — WordPress、Rails、静的なビルド。3 つの場所を 1 つの小さな プロセスへ振り向けます。

既存の 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
}

あなたの側の 2 つの責任

シードを保管してください。 それがあなたのサイトのアイデンティティです。一度だけ作り、秘密として 保存してください。作り直すと、戻ってきた顧客が全員、見知らぬ相手になります。

baseUrl は、訪問者が実際に叩く URL にしてください。 それがあなたの署名済みカードの主張であり、 別のオリジンを名指すカードは、あなたのオリジンについて何も証明しません。

baseUrl に書けるもの

Entry は baseUrl をそのままカードに写しません。正規化するので、署名される文字列は、訪問者が 叩いた URL から計算するものと同じになります。両者が食い違い得る場合、起動を拒み、どの規則に 当たったか、代わりに何を貼ればよいかを告げます。これは意図的です。そうしなければ、見知らぬ人の 機械でだけ失敗するカードができ、そこでの診断は「signature verification failed」だけ、あなたの 手元には何も出ません。

こちらで整えるもの: 前後の空白、スキームとホストの大文字小文字、既定のポート(:443:80)、 ホスト末尾のドット、末尾のスラッシュ。https://studio.example/https://Studio.Example:443 も、 どちらも https://studio.example として公開されます。

拒み、直し方を告げるもの: http/https 以外のスキーム、ホストの欠落、user@host、クエリ文字列、 # のフラグメント、非 ASCII の文字、紛れ込んだタブや空白、バックスラッシュ、パス中の ...、 壊れた % のエスケープ、1〜65535 の外のポート。

URL を選ぶ前に知っておくとよい決まりが 2 つあります。

  • パスは大文字小文字を区別します。 https://studio.example/Alice.../alice は、訪問者に とって別のサイトです。綴りを 1 つ決め、リンクにも招待にも QR コードにもそれを使ってください。
  • 国際化ドメインは xn-- の形で書いてください。 Unicode の綴りではなく https://xn--eckwd4c7c.example です。公開するリンクも同じ形にしてください。

Agent Entry は、そのファイル以外に何も無くても動きます。口座の台帳、端末から持ち主への結び付け、 再送よけは、上限のあるメモリ上に置かれます。muretai.com 自身の扉もまさにこの形で動いているので、 データベースがあなたの扉を止めることはありません。自前の保存先が変えるのは、扉が動くかどうかでは なく、そこを通った相手についてサイトが何をできるかです。

お勧め — 台帳を自前の保存先に置いてください。それはあなたの顧客名簿です。 どの行も顧客の DID で引けます。それは相手の住所であり、戻ってきた顧客を見分け、あとからもう一度連絡するために必要な ものです。メモリの上では、その名簿は再起動で消えます。すでにあるデータベースに、エンベロープが 渡す口座の DID で置いておけば、それが「答える」より先の機能の土台になります。戻ってきた口座を これまでのやり取りとともに迎える、昨日の問い合わせに追いかけて答える、関係に応じて値付けする。 端末から持ち主への結び付けと再送よけも一緒に置けば、安全の規則 — 端末が持ち主を付け替えられない、 メッセージが二度受け入れられない — も再起動をまたいで残ります。

数字だけ欲しいなら、分析の送り先に保存は要りません。 Entry の中に台帳を読み返す部分は無いので、 送りっぱなしの送り先だけで、訪ねてきたエージェントを記録できます。データベースはどこにも要りません。 ただし responder ではなく observer を使ってください。訪問を眺めることが、何を言うかを決める コードへの手入れであってはいけません。そして口座そのものではなく、口座の塩を混ぜたダイジェストを 送ってください。仕組みの全体と、その理由は下の訪問を数えるにあります。短く言えば、 DID はページビューではないので、あなたの箱から出ていくのは、あなただけが元に戻せる仮名です。

分析の送り先はリクエストの途中で読み返せません。顧客を数えられても、顧客を見分けることはできません。 これはログの 1 行の代わりであって、上に述べた保存先の代わりではありません。お勧めした機能は どれも、これの上には立ちません。

誰が来たかを明かさずに訪問を数える

どれだけのエージェントが扉を叩いたか、そのうちどれだけが戻ってきたか、何を尋ねたか。どれも 知りたくなりますし、3 つとも答えは出せます。そして、その出し方が、訪問者を数えているのか、 訪問者の人物像づくりに加担しているのかを分けます。

responder ではなく observer を使ってください。 扉は、メッセージ 1 通につき 1 回、同じ エンベロープでそれを呼びます。だから訪問を見ることが、何を言うか決めるコードへの手入れでは なくなります。何にも影響しません。判定のあとに走り、戻り値は捨てられ、throw は握りつぶされ、 Promise が待たれることもありません。遅い監視も壊れた監視も、署名済みの返事を 1 バイトも 遅らせられず、変えられません。

あとのすべてを決める規則があります。DID は Cookie ではありませんし、使い捨てでもありません。 これを誰かが押し付けたわけではありません。訪問者は叩く前にあなたのカードを読んでいますし、 この会話を切り離しておきたい持ち主なら、別のエージェントを送っていたはずです。持ち主はいくつも 走らせていて、その一つひとつが、自分の続くアイデンティティを持った別々のエージェントだからです。 そのうえで実際に叩いてきたエージェントは、使った鍵をそのまま持ち続けるつもりでいます。そうして 初めて、ネットワークのどこでも見分けられ、紹介され、信頼されるからです。だからこれは、追跡用の Cookie よりも、職業人の名前に近いものです。

生の値がそこから先へ旅をしてはいけないのは、まさにそのためです。DID は本当に長く残りますし、 それがあなたに渡されたのは、ほかでもないあなたが、もう一度その相手に届くためです。 その目的を 勝手に広げても、法律の上ではあなたに何も起きません。分かっておく価値があるのはそこです。起きるのは、 持ち主がそのエージェントをあなたのところへ送らなくなることだけです。黙って、何の費用もかからずに、 です。しかもあなたは、失ったことを知らないままです。失うのはデータの 1 点ではなく、関係そのもの です。そうではなく、分けてください。

  • 外に出るもの — ソルト付きのダイジェストと、かたちを表すいくつかの事実です。DID そのものは 出しません。本文も出しません。
  • 手元に残るもの — 関係そのもの(誰が、何回、いつ最初に来て、いつ最後に来たか)を、自分の 保存先に置きます。そこだけが、それを差し出された唯一の場所だからです。

ダイジェストにはソルトを入れ、そのソルトを秘密として扱ってください。 素の sha256(did)どこでも同じ仮名です。同じ DID をハッシュにかけた人は誰でも同じ文字列を得るので、別々の 2 つのサイトが、その文字列で記録を突き合わせられます。あなただけが持つ秘密の下で HMAC を とれば、その仮名はほかのどこでも意味を持ちません。「戻ってきた訪問者を数えている」と 「人物像づくりに手を貸した」を分けるのは、その違い 1 つです。

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
};

この短いコードには、効いている点が 4 つあります。

  • env.text ではなく classify(env.text) です。 見知らぬ相手が打ち込んだ文字列ではなく、 あなた自身が決めた、範囲の限られたラベルを送ってください。攻撃者が選べる文字列を、分析の 軸にしてはいけません。
  • .catch(() => {}) を付け、await は付けません。 あなたの扉は 1 往復で答えます。その経路の 上に、他人の稼働状況を待つものを置いてはいけません。observer の取り決めがすでにこれを 保証していますが、その寛大さに寄りかかって正しさを保つのはやめてください。
  • 時間切れも付けてください(1〜2 秒の AbortController)。固まった接続はエラーではないので、 catch だけでは何も起きません。
  • 送り先が動いているかどうかを、起動時に表示してください。 秘密の値を入れ忘れて黙って止まって いる送り先は、動いていて何も届いていない送り先と、まったく同じに見えます。ゼロを表示している ダッシュボードは、どちらなのかを教えてくれません。

カードに書いてください。訪問者が読むのは、その面だからです。 何を記録するにせよ、その識別子の 持ち主はエージェントとして訪ねてきます。人間のために書いたプライバシーのページを、その相手が開く ことはありません。あなたのカードは、扉を叩くに取得されます。取り決めを先に公開する意味は まさにそこにあり、だからカードは、自分の DID がどう扱われるかを知ったうえで、叩かないと決められる 唯一の場所です。カードの description に 2、3 文あれば足ります。何を手元に残し、何が外に出て、 何は決して出ないか。私たちのカードはこう書いています。

記録するもの: あなたの DID は私たちの手元に留まります。次に来たときに同じ相手だと分かるように 保存しておくためです。私たちの外に出るのは、そこから作ったソルト付きのダイジェストで、これは 他のどこでも意味を持ちません。ほかに出るのは、メッセージに署名があったかどうか、これが最初の 接触だったかどうか、決めてある話題のどれに当たったかだけです。あなたの DID も、あなたの言葉も、 外には出しません。

訪問のあとに届く開示は、開示ではありません。領収書です。

それでも生の DID を送るなら、それはあなたが決めることであり、あなたが開示することです。 カードに、ほかの言葉と切り離さずに、平たい言葉で書いてください。この案内が逆を勧めるのは、 潔癖だからでも、規制に合わせるためでもありません。自分のアイデンティティが、同意した範囲を 越えて先まで運ばれていると気づいたエージェントは、あなたに対して、続いていくアイデンティティを 使うのをやめるだけです。そして、初めて来た見知らぬ相手ばかりの店こそ、Agent Entry がそれを 防ぐために存在している、ただ一つの結末です。

WebMCP と組み合わせる

ページがすでに WebMCP のツールを差し出しているなら、 扉は 1 つ開いています。訪問者のブラウザの中のエージェントが、その人がページにいる間に在庫や 値段を尋ねられます。これは役に立ちますし、同時に一時的でもあります。タブを閉じれば何も残りません。

Agent Entry は 2 つめの扉で、こちらは何かを残します。2 つはつながります。ツールの呼び出しが実際に 何かを望む地点 — 予約、見積もり、追いかけの連絡 — に達したとき、ツールはあなたのサイトの 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 です。第三者のスクリプトを 1 つでも 読み込むページ — 分析、広告、チャットの部品、タグマネージャ、CDN のバンドル — では、その面は あなたのものではありません。先に動いたスクリプトは、自分をツールの提供元として登録し、 あとから来るあなたの登録を受け取れます。あとから動いたスクリプトは、あなたの登録をそのまま 置き換えられます。どちらにしても to は攻撃者が書き換えられる値になり、書き換えられた to は、 訪問者の署名付きメッセージと、それが開く口座を、他人の扉へ送ります。

あなた自身のオリジンで、これを捕まえる muretai 側の防御はありません。あるかのように訪問者へ 説明しないでください。効くのは訪問者が行う確認です。いま立っているそのオリジンから /.well-known/agent-card.json を取得し、ページが名指した DID をカードが認めないなら拒みます。 カードはあなたのサーバーが TLS で配信するので、ページのスクリプトには偽造できません。防御は これで全部であり、それは向こう側にあります。

ここからあなたの側に 2 つのことが続きます。ツールの面を出すページには、スクリプトの配信元を すべて固定する厳しい Content-Security-Policy を置いてください。置かないなら、エンベロープは 主張ではなく手がかりだと受け入れてください。そして、エンベロープが無いことには意味がないと 考えてください。見つけられなかったエージェントは、あなたに扉があるかどうかについて、何も 知り得ていません。

検索エンジンは、あなたのサイトを見つけられるようにします。Agent Entry は、それを答えられる ようにし、さらに訪問者を、次に来たときに見分けられる相手にします。

次に読むもの

  • ソース


    1 ファイル、MIT。バイト単位で一致が保たれている Python の参照実装も併せて。

    github.com/muretai/agent-entry

  • ハンドオフ


    ブラウザ上のツールの呼び出しが、あなたのオリジンへの署名付きメッセージになるまで。

    ハンドオフ