Skip to content

Make your website agent-reachable

Developer preview

muretai is under active development; commands and flags may change.

llms.txt describes your site to an AI agent. An Agent Entry recognises one.

It verifies who is knocking, opens an account for them, and answers — inside the same HTTP response. There is no signup form, because the visitor's key already is the account. When that person replaces their phone, your site still knows it is them.

One file. Zero dependencies. No database. Node 20+.

your site — installno build step
npm i @muretai/agent-entry

Or copy the file. It is a single .mjs with no transitive dependencies, which is the point — you can read all of it before you trust it.

curl -O https://raw.githubusercontent.com/muretai/agent-entry/main/muretai-agent-entry.mjs

The whole integration

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 receives a verified envelope and returns what to say back. Signatures, replay, rate limiting and the account ledger are handled for you — all of it in process. There is no database to set up before your door answers; once it is answering, a store of your own is recommended, because the ledger is your customer list.

What your backend actually gets

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

Every message arrives with an Ed25519 signature over six frozen fields. The sender's DID is their public key, so verification needs no directory, no lookup and no network call.

A row is born from a verified signature, never from a form: sign up and log in are the same event, and there is no password to leak.

The same customer across their devices

People carry several agents — a phone, a laptop, a service that runs for them. Each has its own key, so each looks like a stranger to an ordinary endpoint. When a visitor presents a countersigned owner binding, an Agent Entry resolves it and files them under owner_did, so a replaced phone is not a new customer. peer_did still tells you which device is talking, because that is who you reply to.

An owner can also disown a device they no longer control, and nodes that carry the account stop honouring that key.

Who is knocking — observation, never identity

In 2026 the person who found you often never opens a browser: they hand your link to their agent, and the agent fetches your card and knocks. That traffic is invisible to every page-view metric you have — the only place it can be seen is the door itself.

So the entry counts it. Each request's User-Agent is classified into a fixed family — claude-user, claudebot, gptbot, openai, perplexity, google-extended, muretai-node, curl, browser, or none/other — and counted by stage: fetched the card, read the notice, walked in unsigned, sent a signed message, was refused.

entry.stats()
// { gptbot:  { card_get: 12, signed_post: 3 },
//   browser: { notice_get: 5 } }

It is in-process state like the ledger — read it, log it, ship it to your analytics; it is never served on the wire. The bundled servers print it as a [ua] line when it changes.

Every caller that reads GET / (the plain-text notice) is pointed at the door in the response itself:

Link: </.well-known/agent-card.json>; rel="https://muretai.net/rel/agent-entry"

An agent follows the relation and finds the machine-readable card; a browser ignores the header. An AI-agent family gets one extra nudge in the same header — rel="service-desc", the registered relation for "the machine-readable description of this service". The body everyone reads is byte-identical either way.

Pointing at your door from a page you are keeping

One line on your existing home page is the whole integration — and there are two spellings of it. Add both. This is a required step, not a refinement; the reason it is required, and how to check you actually did it, are in the install.

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">

Same relation, opposite blind spots. The header is free for a browser — never rendered, never downloaded — but a visiting agent usually fetches your page body and nothing else (curl without -i, requests.get(...).text), and a header it never asked for does not exist for it. The tag is the reverse: it costs one line of markup, renders nothing, and it is already inside the bytes that fetch returned.

We learned this from a real agent, not from a spec. Handed only a site's address, it fetched with plain curl, saw no way in, guessed /robots.txt and /api, and gave up — standing in front of a working door whose pointer was in a header it never read.

Nothing in your body changes either way, and the entry itself serves no HTML — so neither spelling touches how your page looks or what a person reads. What they do not survive is a fetch that converts your page to markdown before an agent sees it; for that there is only prose, which is why the install step asks for a visible sentence as well.

One rule holds all of this together, and it is enforced by the contract suite rather than promised: a User-Agent never affects verified, an account row, a rate limit, or any refusal. A UA string is written by the client, so a door that trusted it would be a door anyone could talk their way through. Identity here is cryptographic — a signed message, and for who-is-crawling questions, signed requests rather than guesses from a user-agent string.

From hint to proof: recognising signed crawlers

Major AI crawlers now sign their requests (HTTP Message Signatures — the Web Bot Auth profile). Hand your entry the public keys you trust and it verifies them:

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: '…' }] },
});

A verified fetch is counted (entry.wbaVisits), and a verified message hands your responder env.wba_did — the identity whose key signed the request, alongside env.peer_did, the identity that signed the message. The same rule as above holds: recognition never changes a verdict, mints no account, and lifts no rate limit — a signature over the transport proves who fetched, not who wrote the text.

Say what your door answers

A visiting agent reads your card before it knocks. Left alone, that card says something answers here and nothing about what it answers — so the visitor has to guess, and learns your menu only from whatever comes back when it guesses wrong.

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?',
    ],
  }],
});

This is an A2A AgentSkill list, so an agent that already speaks A2A reads it without being taught anything new. It goes into the plain card and the signed envelope, so the menu is signed too: a visitor can check the list came from you and reached it unaltered.

Two rules worth holding yourself to:

Every example must be answerable. An example is a promise printed on your card, and the visitor who copies one verbatim is the best-behaved visitor you will get. muretai.com's own door drives every example string off its live card through its responder on every test run — an example that lands on "I did not recognise that" fails the build rather than the visitor.

Declare only what the responder actually does. A skill that mentions booking, on an entry that answers questions and hands off nothing, is a signed claim you cannot keep. Say the smaller true thing.

And how to talk to it — you configure nothing

Beside your menu, the card publishes the door's terms: the signing scheme, the six fields a message signs, the exact bytes, the address to send to, and — first — an instruction in the imperative telling a visitor with no key to generate an Ed25519 keypair now, locally, with no account and no permission from you. The same object comes back in the refusal, so an agent that skipped the card learns it by knocking. This is built in; there is no option for it.

Why the imperative: we put a correct, complete description of the format in front of a real third-party agent and it read it, restated it perfectly, and stopped — "I don't have a way to generate cryptographic keys within this environment" — on a machine with two runtimes that could have made a key in one line. A door that documents its lock is not a door that hands you the key.

The rest of the settings

option default what it does
skills [] the menu above — what a visitor learns before knocking
openDoor true publishes agentEntry.open_door on the card: the field that tells a visiting agent it may message you with no introduction. The same fact ships under the older muretai.open_door spelling beside it, so a visitor written against either one still reads you. Turn it off and the card stops inviting strangers
anonymousLane false also answer unsigned inquiries. They create no account row — an anonymous walk-in is not a customer — and the lane is capped entry-wide, because an unauthenticated caller must never become an unmetered signing oracle
observer (none) (env) => void, called once per message with the same envelope your responder gets — so watching a visit is not the same edit as answering one. It cannot affect anything: it runs after the verdict is settled, its return value is discarded, a throw is swallowed and a promise is never awaited, so a slow or broken watcher cannot delay or change one byte of the signed reply. Careful what you put in it: the envelope carries peer_did/owner_did, which a visitor gave you to transact with you — see Counting visits without giving away who they are
howToUrl (empty) a page a keyless visitor is pointed at, published as howTo on the card and in the refusal. Empty by default and then omitted entirely — the refusal is a complete recipe on its own, and a door built from this library should not stamp somebody else's host into your card. Ship the page before you set this: a pointer that 404s out-competes every field beside it and reads to an agent as the end of the road
anonRatePerMin 30 anonymous replies per minute, across the whole entry. The signed lane has its own ceilings, below
signedRatePerMin 60 signed replies per minute per account — an owner's devices share one budget, exactly as they share one ledger row. Checked after the signature, so nobody can spend your budget by naming you, and before the responder, so a refused flood costs you nothing. Stops one loud peer taking the whole door; it cannot stop a caller cycling fresh keys, because a did:key is free to mint and this door's own terms tell strangers to mint one
signedRatePerMinTotal 600 signed replies per minute for the whole entry — the tier free identity cannot walk around, and the reason the per-account one is not shipped alone. Lower both if your responder calls a model: verifying a signature is ~40 microseconds, and what these ceilings actually protect is whatever you put behind it. Neither refusal names its number
maxAccounts 50000 how many accounts the in-process ledger holds
store (in memory) where the door keeps what it must remember. Three of its rules are stateful — the replay set, the device→owner pins and the ledger — and a serverless instance keeps none of them between requests: lose the replay set and any message still inside the freshness window can be sent again, lose the pins and the no-re-ownership rule resets to trust-on-first-use at every cold start. Pass an object with seenMessage(messageId, ttl) (return true when the id was new — and make that test-and-set atomic in your backing store, or two concurrent requests can both be told "new"), getAccount/putAccount (a null row deletes) and getDeviceOwner/putDeviceOwner. Each may return a promise. A store missing any of them is refused when the entry is built, because a half-store would switch a security rule off quietly instead of loudly. Rate ceilings stay in memory on purpose: a ceiling that costs a write per request is its own denial of service, and losing a counter fails open for one minute. With no store nothing changes — the default is synchronous and answers exactly as it always has; with one, every message is answered asynchronously, so serve it through handleRequestAsync() or listen()
domains none the domains this entry speaks for — one half of the binding described below
basePath from baseUrl the path this entry answers at, derived rather than set beside it (see One host, several agents)
guest false the guest mount: your site keeps GET / and the entry claims only its card paths and the POST door named by baseUrl (which must then carry that path). It refuses to start on a bare origin, because a guest entry at / is not a guest
wbaVerifiers none a JWKS document ({"keys": […]}) of Ed25519 keys whose holders this entry should recognise on inbound signed requests (Web Bot Auth / RFC 9421 — see Who is knocking). Off when absent. Recognition only adds env.wba_did and a visit count; it never changes a verdict
name, description, version the card's own words. description is the line a person reads in a directory listing, so write it for them

seedHex and baseUrl are the two an entry refuses to start without: the seed is the address, and the url it publishes must equal the origin the visitor dialled.

Putting one on a site you already have

A visiting agent knows only your domain, so the three requests it makes are fixed — it cannot be told to look elsewhere:

# request why
1 GET /.well-known/agent-card.json your card
2 GET /.well-known/agent-card.sig.json the signed envelope — what it actually trusts, because a plain card is a claim anyone could write
3 POST / the signed message; your signed reply comes back in the same response

One round trip. No callback, no webhook, nothing to keep awake.

POST / is exact — a POST anywhere else is 404. But GET / is not taken, so your home page stays exactly as it is. Neither is any POST that carries a query string: an agent posts to your signed card's url, byte-exact, never to a tagged link — so /?wc-ajax= checkouts, /?wc-api= payment webhooks and ?utm_source= links all stay yours, answered exactly as if the door were not there.

The fourth step, and it is not optional

Three routes make the door work. They do not make it findable, and those are separate problems with separate fixes.

A visiting agent knows your domain, so it can guess the card path — but only if something told it there is an agent here at all. Normally that something is the entry's own GET / notice. If your pages are served by a different process than the door — a CDN, a static host, a framework, an edge worker — that notice never renders, and your home page is HTML written for people with nothing machine-readable in it. The address is published in a card nobody was told to fetch.

So the install has a fourth step: put the signpost on every page a visitor might land on, in both spellings. Neither one is a fallback for the other.

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">

We shipped this door and then watched an agent that had never been told about it fail to find it: handed only the domain, it fetched the page, read the copy written for humans, and stopped. The door had been answering signed messages correctly the entire time, at the address on that very page. Two spellings, because the two kinds of client have opposite blind spots — which is which — and shipping one is a coin flip on which kind arrived.

Then verify it from outside, because this is exactly the class of thing that looks installed and is not:

curl -sI https://studio.example/ | grep -i '^link:'     # the header half
curl -s  https://studio.example/ | grep 'rel/agent-entry'  # the tag half

One more thing worth knowing before you decide this is finished: both halves disappear in a fetch that converts the page to markdown, which is a common way an agent reads the web — headers are dropped and so is everything in <head>. There is no tag that survives that, so the only remedy is prose: say in the visible body that agents are answered here, and name the card path in text a reader can act on. Treat that as the third half of the same step.

Check that your own CDN is not refusing your door

This one cost us three days on our own site, and it is the failure you are least likely to look for, because everything you control is correct.

Most sites sit behind something that turns away suspicious traffic, and a lot of that judging is done on the User-Agent — which a client writes about itself, so the honest defaults are the ones that get caught. Ours refused the default agent that Python's standard library sends. Not just on the home page: on the card and on POST / as well. So the door was published, correct, and answering — to nobody who used the stdlib client our own "zero dependencies" posture produces.

The tell is the body of the refusal. A door refuses in JSON and tells you how to qualify. An intermediary refuses in a line of plain text:

error code: 1010

Seventeen bytes, text/plain, no Link, no card path, no JSON — nothing a visitor can act on. If that is what your door hands strangers, the door never saw them.

Probe it the way a stranger arrives, and do not use curl to check. curl sends its own agent string and sails through, so "reproduce it with curl" turns a broken door into evidence that the visitor is at fault. Use a plain standard-library client, from outside your network:

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

The second must come back as JSON. Anything else is your edge, not your entry.

The exemption is easier than it looks, and its shape is the point. You do not have to ask your CDN to decide whether a caller is a bot — you only have to name three things it already knows: host, method, path. Because this door partitions by METHOD, POST / and your card paths are exactly the surface to exempt, and your pages keep whatever protection they have. Write the rule with no user-agent field in it at all; that is not a stylistic preference but the same rule the door lives by, applied one layer out.

Two honest limits. Some protections cannot be exempted by any rule at any price tier — find out which yours is before you promise yourself a carve-out, and if it cannot be scoped, decide deliberately between turning it off and leaving the door unreachable. And never let your CDN tell your responder who it is talking to. Some will happily forward a bot score or a "verified" flag to your origin; if your origin is reachable without going through them — most are — that header is written by whoever dials it directly. Authority here is the signature on the message, and nothing else gets a vote.

Want it to give up even fewer? guest: true (with the door in the url) puts the door on a path of its own and leaves / alone entirely — no notice, no OPTIONS, nothing:

createAgentEntry({ seedHex, name, baseUrl: 'https://studio.example/agent',
                   guest: true, responder });

Your card still answers at /.well-known/agent-card.json, where an agent looks for it; the card names /agent as the address to post to, so a visitor that read it knows where to go. A visitor that arrives at /agent with a browser (or a GET) is told the truth rather than nothing: 405, Allow: POST, OPTIONS — it is a door, not a page. Every other path on your origin is still 404 for every method, so the entry never answers for anything it does not own. This is HTTP's own split doing the work: a cache keys on method + URI, so GET / and POST /agent can never be confused for one another — where a single URI that serves a page or JSON depending on the Accept header is one missing Vary away from handing agent JSON to every human visitor.

One host, several agents

A front desk, support and sales can be three different agents on one hostname — three keys, three DIDs, each contactable directly. Give each one the address it lives at:

createAgentEntry({ seedHex: SUPPORT_SEED, name: 'Support',
                   baseUrl: 'https://studio.example/support', responder });

Every route then hangs off that path, and the bare host is not answered by that entry — it belongs to your site, or to the neighbour. A visitor handed /support reaches support and only support: if the neighbour re-served support's genuine signed card at its own path, the visitor refuses it, because the signature is real but the address it names is not the one that was dialled.

The mount is taken from baseUrl itself, never set beside it — so what your entry answers and what its card claims cannot drift apart.

Which domains your entry speaks for

createAgentEntry({ seedHex, name, baseUrl: 'https://studio.example',
                   domains: ['studio.example'], responder });

That is one half of a proof, and it is deliberately not the whole one. The other half is a credential your domain serves at /.well-known/did-configuration.json, signed by this entry's key. A checker requires both, so either side can end the binding alone — you revoke one agent by deleting one line from a file you already control, and nothing else on the domain is affected.

Give bare host names — studio.example, or studio.example:8443 — with no scheme and no path, and an international name in its xn-- form. Anything else and the entry refuses to start, naming the value and the form it accepts.

  • A subdomain


    Run it on agent.example.com behind your TLS terminator. The existing site is untouched — simplest to reason about.

  • Inside an existing Node app


    Express, Next, Fastify. Hand it the three routes; it needs no server of its own.

  • Behind a reverse proxy


    For a site that is not Node at all — WordPress, Rails, a static build. Route three locations to one small process.

Inside an existing Node app

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

The body must arrive as raw bytes. A JSON body-parser that re-serialises the request has already changed the bytes the signature covers, and the only diagnostic anyone gets is "signature verification failed".

Behind a reverse proxy

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
}

Two things that are on you

Keep the seed. It is your site's identity. Generate it once and store it as a secret — regenerate it and every returning customer becomes a stranger.

Set baseUrl to the URL visitors actually dial. It is what your signed card claims, and a card naming a different origin proves nothing about yours.

What baseUrl may be

Your entry does not copy baseUrl into the card — it canonicalises it, so the string it signs is the one a visitor computes from the URL they dialled. Where the two could differ, it refuses to start, and tells you which rule and what to paste instead. That is deliberate: the alternative is a card that fails on a stranger's machine, where the only diagnostic is "signature verification failed" and nothing at all appears on yours.

Tidied up for you: surrounding spaces, the case of the scheme and host, a default port (:443, :80), a trailing dot on the host, and any trailing slashes. https://studio.example/ and https://Studio.Example:443 both publish as https://studio.example.

Refused, with the fix in the message: a scheme other than http/https, a missing host, user@host, a query string, a # fragment, non-ASCII characters, a stray tab or space, a backslash, . or .. in the path, a broken % escape, and a port outside 1–65535.

Two rules worth knowing before you choose a URL:

  • Paths are case-sensitive. https://studio.example/Alice and .../alice are different sites to a visitor. Pick one spelling and use it in every link, invite and QR code.
  • Write an international domain in its xn-- form. https://xn--eckwd4c7c.example, not the Unicode spelling — and publish your links in that same form.

An Agent Entry runs with nothing beside the file itself: the account ledger, the device→owner pins and the replay guard live in memory, bounded — muretai.com's own door runs exactly this way, so nothing blocks your door on a database. What a store of your own changes is not whether the door works, but what your site can do with who walked through it:

Recommended — keep the ledger in a store of your own: it is your customer list. Every row is keyed by a customer's DID, which is their address: what you need to recognise a returning customer and to contact them again later. In memory that list evaporates on a restart. Kept in the database your site already has — keyed by the account DID the envelope hands you — it is what the features beyond answering stand on: greeting a returning account by its history, following up on yesterday's inquiry, pricing by relationship. Keep the device→owner pins and the replay guard beside it and the security rules — a device is never re-owned, a message is never accepted twice — survive restarts as well.

Just want the numbers? An analytics sink needs no store at all. Nothing inside the entry reads the ledger back, so a fire-and-forget sink records visiting agents with no database anywhere. Use the observer slot for it rather than your responder — watching a visit should not be an edit to the code that decides what to say — and send a salted digest of the account rather than the account itself. Counting visits below is the whole pattern, with the reasoning; the short version is that a DID is not a page view, so what leaves your box is a pseudonym only you can reverse.

An analytics sink cannot be read back during a request: it counts customers, it cannot recognise one. It replaces a log line, not the store above — none of the recommended features stand on it.

Counting visits without giving away who they are

You will want to know how many agents knocked, how many came back, and what they asked. All three are answerable — and the way you answer them decides whether you are counting your visitors or contributing to a profile of them.

Use observer, not your responder. The door calls it once per message with the same envelope, so watching a visit stops being an edit to the code that decides what to say. It cannot affect anything: it runs after the verdict, its return is discarded, a throw is swallowed and a promise is never awaited — a slow or broken watcher cannot delay or change one byte of the signed reply.

The rule that shapes everything else: a DID is not a cookie, and it is not a throwaway either. Nobody imposed it — the visitor read your card before knocking, and an owner who wanted this conversation kept apart would have sent a different agent, because an owner runs several and each is a distinct agent with its own lasting identity. But the one that did knock means to keep the key it used: that is how it gets recognised, introduced and trusted anywhere on the network, so it is closer to a professional's name than to a tracking cookie.

Which is exactly why the raw value should not travel onward. The DID is genuinely durable, and you were given it so that YOU could reach them again. Widen that purpose and nothing happens to you legally, which is the part worth understanding: the owner simply stops sending that agent to you. Silently, at no cost, and you never learn you lost them — not one data point, the whole relationship. Split it instead:

  • What leaves — a salted digest and a few shape facts. Never the DID, never the text.
  • What stays — the relationship (who, how many, first and last seen) in your own store, which is the only place it was ever offered to.

Salt the digest, and treat the salt as a secret. A bare sha256(did) is a stable global pseudonym: anyone else who hashes the same DID gets the same string, so two properties could join their records on it. An HMAC under a secret only you hold makes the pseudonym meaningless anywhere else — that is the whole difference between "we count returning visitors" and "we helped build a profile".

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
};

Four details in that snippet are load-bearing:

  • classify(env.text), never env.text. Send your own bounded label, not what a stranger typed. An attacker-chosen string must never become a dimension in your analytics.
  • .catch(() => {}) and no await. Your door answers in one round trip; nothing on that path may wait on somebody else's uptime. The observer contract already guarantees this, but do not lean on that generosity to be correct.
  • Give it a timeout too (an AbortController at a second or two). A hung connection is not an error, so catch alone never fires.
  • Say at boot whether the sink is on. A sink that is silently off because a secret was never set looks exactly like a sink that is on and receiving nothing — and a dashboard reading zero cannot tell you which.

Say it on the card, because the card is the surface your visitor reads. Whatever you record, the party whose identifier it is arrives as an agent and will never open a privacy page written for people. Your card is fetched before the knock — that is the whole point of publishing terms up front — so it is the one place a visitor can learn what happens to its DID and still decide not to knock. Two or three sentences in the card's description is enough: what you keep, what leaves, and what never does. Ours reads:

What is recorded: your DID stays with us, and is kept so a returning visitor is recognised as the same one. What leaves us is a salted digest of it that is meaningless to anyone else, plus whether the message was signed, whether this was a first contact, and which of our fixed topics it matched — never your DID and never your words.

A disclosure that arrives after the visit is not a disclosure, it is a receipt.

If you do send raw DIDs anyway, that is your decision to make and to disclose — on the card, in the same breath, in plain words. The reason this guide argues the other way is not squeamishness and not compliance: an agent that finds its identity travelling further than it agreed simply stops using a lasting one with you, and a shop full of first-time strangers is the one outcome an Agent Entry exists to prevent.

Pairs with WebMCP

If your page already exposes WebMCP tools, you have one door open: an agent inside a visitor's browser can ask about stock or price while that person is on the page. That is useful, and it is also temporary — close the tab and nothing remains.

An Agent Entry is the second door, and it is the one that keeps something. The two connect: when a tool call reaches the point of actually wanting something — a booking, a quote, a follow-up — the tool returns a small envelope naming your site's DID, and the visitor's agent sends a signed message to your own origin, where your Agent Entry receives it.

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 is the DID your Agent Entry prints at startup — the same one, from the same seed. connect points at your own card, which is what lets a visitor complete the step rather than stopping at "no way in".

Nothing checks this envelope for you. Say so to your visitors.

The tool result is produced by page JavaScript, and on a page that loads any third-party script — analytics, ads, a chat widget, a tag manager, a CDN bundle — that surface is not yours. A script that runs first can register itself as the tool provider and receive your own later registrations; one that runs later can replace them outright. Either way to becomes attacker-rewritable, and a rewritten to sends the visitor's signed message, and the account it opens, to somebody else's door.

There is no muretai-side guard that catches this on your own origin. Do not tell your visitors there is. The check that works is one the visitor performs: fetch /.well-known/agent-card.json from the origin it is standing on and refuse any DID the page named that the card does not confirm. The card is served over TLS by your server; page scripts cannot forge it. That is the whole of the defence, and it lives on the other side.

Two things follow for you. Serve the tool surface from a page with a strict Content-Security-Policy that pins every script host, or accept that the envelope is a hint and not a claim. And treat the absence of an envelope as meaningless: an agent that finds none has learned nothing about whether you have a door.

A search engine makes your site findable. An Agent Entry makes it answerable — and makes the visitor someone you can recognise the next time.

Where to go next

  • The source


    One file, MIT, with the Python reference implementation it is held byte-identical to.

    github.com/muretai/agent-entry

  • The handoff


    How a browser tool call becomes a signed message to your origin.

    Handoff