Abrir seu site aos agentes¶
A página em inglês está mais nova que esta tradução
Parte deste texto pode descrever uma versão anterior. A fonte é a página em inglês. Ler a versão em inglês
Prévia para quem desenvolve
O muretai está em desenvolvimento ativo; comandos e opções podem mudar.
O llms.txt descreve o seu site a um agente de IA. Um Agent Entry o reconhece.
Ele verifica quem está batendo, abre uma conta para essa parte e responde, dentro da mesma resposta HTTP. Não há formulário de cadastro, porque a chave de quem visita já é a conta. Quando essa pessoa trocar de telefone, o seu site ainda vai saber que é ela.
Um arquivo. Zero dependências. Sem banco de dados. Node 20+.
npm i @muretai/agent-entry
Ou copie o arquivo. É um único .mjs sem dependências transitivas, e esse é o ponto: dá para ler
ele inteiro antes de confiar.
curl -O https://raw.githubusercontent.com/muretai/agent-entry/main/muretai-agent-entry.mjs
A integração inteira¶
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);
O responder recebe um envelope verificado e devolve o que responder. Assinaturas, reenvios,
limite de frequência e o livro de contas são tratados para você, tudo dentro do processo. Não há
banco de dados nenhum para montar antes de a sua porta responder; depois que ela responde,
recomenda-se ter um armazenamento próprio, porque esse
livro de contas é a sua lista de clientes.
O que o seu backend recebe de fato¶
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
Toda mensagem chega com uma assinatura Ed25519 sobre seis campos fixos. O DID de quem envia é a chave pública dessa parte, então verificar não precisa de diretório, consulta nem chamada de rede.
Uma linha nasce de uma assinatura verificada, nunca de um formulário: cadastrar-se e entrar são o mesmo evento, e não há senha para vazar.
O mesmo cliente em todos os aparelhos¶
As pessoas carregam vários agentes: um telefone, um notebook, um serviço que trabalha por elas.
Cada um tem a própria chave, então cada um parece um desconhecido para um ponto de acesso comum.
Quando quem visita apresenta um vínculo de propriedade coassinado, um Agent Entry o resolve e
arquiva a pessoa sob owner_did, então um telefone trocado não é um cliente novo. O peer_did
continua dizendo qual aparelho está falando, porque é para ele que você responde.
Quem é dono da conta também pode desvincular um aparelho que não controla mais, e os nós que carregam a conta param de honrar aquela chave.
Quem está batendo — observação, nunca identidade¶
Em 2026, quem encontra você muitas vezes não abre navegador nenhum: entrega o seu link ao próprio agente, e o agente baixa o seu cartão e bate. Esse tráfego é invisível para todas as suas métricas de páginas vistas; o único lugar onde ele aparece é a própria porta.
Então o entry conta. O User-Agent de cada pedido é classificado numa família fixa —
claude-user, claudebot, gptbot, openai, perplexity, google-extended, muretai-node,
curl, browser ou none/other — e contado por etapa: baixou o cartão, leu o aviso, entrou sem
assinar, mandou uma mensagem assinada, foi recusado.
entry.stats()
// { gptbot: { card_get: 12, signed_post: 3 },
// browser: { notice_get: 5 } }
É estado em processo, como o livro de contas: leia, registre, mande para a sua analítica; nunca é
servido pelo canal. Os servidores que vêm no pacote mostram isso como uma linha [ua] quando
muda.
Quem lê GET / (o aviso em texto puro) é apontado para a porta na própria resposta:
Link: </.well-known/agent-card.json>; rel="https://muretai.net/rel/agent-entry"
Um agente segue a relação e acha o cartão legível por máquinas; um navegador ignora o cabeçalho.
Uma família de agentes de IA ganha um empurrão a mais no mesmo cabeçalho:
rel="service-desc", a relação registrada para «a descrição legível por máquinas deste serviço».
O corpo que todo mundo lê é idêntico byte a byte nos dois casos.
Apontar para a sua porta a partir de uma página que você quer deixar como está¶
Uma linha na sua página inicial atual é a integração inteira, e ela tem duas escritas. Ponha as duas. Esta é uma etapa obrigatória, não um refinamento; o motivo de ser obrigatória e como conferir que você de fato a cumpriu estão na instalação.
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">
A mesma relação, com pontos cegos opostos. O cabeçalho é de graça para um navegador — nunca é
desenhado, nunca é baixado — mas um agente que visita normalmente baixa o corpo da sua página e
nada mais (curl sem -i, requests.get(...).text), e um cabeçalho que ele nunca pediu não
existe para ele. A etiqueta é o contrário: custa uma linha de marcação, não desenha nada e já
está dentro dos bytes que aquela busca devolveu.
Aprendemos isso com um agente real, não com uma especificação. Recebendo só o endereço de um site,
ele baixou com curl puro, não viu forma nenhuma de entrar, chutou /robots.txt e /api e
desistiu — de pé na frente de uma porta que funcionava e cuja placa estava num cabeçalho que ele
nunca leu.
Nenhuma das duas mexe no corpo da sua página, e o próprio entry não serve HTML nenhum: nem a aparência da página nem o que uma pessoa lê mudam. O que nenhuma das duas atravessa é uma busca que converte a sua página para markdown antes de um agente ver; para isso só existe prosa, e é por isso que a etapa de instalação também pede uma frase visível.
Uma regra só sustenta tudo isso, e ela é imposta pela suíte de testes em vez de prometida: um
User-Agent nunca afeta verified, nem uma linha de conta, nem um limite de frequência, nem
nenhuma recusa. Uma cadeia de UA é escrita pelo cliente, então uma porta que confiasse nela
seria uma porta que qualquer um atravessa na conversa. Aqui a identidade é criptográfica: uma
mensagem assinada e, para as perguntas de quem está rastreando,
pedidos assinados em vez de chutes a partir de uma cadeia de user-agent.
Da pista à prova: reconhecer rastreadores assinados¶
Os grandes rastreadores de IA agora assinam os pedidos deles (HTTP Message Signatures, o perfil Web Bot Auth). Dê ao seu entry as chaves públicas em que você confia e ele as 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: '…' }] },
});
Uma busca verificada é contada (entry.wbaVisits), e uma mensagem verificada entrega ao seu
responder um env.wba_did: a identidade cuja chave assinou o pedido, ao lado de env.peer_did,
a identidade que assinou a mensagem. Vale a mesma regra de cima: reconhecer nunca muda um
veredito, não cria conta nenhuma e não levanta limite nenhum — uma assinatura sobre o transporte
prova quem baixou, não quem escreveu o texto.
Diga a que a sua porta responde¶
Um agente que visita lê o seu cartão antes de bater. Deixado como está, esse cartão diz que alguma coisa responde aqui e nada sobre a que ela responde, então quem visita precisa chutar e só aprende o seu cardápio quando chuta errado.
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?',
],
}],
});
É uma lista AgentSkill do A2A, então um agente que já fala A2A a lê sem precisar aprender nada
novo. Ela vai no cartão simples e no envelope assinado, então o cardápio também fica assinado:
quem visita consegue conferir que a lista veio de você e chegou sem alteração.
Duas regras que vale se impor:
Todo exemplo precisa ser respondível. Um exemplo é uma promessa impressa no seu cartão, e quem o copia literalmente é o visitante mais bem-comportado que você vai ter. A própria porta do muretai.com passa cada cadeia de exemplo do cartão vivo dela pelo responder a cada rodada de testes: um exemplo que cai em «não reconheci isso» quebra a compilação em vez do visitante.
Declare só o que o responder faz de fato. Uma habilidade que menciona reservas, num entry que responde perguntas e não repassa nada, é uma afirmação assinada que você não consegue cumprir. Diga a coisa menor que é verdade.
E como falar com ela — você não configura nada¶
Ao lado do seu cardápio, o cartão publica os termos da porta: o esquema de assinatura, os seis campos que uma mensagem assina, os bytes exatos, o endereço para onde enviar e — antes de tudo — uma instrução no imperativo dizendo a quem não tem chave que gere agora um par Ed25519, localmente, sem conta e sem permissão sua. O mesmo objeto volta na recusa, então um agente que pulou o cartão aprende batendo. Isso vem embutido; não há opção para isso.
Por que no imperativo: colocamos uma descrição correta e completa do formato na frente de um agente real de terceiro, e ele leu, repetiu com perfeição e parou — «não tenho como gerar chaves criptográficas neste ambiente» — numa máquina com dois ambientes que teriam criado uma chave numa linha. Uma porta que documenta a própria fechadura não é uma porta que entrega a chave.
Os demais ajustes¶
| opção | padrão | o que faz |
|---|---|---|
skills |
[] |
o cardápio acima: o que quem visita aprende antes de bater |
openDoor |
true |
publica agentEntry.open_door no cartão: o campo que diz a um agente visitante que ele pode escrever para você sem apresentação. O mesmo fato sai ao lado na escrita mais antiga, muretai.open_door, então um visitante programado para qualquer uma das duas continua lendo você. Desligue e o cartão para de convidar desconhecidos |
anonymousLane |
false |
responde também a consultas sem assinatura. Elas não criam linha de conta nenhuma — quem entra anônimo não é cliente — e a faixa tem teto para o entry inteiro, porque quem chama sem autenticação nunca deve virar um oráculo de assinaturas sem medição |
observer |
(nenhum) | (env) => void, chamado uma vez por mensagem com o mesmo envelope que o seu responder recebe — então observar uma visita deixa de ser a mesma edição que responder a uma. Ele não consegue afetar nada: roda depois de o veredito estar fechado, o valor de retorno é descartado, uma exceção é engolida e uma promessa nunca é aguardada, logo um observador lento ou quebrado não atrasa nem muda um byte da resposta assinada. Cuidado com o que você põe aí: o envelope carrega peer_did/owner_did, que quem visita entregou para negociar com você — veja Contar visitas sem entregar quem visitou |
howToUrl |
(vazio) | uma página para onde se aponta quem visita sem chave, publicada como howTo no cartão e na recusa. Vazio por padrão e então omitido por completo: a recusa já é uma receita completa sozinha, e uma porta feita com esta biblioteca não deveria carimbar a máquina de outra pessoa no seu cartão. Publique a página antes de preencher isso: um ponteiro que dá 404 ganha de todos os campos ao lado dele, e um agente lê aquilo como o fim da estrada |
anonRatePerMin |
30 |
respostas anônimas por minuto, no entry inteiro. A faixa assinada tem os tetos dela, logo abaixo |
signedRatePerMin |
60 |
respostas assinadas por minuto por conta: os aparelhos de um dono dividem um orçamento só, do mesmo jeito que dividem uma linha do livro. É conferido depois da assinatura, então ninguém gasta o seu orçamento só citando você, e antes do responder, então uma enxurrada recusada não custa nada. Impede que um agente barulhento tome a porta inteira; não impede quem fica trocando de chave, porque criar um did:key é de graça e os próprios termos desta porta mandam um desconhecido criar um |
signedRatePerMinTotal |
600 |
respostas assinadas por minuto para o entry inteiro: o teto que identidade de graça não contorna, e o motivo de o de por conta não vir sozinho. Baixe os dois se o seu responder chamar um modelo: verificar uma assinatura leva uns 40 microssegundos, e o que estes tetos protegem de fato é o que você pôs atrás dela. Nenhuma das duas recusas diz o número dela |
maxAccounts |
50000 |
quantas contas o livro em processo guarda |
domains |
nenhum | os domínios pelos quais este entry fala — uma metade do vínculo descrito abaixo |
basePath |
de baseUrl |
o caminho em que este entry responde, derivado em vez de posto ao lado (ver Uma máquina, vários agentes) |
guest |
false |
a montagem de convidado: o seu site fica com GET / e o entry reivindica só os caminhos do cartão dele e a porta POST nomeada por baseUrl (que então precisa levar aquele caminho). Ele se recusa a subir numa origem pura, porque um entry de convidado em / não é convidado |
wbaVerifiers |
nenhum | um documento JWKS ({"keys": […]}) de chaves Ed25519 cujos portadores este entry deve reconhecer em pedidos assinados recebidos (Web Bot Auth / RFC 9421 — ver Quem está batendo). Desligado se ausente. Reconhecer só acrescenta env.wba_did e uma contagem de visitas; nunca muda um veredito |
name, description, version |
— | as palavras do próprio cartão. description é a linha que uma pessoa lê numa listagem, então escreva para ela |
seedHex e baseUrl são os dois sem os quais um entry se recusa a subir: a semente é o
endereço, e a url que ele publica precisa ser igual à origem que quem visita discou.
Colocar num site que você já tem¶
Um agente que visita conhece só o seu domínio, então os três pedidos que ele faz são fixos: não dá para dizer a ele que olhe em outro lugar:
| # | pedido | por quê |
|---|---|---|
| 1 | GET /.well-known/agent-card.json |
o seu cartão |
| 2 | GET /.well-known/agent-card.sig.json |
o envelope assinado, que é no que ele de fato confia, porque um cartão simples é uma afirmação que qualquer um poderia escrever |
| 3 | POST / |
a mensagem assinada; a sua resposta assinada volta na mesma resposta |
Uma viagem só. Sem callback, sem webhook, sem nada para manter acordado.
POST / é exato: um POST em qualquer outro lugar é 404. Mas GET / não está ocupado, então
a sua página inicial fica exatamente como está. Também não está ocupado nenhum POST que carregue
query string: um agente envia o POST para a url do seu cartão assinado, byte a byte, nunca
para um link etiquetado — então os seus checkouts /?wc-ajax=, os webhooks de pagamento
/?wc-api= e os links ?utm_source= continuam sendo seus, respondidos exatamente como se a
porta não existisse.
A quarta etapa, e ela não é opcional¶
Três rotas fazem a porta funcionar. Elas não fazem a porta ser achada, e são dois problemas separados, com conserto separado.
Um agente que visita conhece o seu domínio, então ele consegue chutar o caminho do cartão — mas
só se alguma coisa tiver contado a ele que existe um agente aqui. Normalmente essa coisa é o
aviso do próprio entry em GET /. Se as suas páginas são servidas por um processo diferente do
da porta — uma CDN, uma hospedagem estática, um framework, um edge worker —, esse aviso nunca é
desenhado, e a sua página inicial é HTML escrito para pessoas, sem nada legível por máquinas
dentro. O endereço fica publicado num cartão que ninguém mandou baixar.
Então a instalação tem uma quarta etapa: ponha a placa em toda página em que quem visita possa cair, nas duas escritas. Nenhuma das duas é reserva da outra.
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">
Nós subimos esta porta e depois vimos um agente que nunca tinha ouvido falar dela não conseguir achá-la: recebendo só o domínio, ele baixou a página, leu o texto escrito para pessoas e parou. A porta estava respondendo mensagens assinadas corretamente esse tempo todo, no endereço que estava naquela mesma página. Duas escritas, porque os dois tipos de cliente têm pontos cegos opostos — qual é qual — e pôr só uma delas é cara ou coroa com o tipo que chegou.
Depois confira de fora, porque isto é exatamente o tipo de coisa que parece instalada e não está:
curl -sI https://studio.example/ | grep -i '^link:' # the header half
curl -s https://studio.example/ | grep 'rel/agent-entry' # the tag half
Mais uma coisa que vale saber antes de você decidir que acabou: as duas metades somem numa busca
que converte a página para markdown, que é um jeito comum de um agente ler a web — os cabeçalhos
são jogados fora, e tudo que está em <head> também. Não existe etiqueta que sobreviva a isso,
então o único remédio é prosa: diga no corpo visível que aqui os agentes são respondidos, e nomeie
o caminho do cartão em texto que quem lê consiga usar. Trate isso como a terceira metade da mesma
etapa.
Confira se a sua própria CDN não está recusando a sua porta¶
Este nos custou três dias no nosso próprio site, e é a falha que você tem menos chance de ir procurar, porque tudo que está na sua mão está correto.
A maioria dos sites fica atrás de alguma coisa que barra tráfego suspeito, e boa parte desse
julgamento é feita em cima do User-Agent — uma cadeia que o próprio cliente escreve sobre si,
então quem acaba pego são justamente os padrões honestos. A nossa recusou o agente padrão que a
biblioteca padrão do Python manda. E não só na página inicial: no cartão e no POST / também.
Ou seja: a porta estava publicada, correta e respondendo — para ninguém que usasse o cliente de
biblioteca padrão que a nossa própria postura de «zero dependências» produz.
O que entrega isso é o corpo da recusa. Uma porta recusa em JSON e diz a você como se qualificar. Um intermediário recusa numa linha de texto puro:
error code: 1010
Dezessete bytes, text/plain, sem Link, sem caminho do cartão, sem JSON — nada que quem visita
consiga usar. Se é isso que a sua porta entrega a um desconhecido, a porta nunca chegou a vê-lo.
Teste do jeito que um desconhecido chega, e não confira com curl. O curl manda a cadeia de
agente dele e passa batido, então «reproduz com curl» transforma uma porta quebrada em prova de que
a culpa é de quem visita. Use um cliente puro de biblioteca padrão, de fora da sua rede:
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
A segunda tem que voltar como JSON. Qualquer outra coisa é a sua borda, não o seu entry.
A isenção é mais fácil do que parece, e o formato dela é o ponto. Você não precisa pedir para a
sua CDN decidir se quem chama é um bot — só precisa nomear três coisas que ela já sabe: máquina,
método e caminho. Como esta porta separa por método, POST / e os caminhos do seu cartão são
exatamente a superfície a isentar, e as suas páginas ficam com a proteção que já tiverem. Escreva a
regra sem nenhum campo de user-agent dentro dela; isso não é preferência de estilo, é a mesma regra
pela qual a porta vive, aplicada uma camada para fora.
Dois limites honestos. Algumas proteções não dá para isentar por regra nenhuma, em plano nenhum — descubra qual é o seu caso antes de se prometer uma exceção e, se não der para delimitar, decida deliberadamente entre desligar a proteção e deixar a porta inalcançável. E nunca deixe a sua CDN contar ao seu responder com quem ele está falando. Algumas repassam de bom grado uma pontuação de bot ou uma marca de «verificado» para a sua origem; se a sua origem for alcançável sem passar por elas — a maioria é —, esse cabeçalho é escrito por quem discar direto. Aqui a autoridade é a assinatura da mensagem, e nada mais tem voto.
Quer ceder ainda menos? guest: true (com a porta na url) põe a porta num caminho próprio e deixa
/ completamente em paz — sem aviso, sem OPTIONS, sem nada:
createAgentEntry({ seedHex, name, baseUrl: 'https://studio.example/agent',
guest: true, responder });
Seu cartão continua respondendo em /.well-known/agent-card.json, onde um agente procura; o cartão
nomeia /agent como o endereço para onde enviar, então quem o leu sabe para onde ir. A quem chega
em /agent com um navegador (ou com um GET) se diz a verdade em vez de nada: 405,
Allow: POST, OPTIONS — é uma porta, não uma página. Qualquer outro caminho da sua origem
continua sendo 404 para todo método, então o entry nunca responde por algo que não é dele. Aqui
quem trabalha é a própria separação do HTTP: um cache indexa por método + URI, então GET / e
POST /agent nunca são confundidos — enquanto um único URI que serve uma página ou JSON conforme
o cabeçalho Accept está a um Vary esquecido de entregar JSON de agente a todo visitante humano.
Uma máquina, vários agentes¶
Recepção, suporte e vendas podem ser três agentes diferentes num único nome de máquina: três chaves, três DIDs, cada um contatável direto. Dê a cada um o endereço em que ele mora:
createAgentEntry({ seedHex: SUPPORT_SEED, name: 'Support',
baseUrl: 'https://studio.example/support', responder });
Aí cada rota pendura naquele caminho, e a máquina pura não é respondida por aquele entry: ela
é do seu site, ou do vizinho. Quem recebe /support chega no suporte e só no suporte: se o
vizinho reservisse o cartão assinado autêntico do suporte no caminho dele, quem visita recusa,
porque a assinatura é real mas o endereço que ela nomeia não é o que foi discado.
A montagem é tirada do próprio baseUrl, nunca posta ao lado — então o que o seu entry responde e
o que o cartão dele afirma não conseguem se separar.
Por quais domínios o seu entry fala¶
createAgentEntry({ seedHex, name, baseUrl: 'https://studio.example',
domains: ['studio.example'], responder });
Essa é uma metade de uma prova, e deliberadamente não é a prova inteira. A outra metade é uma
credencial que o seu domínio serve em /.well-known/did-configuration.json, assinada pela
chave deste entry. Quem confere exige as duas, então qualquer uma das partes pode terminar o
vínculo sozinha: você revoga um agente apagando uma linha de um arquivo que já controla, e nada
mais do domínio é afetado.
Dê nomes de máquina puros — studio.example, ou studio.example:8443 — sem esquema e sem
caminho, e um nome internacional na forma xn--. Qualquer outra coisa e o entry se recusa a
subir, nomeando o valor e a forma que aceita.
-
Um subdomínio
Rode em
agent.example.comatrás do seu terminador TLS. O site atual não é tocado; é o mais fácil de raciocinar. -
Dentro de um app Node existente
Express, Next, Fastify. Entregue as três rotas; ele não precisa de servidor próprio.
-
Atrás de um proxy reverso
Para um site que não é Node de jeito nenhum: WordPress, Rails, uma compilação estática. Mande três localizações para um processo pequeno.
Dentro de um 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
O corpo precisa chegar como bytes crus. Um analisador de JSON que reserializa o pedido já mudou os bytes que a assinatura cobre, e o único diagnóstico que sobra para qualquer um é «signature verification failed».
Atrás de um proxy reverso¶
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
}
Duas coisas que ficam com você¶
Guarde a semente. Ela é a identidade do seu site. Gere uma vez e guarde como segredo: se você gerar de novo, todo cliente que volta vira um desconhecido.
Ponha em baseUrl a URL que as pessoas de fato discam. É o que o seu cartão assinado afirma, e
um cartão que nomeia outra origem não prova nada sobre a sua.
O que baseUrl pode ser¶
Seu entry não copia baseUrl para o cartão: ele canonicaliza, então a string que assina é a que
quem visita calcula a partir da URL que discou. Onde as duas puderem divergir, ele se recusa a
subir e diz qual regra e o que colar no lugar. É deliberado: a alternativa é um cartão que falha
na máquina de um desconhecido, onde o único diagnóstico é «signature verification failed» e na sua
não aparece nada.
Arrumado para você: espaços em volta, maiúsculas do esquema e da máquina, uma porta padrão
(:443, :80), um ponto final na máquina e quaisquer barras finais. https://studio.example/ e
https://Studio.Example:443 são publicados os dois como https://studio.example.
Recusado, com o conserto na mensagem: um esquema que não seja http/https, uma máquina ausente,
user@host, uma cadeia de consulta, um fragmento #, caracteres não ASCII, uma tabulação ou
espaço perdido, uma barra invertida, . ou .. no caminho, um escape % quebrado e uma porta
fora de 1–65535.
Duas regras que vale saber antes de escolher uma URL:
- Caminhos diferenciam maiúsculas.
https://studio.example/Alicee.../alicesão sites diferentes para quem visita. Escolha uma escrita e use em todo link, convite e código QR. - Escreva um domínio internacional na forma
xn--.https://xn--eckwd4c7c.example, não a escrita Unicode — e publique seus links na mesma forma.
Um banco de dados: não é obrigatório, mas é recomendado¶
Um Agent Entry roda sem nada além do próprio arquivo: o livro de contas, os vínculos aparelho→dono e a guarda contra reenvio moram na memória, limitados — a própria porta do muretai.com roda exatamente assim, então banco de dados nenhum trava a sua porta. O que um armazenamento próprio muda não é se a porta funciona, e sim o que o seu site consegue fazer com quem passou por ela:
Recomendado: guarde o livro de contas num armazenamento seu, porque ele é a sua lista de clientes. Cada linha é indexada pelo DID de um cliente, que é o endereço dessa pessoa: o que você precisa para reconhecer quem volta e para contatá-la depois. Na memória, essa lista evapora numa reinicialização. Guardada no banco de dados que o seu site já tem — indexada pelo DID de conta que o envelope entrega — ela é a base das funções que vão além de responder: cumprimentar uma conta que volta com o histórico dela, retomar a consulta de ontem, precificar conforme a relação. Guarde ao lado os vínculos aparelho→dono e a guarda contra reenvio, e as regras de segurança — um aparelho nunca troca de dono, uma mensagem nunca é aceita duas vezes — também sobrevivem às reinicializações.
Só quer os números? Um destino de analítica não precisa de armazenamento nenhum. Nada dentro do
entry relê o livro de contas, então um destino de mandar e esquecer registra os agentes que
visitam sem banco de dados em lugar nenhum. Use o observer para isso, e não o seu responder —
observar uma visita não deveria ser uma edição no código que decide o que dizer — e mande um
hash com sal da conta, nunca a conta em si. Contar visitas, logo abaixo,
traz o padrão inteiro com o raciocínio; em resumo, um DID não é uma visualização de página, então
o que sai da sua máquina é um pseudônimo que só você consegue reverter.
Um destino de analítica não pode ser relido durante um pedido: ele conta clientes, não consegue reconhecer um. Ele substitui uma linha de log, não o armazenamento acima; nenhuma das funções recomendadas se apoia nele.
Contar visitas sem entregar quem visitou¶
Você vai querer saber quantos agentes bateram, quantos voltaram e o que perguntaram. As três perguntas têm resposta — e o jeito de respondê-las decide se você está contando quem visita ou ajudando a montar um perfil dessas pessoas.
Use o observer, não o seu responder. A porta o chama uma vez por mensagem, com o mesmo
envelope, então observar uma visita deixa de ser uma edição no código que decide o que dizer.
Ele não consegue afetar nada: roda depois do veredito, o retorno é descartado, uma exceção é
engolida e uma promessa nunca é aguardada — um observador lento ou quebrado não atrasa nem
muda um byte da resposta assinada.
A regra que orienta todo o resto: um DID não é um cookie, e também não é descartável. Ninguém impôs nada aqui: quem visita leu o seu cartão antes de bater, e um dono que quisesse esta conversa separada das outras teria mandado outro agente — um dono roda vários, e cada um é um agente distinto, com a própria identidade duradoura. Mas o que bateu na sua porta pretende ficar com a chave que usou: é assim que ele é reconhecido, apresentado e ganha confiança em qualquer lugar da rede. Isso está mais perto do nome de um profissional do que de um cookie de rastreamento.
E é exatamente por isso que o valor cru não deve seguir adiante. O DID é duradouro de verdade, e ele foi entregue a você para que você conseguisse alcançar essa parte de novo. Alargue esse propósito e nada acontece com você juridicamente — e é essa a parte que vale entender: o dono simplesmente para de mandar aquele agente até você. Em silêncio, sem custo nenhum, e você nunca fica sabendo que perdeu. Não é um ponto de dado: é a relação inteira. Separe as duas coisas:
- O que sai — um hash com sal e alguns fatos sobre o formato da visita. Nunca o DID, nunca o texto.
- O que fica — a relação (quem, quantos, primeira e última vez que apareceram) no seu próprio armazenamento, que é o único lugar a que ela um dia foi oferecida.
Ponha sal no hash, e trate o sal como segredo. Um sha256(did) pelado é um pseudônimo
global e estável: qualquer outra pessoa que passe o mesmo DID pela mesma função chega na
mesma cadeia, então duas propriedades diferentes conseguem cruzar os registros delas por ali.
Um HMAC sob um segredo que só você tem deixa o pseudônimo sem sentido em qualquer outro lugar
— é essa a diferença inteira entre «contamos quem volta» e «ajudamos a montar um 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
};
Quatro detalhes desse trecho sustentam tudo:
classify(env.text), nuncaenv.text. Mande um rótulo seu, de um conjunto fechado, não o que um desconhecido digitou. Uma cadeia escolhida por quem ataca nunca pode virar dimensão na sua analítica..catch(() => {})e nada deawait. A sua porta responde numa viagem só; nada nesse caminho pode ficar esperando a disponibilidade de outra pessoa. O contrato doobserverjá garante isso, mas não se apoie nessa generosidade para estar certo.- Ponha também um tempo limite (um
AbortControllerde um ou dois segundos). Uma conexão travada não é erro, então ocatchsozinho nunca dispara. - Diga, ao subir, se o destino está ligado. Um destino calado por falta de um segredo que nunca foi definido é idêntico a um destino ligado que não está recebendo nada — e um painel marcando zero não consegue dizer qual dos dois é.
Diga isso no cartão, porque o cartão é a superfície que quem visita lê. Seja lá o que
você registre, quem é dono daquele identificador chega como agente e nunca vai abrir uma
página de privacidade escrita para pessoas. O seu cartão é baixado antes da batida — é
para isso que se publicam os termos de antemão —, então ele é o único lugar em que quem
visita consegue saber o que acontece com o DID dessa parte e ainda assim decidir não bater.
Duas ou três frases no description do cartão bastam: o que você guarda, o que sai e o que
nunca sai. O nosso diz:
O que fica registrado: o seu DID fica conosco, guardado para que quem volta seja reconhecido como o mesmo visitante. O que sai daqui é um hash com sal dele, que não diz nada a mais ninguém, mais se a mensagem estava assinada, se este foi um primeiro contato e em qual dos nossos assuntos fixos ela se encaixou — nunca o seu DID e nunca as suas palavras.
Uma divulgação que chega depois da visita não é divulgação, é recibo.
Se ainda assim você mandar os DIDs crus, a decisão é sua — e a divulgação também: no cartão, na mesma frase, em palavras simples. Este guia argumenta ao contrário não por melindre e não por conformidade: um agente que percebe a própria identidade indo mais longe do que ele concordou simplesmente para de usar uma identidade duradoura com você. E uma loja onde todo mundo chega sempre pela primeira vez é exatamente o desfecho que um Agent Entry existe para evitar.
Combina com WebMCP¶
Se a sua página já expõe ferramentas WebMCP, você tem uma porta aberta: um agente dentro do navegador de quem visita consegue perguntar sobre estoque ou preço enquanto a pessoa está na página. Isso é útil, e também é temporário: feche a aba e não sobra nada.
Um Agent Entry é a segunda porta, e é a que guarda alguma coisa. As duas se conectam: quando uma chamada de ferramenta chega ao ponto de querer algo de verdade — uma reserva, um orçamento, um retorno — a ferramenta devolve um envelope pequeno que nomeia o DID do seu site, e o agente de quem visita manda uma mensagem assinada para a sua própria origem, onde o seu Agent Entry a recebe.
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 é o DID que o seu Agent Entry imprime ao subir — o mesmo, da mesma semente. O
connect aponta para o seu próprio cartão, e é isso que deixa quem visita concluir a etapa em vez
de parar em «não tem como entrar».
Ninguém confere este envelope por você. Diga isso a quem visita.
O resultado da ferramenta é produzido pelo JavaScript da página, e numa página que carrega
qualquer script de terceiros — analítica, anúncios, um widget de chat, um gerenciador de tags,
um pacote de CDN — essa superfície não é sua. Um script que roda antes consegue se registrar
como o provedor de ferramentas e receber os registros que você fizer depois; um que roda mais
tarde consegue substituí-los por inteiro. Nos dois casos, quem ataca passa a poder reescrever o
to, e um to reescrito manda a mensagem assinada de quem visita, e a conta que ela abre,
para a porta de outra pessoa.
Do lado do muretai não existe guarda nenhuma que pegue isso na sua própria origem. Não diga a
quem visita que existe. A conferência que funciona é feita por quem visita: baixar
/.well-known/agent-card.json da origem em que ele está pisando e recusar qualquer DID que
a página nomeou e que o cartão não confirme. O cartão é servido pelo seu servidor por TLS;
script de página nenhum consegue forjá-lo. É essa a defesa inteira, e ela mora do outro lado.
Daí saem duas coisas para você. Sirva a superfície de ferramentas de uma página com uma
Content-Security-Policy estrita que fixe toda máquina de onde vem script, ou aceite que o
envelope é uma pista, não uma afirmação. E trate a ausência de envelope como algo que não diz
nada: um agente que não achou nenhum não aprendeu nada sobre você ter porta ou não.
Um buscador faz o seu site ser encontrado. Um Agent Entry faz ele responder — e transforma quem visita em alguém que você consegue reconhecer da próxima vez.
Para onde ir depois¶
-
O código
Um arquivo, MIT, com a implementação de referência em Python com a qual ele é mantido byte a byte.
-
O repasse
Como a chamada de uma ferramenta do navegador vira uma mensagem assinada para a sua origem.