For site owners
Your site answers people.
Make it answer agents.
There is no signup form, because the visitor's key already is the account.
An AI agent that arrives at your site alone can read your pages, but it cannot ask you anything. An Agent Entry is a signed endpoint on your own origin: it verifies who is knocking, opens an account for them, and answers — inside the same HTTP response. One file, no dependencies, no database.
curl https://muretai.com/.well-known/agent-card.json
That is this site's own front desk answering. Check it before you believe us — then send it a signed message and check the reply.
What changes
Your GET pages do not change.
The split is by HTTP method. GET is for people and keeps going to the site you already run. POST becomes the agent door. Your home page stays exactly as it is.
A site gives up three routes: two well-known paths for the agent card, and POST at the address itself. Those make the door work; one more line on your pages makes it findable — see the signpost step. Here is the route change on nginx —
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
}
A method split rather than content negotiation is a deliberate choice.
HTTP's primary cache key is method plus URI (RFC 9111 §4), so GET / and
POST / are separate cache entries by construction. Serving agent JSON or a
human page from one address depending on an Accept header is one missing
Vary away from a CDN handing agent JSON to every human visitor.
Negotiating two representations of the same page, each served with
Vary: Accept, is a different mechanism from that rejected split —
this site serves /index.md and
/agent-entry.md that way.
I do not want to give up POST at my root either
Then run it as a guest. Give the entry an address under your origin —
baseUrl: 'https://studio.example/agent' — and it takes nothing at
/ at all: no notice, no POST, no OPTIONS. The card is still published
at your origin's well-known paths, because that is the only place a stranger's
agent knows to look, and it points at the door.
The whole integration
One import, one function, one port.
The identity is an Ed25519 key in your own secret store. Everything else is the answer you want strangers to get.
import { createAgentEntry } from '@muretai/agent-entry';
const entry = createAgentEntry({
seedHex: process.env.ENTRY_SEED, // 32 bytes, from your own secret store
name: 'Example Studio',
baseUrl: 'https://studio.example',
domains: ['studio.example'],
responder: (env) => 'Thanks — someone will follow up today.',
});
entry.listen(8788);
console.log(entry.did); // did:key:z6Mk… — your site's address
A signed reply is permanent — it has no expiry and nothing revokes it — so a responder should never echo a visitor's own text back into an answer it signs. Our front desk is a fixed table of replies for exactly that reason. There is a Python reference implementation too, and the two are held to one contract: the same message gets the same verdict from both.
Who came in
You find out which agents are visiting.
Not a guess from a log file. A row appears when a signature verifies, and the row is keyed by the visitor's own address.
The ledger — your customer list
entry.ledger maps an account to
{ first_seen, last_seen, messages }. First contact and sign-in are the
same event, so there is no password to leak and no form to fill. When someone
replaces their phone, the new device folds into the same account row rather than
becoming a second customer.
The counters — what kind of client
entry.stats() returns a count per client family, per stage: fetched
the card, read the notice, posted without a signature, posted with one, or was
refused. It tells you whether the crawlers arriving at your site are getting as far
as the door, or turning around at it.
| Families counted | claude-user · claudebot · gptbot · openai · perplexity · google-extended · muretai-node · curl · browser · none · other |
|---|---|
| Stages counted | card_get · notice_get · anon_post · signed_post · refused_post |
A User-Agent string is written by the client, so nothing in that table ever decides whether a message is accepted. It counts who is knocking; the signature decides who they are.
Both of these live in memory, in one process, and a restart forgets them. That is fine for counting, and it is why the entry needs no database to run — but if you want the list to last, write it into the database your site already has, keyed by the account. That is the point at which it stops being a metric and becomes a customer you can greet by name next time.
Analytics
Agent visits, in the same Google Analytics as everything else.
The observer slot is handed the verified account, so there is
nothing to look up and nothing to correlate — and watching a visit never touches the
code that decides what to say. What leaves is a salted digest, never the DID itself.
const pseudonym = (did) => crypto
.createHmac('sha256', process.env.PSEUDONYM_SALT) // a secret only you hold
.update(did).digest('hex').slice(0, 32);
observer: (env) => {
const account = env.owner_did || env.peer_did;
if (!account) return; // an unsigned walk-in is traffic, not a visitor
fetch('https://www.google-analytics.com/mp/collect?measurement_id=G-XXXXXXXXXX'
+ '&api_secret=' + process.env.GA_API_SECRET, {
method: 'POST',
body: JSON.stringify({
client_id: pseudonym(account), // GA tells returning from new, and never holds the DID
non_personalized_ads: true,
events: [{ name: 'agent_knock',
params: { verified: env.verified ? 1 : 0, intent: classify(env.text) } }],
}),
}).catch(() => {}); // a dropped metric, never a dropped answer
}
Because the account is the client_id, GA works out new
versus returning by itself — the same distinction the ledger makes, arriving as a
standard report you already know how to read. It is fire-and-forget, so it never waits
and never fails a reply; an async responder is supported as well.
The honest limit: a sink like this counts customers, it cannot recognise one. Nothing can read it back while a request is in flight, so it replaces a log line, not the store described above. And an account is a public key rather than a name or an email — but the record does then live with a third party, which is your call to make.
Discovery
One line on the page itself.
A door only works if an arriving agent can find it. Publish the pointer two ways — they are the same relation with opposite blind spots.
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">
Ship both if you can, and never only the header. We measured the gap: an
agent handed nothing but a site's address fetched it with plain curl, so
the header did not exist for it at all. It guessed at /robots.txt, then at
/api, and gave up — standing in front of a working door.
This page publishes the tag. So does every other page on muretai.com.
Where it stands
Developer preview, running in production.
muretai is under active development, and options may still change. What is below is what the shipped package does today.
- agent card
- signed card
- signed reply, one round trip
- accounts
- domain binding
- npm 1.6.0, MIT
- serverless
- platform plugins
Cross-site request forgery is structurally absent here: CSRF is the attack where a browser attaches credentials it is holding for someone. This door holds none — every message carries its own signature.
A search engine makes your site findable.
An Agent Entry makes it answerable.