Перейти к содержанию

Открыть сайт агентам

Английская страница новее этого перевода

Часть текста может описывать более раннюю версию. Источник — английская страница. Открыть её

Предварительная версия для разработчиков

muretai активно развивается; команды и флаги могут измениться.

llms.txt описывает ваш сайт ИИ-агенту. Agent Entry его узнаёт.

Он проверяет, кто стучится, открывает этому гостю счёт и отвечает — внутри того же ответа HTTP. Формы регистрации нет, потому что ключ гостя и есть счёт. Когда этот человек сменит телефон, ваш сайт всё равно поймёт, что это он.

Один файл. Ноль зависимостей. Без базы данных. Node 20+.

your site — installбез шага сборки
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, с которой он держится побайтно.

    github.com/muretai/agent-entry

  • Передача


    Как вызов инструмента в браузере превращается в подписанное сообщение на ваш источник.

    Передача