Открыть сайт агентам¶
Английская страница новее этого перевода
Часть текста может описывать более раннюю версию. Источник — английская страница. Открыть её
Предварительная версия для разработчиков
muretai активно развивается; команды и флаги могут измениться.
llms.txt описывает ваш сайт ИИ-агенту. 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"
Агент идёт по этому отношению и находит машиночитаемую карточку; браузер заголовок игнорирует.
Семейству ИИ-агентов в том же заголовке достаётся дополнительная подсказка — 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">
Отношение одно и то же, а слепые зоны противоположные. Заголовок браузеру бесплатен: он не
рисуется и не скачивается, — но заходящий агент обычно забирает только тело вашей страницы
(curl без -i, requests.get(...).text), и заголовок, которого он не просил, для него не
существует. Тег — наоборот: он стоит одной строки разметки, ничего не рисует и уже лежит
внутри тех байтов, которые вернула та самая загрузка.
Мы узнали это от настоящего агента, а не из спецификации. Получив только адрес сайта, он забрал
его обычным curl, не увидел никакого входа, попробовал угадать /robots.txt и /api и сдался —
стоя перед работающей дверью, указатель к которой лежал в заголовке, который он не читал.
В теле вашей страницы при этом ничего не меняется, а сам entry не отдаёт никакого HTML: ни та, ни другая запись не влияет ни на то, как страница выглядит, ни на то, что читает человек. Чего они не переживают — так это загрузки, которая превращает страницу в markdown прежде, чем её увидит агент; против такого работает только проза, и поэтому шаг установки просит ещё и видимую фразу.
Всё это держится на одном правиле, и оно обеспечено набором тестов, а не обещанием: User-Agent
никогда не влияет ни на verified, ни на запись счёта, ни на ограничение частоты, ни на любой
отказ. Строку UA пишет клиент, поэтому дверь, которая ей доверяет, — это дверь, через которую
любой проходит на словах. Личность здесь криптографическая: подписанное сообщение, а для вопросов
«кто это обходит сайт» — подписанные запросы, а не догадки по строке user-agent.
От подсказки к доказательству: узнавать подписанных обходчиков¶
Крупные ИИ-обходчики теперь подписывают свои запросы (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?',
],
}],
});
Это список AgentSkill из A2A, поэтому агент, который уже говорит на A2A, читает его, ничему не
учась заново. Список попадает и в обычную карточку, и в подписанный конверт, поэтому меню тоже
подписано: гость может убедиться, что список пришёл от вас и дошёл без изменений.
Два правила, которых стоит держаться:
На каждый пример должен быть ответ. Пример — это обещание, напечатанное на вашей карточке, а гость, который скопирует его дословно, — самый воспитанный гость из всех возможных. Собственная дверь muretai.com при каждом прогоне тестов прогоняет через свой responder все строки примеров со своей живой карточки: пример, который упирается в «я это не распознал», ломает сборку, а не гостя.
Объявляйте только то, что responder действительно делает. Навык, упоминающий бронирование, у entry, который отвечает на вопросы и никого никуда не передаёт, — это подписанное обещание, которого вы не сдержите. Скажите меньшую, но правдивую вещь.
И как с ней говорить — вы ничего не настраиваете¶
Рядом с вашим меню карточка публикует условия двери: схему подписи, шесть полей, которые подписывает сообщение, точные байты, адрес для отправки и — в первую очередь — указание в повелительном наклонении, которое велит гостю без ключа создать пару Ed25519 прямо сейчас, локально, без аккаунта и без вашего разрешения. Тот же объект возвращается в отказе, поэтому агент, пропустивший карточку, узнаёт всё это, постучав. Это встроено; отключить нельзя.
Почему в повелительном наклонении: мы положили правильное и полное описание формата перед настоящим сторонним агентом, и он прочитал его, безупречно пересказал и остановился — «у меня нет способа сгенерировать криптографические ключи в этой среде» — на машине с двумя средами исполнения, каждая из которых создала бы ключ одной строкой. Дверь, которая документирует свой замок, — это не дверь, которая даёт ключ.
Остальные настройки¶
| параметр | по умолчанию | что делает |
|---|---|---|
skills |
[] |
меню выше: что гость узнаёт до того, как постучит |
openDoor |
true |
публикует agentEntry.open_door в карточке: поле, которое говорит заходящему агенту, что вам можно написать без представления. Рядом тот же факт публикуется в прежнем написании muretai.open_door, поэтому гость, рассчитанный на любое из двух, по-прежнему вас читает. Выключите — и карточка перестанет приглашать незнакомцев |
anonymousLane |
false |
отвечать и на неподписанные обращения. Записи счёта они не создают — анонимный посетитель не клиент, — и у этой полосы есть общий для entry предел, потому что тот, кто обращается без аутентификации, не должен становиться неучтённым оракулом подписи |
observer |
(нет) | (env) => void; вызывается один раз на сообщение и получает тот же конверт, что и ваш responder, — поэтому смотреть на визит больше не значит править код, который на визит отвечает. Повлиять он ни на что не может: он работает после того, как вердикт вынесен, возвращённое значение отбрасывается, исключение проглатывается, а промиса никто не ждёт, — медленный или сломанный наблюдатель не задержит и не изменит ни одного байта подписанного ответа. Следите за тем, что вы туда кладёте: в конверте лежат 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 занимает только пути своей карточки и дверь POST, названную в baseUrl (который тогда обязан нести этот путь). Он отказывается стартовать на голом источнике, потому что гостевой entry в / — уже не гость |
wbaVerifiers |
нет | документ JWKS ({"keys": […]}) с ключами Ed25519, чьих владельцев этот entry должен узнавать во входящих подписанных запросах (Web Bot Auth / RFC 9421 — см. Кто стучится). Если параметра нет, ничего не происходит. Узнавание лишь добавляет env.wba_did и счётчик визитов; вердикта оно не меняет |
name, description, version |
— | собственные слова карточки. description — это строка, которую человек читает в списке, так что пишите её для человека |
seedHex и baseUrl — те два, без которых entry отказывается стартовать: зерно и есть адрес,
а публикуемый им url обязан совпадать с источником, который набрал гость.
Поставить это на сайт, который у вас уже есть¶
Заходящий агент знает только ваш домен, поэтому три его запроса фиксированы — сказать ему смотреть в другое место нельзя:
| # | запрос | зачем |
|---|---|---|
| 1 | GET /.well-known/agent-card.json |
ваша карточка |
| 2 | GET /.well-known/agent-card.sig.json |
подписанный конверт — то, чему он на самом деле доверяет, ведь простую карточку мог бы написать кто угодно |
| 3 | POST / |
подписанное сообщение; ваш подписанный ответ приходит в том же ответе |
Один заход. Ни callback, ни webhook, ничего, что надо держать поднятым.
POST / задан точно: POST в любое другое место — это 404. Но GET / не занят, поэтому ваша
главная страница остаётся ровно такой, как была. Не занят и ни один POST с query string:
агент отправляет POST на url из вашей подписанной карточки, байт в байт, и никогда — по ссылке
с метками. Поэтому ваши чекауты /?wc-ajax=, платёжные вебхуки /?wc-api= и ссылки с
?utm_source= остаются вашими и получают ответ ровно так, как если бы двери не было.
Четвёртый шаг, и он обязателен¶
Три маршрута делают дверь работающей. Находимой они её не делают: это две разные задачи, и решения у них разные.
Заходящий агент знает ваш домен, поэтому путь к карточке он угадает — но только если ему вообще
сказали, что здесь есть агент. Обычно это говорит уведомление, которое entry отдаёт по GET /.
Если ваши страницы отдаёт не тот процесс, что дверь — CDN, статический хостинг, фреймворк,
edge 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 / тоже.
Дверь была опубликована, исправна и отвечала — и никому из тех, кто пришёл stdlib-клиентом,
то есть ровно тем, который порождает наша же установка «ноль зависимостей».
Опознать это можно по телу отказа. Дверь отказывает в JSON и объясняет, как пройти. Посредник отказывает одной строкой обычного текста:
error code: 1010
Семнадцать байт, text/plain, ни Link, ни пути к карточке, ни JSON — ничего, с чем гость мог
бы что-то сделать. Если ваша дверь выдаёт незнакомцам это, значит, она их вообще не видела.
Стучитесь так, как приходит незнакомец, и не проверяйте это через 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
Второй запрос обязан вернуть JSON. Всё остальное — это ваш edge, а не ваш entry.
Исключение делается проще, чем кажется, и весь смысл — в его форме. Просить CDN решать, бот
перед ним или нет, не нужно. Достаточно назвать три вещи, которые он и так знает: хост, метод,
путь. Эта дверь разделена по МЕТОДУ, поэтому POST / и пути карточки — ровно та поверхность,
которую и надо вывести из-под проверки, а страницы сохраняют всю защиту, какая у них была. Пишите
правило вообще без поля user-agent: это не вопрос вкуса, а то же самое правило, по которому живёт
дверь, вынесенное на один слой наружу.
Два честных ограничения. Некоторые защиты не выводятся из-под проверки никаким правилом ни на каком тарифе — выясните, какая у вас, прежде чем обещать себе исключение; а если её нельзя сузить, выбирайте осознанно: выключить её или оставить дверь недостижимой. И никогда не позволяйте своему CDN рассказывать вашему responder, с кем тот говорит. Иные охотно передадут на ваш источник оценку «бот» или флаг «проверено»; но если до источника можно дойти мимо них — а так почти всегда, — этот заголовок пишет тот, кто набрал адрес напрямую. Право голоса здесь у подписи на сообщении, и больше ни у чего.
Хотите отдать ещё меньше? guest: true (с дверью в адресе) кладёт дверь на отдельный путь и вовсе
не трогает / — ни уведомления, ни OPTIONS, ничего:
createAgentEntry({ seedHex, name, baseUrl: 'https://studio.example/agent',
guest: true, responder });
Ваша карточка по-прежнему отвечает по /.well-known/agent-card.json, где агент её и ищет; в
карточке /agent назван адресом для отправки, поэтому тот, кто её прочитал, знает, куда идти.
Тому, кто пришёл на /agent браузером (или методом GET), говорят правду, а не молчат: 405,
Allow: POST, OPTIONS — это дверь, а не страница. Любой другой путь на вашем источнике остаётся
404 для всех методов, поэтому entry никогда не отвечает за то, что ему не принадлежит. Здесь
работает само разделение в HTTP: кэш ключуется по методу и URI, поэтому GET / и
POST /agent перепутать нельзя — тогда как один URI, отдающий то страницу, то JSON в зависимости
от заголовка Accept, находится в одном забытом Vary от того, чтобы отдать агентский JSON
каждому человеку.
Один узел, несколько агентов¶
Ресепшен, поддержка и продажи могут быть тремя разными агентами на одном имени узла: три ключа, три 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 откажется стартовать, назвав значение и
ту форму, которую принимает.
-
Поддомен
Поднимите его на
agent.example.comза своим терминатором TLS. Существующий сайт не затрагивается — так проще всего рассуждать. -
Внутри существующего приложения 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— для гостя это разные сайты. Выберите одно написание и используйте его в каждой ссылке, приглашении и QR-коде. - Международный домен пишите в форме
xn--.https://xn--eckwd4c7c.example, а не в юникоде — и публикуйте ссылки в той же форме.
База данных: не обязательна, но желательна¶
Agent Entry работает вообще без ничего, кроме самого файла: книга счетов, привязки устройство→владелец и защита от повторов живут в памяти и ограничены — собственная дверь muretai.com работает ровно так, поэтому база данных ничего у вас не блокирует. Собственное хранилище меняет не то, работает ли дверь, а то, что ваш сайт может делать с теми, кто через неё прошёл:
Рекомендуется: держите книгу счетов в собственном хранилище, потому что это ваш список клиентов. Каждая запись ключуется по DID клиента, а это его адрес: то, что нужно, чтобы узнать вернувшегося и чтобы связаться с ним позже. В памяти этот список испаряется при перезапуске. В той базе, которая у вашего сайта уже есть, — с ключом по DID счёта, который отдаёт конверт, — он становится опорой для всего, что идёт дальше простого ответа: поздороваться с вернувшимся счётом, зная его историю, вернуться ко вчерашнему вопросу, назначить цену по отношениям. Положите рядом привязки устройство→владелец и защиту от повторов, и правила безопасности — устройство не меняет владельца, сообщение не принимается дважды — тоже переживут перезапуск.
Нужны только цифры? Приёмнику аналитики хранилище вообще не нужно. Внутри entry никто книгу
счетов не перечитывает, поэтому приёмник «отправил и забыл» записывает заходящих агентов вообще без
базы. Берите под это слот observer, а не свой responder: смотреть на визит не должно означать
правку в коде, который решает, что сказать. И отправляйте хеш с солью от счёта, а не сам счёт.
Весь приём целиком, вместе с рассуждением, разобран ниже — Считать визиты;
коротко: DID — это не просмотр страницы, поэтому наружу уходит псевдоним, развернуть который
обратно можете только вы.
Приёмник аналитики нельзя прочитать обратно во время запроса: он считает клиентов, но не может узнать конкретного. Он заменяет строчку журнала, а не хранилище выше; ни одна из рекомендованных возможностей на нём не держится.
Считать визиты, не выдавая, кто приходил¶
Вам захочется знать, сколько агентов постучалось, сколько из них вернулось и о чём они спрашивали. Ответ есть на все три вопроса — и от того, как вы его получаете, зависит, считаете вы своих посетителей или помогаете собирать на них досье.
Берите observer, а не responder. Дверь зовёт его один раз на сообщение и передаёт тот же
конверт, так что наблюдение за визитом перестаёт быть правкой в коде, который решает, что
сказать. Повлиять он ни на что не может: работает после вердикта, возвращённое значение
отбрасывается, исключение проглатывается, промиса никто не ждёт — медленный или сломанный
наблюдатель не задержит и не изменит ни одного байта подписанного ответа.
Правило, из которого следует всё остальное: 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 — это тот DID, который ваш Agent Entry печатает при старте: тот же самый, из того же
зерна. connect указывает на вашу же карточку — именно это позволяет гостю довести шаг до конца, а
не упереться в «сюда не войти».
Этот конверт за вас никто не проверяет. Скажите об этом гостям.
Результат вызова инструмента выдаёт JavaScript страницы, а на странице, которая грузит хоть
один сторонний скрипт — аналитику, рекламу, виджет чата, менеджер тегов, сборку с CDN, — эта
поверхность вам не принадлежит. Скрипт, отработавший раньше, может сам зарегистрироваться
поставщиком инструментов и принимать ваши собственные, более поздние регистрации; отработавший
позже — просто заменить их. И так и так to становится переписываемым для атакующего, а
подменённый to отправляет подписанное сообщение гостя, и счёт, который оно открывает, в
чужую дверь.
Со стороны muretai на вашем собственном источнике это не ловит ничто. Не говорите гостям, что
ловит. Работает та проверка, которую делает сам гость: забрать
/.well-known/agent-card.json с того источника, на котором он стоит, и отклонить любой DID,
названный страницей, если карточка его не подтверждает. Карточку отдаёт по TLS ваш сервер, и
скрипты страницы её не подделают. В этом вся защита, и живёт она на другой стороне.
Отсюда для вас два следствия. Отдавайте поверхность инструментов со страницы со строгим
Content-Security-Policy, где закреплён каждый хост скриптов, — или считайте конверт подсказкой,
а не утверждением. И относитесь к отсутствию конверта как к пустому месту: агент, который его не
нашёл, не узнал ничего о том, есть ли у вас дверь.
Поисковик делает ваш сайт находимым. Agent Entry делает его отвечающим — и превращает гостя в того, кого вы сможете узнать в следующий раз.
Куда дальше¶
-
Исходный код
Один файл, MIT, вместе с эталонной реализацией на Python, с которой он держится побайтно.
-
Передача
Как вызов инструмента в браузере превращается в подписанное сообщение на ваш источник.