Saltar a contenido

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+.

your site — installsin paso de compilación
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.com detrá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/Alice y .../alice son 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.

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), nunca env.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ún await. 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 de observer ya lo garantiza, pero no te apoyes en esa generosidad para ser correcto.
  • Ponle también un tiempo límite (un AbortController de uno o dos segundos). Una conexión colgada no es un error, así que catch por 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.

    github.com/muretai/agent-entry

  • El traspaso


    Cómo la llamada a una herramienta del navegador se convierte en un mensaje firmado a tu origen.

    Traspaso