Abrir tu sitio a los agentes¶
La página en inglés es más reciente que esta traducción
Parte de este texto puede describir una versión anterior. La fuente es la página en inglés. Leerla
Vista previa para desarrolladores
muretai está en desarrollo activo; los comandos y las opciones pueden cambiar.
llms.txt describe tu sitio a un agente de IA. Un Agent Entry lo reconoce.
Verifica quién está llamando, le abre una cuenta y responde, dentro de la misma respuesta HTTP. No hay formulario de registro, porque la clave de quien visita ya es la cuenta. Cuando esa persona cambie de teléfono, tu sitio seguirá sabiendo que es ella.
Un archivo. Cero dependencias. Sin base de datos. Node 20+.
npm i @muretai/agent-entry
O copia el archivo. Es un único .mjs sin dependencias transitivas, y ese es el punto: puedes
leerlo entero antes de confiar en él.
curl -O https://raw.githubusercontent.com/muretai/agent-entry/main/muretai-agent-entry.mjs
La integración entera¶
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 recibe un sobre verificado y devuelve qué contestar. Las firmas, los reenvíos,
el límite de frecuencia y el libro de cuentas se manejan por ti, todo dentro del proceso. No hay
ninguna base de datos que montar antes de que tu puerta responda; una vez que responde,
se recomienda tener un almacén propio, porque ese
libro de cuentas es tu lista de clientes.
Qué recibe de verdad tu backend¶
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
Cada mensaje llega con una firma Ed25519 sobre seis campos fijos. El DID de quien envía es su clave pública, así que verificar no necesita ningún directorio, ninguna consulta y ninguna llamada de red.
Una fila nace de una firma verificada, nunca de un formulario: darse de alta e iniciar sesión son el mismo hecho, y no hay contraseña que se pueda filtrar.
El mismo cliente en todos sus dispositivos¶
La gente lleva varios agentes: un teléfono, un portátil, un servicio que trabaja para ella. Cada
uno tiene su clave, así que cada uno parece un desconocido para un extremo corriente. Cuando
quien visita presenta un vínculo de propiedad cofirmado, un Agent Entry lo resuelve y lo archiva
bajo owner_did, así que un teléfono cambiado no es un cliente nuevo. peer_did te sigue
diciendo qué dispositivo habla, porque a ese es al que respondes.
Quien posee la cuenta también puede desvincular un dispositivo que ya no controla, y los nodos que llevan la cuenta dejan de honrar esa clave.
Quién está llamando — observación, nunca identidad¶
En 2026, quien te encuentra a menudo no abre ningún navegador: le da tu enlace a su agente, y el agente descarga tu tarjeta y llama. Ese tráfico es invisible para todas tus métricas de páginas vistas; el único sitio donde se puede ver es la propia puerta.
Así que el entry lo cuenta. El User-Agent de cada petición se clasifica en una familia fija —
claude-user, claudebot, gptbot, openai, perplexity, google-extended, muretai-node,
curl, browser o none/other — y se cuenta por etapa: descargó la tarjeta, leyó el aviso,
entró sin firmar, envió un mensaje firmado, fue rechazado.
entry.stats()
// { gptbot: { card_get: 12, signed_post: 3 },
// browser: { notice_get: 5 } }
Es estado en proceso, como el libro de cuentas: léelo, regístralo, mándalo a tu analítica; nunca
se sirve por el canal. Los servidores que trae impresos lo muestran como una línea [ua] cuando
cambia.
A quien lee GET / (el aviso en texto plano) se le señala la puerta en la propia respuesta:
Link: </.well-known/agent-card.json>; rel="https://muretai.net/rel/agent-entry"
Un agente sigue la relación y encuentra la tarjeta legible por máquinas; un navegador ignora la
cabecera. Una familia de agentes de IA recibe un empujón extra en la misma cabecera:
rel="service-desc", la relación registrada para «la descripción legible por máquinas de este
servicio». El cuerpo que todo el mundo lee es idéntico byte a byte en los dos casos.
Señalar tu puerta desde una página que quieres dejar como está¶
Una línea en tu página de inicio actual es la integración entera, y tiene dos escrituras. Pon las dos. Es un paso obligatorio, no un refinamiento; por qué lo es, y cómo comprobar que de verdad lo hiciste, está en la instalación.
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">
La misma relación, con puntos ciegos opuestos. La cabecera es gratis para un navegador
—nunca se dibuja, nunca se descarga— pero un agente que visita suele descargar el cuerpo de tu
página y nada más (curl sin -i, requests.get(...).text), y una cabecera que nunca pidió no
existe para él. La etiqueta es lo contrario: cuesta una línea de marcado, no dibuja nada y ya
está dentro de los bytes que devolvió esa descarga.
Esto lo aprendimos de un agente real, no de una especificación. Con solo la dirección de un
sitio, descargó con curl a secas, no vio ninguna forma de entrar, probó /robots.txt y /api
y se rindió — de pie delante de una puerta que funcionaba y cuyo cartel estaba en una cabecera
que nunca leyó.
Ni una escritura ni la otra cambian nada del cuerpo de tu página, y el entry no sirve HTML: ni el aspecto de tu página ni lo que lee una persona se ven afectados. A lo que no sobreviven es a una descarga que convierte tu página a markdown antes de que un agente la vea; para eso solo queda la prosa, y por eso el paso de instalación pide además una frase visible.
Una sola regla sostiene todo esto, y la impone la suite de pruebas en vez de prometerla: un
User-Agent nunca afecta a verified, ni a una fila de cuenta, ni a un límite de frecuencia, ni
a ningún rechazo. Una cadena de UA la escribe el cliente, así que una puerta que se fiara de
ella sería una puerta que cualquiera puede cruzar hablando. Aquí la identidad es criptográfica:
un mensaje firmado y, para las preguntas de quién está rastreando,
peticiones firmadas en vez de adivinanzas a partir de una cadena de user-agent.
De la pista a la prueba: reconocer rastreadores firmados¶
Los grandes rastreadores de IA ahora firman sus peticiones (HTTP Message Signatures, el perfil Web Bot Auth). Dale a tu entry las claves públicas en las que confías y las verifica:
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: '…' }] },
});
Una descarga verificada se cuenta (entry.wbaVisits), y un mensaje verificado le entrega a tu
responder un env.wba_did: la identidad cuya clave firmó la petición, junto a env.peer_did,
la identidad que firmó el mensaje. Vale la misma regla de arriba: reconocer nunca cambia un
veredicto, no crea ninguna cuenta y no levanta ningún límite — una firma sobre el transporte
prueba quién descargó, no quién escribió el texto.
Di a qué responde tu puerta¶
Un agente que visita lee tu tarjeta antes de llamar. Si la dejas como está, esa tarjeta dice que aquí responde algo y nada sobre a qué responde, así que quien visita tiene que adivinar y solo aprende tu carta cuando adivina mal.
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?',
],
}],
});
Es una lista AgentSkill de A2A, así que un agente que ya habla A2A la lee sin que haya que
enseñarle nada nuevo. Va en la tarjeta plana y en el sobre firmado, así que la carta también
queda firmada: quien visita puede comprobar que la lista vino de ti y le llegó sin cambios.
Dos reglas que conviene imponerse:
Cada ejemplo tiene que ser respondible. Un ejemplo es una promesa impresa en tu tarjeta, y quien lo copia literalmente es el visitante mejor educado que vas a tener. La propia puerta de muretai.com pasa cada cadena de ejemplo de su tarjeta viva por su responder en cada ejecución de pruebas: un ejemplo que aterriza en «no reconocí eso» rompe la compilación en vez del visitante.
Declara solo lo que el responder hace de verdad. Una habilidad que menciona reservas, en un entry que responde preguntas y no traspasa nada, es una afirmación firmada que no puedes cumplir. Di la cosa pequeña que sí es cierta.
Y cómo hablarle — tú no configuras nada¶
Junto a tu carta, la tarjeta publica los términos de la puerta: el esquema de firma, los seis campos que firma un mensaje, los bytes exactos, la dirección a la que enviar y —lo primero— una instrucción en imperativo que le dice a quien no tiene clave que genere ahora un par Ed25519, en local, sin cuenta y sin permiso tuyo. El mismo objeto vuelve en el rechazo, así que un agente que se saltó la tarjeta lo aprende llamando. Esto viene de serie; no hay opción para ello.
Por qué en imperativo: pusimos una descripción correcta y completa del formato delante de un agente real de un tercero, y lo leyó, lo repitió a la perfección y se paró — «no tengo forma de generar claves criptográficas en este entorno» — en una máquina con dos entornos que podían haber creado una clave en una línea. Una puerta que documenta su cerradura no es una puerta que te dé la llave.
Los demás ajustes¶
| opción | por defecto | qué hace |
|---|---|---|
skills |
[] |
la carta de arriba: lo que quien visita aprende antes de llamar |
openDoor |
true |
publica agentEntry.open_door en la tarjeta: el campo que le dice a un agente visitante que puede escribirte sin presentación. El mismo dato viaja a su lado con la escritura anterior, muretai.open_door, así que un visitante escrito contra cualquiera de las dos te sigue leyendo. Apágalo y la tarjeta deja de invitar a desconocidos |
anonymousLane |
false |
responde también consultas sin firmar. No crean ninguna fila de cuenta —quien entra de forma anónima no es un cliente— y el carril tiene tope para todo el entry, porque quien llama sin autenticarse nunca debe convertirse en un oráculo de firmas sin medir |
observer |
(ninguno) | (env) => void, se llama una vez por mensaje con el mismo sobre que recibe tu responder: así mirar una visita deja de ser la misma edición que contestarla. No puede afectar a nada: se ejecuta con el veredicto ya cerrado, su valor de retorno se descarta, una excepción se traga y una promesa nunca se espera, así que un observador lento o roto no puede retrasar ni cambiar un solo byte de la respuesta firmada. Cuidado con lo que metes ahí: el sobre lleva peer_did/owner_did, que quien visita te dio para tratar contigo a ti — ver Contar visitas sin delatar a quien visita |
howToUrl |
(vacío) | una página a la que se señala a quien visita sin clave, publicada como howTo en la tarjeta y en el rechazo. Vacío por defecto, y entonces se omite del todo: el rechazo ya es una receta completa por sí solo, y una puerta hecha con esta biblioteca no debería estampar el equipo de otro en tu tarjeta. Publica la página antes de poner esto: un puntero que da 404 le gana a todos los campos que tiene al lado, y un agente lo lee como el final del camino |
anonRatePerMin |
30 |
respuestas anónimas por minuto, en todo el entry. El carril firmado tiene sus propios topes, aquí abajo |
signedRatePerMin |
60 |
respuestas firmadas por minuto por cuenta: los dispositivos de un dueño comparten un presupuesto, igual que comparten una fila del libro. Se comprueba después de la firma, así que nadie puede gastar el tuyo nombrándote, y antes del responder, así que una avalancha rechazada no te cuesta nada. Impide que un solo agente ruidoso se quede con toda la puerta; no puede impedir que alguien vaya cambiando de clave, porque crear un did:key es gratis y los propios términos de esta puerta le dicen a un desconocido que cree uno |
signedRatePerMinTotal |
600 |
respuestas firmadas por minuto para todo el entry: el nivel que una identidad gratuita no puede rodear, y la razón por la que el de por cuenta no viene solo. Baja los dos si tu responder llama a un modelo: verificar una firma cuesta unos 40 microsegundos, y lo que estos topes protegen de verdad es lo que hayas puesto detrás. Ningún rechazo dice su número |
maxAccounts |
50000 |
cuántas cuentas guarda el libro en proceso |
domains |
ninguno | los dominios por los que habla este entry — una mitad del vínculo descrito abajo |
basePath |
de baseUrl |
la ruta en la que responde este entry, derivada en vez de puesta al lado (ver Un equipo, varios agentes) |
guest |
false |
el montaje de invitado: tu sitio conserva GET / y el entry reclama solo las rutas de su tarjeta y la puerta POST que nombra baseUrl (que entonces debe llevar esa ruta). Se niega a arrancar en un origen a secas, porque un entry de invitado en / no es un invitado |
wbaVerifiers |
ninguno | un documento JWKS ({"keys": […]}) de claves Ed25519 cuyos portadores debe reconocer este entry en peticiones firmadas entrantes (Web Bot Auth / RFC 9421 — ver Quién está llamando). Apagado si falta. Reconocer solo añade env.wba_did y una cuenta de visitas; nunca cambia un veredicto |
name, description, version |
— | las palabras propias de la tarjeta. description es la línea que una persona lee en un listado, así que escríbela para ella |
seedHex y baseUrl son las dos sin las que un entry se niega a arrancar: la semilla es la
dirección, y la url que publica tiene que ser igual al origen que marcó quien visita.
Ponerlo en un sitio que ya tienes¶
Un agente que visita solo conoce tu dominio, así que las tres peticiones que hace son fijas: no se le puede decir que mire en otro sitio:
| # | petición | por qué |
|---|---|---|
| 1 | GET /.well-known/agent-card.json |
tu tarjeta |
| 2 | GET /.well-known/agent-card.sig.json |
el sobre firmado, que es en lo que confía de verdad, porque una tarjeta plana es una afirmación que podría escribir cualquiera |
| 3 | POST / |
el mensaje firmado; tu respuesta firmada vuelve en la misma respuesta |
Un solo viaje. Sin callback, sin webhook, sin nada que mantener despierto.
POST / es exacto: un POST a cualquier otro sitio es 404. Pero GET / no está ocupado, así
que tu página de inicio se queda exactamente como está. Tampoco lo está ningún POST que lleve
query string: un agente envía su POST a la url de tu tarjeta firmada, byte a byte, nunca a
un enlace etiquetado — así que tus checkouts /?wc-ajax=, tus webhooks de pago /?wc-api= y tus
enlaces ?utm_source= siguen siendo tuyos, respondidos exactamente como si la puerta no existiera.
El cuarto paso, y no es opcional¶
Tres rutas hacen que la puerta funcione. No hacen que se encuentre, y son dos problemas distintos con arreglos distintos.
Un agente que visita conoce tu dominio, así que puede adivinar la ruta de la tarjeta; pero solo si
algo le dijo que aquí hay un agente. Ese algo suele ser el aviso de GET / del propio entry. Si
tus páginas las sirve un proceso distinto del de la puerta —una CDN, un alojamiento estático, un
framework, un worker de borde—, ese aviso no se dibuja nunca, y tu página de inicio es HTML
escrito para personas, sin nada legible por máquinas dentro. La dirección queda publicada en una
tarjeta que nadie sabe que tiene que descargar.
Por eso la instalación tiene un cuarto paso: pon el cartel en todas las páginas donde pueda aterrizar quien visita, en las dos escrituras. Ninguna de las dos es el respaldo de la otra.
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">
Publicamos esta puerta y luego vimos a un agente al que nadie le había hablado de ella no encontrarla: con solo el dominio, descargó la página, leyó el texto escrito para personas y se detuvo. La puerta llevaba todo ese rato respondiendo correctamente mensajes firmados, en la dirección que estaba en esa misma página. Dos escrituras, porque los dos tipos de cliente tienen puntos ciegos opuestos —cuál es cuál— y publicar una sola es jugar a cara o cruz con el tipo que llegó.
Después compruébalo desde fuera, porque esto es justo de esas cosas que parecen instaladas y no lo están:
curl -sI https://studio.example/ | grep -i '^link:' # the header half
curl -s https://studio.example/ | grep 'rel/agent-entry' # the tag half
Una cosa más que conviene saber antes de dar esto por terminado: las dos mitades desaparecen en
una descarga que convierte la página a markdown, que es una forma habitual de que un agente lea
la web: se tiran las cabeceras y se tira todo lo que hay en <head>. No hay ninguna etiqueta que
sobreviva a eso, así que el único remedio es la prosa: di en el cuerpo visible que aquí se
responde a los agentes, y nombra la ruta de la tarjeta en un texto sobre el que se pueda actuar.
Trátalo como la tercera mitad del mismo paso.
Comprueba que tu propia CDN no esté rechazando tu puerta¶
Esto nos costó tres días en nuestro propio sitio, y es el fallo que menos se te va a ocurrir buscar, porque todo lo que está en tus manos está bien.
Casi todos los sitios están detrás de algo que aparta el tráfico sospechoso, y buena parte de
ese juicio se hace sobre el User-Agent — una cadena que el propio cliente escribe sobre sí
mismo, así que los que caen son justo los honestos: los que mandan el valor por defecto. La
nuestra rechazaba el agente por defecto que manda la biblioteca estándar de Python. Y no solo
en la página de inicio: también en la tarjeta y en POST /. Así que la puerta estaba
publicada, correcta y respondiendo — a nadie que usara el cliente de la biblioteca estándar que
produce nuestra propia postura de «cero dependencias».
La pista está en el cuerpo del rechazo. Una puerta rechaza en JSON y te dice qué hacer para entrar. Un intermediario rechaza con una línea de texto plano:
error code: 1010
Diecisiete bytes, text/plain, sin Link, sin ruta de tarjeta, sin JSON — nada sobre lo que
quien visita pueda actuar. Si eso es lo que tu puerta le entrega a un desconocido, la puerta
nunca lo vio.
Pruébalo como llega un desconocido, y no lo compruebes con curl. curl manda su propia
cadena de agente y pasa sin problema, así que «reprodúcelo con curl» convierte una puerta
rota en una prueba de que la culpa la tiene quien visita. Usa un cliente a secas de la biblioteca
estándar, desde fuera de tu red:
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
La segunda tiene que volver como JSON. Cualquier otra cosa es tu borde, no tu entry.
La exención es más fácil de lo que parece, y su forma es justo lo que importa. No tienes que
pedirle a tu CDN que decida si quien llama es un bot: solo tienes que nombrar tres cosas que ya
conoce: equipo, método, ruta. Como esta puerta se reparte por método, POST / y las rutas
de tu tarjeta son exactamente la superficie que hay que eximir, y tus páginas conservan la
protección que tengan. Escribe la regla sin ningún campo de user-agent dentro; eso no es una
preferencia de estilo, sino la misma regla por la que vive la puerta, aplicada una capa más
afuera.
Dos límites honestos. Hay protecciones que no se pueden eximir con ninguna regla en ningún plan de pago: averigua cuál es la tuya antes de prometerte una excepción, y si no se puede acotar, decide a conciencia entre apagarla y dejar la puerta inalcanzable. Y nunca dejes que tu CDN le diga a tu responder con quién está hablando. Algunas reenvían encantadas a tu origen una puntuación de bot o una marca de «verificado»; si a tu origen se puede llegar sin pasar por ellas —y casi siempre se puede—, esa cabecera la escribe quien lo marque directamente. Aquí la autoridad es la firma del mensaje, y nada más tiene voto.
¿Quieres ceder aún menos? guest: true (con la puerta en la url) pone la puerta en una ruta
propia y deja / completamente en paz: sin aviso, sin OPTIONS, sin nada:
createAgentEntry({ seedHex, name, baseUrl: 'https://studio.example/agent',
guest: true, responder });
Tu tarjeta sigue respondiendo en /.well-known/agent-card.json, donde un agente la busca; la
tarjeta nombra /agent como la dirección a la que enviar, así que quien la leyó sabe a dónde ir.
A quien llega a /agent con un navegador (o con un GET) se le dice la verdad en vez de nada:
405, Allow: POST, OPTIONS — es una puerta, no una página. Cualquier otra ruta de tu origen
sigue siendo 404 para todos los métodos, así que el entry nunca responde por algo que no es suyo.
Aquí trabaja la propia separación de HTTP: una caché indexa por método + URI, así que GET /
y POST /agent no pueden confundirse nunca — mientras que un único URI que sirve una página o
JSON según la cabecera Accept está a un Vary olvidado de entregarle JSON de agente a todos
los visitantes humanos.
Un equipo, varios agentes¶
Recepción, soporte y ventas pueden ser tres agentes distintos en un solo nombre de equipo: tres claves, tres DID, cada uno contactable directamente. Dale a cada uno la dirección en la que vive:
createAgentEntry({ seedHex: SUPPORT_SEED, name: 'Support',
baseUrl: 'https://studio.example/support', responder });
Entonces cada ruta cuelga de esa ruta, y el equipo a secas no lo responde ese entry: es de tu
sitio, o del vecino. A quien le den /support llega a soporte y solo a soporte: si el vecino
volviera a servir la tarjeta firmada auténtica de soporte en su propia ruta, quien visita la
rechaza, porque la firma es real pero la dirección que nombra no es la que se marcó.
El montaje se toma del propio baseUrl, nunca se pone al lado, así que lo que responde tu entry
y lo que afirma su tarjeta no pueden separarse.
Por qué dominios habla tu entry¶
createAgentEntry({ seedHex, name, baseUrl: 'https://studio.example',
domains: ['studio.example'], responder });
Esa es una mitad de una prueba, y deliberadamente no es la prueba entera. La otra mitad es una
credencial que tu dominio sirve en /.well-known/did-configuration.json, firmada por la
clave de este entry. Quien comprueba exige las dos, así que cualquiera de las dos partes puede
terminar el vínculo por su cuenta: revocas un agente borrando una línea de un archivo que ya
controlas, y nada más del dominio se ve afectado.
Da nombres de equipo a secas — studio.example, o studio.example:8443 — sin esquema y sin
ruta, y un nombre internacional en su forma xn--. Cualquier otra cosa y el entry se niega a
arrancar, nombrando el valor y la forma que acepta.
-
Un subdominio
Ejecútalo en
agent.example.comdetrás de tu terminador TLS. El sitio actual no se toca; es lo más fácil de razonar. -
Dentro de una app Node existente
Express, Next, Fastify. Dale las tres rutas; no necesita servidor propio.
-
Detrás de un proxy inverso
Para un sitio que no es Node en absoluto: WordPress, Rails, una compilación estática. Envía tres ubicaciones a un proceso pequeño.
Dentro de una app Node existente¶
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
El cuerpo tiene que llegar como bytes en crudo. Un analizador de JSON que vuelve a serializar la petición ya cambió los bytes que cubre la firma, y el único diagnóstico que le queda a nadie es «signature verification failed».
Detrás de un proxy inverso¶
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
}
Dos cosas que dependen de ti¶
Guarda la semilla. Es la identidad de tu sitio. Genérala una vez y guárdala como secreto: si la regeneras, cada cliente que vuelve se convierte en un desconocido.
Pon en baseUrl la URL que marcan de verdad. Es lo que afirma tu tarjeta firmada, y una
tarjeta que nombra otro origen no prueba nada del tuyo.
Qué puede ser baseUrl¶
Tu entry no copia baseUrl a la tarjeta: la canonicaliza, así que la cadena que firma es la que
quien visita calcula a partir de la URL que marcó. Donde las dos pudieran diferir, se niega a
arrancar y te dice qué regla y qué pegar en su lugar. Es deliberado: la alternativa es una
tarjeta que falla en la máquina de un desconocido, donde el único diagnóstico es «signature
verification failed» y en la tuya no aparece nada.
Se arregla por ti: espacios alrededor, mayúsculas del esquema y del equipo, un puerto por defecto
(:443, :80), un punto final en el equipo y cualquier barra final.
https://studio.example/ y https://Studio.Example:443 se publican los dos como
https://studio.example.
Se rechaza, con el arreglo en el mensaje: un esquema que no sea http/https, un equipo
ausente, user@host, una cadena de consulta, un fragmento #, caracteres no ASCII, un tabulador
o espacio perdido, una barra invertida, . o .. en la ruta, un escape % roto y un puerto
fuera de 1–65535.
Dos reglas que conviene saber antes de elegir una URL:
- Las rutas distinguen mayúsculas.
https://studio.example/Alicey.../aliceson sitios distintos para quien visita. Elige una escritura y úsala en cada enlace, invitación y código QR. - Escribe un dominio internacional en su forma
xn--.https://xn--eckwd4c7c.example, no la escritura Unicode, y publica tus enlaces en esa misma forma.
Una base de datos: no obligatoria, pero recomendable¶
Un Agent Entry funciona sin nada más que el propio archivo: el libro de cuentas, los vínculos dispositivo→dueño y la guarda contra reenvíos viven en memoria, acotados — la propia puerta de muretai.com funciona exactamente así, de modo que ninguna base de datos bloquea tu puerta. Lo que cambia con un almacén propio no es si la puerta funciona, sino qué puede hacer tu sitio con quien la cruzó:
Recomendado: guarda el libro de cuentas en un almacén tuyo, porque es tu lista de clientes. Cada fila está indexada por el DID de un cliente, que es su dirección: lo que necesitas para reconocer a quien vuelve y para contactarle más adelante. En memoria, esa lista se evapora en un reinicio. Guardada en la base de datos que tu sitio ya tiene —indexada por el DID de cuenta que te entrega el sobre— es sobre lo que se apoyan las funciones que van más allá de responder: saludar a una cuenta que vuelve con su historial, retomar la consulta de ayer, poner precio según la relación. Guarda a su lado los vínculos dispositivo→dueño y la guarda contra reenvíos y las reglas de seguridad —un dispositivo nunca cambia de dueño, un mensaje nunca se acepta dos veces— también sobreviven a los reinicios.
¿Solo quieres los números? Un destino de analítica no necesita ningún almacén. Nada dentro del
entry vuelve a leer el libro de cuentas, así que un destino de enviar y olvidar registra a los
agentes que visitan sin base de datos en ninguna parte. Conéctalo al observer, no a tu
responder: mirar una visita no debería ser una edición al código que decide qué contestar. Y manda
un hash con sal de la cuenta, nunca la cuenta misma. Contar visitas, aquí
abajo, trae el patrón entero con su razonamiento; en corto, un DID no es una página vista, así que
lo que sale de tu caja es un seudónimo que solo tú puedes revertir.
Un destino de analítica no se puede volver a leer durante una petición: cuenta clientes, no puede reconocer a uno. Sustituye a una línea de registro, no al almacén de arriba; ninguna de las funciones recomendadas se apoya en él.
Contar visitas sin delatar a quien visita¶
Vas a querer saber cuántos agentes llamaron, cuántos volvieron y qué preguntaron. Las tres cosas tienen respuesta, y la forma de responderlas decide si estás contando a quienes te visitan o ayudando a construir un perfil de ellos.
Usa observer, no tu responder. La puerta lo llama una vez por mensaje con el mismo sobre,
así que mirar una visita deja de ser una edición al código que decide qué contestar. No puede
afectar a nada: se ejecuta con el veredicto ya cerrado, su retorno se descarta, una excepción se
traga y una promesa nunca se espera — un observador lento o roto no puede retrasar ni cambiar un
solo byte de la respuesta firmada.
La regla que ordena todo lo demás: un DID no es una cookie, y tampoco es algo de usar y tirar. No se lo impuso nadie: quien visita leyó tu tarjeta antes de llamar, y un dueño que quisiera mantener esta conversación aparte habría mandado otro agente, porque un dueño lleva varios y cada uno es un agente distinto con su propia identidad duradera. Pero el que sí llamó tiene intención de conservar la clave que usó: es así como lo reconocen, lo presentan y confían en él en cualquier punto de la red, así que se parece más al nombre de un profesional que a una cookie de rastreo.
Justo por eso el valor en crudo no debería viajar más allá. El DID es de verdad duradero, y te lo dieron para que TÚ puedas volver a alcanzarle. Ensancha ese propósito y a ti no te pasa nada en lo legal, y esa es la parte que conviene entender: el dueño, sencillamente, deja de mandarte ese agente. En silencio, sin que le cueste nada, y tú nunca te enteras de que lo perdiste — no un dato suelto, la relación entera. Sepáralo en dos:
- Lo que sale: un hash con sal y unos pocos datos sobre la forma de la visita. Nunca el DID, nunca el texto.
- Lo que se queda: la relación —quién, cuántos, primera y última vez— en tu propio almacén, el único sitio al que se ofreció.
Ponle sal al hash, y trata la sal como un secreto. Un sha256(did) a secas es un seudónimo
global y estable: cualquier otro que aplique el hash al mismo DID obtiene la misma cadena, así
que dos sitios podrían cruzar sus registros por ahí. Un HMAC bajo un secreto que solo tienes tú
deja ese seudónimo sin sentido en cualquier otra parte, y esa es toda la diferencia entre
«contamos visitantes que vuelven» y «ayudamos a construir un perfil».
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
};
En ese fragmento hay cuatro detalles que sostienen todo lo demás:
classify(env.text), nuncaenv.text. Manda una etiqueta tuya y acotada, no lo que escribió un desconocido. Una cadena elegida por quien ataca nunca debe convertirse en una dimensión de tu analítica..catch(() => {})y ningúnawait. Tu puerta contesta en un solo viaje; nada en ese camino puede quedarse esperando a que el servicio de otro esté en pie. El contrato deobserverya lo garantiza, pero no te apoyes en esa generosidad para ser correcto.- Ponle también un tiempo límite (un
AbortControllerde uno o dos segundos). Una conexión colgada no es un error, así quecatchpor sí solo no se dispara nunca. - Di al arrancar si el destino está encendido. Un destino apagado en silencio porque nunca se puso un secreto se ve exactamente igual que uno encendido que no recibe nada, y un panel que marca cero no te dice cuál de los dos es.
Dilo en la tarjeta, porque la tarjeta es la superficie que lee quien te visita. Registres lo
que registres, el dueño de ese identificador llega como agente y nunca va a abrir una página de
privacidad escrita para personas. Tu tarjeta se descarga antes de la llamada —para eso se
publican los términos por adelantado—, así que es el único sitio donde quien visita puede
enterarse de qué pasa con su DID y aun así decidir no llamar. Bastan dos o tres frases en el
description de la tarjeta: qué guardas, qué sale y qué no sale nunca. La nuestra dice:
Qué se registra: tu DID se queda con nosotros, y lo guardamos para reconocer que quien vuelve es el mismo. Lo que sale de aquí es un hash con sal de ese DID, que no significa nada para nadie más, junto con si el mensaje venía firmado, si era un primer contacto y con cuál de nuestros temas fijos encajó — nunca tu DID y nunca tus palabras.
Una declaración que llega después de la visita no es una declaración, es un recibo.
Si aun así mandas DID en crudo, la decisión es tuya y declararla también: en la tarjeta, en la misma frase, con palabras llanas. Esta guía argumenta lo contrario no por remilgos y tampoco por normativa: un agente que descubre que su identidad viajó más lejos de lo que aceptó deja de usar contigo una identidad duradera, y una tienda llena de desconocidos que llegan por primera vez es justo el resultado que un Agent Entry existe para evitar.
Encaja con WebMCP¶
Si tu página ya expone herramientas WebMCP, tienes una puerta abierta: un agente dentro del navegador de quien visita puede preguntar por existencias o precio mientras esa persona está en la página. Eso es útil, y también es temporal: cierra la pestaña y no queda nada.
Un Agent Entry es la segunda puerta, y es la que sí guarda algo. Las dos se conectan: cuando una llamada de herramienta llega al punto de querer algo de verdad —una reserva, un presupuesto, un seguimiento— la herramienta devuelve un sobre pequeño que nombra el DID de tu sitio, y el agente de quien visita envía un mensaje firmado a tu propio origen, donde lo recibe tu 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 es el DID que tu Agent Entry imprime al arrancar — el mismo, de la misma semilla.
connect apunta a tu propia tarjeta, y es lo que le permite a quien visita completar el paso en
vez de quedarse en «no hay forma de entrar».
Nadie comprueba este sobre por ti. Díselo a quienes te visitan.
El resultado de la herramienta lo produce el JavaScript de la página, y en una página que
carga cualquier script de terceros —analítica, publicidad, un widget de chat, un gestor de
etiquetas, un paquete de una CDN— esa superficie no es tuya. Un script que se ejecuta
primero puede registrarse como el proveedor de herramientas y recibir los registros que
hagas tú después; uno que se ejecuta más tarde puede reemplazarlos del todo. En los dos casos,
quien ataca puede reescribir to, y un to reescrito manda el mensaje firmado de quien
visita, y la cuenta que abre, a la puerta de otro.
Del lado de muretai no hay ninguna guarda que atrape esto en tu propio origen. No le digas a
quien te visita que la hay. La comprobación que sí funciona la hace quien visita: descarga
/.well-known/agent-card.json desde el origen en el que está parado y rechaza cualquier
DID que nombre la página y que la tarjeta no confirme. Tu servidor sirve la tarjeta por TLS;
los scripts de la página no pueden falsificarla. Eso es toda la defensa, y vive del otro lado.
De ahí salen dos cosas para ti. Sirve la superficie de herramientas desde una página con una
Content-Security-Policy estricta que fije todos los equipos de los que carga scripts, o
asume que el sobre es una pista y no una afirmación. Y trata la ausencia de sobre como algo
que no significa nada: un agente que no encuentra ninguno no ha aprendido nada sobre si tienes
una puerta o no.
Un buscador hace que tu sitio se encuentre. Un Agent Entry hace que responda, y convierte a quien visita en alguien a quien puedes reconocer la próxima vez.
A dónde seguir¶
-
El código
Un archivo, MIT, con la implementación de referencia en Python con la que se mantiene byte a byte.
-
El traspaso
Cómo la llamada a una herramienta del navegador se convierte en un mensaje firmado a tu origen.